Progmar Marcin Załęczny

1. Wstęp

Programjąc w Javie czasami niezbędne jest użycie kodu natywnego (C/C++), aby obejść ograniczenia wydajności kodu Java czy też mechanizmu zarządzania pamięcią. W tym właśnie celu Java udostępnia interfejs JNI (Java Native Interface).

JNI jest trudniejsze do opanowania niż standardowy kod Javy, gdyż wymaga poznania dwóch języków programowania oraz ich środowiska uruchomieniowego.

W tutorialu tym zakładam, że znasz:

  1. Javę,
  2. Shell systemu Linux, język programowania C i kompilator gcc.

2. Zaczynamy

2.1. JNI oraz C

Krok 1. Piszemy klasę Javy, która używa kodu w C - HelloJNI.java

public class HelloJNI {
    static {
        System.loadLibrary("hello"); // Załadowanie biblioteki natywnej w pliku uruchomieniowym libhello.so
    }

    // Deklaracja metody natywnej sayHello(), która nie pobiera żadnych parametrów i nic nie zwraca
    private native void sayHello();

    // Test metody natywnej - wywołanie jej w funkcji startowej programu Java
    public static void main(String[] args) {
        new HelloJNI().sayHello();
    }
}

Podczas ładowania programu statyczny inicjalizator wywołuje funkcję System.loadLibrary(), aby załadować natywną bibliotekę "hello", zawierającą natywną metodę sayHello(). W systemie Linux plik tej biblioteki powinien się nazywać libhello.so i powinien się znajdować w ścieżce wyszukiwania bibliotek Javy. Przeszukiwane ścieżki są dostępne w zmiennej systemowej java.library.path. Jeśli biblioteka nie zostanie znaleziona, to nastąpi rzucenie wyjątku UnsatisfiedLinkError. Aby dodać dowolną ścieżkę do przeszukiwanych przez Javę lokalizacji, należy przy uruchomieniu programu użyć argumentu -Djava.library.path=sciezka_do_katalogu_zawierajacego_biblioteke.
Następnie deklarujemy metodę natywną sayHello() z wykorzystaniem słowa kluczowego native. Instruuje ono kompilator Javy, że metoda ta została zaimplementowana w innym języku programowania. Metoda natywna oczywiście nie zawiera ciała.
W metodzie main() tworzymy instancję klasy HelloJNI i wywołujemy na niej naszą metodę natywną.

Kompilujemy plik HelloJNI.java do pliku HelloJNI.class: javac HelloJNI.java

Krok 2. Tworzymy plik nagłówkowy języka C/C++ - HelloJNI.h

Teraz uruchamiamy polecenie javah, aby wygenerować plik nagłówkowy w C/C++: javah HelloJNI Zostanie wygenerowany plik HelloJNI.h o następującej zawartości:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class HelloJNI */

#ifndef _Included_HelloJNI
#define _Included_HelloJNI
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     HelloJNI
 * Method:    sayHello
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_HelloJNI_sayHello
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

Jak widzimy, plik nagłówkowy zawiera deklarację funkcji Java_HelloJNI_sayHello: JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *, jobject); Konwencja nazewnicza funkcji natywnych w C jest następująca: Java_{paczka_i_NazwaKlasy}_{nazwaFunkcji}(JNI argumenty) (kropka w nazwie paczki powinna zostać zastąpiona znakiem podkreślenia '_').
Argumenty powyższej funkcji, to:

  • JNIEnv*: wskaźnik na środowisko JNI, pozwala między innymi uzyskać dostęp do wszystkich funkcji natywnych.
  • jobject: referencja na obiekt Javy this.

W tym momencie nie używamy argumentów w powyższej funkcji, ale zrobimy to nieco później. Na razie także, dla zachowania jak największej prostoty, pominiemy wyjaśnienie makrodefinicji JNIEXPORT i JNICALL.
Składnia extern "C" ma zastosowanie wyłącznie w języku C++. Instruuje ona kompilator tego języka, żeby wykorzystywał protokół nazewniczy funkcji języka C a nie C++. Oba protokoły różnią się między sobą, jako że C++ wspiera tzw. przeciążanie funkcji, czyli tworzenie funkcji o tej samej nazwie, ale innych argumentach.

