Java Virtual Machine - Il compilatore JIT

In questo capitolo, impareremo il compilatore JIT e la differenza tra linguaggi compilati e interpretati.

Linguaggi compilati e interpretati

Linguaggi come C, C ++ e FORTRAN sono linguaggi compilati. Il loro codice viene fornito come codice binario mirato alla macchina sottostante. Ciò significa che il codice di alto livello viene compilato in codice binario contemporaneamente da un compilatore statico scritto specificamente per l'architettura sottostante. Il file binario prodotto non verrà eseguito su altre architetture.

D'altra parte, linguaggi interpretati come Python e Perl possono essere eseguiti su qualsiasi macchina, purché abbiano un interprete valido. Passa riga per riga al codice di alto livello, convertendolo in codice binario.

Il codice interpretato è in genere più lento del codice compilato. Ad esempio, considera un loop. Un interpretato convertirà il codice corrispondente per ogni iterazione del ciclo. D'altra parte, un codice compilato renderà la traduzione solo una. Inoltre, poiché gli interpreti vedono solo una riga alla volta, non sono in grado di eseguire alcun codice significativo come la modifica dell'ordine di esecuzione di istruzioni come i compilatori.

Esamineremo un esempio di tale ottimizzazione di seguito:

Adding two numbers stored in memory. Poiché l'accesso alla memoria può consumare più cicli della CPU, un buon compilatore fornirà istruzioni per recuperare i dati dalla memoria ed eseguire l'aggiunta solo quando i dati sono disponibili. Non aspetterà e nel frattempo eseguirà altre istruzioni. D'altra parte, nessuna tale ottimizzazione sarebbe possibile durante l'interpretazione poiché l'interprete non è a conoscenza dell'intero codice in un dato momento.

Ma poi, le lingue interpretate possono essere eseguite su qualsiasi macchina che abbia un valido interprete di quella lingua.

Java è compilato o interpretato?

Java ha cercato di trovare una via di mezzo. Poiché la JVM si trova tra il compilatore javac e l'hardware sottostante, il compilatore javac (o qualsiasi altro compilatore) compila il codice Java nel Bytecode, che è compreso da una JVM specifica della piattaforma. La JVM quindi compila il Bytecode in binario utilizzando la compilazione JIT (Just-in-time), mentre il codice viene eseguito.

Hotspot

In un programma tipico, c'è solo una piccola sezione di codice che viene eseguita frequentemente e, spesso, è questo codice che influisce in modo significativo sulle prestazioni dell'intera applicazione. Tali sezioni di codice vengono chiamateHotSpots.

Se una sezione di codice viene eseguita una sola volta, la sua compilazione sarebbe uno spreco di fatica e sarebbe invece più veloce interpretare il Bytecode. Ma se la sezione è una sezione calda e viene eseguita più volte, la JVM la compila invece. Ad esempio, se un metodo viene chiamato più volte, i cicli aggiuntivi necessari per compilare il codice sarebbero compensati dal binario più veloce generato.

Inoltre, più la JVM esegue un particolare metodo o un ciclo, più informazioni raccoglie per effettuare varie ottimizzazioni in modo da generare un binario più veloce.

Consideriamo il seguente codice:

for(int i = 0 ; I <= 100; i++) {
   System.out.println(obj1.equals(obj2)); //two objects
}

Se questo codice viene interpretato, l'interprete dedurrebbe per ogni iterazione che le classi di obj1. Questo perché ogni classe in Java ha un metodo .equals (), che viene esteso dalla classe Object e può essere sovrascritto. Quindi, anche se obj1 è una stringa per ogni iterazione, la deduzione verrà comunque eseguita.

D'altra parte, ciò che accadrebbe effettivamente è che la JVM noterebbe che per ogni iterazione, obj1 è della classe String e quindi genererebbe direttamente il codice corrispondente al metodo .equals () della classe String. Pertanto, non saranno richieste ricerche e il codice compilato verrà eseguito più velocemente.

Questo tipo di comportamento è possibile solo quando la JVM sa come si comporta il codice. Pertanto, attende prima di compilare alcune sezioni del codice.

Di seguito è riportato un altro esempio:

int sum = 7;
for(int i = 0 ; i <= 100; i++) {
   sum += i;
}

Un interprete, per ogni ciclo, preleva il valore di "sum" dalla memoria, aggiunge "I" ad esso e lo salva di nuovo in memoria. L'accesso alla memoria è un'operazione costosa e in genere richiede più cicli della CPU. Poiché questo codice viene eseguito più volte, è un HotSpot. Il JIT compilerà questo codice e realizzerà la seguente ottimizzazione.

Una copia locale di "sum" verrebbe memorizzata in un registro, specifico per un particolare thread. Tutte le operazioni verrebbero eseguite sul valore nel registro e al termine del ciclo, il valore verrebbe riscritto in memoria.

E se anche altri thread accedessero alla variabile? Poiché gli aggiornamenti vengono eseguiti su una copia locale della variabile da un altro thread, vedrebbero un valore non aggiornato. In questi casi è necessaria la sincronizzazione dei thread. Una primitiva di sincronizzazione molto semplice sarebbe dichiarare "sum" come volatile. Ora, prima di accedere a una variabile, un thread scarica i suoi registri locali e recupera il valore dalla memoria. Dopo l'accesso, il valore viene immediatamente scritto in memoria.

Di seguito sono riportate alcune ottimizzazioni generali eseguite dai compilatori JIT:

  • Metodo inlining
  • Eliminazione del codice guasto
  • Euristica per l'ottimizzazione dei siti di chiamata
  • Piegatura costante