| Index | Next | Prev. |
    2.La Macchina Virtuale Java


2.1 Perché una macchina virtuale
L’équipe che ha sviluppato Java è molto ambiziosa, l’obiettivo finale è quello di rivoluzionare il modo in cui viene scritto e distribuito il software. Gli ideatori di Java hanno iniziato con Internet, dove a loro parere vivrà la maggior parte del software del futuro, la speranza è che il linguaggio Java, con le sue caratteristiche di semplicità e sicurezza, la sua flessibilità e il suo ambiente orientato alla rete, possa diventare il punto di riferimento attorno a cui si raduni questa nuova legione di programmatori.

A questo scopo la Sun Microsystems ha reso gratuito e relativamente libero il progetto Java assieme alla sua tecnologia, dando la possibilità a ognuno di costruire. La Sun Microsystems si è riservata solo i diritti necessari per mantenere e accrescere lo standard.

Seguendo questa filosofia la Sun Microsystems ha messo a disposizione su Internet un’implementazione dimostrativa, una versione alfa, una beta e infine la versione finale del JDK. In parallelo, diverse università, società e singole persone hanno espresso la propria intenzione di duplicare l’ambiente di Java, basandosi sull’interfaccia API aperta creata dalla Sun Microsystems.

Al momento attuale il mondo informatico è da una parte frammentato dalle dozzine di linguaggi di programmazione utilizzati che dividono e separano tutti quanti, dall’altra dall’enorme sviluppo di Internet che porta sempre più in evidenza il problema della condivisione del codice. Java è un linguaggio semplice, utile per la programmazione in Internet, e Internet è in questo momento di moda, e questo dovrebbe aiutare Java nel ruolo di piattaforma centrale.

Per rendere possibile tutto questo, Java deve poter essere eseguito su qualsiasi computer e sistema operativo, oggi come in futuro. Per poter raggiungere questo livello di portabilità, occorre essere molto precisi non solo per quanto riguarda il linguaggio stesso, ma anche per l’ambiente nel quale vive.

Se si fonda il proprio sistema su una qualunque ipotesi in riguardo a cosa c’è "sotto" il sistema di esecuzione, o se si dipende dal computer o dal sistema operativo si è sconfitti in partenza.

Java risolve questo problema inventando un proprio computer astratto ed eseguendo su di esso i propri processi.

Questa macchina virtuale esegue uno speciale gruppo di istruzioni, i bytecode, che consistono in un flusso formattato di byte, ciascuno dei quali possiede una precisa specificazione di ciò che esattamente fa alla macchina virtuale. La macchina virtuale è, inoltre, responsabile di alcune funzionalità principali di Java, quali la creazione di un oggetto e le operazioni di riassetto e pulizia della memoria svolte dalla routine di garbage collection e, per essere in grado di trasferire in modo sicuro i bytecode attraverso Internet usa un preciso formato delle modalità con cui questo flusso di bytecode possa essere inviato da una macchina virtuale all’altra.
  Scopo e Visione
Vale la pena, a questo punto citare l’introduzione alla documentazione della macchina virtuale di Java:

"La specifica della macchina virtuale di Java ha un obbiettivo che è per certi versi simile e per altri dissimile a quello dei documenti per altri linguaggi e macchine astratte. Intende presentare un progetto di una macchina logica astratta, libera dalle distrazioni causate da illogici dettagli di qualsiasi tipo di implementazione. Essa non anticipa una tecnologia di implementazione o un host di implementazione. Allo stesso tempo, fornisce informazioni sufficienti per consentire l’implementazione di un progetto astratto all’interno di una serie di tecnologie.

Tuttavia, l’intento del progetto Java è quello di creare un linguaggio che consenta di scambiare in Internet un "contenuto eseguibile" incorporato in un codice Java compilato. Il progetto non vuole, in modo specifico, che Java sia un linguaggio proprietario, e non vuole essere l’unico fornitore delle implementazioni in linguaggio Java. Al contrario, speriamo di rendere documenti come questo e il codice sorgente della nostra implementazione disponibili gratuitamente per coloro che intendono utilizzarli.

Questo traguardo può essere raggiunto solo se il contenuto eseguibile può essere condiviso in modo affidabile tra diverse implementazioni di Java.