Krok 3. Implementacja w języku C - HelloJNI.c

#include <jni.h>
#include <stdio.h>
#include "HelloJNI.h"

// Implementacja natwywnej metody sayHello() klasy Javy HelloJNI
JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *env, jobject thisObj) {
   printf("Witaj świecie!\n");
   return;
}

Plik nagłówkowy jni.h znajduje się w katalogu: <JAVA_HOME>/include, gdzie <JAVA_HOME> jest katalogiem, w którym zainstalowano JDK, np. /usr/lib/jvm/java-8-openjdk-amd64. W powyższym przykładzie funkcja natywna wyświetla na ekranie tekst "Witaj świecie!" i kończy działanie. Teraz skompilujmy ten kod za pomocą polecenia:


JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
gcc -o libhello.so HelloJNI.c -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" -shared -fPIC
W poleceniu tym użyliśmy następujących flag kompilatora:
  • -I: Wyszczególnia katalogi jakie mają być przeszukiwane w celu dołączenia użytych plików nagłówkowych. W naszym przypadku są to pliki jni.h (w katalogu "$JAVA_HOME/include") i jni_md.h (w katalogu "$JAVA_HOME/include/linux"). Jeśli ścieżka do katalogu zawiera spacje, to powinna być podana w podwójnych cudzysłowach.
  • -shared i -fPIC: informują linker, że ma być utworzony plik biblioteki dynamicznej (współdzielonej).
  • -o: określa nazwę pliku wynikowego - tutaj "libhello.so".

Po pomyślnie zakończonej kompilacji spróbujmy wywołać polecenie nm (które wyświetla listę symboli w pliku biblioteki) na otrzymanej bibliotece i przefiltrujmy grepem wyniki w poszukiwaniu naszej funkcji:

nm libhello.so | grep say
00000000000006b0 T Java_HelloJNI_sayHello

Krok 4. Uruchamiamy program javowy HelloJNI.class

java -Djava.library.path=. HelloJNI

2.2. JNI oraz mieszanka kodu w C i C++

Krok 1. Piszemy klasę Javy, która używa kodu w C++ - HelloJNICpp.java

public class HelloJNICpp {
   static {
      System.loadLibrary("hello");
   }

   // Deklaracja metody natywnej
   private native void sayHello();

   // Test działania metody natywnej
   public static void main(String[] args) {
      new HelloJNICpp().sayHello();
   }
}

Jak widzimy klasa ta jest identyczna z klasą dla kodu w C. Kompilujemy ją wydając polecenie: javac HelloJNICpp.java

Krok 2. Generujemy plik nagłówkowy, który deklaruje użytą metodę natywną dla języków C/C++ - HelloJNICpp.h

javah HelloJNICpp

Wygenerowany plik wygląda identycznie jak ten dla języka C (i prawidłowo bo jak na razie jedyną zmianą w stosunku do poprzedniego punktu była zmiana nazw utworzonych plików źródłowych).

Krok 3. Implementacja w C i C++ - HelloJNICppImpl.h, HelloJNICppImpl.cpp, and HelloJNICpp.c

Właściwy program obsługujący wywołanie JNI implementujemy w języku C++ - są to pliki: HelloJNICppImpl.h i HelloJNICppImpl.cpp. Natomiast kod wywołujący zaimplementowaną przed chwilą funkcjonalność i wywoływany z poziomu Javy implementujemy w C w pliku HelloJNICpp.c.

Nagłówek pliku w C++: HelloJNICppImpl.h

#ifndef _HELLO_JNI_CPP_IMPL_H
#define _HELLO_JNI_CPP_IMPL_H

#ifdef __cplusplus
        extern "C" {
#endif
        void sayHello ();
#ifdef __cplusplus
        }
#endif

#endif

Implementacja funkcji sayHello w pliku w C++: HelloJNICppImpl.cpp

#include "HelloJNICppImpl.h"
#include  <iostream>
using namespace std;

void sayHello () {
    cout << "Witaj świecie" << endl;
    return;
}

