MokaByte Numero 25  -  Dicembre 98
 
Implementazione di 
codice nativo Java
di 
Michele Crudele 
Come interfacciare l'astratto Java con qualcosa di più concreto



Con questo articolo intendiamo descrivere il processo di integrazione di codice nativo C/C++ in una applicazione Java, avvalendoci di un esempio concreto per illustrare le principali caratteristiche della JNI.


Java è un linguaggio sostanzialmente nuovo, la cui flessibilità e maggiore semplicità rispetto al C++ derivano dal suo paradigma object oriented, dalla sua natura interpretata e dalla portabilità.   Al contrario del C++, che è stato progettato come una evoluzione del C mantenendo la totale compatibilità  all’indietro, Java parte da zero eliminando tutti i residui di linguaggio procedurale, semplificando la gestione della  memoria da parte del programmatore, risolvendo in modo elegante il problema dell’ereditarietà multipla; in altre   parole eliminando tutti i meccanismi più ostici del C++ e purificandolo dal punto di vista object oriented.   Questo approccio, se da un lato offre un linguaggio più semplice e congruente del C++, dall’altro scopre il fianco   ad alcune critiche. Tra le più significative annotiamo ([2]):

  • Compatibilità hardware: data la natura portabile di Java, il linguaggio deve implementare funzionalità indipendenti dalla piattaforma. Quindi, particolari classi di oggetti dipendenti da una specifica piattaforma hardware non sono supportate direttamente dal linguaggio. Un esempio banale può essere quello del drive. Il concetto di drive è presente sui PC ma non sulle piattaforme UNIX. Se stiamo sviluppando codice per PC ed abbiamo bisogno di manipolare i drive, non abbiamo classi di oggetti che li gestiscono;
  • Legacy code: con legacy code si intende codice già sviluppato in un altro linguaggio e funzionante. Cosa facciamo, lo  riscriviamo in Java? L’ideale sarebbe poterlo riutilizzare, al limite con qualche piccolo sforzo di adattamento;
  • Velocità di esecuzione: la natura interpretata di Java pregiudica inevitabilmente la velocità di esecuzione del programma. Questo può  essere un problema rilevante specialmente nella manipolazione di oggetti grafici, anche se l’evoluzione  tecnologica lo renderà sempre meno evidente se contrapposto alla portabilità del codice che si scrive.
La risposta a queste critiche risiede nella possibilità di utilizzare all’interno di applicazioni Java codice scritto usando altri linguaggi di programmazione. Interfacciando codice nativo si possono pertanto implementare funzionalità specifiche di una piattaforma, si possono delegare compiti dispendiosi in termini di utilizzo di processore, si può riutilizzare codice già scritto il cui porting in Java sarebbe dispendioso, se non improponibile. Il meccanismo di integrazione di codice nativo è fondamentale per incoraggiare la transizione verso la tecnologia Java. Se Java non gettasse un ponte verso il mondo esistente, gli investimenti per passare alla nuova tecnologia da parte delle aziende che producono software sarebbero molto più elevati, probabilmente tali da oscurarne i vantaggi per un certo periodo di tempo. L’importanza di poter usare librerie software ben testate e funzionanti è fondamentale se si pensa che ancor oggi sopravvivono librerie matematiche scritte in FORTRAN. 
Già con il JDK 1.0 Java permette di caricare codice nativo all’interno di una applicazione, ma con il JDK 1.1 è stata formalizzata una interfaccia chiamata Java Native Interface (JNI), che mette a disposizione del programmatore un insieme di funzioni standard attraverso le quali è possibile effettuare, da codice nativo, svariate operazioni nell’ambiente Java. Si arriva così ad accedere oggetti Java, crearli, distruggerli, invocare metodi Java, gestire le eccezioni, sincronizzare thread, caricare la Virtual Machine in una applicazione nativa.
 

Step by step

