Java Tutorial - Parte 1 0.1
Un tutorial per esempi
Caricamento in corso...
Ricerca in corso...
Nessun risultato
Java Native Interface

Introduzione

Delle due tecniche di interfacciamento tra Java ed i linguaggi nativi, la Java Native Interface (JNI) è la più vecchia, disponibile già fin dalla versione 8 di Java ma è anche la meno efficiente e soffre di una serie di problemi (vedi I problemi di JNI) tanto che è stata soppiantata dalla ben più efficiente Foreign Function and Memory .

Il nuovo package

Java è un linguaggio potente, veloce ed intrinsecamente sicuro ma è anche molto flessibile: esso permette attraverso la Java Native Interface (JNI) di richiamare metodi nativi cioè scritti in linguaggi compilati come il "C" ed il "C++".
Nelle sezioni seguenti il lettore imparerà ad usare la JNI cioè a richiamare codice "nativo" (contenente istruzioni in codice macchina) dai metodi Java (e viceversa, pure).

Contenuto del package

Il package javatutor.primes.crivello.jni contiene i sorgenti in linguaggio "C" e Java per interfacciare classi Java coi metodi cosidetti nativi e scritti in linguaggio "C". Useremo il linguaggio "C" per allocare ed elaborare il setaccio in modo da avere velocità e praticamente nessuna restrizione sulle sue dimensioni salvo, ovviamente, la quantità di memoria libera a disposizione sul computer.

Nome del file Descrizione
CrivelloJNI.java La prima implementazione del crivello in JNI
CrivelloJNIby.java Estrazione a livello byte dal setaccio in DLL
CrivelloJNIcb.java Estrazione con callback Java dal setaccio in DLL
CrivelloJNIar.java Estrazione con Java-array dal setaccio in DLL
CrivelloJNI.c Sorgente in linguaggio C
CrivelloJNIby.c Sorgente in linguaggio C
CrivelloJNIcb.c Sorgente in linguaggio C
CrivelloJNIar.c Sorgente in linguaggio C
CrivelloJNI.h Header in linguaggio C
CrivelloJNIby.h Header in linguaggio C
CrivelloJNIcb.h Header in linguaggio C
CrivelloJNIar.h Header in linguaggio C
makefile.txt Makefile per utility make

Concetti generali della JNI

La Java Native Interface (JNI) consiste in un ambiente specifico che consente di interfacciare i metodi scritti in Java con i metodi scritti in linguaggio "C" (oppure in "C++") che vengono chiamati metodi nativi. I metodi nativi devono trovarsi in una libreria dinamica accessibile alla JVM. il nome della libreria viene specificato nel blocco di inizializzazione della classe che li usa. Una libreria dinamica è caratterizzata dalla estensione del file su disco:

  • dll in windows; per esempio setacciojni.dll
  • so in Unix/Linux; per esempio setacciojni.so

Un metodo nativo viene dichiarato in modo simile al metodo astratto: non ha corpo e la dichiarazione termina col carattere punto-e-virgola. Invece della parola chiave abstract si usa la parola chiave native.

// il metodo nativo
public native void nativeMethod();
// la libreria dinamica che contiene il metodo nativo
static{
System.loadLibrary("setacciojni");
}

Il caricamento della libreria

Per poter interfacciarsi correttamente, la libreria dinamica che contiene i metodi nativi deve essere caricata nella memoria della JVM in modo che il runtime Java possa risolvere gli indirizzi di memoria dei metodi nativi da richiamare.
Il modo migliore per caricare la libreria è quello di usare uno static initializer (=inizializzatore statico) come quello che vedete nello spezzone di codice suesposto: l'inizializzatore statico esegue le istruzioni contenute nel suo blocco di codice già prima che venga eseguito il costruttore della classe e questo è il motivo per cui si chiama static. E' comunque possibile caricare la libreria nativa in modo dinamico cioè anche solo al momento del bisogno eseguendo il codice seguente:

Runtime.getRuntime().loadLibrary( "setacciojni" );

I metodi astratti

Deriviamo CrivelloJNI da CrivelloNative (vedi CrivelloNative: la classe base) e ne implementiamo i metodi astratti che sono i responsabili del effettivo interfacciamento tra Java ed il codice nativo (vedi CrivelloNative: metodi astratti):

  • nativeSetaccia: alloca ed segue il setaccio
  • nativeIsCancelled: verifica se il numero è stato cancellato nel setaccio
  • nativeCount: ritorna il conteggio dei numeri primi
  • nativeClose: libera le risorse allocate nella libreria nativa

Tutti questi metodi astratti non fanno altro che richiamare il relativo metodo nativo che, per restare coerenti con lo schema dei nomi, avrà lo stesso nome del metodo concreto ed il suffisso "JNI" ad indicare che questa particolare implementazione della classe CrivelloNative usa l'approccio della Java Native Interface.

