|
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.
"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:
2.2 Parti fondamentali della Macchina Virtuale La macchina virtuale di Java può essere così suddivisa:
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
La macchina virtuale Java include i seguenti registri:
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
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 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.
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).
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
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.
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.
|