Queste intenzioni impediscono una definizione di macchina virtuale di Java completamente astratta. Piuttosto, gli elementi logici pertinenti del progetto devono essere costruiti in modo sufficientemente concreto per consentire l’interscambio di un codice Java compilato. Tutto ciò non riduce la definizione di macchina virtuale di Java a una descrizione di un’implementazione di Java; gli elementi del progetto che non giocano una parte nell’interscambio del contenuto eseguibile restano astratti. Invece, ci costringe a specificare, oltre al progetto della macchina astratta, un formato di interscambio concreto per il codice Java compilato."

La specifica della macchina virtuale di Java è composta dai seguenti elementi:

  • La sintassi dei bytecode, inclusi gli opcode e le dimensioni degli operandi, i valori, i tipi e i relativi allineamenti.
  • I valori di ciascun identificatore (ad esempio quelli di tipo) contenuto nei bytecode o in strutture di supporto.
  • La disposizione delle strutture di supporto che compaiono nel codice Java compilato (ad esempio il pool di costanti).
  • Il formato dei file .class.
Nonostante questo grado di specificità, esistono tuttora diversi elementi del progetto che rimangono attratti (appositamente), tra cui i seguenti:
  • La disposizione e la gestione delle aree di dati all’esecuzione.
  • Gli algoritmi, le strategie e le limitazioni specifiche relative alla routine di garbage collection.
  • Il compilatore, l’ambiente di sviluppo, le estensioni di esecuzione (oltre alla necessità di generare e leggere bytecode di Java validi).
  • Qualsiasi ottimizzazione eseguita, una volta che i bytecode validi sono stati ricevuti.
Su questi ultimi punti chi implementa la macchina virtuale di Java ha pieno controllo.
2.2 Parti fondamentali della Macchina Virtuale
La macchina virtuale di Java può essere così suddivisa:
  • Un set di istruzioni per i bytecode;
  • Un gruppo di registri;
  • Uno stack;
  • Un’area di heap su cui opera la routine di garbage collection;
  • Un’area per la memorizzazione del metodi.
Queste parti devono essere implementate tramite un interprete, un compilatore di codice binario nativo, o persino attraverso un chip hardware - ma tutte queste componenti logiche, astratte della macchina virtuale devono essere fornite, in qualche forma, in ciascun sistema di Java.

Non è richiesto che le aree di memoria utilizzate dalla macchina virtuale si trovino in un’area particolare, in un certo ordine e nemmeno che utilizzino locazioni di memoria contigue. E’ però richiesta la rappresentasentazione dei valori a 32 bit.

La macchina virtuale, e il relativo codice di supporto, sono spesso indicati come ambiente di esecuzione. Bytecode di Java L’insieme di istruzioni della macchina virtuale è ottimizzato in modo che risulti piccolo e compatto. E’ stato progettato per viaggiare attraverso la rete e, in tal modo, si è sacrificata una maggiore velocità di interpretazione a favore di una riduzione dello spazio.

Come si è detto , il codice sorgente di Java viene compilato in bytecode e memorizzato in file .class. Nel JDK della Sun Microsystems viene fornito lo strumento javac per "compilare" i file sorgenti. javac non è un compilatore tradizionale, perché traduce il codice sorgente in bytecode, un formato che non può essere eseguito direttamente ma deve essere ulteriormente interpretato su ciascun computer. Ma è proprio questo passaggio intermedio che offre la flessibilità e l’estrema portabilità del codice Java.

Un’istruzione di bytecode è composta da un opcode di un byte che serve per identificare l’istruzione in questione e da zero o più operandi, ciascuno dei quali può essere più lungo di un byte.

Quando gli operandi sono più lunghi di un byte, viene memorizzato per primo il byte di ordine superiore (big-endian), questi operandi vengono poi assemblati dal flusso di byte in fase di esecuzione. Ad esempio, un parametro a 16 bit è rappresentato, all’interno del flusso di istruzioni, da due byte, per cui il relativo valore è uguale a:

primo_byte * 256 + secondo_byte.

Il set di istruzioni della macchina virtuale di Java interpreta i dati nelle aree di memoria di esecuzione come appartenenti a un insieme prefissato di tipi: i tipi primitivi, che sono rappresentati da diversi tipi interi con segno (byte, short, int, long), un tipo intero senza segno (char), due tipi in virgola mobile (float e double) e in più il tipo che fa riferimento a un oggetto (puntatore a 32 bit).

