Quando si parla di Java, non si può fare a meno di restare colpiti da quella che, con ogni probabilità, rappresenta la maggiore peculiarità di tale linguaggio: la portabilità. Questo termine, per coloro che ne ignorassero il significato, indica la capacità di un programma (scritto in Java) di tenere lo stesso comportamento in modo indipendente dalla piattaforma in cui il programma stesso viene eseguito.
Lo slogan della Sun: "Write once, Run everywhere" ("Scrivi una volta, Esegui dappertutto") riassume in quattro parole questa preziosa caratteristica che ha contribuito in modo determinante alla fortuna della tecnologia Java.
Fermo restando il fatto che nella realtà il concetto di portabilità assoluta rimane ancora di difficile realizzazione (per programmi complessi è quasi sempre necessario effettuare degli "aggiustamenti" ad hoc sui vari sistemi operativi), è noto che il principale artefice di un simile comportamento è da identificare nella famosa Java Virtual Machine (JVM). Ma cos'è e come è strutturata realmente la JVM?
Le specifiche contenute nel documento "The Java Virtual Machine Specification" contengono la seguente definizione:
«La Java Virtual Machine è una macchina immaginaria (astratta) la cui implementazione può essere effettuata attraverso l'utilizzo di un software di emulazione che venga eseguito su una macchina reale. I programmi che una JVM sono in grado di eseguire devono essere scritti in appositi file con estensione .class, ognuno dei quali deve contenere al suo interno il codice per, al massimo, una classe pubblica.»
Vediamo di capire meglio il concetto. Per la stragrande maggioranza dei linguaggi di programmazione, il cammino che viene seguito dalla scrittura del codice alla sua esecuzione, può essere identificato nei seguenti passi:
- Viene scritto il codice sorgente utilizzando la sintassi propria del linguaggio in questione
- Il codice sorgente viene, quindi, analizzato dal compilatore del linguaggio il quale (se non vi sono errori di sintassi) produce un codice macchina (object code), in stretta relazione al sistema operativo e all'hardware in uso.
- Il codice macchina viene "dato in pasto" ad un linker, il quale si occupa di effettuare il collegamento con eventuali librerie esterne richiamate dal codice sorgente e di produrre, al termine, un file eseguibile (in Windows un .exe).
La Java Virtual Machine è che una sorta di processore in grado di interpretare un particolare codice macchina denominato bytecode (contenuto all'interno dei file .class), che rappresenta il prodotto della compilazione di un file sorgente scritto in linguaggio Java.
Come è noto, il comando per generare un bytecode, a partire da un sorgente java è il seguente:
javac nomeapplicazione.java
Quando, invece, si vuole mandare in esecuzione un'applicazione (che contenga al suo interno l'implementazione del metodo main()
) viene utilizzata la riga di comando:
java nomeapplicazione
Dietro le quinte, ogni volta che si vuole eseguire un programma java, viene creata un'istanza di una virtual machine in grado di interpretare il contenuto di un bytecode ed eseguire, al suo interno, l'applicazione stessa. È importante sapere che ogni applicazione Java in esecuzione dà vita ad una nuova istanza di una virtual machine, che viene dismessa al termine dell'esecuzione stessa.
Per consentire, dunque, ad un programma Java di essere "portabile" su una particolare piattaforma basterà installare una JVM all'interno di una macchina reale (basata sulla piattaforma in questione) e delegare alla JVM stessa il compito di interpretare il bytecode in modo opportuno ed efficace per l'hardware in uso.
In altre parole, la JVM rappresenta una sorta di macchina immaginaria all'interno di una macchina reale:
Naturalmente, i compiti della Java Virtual Machine non si esauriscono con l'interpretazione del bytecode. Infatti, essa è responsabile, tra le altre cose, di alcune funzionalità basilari di Java come la creazione degli oggetti e la "pulizia" della memoria, svolta attraverso la routine di garbage collection.
Anche talune operazioni differenti da un sistema operativo all'altro vengono svolte in modo del tutto trasparente dalla JVM: si pensi, ad esempio, alla gestione dei socket, che può richiedere il coinvolgimento di alcune chiamate di sistema ben precise. Questo pone un ulteriore livello di astrazione allo sviluppatore di applicazioni Java, che non dovrà preoccuparsi, durante la scrittura di un programma, né della piattaforma hardware né dei sistemi operativi su cui il programma stesso andrà eseguito.
L'Architettura della Java Virtual Machine
Andiamo un po' più a fondo e cerchiamo di capire come è organizzata l'architettura di una JVM. Il diagramma seguente ne illustra la struttura interna:
Vediamo, dunque, di analizzare ciascuno dei componenti in gioco.
Il Class Loader
Un programma Java è solitamente organizzato in più classi, ognuna delle quali è responsabile di un particolare compito all'interno dell'applicazione. Come detto, ognuna di queste classi, al termine della compilazione, viene memorizzata in un file con estensione .class.
I file .class non vengono caricati in memoria tutti in una volta, alla partenza dell'applicazione ma, piuttosto, vengono caricati su richiesta ogni volta che sia necessario utilizzare una specifica classe. Il Class Loader è il componente della Java Virtual Machine che si occupa del caricamento in memoria delle classi.
I tipi di dati
Prima di andare ad esaminare i componenti fondamentali dell'area dati, è importante specificare quali sono i tipi di dati utilizzati dalla JVM.
Esistono, come per il linguaggio Java, due tipi di dati su cui opera la Java Virtual Machine: i tipi di dati primitivi e i tipi di dati reference. Lo schema seguente illustra in dettaglio tale suddivisione:
I tipi di dati primitivi sono i seguenti (tra parentesi l'occupazione in memoria):
- byte (1-byte)
- short (2-byte)
- int (4-byte)
- long (8-byte)
- float (4-byte)
- double (8-byte)
- char (2-byte)
Come si può notare, non è presente, tra i tipi primitivi, il tipo tipo boolean (che invece è definito nel linguaggio Java e che nella figura precedente è stato evidenziato in rosso). Per tale scopo, infatti, la JVM utilizza dei valori interi, mentre per array di boolean vengono utilizzati array di byte.
Esiste, inoltre, un altro tipo di dato primitivo numerico noto come returnAddress ("tipo di ritorno") che viene utilizzato dalle istruzioni che indicano il ritorno da una funzione al programma principale. Un returnAddress rappresenta un puntatore ad un opcode (con tale termine si intende, in generale, un codice numerico che rappresenta una particolare istruzione eseguita dal processore) relativo ad un'istruzione della Java Virtual Machine.
I tipi reference utilizzati sono suddivisi in tre categorie:
- Riferimenti a classi
- Riferimenti a interfacce
- Riferimenti ad array
- Esiste anche il riferimento a nessun oggetto, nel qual caso il valore del reference è definito come null.
In generale, per la memorizzazione dei valori associati ai tipi, la JVM ragiona in termini di word ("parola"), che normalmente equivale a quattro byte. In particolare, è importante che una singola word sia sufficientemente grande da poter immagazzinare dati di tipo char, byte, short, int, float, reference e returnAddress. Due word, invece, devono essere in grado di poter contenere valori di tipo long e double.
I componenti basilari dell'area dati
L'area dati della macchina virtuale di Java può essere suddivisa, come raffigurato in figura 1, in 5 componenti principali:
- Un insieme di istruzioni per i bytecode;
- Un gruppo di registri;
- Uno stack;
- Un'area di heap su cui agisce la routine di garbage collection;
- Un'area per la memorizzazione dei metodi.
Istruzioni per i bytecode
Abbiamo ribadito che il bytecode rappresenta il risultato della compilazione di un sorgente Java e che viene memorizzato nei file con estensione .class. Un'istruzione tradotta in bytecode è formata da un opcode di un byte, il quale ha il compito di identificare l'istruzione in questione e da zero o più operandi, di dimensioni variabili, che codificano i parametri richiesti dall'opcode.
I Registri
Su un computer reale i registri costituiscono una parte fondamentale del processore, e rappresentano delle unità di memoria particolarmente efficienti e veloci usate per salvare le informazioni di necessità immediata per il processore stesso. Su una Java Virtual Machine il concetto è assolutamente analogo. In particolare, all'interno di essa, i registri hanno la funzione di memorizzare lo stato della macchina e contenere l'indirizzo del successivo bytecode da eseguire. Ogni registro definito occupa un'ampiezza di una word.
I registri utilizzati in una JVM sono i seguenti:
- pc - acronimo di Program Counter. Tale registro, quando riferito a metodi nativi, contiene informazioni sull'indirizzo dell'istruzione che è in esecuzione. In caso contrario il suo valore è indefinito.
- optop - rappresenta un puntatore al top dello stack degli operandi ed è utilizzato per valutare tutte le espressioni aritmetiche.
- frame - rappresenta un puntatore all'ambiente di esecuzione che fa riferimento al metodo correntemente invocato. Questo registry contiene anche eventuali informazioni di debug.
- vars - rappresenta un puntatore alla prima variabile locale del metodo correntemente in esecuzione.
Lo Stack
Quando viene mandata in esecuzione un'applicazione, viene costruito uno stack associato all'applicazione stessa (in particolare, viene creato uno stack per ogni thread eseguito). La logica dello stack della JVM è identica a quella utilizzata nei linguaggi di programmazione classici. Nello specifico, ogni componente dello stack, denominato frame, contiene lo stato di una singola chiamata ad un metodo. Lo stack è, quindi, in grado di effettuare solo due operazioni : push di un frame o pop di un frame.
Quando un thread invoca un metodo, la virtual machine crea un nuovo frame ed inserisce tale frame sullo stack effettuando un'operazione di push. Grazie a tale operazione, sul frame corrente (quello in cima alla pila) possono, quindi, essere eseguite svariate operazioni come la memorizzazione di parametri e variabili o l'esecuzione di eventuali calcoli, che contribuiranno, a loro volta, a creare uno stack più interno, denominato stack frame (vedasi figura).
Quando il metodo associato ad uno stack frame completa il suo compito (in modo normale o scatenando un'eccezione) lo stack effettua un'operazione di pop ed elimina il corrispondente frame.
L' Heap
Quando un'applicazione Java crea un'istanza di una classe (ovvero crea un oggetto), questa viene allocata in una parte di memoria, denominata heap. E' importante sottolineare il fatto che ogni Java Virtual Machine (e quindi, ogni singola applicazione Java) può utilizzare, al massimo, un solo heap. Questo è vero anche nel caso in cui un'applicazione definisse al suo interno svariati thread separati. Si capisce, allora, l'importanza di una accurata e attenta programmazione quando si fa uso dei thread!
Dunque, un'istruzione del tipo:
Dichiarazione dell'istanza di un oggetto
Object obj = new Object();
crea sull'heap un'area di memoria adibita per la variabile obj. L'operazione contraria, ovvero la deallocazione di un oggetto dalla memoria, è invece totalmente gestita da una routine nota come garbage collection, che esonera il programmatore da un compito delicato che, in linguaggi di programmazione come il C++, era sovente causa di errori inattesi (definiti memory leak).
L'area dei metodi
Compito dell'area dei metodi è quello di contenere i bytecode che gestiscono l'implementazione di tutti i metodi presenti in Java e di contenere le tabelle dei simboli che consentono il link dinamico da associare all'implementazione di un metodo. La dimensione di tale area non è fissa ma viene gestita in modo dinamico dalla virtual machine che ne incrementa o decrementa la dimensione in base alle necessità.
L'Execution Engine
L'Execution Engine rappresenta il fulcro della Java Virtual Machine. Fondamentalmente, esso è rappresentato da un insieme di istruzioni che consentono l'esecuzione di una determinata operazione su una certa piattaforma.
Nessuna costrizione viene fornita nelle specifiche della JVM circa il modello da seguire per la scrittura del codice che si occuperà di "tradurre" i bytecode in linguaggio macchina. Viene, al contrario, lasciata totale libertà nella scelta implementativa.
Il Garbage collector
In realtà, anche se può lasciare sorpresi, la specifica del Garbage Collector non rientra nelle specifiche architetturali della Java Virtual Machine. Viene richiesto soltanto che esista un metodo per gestire la memoria in modo appropriato senza che, però, ne venga imposto l'algoritmo. In linea teorica, un garbage collector potrebbe, quindi, limitarsi a controllare l'ammontare di memoria libera durante l'esecuzione e a bloccare l'esecuzione quando l'heap dovesse riempirsi.
Esistono svariate versioni di routine di garbage collection, più o meno ottimizzate, ma tutte hanno in comune la funzione primaria di ripristinare la memoria allocata da oggetti non più referenziati dall'applicazione in esecuzione. Un altro compito importante che svolge il garbage collector è quello di ridurre la frammentazione dell'heap, spostando gli oggetti in modo da rendere quanto più contigua possibile l'allocazione della memoria.
Riferimenti
Per maggiori approfondimenti sul tema, si consiglia il libro: "The Java Virtual Machine Specification" (Second Edition).