Guida a JNI (Java Native Interface)

1. Introduzione

Come sappiamo, uno dei principali punti di forza di Java è la sua portabilità, il che significa che una volta che scriviamo e compiliamo il codice, il risultato di questo processo è bytecode indipendente dalla piattaforma.

In poche parole, questo può essere eseguito su qualsiasi macchina o dispositivo in grado di eseguire una Java Virtual Machine e funzionerà senza problemi come ci si potrebbe aspettare.

Tuttavia, a volte abbiamo effettivamente bisogno di utilizzare codice compilato in modo nativo per un'architettura specifica .

Potrebbero esserci alcuni motivi per dover utilizzare il codice nativo:

  • La necessità di gestire un po 'di hardware
  • Miglioramento delle prestazioni per un processo molto impegnativo
  • Una libreria esistente che vogliamo riutilizzare invece di riscriverla in Java.

Per ottenere ciò, il JDK introduce un ponte tra il bytecode in esecuzione nella nostra JVM e il codice nativo (solitamente scritto in C o C ++).

Lo strumento si chiama Java Native Interface. In questo articolo vedremo com'è scrivere del codice con esso.

2. Come funziona

2.1. Metodi nativi: la JVM soddisfa il codice compilato

Java fornisce la parola chiave nativa utilizzata per indicare che l'implementazione del metodo verrà fornita da un codice nativo.

Normalmente, quando creiamo un programma eseguibile nativo, possiamo scegliere di utilizzare librerie statiche o condivise:

  • Librerie statiche: tutti i file binari della libreria verranno inclusi come parte del nostro eseguibile durante il processo di collegamento. Quindi, non avremo più bisogno delle librerie, ma aumenterà la dimensione del nostro file eseguibile.
  • Librerie condivise: l'eseguibile finale ha solo riferimenti alle librerie, non al codice stesso. Richiede che l'ambiente in cui eseguiamo il nostro eseguibile abbia accesso a tutti i file delle librerie utilizzate dal nostro programma.

Quest'ultimo è ciò che ha senso per JNI in quanto non possiamo mescolare bytecode e codice compilato in modo nativo nello stesso file binario.

Pertanto, la nostra libreria condivisa manterrà il codice nativo separatamente all'interno del suo file .so / .dll / .dylib (a seconda del sistema operativo che stiamo utilizzando) invece di far parte delle nostre classi.

La parola chiave native trasforma il nostro metodo in una sorta di metodo astratto:

private native void aNativeMethod();

Con la differenza principale che invece di essere implementato da un'altra classe Java, verrà implementato in una libreria condivisa nativa separata .

Verrà costruita una tabella con puntatori in memoria all'implementazione di tutti i nostri metodi nativi in ​​modo che possano essere richiamati dal nostro codice Java.

2.2. Componenti necessari

Ecco una breve descrizione dei componenti chiave che dobbiamo prendere in considerazione. Li spiegheremo ulteriormente più avanti in questo articolo

  • Codice Java: le nostre classi. Includeranno almeno un metodo nativo .
  • Codice nativo: la logica effettiva dei nostri metodi nativi, solitamente codificati in C o C ++.
  • File di intestazione JNI: questo file di intestazione per C / C ++ ( include / jni.h nella directory JDK) include tutte le definizioni degli elementi JNI che possiamo utilizzare nei nostri programmi nativi.
  • Compilatore C / C ++: possiamo scegliere tra GCC, Clang, Visual Studio o qualsiasi altro che ci piace nella misura in cui è in grado di generare una libreria condivisa nativa per la nostra piattaforma.

2.3. Elementi JNI nel codice (Java e C / C ++)

Elementi Java:

  • Parola chiave "nativa": come già spiegato, qualsiasi metodo contrassegnato come nativo deve essere implementato in una libreria nativa e condivisa.
  • System.loadLibrary (String libname) - un metodo statico che carica una libreria condivisa dal file system in memoria e rende le sue funzioni esportate disponibili per il nostro codice Java.

