MokaByte Numero  37 - Gennaio 2000
Java Native Interface
di 
Emmanuele
Sordini
Interazione fra Java e sistemi nativi

 


Il presente è il primo di una serie di articoli che si propongono di approfondire la tematica dell’interazione a basso livello di Java con i vari sistemi operativi, descrivendo le sue funzionalità, con esempi e descrizioni di esperienze reali

Introduzione
Uno dei cavalli di battaglia di Java, dalla sua concezione quattro anni fa, è sempre stata la portabilità delle applicazioni su un numero enorme di piattaforme, in primis Windows NT, Mac e vari dialetti di Unix. Questa promessa, lo si sa, è stata quasi sempre mantenuta, anche se la situazione varia molto a seconda delle singole implementazioni, poiché la Sun stessa supporta direttamente soltanto quella per Solaris e per Win32. Il resto è lasciato alla responsabilità ed alla prontezza delle singole case madri che autonomamente sviluppano la versione di Java ognuna per le proprie piattaforme. 
Tale caratteristica di Java ha però costretto coloro che hanno preso in considerazione tale aspetto, a dotarlo di un insieme di funzionalità a livello lievemente astratto, che quindi rappresentasse una sorta di minimo comune multiplo tra le possibilità dei sistemi più diffusi. Quindi, se da un lato in Java la manipolazione dei file e directory (sia come contenitori di dati, sia come entità sulla memoria di massa) è molto agevole, da un altro non è possibile sviluppare applicazioni che visualizzino finestre su monitor multiplo, come avviene già in Unix e Macintosh. Questo esempio non è casuale: a partire dalla versione di Java2 SDK 1.3 (“Kestrel”), la Sun ha annunciato il supporto di Java per il display multiplo; ciò è stato possibile da quando anche la Microsoft ha incluso il supporto per questa funzionalità nei propri sistemi operativi, a partire da Windows 98 e Windows 2000, e quindi il concetto di minimo comune multiplo poteva comprendere anche tale caratteristica, ormai comune (seppure con qualche differenza) a tutte le piattaforme interessate da Java.
Il rilascio della versione 1.3 del JDK per Solaris e Windows è prevista per la primavera: speriamo che le promesse siano mantenute e che le altre aziende sappiano stare al passo con la sua evoluzione.
 
 

Che cosa è JNI e quando serve?
In generale, Java fornisce allo sviluppatore tutto quello di cui ha bisogno nei vari settori, e in alcuni di questi (programmazione distribuita, applicazioni per Internet, etc.) permette di ottenere ottimi risultati in tempo significativamente inferiore ad altri linguaggi. Tuttavia, esistono casi in cui è indispensabile che un’applicazione Java interagisca a basso livello con il sistema in cui viene eseguita, cioè con la sua parte nativa. Per questo l’ambiente di sviluppo di Java (cioè il JDK in una sua qualunque versione) contiene la libreria JNI, o Java Native Interface. Si tratta di una collezione di librerie che permettono l’interazione e lo scambio di dati tra una qualunque macchina virtuale Java (JVM) e il sistema in cui essa “vive”. Tipicamente, l’interazione con le parti a basso livello del sistema operativo è realizzata con linguaggi la consentano, e tra questi primeggiano sicuramente il C e il C++. 
L’interazione a basso livello con il sistema, come regola, dovrebbe essere evitata, soprattutto nello sviluppo di nuove applicazioni, che possono essere disegnate da zero con certi criteri progettuali che tengano conto di tali problematiche; tuttavia, esistono casi essa può rendersi estremamente  utile. 
Fra i più comuni, possiamo annoverare:

  • Interfacciamento di nuove applicazioni Java con software già esistente e non convertibile per le più disparate ragioni, quali fattibilità, costi, vincoli di carattere tecnico (es. sistemi di acquisizione dati sul campo);
  • Aggiunta ai propri programmi di funzionalità che non sono messe a disposizione da Java (vedi, tuttavia, il caso del monitor multiplo citato nell’introduzione);
  • Necessità di disporre di particolari performance in alcune funzionalità che Java, malgrado i recenti ed importanti progressi (il più eclatante dei quali è HotSpot), non è in grado di fornire.
