Ottimizzazione delle prestazioni di JVM, parte 2: compilatori

I compilatori Java sono al centro della scena in questo secondo articolo della serie di ottimizzazione delle prestazioni JVM. Eva Andreasson introduce le diverse razze di compilatori e confronta i risultati delle prestazioni dalla compilazione client, server e a livelli. Conclude con una panoramica delle ottimizzazioni JVM comuni come l'eliminazione del codice morto, l'inlining e l'ottimizzazione del loop.

Un compilatore Java è la fonte della famosa indipendenza dalla piattaforma di Java. Uno sviluppatore di software scrive la migliore applicazione Java possibile, quindi il compilatore lavora dietro le quinte per produrre codice di esecuzione efficiente e ben performante per la piattaforma di destinazione prevista. Diversi tipi di compilatori soddisfano varie esigenze applicative, producendo così risultati prestazionali specifici desiderati. Più comprendi i compilatori, in termini di come funzionano e quali tipi sono disponibili, più sarai in grado di ottimizzare le prestazioni delle applicazioni Java.

Questo secondo articolo della serie sull'ottimizzazione delle prestazioni di JVM evidenzia e spiega le differenze tra i vari compilatori di macchine virtuali Java. Discuterò anche di alcune ottimizzazioni comuni utilizzate dai compilatori Just-In-Time (JIT) per Java. (Vedere "Ottimizzazione delle prestazioni JVM, Parte 1" per una panoramica JVM e un'introduzione alla serie.)

Cos'è un compilatore?

In parole semplici, un compilatore accetta un linguaggio di programmazione come input e produce un linguaggio eseguibile come output. Un compilatore comunemente noto è javac, che è incluso in tutti i JDK (Java Development Kit) standard. javacprende il codice Java come input e lo traduce in bytecode, il linguaggio eseguibile per una JVM. Il bytecode viene memorizzato in file .class che vengono caricati nel runtime Java quando viene avviato il processo Java.

Il bytecode non può essere letto dalle CPU standard e deve essere tradotto in un linguaggio di istruzioni comprensibile dalla piattaforma di esecuzione sottostante. Il componente nella JVM che è responsabile della traduzione del bytecode in istruzioni eseguibili della piattaforma è ancora un altro compilatore. Alcuni compilatori JVM gestiscono diversi livelli di traduzione; per esempio, un compilatore potrebbe creare vari livelli di rappresentazione intermedia del bytecode prima che si trasformi in vere e proprie istruzioni macchina, il passaggio finale della traduzione.

Bytecode e JVM

Se vuoi saperne di più sul bytecode e sulla JVM, vedi "Nozioni di base sul bytecode" (Bill Venners, JavaWorld).

Da una prospettiva indipendente dalla piattaforma, vogliamo mantenere il codice indipendente dalla piattaforma il più possibile, in modo che l'ultimo livello di traduzione, dalla rappresentazione più bassa al codice macchina effettivo, sia il passaggio che blocca l'esecuzione all'architettura del processore di una piattaforma specifica . Il livello più alto di separazione è tra compilatori statici e dinamici. Da lì, abbiamo opzioni a seconda dell'ambiente di esecuzione a cui miriamo, dei risultati di prestazioni che desideriamo e delle limitazioni delle risorse che dobbiamo soddisfare. Ho discusso brevemente dei compilatori statici e dinamici nella Parte 1 di questa serie. Nelle sezioni seguenti spiegherò un po 'di più.

Compilazione statica vs dinamica

Un esempio di compilatore statico è il già citato javac. Con i compilatori statici il codice di input viene interpretato una volta e l'eseguibile di output è nella forma che verrà utilizzata quando il programma viene eseguito. A meno che non si apportino modifiche alla fonte originale e si ricompili il codice (utilizzando il compilatore), l'output darà sempre lo stesso risultato; questo perché l'input è un input statico e il compilatore è un compilatore statico.

In una compilazione statica, il seguente codice Java

static int add7( int x ) { return x+7; }

risulterebbe in qualcosa di simile a questo bytecode:

iload0 bipush 7 iadd ireturn

Un compilatore dinamico traduce da una lingua all'altra in modo dinamico, il che significa che accade mentre il codice viene eseguito - durante il runtime! La compilazione e l'ottimizzazione dinamiche offrono ai runtime il vantaggio di essere in grado di adattarsi ai cambiamenti nel carico dell'applicazione. I compilatori dinamici sono molto adatti ai runtime Java, che comunemente vengono eseguiti in ambienti imprevedibili e in continua evoluzione. La maggior parte delle JVM utilizza un compilatore dinamico come un compilatore Just-In-Time (JIT). Il problema è che i compilatori dinamici e l'ottimizzazione del codice a volte richiedono strutture dati, thread e risorse della CPU aggiuntivi. Più avanzata è l'ottimizzazione o l'analisi del contesto del bytecode, più risorse vengono consumate dalla compilazione. Nella maggior parte degli ambienti il ​​sovraccarico è ancora molto ridotto rispetto al significativo aumento delle prestazioni del codice di output.

Varietà JVM e indipendenza dalla piattaforma Java

