Java Virtual Machine - Guida rapida

La JVM è una specifica e può avere diverse implementazioni, purché aderiscano alle specifiche. Le specifiche possono essere trovate nel link sottostante -https://docs.oracle.com

Oracle ha la sua implementazione JVM (chiamata HotSpot JVM), IBM ha la sua (la J9 JVM, per esempio).

Le operazioni definite all'interno della specifica sono fornite di seguito (fonte: Oracle JVM Specs, vedere il collegamento sopra) -

  • Il formato del file "classe"
  • Tipi di dati
  • Tipi e valori primitivi
  • Tipi e valori di riferimento
  • Aree dati di runtime
  • Frames
  • Rappresentazione di oggetti
  • Aritmetica in virgola mobile
  • Metodi speciali
  • Exceptions
  • Riepilogo del set di istruzioni
  • Librerie di classi
  • Progettazione pubblica, realizzazione privata

La JVM è una macchina virtuale, un computer astratto che ha il proprio ISA, la propria memoria, lo stack, l'heap, ecc. Funziona sul sistema operativo host e richiede le sue risorse.

L'architettura dell'HotSpot JVM 3 è mostrata di seguito:

Il motore di esecuzione comprende il garbage collector e il compilatore JIT. La JVM è disponibile in due versioni:client and server. Entrambi condividono lo stesso codice di runtime ma differiscono per ciò che viene utilizzato JIT. Ne sapremo di più in seguito. L'utente può controllare che sapore usare specificando le bandiere JVM -client o -server . La JVM del server è stata progettata per applicazioni Java a lunga esecuzione sui server.

La JVM è disponibile nelle versioni 32b e 64b. L'utente può specificare quale versione utilizzare utilizzando -d32 o -d64 negli argomenti della VM. La versione 32b poteva indirizzare solo fino a 4G di memoria. Con applicazioni critiche che mantengono grandi set di dati in memoria, la versione 64b soddisfa tale esigenza.

La JVM gestisce il processo di caricamento, collegamento e inizializzazione di classi e interfacce in modo dinamico. Durante il processo di caricamento, il fileJVM finds the binary representation of a class and creates it.

Durante il processo di collegamento, il loaded classes are combined into the run-time state of the JVM so that they can be executed during the initialization phase. La JVM utilizza fondamentalmente la tabella dei simboli memorizzata nel pool di costanti di runtime per il processo di collegamento. L'inizializzazione consiste in realtàexecuting the linked classes.

Tipi di caricatori

Il BootStrapil programma di caricamento classi si trova in cima alla gerarchia del programma di caricamento classi. Carica le classi JDK standard nella directory lib di JRE .

Il Extension class loader si trova al centro della gerarchia del class loader ed è il figlio immediato del class loader bootstrap e carica le classi nella directory lib \ ext di JRE.

Il Applicationil programma di caricamento classi si trova in fondo alla gerarchia del programma di caricamento classi ed è l'elemento secondario immediato del programma di caricamento classi dell'applicazione. Carica i barattoli e le classi specificati daCLASSPATH ENV variabile.

Collegamento

Il processo di collegamento consiste nei seguenti tre passaggi:

Verification- Questo viene fatto dal verificatore Bytecode per garantire che i file .class generati (il Bytecode) siano validi. In caso contrario, viene generato un errore e il processo di collegamento si interrompe.

Preparation - La memoria viene allocata a tutte le variabili statiche di una classe e vengono inizializzate con i valori predefiniti.

Resolution- Tutti i riferimenti simbolici alla memoria vengono sostituiti con i riferimenti originali. A tale scopo, viene utilizzata la tabella dei simboli nella memoria delle costanti di runtime dell'area del metodo della classe.

Inizializzazione

Questa è la fase finale del processo di caricamento delle classi. Alle variabili statiche vengono assegnati valori originali e vengono eseguiti blocchi statici.

La specifica JVM definisce alcune aree di dati di runtime necessarie durante l'esecuzione del programma. Alcuni di essi vengono creati durante l'avvio della JVM. Altri sono locali nei thread e vengono creati solo quando viene creato un thread (e distrutti quando il thread viene distrutto). Questi sono elencati di seguito:

Registro PC (contatore programma)

È locale per ogni thread e contiene l'indirizzo dell'istruzione JVM che il thread sta attualmente eseguendo.

Pila

È locale per ogni thread e memorizza parametri, variabili locali e indirizzi di ritorno durante le chiamate al metodo. Un errore StackOverflow può verificarsi se un thread richiede più spazio dello stack di quanto consentito. Se lo stack è espandibile dinamicamente, può comunque generare OutOfMemoryError.

Mucchio

È condiviso tra tutti i thread e contiene oggetti, metadati delle classi, array, ecc. Che vengono creati durante il runtime. Viene creato all'avvio della JVM e viene distrutto quando la JVM si arresta. È possibile controllare la quantità di heap richiesta dalla JVM dal sistema operativo utilizzando determinati flag (ne parleremo più avanti). Bisogna fare attenzione a non richiedere troppo meno o troppo della memoria, poiché ha importanti implicazioni sulle prestazioni. Inoltre, il GC gestisce questo spazio e rimuove continuamente gli oggetti morti per liberare lo spazio.

Area del metodo

Questa area di runtime è comune a tutti i thread e viene creata all'avvio della JVM. Memorizza strutture per classe come il pool di costanti (ne parleremo più avanti), il codice per costruttori e metodi, dati di metodo, ecc. JLS non specifica se quest'area deve essere garbage collection e quindi JVM può scegliere di ignorare GC. Inoltre, questo può o meno espandersi in base alle esigenze dell'applicazione. Il JLS non impone nulla al riguardo.

Pool costante di tempo di esecuzione

La JVM mantiene una struttura dati per classe / per tipo che funge da tabella dei simboli (uno dei suoi numerosi ruoli) mentre collega le classi caricate.

Stack di metodi nativi