Elementi C / C ++ (molti dei quali definiti all'interno di jni.h )

  • JNIEXPORT: contrassegna la funzione nella libreria condivisa come esportabile in modo che venga inclusa nella tabella delle funzioni e quindi JNI possa trovarla
  • JNICALL - combinato con JNIEXPORT , assicura che i nostri metodi siano disponibili per il framework JNI
  • JNIEnv - una struttura contenente metodi che possiamo utilizzare il nostro codice nativo per accedere agli elementi Java
  • JavaVM: una struttura che ci consente di manipolare una JVM in esecuzione (o anche di avviarne una nuova) aggiungendovi thread, distruggendola, ecc ...

3. Hello World JNI

Successivamente, diamo un'occhiata a come funziona JNI nella pratica.

In questo tutorial, useremo C ++ come lingua madre e G ++ come compilatore e linker.

Possiamo usare qualsiasi altro compilatore di nostra preferenza, ma ecco come installare G ++ su Ubuntu, Windows e MacOS:

  • Ubuntu Linux: esegui il comando "sudo apt-get install build-essential" in un terminale
  • Windows: installa MinGW
  • MacOS - esegui il comando "g ++" in un terminale e se non è ancora presente, lo installerà.

3.1. Creazione della classe Java

Cominciamo a creare il nostro primo programma JNI implementando un classico "Hello World".

Per iniziare, creiamo la seguente classe Java che include il metodo nativo che eseguirà il lavoro:

package com.baeldung.jni; public class HelloWorldJNI { static { System.loadLibrary("native"); } public static void main(String[] args) { new HelloWorldJNI().sayHello(); } // Declare a native method sayHello() that receives no arguments and returns void private native void sayHello(); }

Come possiamo vedere, carichiamo la libreria condivisa in un blocco statico . Ciò garantisce che sarà pronto quando ne avremo bisogno e ovunque ne abbiamo bisogno.

In alternativa, in questo programma banale, potremmo invece caricare la libreria appena prima di chiamare il nostro metodo nativo perché non stiamo usando la libreria nativa da nessun'altra parte.

3.2. Implementazione di un metodo in C ++

Ora, dobbiamo creare l'implementazione del nostro metodo nativo in C ++.

In C ++ la definizione e l'implementazione sono solitamente archiviate rispettivamente nei file .h e .cpp .

Innanzitutto, per creare la definizione del metodo, dobbiamo utilizzare il flag -h del compilatore Java :

javac -h . HelloWorldJNI.java

Questo genererà un file com_baeldung_jni_HelloWorldJNI.h con tutti i metodi nativi inclusi nella classe passati come parametro, in questo caso, solo uno:

JNIEXPORT void JNICALL Java_com_baeldung_jni_HelloWorldJNI_sayHello (JNIEnv *, jobject); 

Come possiamo vedere, il nome della funzione viene generato automaticamente utilizzando il pacchetto completo, la classe e il nome del metodo.

Inoltre, qualcosa di interessante che possiamo notare è che stiamo ricevendo due parametri passati alla nostra funzione; un puntatore all'attuale JNIEnv; e anche l'oggetto Java a cui è collegato il metodo, l'istanza della nostra classe HelloWorldJNI .

Ora dobbiamo creare un nuovo file .cpp per l'implementazione della funzione sayHello . Qui è dove eseguiremo azioni che stampano "Hello World" sulla console.

Chiameremo il nostro file .cpp con lo stesso nome di quello .h contenente l'intestazione e aggiungeremo questo codice per implementare la funzione nativa:

JNIEXPORT void JNICALL Java_com_baeldung_jni_HelloWorldJNI_sayHello (JNIEnv* env, jobject thisObject) { std::cout << "Hello from C++ !!" << std::endl; } 

3.3. Compilazione e collegamento

A questo punto, abbiamo tutte le parti di cui abbiamo bisogno e abbiamo una connessione tra di loro.

Dobbiamo costruire la nostra libreria condivisa dal codice C ++ ed eseguirla!

Per fare ciò, dobbiamo utilizzare il compilatore G ++, senza dimenticare di includere le intestazioni JNI dalla nostra installazione Java JDK .

Versione di Ubuntu:

g++ -c -fPIC -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux com_baeldung_jni_HelloWorldJNI.cpp -o com_baeldung_jni_HelloWorldJNI.o

Versione Windows:

g++ -c -I%JAVA_HOME%\include -I%JAVA_HOME%\include\win32 com_baeldung_jni_HelloWorldJNI.cpp -o com_baeldung_jni_HelloWorldJNI.o

Versione per MacOS;

g++ -c -fPIC -I${JAVA_HOME}/include -I${JAVA_HOME}/include/darwin com_baeldung_jni_HelloWorldJNI.cpp -o com_baeldung_jni_HelloWorldJNI.o

Once we have the code compiled for our platform into the file com_baeldung_jni_HelloWorldJNI.o, we have to include it in a new shared library. Whatever we decide to name it is the argument passed into the method System.loadLibrary.

We named ours “native”, and we'll load it when running our Java code.

The G++ linker then links the C++ object files into our bridged library.

Ubuntu version:

g++ -shared -fPIC -o libnative.so com_baeldung_jni_HelloWorldJNI.o -lc

Windows version:

g++ -shared -o native.dll com_baeldung_jni_HelloWorldJNI.o -Wl,--add-stdcall-alias

MacOS version:

g++ -dynamiclib -o libnative.dylib com_baeldung_jni_HelloWorldJNI.o -lc

And that's it!

We can now run our program from the command line.

However, we need to add the full path to the directory containing the library we've just generated. This way Java will know where to look for our native libs:

java -cp . -Djava.library.path=/NATIVE_SHARED_LIB_FOLDER com.baeldung.jni.HelloWorldJNI

Console output:

Hello from C++ !!

4. Using Advanced JNI Features

Saying hello is nice but not very useful. Usually, we would like to exchange data between Java and C++ code and manage this data in our program.

4.1. Adding Parameters To Our Native Methods

We'll add some parameters to our native methods. Let's create a new class called ExampleParametersJNI with two native methods using parameters and returns of different types:

private native long sumIntegers(int first, int second); private native String sayHelloToMe(String name, boolean isFemale);

And then, repeat the procedure to create a new .h file with “javac -h” as we did before.

Now create the corresponding .cpp file with the implementation of the new C++ method:

... JNIEXPORT jlong JNICALL Java_com_baeldung_jni_ExampleParametersJNI_sumIntegers (JNIEnv* env, jobject thisObject, jint first, jint second) { std::cout << "C++: The numbers received are : " << first << " and " << second 
    
     NewStringUTF(fullName.c_str()); } ...
    

We've used the pointer *env of type JNIEnv to access the methods provided by the JNI environment instance.

JNIEnv allows us, in this case, to pass Java Strings into our C++ code and back out without worrying about the implementation.

We can check the equivalence of Java types and C JNI types into Oracle official documentation.

To test our code, we've to repeat all the compilation steps of the previous HelloWorld example.

4.2. Using Objects and Calling Java Methods From Native Code

In this last example, we're going to see how we can manipulate Java objects into our native C++ code.

We'll start creating a new class UserData that we'll use to store some user info:

package com.baeldung.jni; public class UserData { public String name; public double balance; public String getUserInfo() { return "[name]=" + name + ", [balance]=" + balance; } }

Then, we'll create another Java class called ExampleObjectsJNI with some native methods with which we'll manage objects of type UserData:

... public native UserData createUser(String name, double balance); public native String printUserData(UserData user); 

One more time, let's create the .h header and then the C++ implementation of our native methods on a new .cpp file:

JNIEXPORT jobject JNICALL Java_com_baeldung_jni_ExampleObjectsJNI_createUser (JNIEnv *env, jobject thisObject, jstring name, jdouble balance) { // Create the object of the class UserData jclass userDataClass = env->FindClass("com/baeldung/jni/UserData"); jobject newUserData = env->AllocObject(userDataClass); // Get the UserData fields to be set jfieldID nameField = env->GetFieldID(userDataClass , "name", "Ljava/lang/String;"); jfieldID balanceField = env->GetFieldID(userDataClass , "balance", "D"); env->SetObjectField(newUserData, nameField, name); env->SetDoubleField(newUserData, balanceField, balance); return newUserData; } JNIEXPORT jstring JNICALL Java_com_baeldung_jni_ExampleObjectsJNI_printUserData (JNIEnv *env, jobject thisObject, jobject userData) { // Find the id of the Java method to be called jclass userDataClass=env->GetObjectClass(userData); jmethodID methodId=env->GetMethodID(userDataClass, "getUserInfo", "()Ljava/lang/String;"); jstring result = (jstring)env->CallObjectMethod(userData, methodId); return result; } 

Again, we're using the JNIEnv *env pointer to access the needed classes, objects, fields and methods from the running JVM.

Normally, we just need to provide the full class name to access a Java class, or the correct method name and signature to access an object method.

We're even creating an instance of the class com.baeldung.jni.UserData in our native code. Once we have the instance, we can manipulate all its properties and methods in a way similar to Java reflection.

We can check all other methods of JNIEnv into the Oracle official documentation.

4. Disadvantages Of Using JNI

JNI bridging does have its pitfalls.

The main downside being the dependency on the underlying platform; we essentially lose the “write once, run anywhere” feature of Java. This means that we'll have to build a new lib for each new combination of platform and architecture we want to support. Imagine the impact that this could have on the build process if we supported Windows, Linux, Android, MacOS…

JNI not only adds a layer of complexity to our program. It also adds a costly layer of communication between the code running into the JVM and our native code: we need to convert the data exchanged in both ways between Java and C++ in a marshaling/unmarshaling process.

Sometimes there isn't even a direct conversion between types so we'll have to write our equivalent.

5. Conclusion

Compiling the code for a specific platform (usually) makes it faster than running bytecode.

This makes it useful when we need to speed up a demanding process. Also, when we don't have other alternatives such as when we need to use a library that manages a device.

However, this comes at a price as we'll have to maintain additional code for each different platform we support.

Ecco perché di solito è una buona idea usare JNI solo nei casi in cui non esiste un'alternativa Java .

Come sempre il codice per questo articolo è disponibile su GitHub.