I metodi nativi

protected native long setacciaJNI( long num );
protected native boolean isCancelledJNI( long num );
protected native void closeJNI();
protected native long countJNI( long start, long end );

Il lavoro del programmatore Java è praticamente finito qui. Ora comincia il lavoro del programmatore "C" che deve scrivere la libreria nativa, compilarla e fornirla al proprio interlocutore.

Chiariamo subito un punto: in questo tutorial NON verrà spiegato come scrivere le funzioni native in linguaggio "C"; questo sarà il compito di un tutorial sul linguaggio "C". Quello che sarà spiegato in questo tutorial è come ottenere la libreria dinamica partendo dai sorgenti scritti in "C" da qualcun altro, per esempio un amico che conosce il linguaggio "C".
Ma conoscere il linguaggio "C" non basta per scrivere la libreria dinamica: è necessario sapere il modo in cui Java si interfaccia col linguaggio "C" e questo è compito del programmatore Java; al programmatore "C" spetta il compito di implementare i metodi nativi e fornire i sorgenti in linguaggio "C" mentre al programmatore Java spetterà il compito di creare la libreria dinamica dai sorgenti ricevuti.

Il linguaggio "C", come molti altri linguaggi compilati, separano nettamente il concetto di dichiarazione da quello della definizione di una variabile, di un metodo o di una classe (le classi non esistono in "C" ma in "C++" si).
In Java, per poter usare un tipo di dato, lo devo importare: la parola chiave import in Java presuppone che il tipo di dato (una classe) sia definita nel package che io ho importato. Benchè in "C" non esistano le classi, il concetto di dichiarazione/definizione esiste per i metodi (che si chiamano funzioni).
In C è necessario dichiarare TUTTE le funzioni che si intendono usare in ogni sorgente:

// dichiaro le funzioni
long setacciaJNI( long limit );
void closeJNI();
void extractJNI();
// ora posso usare le funzioni
int main()
{
closeJNI();
}

Poichè risulta tedioso dover dichiarare le funzioni ogni volta che si intendono usare, nel linguaggio "C" si usa inserire tutte le dichiarazioni in un file speciale detto header che poi viene incluso nel file sorgente che usa dette funzioni. Il file header normalmente ha lo stesso nome del file sorgente che implementa le funzioni ma una estensione diversa: .h per il file header, .c per il sorgente:

// file: CrivelloJNI.h
long setacciaJNI( long limit );
void closeJNI();
long countJNI();
// file: CrivelloJNI.c
#include "CrivelloJNI.h"
long setacciaJNI( long limit )
{
... implementazione ...
}
...

Notate nel file CrivelloJNI.c la direttiva #include che ingloba il file CrivelloJNI.h nel sorgente. Benchè si possa in qualche modo assimilare la #include alla import del Java, vi è una netta differenza tra le due istruzioni: non sono nemmeno lontanamente paragonabili.

Per poter scrivere (o far scrivere ad un nostro amico) i metodi nativi abbiamo quindi bisogno delle dichiarazioni di essi, cioè dei files header di tutti i metodi nativi che intendiamo scrivere. A questo ci pensa il compilatore Java specificando la opzione -h sulla command-line seguita dalla directory dove vogliamo che il compilatore salvi il file header:

>javac -h ./javatutor/primes/crivello/jni javatutor\primes\crivello\jni\*.java

Il comando suesposto, oltre a compilare tutte le classi Java del package javatutor.primes.crivello.jni ha creato nella directory del package stesso, i files header di tutte le funzioni da scrivere in linguaggio "C" e che implementano i metodi nativi Java.

Il compilatore Java crea i files header con un nome che è formato dal nome della classe precedento dal nome del package ma sostituendo il carattere punto con il carattere undercore:

javatutor_primes_crivello_jni_CrivelloJNI.h
javatutor_primes_crivello_jni_CrivelloJNIar.h
javatutor_primes_crivello_jni_CrivelloJNIby.h
javatutor_primes_crivello_jni_CrivelloJNIcb.h

A differenza di Java, nel quale il nome del file sorgente deve essere uguale a quello della classe pubblica che vi è contenuta, in "C" i nomi dei files non contano nulla. Quindi li rinominiamo in qualcosa di più leggibile come per esempio CrivelloJNIxx.h rimuovendo la parte del nome che identifica il package.
Ogni file header generato dal compilatore Java comincia nel modo seguente; da notare la ammonizione scritta in maiuscolo nella prima riga:
NON MODIFICATE QUESTO FILE.

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

Le righe interessanti del file header sono quelle che dichiarano le funzioni o, per meglio dire, i metodi nativi. Questa è la prima:

JNIEXPORT jlong JNICALL Java_javatutor_primes_crivello_jni_CrivelloJNI_setacciaJNI
(JNIEnv *, jobject, jlong);