La principale conseguenza dell’uso di JNI in un’applicazione è che questa, ovviamente, non può più fregiarsi del titolo “100% Pure Java”. Allora viene spontanea una domanda: JNI è sconsigliabile oppure no?
Anche se non abbiamo la pretesa di dare una risposta definitiva, risposta che probabilmente non esiste, cerchiamo di approfondire il problema anche alla luce di quelle che sono le linee guida tracciate dalla Sun. 
Per comprendere meglio la situazione, ci si riferisca alla Figura 1. In essa è rappresentato uno schema “a blocchi” sulla base della visibilità che ciascuna parte del software possiede in relazione al resto del sistema.
 
 
Figura 1 Interazione di applicazioni Java con l'ambiente in cui operano

Per semplicità, abbiamo limitato la visibilità di Java soltanto alle librerie di base (es. package java.lang, java.io, AWT, etc.) lasciando da parte le funzionalità di più alto livello (es. Swing) le quali nella figura dovrebbero collocarsi in un livello intermedio tra gli ultimi più in alto; tutto ciò complicherebbe la spiegazione senza peraltro apportare informazione indispensabile per la comprensione di questi concetti. E’ chiaro che un’applicazione grafica potrebbe usare sia AWT sia Swing, e utilizzare in vario modo le librerie di base.
Nella nostra figura, le frecce bidirezionali piene rappresentano le normali interazioni di visibilità che si realizzano fra i diversi strati di un sistema software; quelle chiare, invece, rappresentano le interazioni che si potrebbero realizzare tra un’applicazione scritta in Java nei confronti delle parti di sistema più a basso livello o di software già esistente in casi come quelli elencati poco sopra. Tali interazioni sono possibili proprio grazie all’utilizzo della libreria JNI.
Quindi, in certi casi di tali interazioni non si può fare a meno, e quindi si mina la portabilità delle applicazioni. Ci sono due possibilità, a seconda dell’obiettivo che ci si prefigga:

  • La nostra nuova applicazione è stata scritta in Java per sfruttare le sue caratteristiche particolari e il suo particolare approccio ai concetti di OOP/OOD, ma deve funzionare solo su una certa piattaforma;
  • La nostra nuova applicazione è stata scritta in Java non solo per quanto detto sopra, ma deve anche funzionare su piattaforme diverse e ovviamente fornire le stesse funzionalità.


Nel primo caso tutto il nostro discorso non pone problemi. Nel secondo, ovviamente, l’unico modo per assicurare la portabilità della nostra applicazione è quello di sviluppare la parte di software (in particolare modo, libreria) che interagisce a basso livello per tutti i sistemi operativi su cui intendiamo eseguire il nostro applicativo. In questo modo, anche se non potremo più affermare di aver scritto un’applicazione solo in Java, di esso potremmo comunque sfruttare tutti i vantaggi, e la portabilità è salva. Sembrerà un paradosso, ma d’altra parte Java stesso è fatto in questa maniera: si pensi ancora una volta ad AWT, che sotto Windows usa il suo SDK, sotto Unix si basa su Motif e Xt, e molti altri esempi potrebbero essere portati. La conclusione è, quindi, che JNI vada usata cum grano salis ma è lecito sfruttarne le potenzialità quando occorre, non senza prima aver tentato di ottenere gli stessi risultati in Java puro.
 

Struttura di JNI: vista d'insieme delle funzionalità
Come si è visto nel paragrafo precedente, JNI è un po’ il tramite tra i due “mondi” del codice nativo e di una qualunque JVM. In Figura 2 è messo in risalto questo aspetto, che in qualche modo riempie le frecce bidirezionali vuote presenti in Figura 1.
 
 
 