Implementacja programu pomostu między kodem w C++ a Javą: HelloJNICpp.c

#include <jni.h>
#include "HelloJNICpp.h"
#include "HelloJNICppImpl.h"

JNIEXPORT void JNICALL Java_HelloJNICpp_sayHello (JNIEnv *env, jobject thisObj) {
    sayHello();  // wywołanie funkcji zaimplementowanej w C++
    return;
}

Kod utorzony w plikach powyżej kompilujemy poleceniem:

JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
g++ -o libhello.so HelloJNICpp.c HelloJNICppImpl.cpp -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" -shared -fPIC

Krok 4. Uruchamiamy program w Javie

java -Djava.library.path=. HelloJNICpp

2.3. JNI oraz pakiety Javowe

Wszystkie duże programy Javowe powinny być podzielone na pakiety zamiast być umieszczone w domyślnym pakiecie bez nazwy. Pokażemy tutaj jak uwzględnić nazwy pakietów w bibliotekach JNI.

Krok 1. Program Javy umieszczony w pakiecie pkgjni - pkgjni/HelloJNI

package pkgjni;

public class HelloJNI {
   static {
      System.loadLibrary("hello");
   }

   private native void sayHello();

   public static void main(String[] args) {
      new HelloJNI().sayHello();
   }
}

Plik ten zachowujemy w katalogu "pkgjni/HelloJNI.java" i kompilujemy poleceniem javac pkgjni/HelloJNI.java

Krok 2. Generujemy plik nagłówkowy dla programu w C

javah -d include pkgjni.HelloJNI Powyższe polecenie utworzy katalog include w bieżącym katalogu a następnie wygeneruje w nim plik pkgjni_HelloJNI.h. W pliku tym zadeklarowana zostanie natywna funkcja: JNIEXPORT void JNICALL Java_pkgjni_HelloJNI_sayHello(JNIEnv *, jobject);. Zauważmy, że w nazwie tej funkcji występuje nazwa pakietu (pkgjni). Gdyby pakiet składał się z kilku członów, wówczas kropki rozdzielające poszczególne jego części zostałyby zastąpione znakami podkreślenia w powyższej nazwie.

Krok 3. Implementujemy naszą funkcję natywną - pkgjni_HelloJNI.c

#include <jni.h>
#include <stdio.h>
#include "include/pkgjni_HelloJNI.h"

JNIEXPORT void JNICALL Java_naszjni_HelloJNI_sayHello(JNIEnv *env, jobject thisObj) {
   printf("Witaj świecie z pakietu pkgjni!\n");
   return;
}

Kompilujemy program poleceniem: gcc -o libhello.so pkgjni_HelloJNI.c -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" -shared -fPIC I na koniec uruchamiamy program Javowy: java -Djava.library.path=. pkgjni.HelloJNI

2.4. JNI w Eclipsie Neon.2 Release (4.6.2)

Jeśli nie masz już zainstalowanego Eclipse IDE for Java Developers, pobierz go ze strony Eclipse Downloads a następnie zainstaluj (rozpakuj w dowolnym katalogu i ustaw zmienną PATH, tak by wskazywała na katalog z binarką tego pakietu).

Upewnij się, że masz zainstalowany plugin CDT dla programistów w C/C++. W tym celu wejdź w menu "Help ⇒ About Eclipse". W okienku, które się pojawi kliknij przycisk "Installation Details". Jeśli masz zainstalowany CDT, to powinien on figurować na liście oprogramoweania w zakładce "Installed Software" (Dokładnie na tej liście powinny się znajdować pozycje "C/C++ Development Tools" i "C/C++ Development Tools SDK").
Jeśli nie jest on obecny na tej liście, to powinieneś go zainstalować w następujący sposób:

  • Wejdź w menu "Help ⇒ Install New Software".
  • Z listy rozwijanej "Work With" wybierz opcję "Neon - http://download.eclipse.org/releases/neon" i poczekaj aż lista oprogramowania leżąca poniże zapełni się dostępnymi dodatkami.
  • W kolumnie "Name", rozwiń opcję "Programming Languages" a następnie zaznacz pola "C/C++ Development Tools". "C/C++ Development Tools SDK". Teraz kliknij przycisk "Next" na dole okienka i w następnym okienku ("Install Details") ponownie kliknij przycisk "Next". W okienku "Review Licenses" zaznacz pole akceptacji wyświetlonej licencji i kliknij przycisk "Finish".