In questo paragrafo cercheremo di suddividere il processo di integrazione di codice nativo in una serie di passi logici che saranno descritti nei prossimi paragrafi modellandoli sul codice di esempio allegato all’articolo. Cominciamo col porci una domanda: come facciamo ad invocare una funzione procedurale da un linguaggio object oriented come Java? L’elemento del linguaggio più vicino al concetto di funzione sembrerebbe essere il metodo di una classe. Ed è proprio qualificando un metodo come nativo che Java si apre al mondo esterno. In pratica è possibile indicare a Java che un certo metodo è implementato altrove, in C, o comunque in un modo dipendente dalla piattaforma ([1]), qualificandolo con il modifier native nella sua dichiarazione. L’implementazione deve essere contenuta in una DLL che esporta la funzione C che lo implementa. Sorge spontanea un’altra domanda: quale è il prototipo di questa funzione? 
Esiste un tool, javah (distribuito con il JDK), che genera un header file che contiene i prototipi di tutte le funzioni C che implementano i metodi nativi dichiarati in una certa classe. Questo file non deve essere modificato, ma solo incluso nel file C/C++ in cui si andranno a sviluppare le funzioni native. A questo punto è sufficiente compilare la DLL ed il gioco è fatto, non resta che caricarla nel codice Java. 
Riassumendo, tutto il processo può essere suddiviso nelle fasi:

  • Dichiarazione dei metodi nativi in una classe Java;
  • Generazione dell’header file che contiene i prototipi delle funzioni C che implementano i metodi nativi;
  • Implementazione del codice nativo e compilazione della DLL;
  • Caricamento della DLL nel codice Java.
La classe Java incapsula le funzionalità native che si vogliono importare nella applicazione, quindi dovrebbe essere progettata in modo aderente alla filosofia object oriented piuttosto che procedurale. 
Attenzione però, bisognerebbe evitare ad esempio, presi dalla frenesia, di progettare una sola classe contenitore per tutte le funzioni native che si vogliono esportare. Si definisca più di una classe Java se necessario. È tutto tempo speso a vantaggio della riusabilità del codice che si sta scrivendo.

Dichiarazione dei metodi nativi

Vogliamo sviluppare una classe Drive che incapsula alcune operazioni di uso comune sui drive di un PC. 
A parte i banali metodi di get e set che si commentano da soli, implementeremo due metodi fondamentali per la gestione dei drive: list() e refresh(), ambedue nativi. list() restituisce un array dei Drive attualmente definiti sul PC usando una maschera di bit per filtrarli in funzione del tipo. A questo proposito sono state definite delle costanti (FIXED, REMOTE, CDROM, REMOVABLE), una per ogni tipo di drive. Ad esempio, per ottenere i drive di rete e quelli fissi, basta invocare il metodo
 

Drive.list (Drive.FIXED | Drive.REMOTE);
Il metodo refresh() aggiorna le informazioni dell’oggetto su cui viene invocato; questo perché, una volta costruito un oggetto Drive, alcune informazioni dinamiche potrebbero cambiare (ad esempio lo spazio libero, oppure il drive potrebbe non esistere perché disconnesso, ecc.). Si usa quindi refresh() per ottenere le informazioni più "fresche" del drive. 
La dichiarazione dei metodi nativi è molto semplice. Basta scrivere il prototipo del metodo, qualificarlo con il modificatore native, e terminare la dichiarazione con un ‘;’, senza corpo del metodo. 
Più formalmente:
 

public valoreDiRitorno native nomeMetodo (listaArgomenti);

Generazione dell’header file

Dichiarati i metodi nativi, dobbiamo ora scrivere i prototipi delle funzioni C che li implementano. Fortunatamente il JDK distribuisce un tool (javah.exe) che, dato in input il file Java compilato, genera l’header file C contenente i prototipi delle funzioni native che implementano i metodi dichiarati nativi nella classe Java. Creare questo file è dunque molto semplice; basta compilare il file Java e lanciare il comando javah con l’opzione -jni. Nel nostro caso:

 

javac Drive.java

javah -jni Drive

Con l’opzione -jni si chiede un prototipo di funzione "stile JNI", il che significa che si vuole usare la JNI per implementare il codice nativo. In effetti si può anche fare a meno di usare le interfacce fornite dalla JNI, ma le limitazioni sono notevoli ed è molto difficile trovare della documentazione adeguata (le uniche fonti di informazione che conosciamo sono i file .h sotto la directory include del JDK e in [2]). 
Il file .h che viene generato da javah.exe ha lo stesso nome della classe Java. Editiamo dunque Drive.h e diamogli un’occhiata. 
Il suo contenuto informativo si riduce sostanzialmente ai prototipi delle funzioni native C:
 