Tutte le implementazioni JVM hanno una cosa in comune, che è il loro tentativo di tradurre il bytecode dell'applicazione in istruzioni macchina. Alcune JVM interpretano il codice dell'applicazione al caricamento e utilizzano i contatori delle prestazioni per concentrarsi sul codice "caldo". Alcune JVM saltano l'interpretazione e si basano solo sulla compilazione. L'intensità delle risorse della compilazione può essere un successo maggiore (soprattutto per le applicazioni lato client) ma consente anche ottimizzazioni più avanzate. Vedere Risorse per ulteriori informazioni.

Se sei un principiante di Java, le complessità delle JVM saranno molte da capire. La buona notizia è che non ne hai davvero bisogno! La JVM gestisce la compilazione e l'ottimizzazione del codice, quindi non devi preoccuparti delle istruzioni della macchina e del modo ottimale di scrivere il codice dell'applicazione per un'architettura della piattaforma sottostante.

Dal bytecode Java all'esecuzione

Dopo aver compilato il codice Java in bytecode, i passaggi successivi consistono nel tradurre le istruzioni del bytecode in codice macchina. Questo può essere fatto da un interprete o da un compilatore.

Interpretazione

La forma più semplice di compilazione del bytecode è chiamata interpretazione. Un interprete cerca semplicemente le istruzioni hardware per ogni istruzione bytecode e le invia per essere eseguite dalla CPU.

Potresti pensare a un'interpretazione simile all'uso di un dizionario: per una parola specifica (istruzione bytecode) c'è una traduzione esatta (istruzione di codice macchina). Poiché l'interprete legge ed esegue immediatamente un'istruzione bytecode alla volta, non è possibile eseguire l'ottimizzazione su un set di istruzioni. Un interprete deve anche eseguire l'interpretazione ogni volta che viene richiamato un bytecode, il che lo rende abbastanza lento. L'interpretazione è un modo accurato di eseguire codice, ma il set di istruzioni di output non ottimizzato probabilmente non sarà la sequenza con le prestazioni più elevate per il processore della piattaforma di destinazione.

Compilazione

Un compilatore d'altra parte carica l'intero codice da eseguire nel runtime. Poiché traduce il bytecode, ha la capacità di esaminare l'intero o parziale contesto di runtime e prendere decisioni su come tradurre effettivamente il codice. Le sue decisioni si basano sull'analisi dei grafici del codice come diversi rami di esecuzione delle istruzioni e dati del contesto di runtime.

Quando una sequenza bytecode viene tradotta in un set di istruzioni del codice macchina e le ottimizzazioni possono essere eseguite su questo set di istruzioni, il set di istruzioni sostitutivo (ad esempio, la sequenza ottimizzata) viene memorizzato in una struttura chiamata cache del codice . La volta successiva che viene eseguito il bytecode, il codice ottimizzato in precedenza può essere immediatamente posizionato nella cache del codice e utilizzato per l'esecuzione. In alcuni casi un contatore delle prestazioni potrebbe attivarsi e sovrascrivere l'ottimizzazione precedente, nel qual caso il compilatore eseguirà una nuova sequenza di ottimizzazione. Il vantaggio di una cache del codice è che il set di istruzioni risultante può essere eseguito contemporaneamente, senza bisogno di ricerche interpretative o compilazione! Ciò accelera i tempi di esecuzione, soprattutto per le applicazioni Java in cui gli stessi metodi vengono chiamati più volte.

Ottimizzazione

Insieme alla compilazione dinamica viene offerta l'opportunità di inserire contatori delle prestazioni. Il compilatore potrebbe, ad esempio, inserire un contatore delle prestazionicontare ogni volta che è stato chiamato un blocco bytecode (ad esempio, corrispondente a un metodo specifico). I compilatori utilizzano i dati su quanto "caldo" è un dato bytecode per determinare dove nel codice le ottimizzazioni avranno un impatto migliore sull'applicazione in esecuzione. I dati di profilatura del runtime consentono al compilatore di prendere al volo un ricco set di decisioni di ottimizzazione del codice, migliorando ulteriormente le prestazioni di esecuzione del codice. Man mano che i dati di profilazione del codice diventano disponibili, possono essere utilizzati per prendere decisioni di ottimizzazione aggiuntive e migliori, come: come sequenziare meglio le istruzioni nel linguaggio compilato, se sostituire un set di istruzioni con set più efficienti, o anche se eliminare le operazioni ridondanti.

Esempio

Considera il codice Java:

static int add7( int x ) { return x+7; }

Questo potrebbe essere compilato staticamente dal javacbytecode:

iload0 bipush 7 iadd ireturn

Quando il metodo viene chiamato, il blocco bytecode verrà compilato dinamicamente in istruzioni macchina. Quando un contatore delle prestazioni (se presente per il blocco di codice) raggiunge una soglia, potrebbe anche essere ottimizzato. Il risultato finale potrebbe essere simile al seguente set di istruzioni della macchina per una data piattaforma di esecuzione:

lea rax,[rdx+7] ret

Diversi compilatori per diverse applicazioni