Il nome della funzione è dato da:

  • la parola Java
  • il nome del package con i punti sostituiti dal carattere underscore
  • il nome della classe
  • il nome del metodo nativo preceduto da underscore

Dobbiamo quindi copiare tutte le dichiarazioni dei metodi nativi in un nuovo file sorgente che chiameremo CrivelloJNI.c e scriveremo (noi o qualcunaltro) il codice per la implementazione delle funzioni.

Per ogni file header creato dal compilatore Java creeremo in file sorgente con estenzione .c e vi copieremo le dichiarazioni dei metodi nativi (le funzioni "C"). Nel file sorgente i metodi nativi devono essere implementati e, pertanto, essi avranno un corpo. Questo è il primo metodo nativo nel file CrivelloJNI.c

File: CrivelloJNI.c
/*
* Class: CrivelloJNI
* Method: setacciaJNI
* Description: alloca il setaccio su base bit ed esegue la operazione
* di setacciamento per tutti i multipli fino a 'limit'.
* Solo i numeri dispari vengono setacciati
* Signature: (J)I
* Param: 'limit' il numero più grande da setacciare
* Return: - se maggiore di ZERO, il limite del setaccio che non può essere minore
* del limite fornito come argomento
* - se minore/uguale a ZERO indica un codice di errore:
* ZERO = memoria esaurita
* -1 = 'size_t' è a 32 bits, inutile nel contesto del crivello nativo
*/
JNIEXPORT jlong JNICALL Java_javatutor_primes_crivello_jni_CrivelloJNI_setacciaJNI
(JNIEnv * env, jobject obj, jlong limit )
{
... implementazione ... (questo lo scrive il programmatore "C")
}

Immagino che non debba raccomandarvi l'importanza di documentare la funzione nativa in modo appropriato, non solo per voi ma sopratutto per il vostro povero amico programmatore "C" che deve scrivere la funzione ...

Uno dei più grossi limiti della JNI è che il programmatore "C" non è abituato alle dichiarazioni generate automaticamente dal compilatore Java e si può trovare a disagio (ne riparlerò in JNI ed i tipi di dato specifici).

Compilare la libreria dinamica

Questo passo non è fondamentale per la esecuzione delle classi Java contenute nel package javatutor.primes.crivello.jni poichè la libreria dinamica necessaria alla esecuzione è già presente: si tratta del file setacciojni.dll che è stata linkata staticamente. Questo significa che funzionerà su qualsiasi piattaforma Windows™ a 64 bits. Chi non ha interesse a questo passaggio può saltare alla prossima sezione.

Chi invece vuole cimentarsi nella compilazione (o per meglio dire nel build) della libreria dinamica setacciojni.dll, può continuare a leggere.

Abbiamo già compilato un eseguibile ricordate? (vedi Compilare il sorgente "C") Era stato facile, un unico breve comando sulla command-line:

> gcc -O3 -DNDEBUG -o crivello.exe crivello.c

Ebbene, adesso è tutta una altra faccenda. Come anticipato, il build di un eseguibile o di una libreria in linguaggio compilato non è una operazione banale. Tuttavia, gli strumenti di sviluppo nei linguaggi compilati mettono a disposizone una utility molto semplice da usare: il cosidetto make.

Make è un comando CLI che legge un file formattato in un modo particolare detto makefile ed esegue i comandi in esso contenuti. Nei linguaggi compilati, il make è uno standard di fatto ed è usato pressochè in ogni progetto anche piccolo.

Per compilare la libreria dinamica che contiene i metodi nativi JNI, spostatevi nella cartella che contiene il relativo package e digitate i seguenti due comandi:

> make -f makefile.txt clean
> make -f makefile.txt

AVVERTENZA: a seconda del tipo di installazione che avete scelto in Strumenti necessari allo sviluppo il comando per eseguire la utility make potrebbe essere make oppure mingw32-make. In questo tutorial io uso il comando make che è disponibile se come tools di sviluppo avete tenuto gli stessi tools utilizzati dal sottoscritto e presenti in nella sottocartella tools di questo tutorial.

Probabilmente otterrete degli errori dal compilatore "C" relativi alla mancanza dei files include. Questo perchè il compilatore ha bisogno di alcuni speciali files header che risiedono nella cartella dove avete installato il JDK che, sicuramente, è diversa dalla mia. Aprite allora il file makefile.txt e modificate la riga:

JAVA_HOME = \Users\lucca\jdk\jdk-22.0.2

in modo che il nome della cartella indicata dopo il segno di uguaglianza corrisponda alla cartella in cui avete installato il Java Development Kit (vedi Strumenti necessari allo sviluppo).
Una volta completato il processo di make, avrete un nuovo file nella cartella del package: javatutor/primes/crivello che rappresenta la libreria dinamica (DLL, Dynamic-Link Library = libreria a collegamento dinamico) che contiene i metodi nativi scritti in codice macchina.