I tipi primitivi vengono individuati e gestiti dal compilatore javac, non dall’ambiente di esecuzione di Java. Questi tipi non sono "etichettati" nella memoria, e quindi non possono essere distinti in fase di esecuzione. Opcode differenti sono progettati per gestire ciascun tipo primitivo in modo unico, così quando si sommano due interi, il compilatore genera un opcode iadd, per sommare due numeri in virgola mobile viene generato fadd o dadd. I Registri
I registri della macchina virtuale di Java sono analoghi ai registri che si trovano all’interno di un computer reale, essi contengono lo stato in cui si trova la macchina durante le operazioni, influiscono sul funzionamento di quest’ultima e vengono aggiornati dopo l’esecuzione di ciascun bytecode.

La macchina virtuale Java include i seguenti registri:

  • pc, il program counter, indica il bytecode che sta per essere eseguito.
  • optop, un puntatore al vertice dello stack degli operandi, è utilizzato per valutare tutte le espressioni aritmetiche.
  • frame, un puntatore all’ambiente di esecuzione del metodo corrente in esecuzione.
  • vars, un puntatore alla prima variabile locale del metodo attualmente in esecuzione.
La macchina virtuale Java definisce questi registri con un’ampiezza di 32 bit.

Poiché la macchina virtuale è principalmente basata sullo stack, non utilizza alcun registro per il passaggio o l’acquisizione di argomenti, questa scelta è stata fatta in favore della semplicità e della compattezza dei bytecode e inoltre favorisce l’implementazione della macchina virtuale su architetture con pochi registri.

Lo Stack
Il funzionamenti della macchina virtuale Java è basato sullo stack, esso viene utilizzato per passare i parametri ai bytecode e ai metodi e per ricevere i risultati comunicati da questi ultimi.

Un frame dello stack di Java è simile al frame dello stack di un linguaggio di programmazione convenzionale, esso implementa lo stato associato ad una singola chiamata a un metodo. I frame delle chiamate annidate vengono "impilati" in cima a questo frame.

Ciascun frame dello stack contiene tre aree (che possono anche essere vuote):

  • Le variabile locali per la chiamata al metodo
  • L’ambiente di esecuzione del metodo stesso
  • Lo stack degli operandi
Le dimensioni delle prime due aree vengono stabilite all’inizio della chiamata al metodo, mentre la dimensione dello stack degli operandi varia in relazione ai bytecode che vengono eseguiti all’interno del metodo stesso.

Le variabili locali vengono memorizzate in un array a 32 bit, indirizzato mediante il registro vars. La maggior parte dei tipi occupa solamente una cella dell’array, solamente i tipi long e double ne richiedono due e vengono indirizzati tramite un indice n sapendo che essi occupano le celle (a 32 bit) n e n+1.

L’ambiente di esecuzione contenuto in un frame dello stack serve al mantenimento dello stack stesso. Vi è contenuto un puntatore al frame precedente dello stack, un puntatore alle variabili locali della chiamata al metodo e due puntatori, uno alla "base" e l’altro alla "cima" dello stack corrente.

Lo stack degli operandi, uno stack a 32 bit tipo FIFO, viene utilizzato per memorizzare i parametri e i valori restituiti dalla maggior parte delle istruzioni di bytecode, ad esempio l’opcode iadd attende in input due interi memorizzati in cima allo stack, li estrae dallo stack con pop, ne esegue la somma e ripone (push) il risultato nello stack.

Ciascun tipo di primitiva possiede istruzioni uniche che sanno come estrarre, maneggiare e riporre gli operandi, così per i tipi long e double che occupano due celle dello stack degli operandi esistono opcode speciali che tengono conto di questo fatto.

Non può accadere che tipi presenti nello stack siano incompatibili con le istruzioni che operano su di essi, javac produce bytecode che obbediscono sempre a questa regola. Lo heap Il termine heap si riferisce a quella parte della memoria nella quale vengono allocate le istanze (oggetti) appena creati.