Na koniec zrestartuj Eclipsa i po ponownym jego uruchomieniu sprawdź - jak to opisałem wyżej - czy dodatek CDT jest zainstalowany.

Krok 1. Tworzenie nowego projektu Javy

Uruchom Eclipsa i utwórz w nim nowy projekt Javy np. "HelloJNI". Następnie dodaj nową klasę: HelloJNI.java o następującej zawartości:

public class HelloJNI {
   static {
      System.loadLibrary("hello"); // libhello.so (Linux)
   }

   // Declare native method
   private native void sayHello();

   public static void main(String[] args) {
      new HelloJNI().sayHello();  // invoke the native method
   }
}

Krok 2. Skonwertuj projekt Javowy na projekt C/C++ Makefile

Kliknij prawym przyciskiem myszki na projekcie "HelloJNI"i z menu wybierz pozycję: New ⇒ Other... ⇒ C/C++ ⇒ "Convert to a C/C++ Project (Adds C/C++ Nature)" Wciśnij przycisk "Next".

Pojawi się okienko "Convert to a C/C++ Project". W sekcji "Project type" wybierz pozycję "Makefile Project" a w sekcji "Toolchains" wybierz "Linux GCC". Kliknij przycisk "Finish".
Od teraz możesz uruchamiać projekt zarówno jako aplikację Javy jak i aplikację C/C++.

Krok 3. Wygeneruj plik nagłówkowy C/C++

Utwórz katalog "jni" w hierarchii projektu. Będzie on przechowywał pliki programu w C. Utwórz plik makefile w katalogu jni. W tym celu kliknij ppm (prawym przyciskiem myszki) folder jni i wybierz menu: "New" ⇒ "File", w okienku które się pojawi wpisz makefile i kliknij przycisk "Finish". Otwórz ten plik i wpisz do niego następujący kod:

# Definiuje zmienną przechowującą ścieżkę classpath
CLASS_PATH = ../bin

# Definiuje wirtualną ścieżkę dla plików .class w katalogu bin
vpath %.class $(CLASS_PATH)

# $* - zmienna, która przechowuje nazwę docelowego pliku bez rozszerzenia
HelloJNI.h : HelloJNI.class
    javah -classpath $(CLASS_PATH) $*

UWAGA! Upewnij się, że wcięcia w powyższym pliku robione są znakiem pojedyńczej tabulacji a nie spacji, gdyż taki jest wymóg składni pliku makefile.

Powyższy makefile definiuje zależność celu HelloJNI.h od pliku HelloJNI.class. Dlatego też zawsze kiedy plik skompilowanej klasy Javowej HelloJNI.class będzie nowszy od pliku nagłówkowego HelloJNI.h, zostanie uruchomione polecenie javah, które zaktualizuje ten plik nagłówkowy.

Teraz utwórz nowy Eclipsowy "Build Target" dla utworzonego pliku makefile. W tym celu: ppm na folderze jni, z menu wybierz opcję "Build Targets" ⇒ "Create...". W okienku "Create Build Target", które się pojawi w polu "Target name" wpisz cel "HelloJNI.h". Kliknij przycisk OK. Pod folderem jni pojawi się nowa pozycja "Build Targets". Rozwiń ją a zobaczysz, że figuruje tam utworzony target HelloJNI.h. Kliknij go dwukrotnie lewym przyciskiem myszy (lpm). Wywołany zostanie nasz plik makefile i za pomocą polecenia javah zostanie wygenerowany plik "HelloJNI.h". Aby to sprawdzić, wystarczy, że przełączysz się do zakładki Console. Powinna ona zawartość podobną do poniższej:

14:58:59 **** Build of configuration Build (GNU) for project HelloJNI ****
make HelloJNI.h
javah -classpath ../bin HelloJNI