Misuriamo la velocità della JNI

Per poter eseguire il programma la libreria deve essere caricata in memoria da parte della JVM (vedi Il caricamento della libreria) ma la JVM stessa deve conoscere la ubicazione del file della libreria dinamica. Questo deve essere specificato con la opzione -D del comando java; questa opzione imposta una proprietà Java nella JVM. Specificando la opzione -D impostiamo la proprietà java.library.path che rappresenta il percorso dove la JVM cercherà le librerie dinamiche.

Per maggiori informazioni sulle proprietà della JVM impostabili con la opzione -D vedi System.getProperties()
Per maggiori informazioni sulle opzioni che possono essere specificate nel comando java vedi: the Java application launcher
Per eseguire il programma di estrazione dei numeri primi da 2 a cento-milioni con algoritmo CrivelloJNI dobbiamo pertanto digitare il seguente comando:

>java -ea -Djava.library.path=./javatutor/primes/crivello javatutor.primes.Performance JNI 100000000

Riprendiamo la tabella dei test di velocità vista in precedenza (vedi I tempi del linguaggio C) e inseriamo una nuova colonna a destra dove inseriremo i tempi della JNI (M=un-milione, G=un-miliardo):

Limite Count Crivello3 exec JNI
200M 11.078.937 0.9 1.6
400M 21.336.326 1.9 3.3
1G 50.847.534 5.3 4.0 8.5
5G 234.954.233 29.5 23.8 45.4
10G 455.052.511 62.4 50.3 94.6
20G 882.206.716 132.5 108.0 197.0
40G 1.711.955.433 Overflow 235.2 410.8

Con nostra grande sorpresa la JNI non ha affatto migliorato i tempi delle implementazioni Java anzi, li ha notevolmente peggiorati.
Unica nota positiva è che l'algoritmo non è andato in overflow col limite di 40-miliardi e questo è comprensibile: il setaccio viene allocato in modalità nativa nella DLL e quindi non soffre del limite di Integer.MAX_VALUE nel numero di elementi nella Java-array.

Quelli visti sopra sono i tempi del conteggio dei numeri primi ma con la estrazione come se la cava la JNI? Bella domanda ma non ci aspettiamo miracoli, anzi, sappiamo che l'estrazione e la memorizzazione dei numeri primi è più lenta del conteggio (vedi Le performances nella estrazione). Proviamo:

>java -ea -Djava.library.path=./javatutor/primes/crivello javatutor.primes.Performance -e JNI 200000000

Test performances of ListPrimes implementations
Starting number: 2
Ending number: 200000000

Using implementation: Crivello JNI

count of primes: 11078937
Time elapsed: 2,8
Releasing DLL resources

Nella tabella vengono riepilogati i tempi della estrazione di tutti i numeri primi da 2 fino al Limite indicato nella prima colonna:

Limite Count JNI count JNI extract
200M 11.078.937 1.6 2.8
400M 21.336.326 3.3 6.2
1G 50.847.534 8.5 14.7
5G 234.954.233 45.4 OutOfMemory

Le performances sono davvero deludenti.

Interfacciarsi con la JNI

Perchè i tempi della JNI non sono migliori di quelli di Java? Eppure abbiamo visto che i tempi di una applicazione scritta in "C" sono nettamente migliori; per quale oscuro motivo i metodi nativi, scritti in linguaggio "C" sono così lenti?

Ebbene, dovete sapere che il richiamo di un metodo nativo da parte della JVM costa un certo tempo chiamato overhead che possiamo tradurre come "sovraprezzo" ossia un costo in termini di tempo che si aggiunge al costo dovuto per il richiamo di un metodo normale. Per approfondire questo argomento vedi The overhead of native calls in Java.
Benchè il setaccio viene elaborato interamente nella DLL, la successiva estrazione dei numeri primi deve iterare dal numero iniziale (start) al numero finale (end) richiamando ad ogni ciclo il metodo nativo isCancelledJNI; quindi abbiamo un continuo andirivieni da un metodo Java (il metodo extract) ad un metodo nativo (il metodo isCancelledJNI).
Se vogliamo ottenere i numeri primi da 2 a centomilioni, il metodo Java dovrà richiamare cinquantamilioni di volte il metodo nativo isCancelledJNI: una volta per ogni numero dispari presente nel range desiderato. Il problema del overhead, quindi, si fa sentire.

Estrazione a livello byte

Assodato che il setaccio viene elaborato interamente nella DLL e che il problema è nella estrazione dei numeri primi, possiamo migliorare le performances riducendo il numero di chiamate al metodo nativo isCancelledJNI.