In Java, viene spesso assegnata allo heap un’ampia area di memoria di dimensione fissa, al momento dell’avvio del sistema di esecuzione di Java, ma su sistemi che supportano la memoria virtuale, quest’area può espandersi a secondo delle necessità, quasi senza limiti. Poiché Java rimuove automaticamente gli oggetti che non servono più (garbage collection), i programmatori non devono ( e di fatto non possono) liberare la memoria allocata per un oggetto, quando quest’ultimo ha esaurito la sua funzione.

Gli oggetti in Java vengono individuati tramite indirizzamento indiretto in fase di esecuzione, per mezzo di handle. Un handle rappresenta una sorta di puntatore all’area di heap.
  L’area dei metodi Analogamente alle aree di codice compilato che si trovano negli ambienti dei linguaggi dei linguaggi di programmazione convenzionali, l’area dei metodi contiene i bytecode di Java che implementano praticamente tutti i metodi presenti nel sistema Java. L’area dei metodi contiene inoltre le tabelle dei simboli necessarie per il link dinamico, oltre a informazioni di debug aggiuntive, o ad ambienti di sviluppo da associare all’implementazione di qualsiasi metodo.

Poiché i bytecode vengono memorizzati come flusso di byte, l’area dei metodi è allineata per byte (le altre aree sono allineate a parole di 32 bit).
  Il pool di costanti Nell’area di heap, ciascuna classe possiede un pool di costanti ad essa associate. Queste costanti sono solitamente create dal compilatore javac e codificano tutti i nomi (di variabili, metodi e così via) utilizzati da ciascun metodo in una determinata classe. La classe contiene il numero di costanti presenti e un offset che specifica quanto dista, all’interno della descrizione della classe stessa, il punto in cui inizia l’array di costanti. Il tipo delle costanti viene assegnato utilizzando byte codificati in modo speciale e, quando compaiono all’interno del file .class, possiedono un formato ben definito. 2.3 Limiti della macchina virtuale Java La macchina virtuale Java, così com’è attualmente definita, pone alcune limitazioni ai programmi di Java.

  • Puntatori a 32 bit implicano che la macchina virtuale possa indirizzare fino a 4 Gigabyte di memoria.
  • Indici a 16 bit senza segno nelle tabelle delle variabili locali, per cui risulta limitata a 64K la dimensione dell’implementazione del bytecode di un metodo.
  • Indici a 16 bit senza segno nel pool di costanti, per cui risulta limitato a 64K il numero di costanti per ciascuna classe.
In più, l’implementazione effettuata dalla Sun Microsystems della macchina virtuale utilizza i cosiddetti bytecode _quick, che limitano ulteriormente il sistema. Offset a 8 bit senza segno, all’interno degli oggetti possono limitare il numero di metodi in una classe a 256, mentre il conteggio a 8 bit senza segno degli argomenti limita la dimensione dell’elenco degli argomenti a 255 parole di 32 bit ciascuna (127 per i tipi long e double).
2.4 I Bytecode
Uno dei compiti principali della macchina virtuale è l’esecuzione veloce ed efficiente dei bytecode, qui la velocità diventa di fondamentale importanza, il sistema di esecuzione deve utilizzare quante più ottimizzazioni possibili per velocizzare l’esecuzione dei bytecode. L’interprete di bytecode esamina, uno alla volta, ciascun byte di opcode (bytecode) all’interno di un flusso di bytecode, ed esegue un’unica operazione per ciascuno di essi.

L’interprete funziona come la CPU di un computer, la quale esamina la memoria alla ricerca delle istruzioni da eseguire nello stesso identico modo. E’ la CPU della macchina virtuale Java. Il ciclo più interno , che smaltisce un solo bytecode a ogni iterazione, è la parte più critica e difficile da ottimizzare. L’équipe che ha progettato Java, facendo tesoro dei risultati ottenuti da alcune menti informatiche che per oltre vent’anni si sono dedicati alla soluzioni di problemi di questo tipo, è riuscita a realizzare un interprete di bytecode con un ciclo interno estremamente veloce. Alcuni test realizzati dalla Sun Microsystems mostrano che l’interprete di bytecode è solo 30 volte più lento di una vera CPU hardware che esegue gli stessi bytecode.

Altre soluzioni sta sperimentando la Sun Microsystems per velocizzare l’esecuzione dei bytecode, dai bytecode _quick al compilatore "just in time" (compilatore in tempo reale).