JNIEXPORT jobjectArray JNICALL Java_Drive_list (JNIEnv*, jclass, jint);
 
 

JNIEXPORT void JNICALL Java_Drive_refresh (JNIEnv*, jobject);

Analizziamoli: JNIEXPORT e JNICALL assicurano la compilazione del codice per Win32 che richiede keyword speciali per le funzioni esportate da una DLL. Sono delle macro dipendenti dalla piattaforma.
Il nome della funzione nativa è formato da:
  • Java_
  • Il nome completo della classe Java (compreso il package). In questo caso solo Drive perché la classe fa parte del package di default
  • Un carattere ‘_’ come separatore
  • Il nome del metodo
  • Ciascuna funzione nativa ha come primo argomento un puntatore a JNIEnv. JNIEnv* rappresenta l’interfaccia JNI; esso è organizzato come una tabella di funzioni che possono essere invocate all’interno del codice nativo per accedere l’ambiente Java;
Il secondo argomento, come si può facilmente intuire, è:
  • La classe Java di riferimento, se la funzione implementa un metodo nativo dichiarato come static
  • L’oggetto sul quale viene invocato il metodo, se la funzione implementa un metodo di istanza.
I rimanenti argomenti rappresentano quelli dichiarati nel metodo nativo Java.
Premesso che è possibile sviluppare il codice nativo sia in C che in C++, tutto ciò che segue è riferito ad una implementazione C++. Consigliamo di seguire questa strada perché la JNI fornisce una interfaccia C++ più chiara e type safety. Si osservi ad esempio la chiamata in C
jclass cls = (*env)->FindClass (env, "java/lang/String");

L’equivalente C++ è:

jclass cls = env->FindClass ("java/lang/String");

ovviamente più congruente e facile da ricordare. A questo punto possiamo iniziare ad implementare le funzioni native. Basta creare un file, in questo caso DriveImp.cxx, includere Drive.h, copiare i prototipi delle funzioni native, e svilupparle. Comunque dobbiamo prima imparare due cose fondamentali: i tipi di dato nativi che corrispondono a quelli Java, ed i metodi per accederli; in breve, abbiamo bisogno di una panoramica introduttiva alla JNI. Di questo ci occuperemo nei prossimi 3 paragrafi.

Tipi di dato nativi in Java

Rileggiamo i prototipi delle funzioni native: jobjectArray, jclass, jint, jobject. Ma cosa sono? Sono i tipi di dato nativi che corrispondono a quelli Java, e li sostituiscono nell’ambiente nativo. 
La principale distinzione che bisogna fare è fra i tipi primitivi e gli oggetti. I tipi primitivi possono essere acceduti direttamente all’interno del codice nativo, al contrario degli oggetti che possono essere manipolati esclusivamente attraverso le interfacce della JNI. 
Gli oggetti Java hanno tutti come corrispondente nell’ambiente nativo il tipo jobject. Ciò significa ad esempio che se dichiariamo un metodo nativo

 

public native void stampaMioOggetto(MiaClasse oggetto);

la corrispondente funzione che lo implementa prenderà in input un jobject

 

JNIEXPORT void JNICALL Java_MiaClasse_stampaMioOggetto (JNIEnv*, jobject, jobject oggetto);