Scriveremo una classe derivata da CrivelloJNI che chiameremo CrivelloJNIby nella quale sovrascriveremo il metodo extract; nella nuova versione questo ultimo metodo non richiamerà più il metodo nativo isCancelledJNI ma il nuovo metodo nativo getByte che restituisce un intero byte del setaccio (otto numeri dispari), riducendo pertanto le chiamate al metodo nativo di otto volte. Lo spezzone di codice modificato nel metodo extract è il seguente:

long length = (limit + 1) >> 4; // lunghezza in bytes del setaccio
for ( long idx = start >> 4; idx < length; idx++ ) {
long num = (idx << 4)+1; // il numero di partenza del byte
long value = getByte(idx );
assert( value < Long.MAX_VALUE );
// elabora ogni bit del byte partendo da sinistra
for ( int i = 0; i < 8; i++ ) {
if ( (value & (long) setValues[i]) == 0 ) {
// numero dispari NON cancellato
long prime = num + (i*2);
if ( prime >= start && prime <= end ) {
primes.add( prime );
}
}
}
}

Dobbiamo anche dichiarare il nuovo metodo nativo getByte che restituisce un long come valore, anzichè un byte. Ovviamente, il range di valori per un byte è tra ZERO e 255 e pertanto basterebbe un tipo di ritorno int oppure anche solo byte. Invece, la scelta di restituire un long è data dal fatto che il metodo nativo deve poter restituire un codice di errore. Usando un tipo long come valore di ritorno possiamo usarlo proprio a questo scopo: dal momento che il range di valori possibili è 0 .. 255 stabiliamo che un valore di Long.MAX_VALUE sia un codice di errore.

public class CrivelloJNIby : extends CrivelloJNI
{
protected native long getByte( long index );
...
}

I risultati del test sono riportati in I risultati dei test; come previsto, le performances sono molto migliorate rispetto alla versione precedente della JNI.

Estrazione con metodo callback

Una modalità alternativa per la estrazione dei numeri dal setaccio elaborato nella DLL è quello di elaborare il ciclo for in un metodo nativo (p.es. extractJNI) e, quando viene incontrato un numero non-cancellato richiamare un metodo Java (nel nostro caso addToList) che inserisce il numero nella lista dei primi,
In gergo, il metodo addToList si dice callback method che possiamo tradurre come "metodo da richiamare" anche se la traduzione è orrenda. Per inciso, non sentire mai un programmatore italiano usare il termine "metodo da richiamare" mentre il termine inglese callback è di uso comune, anche tra i programmatori italiani.

Scriveremo pertanto una classe specializzata in questo modo di estrazione, derivata da CrivelloJNI, che chiameremo CrivelloJNIcb la quale definirà:

  • il metodo nativo extractJNI
  • il metodo callback addToList, che viene richiamato da extractJNI dall'interno della DLL
  • il metodo sovrascritto extract: questo metodo non fà altro che richiamare il metodo nativo poichè tutta la elaborazione è nella DLL
public class CrivelloJNIcb extends CrivelloJNI
{
private native long extractJNI( long start, long end );
@Override
protected void extract( long start, long end )
{
...
long r = extractJNI( start, end );
if ( r < 0 ) {
String[] errorDescr = { "", // placeholder for error-code ZERO
"cannot obtain callback\'s method ID from native method",
"the setaccio was not allocated" };
int num = (int) Math.abs( r );
throw new IllegalStateException( "cannot extract primes from native method: "
+ errorDescr[num] );
}
}
protected boolean addToList( long num, long start, long end )
{
if ( num >= start && num <= end ) {
primes.add( num );
}
return true;
}

I parametri ai metodi nativi


Analizziano il prototipo della funzione "C" che dovremmo implementare:

JNIEXPORT jlong JNICALL Java_galdom_factor_crivello_jni_CrivelloJNIcb_extractJNI
(JNIEnv *env, jobject obj, jlong start, jlong end)

Notiamo immediatamente che il metodo nativo dichiarato nel file .java ha due soli parametri: start ed end che rappresentano i due estremi numerici entro i quali dobbiamo estrarre i numeri primi. Tuttavia, il prototipo in linguaggio "C" ha quattro parametri: oltre a start ed end, dei quali conosciamo il significato, ne possiede altri due:

  • env, di tipo JNIEnv
  • obj, di tipo jobject

Il primo argomento è un puntatore alla interfaccia tra il codice nativo e la Java Virtual Machine (JVM): è possibile richiamare metodi Java attraverso questo puntatore dalla DLL scritta in linguaggio "C". Per maggiori informazioni vedi: JNI Interface Functions and Pointers.

Il secondo argomento è un puntatore all'oggetto this tramite il quale è stato richiamato il metodo nativo: quindi questo parametro punta la istanza della classe CrivelloJNIcb tramite la quale è stato richiamato il metodo extractJNI. Per maggiori informazioni vedi: Reference Types.

Per poter richiamare il metodo callback abbiamo bisogno di entrambi questi parametri. L'operazione funziona in questo modo:

  • usiamo i parametri env ed obj per ottenere un riferimento alla classe dove è stato definito il metodo callback
  • usiamo il riferimento alla classe per ottenere il methodID (=identificativo del metodo) del metodo callback
  • per ottenere l'identificativo del metodo callback dobbiamo passare alla interfaccia JNI sia il nome del metodo che la sua signature cioè la firma ottenuta dal numero e dal tipo dei suoi argomenti.

Il codice "C" per ottenere il methodID del metodo addToList della classe CrivelloJNIcb è il seguente:

jclass thisClass = (*env)->GetObjectClass(env, obj);
jmethodID callBack = (*env)->GetMethodID(env, thisClass, "addToList", "(JJJ)Z");
if (NULL == callBack) {
return -1;
}

La signature dei metodi callback

Avrete notato che per ottenere il methodID abbiamo passato alla funzione GetMethodID della interfaccia JNI quattro parametri:

  • il puntatore alla interfaccia JNI stessa: env
  • il riferimento alla classe che contiene il metodo: thisClass
  • il nome del metodo del quale ottenere la ID: "addToList"
  • la stringa "(JJJ)Z" che rappresenta la signature del metodo addToList

La signature non rappresenta altro che un modo per distinguere i metodi sovraccaricati (vedi Il metodo sovraccaricato): due o più metodi possono avere lo stesso nome purchè il numero e/o il tipo dei loro argomenti sia diverso.
La stringa "(JJJ)Z" indica alla funzione di interfaccia di cercare un metodo col nome specificato e che abbia:

  • tre argomenti di tipo long (la "J" indica il tipo long)
  • un valore di ritorno di tipo boolean (la "Z" indica il tipo boolean)

La tabella dei tipi e delle rispettive signatures è al seguente link: Type Signatures ma c'è un modo alternativo e più comodo per trovare la signature dei metodi ed è tramite la utility javap.exe. Tra le molte opzioni disponibili, quella che ci interessa è la opzione -s che stampa a terminale le signatures di tutti i metodi definiti nella classe:

>javap -s javatutor\factor\crivello\jni\crivelloJNIcb
Compiled from "CrivelloJNIcb.java"

  ... omissis ...
  
  protected boolean addToList(long, long, long);
    descriptor: (JJJ)Z
}

Il resto del metodo nativo extractJNI è di facile interpretazione: esso itera, come tutti i metodi di estrazione, su tutti i numeri dispari da start ad end e, per ognuno di essi che non è stato cancellato dal setaccio, richiama il metodo callback la cui ID è stata ottenuta come descritto poc'anzi.

Il metodo nativo ritorna il numero di elementi aggiunti alla lista dei primi che può essere solo un numero positivo o ZERO. Il metodo ritorna un numero negativo quale codice di errore interno; se non è stato possibile ottenere il methodID.

Le fasi successive per ottenere la libreria dinamica solo le stesse già viste per la implementazione della classe base CrivelloJNI. I risultati del test sono riportati in I risultati dei test ma sono piuttosto deludenti, segno che il richiamo dei metodi callback tra la DLL e la JVM ha un overhead piuttosto alto.

Una Java-array di ritorno dalla DLL

Un ulteriore modalità di estrazione dei numeri primi dalla DLL è quella di elaborare l'intera estrazione dei primi nella libreria nativa e di costruire una array di numeri primi nel metodo nativo ed alla fine delle operazioni restituire la intera array a Java.

Una array in "C" è sostanzialmente diversa da quella Java: in Java una array è un oggetto complesso che possiede delle proprietà come per esempio length che ne indica la lunghezza, usata anche per verificare che gli accessi ad essa non specifichino indici fuori range.
In linguaggio "C", invece, una array non è altro che una zona di memoria contigua di dimensioni stabilite. Che cosa contenga questa zona di memoria è responsabilità del programmatore come anche non superare i limiti quandi vi si accede tramite gli indici. Il linguaggio "C" non esegue alcun controllo sugli indici ed i risultati che si ottengono se essi vengono superati sono imprevedibili.

Proprio a causa della diversa natura tra una C-array ed una Java-array dopo aver creato la array in "C" è necessario usare le funzioni della interfaccia JNI per convertire la C-array in una Java-array.

Scriveremo quindi una classe specializzata per questa nuova implementazione che chiameremo CrivelloJNIar, derivata da CrivelloJNI:

public class CrivelloJNIar extends CrivelloJNI
{
private native long[] extractJNI( long start, long end );
private native int getErrorCode();
@Override
protected void extract( long start, long end )
{
... omissis ...
long[] primesArray = extractJNI( start, end );
if ( primesArray == null ) {
throw new OutOfMemoryError( "cannot get Java-array from JNIar "
+ " errorcode=" + getErrorCode());
}
for ( long i : primesArray ) {
primes.add( i );
}
}

Il metodo nativo extractJNI ritorna una Java-array di long che contiene la lista dei primi compresi tra start ed end, come di consueto. Se l'oggetto ritornato è null significa che non è stato possibile elaborare la lista per qualche motivo; la causa della mancata elaborazione viene ritornata dal metodo nativo getErrorCode.

Per quanto riguarda il metodo nativo extractJNI, la elaborazione del setaccio procede nel solito modo ma con la differenza che tutti i numeri non-cancellati vengono inseriti in una C-array.
Al termine della elaborazione si usano le funzioni della interfaccia JNI per convertire la C-array in una Java-array che viene ritornata dal metodo nativo. Il codice di interesse è il seguente:

// crea una Java-array da una C-array
jlongArray javaArray = (*env)->NewLongArray(env, count); // allocate
if (NULL == javaArray) {
errorCode = -3;
return NULL;
}
(*env)->SetLongArrayRegion(env, javaArray, 0 , count, cArray); // copy
return javaArray;

Anche in questo caso, le fasi successive per ottenere la libreria dinamica sono le stesse già viste per la implementazione della classe base CrivelloJNI. I risultati del test sono riportati in I risultati dei test ma, di nuovo, sono piuttosto deludenti.

Il processo esterno

Considerato che le performances del linguaggio "C" sono nettamente migliori del Java, proviamo una ultima modalità di estrazione dei numeri primi: usare l'eseguibile crivello.exe i cui tempi sono davvero buoni.

In Java è possibile mandare in esecuzione un qualsiasi eseguibile (che chiameremo comando esterno) attraverso le classi Process e ProcessBuilder entrambi facenti parte del package java.lang.
Per mezzo di queste due classi è possibile ottenere:

  • uno stream in input che corrisponde allo standard output del comando esterno
  • uno stream in input che corrisponde allo standard error del comando esterno
  • uno stream in output che corrisponde allo standard input del comando esterno

Dal momento che il comando esterno crivello.exe non si aspetta dati dallo standard input poichè i dati in input sono specificati sulla command-line, il terzo stream non viene creato.

La classe ProcessBuilder viene usata per impostare le caratteristiche del comando da eseguire, come il nome del comando, la command-line, un eventuale ambiente di esecuzione e la cartella corrente, se diversa da quella attuale.

Nel nostro caso, il comando da eseguire è il seguente:

javatutor/factor/crivello/clang/crivello.exe -t -q START END 

dove:

  • la opzione -t indica che deve essere stampata la lista dei primi
  • la opzione -q indica che il comando deve essere eseguito in modalità quiet, cioè senza output aggiuntivo oltre alla lista dei primi
  • START corrisponde al parametro start del metodo getPrimes
  • END corrisponde al parametro end del metodo getPrimes

La classe CrivelloExtern

Benchè la elaborazione della lista dei primi si basi sul crivello di Eratostene, possiamo osservare che questa nuova classe non ha nulla a che vedere con la classe Crivello scritta in Il crivello di Eratostene dal momento che non alloca un setaccio nè lo usa per estrarne i numeri primi. Questa nuova classe si limita ad eseguire un comando esterno.
Per questo motivo, la classe CrivelloExtern non deriva da Crivello ma si limita ad implementare la interfaccia ListPrimes. Per questo motivo, l'unico metodo da implementare obbligatoriamente è il metodo getPrimes(long,long).

Il metodo è molto simile a quello della classe Crivello ma, anzichè allocare ed elaborare il setaccio, prepara la array di stringhe che rappresentano il comando esterno da eseguire:

public List<Long> getPrimes( long start, long end ) throws OverflowException
{
... omissis ...
String[] cmdline = new String[5];
cmdline[0] = "./javatutor/factor/crivello/clang/crivello.exe"
cmdline[1] = "-q";
cmdline[2] = "-t";
cmdline[3] = Long.toString( start );
cmdline[4] = Long.toString( end );
Process proc = null;
try {
ProcessBuilder pb = new ProcessBuilder( cmdline );
proc = pb.start();
}
catch( IOException | UnsupportedOperationException |
SecurityException | IndexOutOfBoundsException ex )
{
throw new ExecutionException( command, ex );
}
...

La array di stringhe viene passata al costruttore della classe ProcessBuilder e, successivamente, viene richiamato il suo metodo start che restituisce un oggetto Process il quale rappresenta il nuovo processo creato dal sistema operativo.
Per mezzo delL'oggetto Process creiamo un flusso in input dallo standard output del comando esterno e leggiamo tutti i numeri che arrivano sul flusso:

assert( proc != null );
try ( InputStream in = proc.getInputStream();
Scanner scan = new Scanner( in )) {
while ( scan.hasNext()) {
long p = scan.nextLong();
primes.add( p );
}
}

Notate il costrutto try-with-resources usato per aprire il flusso in input e lo scanner che legge i numeri primi da aggiungere alla lista. Questo costrutto garantisce che i due flussi siano correttamente chiusi sia nel caso in cui il loop termini normalmente che in caso di eccezioni.

Vi è da osservare che quando il comando esterno viene eseguito per mezzo della istruzione pb.start() il sistema operativo crea un nuovo processo, totalmente separato dal nostro programma Java. I due processi vengono eseguiti in parallelo e non vi è alcuna sincronizzazione tra di essi: il programma Java potrebbe quindi terminare prima della conclusione del comando esterno ma questo non va bene.
Per costringere il metodo getPrimes ad attendere la terminazione del comando esterno si usa il metodo Process.waitFor che, come suggerisce il nome, attende che il processo iniziato con start si concluda.

try {
int exitVal = proc.waitFor();
if ( exitVal != 0 ) {
throw new ExecutionException( command, exitVal, proc );
}
}
catch ( InterruptedException ex )
{
throw new ExecutionException( command, "the process was interrupted" );
}

Il metodo waitFor restituisce il codice di terminazione del processo. Si tratta di un numero intero che, convenzionalmente, vale ZERO se il processo termina normalmente; un valore diverso da ZERO indica una terminazione anormale.
Nel caso di terminazione anormale, viene sollevata una eccezione di tipo ExecutionException della quale scriverò nella prossima sezione.

La eccezione ExecutionException

La esecuzione del comando esterno può fallire per molti motivi:

  • il comando non esiste sulla memoria di massa oppure si trova in una cartella diversa da quella specificata nel comando
  • il processo può essere interrotto dal gestore delle attività
  • il sistema operativo fallisce nel caricare il comando oppure non si posseggono i privilegi necessari ad eseguirlo
  • possono intervenire errori di I/O nella lettura del flusso in input dal quale si leggono i numeri primi
  • il comando termina con un codice di errore dovuto ad errori di sintassi oppure per overflow oppure per memoria insufficente

Poichè le cause di fallimento delle operazioni sono davvero molte, è più conveniente creare un oggetto eccezione specializzato. Pertanto scriveremo la classe ExecutionException, che è una unchecked exception, implementando una serie di costruttori specifici per ogni situazione eccezionale.
Tutti i costruttori di questa classe eccezione accettano come primo argomento una stringa che rappresenta il comando da eseguire. Gli altri argomenti sono specifici per ogni costruttore:

  • un costruttore che prende come secondo argomento una stringa che descrive l'errore
  • un costruttore che prende come secondo argomento un oggetto eccezione che è la causa primaria del fallimento (p.es. la SecurityException)
  • un costruttore che prende come secondo argomento una stringa che descrive l'operazione da eseguire (p.es. "read") e come terzo argomento un oggetto eccezione che è la causa primaria del fallimento (p.es. la IOException)
  • un costruttore che prende come secondo argomento il codice di uscita del processo (che ovviamente sarà diverso da ZERO) e come terzo argomento l'oggetto Process appena terminato e dal quale viene letto il flusso standard error che descrive l'errore occorso

Poichè il messaggio di errore gestito da questa classe è piuttosto complesso, non è possibile passarlo direttamente nel costruttore della classe base Exception.
Il messaggio viene elaborato in un membro privato della classe ExecutionException e ritornato dal metodo getMessage, che deve essere sovrascritto.

I risultati dei test

Nella tabella seguente sono riportati i risultati del test per la estrazione della lista dei numeri primi dal crivello:

Limite Count Crivello3 JNI JNIby JNIcb JNIar extern
100M 5.761.455 0.5 1.2 0.7 1.7 0.9 4.5
200M 11.078.937 1.2 2.7 1.6 3.5 2.1 8.8
400M 21.336.326 2.5 6.7 3.5 6.7 4.2 17.2
1G 50.847.534 6.7 14.3 9.4 18.1 10.7

In conclusione abbiamo potuto constatare che la JNI ha un overhead importante e che non si ottengono miglioramenti delle performances nel suo uso rispetto al Java.
Anche l'esecuzione di un comando esterno peggiora ulteriormente le prestazioni: questo è dovuto al fatto che leggere i numeri primi dai flussi di input/output è sempre molto costoso in termini di tempo poichè tutte le operazioni di I/O passano, solitamente, attraverso le primitive del kernel.
Se la quantità di dati in output dal programma esterno è particolarmente contenuta allora può essere davvero conveniente percorrere questa strada per ottenere migliori risultati.

Ma, esiste una alternativa molto più efficiente di JNI, la Foreign Function and Memory API, che imparerete ad usare nel prossimo capitolo.

Argomento precedente - Argomento successivo - Indice Generale