In questo articolo ci occuperemo del cuore pulsante delle tecnologie Java, giunto ad una evoluzione tale da poter mettere a paragone Java con linguaggi come C e C++. Il paragone può sembrare azzardato e mai siamo entrati nel dettaglio di questa diatriba nei nostri articoli. Esaminiamo allora più da vicino alcune delle virtù nascoste di Java per comprendere le reali capacità di questo linguaggio.
Prima di descrivere le caratteristiche che favoriscono l'aumentato di presazioni della JVM, abbiamo dato un'occhiata ad ricerca fatta nel 2003 presso una università Californiana. Il risultato, sembrerà incredibile, stabilisce che in determinate condizioni, Java è comparabile in termini di performance a C++.
Java Hot Spot
Il reale scopo di questo articolo non è dibattere sulla comparazione quanto illustrare e capire come negli anni gli uomini della Sun (e più recentemente Oracle) siano stati in grado raggiungere tale obiettivo. Il fulcro delle investigazioni si chiama Java Hot Spot (tanto importante da essere un marchio registrato).
Java Hot Spot è la Java Virtual Machine che usa una serie di nuove tecniche e tecnologie di compilazione a partire dalla fine degli anni novanta. Storicamente si tratta di un prodotto nato dalla Longview Technologies, LLC, software house comprata dalla Sun proprio per inglobare nella tecnologia base il prodotto (tale Hot Spot) e renderlo parte integrante della piattaforma.
Il modello adottato da Java fino ad allora si era basato principalmente sulle cosiddette tecniche JIT (Just In Time), il cui scopo era quello di applicare la compilazione del byte code "on the fly". Questo tipo di tecnologia, da sola, non è capace di superare alcune delle problematiche legate al linguaggio stesso, non permettendo un efficiente lavoro in fase di compilazione globale del codice:
- Java è un linguaggio safe-dynamic: ciò comporta una serie di test nel casting-uncasting degli oggetti in memoria
- Il modello di memoria è heap oriented e la presenza del garbage collector è un altro elemento da tenere in considerazione in quanto qualsiasi tecnica deve sincronizzarsi con esso
- Più frequentemente che in C++ l'invocazione dei metodi è "virtuale", quindi l'inlining dei metodi diventa complesso
- La grande possibilità offerta dal dynamic loading rende vana qualsiasi possibile ottimizzazione fatta in compilazione in quanto a runtime tutto potrebbe essere cambiato
Lo studio fatto dai laboratori Sun a partire dall'adozione di Hot Spot come JVM è andato nella direzione di ottimizzare la compilazione del codice on the fly, come teorizzato dalle tecniche JIT, ma, con un livello di "intelligenza" adattativa, che faccia cambiare il comportamento del compilatore in base alla storia e all'evoluzione del programma in corso.
Riassumendo con un semplice esempio, immaginiamo l'esecuzione di un programma, sia esso un'applicazione desktop o server. È evidente che dopo un certo periodo di tempo ci saranno punti del programma che saranno stati eseguiti spesso, magari seguendo un pattern particolare.
Questi punti caldi (appunto "hot spots") verranno identificati dalla JVM ed ottimizzati con la compilazione in codice nativo per un'esecuzione rapidissima.
Dalla prossima pagina esamineremo con maggior dettaglio tale tecnica adattativa che permette addirittura de-ottimizzazione quando i punti si "raffreddano".
Migliorie
Per rendere la discussione più completa abbiamo pensato di non limitarci nella descrizione della sola individuazione degli Hot Spots, ma fare una panoramica piuttosto dettagliata su tutti i cambiamenti avuti fino al giorno d'oggi e descrivere ognuno di essi.
- Hot Spot
- Client vs Server
- Memory model
- Garbage collection
- Altre caratteristiche avanzate
Hot Spot
Sicuramente il cambiamento più importante è quello che da il nome alla tecnologia. L'esempio fatto a conclusione del paragrafo precedente è una buona approssimazione per descrivere il compito della tecnologia Hot Spot.
Qualsiasi programma (che consta di classi e metodi) dedica la maggior parte del tempo a poche linee di codice che si ripetono (magari un metodo o il corpo di un ciclo). L'attenzione del compilatore sarà così dedicata maggiormente all'esecuzione degli hot-spots, evitando la compilazione di punti che raramente vengono eseguiti.
Il monitoraggio di questi punti critici dà la possibilità di cambiare dinamicamente e spostare l'attenzione su nuovi punti critici individuati durante il corso dell'esecuzione del programma. Per far ciò la compilazione viene ritardata fino all'esecuzione del programma, proprio per permettere durante i primi istanti di esecuzione, di raccogliere dati e procedere con l'ottimizzazione.
Tra le tecniche di compilazione più antiche si annovera quella dell'inlining. Fare "inlining" significa copiare una porzione di codice sorgente in un altro punto dove essa è stata invocata (generalmente metodi, ma non necessariamente). Addirittura, linguaggi come il C++ permettono allo sviluppatore di decidere quando fare inlining con uno statement dedicato.
Tale tecnica è una grande ottimizzazione in quanto evita di fatto la chiamata al metodo e tutto il relativo tempo di context switching. Senza una tecnica adattativa, il compilatore si troverebbe in difficoltà in virtù di caratteristiche del linguaggio come il polimorfismo. Attraverso l'analisi comportamentale del programma, adesso la JVM è in grado di sapere quando e come fare inlining e quindi averne il relativo beneficio.
Le potenziali problematiche causate dall'uso dell'inlining dovute al dynamic loading vengono risolte adottando la caratteristica nota come "dynamic deoptimization". Fondamentalmente si tratta della capacità (sempre durante l'esecuzione del programma) di individuare un punto che non ha più senso mantenere ottimizzato o che addirittura cambierà completamente logica alla prossima esecuzione. Con la dynamic deoptimization ci garantiamo la possibilità di riportare la situazione di un punto critico all'inizio o, meglio, di applicare nuovi pattern di ottimizzazione in modo da seguire l'evoluzione del codice.
Client vs Server
Java come sappiamo offre un ampissimo ventaglio di tecnologie per coprire praticamente tutti gli ambiti di programmazione possibile. Per quanto riguarda la SDK, si è perciò pensato bene di offrire due profili di esecuzione: client e server.
Si tratta di due implementazioni concrete delle attività di compilazione ed esecuzione del programma, la prima, più adatta alle applet e alle desktop application, la seconda più adatta alle applicazioni lato server. L'utente stesso può decidere se applicare la prima ottimizzazione o la seconda, e, se non specificato altrimenti, verrà applicato il profilo client come default, a meno che la memoria RAM non sia superiore a 2GB (allora per default si applicherà il profilo server).
Cerchiamo di capire la differenza e il perché la necessita di due profili diversi. Come dicevamo, la presenza di diverse tecnologie Java è la premessa a diversi modelli di esecuzione. Non sempre la rapidità di esecuzione potrebbe essere un'esigenza. Difatti, una compilazione con profilo client risulta meno ottimizzata ma il suo tempo di startup e il suo consumo di memoria è decisamente minore di un profilo server. È nel profilo server che infatti vengono eseguiti dei pattern di compilazione molto complessi (oltre al discorso già fatto sull'adattabilità) tipici di linguaggi come il C++. Citiamone alcuni come esempio:
- Deep inlining
- Fast checkcast
- Range check elimination
- Loop unrolling
L'applicazione di queste tecniche rende il processo di compilazione e lo startup del programma più lento, ma in seguito la sua esecuzione più rapida (e con un maggior consumo di memoria). Dell'inlining abbiamo già discusso, per quanto riguarda i check, si tratta di controlli sulla tipizzazione e sui range (index bound). Il loop unrolling è invece una tecnica per diminuire il numero di iterazioni (espandendo, unrolling, il corpo del metodo).
Memory model
Il modello di memoria adottato per sostenere il processo di esecuzione è uno dei punti critici di una architettura di un linguaggio di programmazione. Molti sforzi di investigazione durante gli anni sono andati quindi anche in questa direzione e, per esempio vediamo come nelle più recenti versioni della JVM la gestione degli oggetti in memoria abbandona l'uso degli handles.
Un handle era usato per referenziare indirettamente gli oggetti in memoria, facilitando il lavoro di garbage collection, ma aumentando il ritardo nell'esecuzione dei programmi (con l'handle bisogna fare due accessi in memoria). Java Hot Spot elimina la presenza degli handle e utilizza come riferimenti i puntatori, provvedendo quindi un accesso analogo a quello del linguaggio C. Ovviamente adesso il meccanismo di garbage collection dovrà preoccuparsi di gestire tali riferimenti, ma l'ottimizzazione in esecuzione è evidente.
Altra miglioria è stata la riduzione dell'intestazione dell'oggetto a due spazi di memoria (word
) e non tre, in quanto si è osservato che la maggior parte degli oggetti non utilizzano più di due spazi. Solo gli array continuano utilizzandone tre. Questo garantisce una minore occupazione dell'heap.
Anche l'evoluzione hardware è stata tenuta in considerazione per un utilizzio più efficace di risorse. Le vecchie versioni di JVM Hot Spot non consentivano un uso di memoria maggiore di quattro Gigabytes data la limitazione delle architetture a 32 bit. Anche sulle architetture a 64 bit tale limitazione persisteva. Gli ultimi aggiornamenti aprono alle applicazioni Java l'utilizzo di spazi di memoria maggiori, ora senza limiti se non quelli propri del sistema operativo.
Infine, importantissimo è sottolineare come sia cambiata la gestione dei thread, con un maggior supporto delle funzioni di sistema operativo su cui la JVM si appoggia dando la possibilità di utilizzare "preemption" e multitasking a livello di sistema operativo. Ovviamente, la possibilità di allocare più processi si trasforma in una evidente maggiore velocità degli stessi che potranno avere a disposizione strumenti nativi ottimizzati per la memoria ed il processore su cui stanno girando nel dato istante.
Nella pagina successiva vedremo l'evoluzione del garbage collector.
Garbage collection
Come ben sappiamo un grande vantaggio portato da Java allo sviluppatore è quello di prendersi carico completo della gestione della memoria. A tal proposito agisce il famigerato Garbage Collector. La presenza di questo strumento invisibile è anche causa di rallentamenti in fase di esecuzione.
Per poter gestire liberare la memoria, infatti, ogni tanto questo strumento deve scorrere l'intera struttura dati alla ricerca di oggetti non più referenziati da nessun oggetto attivo. Si tratta di un lavoro oneroso a causa della ricorsione effettuata su ogni oggetto dell'applicazione (quindi migliaia o decine di migliaia per programmi medi).
Su questo punto in passato si sono aperti fronti di discussione. Alcuni studi addirittura arrivano a sostenere che una gestione automatica ed efficace può essere migliore di un meccanismo di uso e rilascio esplicito della memoria.
Non entreremo nel dettaglio dell'algoritmo, ci limiteremo a citare le innovazioni portate avanti nelle ultime versioni, in particolare la presenza delle due generazioni di collector: young e old garbage collector.
Come visto finora, la maggior parte delle innovazioni prendono spunto dallo studio delle abitudini dei programmi sviluppati durante gli ultimi 15 anni (cioè da quando Java è sul mercato), anche in questo frangente si sono osservati due comportamenti durante l'esecuzione di un programma:
- oggetti il cui ciclo di vita è cortissimo (la maggior parte)
- oggetti il cui ciclo di vita è pari a quello della stessa applicazione
Lo young collector
si occuperà dei primi, mentre l'old
dei secondi.
Anche per lo young garbage collector, le innovazioni sono di tipo adattativo. A parte sfruttare in maniera ottimale un multitasking avanzato (cioè vengono lanciati più processi in base alle risorse della macchina), un'altra tecnica adottata è quella di utilizzare un principio di contiguità nell'uso della memoria (le stesse variabili di istanza di un oggetto procedono in contiguità).
Una tecnica similare (Mark-compact old object collector) è utilizzata nella gestione del grafo degli oggetti della generazione "old". Qui, gli oggetti dead
vengono deallocati come è giusto che sia, ma lo spazio vuoto viene gestito "shiftando" opportunamente e quindi compattando la quantità finale di memoria.
In parallelo all'esecuzione della generazione young, procede l'esecuzione della generazione old che, in date circostanze, procede con i suoi controlli meno frequentemente e con altri tipi di ottimizzazione più consoni alla tipologia di oggetti gestita nella sua struttura dati (come il mark-compact old object).
Altre caratteristiche avanzate
Le principali e più importanti tecniche sono quelle di cui abbiamo già discusso finora. Ma ne sono state sviluppate altre per raggiungere lo stato attuale, eccon alcune tra quelle più importanti:
Packaging degli oggetti: nelle architetture a 64 bit la gestione della rappresentazione degli oggetti trae vantaggio (in termini di spazio consumato) dall'ottimizzazione tra gli spazi tra una variabile ed un'altra.
Scalabilità: attraverso una funzione nota come ergonomics
, la JVM si adatta ed effettua il tuning dei propri servizi in base alla configurazione della macchina su cui viene eseguita, come per esempio selezionare il profilo client o server in base alla memoria a disposizione).
Reflection: i framework che fanno un uso intenso di reflection (praticamente tutti quelli JEE) traggono vantaggio grazie all'ottimizzazione fatta su hotspot in cui operazioni di reflection vengono effettuate.
NIO: il package java.nio
(new input/output
) viene massivamente utilizzato permettendo ottimizzazioni grazie alla sua architettura. Ne traggono vantaggio operazioni di scambio informazioni distribuite o quelle che fanno uso di grafica 3D.
Debugging: novità anche introdotte per quanto riguarda il debugging di applicazioni. Con la nuova architettura object oriented è possibile modificare i valori delle variabili "on the fly", permettendo quindi delle operazioni di debugging avanzato.