A parte i primi due argomenti di cui abbiamo spiegato il significato, il terzo è un jobject che corrisponde ad un oggetto della classe Java MiaClasse. La JNI definisce un insieme di tipi concettualmente sotto tipi di jobject, allo scopo di agevolare il compito del programmatore e ridurre la possibilità di errore. Prima di concludere con l’argomento è opportuno soffermarci a descrivere molto rapidamente due tipi di dato ricorrenti, jstring e jarray.
jstring non è una stringa C (char* per intenderci), quindi manipolarla come tale è scorretto. Tutte le operazioni su jstring devono essere compiute invocando opportune funzioni di JNIEnv*. È improbabile che nel codice nativo si abbia la necessità di gestire direttamente oggetti jstring; è più agevole convertire una jstring in una stringa C, elaborarla, ed eventualmente riconvertire il risultato in jstring se la funzione nativa restituisce al chiamante un tale oggetto.
Quindi, le interfacce più importanti da ricordare per quanto riguarda l’oggetto jstring sono GetStringUTFChars() per la conversione da jstring a stringa C, e NewStringUTF() per la conversione da char* a jstring. Non dimentichiamo di liberare la memoria riservata per il char* restituito dalla GetStringUTFChars() chiamando la ReleaseStringUTFChars(). jarray a sua volta non è un array C e come tale non può essere acceduto se non attraverso appropriate funzioni di JNIEnv*. Questo codice ad esempio è scorretto
 

jintArray arr;

jint sum=0;

jsize size = env->GetArrayLength(arr);

...

for (int i=0; i<size; i++)

  sum += arr[i]; // è sbagliato

Ancora una volta la distinzione tra tipi primitivi di dato ed oggetti è d’obbligo. Gli array di tipi primitivi possono essere trasformati in array C invocando i metodi Get<tipo>ArrayElements() che ritornano un <tipo>*, al contrario degli altri per i quali invece è possibile soltanto l’accesso al singolo elemento attraverso i metodi Get/SetObjectArrayElement().
Alla luce di ciò, questo è il codice corretto per accedere un array di tipi primitivi da codice nativo:

jint* body = env->GetIntArrayElements (arr, 0);

...

for (int i=0; i<10; i++)

  sum += body[i]; // è corretto

...

env->ReleaseIntArrayElements(arr, body, 0);

Si noti come per ogni Get<tipo>ArrayElements() sia necessario chiamare una Release<tipo>ArrayElements() per rilasciare la memoria allocata per il valore di ritorno. 
Questo perché tale memoria non è automaticamente gestita dal garbage collector di Java. Onde evitare di copiare un intero array, qualora questo sia molto grande, si possono accedere regioni parziali dell’array stesso usando i metodi Get/Set<tipo>ArrayRegion(). Queste informazioni dovrebbero essere sufficienti per quanto riguarda l’argomento array.

Come invocare i metodi Java

Vediamo adesso come si invocano i metodi Java da codice nativo. I dati del problema sono: un jobject drive, il nome della sua classe Java Drive, il nome del metodo da chiamare setName.  La prima cosa da fare è creare un oggetto che rappresenta Drive nell’ambiente nativo, un jclass dato dalla funzione:

 

jclass clazz = env->GetObjectClass (drive);

Il passo successivo consiste nell’ottenere l’identificativo del metodo, e questo può essere fatto usando una delle funzioni JNI:
 

jmethodID GetMethodID (jclass clazz, const char *name, const char *signature);

jmethodID GetStaticMethodID (jclass clazz, const char *name, const char *signature);

a seconda che il metodo sia di istanza o di classe (static). jclass lo conosciamo, il nome del metodo anche, manca il terzo argomento signature. Cerchiamo di capire cos’è. Un metodo non può essere identificato univocamente dal suo nome, perché potrebbe essercene un altro con lo stesso nome ma differenti argomenti. Signature rappresenta proprio la ‘firma’ del metodo, ossia una stringa che identifica il tipo del valore di ritorno ed il numero e tipo degli argomenti; quindi, unitamente al nome, un qualcosa che identifica univocamente il metodo stesso.  Anche qui fortunatamente, il JDK ci viene in aiuto fornendo il tool javap.exe che è in grado di generare la signature dei metodi di una classe. 
Facciamolo:

 

javap -s -p Drive > Drive.sig

L’opzione -s informa javap che vogliamo le signature, l’opzione -p include anche i membri privati della classe. Siccome l’output è a video, redirezioniamolo per comodità nel file Drive.sig. Se cerchiamo setName in questo file vediamo:
 

public setName (Ljava/lang/String;)V

La signature di setName è dunque "(Ljava/lang/String;)V" ([3]). Ricapitolando, questa è l’istruzione completa per ottenere l’identificativo di setName:
 

