Java Tutorial - Parte 1 0.1
Un tutorial per esempi
Caricamento in corso...
Ricerca in corso...
Nessun risultato
Una applicazione nativa

Introduzione

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!

Il crivello in "C"

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:

  • le opzioni -1, -2 e -3 specificano quale dei tre algoritmi del crivello usare e hanno il significato analogo all'argomento ALGO della applicazione Java Performance: C1,C2,C3
  • la opzione -d viene usata per il debug della applicazione e visualizza lo stato del setaccio
  • la opzione -q visualizza la estrazione dei numeri primi con un output molto compatto, che può essere usato per richiamare il programma nativo come un comando esterno da una applicazione Java
  • la opzione -e viene usata per forzare un codice di errore in uscita dal programma nativo quando esso viene usato come comando esterno da una applicazione Java

I files sorgente

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

Compilare il sorgente "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:

  • per compilare un sorgente scritto in linguaggio "C" è necessario un compilatore "C"; nella sezione Strumenti necessari allo sviluppo viene spiegato come installare la GNU compiler collection (GCC). Se non lo avete ancora fatto ed intendete proseguire in questa sezione, dovete tornare alla sezione in riferimento e seguire i passi necessari alla installazione del GCC.
  • se non intendete installare il compilatore GCC potete comunque eseguire direttamente il programma che, per piattaforme Windows™ a 64-bits è stato buildato (vi ricordate la differenza tra build e compilazione, vero?) staticamente e non ha alcun bisogno delle librerie di runtime native

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!!

I tempi del linguaggio C

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:

public void main( ... )
{
int alpha = -1;
int[] numeri = { 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 );
numeri[10] = 1000;
}

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":

int main( ... )
{
int alpha = -1;
int[] numeri = { 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 );
numeri[10] = 1000;
}

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!

La memoria dinamica

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:

byte[] setaccio = new byte[1000];

è 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:

class MyClass
{
public static final double PI_GRECO = 3.14;

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.

Interfacciamo Java ed i linguaggi nativi

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 Java
long numero = Long.parseLong( "4525413654" );

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:

CrivelloNative: la classe base

CrivelloNative: costruttore

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.

CrivelloNative: metodi concreti

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.

CrivelloNative: metodi astratti

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 nativa

Questi metodi astratti saranno sovrascritti nelle due classi derivate:

L'effettivo interfacciamento tra Java ed il linguaggio nativo è profondamente diverso nelle due tecniche.

Argomento precedente - Argomento successivo - Indice Generale