Figura 2 Dettaglio del rapporto tra Java e il mondo nativo attraverso JNI

 

La relazione tra i due mondi è in realtà in qualche modo speculare. Infatti, abbiamo detto che è possibile chiamare e interagire con la parte a basso livello implementando in codice nativo metodi di classi Java per le ragioni di cui al §1, ed è su questa parte che si concentrerà essenzialmente la trattazione. In realtà, è possibile anche fare esattamente il cammino inverso: creare una JVM e invocarne i metodi di classi di sistema o anche definite dall’utente.
In Figura 3 è riportata una breve panoramica (estratta dal JNI tutorial) di come è possibile chiamare da Java software scritto in C/C++.
 
 
 

Figura 3 Interazione con il C/C++ dalla parte di Java.

 

I metodi forniti da JNI consentono una grande flessibilità, e di essi riportiamo le caratteristiche più importanti nell’elenco che segue. Per una descrizione dettagliata si rimanda alla JNI Specification. E’ possibile dunque:

  • Avere completo accesso alle classi Java, sia di sistema, sia definite dall’utente, esaminandone gli attributi, chiamandone i metodi, etc;
  • Definire e dichiarare intere classi con tutte le loro caratteristiche, in maniera analoga a quanto è possibile in Java puro con la Reflection API;
  • Creare nuovi oggetti di cui è stata già data completa definizione in Java attraverso uno dei costruttori;
  • Creare array e stringhe di tipi primitivi o di qualsiasi altro oggetto;
  • Creare e sollevare eccezioni.