jmethodID mID = GetMethodID (clazz, "setName", "(Ljava/lang/String;)V");

Tutte queste funzioni ritornano un jmethodID 0 in caso di errore. Il passo finale consiste nella chiamata del metodo usando una delle funzioni JNI
 

jobject CallObjectMethod (jobject obj, jmethodID methodID, ...);

jobject CallStaticObjectMethod (jclass clazz, 

jmethodID methodID, ...);

Come si vede, sono funzioni con un numero variabile di argomenti. I primi due sono facili da intuire, gli altri verranno passati al metodo Java da chiamare e devono rispettarne la signature. Concludiamo ricapitolando i passi necessari per invocare un metodo Java:

  • Ricavare l’oggetto jclass che rappresenta la classe cui appartiene il metodo;
  • Ricavare la signature del metodo;
  • Ricavare il jobjectID del metodo;
  • Invocare il metodo.
Come accedere i membri Java

La sequenza di operazioni da eseguire per accedere i membri Java è identica a quella descritta per l’invocazione dei metodi: bisogna prima ottenere un oggetto che rappresenta la classe nell’ambiente nativo, poi generare la signature del membro, quindi chiamare una delle funzioni JNI:

jfieldID GetFieldID (jclass clazz, const char *name, const char *sig);

jfieldID GetStaticFieldID (jclass clazz,const char *name, const char *sig);

per ottenere l’identificativo del membro. A questo punto si possono utilizzare:

 

jobject GetObjectField (jobject obj, jfieldID fieldID);

jobject GetStaticObjectField (jobject obj, jfieldID fieldID);

per ottenere il valore del membro, oppure
 

void SetObjectField (jclass clazz, jfieldID fieldID, jobject value);

void SetStaticObjectField (jclass clazz, jfieldID fieldID, jobject value);
 

per modificarne il valore. JNI definisce funzioni più specifiche per i tipi primitivi. Ad esempio, se il membro è di tipo intero useremo Get/SetIntField, e via discorrendo per gli altri tipi primitivi.

Implementazione del codice nativo

Finalmente conclusa la panoramica JNI, possiamo affrontare la fase implementativa del processo di integrazione del codice nativo.
list() ritorna un jobjectArray che rappresenta un array di Drive in Java filtrati in funzione del parametro di input mask. mask è un intero in cui sono attivati i bit relativi alle costanti Java FIXED, REMOTE, CDROM, REMOVABLE a seconda dei drive richiesti dall’operazione di list. Dovendo verificare quali di questi bit sono attivi, occorrono i valori di queste costanti. Seguiamo pertanto i passi descritti per accedere i membri di una classe Java; costruiamo gli ID dei membri, ed usiamo GetStaticIntField() (il jclass rappresentativo di Drive già lo abbiamo come parametro di input in quanto list è un metodo static). A questo punto c’è la quantità di codice Windows sufficiente per ottenere il numero e la lettera dei drive definiti sulla macchina. Usiamo questo numero per allocare un jobjectArray che dovrà contenere i Drive (NewObjectArray()). 
A questo punto c’è un’istruzione che è utile commentare:

 

env->GetMethodID (cDrive, "<init>", "Ljava/lang/String;)V");

Cos’è la stringa "<init>"? Ebbene, <init> si usa per indicare un costruttore. Il nome di un costruttore nelle chiamate a GetMethodID è sempre <init> indipendentemente dal nome della classe Java.
Ottenuti l’ID del costruttore e l’ID del metodo setName, si effettua un loop sulla lista dei drive filtrati e per ognuno di essi si crea un jobject, si invoca setName, ed infine si aggiunge all’array di output. È interessante notare come si crea un nuovo jobject:
 

jobject drive = env->NewObject (cDrive, mDrive);