14:58:59 Build Finished (took 439ms)

Alternatywnie kompilację możesz wykonać w konsoli systemowej. Wystarczy, że przejdziesz w niej do katalogu jni w naszym projekcie i wywołasz polecenie: make HelloJNI.h

Krok 4. Implementacja w C - HelloJNI.c

Utwórz program HelloJNI.c klikając ppm na folderze jni a następnie New ⇒ Source file. W okienku "Source file", które się pojawi wpisz "HelloJNI.c" i kliknij "Finish".

W nowo utworzonym pliku wpisz następujący kod:

#include <jni.h>
#include <stdio.h>
#include "HelloJNI.h"

JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *env, jobject thisObj) {
   printf("Witaj świecie!\n");
   return;
}

Zmodyfikuj plik makefile następująco:

# Definiuje zmienną przechowującą ścieżkę classpath
CLASS_PATH = ../bin
JAVA_HOME = /usr/lib/jvm/java-8-openjdk-amd64

# Definiuje wirtualną ścieżkę dla plików .class w katalogu bin
vpath %.class $(CLASS_PATH)

all : libhello.so

# $@ - zmienna przechowująca plik docelowy razem z rozszerzeniem, $< - zmienna przechowująca pierwszy plik zależności
libhello.so : HelloJNI.o
    gcc -o $@ $< -I"$(JAVA_HOME)/include" -I"$(JAVA_HOME)/include/linux" -shared

# $@ - zmienna przechowująca plik docelowy razem z rozszerzeniem, $< - zmienna przechowująca pierwszy plik zależności
HelloJNI.o : HelloJNI.c HelloJNI.h
    gcc -c $< -o $@ -I"$(JAVA_HOME)/include" -I"$(JAVA_HOME)/include/linux" -fPIC

# $* - zmienna, która przechowuje nazwę docelowego pliku bez rozszerzenia
HelloJNI.h : HelloJNI.class
    javah -classpath $(CLASS_PATH) $*


clean :
    rm HelloJNI.h HelloJNI.o libhello.so

UWAGA! Upewnij się, że wcięcia w powyższym pliku robione są znakiem pojedyńczej tabulacji a nie spacji, gdyż taki jest wymóg składni pliku makefile.

W sposób opisany tutaj utwórz cele "all" i "clean". Następnie kliknij lpm w cel "all" znajdujący się w drzewie projektu pod pozycją "Build Targets". Program zostanie skompilowany i powstanie plik biblioteki dynamicznej libhello.so. Zawartość konsoli powinna wyglądać podobnie jak poniżej:

16:23:02 **** Build of configuration Build (GNU) for project HelloJNI ****
make all
javah -classpath ../bin HelloJNI
gcc -c HelloJNI.c -o HelloJNI.o -I"/usr/lib/jvm/java-8-openjdk-amd64/include" -I"/usr/lib/jvm/java-8-openjdk-amd64/include/linux" -fPIC
gcc -o libhello.so HelloJNI.o -I"/usr/lib/jvm/java-8-openjdk-amd64/include" -I"/usr/lib/jvm/java-8-openjdk-amd64/include/linux" -shared

16:23:02 Build Finished (took 502ms)

Krok 5. Uruchom Javowy program

Teraz prawie możesz już uruchomić program Javowy. Zanim jednak to zrobisz musisz jeszcze podać linkerowi gdzie ma szukać pliku biblioteki natywnej libhello.so. W tym celu kliknij ppm na projekcie i z menu wybierz: "Run As" ⇒ "Run Configurations...". W okienku "Run Configurations" kliknij na liście z lewej strony pozycję "HelloJNI" znajdującą się w węźle "Java Application". Upewnij się, że w zakładce "Main", w centralnej części okna, pole "Main class" zawiera wpis "HelloJNI". Następnie przełącz się do zakładki "Arguments" i w polu "VM arguments" wpisz "-Djava.library.path=jni" - czyli ścieżkę do naszej biblioteki. Kliknij przycisk "Run".

W konsoli (zakładka "Console") zobaczysz nasz napis: Witaj świecie!.