|
Java Tutorial - Parte 1 0.1
Un tutorial per esempi
|
Premetto che questo capitolo non insegna nulla sul linguaggio Java: si tratta di un excursus sui lignuaggi nativi e su come si ottengono. Se il lettore non è interessato, può saltare al capitolo successivo, il Java Native Interface.
Come abbiamo osservato in I risultati del test Java è un linguaggio piuttosto veloce, che di certo non sfigura se confrontato con il codice nativo ma i risultati di cui al riferimento suesposto sono validi sempre oppure si è trattato di un caso, dovuto alla particolare semplicità dell'algoritno? Come se la caverebbe Java nel confronto con il "C" se dovesse elaborare il crivello di Eratostene, nel quale si fà largo uso di indici ed array? Proviamolo!
Nel package javatutor.primes.crivello.exec troverete i sorgenti di una applicazione CLI molto simile a Performance (vedi Performance: la sintassi) ma scritta interamente in linguaggio "C". La sintassi di questa applicazione CLI è molto simile a quella del programma Java Performance salvo il fatto che non esiste l'argomento che riguarda l'algoritmo:
crivello.exe [OPTIONS] [START] END
Le opzioni ricalcano quasi le analoghe del programma Java con alcune differenze:
Performance: C1,C2,C3 | Nome del file | Descrizione |
|---|---|
| crivello.c | Implementazione "C" del crivello per architetture a 64 bits |
| crivello.exe | Il programma eseguibile, collegato staticamente |
| CrivelloExtern.java | Classe Java per eseguire il comando esterno |
| ExecutionException.java | Classe eccezione per mancata esecuzione comando esterno |
| package-info.java | il file di documentazione javadoc del package |
| README.txt | Note per la compilazione di crivello.c |
Abbiamo già avuto modo di "compilare" un programma scritto in linguaggio "C" (vedi Il build di un eseguibile) ma ribadisco i concetti essenziali di seguito:
Spostatevi nella cartella che contiene il package javatutor/primes/crivello/exec e compilate il sorgente "C" col comando:
> gcc -O3 -o crivello.exe crivello.c
Anche in questo caso, non fatevi ingannare dalla semplicità del comando: il build di un eseguibile (o di una libreria dinamica) in linguaggio nativo (cioè specifico per una particolare piattaforma hardware/software, come per esempio x86/Windows) non è una operazione banale ma di questo ne riparleremo!!
Per confrontare i tempi del "C" con quelli di Java, prendiamo il migliore tra gli algoritmi scritti finora, e quindi Crivllo3. Proviamo il conteggio dei primi fino a 1 miliardo:
javatutor\primes\crivello\exec>crivello -c 1000000000 Crivello di Eratostene: Starting number: 2 Ending number: 1000000000 Aproximated list size: 57905932 Prime number count: 50847534 Elapsed time: 4.0
4 secondi netti. Il tempo impiegato da Java è stato di 5.3 secondi quindi il codice nativo è stato, nella media dei confronti, del 20-30% più veloce sulla mia macchina; una bella differenza, non c'è che dire. Nella tabella seguente ecco i risultati per altri limiti:
| Limite | Count | Crivello3 | exec |
|---|---|---|---|
| 1G | 50.847.534 | 5.3 | 4.0 |
| 5G | 234.954.233 | 29.5 | 23.8 |
| 10G | 455.052.511 | 62.4 | 50.3 |
| 20G | 882.206.716 | 132.5 | 108.0 |
| 40G | 1.711.955.433 | Overflow | 235.2 |
La differenza sostanziale tra Java e "C" è presto spiegata: nonostante il bytecode di Java sia molto performante vi sono alcuni aspetti di Java che ne penalizzano le performance perchè Java è un linguaggio intrinsecamente sicuro mentre il "C" non lo è per niente!
Prendiamo questo spezzone di codice Java:
Dopo aver allocato una array di 10 elementi di tipo intero voglio cambiarne l'ultimo elemento da 100 a 1000 ma ... sbaglio l'indice della array! Ricordo al lettore che l'ultimo elemento della array (il decimo) si trova ad indice 9 e non 10 (vedi Le arrays).
Java mi segnala l'errore sollevando una IndexOutOfBoundsException e non mi permette di completare la operazione che potrebbe essere potenzialmente molto pericolosa.
Di contro, in linguaggio "C":
A fronte di una sintassi praticamente identica, il linguaggio "C" NON mi segnala alcun errore e scrive il valore 1000 nell'undicesimo elemento della array che però se non esiste davvero: il risultato è, probabilmente, quello di sovrascrivere la variabile alpha che si potrebbe trovare ad indirizzi di memoria più alti nello stack frame.
Questa è una precisa scelta strategica del linguaggio "C": quello che interessa in questo linguaggio non è la sicurezza intrinseca del linguaggio ma la maggiore velocità possibile!
Probabilmente avrete notato una altra particolarità del crivello in linguaggio nativo: non è andato in overflow sul limite dei quaranta-miliardi. Ricorderete che il limite massimo del Crivello3 è di Integer.MAX_VALUE volte 16 come descritto in Il metodo getMaxLimit . In soldoni:
getMaxLimit() = Integer.MAX_VALUE * 16 = 231 * 24 = 231+4 = circa 34 miliardi
Questo perchè non è possibile allocare una array con un numero di elementi maggiore di Integer.MAX_VALUE. Tutti gli oggetti in Java vengono allocati nello heap (=memoria dinamica) che si chiama così perchè viene allocata solo alla bisogna:
è l'operatore new che alloca l'oggetto Java (le Array vengono equiparate agli oggetti) e la memoria necessaria viene allocata solo quando questa istruzione viene eseguita; quando poi l'oggetto non serve più la memoria allocata viene liberata dal garbage collector. Ecco perchè è chiamata dinamica: viene allocata e deallocata alla bisogna nel corso della applicazione.
Vi chiederete se, per caso, esiste anche la memoria statica. Certo che esiste; per esempio:
La costante mnemonica dichiarata static viene allocata allo startup della applicazione e sarà deallocata solo quando la applicazione termina: in questo senso è statica perchè il suo spazio in memoria sarà sempre occupato, almeno finchè la applicazione è in esecuzione.
Il linguaggio Java dispone di una propria zona di memoria dinamica chiamata Java heap che viene destinata alla JVM dal sistema operativo. La JVM può richiedere spazio aggiuntivo alla bisogna ma, comunque, nessun oggetto Java può essere allocato al di fuori dello Java heap.
Il linguaggio "C", invece, può richiedere le zone di memoria direttamente al sistema operativo per mezzo di una funzione della libreria standard del "C" alla quale viene passato come argomento la dimensione in bytes della zona di memoria libera.
Su piattaforme a 64 bits la ampiezza della dimensione richiesta è di 64 bits e quindi in linguaggio "C" non ho il limite delle array come in Java: il limite di grandezza della zona di memoria allocabile è di 264 bytes pari a circa 1,8x1019 (decine di miliardi di miliardi di bytes) sempre se la memoria installata nella macchina risulti sufficiente.
I linguaggi nativi non hanno le limitazioni di Java: non esiste un numero massimo di elementi allocabili poichè nei linguaggi nativi il concetto di "elemento di una array" non esiste: è possibile simularlo, è vero, ma fondamentalmente in un linguaggio nativo come per esempio il "C" la memoria viene allocata per "segmenti": il sistema operativo ritorna al richiedente un indirizzo di memoria libera della dimensione desiderata. Per il sistema operativo, il cosa conterrà il segmento non ha alcuna importanza, forse una array ma forse qualcosa di diverso.
Vi è un limite anche nei linguaggi nativi: la dimensione del segmento di memoria viene richiesta attraverso un tipo di dato particolare che, in linguaggi come "C" e "C++" si chiama size_t oppure anche size_type la cui ampiezza dipende dal tipo di piattaforma: nelle architetture a 64 bits size_t è a 64 bits mentre nelle architetture a 32 bits esso è ampio 32 bits.
Al momento in cui scrivo quasi tutti i computer hanno hardware e sistema operativo a 64 bits, perfino il mio computer, un desktop di fascia medio/bassa è a 64 bits; Considerato che ogni byte di memoria può indirizzare 16 numeri, il limite massimo teorico del setaccio nativo diventa:
limite = 264 * 24 = 264+4 - 1
pari a circa 2.9*1020, un numero molto grande: quasi 300 miliardi di miliardi. Ancora una volta, si tratta di un limite teorico perchè non esistono macchine con una quantità tale di memoria da allocare.
Tuttavia, il limite massimo del crivello nativo resterà comunque Long.MAX_VALUE dal momento che la nostra applicazione è in grado di interpretare solo numeri al massimo grandi come un intero lungo. Questo non è un limite di Java, ma della nostra applicazione poichè nel interpretare gli argomenti sulla riga di comando usiamo il metodo Long.parseLong. In realtà il linguaggio Java è in grado di interpretare numeri ben più grandi, a precisione arbitaria, per mezzo della classe BigInteger.
Ma allora è meglio usare i linguaggi nativi per scrivere applicazioni veloci? Assolutamente no! Non dimenticatevi che il programma crivello.exe che avete eseguito poc'anzi funziona solo su Windows™ a 64 bits poichè l'autore lo ha compilato su quella piattaforma e per quella piattaforma.
Probabilmente funzionerebbe anche su altre piattaforme ma dovrà comunque essere ricompilato. E questo costringe l'utente ad avere a disposizione i sorgenti. Il bytecode Java, invece, funziona su qualsiasi piattaforma, basta avere la JVM specifica. Ma c'è di più; se avete aperto nel vostro editor preferito il sorgente "C" avrete forse notato che per interpretare il numero fornito sulla cmdline si è dovuto scrivere una funzione di oltre 30 righe di codice.
Perchè dovrei rinunciare alla comodità di Java per eseguire operazioni delle quali non mi interessa affatto la velocità?
In fin dei conti, per cosa mi serve la maggiore velocità possibile? Ma la risposta è ovvia: per elaborare il setaccio. E allora, perchè rinunciare alla comodità e portabilità di Java se ricerco la velocità solo per una piccola parte della applicazione?
Molto meglio scrivere la applicazione in Java avendo cura di scrivere in linguaggio nativo solo quei piccoli pezzi in cui cerchiamo la maggiore velocità possibile oppure perchè dobbiamo superare alcuni limiti di Java oppure perchè dobbiamo controllare hardware specifico e ci servono istruzioni e/o costrutti speciali che il linguaggio Java non possiede.
Il linguaggio Java consente di interfacciare i metodi e le classi Java con librerie scritte in linguaggio nativo e richiamarne i metodi, che d'ora in poi chiameremo funzioni, così da distinguerli dai metodi Java. Vi sono due modalità per inferfacciare Java con i linguaggi nativi, le esamineremo entrambe ma la seconda modalità, che è una evoluzione della prima, è sicuramente la più efficace:
Il costruttore del crivello nativo è tale e quale a tutti gli altri: non esegue alcunchè poichè la elaborazione vera e propria avviene nei metodi di conteggio e/o estrazione dei numeri primi.
I metodi concreti della classe CrivelloNative sovrascrivono quelli della classe base usati per la elaborazione vera e propria del setaccio dal momento che, in questa nuova implementazione del algoritmo, il setaccio viene allocato ed elaborato in linguaggio nativo e non in Java:
setaccia: alloca ed esegue il setaccio; questo metodo è quasi identico a quello di Crivello3 ma, invece di elaborare in Java il setaccio, richiama il metodo astratto nativeSetaccia che, a sua volta, richiamerà una funzione nativa count: conta i numeri primi nel setaccio; anche questo metodo è praticamente identico a quello di Crivello3 ma, invece di elaborare in Java il setaccio, richiama il metodo astratto nativeCount che, a sua volta, richiamerà una funzione nativa extract: estrae i numeri primi dal setaccio; ancora una volta, questo metodo è praticamente uguale a quello di Crivello3 nel quale si itera su tutti i numeri partendo da start fino a end ma si richiama il metodo astratto nativeIsCancelled il quale, a sua volta, richiamerà una funzione nativa close: perchè un metodo close() vi chiederete? La classe non apre un flusso di dati da dover poi chiudere, questo è certo. Ma allora perchè? Questo metodo è necessario per liberare le risorse allocate nella librerie nativa dal momento che la garbage collection di Java nulla sa, ne può sapere, di cosa è stato allocato nelle funzioni native.Poichè intendiamo usare due approcci di interfacciamento diversi, scriviamo i metodi concreti in modo da poterli riutilizzare nelle due classi derivate. In pratica, il metodo concreto non deve fare altro che richiamare il suo omologo metodo astratto che sarà sovrascritto.
I metodi astratti hanno lo stesso nome dei metodi concreti ma col prefisso native in modo da distinguerli da quelli concreti ma che ne rivelano immediatamente la natura:
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 nativaQuesti metodi astratti saranno sovrascritti nelle due classi derivate:
CrivelloJNI che utilizza la tecnica della Java Native Interface CrivelloFFM che utilizza la tecnica della Foreign Function and MemoryL'effettivo interfacciamento tra Java ed il linguaggio nativo è profondamente diverso nelle due tecniche.
Argomento precedente - Argomento successivo - Indice Generale