La funzione JNI da chiamare è NewObject(), a cui si passa l’identificativo del costruttore da invocare durante l’inizializzazione dell’oggetto. Si noti come il metodo setName() in Drive.java chiami refresh() per inizializzare gli altri membri dell’oggetto Java, per cui non abbiamo bisogno di fare altro per inizializzare l’oggetto appena creato.
Il metodo refresh() ha bisogno di aggiornare i membri dell’oggetto su cui è invocato; pertanto otteniamo gli ID di tali membri e delle costanti di tipo. A questo punto abbiamo bisogno di una stringa C che rappresenta il nome del drive per poter usare le API Windows che danno le dovute informazioni sul drive. 
Quindi invochiamo una GetObjectField() per avere la jstring che rappresenta il nome del drive, e poi convertiamo tale oggetto in un char* con la GetStringUTFChars(). A questo punto, ottenute le informazioni di tipo e di spazio sul drive, invochiamo le opportune Set<tipo>Field() per aggiornare l’oggetto Drive. 
Possiamo concludere questo paragrafo con due parole sugli oggetti restituiti dalle funzioni JNI. Tutti questi oggetti, nonché i parametri di input alle funzioni native, sono dei riferimenti locali. Ciò significa che essi diventano invalidi quando termina l’esecuzione della funzione nativa, ed il garbage collector provvederà a liberare la memoria ad essi associata. Ciò significa anche che un ID di un metodo, per esempio, può essere diverso tra una invocazione e l’altra. Non si tenti pertanto di dichiararlo come static ed aspettarsi che dopo la prima invocazione della funzione nativa esso continui ad avere un valore valido. 
Per fare ciò bisogna usare un riferimento globale usando la funzione JNI NewGlobalRef(). Il riferimento ottenuto in tal modo rimane valido finché non viene esplicitamente rilasciato con la DeleteGlobalRef().

Caricamento della DLL nel codice Java

Compilata la DLL che esporta le funzioni native, bisogna stabilire un collegamento con la classe Java. Da qualche parte nel codice Java dovrà esserci una istruzione che dice dove andare a cercare i metodi dichiarati nativi. Ebbene, la classe java.lang.System definisce il metodo loadLibrary() che può essere invocato staticamente (cioè all’atto dell’inizializzazione della classe) nella classe Drive.

 

static {

  System.load ("Drives");

}

Il runtime di Java caricherà Drives.dll in fase di inizializzazione. In caso di problemi il linker di Java lancerà l’eccezione UnsatisfiedLinkException.

Conclusioni

In questo articolo abbiamo introdotto il concetto di codice nativo in Java, spiegando quando è opportuno ricorrervi. Abbiamo poi affrontato il processo di integrazione di tale codice in una applicazione Java avvalendoci di un esempio concreto per essere il più possibile aderenti alla realtà. Gran parte dell’articolo è stata dedicata allo studio degli strumenti messi a disposizione dalla JNI per una buona interazione con l’ambiente Java. Ed è infatti questo lo scopo principale che ci siamo prefissi in quanto tutto il processo può essere suddiviso in pochi e meccanici passi che sono totalmente indipendenti dalla piattaforma di sviluppo, a parte la fase implementativa. Si ricordi sempre di prestare particolare attenzione alla progettazione delle classi Java che incapsulano le funzionalità native. Questo è l’unico passo progettuale, e non è bene partire con il piede sbagliato. Il resto è pura tecnica di programmazione. Per motivi di spazio, ed anche per non essere noiosi, abbiamo descritto soltanto i concetti e gli strumenti indispensabili della JNI. Per approfondire l’argomento si può consultare [3]. Scopriranno che la JNI offre altri strumenti che potrebbero rivelarsi utili quali ad esempio la gestione delle eccezioni (è possibile lanciare ed intercettare eccezioni da codice nativo), la sincronizzazione di thread di codice nativo, il caricamento della Java Virtual Machine nel codice nativo.

Bibliografia

[1] David Flanagan, "Java in a nutshell 2nd Edition", O’REILLY, 1997.
[2] Tim Ritchey, "Programming with Java! Beta 2.0", New Riders, 1995.
[3] http://hensel.ethz.ch/java/tutorial/native1.1/implementing/index.html. 
 
 
 

 


 
 


MokaByte Web  1998 - www.mokabyte.it 
MokaByte ricerca nuovi collaboratori. Chi volesse mettersi in contatto con noi può farlo scrivendo a mokainfo@mokabyte.it