Per chi sviluppa in Java prima o poi arriva il momento di confrontarsi con problemi di tuning della JVM, in particolare dei parametri inerenti l’utilizzo della memoria dinamica (heap).
È quello che è capitato a me un po’ di tempo fa, dopo aver capito che la causa che portava a chash continui dell’applicazione su cui stavo lavorando era la frequenza altissima di Full GC.
La configurazione della memoria può incidere notevolmente sulle prestazioni delle nostre applicazioni. È quindi fondamentale capire come la JVM gestisca tale porzione della memoria ad essa riservata prima di agire sui parametri di configurazione.
Ok, cominciamo.
Ogni applicazione java è running nel contesto di una JVM, o meglio di un’istanza della JVM (un processo java). Una parte della memoria riservata a tale istanza è adibita a memoria dinamica (heap – letteralmente “mucchio”).
Lo spazio occupato da tale area non è fisso, quindi, ma varia durante l’esecuzione dell’applicazione: è questa l’area dove finiscono tutti i nostri oggetti java durante il loro ciclo di vita.
Mentre l’allocazione degli oggetti è a carico degli sviluppatori (con la new), in Java, la deallocazione è gestita automaticamente dalla JVM tramite il Garbage Collector (GC), un thread che passa in rassegna la heap deallocando tutti gli oggetti che non sono più referenziati e che quindi hanno completato il loro ciclo di vita.
Un lavoro in meno per noi…bello no?
Il “guaio” è che quando la JVM decide di far entrare in gioco il GC è necessario stoppare l’esecuzione di tutti gli altri thread fino a che il GC non ha finito. Quindi in questo lasso di tempo la nostra applicazione è running ma non lavora. Dunque è importante che il GC venga chiamato il minor numero di volte possibile e che il suo lavoro abbia durata minore possibile per non degradare anche pesantemente le prestazioni delle nostre applicazioni.
Se il GC dovesse ogni volta passare in rassegna tutti gli oggetti correntemente in vita, per applicazioni medio-grandi il suo utilizzo sarebbe proibitivo. È per questo motivo che in realtà esistono più GC ciascuno assegnato ad una generazione diversa di oggetti.
Generazioni
Osservando il comportamento di molte applicazioni si è riscontrato che la maggior parte degli oggetti che vengono allocati hanno vita breve: quella di un ciclo for, ad esempio, o di un singolo metodo. Per questo motivo all’interno della heap distinguiamo tre generazioni di oggetti: young, tenured o old, permanent. Esiste un GC per ognuna di essa che entra in gioco ogni qualvolta le generazione si satura.
La saturazione della young genaration comporta la cosiddetta minor collection che è piuttosto veloce ed efficiente, mentre se viene saturata la tenured generation si parla di major collecction o Full GC che può essere molto più lenta perché riguarda tutti gli oggetti in vita.
La young e la tenured generation sono usate per gestire i nostri oggetti java mentre la permanent è usata per gestire metadati necessari alla JVM, come ad esempio la descrizione delle nostre classi, metodi e tutto quanto accessibile tramite reflaction. Perché usare memoria dinamica per tali informazioni? Perché in Java il caricamento delle classi è dinamico, tramite ClassLoader (magari su questo argomento farò un post dedicato).
A questo punto vediamo come è strutturata la hep:
Fig.1 - Struttura heap |
- Inizialmente viene riservata una certa quantità di memoria alla heap (cosiddetta committed memory) anche se non usata. In ogni istante una delle due aree indicate come survivor space è sempre vuota.
- Quando a runtime la nostra applicazione crea un nuovo oggetto esso viene allocato nella young generation nell'area denominata eden.
- Quando l'eden si riempe viene invocata la minor collection che dealloca gli oggetti in eden e nella survivor space non vuota che non sono più referenziati. Gli oggetti "superstiti" vengono spostati nella survivor space vuota.
- Se non c'è abbastanza spazio per tutti nella survivor space vuota quelli più "vecchi" (quelli che sono stati spostati più volte all'interno della young generation) li si promuove nella tenured generation.
- Se la tenured generation diventa piena e/o non è in grado di accogliere tutti gli oggetti superstiti si rende necessaria la major collection o Full GC.
- La JVM aumenta o riduce la dimensione delle generazioni in base a dei parametri configurabili (min e max spazio da destinare alla heap, % heap libera, ecc).
Considerazioni sul dimensionamento delle generazioni
Osserviamo che nel caso peggiore della minor collection tutti gli oggetti possono risultare ancora in vita, e quindi la survivor space libera può non bastare per lo spostamento. Pertanto affinché la tenured generation possa ospitarli tutti dovrà avere dimensioni almeno pari ad eden + survivor space piena.
Inoltre se la tenured generation è piccola ciò implica una maggiore frequenza della Full GC che, come detto, è costosa in termini di prestazioni.
Nel caso di survivor space troppo piccola si è costretti ad usare spesso la tenured, viceversa se troppo grande avremo inutile spreco di memoria.
Tipologie di GC
Esistono diverse tipologie di GC che la JVM può usare. Per la minor e per la major collection possono essere usati GC di tipologie diverse:
- Serial GC, utilizza un solo thread per realizzare il comportamento descritto. E' applicabile sia alla minor che alla major collection;
- Thrughput GC, è applicabile alla minor collection ed utilizza più thread concorrenti per scandire la memoria. Di default il loro numero è pari al numero di processori del sistema host. Per avere vantaggi di prestazione rispetto al seriale è necessario avere più di due processori perché il prezzo della concorrenza dei thread è un overhead dovuto alla sincronizzazione tra questi. Inoltre, siccome ogni thread utilizza una diversa area della tenured generation in cui spostare gli oggetti "vecchi", può aver luongo una frammentazione di tale generazione con conseguente spreco di memoria.
- Parallel Compaction GC, è applicabile alla tenured generation ed utilizza più thread concorrenti per scandire la memoria. Valgono le stesse considerazioni fatte Thrughput GC.
- Concurrent Low Pause, è applicabile alla tenured generation e comporta un minimo fermo dei thread applicativi durante il suo lavoro. Quindi il Full GC è praticamente concorrente con l'applicazione. In ogni caso essendo un thread ulteriore che si aggiunge all'applicazione, per non avere un degrado delle performance è consigliabile avere più di 2 processori.
Nel prossimo post vedremo come possiamo sfruttare queste conoscenze per ottenere maggiori performance dalle nostre applicazioni e quali sono i parametri per un tuning della nostra JVM.
Sono molto interessato sul proseguio di questo discorso. Dov'è l'altro post che spiega come utilizzare lo heap al meglio per migliorare le performace della JVM?
RispondiElimina