I bytecode _quick

    Per rendere più veloce l’nterprete di bytecode si usa una variante ai bytecode standard: il suffisso _quick; non fa parte ufficialmente delle specifiche della macchina virtuale Java è sono invisibili al di fuori dell’implementazione della stessa. Tuttavia, hanno dimostrato di produrre un’efficiente ottimizzazione.

    E’ necessario sapere che javac tuttora genera solo bytecode non di tipo _quick è che tutti i bytecode che possiedono una variante _quick fanno riferimento al pool di costanti.

    Quando viene attivata l’ottimizzazione _quick , ciascun bytecode non _quick (ma che può avere la variante _quick) risolve l’elemento specificato nel pool di costanti, invia un messaggio di errore se l’elemento del pool di costanti non può essere risolto per qualche ragione, trasforma se stesso nella relativa variante _quick e, infine esegue l’operazione che gli compete.

    Questa sequenza di operazioni è identica a quella di un bytecode che non ha la variante _quick, fatta eccezione per il passaggio in cui il bytecode trasforma se stesso nella variante _quick. La variante _quick di un bytecode presuppone che l’elemento nel pool di costanti sia già stato risolto, e che questa risoluzione non abbia prodotto alcun errore. Quindi mentre i propri bytecode vengono interpretati, essi vengono automaticamente resi sempre più veloci.
     

Il compilatore "in tempo reale" L’interprete Java per ciascun bytecode che interpreta, elabora una sequenza di istruzioni in codice nativo per la CPU hardware che sta utilizzando. Salvando una copia di ciascuna istruzione binaria così come viene eseguita, l’interprete può ottenere un registro di esecuzione del codice binario che esso stesso ha eseguito per interpretare un certo bytecode. L’interprete può quindi facilmente mantenere una copia della serie di bytecode da lui stesso eseguiti per interpretare un certo metodo.

La volta successiva in cui viene eseguito quel metodo, l’interprete può eseguire direttamente il codice binario memorizzato in precedenza. Se viene modificato in un modo qualsiasi una classe o un metodo, quest’ultimo intraprende un percorso differente all’interno dell’interprete e deve essere registrato di nuovo. La cache del codice nativo di un certo metodo deve essere disattivata ogni volta che il metodo risulta cambiato, e l’interprete deve pagare un piccolo costo ogni volta che un metodo viene eseguito per la prima volta. In ogni caso, questo piccolo costo è ampiamente ricompensato dal sorprendente aumento di velocità che si ottiene. Una versione sperimentale di questa tecnologia, che la Sun Microsystems ha chiamato Just in Time (JIT), ha evidenziato che i programmi Java che la utilizzano hanno prestazioni di velocità pari a quelle dei programmi C compilati. 2.5 I file .class I file .class sono utilizzati per contenere le versioni compilate delle classi e delle interfacce di Java. Gli interpreti conformi a Java devono essere in grado di gestire tutti i file .class che si attengono alle specifiche seguenti.

Un file .class di Java è composto da un flusso di byte da 8 bit. Tutte le quantità a 16 e a 32 bit vengono costruite leggendo, rispettivamente, due o quattro byte. I byte vengono uniti assieme con il bye di ordine superiore al primo posto.

Il formato di un file .class si presenta come una struttura, dove ciascun campo ha una dimensione variabile. Esso ha un formato di questo tipo:

AttributoGenerico_info {

u2 nome_attributo ;

u4 lunghezza_attributo ;

u1 info[lunghezza_attributo] ;

}

I tipi u1, u2, u4, rappresentano rispettivamente quantità di uno, due o quattro byte senza segno. nome_attibuto è un indice a 16 bit nel pool di costanti della classe; il nome dell’attributo viene fornito in formato stringa dal valore di costant_pool [nome_attributo]. Il campo lunghezza_attributo fornisce la lunghezza in byte dell’informazione che segue. Questa lunghezza non include i quattro byte necessari per memorizzare nome_attributo e lunghezza_attributo. E’ prevista la possibilità di aggiungere altri attributi. E’ previsto che i lettori di file .class passino oltre e ignorino l’informazione di un attributo che non comprendono.


| Index | Next | Prev. |