|
Java Tutorial - Parte 1 0.1
Un tutorial per esempi
|
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 .
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).
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 |
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.
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:
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 nativaTutti 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.
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:
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:
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.
Le righe interessanti del file header sono quelle che dichiarano le funzioni o, per meglio dire, i metodi nativi. Questa è la prima:
Il nome della funzione è dato da:
Java 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
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).
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.
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.
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.
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:
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.
I risultati del test sono riportati in I risultati dei test; come previsto, le performances sono molto migliorate rispetto alla versione precedente della JNI.
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à:
extractJNI addToList, che viene richiamato da extractJNI dall'interno della DLL extract: questo metodo non fà altro che richiamare il metodo nativo poichè tutta la elaborazione è nella DLL
Analizziano il prototipo della funzione "C" che dovremmo implementare:
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:
env ed obj per ottenere un riferimento alla classe dove è stato definito il metodo callback Il codice "C" per ottenere il methodID del metodo addToList della classe CrivelloJNIcb è il seguente:
Avrete notato che per ottenere il methodID abbiamo passato alla funzione GetMethodID della interfaccia JNI quattro parametri:
env thisClass 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:
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.
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:
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:
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.
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:
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:
-t indica che deve essere stampata la lista dei primi -q indica che il comando deve essere eseguito in modalità quiet, cioè senza output aggiuntivo oltre alla lista dei primi start del metodo getPrimes end del metodo getPrimes 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:
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:
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.
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 esecuzione del comando esterno può fallire per molti motivi:
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:
Process appena terminato e dal quale viene letto il flusso standard error che descrive l'errore occorsoPoichè 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.
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