Java e il C/C++ sono linguaggi, come si sa, che hanno una matrice in comune. Siccome in Java il trattamento dei tipi base (interi, booleani, numeri in virgola mobile, etc.) non è altrettanto flessibile che in C/C++, esiste una corrispondenza standard di tali tipi nel passaggio fra i due ambienti. Ad esempio, gli interi in Java sono soltanto con segno, e di questo bisogna tenere conto (ad es. un numero intero a 32 bit in C/C++ senza segno maggiore di 231-1 (2147483647), se passato ad un programma  Java, dev’essere contenuto in un intero lungo a 64 bit.
In Figura 4 e Figura 5 sono riportate rispettivamente le mappature tra i tipi primitivi e quelli complessi tra Java e C/C++ [v. JNI Spec.]
 
 

Figura 4 Tipi primitivi tra Java e C/C++

 
Figura 5  Equivalente dei tipi complessi di Java nel mondo del codice nativo.

 
 

Nel caso dei tipi rappresentati in Figura 5 se una procedura Java ad esempio ha come parametro una String, nella corrispondente implementazione nativa quel parametro sarà dichiarato di tipo jstring e così via; non scendiamo più oltre nel dettaglio, poiché gli esempi che seguiranno serviranno a chiarire le idee più di qualunque discorso astratto.
Un’altra cosa importante le cosiddette type signatures (“firme”), riportate in Tabella 1 che indicano con un codice convenzionale il tipo di parametri e il tipo di ritorno di una qualunque funzione nella sua controparte Java. Inoltre, queste firme servono alle istruzioni in C/C++ per attribuire alla controparte nativa di un oggetto Java il suo tipo corretto, ed inoltre di esplorare le classi definite in Java in modo analogo a quanto avviene grazie al package java.lang.reflection. In aggiunta a questo, l’implementazione nativa di metodi in Java segue una particolare nomenclatura, che vedremo più avanti.
 
 

TIPO JAVA
“FIRMA”
void
V
boolean
Z
byte
B
char
C
short
S
int
I
long
J
float
F
double
D
class
Lclass;
[type
type[]
Tipo di ritorno di un metodo
(lista argomenti)tipo di ritorno
Tabella 1. "Firme" dei tipi di Java.

Come esempio, un esempio supponiamo di avere due metodi di una classe così definiti:

 
1. void pippo(int x, String s, double y);
2. byte[] topolino(com.foo.bar.Minni x, boolean b)


Le due firme diventano rispettivamente:
 

1. (Iljava/lang/String;D)V
2. (Lcom/foo/bar/Minni;Z)[B


il tipo di uscita di una funzione va in fondo a tutto, mentre le firme dei parametri vanno inserite tra parentesi e in sequenza senza alcun separatore (es. spazio) interposto. 
 
 
 

Un primo semplice esempio
Nelle sezioni precedenti, molto discorsive e forse un po’ tediose, ci siamo limitati a descrivere gli aspetti principali e simbologia, che risultano fondamentali per capire la filosofia che sta dietro a tutto questo discorso, nonché per rendere immediatamente fruibili gli esempi. In questo capitolo presentiamo, passo per passo, la nostra prima implementazione di un semplicissimo metodo Java in codice nativo, in modo tale che possa essere ripetuto da chiunque volesse.
Siccome in questo esempio e in tutti gli altri che verranno presentati si è scelto di usare il C++ come codice per l’implementazione nativa, si ritiene per scontata da parte del lettore almeno dei rudimenti essenziali. Gli strumenti indispensabili per mettere in pratica gli esempi sono:

  •  Un compilatore C++, meglio se come ambiente integrato. Sotto PC andranno bene il Visual C++ dalla versione 4.0 in poi; sotto Unix ci si può accontentare di un editor di testi (Xemacs, Nedit, o se non c’è altro, il caro VI) e di un compilatore a riga di comando (es. g++, cxx, e similari) e di un Makefile. Si richiede la capacità di generare, sotto ambedue le piattaforme, librerie dinamiche (DLL in Windows e .so in Unix);
  • Una versione del JDK completa e installata correttamente (classpath configurato ed eseguibili presenti nel percorso di sistema). Per semplicità, ci si riferirà alla versione 1.1.x, con cui forse la maggior parte ha familiarità, ma dovrebbe funzionare tutto anche con Java 2;
  • Una qualunque shell sotto Unix e il prompt di MS-DOS (o qualche shell più evoluta come 4DOS oppure 4NT).

 

Il programma in Java
Ovviamente la prima cosa di cui disporre è un piccolo programmino in Java, che scriveremo e salveremo con un editor. Sia Hello.java il seguente:
 

package it.provaJNI;

public class Hello{
  public native void sayHello(String person);
  public static void main(String[] args){
    Hello hello = new Hello();
    if (args.length > 1)
      for (int i=0; i<=args.length-1; i++)
        hello.sayHello(args[i]);
  }
  static{
    System.loadLibrary(“it_provaJNI_Hello”);
  }
}


Da notare che il metodo sayHello(...) è soltanto definito, ma non è composto da una singola riga di codice in Java. E’ però preceduto dalla parola chiave native che ne specifica appunto le caratteristiche. Altra cosa da notare è il blocco static, che prima dell’esecuzione del programma carica la libreria dinamica (it_provaJNI_Hello.dll oppure libit_provaJNI_Hello.so) che fra poco scriveremo. Compiliamolo quindi con javac -g it\provaJNI\Hello.java. Si presti attenzione alle directory, poiché Java su questo è molto fiscale: se il package è quello del sorgente, bisogna mettere il sorgente in un albero del tipo <radice>\it\provaJNI\, ove la radice di riferimento può essere scelta a piacere.
 
 
 

Lo “scheletro” dell’implementazione in C++
Dopo aver scritto e compilato il programma in Java, non si può ancora lanciarlo. Se infatti lo facessimo, l’interprete si lamenterebbe dando un’eccezione di UnsatisfiedLinkException, non avendo trovato la libreria dinamica che si aspetterebbe.
Ora dobbiamo creare l’implementazione del nostro metodo in C++. Per fare questo, dobbiamo in qualche modo generare il prototipo della sua controparte in C++, seguendo la nomenclatura descritta al paragrafo 3. Siccome sarebbe impensabile ricavarla a mano, ci viene in aiuto un tool di Java, detto javah. Eseguiamo dunque il comando:

javah -jni it.provaJNI.Hello
che produce il seguente file di intestazione, dal nome it_provaJNI_Hello.h:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class it_provaJNI_Hello */

#ifndef _Included_it_provaJNI_Hello
#define _Included_it_provaJNI_Hello
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     it_provaJNI_Hello
 * Method:    sayHello
 * Signature: (Ljava/lang/String;)V
 */
JNIEXPORT void JNICALL Java_it_provaJNI_Hello_sayHello
  (JNIEnv *, jobject, jstring);

#ifdef __cplusplus
}
#endif
#endif


Questo file è molto interessante. Osserviamo i seguenti fatti:

  • Un’ultima regola di nomenclatura che avevamo lasciato da parte ci mostra la nomenclatura con cui un metodo nativo è mappato in java: 

  • <Tipo di ritorno> Java_<nome package>_<Classe>_<Metodo>

    in pratica, al prefisso Java è attaccato il nome completamente esplicitato, solo che al posto dei punti vengono messi dei “_”.

  • JNIEXPORT e JNICALL sono due macro dipendenti dal sistema operativo definite in jni.h; non ci soffermiamo su di loro. Il primo parametro è il puntatore all’”ambiente” che ci fornirà tutti i necessari metodi per manipolazione di oggetti (elencati al §3), il primo è il puntatore alla classe stessa (cioè “this”), e l’ultimo parametro è la stringa “mappata” (a java.lang.String corrisponde jstring) secondo lo schema di Figura 5. Quindi, la lista dei parametri sarà sempre del tipo (puntatore all’ambiente, puntatore this, <lista dei parametri del metodo java con i tipi mappati>) .
  • Ultima osservazione, la firma (“Signature”) del metodo scritta nei commenti segue esattamente le convenzioni esposte in Tabella 1.

 

L’implementazione del sorgente in C++, compilazione ed esecuzione
Dopo aver generato il file di intestazione, procediamo all’implementazione della parte in C++. Si noti che il file .h può essere anche rinominato a nostro piacimento (lo stesso invece, non vale ovviamente per le procedure al suo interno). Il consiglio è, tuttavia, di mantenere e attribuirlo anche al file .cpp . Nel nostro caso avremo quindi il sorgente nel file it_provaJNI_Hello.cpp il cui sorgente è come segue:
 

#include <iostream.h>
#include “it_provaJNI_Hello.h”

// Native implementation for the method sayHello
// of the class “Hello”
// Emmanuele Sordini 1999

JNIEXPORT void JNICALL Java_it_provaJNI_Hello_sayHello
  (JNIEnv* env, jobject this, jstring person)
{
  const char* helloString = 
    env->GetStringUTFChars(person, NULL);

  cout << “Hello, “<< helloString << “!\n”;

  env->ReleaseStringUTFChars(helloString);
}


Sebbene semplice, il corpo del sorgente è interessante. Intanto, il nome (ma non il tipo!) attribuito ai parametri della funzione è assolutamente arbitrario, e non deve per forza coincidere con il sorgente in Java; tuttavia, si tratta di una buona prassi, esattamente come il nome stesso del file in C++.
Volendo stampare una stringa sulla console con le procedure standard del C++ oppure anche quelle del C (printf(…)), non è possibile passare loro direttamente una stringa così come è stata ricevuta dalla JVM, poiché il piantamento del programma è garantito. Infatti, la stringa in Java è in formato Unicode a 16 bit, mentre noi abbiamo bisogno di uno strumento per trasformarla in un array di caratteri utilizzabili con in C++. Per fare ciò, si usa la procedura GetStringUTFChars(…) che esegue tale trasformazione in una stringa di char (terminata dal carattere nullo) in formato UTF (Unicode Text Format) a 8 bit, che praticamente coincide con il familiare ASCII. Il secondo parametro è un puntatore ad un flag di tipo logico il quale, se non impostato a NULL ma associato ad una variabile, riporta se durante la creazione della stringa ne sia stata effettuata o meno una copia.
Il metodo nativo è chiamato dalla JVM, il cui garbage collector (GC) non è abilitato a deallocare la stringa di caratteri che abbiamo creato. La sua liberazione è quindi nostro compito tramite la procedura ReleaseStringUTFChars(…). Non compiere quest’operazione in caso creassimo un oggetto Java all’interno del nostro codice nativo e ne passassimo la reference come parametro di uscita dal metodo. In questo modo, il suo controllo ritornerebbe pienamente sotto la JVM e noi non dovremmo liberarlo.
Ora creare la libreria dinamica è molto semplice. Prendiamo come esempio il compilatore in linea di comando del MS VC++ 6.0 cl.exe (in questo specifico caso nella versione 6.0). Supponendo di avere tutti i percorsi e le variabili di ambiente impostati in modo correttamo (per il MSVC eseguendo il file batch vcvars32.bat), ci posizioniamo nella directory ove si trova il sorgente in C++ e il suo file di intestazione e il comando:
 

cl it_provaJNI_Hello.cpp -I\Java\jdk118\include\win32 
-I\Java\jdk118\include /LD


sostituendo i percorsi con quelli caratteristici della macchina su cui si stava lavorando; il flag /LD indica al linker di generare una libreria dinamica. Se tutto è andato bene, otterremo numerosi file, tra cui anche it_provaJNI_Hello.dll.
Ora è sufficiente copiarla nella directory ove lanciamo l’interprete Java, oppure fare in modo che essa si trovi nel PATH di sistema (variabile di ambiente!).
Per fare una prova, battiamo

 
java it.provaJNI.Hello Paperino Pippo Topolino


e se tutto è stato eseguito correttamente, si ottiene
 

Hello, Paperino!
Hello, Pippo!
Hello, Topolino!


Un avvertimento: l’errore più frequente (come già accennato prima) in cui si possa incorrere in fase di esecuzione è un’eccezione del tipo
 

java.lang.UnsatisfiedLinkException: no it_provaJNI_Hello in java.library.path


(seguita dalla stampa dello stack delle chiamate), causata dall’inteprete Java che non riesce a trovare la DLL. Verificarne la giusta collocazione secondo quanto detto sopra e riprovare.
 
 
 

Conclusione e riferimenti
In questa prima puntata sono stati esaminati gli aspetti e le funzionalità principali di più importanti di JNI, con particolare riguardo alla sua opportunità di utilizzo in diverse situazioni e nell’integrazione di nuove applicazioni in Java con software esistente. Si spera che il primo e semplice esempio presentato al §4 sia sufficiente per introdurre il lettore all’uso di JNI; seguirà prossimamente la discussione di altri casi, alcuni reali frutto di esperienze lavorative, altri costruiti su misura per scendere più in dettaglio negli aspetti di JNI.
La bibliografia e i riferimenti riguardo a JNI non è, purtroppo, così vasta come quella disponibile sulle JFC e su altri argomenti: come al solito, Internet recita la parte del leone in quanto a numero di fonti, anche se spesso è necessario discernere la qualità. Tra i riferimenti ritenuti più importanti riportiamo:

[1] Javasoft, The JNI Interface Specification: documento ufficiale disponibile dal sito di Javasoft http://www.javasoft.com.
[2] Javasoft, The Java Tutorial (JNI Trail), disponibile in linea o scaricabile dal sito di Javasoft http://www.javasoft.com.
[3] Sheng Liang, The JavaTM Native Interface: Programmer's Guide and Specification, ed.Addison Wesley Longman, Inc. June 1999.
 


MokaByte rivista web su Java
MokaByte ricerca nuovi collaboratori
Chi volesse mettersi in contatto con noi può farlo scrivendo a mokainfo@mokabyte.it