Quando un thread invoca un metodo nativo, entra in un nuovo mondo in cui le strutture e le restrizioni di sicurezza della Java virtual machine non ne ostacolano più la libertà. Un metodo nativo può probabilmente accedere alle aree dati di runtime della macchina virtuale (dipende dall'interfaccia del metodo nativo), ma può anche fare qualsiasi altra cosa desideri.

Raccolta dei rifiuti

La JVM gestisce l'intero ciclo di vita degli oggetti in Java. Una volta creato un oggetto, lo sviluppatore non deve più preoccuparsene. Nel caso in cui l'oggetto diventi morto (ovvero, non vi è più alcun riferimento ad esso), viene espulso dall'heap dal GC utilizzando uno dei tanti algoritmi: GC seriale, CMS, G1, ecc.

Durante il processo GC, gli oggetti vengono spostati in memoria. Quindi, quegli oggetti non sono utilizzabili mentre il processo è in corso. L'intera applicazione deve essere interrotta per la durata del processo. Tali pause sono chiamate pause "stop-the-world" e sono un enorme sovraccarico. Gli algoritmi GC mirano principalmente a ridurre questo tempo. Discuteremo questo in dettaglio nei capitoli seguenti.

Grazie al GC, le perdite di memoria sono molto rare in Java, ma possono verificarsi. Vedremo nei capitoli successivi come creare una perdita di memoria in Java.

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

JVM supporta cinque livelli di compilazione:

  • Interpreter
  • C1 con ottimizzazione completa (senza profilazione)
  • C1 con invocazione e contatori back-edge (profilatura leggera)
  • C1 con profilatura completa
  • C2 (utilizza i dati di profilazione dei passaggi precedenti)

Usa -Xint se vuoi disabilitare tutti i compilatori JIT e usa solo l'interprete.

Client vs server JIT

Usa -client e -server per attivare le rispettive modalità.

Il compilatore client (C1) inizia a compilare il codice prima del compilatore server (C2). Quindi, nel momento in cui C2 ha iniziato la compilazione, C1 avrebbe già compilato sezioni di codice.

Ma mentre attende, C2 profila il codice per saperne di più rispetto a C1. Quindi, il tempo che attende se compensato dalle ottimizzazioni può essere utilizzato per generare un binario molto più veloce. Dal punto di vista di un utente, il compromesso è tra l'ora di avvio del programma e il tempo impiegato per l'esecuzione del programma. Se il tempo di avvio è il premio, è necessario utilizzare C1. Se si prevede che l'applicazione venga eseguita per un lungo periodo di tempo (tipico delle applicazioni distribuite sui server), è meglio utilizzare C2 poiché genera codice molto più veloce che compensa notevolmente qualsiasi tempo di avvio aggiuntivo.

Per programmi come IDE (NetBeans, Eclipse) e altri programmi GUI, il tempo di avvio è fondamentale. NetBeans potrebbe impiegare un minuto o più per avviarsi. Centinaia di classi vengono compilate quando vengono avviati programmi come NetBeans. In questi casi, il compilatore C1 è la scelta migliore.

Nota che ci sono due versioni di C1: 32b and 64b. C2 arriva solo in64b.

Compilazione a più livelli

Nelle versioni precedenti su Java, l'utente avrebbe potuto selezionare una delle seguenti opzioni:

  • Interprete (-Xint)
  • C1 (-client)
  • C2 (-server)

È disponibile in Java 7. Utilizza il compilatore C1 per l'avvio e, quando il codice si surriscalda, passa a C2. Può essere attivato con le seguenti opzioni JVM: -XX: + TieredCompilation. Il valore predefinito èset to false in Java 7, and to true in Java 8.

Dei cinque livelli di compilazione, utilizza la compilazione a più livelli 1 -> 4 -> 5.

Su una macchina 32b, può essere installata solo la versione 32b della JVM. Su una macchina 64b, l'utente può scegliere tra la versione 32b e 64b. Ma ci sono alcune sfumature in questo che possono influenzare il funzionamento delle nostre applicazioni Java.

Se l'applicazione Java utilizza meno di 4G di memoria, dovremmo utilizzare la JVM a 32b anche su macchine da 64b. Questo perché i riferimenti di memoria in questo caso sarebbero solo 32b e manipolarli sarebbe meno costoso rispetto a manipolare indirizzi 64b. In questo caso, la JVM 64b avrebbe prestazioni peggiori anche se stiamo usando OOPS (normali puntatori a oggetti). Utilizzando OOPS, la JVM può utilizzare indirizzi 32b nella JVM 64b. Tuttavia, la loro manipolazione sarebbe più lenta rispetto ai riferimenti reali a 32b poiché i riferimenti nativi sottostanti sarebbero ancora 64b.

Se la nostra applicazione consumerà più della memoria 4G, dovremo utilizzare la versione 64b poiché i riferimenti 32b possono indirizzare non più di 4G di memoria. Possiamo avere entrambe le versioni installate sulla stessa macchina e possiamo passare da una all'altra usando la variabile PATH.

In questo capitolo, impareremo a conoscere le ottimizzazioni JIT.

Metodo Inlining

In questa tecnica di ottimizzazione, il compilatore decide di sostituire le chiamate di funzione con il corpo della funzione. Di seguito è riportato un esempio per lo stesso:

int sum3;

static int add(int a, int b) {
   return a + b;
}

public static void main(String…args) {
   sum3 = add(5,7) + add(4,2);
}

//after method inlining
public static void main(String…args) {
   sum3 = 5+ 7 + 4 + 2;
}

Usando questa tecnica, il compilatore salva la macchina dall'overhead di effettuare qualsiasi chiamata di funzione (richiede il push e il popping dei parametri nello stack). Pertanto, il codice generato viene eseguito più velocemente.

L'inlining del metodo può essere eseguito solo per funzioni non virtuali (funzioni che non vengono sovrascritte). Considera cosa accadrebbe se il metodo "add" fosse ignorato in una sottoclasse e il tipo di oggetto contenente il metodo non fosse noto fino al runtime. In questo caso, il compilatore non saprebbe quale metodo incorporare. Ma se il metodo fosse contrassegnato come "finale", il compilatore saprebbe facilmente che può essere inline perché non può essere superato da nessuna sottoclasse. Si noti che non è affatto garantito che un metodo finale sia sempre integrato.

Eliminazione del codice irraggiungibile e morto

Il codice irraggiungibile è un codice che non può essere raggiunto da nessun possibile flusso di esecuzione. Considereremo il seguente esempio:

void foo() {
   if (a) return;
   else return;
   foobar(a,b); //unreachable code, compile time error
}

Anche il codice morto è un codice irraggiungibile, ma in questo caso il compilatore sputa un errore. Invece, riceviamo solo un avviso. Ogni blocco di codice come costruttori, funzioni, try, catch, if, while, ecc., Ha le proprie regole per il codice non raggiungibile definite nella JLS (Java Language Specification).

Piegatura costante

Per comprendere il concetto di piegatura costante, vedere l'esempio seguente.

final int num = 5;
int b = num * 6; //compile-time constant, num never changes
//compiler would assign b a value of 30.

Il ciclo di vita di un oggetto Java è gestito dalla JVM. Una volta che un oggetto è stato creato dal programmatore, non dobbiamo preoccuparci per il resto del suo ciclo di vita. La JVM troverà automaticamente quegli oggetti che non sono più in uso e recupererà la loro memoria dall'heap.

La raccolta dei rifiuti è un'operazione importante che JVM fa e ottimizzarla per le nostre esigenze può fornire un enorme aumento delle prestazioni della nostra applicazione. Esistono numerosi algoritmi di garbage collection forniti dalle moderne JVM. Dobbiamo essere consapevoli delle esigenze della nostra applicazione per decidere quale algoritmo utilizzare.

Non è possibile deallocare un oggetto a livello di programmazione in Java, come si può fare in linguaggi non GC come C e C ++. Pertanto, non è possibile avere riferimenti pendenti in Java. Tuttavia, potresti avere riferimenti nulli (riferimenti che fanno riferimento a un'area di memoria in cui la JVM non memorizzerà mai oggetti). Ogni volta che viene utilizzato un riferimento null, la JVM genera un'eccezione NullPointerException.

Si noti che sebbene sia raro trovare perdite di memoria nei programmi Java grazie al GC, si verificano. Creeremo una perdita di memoria alla fine di questo capitolo.

I seguenti GC vengono utilizzati nelle moderne JVM

  • Collettore seriale
  • Raccoglitore di throughput
  • Collettore CMS
  • Collettore G1

Ciascuno degli algoritmi di cui sopra svolge lo stesso compito: trovare oggetti che non sono più in uso e recuperare la memoria che occupano nell'heap. Uno degli approcci ingenui a questo sarebbe contare il numero di riferimenti che ogni oggetto ha e liberarlo non appena il numero di riferimenti diventa 0 (questo è anche noto come conteggio dei riferimenti). Perché questo è ingenuo? Considera un elenco collegato circolare. Ciascuno dei suoi nodi avrà un riferimento ad esso, ma l'intero oggetto non viene referenziato da nessuna parte e dovrebbe essere liberato, idealmente.

La JVM non solo libera la memoria, ma unisce anche piccoli mandrini di memoria in quelli più grandi. Questo viene fatto per prevenire la frammentazione della memoria.

In una semplice nota, un tipico algoritmo GC svolge le seguenti attività:

  • Trovare oggetti inutilizzati
  • Liberare la memoria che occupano nel mucchio
  • Unendo i frammenti

Il GC deve arrestare i thread dell'applicazione mentre è in esecuzione. Questo perché sposta gli oggetti in giro quando viene eseguito e, pertanto, tali oggetti non possono essere utilizzati. Tali fermate sono chiamate pause "stop-the-world" e ridurre al minimo la frequenza e la durata di queste pause è ciò a cui miriamo durante la regolazione del nostro GC.

Coalescenza della memoria

Di seguito viene mostrata una semplice dimostrazione della fusione della memoria

La parte ombreggiata sono oggetti che devono essere liberati. Anche dopo che tutto lo spazio è stato recuperato, possiamo allocare solo un oggetto di dimensione massima = 75Kb. Questo è anche dopo che abbiamo 200 KB di spazio libero come mostrato di seguito

La maggior parte delle JVM divide l'heap in tre generazioni: the young generation (YG), the old generation (OG) and permanent generation (also called tenured generation). Quali sono le ragioni dietro questo pensiero?

Studi empirici hanno dimostrato che la maggior parte degli oggetti creati ha una durata di vita molto breve -

fonte

https://www.oracle.com

Come puoi vedere, man mano che sempre più oggetti vengono allocati nel tempo, il numero di byte sopravvissuti diminuisce (in generale). Gli oggetti Java hanno un alto tasso di mortalità.

Analizzeremo un semplice esempio. La classe String in Java è immutabile. Ciò significa che ogni volta che è necessario modificare il contenuto di un oggetto String, è necessario creare un nuovo oggetto del tutto. Supponiamo di apportare modifiche alla stringa 1000 volte in un ciclo come mostrato nel codice seguente:

String str = “G11 GC”;

for(int i = 0 ; i < 1000; i++) {
   str = str + String.valueOf(i);
}

In ogni ciclo, creiamo un nuovo oggetto stringa e la stringa creata durante l'iterazione precedente diventa inutile (cioè non è referenziata da alcun riferimento). La durata di vita di quell'oggetto era solo un'iterazione: verranno raccolti dal GC in pochissimo tempo. Tali oggetti di breve durata sono conservati nell'area delle giovani generazioni del mucchio. Il processo di raccolta degli oggetti della giovane generazione è chiamato raccolta dei rifiuti minori e provoca sempre una pausa "ferma il mondo".

Man mano che la giovane generazione si riempie, il GC fa una piccola raccolta di rifiuti. Gli oggetti morti vengono scartati e gli oggetti attivi vengono spostati nella vecchia generazione. I thread dell'applicazione si interrompono durante questo processo.

Qui possiamo vedere i vantaggi che offre un design di tale generazione. La giovane generazione è solo una piccola parte del mucchio e si riempie rapidamente. Ma l'elaborazione richiede molto meno tempo rispetto al tempo impiegato per elaborare l'intero heap. Quindi, le pause "stop-theworld" in questo caso sono molto più brevi, anche se più frequenti. Dovremmo sempre mirare a pause più brevi rispetto a quelle più lunghe, anche se potrebbero essere più frequenti. Ne discuteremo in dettaglio nelle sezioni successive di questo tutorial.

La giovane generazione si divide in due spazi: eden and survivor space. Gli oggetti che sono sopravvissuti durante la raccolta dell'eden vengono spostati nello spazio dei sopravvissuti e quelli che sopravvivono allo spazio dei sopravvissuti vengono spostati nella vecchia generazione. La giovane generazione viene compattata mentre viene raccolta.

Quando gli oggetti vengono spostati nella vecchia generazione, alla fine si riempie e deve essere raccolto e compattato. Diversi algoritmi adottano approcci diversi a questo. Alcuni di loro interrompono i thread dell'applicazione (il che porta a una lunga pausa "stop-the-world" poiché la vecchia generazione è abbastanza grande rispetto alla generazione giovane), mentre alcuni di loro lo fanno contemporaneamente mentre i thread dell'applicazione continuano a funzionare. Questo processo è chiamato GC completo. Due di questi collezionisti lo sonoCMS and G1.

Analizziamo ora questi algoritmi in dettaglio.

GC seriale

è il GC predefinito sulle macchine di classe client (macchine a processore singolo o JVM 32b, Windows). In genere, i GC sono fortemente multithread, ma il GC seriale non lo è. Ha un singolo thread per elaborare l'heap e interromperà i thread dell'applicazione ogni volta che esegue un GC minore o un GC principale. Possiamo comandare alla JVM di utilizzare questo GC specificando il flag:-XX:+UseSerialGC. Se vogliamo che utilizzi un algoritmo diverso, specifica il nome dell'algoritmo. Si noti che la vecchia generazione è completamente compattata durante un GC principale.

Velocità effettiva GC

Questo GC è predefinito su JVM 64b e macchine multi-CPU. A differenza del GC seriale, utilizza più thread per elaborare i giovani e la vecchia generazione. Per questo motivo, il GC è anche chiamatoparallel collector. Possiamo comandare alla nostra JVM di utilizzare questo raccoglitore utilizzando il flag:-XX:+UseParallelOldGC o -XX:+UseParallelGC(per JDK 8 in poi). I thread dell'applicazione vengono interrotti mentre esegue una garbage collection principale o secondaria. Come il collezionista seriale, compatta completamente la giovane generazione durante un importante GC.

Il throughput GC raccoglie l'YG e l'OG. Quando l'eden si è riempito, il raccoglitore espelle gli oggetti vivi da esso nell'OG o in uno degli spazi superstiti (SS0 e SS1 nel diagramma sotto). Gli oggetti morti vengono scartati per liberare lo spazio che occupavano.

Prima di GC di YG

Dopo GC di YG

Durante un GC completo, il raccoglitore di velocità effettiva svuota l'intero YG, SS0 e SS1. Dopo l'operazione, l'OG contiene solo oggetti live. Dobbiamo notare che entrambi i collector di cui sopra arrestano i thread dell'applicazione durante l'elaborazione dell'heap. Ciò significa lunghe pause di "stop the world" durante un importante GC. I prossimi due algoritmi mirano ad eliminarli, a scapito di maggiori risorse hardware -

CMS Collector

Sta per "concurrent mark-sweep". La sua funzione è che utilizza alcuni thread in background per scansionare periodicamente la vecchia generazione e sbarazzarsi di oggetti morti. Ma durante un GC minore, i thread dell'applicazione vengono interrotti. Tuttavia, le pause sono piuttosto piccole. Questo rende il CMS un raccoglitore a bassa pausa.

Questo collector richiede tempo CPU aggiuntivo per eseguire la scansione dell'heap durante l'esecuzione dei thread dell'applicazione. Inoltre, i thread in background raccolgono solo l'heap e non eseguono alcuna compattazione. Possono portare alla frammentazione dell'heap. Dato che ciò continua, dopo un certo punto di tempo, il CMS interromperà tutti i thread dell'applicazione e compatterà l'heap utilizzando un singolo thread. Utilizzare i seguenti argomenti JVM per dire alla JVM di utilizzare il raccoglitore CMS:

“XX:+UseConcMarkSweepGC -XX:+UseParNewGC” come argomenti JVM per dirgli di utilizzare il raccoglitore CMS.

Prima di GC

Dopo GC

Notare che la raccolta viene eseguita contemporaneamente.

G1 GC

Questo algoritmo funziona dividendo l'heap in un numero di regioni. Come il raccoglitore CMS, arresta i thread dell'applicazione mentre esegue un GC minore e utilizza thread in background per elaborare la vecchia generazione mantenendo i thread dell'applicazione in esecuzione. Poiché ha diviso la vecchia generazione in regioni, continua a compattarle mentre sposta gli oggetti da una regione all'altra. Quindi, la frammentazione è minima. Puoi usare la bandiera:XX:+UseG1GCper dire alla tua JVM di utilizzare questo algoritmo. Come CMS, richiede anche più tempo della CPU per l'elaborazione dell'heap e l'esecuzione simultanea dei thread dell'applicazione.

Questo algoritmo è stato progettato per elaborare heap più grandi (> 4G), che sono suddivisi in un numero di regioni diverse. Alcune di queste regioni comprendono le giovani generazioni e il resto comprendono le vecchie. L'YG viene cancellato usando tradizionalmente: tutti i thread dell'applicazione vengono arrestati e tutti gli oggetti che sono ancora vivi nella vecchia generazione o nello spazio dei sopravvissuti.

Si noti che tutti gli algoritmi GC hanno diviso l'heap in YG e OG e utilizzano un STWP per cancellare l'YG. Questo processo è generalmente molto veloce.

Nell'ultimo capitolo, abbiamo imparato a conoscere vari CC generazionali. In questo capitolo, discuteremo su come regolare il GC.

Dimensione heap

La dimensione dell'heap è un fattore importante per le prestazioni delle nostre applicazioni Java. Se è troppo piccolo, verrà riempito frequentemente e, di conseguenza, dovrà essere raccolto frequentemente dal GC. D'altra parte, se aumentassimo solo la dimensione dell'heap, sebbene debba essere raccolto meno frequentemente, la lunghezza delle pause aumenterebbe.

Inoltre, l'aumento della dimensione dell'heap comporta una grave penalità per il sistema operativo sottostante. Utilizzando il paging, il sistema operativo fa sì che i nostri programmi applicativi vedano molta più memoria di quella effettivamente disponibile. Il sistema operativo gestisce ciò utilizzando uno spazio di swap sul disco, copiando parti inattive dei programmi in esso. Quando queste porzioni sono necessarie, il sistema operativo le copia dal disco alla memoria.

Supponiamo che una macchina abbia 8G di memoria e che la JVM veda 16G di memoria virtuale, la JVM non saprebbe che in realtà sono disponibili solo 8G sul sistema. Richiederà solo 16G dal sistema operativo e, una volta ottenuta quella memoria, continuerà a utilizzarlo. Il sistema operativo dovrà scambiare molti dati dentro e fuori, e questa è un'enorme penalità per le prestazioni del sistema.

E poi arrivano le pause che si verificherebbero durante l'intero GC di tale memoria virtuale. Poiché il GC agirà sull'intero heap per la raccolta e la compattazione, dovrà attendere molto prima che la memoria virtuale venga sostituita dal disco. In caso di un collector simultaneo, i thread in background dovranno attendere molto prima che i dati vengano copiati dallo spazio di swap alla memoria.

Quindi ecco la domanda su come dovremmo decidere la dimensione ottimale dell'heap. La prima regola è non richiedere mai al sistema operativo più memoria di quella effettivamente presente. Ciò eviterebbe totalmente il problema di frequenti scambi. Se la macchina ha più JVM installate e in esecuzione, la richiesta di memoria totale da parte di tutte combinate è inferiore alla RAM effettiva presente nel sistema.

È possibile controllare la dimensione della richiesta di memoria da parte della JVM utilizzando due flag:

  • -XmsN - Controlla la memoria iniziale richiesta.

  • -XmxN - Controlla la memoria massima che può essere richiesta.

I valori predefiniti di entrambi questi flag dipendono dal sistema operativo sottostante. Ad esempio, per JVM 64b in esecuzione su MacOS, -XmsN = 64M e -XmxN = minimo 1G o 1/4 della memoria fisica totale.

Notare che la JVM può regolare automaticamente tra i due valori. Ad esempio, se rileva che è in corso una quantità eccessiva di GC, continuerà ad aumentare la dimensione della memoria fintanto che è inferiore a -XmxN e gli obiettivi di prestazioni desiderati vengono raggiunti.

Se sai esattamente quanta memoria ha bisogno la tua applicazione, puoi impostare -XmsN = -XmxN. In questo caso, la JVM non ha bisogno di calcolare un valore "ottimale" dell'heap e, di conseguenza, il processo GC diventa un po 'più efficiente.

Dimensioni della generazione

Puoi decidere quanta parte dell'heap vuoi allocare a YG e quanta parte vuoi allocare all'OG. Entrambi questi valori influenzano le prestazioni delle nostre applicazioni nel modo seguente.

Se la dimensione dell'YG è molto grande, verrà raccolta meno frequentemente. Ciò comporterebbe un numero inferiore di oggetti promossi al OG. D'altra parte, se aumenti troppo le dimensioni di OG, raccoglierlo e compattarlo richiederebbe troppo tempo e questo porterebbe a lunghe pause STW. Pertanto, l'utente deve trovare un equilibrio tra questi due valori.

Di seguito sono riportati i flag che puoi utilizzare per impostare questi valori:

  • -XX:NewRatio=N: Rapporto tra YG e OG (valore predefinito = 2)

  • -XX:NewSize=N: La dimensione iniziale di YG

  • -XX:MaxNewSize=N: Dimensione massima di YG

  • -XmnN: Impostare NewSize e MaxNewSize sullo stesso valore utilizzando questo flag

La dimensione iniziale dell'YG è determinata dal valore di NewRatio dalla formula data -

(total heap size) / (newRatio + 1)

Poiché il valore iniziale di newRatio è 2, la formula precedente fornisce il valore iniziale di YG pari a 1/3 della dimensione totale dell'heap. È sempre possibile sovrascrivere questo valore specificando esplicitamente la dimensione dell'YG utilizzando il flag NewSize. Questo flag non ha alcun valore predefinito e, se non è impostato esplicitamente, la dimensione dell'YG continuerà a essere calcolata utilizzando la formula sopra.

Permagen e Metaspace

Il permagen e il metaspace sono aree di heap in cui la JVM conserva i metadati delle classi. Lo spazio è chiamato "permagen" in Java 7, e in Java 8 è chiamato "metaspace". Queste informazioni vengono utilizzate dal compilatore e dal runtime.

Puoi controllare la dimensione del permagen utilizzando i seguenti flag: -XX: PermSize=N e -XX:MaxPermSize=N. Le dimensioni di Metaspace possono essere controllate utilizzando:-XX:Metaspace- Size=N e -XX:MaxMetaspaceSize=N.

Ci sono alcune differenze nel modo in cui vengono gestiti permagen e metaspace quando i valori del flag non sono impostati. Per impostazione predefinita, entrambi hanno una dimensione iniziale predefinita. Ma mentre il metaspace può occupare tutto l'heap necessario, permagen non può occupare più dei valori iniziali predefiniti. Ad esempio, la JVM 64b ha 82M di spazio heap come dimensione massima permagen.

Si noti che poiché il metaspace può occupare quantità illimitate di memoria a meno che non venga specificato di non farlo, potrebbe esserci un errore di memoria insufficiente. Ogni volta che queste regioni vengono ridimensionate, viene eseguito un GC completo. Quindi, durante l'avvio, se ci sono molte classi che vengono caricate, il metaspace può continuare a ridimensionarsi ottenendo ogni volta un GC completo. Pertanto, l'avvio di applicazioni di grandi dimensioni richiede molto tempo nel caso in cui la dimensione iniziale del metaspace sia troppo bassa. È una buona idea aumentare la dimensione iniziale in quanto riduce il tempo di avvio.

Sebbene il permagen e il metaspace contengano i metadati della classe, non sono permanenti e lo spazio viene recuperato dal GC, come nel caso degli oggetti. Questo è in genere nel caso delle applicazioni server. Ogni volta che si effettua una nuova distribuzione sul server, i vecchi metadati devono essere ripuliti poiché i nuovi caricatori di classi ora avranno bisogno di spazio. Questo spazio viene liberato dal GC.

In questo capitolo discuteremo del concetto di perdita di memoria in Java.

Il codice seguente crea una perdita di memoria in Java:

void queryDB() {
   try{
      Connection conn = ConnectionFactory.getConnection();
      PreparedStatement ps = conn.preparedStatement("query"); // executes a
      SQL
      ResultSet rs = ps.executeQuery();
      while(rs.hasNext()) {
         //process the record
      }
   } catch(SQLException sqlEx) {
      //print stack trace
   }
}

Nel codice sopra, quando il metodo esce, non abbiamo chiuso l'oggetto connessione. Pertanto, la connessione fisica rimane aperta prima che il GC venga attivato e vede l'oggetto connessione come irraggiungibile. Ora chiamerà il metodo finale sull'oggetto connessione, tuttavia, potrebbe non essere implementato. Quindi, l'oggetto non verrà raccolto in modo indesiderato in questo ciclo.

La stessa cosa accadrà nel prossimo fino a quando il server remoto non vedrà che la connessione è stata aperta da molto tempo e la terminerà forzatamente. Pertanto, un oggetto senza riferimento rimane a lungo nella memoria, il che crea una perdita.