Diverse applicazioni Java hanno esigenze diverse. Le applicazioni lato server aziendali di lunga durata potrebbero consentire maggiori ottimizzazioni, mentre le applicazioni lato client più piccole potrebbero richiedere un'esecuzione rapida con un consumo minimo di risorse. Consideriamo tre diverse impostazioni del compilatore e i rispettivi pro e contro.

Compilatori lato client

Un noto compilatore di ottimizzazione è C1, il compilatore abilitato tramite l' -clientopzione di avvio JVM. Come suggerisce il nome di avvio, C1 è un compilatore lato client. È progettato per le applicazioni lato client che hanno meno risorse disponibili e sono, in molti casi, sensibili al tempo di avvio dell'applicazione. C1 utilizza i contatori delle prestazioni per la profilatura del codice per consentire ottimizzazioni semplici e relativamente non invasive.

Compilatori lato server

Per applicazioni a esecuzione prolungata come le applicazioni Java aziendali lato server, un compilatore lato client potrebbe non essere sufficiente. Al suo posto potrebbe essere utilizzato un compilatore lato server come C2. C2 viene solitamente abilitato aggiungendo l'opzione di avvio JVM -serveralla riga di comando di avvio. Poiché si prevede che la maggior parte dei programmi lato server funzionerà a lungo, abilitare C2 significa che sarai in grado di raccogliere più dati di profilatura di quanto faresti con un'applicazione client leggera di breve durata. Così sarai in grado di applicare tecniche e algoritmi di ottimizzazione più avanzati.

Suggerimento: riscalda il compilatore lato server

Per le distribuzioni lato server potrebbe essere necessario del tempo prima che il compilatore abbia ottimizzato le parti "calde" iniziali del codice, quindi le distribuzioni lato server richiedono spesso una fase di "riscaldamento". Prima di eseguire qualsiasi tipo di misurazione delle prestazioni su una distribuzione lato server, assicurati che l'applicazione abbia raggiunto lo stato stabile! Lasciare al compilatore abbastanza tempo per compilare correttamente funzionerà a tuo vantaggio! (Vedi l'articolo di JavaWorld "Guarda il tuo compilatore HotSpot andare" per ulteriori informazioni sul riscaldamento del compilatore e sui meccanismi di profilazione.)

Un compilatore server tiene conto di più dati di profilatura rispetto a un compilatore lato client e consente un'analisi dei rami più complessa, il che significa che prenderà in considerazione quale percorso di ottimizzazione sarebbe più vantaggioso. Avere più dati di profilazione disponibili produce risultati migliori dell'applicazione. Ovviamente, fare profiling e analisi più estesi richiede di spendere più risorse per il compilatore. Una JVM con C2 abilitato utilizzerà più thread e più cicli di CPU, richiederà una cache del codice più grande e così via.

Compilazione a più livelli

Compilazione a più livellicombina la compilazione lato client e lato server. Azul ha reso disponibile per la prima volta la compilazione a più livelli nella sua JVM Zing. Più recentemente (a partire da Java SE 7) è stato adottato da Oracle Java Hotspot JVM. La compilazione a livelli sfrutta i vantaggi del compilatore client e server nella tua JVM. Il compilatore client è più attivo durante l'avvio dell'applicazione e gestisce le ottimizzazioni attivate da soglie di contatore delle prestazioni inferiori. Il compilatore lato client inserisce anche i contatori delle prestazioni e prepara set di istruzioni per ottimizzazioni più avanzate, che verranno affrontate in una fase successiva dal compilatore lato server. La compilazione a livelli è un modo di profilare molto efficiente in termini di risorse perché il compilatore è in grado di raccogliere dati durante l'attività del compilatore a basso impatto, che possono essere utilizzati per ottimizzazioni più avanzate in seguito.Questo approccio fornisce anche più informazioni di quelle ottenibili utilizzando solo i contatori del profilo di codice interpretato.

Lo schema grafico nella Figura 1 illustra le differenze di prestazioni tra la pura interpretazione, la compilazione lato client, lato server e a livelli. L'asse X mostra il tempo di esecuzione (unità di tempo) e le prestazioni dell'asse Y (operazioni / unità di tempo).

Figura 1. Differenze di prestazioni tra i compilatori (fare clic per ingrandire)

Rispetto al codice puramente interpretato, l'utilizzo di un compilatore lato client porta a prestazioni di esecuzione da 5 a 10 volte migliori (in operazioni / s), migliorando così le prestazioni dell'applicazione. La variazione del guadagno dipende ovviamente dall'efficienza del compilatore, dalle ottimizzazioni abilitate o implementate e (in misura minore) da quanto è ben progettata l'applicazione rispetto alla piattaforma di esecuzione di destinazione. Quest'ultimo è davvero qualcosa di cui uno sviluppatore Java non dovrebbe mai preoccuparsi, però.

Rispetto a un compilatore lato client, un compilatore lato server in genere aumenta le prestazioni del codice dal 30 al 50 percento misurabile. Nella maggior parte dei casi, il miglioramento delle prestazioni bilancia il costo delle risorse aggiuntive.