Caccia alle perdite di memoria Java

Pubblicato: 2022-03-11

I programmatori inesperti spesso pensano che la raccolta automatica dei rifiuti di Java li liberi completamente dal preoccuparsi della gestione della memoria. Questa è una percezione errata comune: mentre il Garbage Collector fa del suo meglio, è del tutto possibile che anche il miglior programmatore cada preda di perdite di memoria paralizzanti. Lasciatemi spiegare.

Una perdita di memoria si verifica quando i riferimenti agli oggetti non più necessari vengono mantenuti inutilmente. Queste perdite sono cattive. Per uno, esercitano una pressione non necessaria sulla tua macchina poiché i tuoi programmi consumano sempre più risorse. A peggiorare le cose, rilevare queste perdite può essere difficile: l'analisi statica spesso fatica a identificare con precisione questi riferimenti ridondanti e gli strumenti di rilevamento delle perdite esistenti tracciano e riportano informazioni a grana fine sui singoli oggetti, producendo risultati difficili da interpretare e privi di precisione.

In altre parole, le perdite sono troppo difficili da identificare o identificate in termini troppo specifici per essere utili.

Esistono in realtà quattro categorie di problemi di memoria con sintomi simili e sovrapposti, ma diverse cause e soluzioni:

  • Prestazioni : solitamente associate a creazione ed eliminazione eccessive di oggetti, lunghi ritardi nella raccolta dei rifiuti, eccessivo scambio di pagine del sistema operativo e altro ancora.

  • Vincoli delle risorse : si verifica quando la memoria disponibile è insufficiente o la memoria è troppo frammentata per allocare un oggetto di grandi dimensioni: può essere nativo o, più comunemente, correlato all'heap Java.

  • Java heap leaks : il classico memory leak, in cui gli oggetti Java vengono continuamente creati senza essere rilasciati. Questo di solito è causato da riferimenti a oggetti latenti.

  • Perdite di memoria nativa : associate a qualsiasi utilizzo della memoria in continua crescita al di fuori dell'heap Java, come allocazioni effettuate da codice JNI, driver o persino allocazioni JVM.

In questo tutorial sulla gestione della memoria, mi concentrerò sulle perdite di heap Java e delineerò un approccio per rilevare tali perdite basato sui report Java VisualVM e utilizzando un'interfaccia visiva per analizzare le applicazioni basate sulla tecnologia Java mentre sono in esecuzione.

Ma prima di poter prevenire e trovare perdite di memoria, dovresti capire come e perché si verificano. ( Nota: se hai una buona padronanza della complessità delle perdite di memoria, puoi saltare avanti. )

Perdite di memoria: un primer

Per cominciare, considera la perdita di memoria come una malattia e OutOfMemoryError (OOM, per brevità) di Java come un sintomo. Ma come per qualsiasi malattia, non tutte le OOM implicano necessariamente perdite di memoria : una OOM può verificarsi a causa della generazione di un gran numero di variabili locali o altri eventi simili. D'altra parte, non tutte le perdite di memoria si manifestano necessariamente come OOM , soprattutto nel caso di applicazioni desktop o client (che non vengono eseguite per molto tempo senza riavvii).

Pensa alla perdita di memoria come a una malattia e a OutOfMemoryError come a un sintomo. Ma non tutti gli OutOfMemoryErrors implicano perdite di memoria e non tutte le perdite di memoria si manifestano come OutOfMemoryErrors.

Perché queste perdite sono così gravi? Tra le altre cose, la perdita di blocchi di memoria durante l'esecuzione del programma spesso degrada le prestazioni del sistema nel tempo, poiché i blocchi di memoria allocati ma non utilizzati dovranno essere sostituiti una volta che il sistema esaurisce la memoria fisica libera. Alla fine, un programma potrebbe anche esaurire lo spazio di indirizzi virtuali disponibile, portando all'OOM.

Decifrare l' OutOfMemoryError

Come accennato in precedenza, l'OOM è un'indicazione comune di una perdita di memoria. In sostanza, l'errore viene generato quando lo spazio non è sufficiente per allocare un nuovo oggetto. Per quanto possibile, il Garbage Collector non riesce a trovare lo spazio necessario e l'heap non può essere ulteriormente ampliato. Pertanto, emerge un errore, insieme a una traccia dello stack.

Il primo passo per diagnosticare il tuo OOM è determinare cosa significa effettivamente l'errore. Sembra ovvio, ma la risposta non è sempre così chiara. Ad esempio: l'OOM viene visualizzato perché l'heap Java è pieno o perché l'heap nativo è pieno? Per aiutarti a rispondere a questa domanda, analizziamo alcuni dei possibili messaggi di errore:

  • java.lang.OutOfMemoryError: Java heap space

  • java.lang.OutOfMemoryError: PermGen space

  • java.lang.OutOfMemoryError: Requested array size exceeds VM limit

  • java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?

  • java.lang.OutOfMemoryError: <reason> <stack trace> (Native method)

“Spazio dell'heap Java”

Questo messaggio di errore non implica necessariamente una perdita di memoria. In effetti, il problema può essere semplice come un problema di configurazione.

Ad esempio, ero responsabile dell'analisi di un'applicazione che produceva costantemente questo tipo di OutOfMemoryError . Dopo alcune indagini, ho capito che il colpevole era un'istanza di matrice che richiedeva troppa memoria; in questo caso, non era colpa dell'applicazione, ma piuttosto, il server delle applicazioni faceva affidamento sulla dimensione dell'heap predefinita, che era troppo piccola. Ho risolto il problema regolando i parametri di memoria della JVM.

In altri casi, e in particolare per le applicazioni di lunga durata, il messaggio potrebbe indicare che stiamo trattenendo involontariamente riferimenti a objects , impedendo al Garbage Collector di ripulirli. Questo è l'equivalente in linguaggio Java di una perdita di memoria . ( Nota: le API richiamate da un'applicazione potrebbero anche contenere involontariamente riferimenti a oggetti. )

Un'altra potenziale fonte di questi OOM "Java heap space" deriva dall'uso di finalizzatori . Se una classe ha un metodo finalize , gli oggetti di quel tipo non hanno il loro spazio recuperato al momento della raccolta dei rifiuti. Al contrario, dopo il Garbage Collection, gli oggetti vengono accodati per la finalizzazione, che avviene in seguito. Nell'implementazione Sun, i finalizzatori vengono eseguiti da un thread daemon. Se il thread del finalizzatore non riesce a tenere il passo con la coda di finalizzazione, l'heap Java potrebbe riempirsi e potrebbe essere generato un OOM.

“Spazio PermGen”

Questo messaggio di errore indica che la generazione permanente è piena. La generazione permanente è l'area dell'heap che memorizza oggetti di classe e metodo. Se un'applicazione carica un numero elevato di classi, potrebbe essere necessario aumentare la dimensione della generazione permanente utilizzando l'opzione -XX:MaxPermSize .

Anche gli oggetti java.lang.String internati vengono archiviati nella generazione permanente. La classe java.lang.String mantiene un pool di stringhe. Quando viene richiamato il metodo intern, il metodo controlla il pool per verificare se è presente una stringa equivalente. In tal caso, viene restituito dal metodo intern; in caso contrario, la stringa viene aggiunta al pool. In termini più precisi, il metodo java.lang.String.intern restituisce la rappresentazione canonica di una stringa; il risultato è un riferimento alla stessa istanza di classe che verrebbe restituita se quella stringa apparisse come un valore letterale. Se un'applicazione interna un numero elevato di stringhe, potrebbe essere necessario aumentare le dimensioni della generazione permanente.

Nota: puoi usare il comando jmap -permgen per stampare le statistiche relative alla generazione permanente, comprese le informazioni sulle istanze String interiorizzate.

"La dimensione richiesta dell'array supera il limite della VM"

Questo errore indica che l'applicazione (o le API utilizzate da tale applicazione) hanno tentato di allocare una matrice maggiore della dimensione dell'heap. Ad esempio, se un'applicazione tenta di allocare una matrice di 512 MB ma la dimensione massima dell'heap è 256 MB, verrà generato un messaggio di errore con questo messaggio di errore. Nella maggior parte dei casi, il problema è un problema di configurazione o un bug che si verifica quando un'applicazione tenta di allocare un array di grandi dimensioni.

“Richiedi <dimensione> byte per <motivo>. Hai esaurito lo spazio di scambio?"

Questo messaggio sembra essere un OOM. Tuttavia, la macchina virtuale HotSpot genera questa apparente eccezione quando un'allocazione dall'heap nativo non è riuscita e l'heap nativo potrebbe essere prossimo all'esaurimento. Nel messaggio sono incluse le dimensioni (in byte) della richiesta non riuscita e il motivo della richiesta di memoria. Nella maggior parte dei casi, il <motivo> è il nome del modulo di origine che segnala un errore di allocazione.

Se viene generato questo tipo di OOM, potrebbe essere necessario utilizzare le utilità di risoluzione dei problemi sul sistema operativo per diagnosticare ulteriormente il problema. In alcuni casi, il problema potrebbe non essere nemmeno correlato all'applicazione. Ad esempio, potresti visualizzare questo errore se:

  • Il sistema operativo è configurato con spazio di swap insufficiente.

  • Un altro processo nel sistema sta consumando tutte le risorse di memoria disponibili.

È anche possibile che l'applicazione non sia riuscita a causa di una perdita nativa (ad esempio, se alcuni bit di codice dell'applicazione o della libreria allocano continuamente memoria ma non riescono a rilasciarla al sistema operativo).

<motivo> <traccia dello stack> (metodo nativo)

Se viene visualizzato questo messaggio di errore e il frame superiore della traccia dello stack è un metodo nativo, il metodo nativo ha riscontrato un errore di allocazione. La differenza tra questo messaggio e il precedente è che l'errore di allocazione della memoria Java è stato rilevato in un metodo JNI o ​​nativo anziché nel codice Java VM.

Se viene generato questo tipo di OOM, potrebbe essere necessario utilizzare le utilità sul sistema operativo per diagnosticare ulteriormente il problema.

Crash dell'applicazione senza OOM

Occasionalmente, un'applicazione potrebbe arrestarsi in modo anomalo subito dopo un errore di allocazione dall'heap nativo. Ciò si verifica se si esegue codice nativo che non verifica la presenza di errori restituiti dalle funzioni di allocazione della memoria.

Ad esempio, la chiamata di sistema malloc restituisce NULL se non è disponibile memoria. Se il ritorno da malloc non è selezionato, l'applicazione potrebbe bloccarsi quando tenta di accedere a una posizione di memoria non valida. A seconda delle circostanze, questo tipo di problema può essere difficile da individuare.

In alcuni casi, le informazioni dal registro degli errori irreversibili o dal dump di arresto anomalo saranno sufficienti. Se la causa di un arresto anomalo è determinata dalla mancanza di gestione degli errori in alcune allocazioni di memoria, è necessario cercare il motivo di tale errore di allocazione. Come con qualsiasi altro problema di heap nativo, il sistema potrebbe essere configurato con spazio di scambio insufficiente, un altro processo potrebbe consumare tutte le risorse di memoria disponibili, ecc.

Diagnosi delle perdite

Nella maggior parte dei casi, la diagnosi delle perdite di memoria richiede una conoscenza molto dettagliata dell'applicazione in questione. Attenzione: il processo può essere lungo e iterativo.

La nostra strategia per dare la caccia alle perdite di memoria sarà relativamente semplice:

  1. Identifica i sintomi

  2. Abilita la raccolta dei rifiuti dettagliata

  3. Abilita la profilazione

  4. Analizza la traccia

1. Identifica i sintomi

Come discusso, in molti casi, il processo Java genererà un'eccezione di runtime OOM, un chiaro indicatore che le risorse di memoria sono state esaurite. In questo caso, è necessario distinguere tra un normale esaurimento della memoria e una perdita. Analizzando il messaggio dell'OOM e provando a trovare il colpevole sulla base delle discussioni fornite sopra.

Spesso, se un'applicazione Java richiede più spazio di archiviazione rispetto a quello offerto dall'heap di runtime, può essere dovuto a una progettazione scadente. Ad esempio, se un'applicazione crea più copie di un'immagine o carica un file in un array, si esaurirà lo spazio di archiviazione quando l'immagine o il file è molto grande. Questo è un normale esaurimento delle risorse. L'applicazione funziona come previsto (sebbene questo design sia chiaramente ossuto).

Ma se un'applicazione aumenta costantemente l'utilizzo della memoria durante l'elaborazione dello stesso tipo di dati, è possibile che si verifichi una perdita di memoria.

2. Abilita la raccolta dei rifiuti dettagliata

Uno dei modi più rapidi per affermare che si dispone effettivamente di una perdita di memoria è abilitare la raccolta di dati inutili dettagliata. I problemi di vincolo di memoria di solito possono essere identificati esaminando i modelli nell'output di verbosegc .

In particolare, l'argomento -verbosegc consente di generare una traccia ogni volta che viene avviato il processo di Garbage Collection (GC). Cioè, poiché la memoria viene raccolta in modo inappropriato, i rapporti di riepilogo vengono stampati in errore standard, dandoti un'idea di come viene gestita la tua memoria.

Ecco alcuni output tipici generati con l'opzione –verbosegc :

output dettagliato della raccolta dei rifiuti

Ciascun blocco (o stanza) in questo file di traccia GC è numerato in ordine crescente. Per dare un senso a questa traccia, dovresti guardare le successive stanze di Allocation Failure e cercare la memoria liberata (byte e percentuale) che diminuisce nel tempo mentre la memoria totale (qui, 19725304) aumenta. Questi sono i tipici segni di esaurimento della memoria.

3. Abilita la profilazione

Diverse JVM offrono modi diversi per generare file di traccia per riflettere l'attività dell'heap, che in genere include informazioni dettagliate sul tipo e la dimensione degli oggetti. Questo è chiamato profilare l'heap .

4. Analizzare la traccia

Questo post si concentra sulla traccia generata da Java VisualVM. Le tracce possono avere formati diversi, in quanto possono essere generate da diversi strumenti di rilevamento delle perdite di memoria Java, ma l'idea alla base è sempre la stessa: trovare un blocco di oggetti nell'heap che non dovrebbe essere presente e determinare se questi oggetti si accumulano invece di rilasciare. Di particolare interesse sono gli oggetti transitori noti per essere allocati ogni volta che un determinato evento viene attivato nell'applicazione Java. La presenza di molte istanze di oggetti che dovrebbero esistere solo in piccole quantità indica generalmente un bug dell'applicazione.

Infine, la risoluzione delle perdite di memoria richiede una revisione completa del codice. Conoscere il tipo di perdita di oggetti può essere molto utile e velocizzare notevolmente il debug.

Come funziona la raccolta dei rifiuti nella JVM?

Prima di iniziare la nostra analisi di un'applicazione con un problema di perdita di memoria, diamo un'occhiata a come funziona la Garbage Collection nella JVM.

La JVM utilizza una forma di Garbage Collector chiamato Tracing Collector , che essenzialmente opera mettendo in pausa il mondo circostante, contrassegnando tutti gli oggetti radice (oggetti a cui si fa riferimento direttamente eseguendo i thread) e seguendo i loro riferimenti, contrassegnando ogni oggetto che vede lungo il percorso.

Java implementa qualcosa chiamato Garbage Collector generazionale basato sul presupposto dell'ipotesi generazionale, che afferma che la maggior parte degli oggetti creati viene scartata rapidamente e che è probabile che gli oggetti che non vengono raccolti rapidamente rimangano in circolazione per un po' .

Sulla base di questo presupposto, Java partiziona gli oggetti in più generazioni. Ecco un'interpretazione visiva:

Java si divide in più generazioni

  • Young Generation - È qui che iniziano gli oggetti. Ha due sottogenerazioni:

    • Eden Space - Gli oggetti iniziano qui. La maggior parte degli oggetti vengono creati e distrutti nell'Eden Space. Qui, il GC esegue Minor GCs , che sono raccolte di dati inutili ottimizzate. Quando viene eseguito un GC minore, tutti i riferimenti agli oggetti che sono ancora necessari vengono migrati in uno degli spazi sopravvissuti (S0 o S1).

    • Survivor Space (S0 e S1) - Gli oggetti che sopravvivono all'Eden finiscono qui. Ce ne sono due e solo uno è in uso in un dato momento (a meno che non abbiamo una grave perdita di memoria). Uno è designato come vuoto e l'altro come live , in alternanza con ogni ciclo GC.

  • Generazione di proprietà - Conosciuta anche come vecchia generazione (vecchio spazio in Fig. 2), questo spazio contiene oggetti più vecchi con una vita più lunga (spostati dagli spazi sopravvissuti, se vivono abbastanza a lungo). Quando questo spazio è pieno, il GC esegue un GC completo , che costa di più in termini di prestazioni. Se questo spazio cresce senza limiti, la JVM genererà uno OutOfMemoryError - Java heap space .

  • Generazione permanente - Una terza generazione strettamente correlata alla generazione di ruolo, la generazione permanente è speciale perché contiene i dati richiesti dalla macchina virtuale per descrivere oggetti che non hanno un'equivalenza a livello di linguaggio Java. Ad esempio, gli oggetti che descrivono classi e metodi vengono archiviati nella generazione permanente.

Java è abbastanza intelligente da applicare diversi metodi di raccolta dei rifiuti a ogni generazione. La giovane generazione viene gestita utilizzando un raccoglitore di tracciamento e copia chiamato Parallel New Collector . Questo collezionista ferma il mondo, ma poiché la giovane generazione è generalmente piccola, la pausa è breve.

Per ulteriori informazioni sulle generazioni di JVM e su come funzionano in modo più dettagliato, visitare la Gestione della memoria nella documentazione della macchina virtuale Java HotSpot.

Rilevamento di una perdita di memoria

Per trovare le perdite di memoria ed eliminarle, sono necessari gli strumenti di perdita di memoria adeguati. È ora di rilevare e rimuovere una tale perdita utilizzando Java VisualVM.

Profilazione remota dell'heap con Java VisualVM

VisualVM è uno strumento che fornisce un'interfaccia visiva per visualizzare informazioni dettagliate sulle applicazioni basate sulla tecnologia Java mentre sono in esecuzione.

Con VisualVM è possibile visualizzare i dati relativi alle applicazioni locali ea quelle in esecuzione su host remoti. Puoi anche acquisire dati sulle istanze software JVM e salvarli sul tuo sistema locale.

Per beneficiare di tutte le funzionalità di Java VisualVM, è necessario eseguire Java Platform, Standard Edition (Java SE) versione 6 o successiva.

Correlati: perché è necessario eseguire già l'aggiornamento a Java 8

Abilitazione della connessione remota per la JVM

In un ambiente di produzione, è spesso difficile accedere alla macchina effettiva su cui verrà eseguito il nostro codice. Fortunatamente, possiamo profilare la nostra applicazione Java in remoto.

Innanzitutto, dobbiamo concederci l'accesso JVM sulla macchina di destinazione. Per fare ciò, crea un file chiamato jstatd.all.policy con il seguente contenuto:

 grant codebase "file:${java.home}/../lib/tools.jar" { permission java.security.AllPermission; };

Una volta creato il file, è necessario abilitare le connessioni remote alla VM di destinazione utilizzando lo strumento jstatd - Virtual Machine jstat Daemon, come segue:

 jstatd -p <PORT_NUMBER> -J-Djava.security.policy=<PATH_TO_POLICY_FILE>

Per esempio:

 jstatd -p 1234 -J-Djava.security.policy=D:\jstatd.all.policy

Con jstatd avviato nella macchina virtuale di destinazione, siamo in grado di connetterci alla macchina di destinazione e profilare in remoto l'applicazione con problemi di perdita di memoria.

Connessione a un host remoto

Nel computer client, apri un prompt e digita jvisualvm per aprire lo strumento VisualVM.

Successivamente, dobbiamo aggiungere un host remoto in VisualVM. Poiché la JVM di destinazione è abilitata per consentire connessioni remote da un'altra macchina con J2SE 6 o superiore, avviamo lo strumento Java VisualVM e ci connettiamo all'host remoto. Se la connessione con l'host remoto è andata a buon fine, vedremo le applicazioni Java in esecuzione nella JVM di destinazione, come mostrato qui:

in esecuzione nella jvm di destinazione

Per eseguire un profiler di memoria sull'applicazione, basta fare doppio clic sul suo nome nel pannello laterale.

Ora che abbiamo configurato un analizzatore di memoria, esaminiamo un'applicazione con un problema di perdita di memoria, che chiameremo MemLeak .

MemLeak

Naturalmente, ci sono diversi modi per creare perdite di memoria in Java. Per semplicità definiremo una classe come chiave in una HashMap , ma non definiremo i metodi equals() e hashcode().

Una HashMap è un'implementazione di una tabella hash per l'interfaccia Map, e come tale definisce i concetti base di chiave e valore: ogni valore è correlato a una chiave univoca, quindi se la chiave per una data coppia chiave-valore è già presente nel HashMap, il suo valore corrente viene sostituito.

È obbligatorio che la nostra classe chiave fornisca una corretta implementazione dei metodi equals() e hashcode() . Senza di essi, non vi è alcuna garanzia che venga generata una buona chiave.

Non definendo i metodi equals() e hashcode() , aggiungiamo la stessa chiave all'HashMap più e più volte e, invece di sostituire la chiave come dovrebbe, l'HashMap cresce continuamente, non riuscendo a identificare queste chiavi identiche e generando un OutOfMemoryError .

Ecco la classe MemLeak:

 package com.post.memory.leak; import java.util.Map; public class MemLeak { public final String key; public MemLeak(String key) { this.key =key; } public static void main(String args[]) { try { Map map = System.getProperties(); for(;;) { map.put(new MemLeak("key"), "value"); } } catch(Exception e) { e.printStackTrace(); } } }

Nota: la perdita di memoria non è dovuta al ciclo infinito alla riga 14: il ciclo infinito può portare a un esaurimento delle risorse, ma non a una perdita di memoria. Se avessimo implementato correttamente i metodi equals() e hashcode() , il codice funzionerebbe bene anche con il ciclo infinito poiché avremmo solo un elemento all'interno della HashMap.

(Per gli interessati, ecco alcuni mezzi alternativi per generare (intenzionalmente) perdite.)

Utilizzo di Java VisualVM

Con Java VisualVM, possiamo monitorare la memoria di Java Heap e identificare se il suo comportamento è indicativo di una perdita di memoria.

Ecco una rappresentazione grafica dell'analizzatore Java Heap di MemLeak subito dopo l'inizializzazione (ricorda la nostra discussione sulle varie generazioni):

monitorare le perdite di memoria usando java visualvm

Dopo soli 30 secondi, la Old Generation è quasi piena, indicando che, anche con un Full GC, la Old Generation è in continua crescita, un chiaro segno di una perdita di memoria.

Un mezzo per rilevare la causa di questa perdita è mostrato nell'immagine seguente ( fare clic per ingrandire ), generata utilizzando Java VisualVM con un heapdump . Qui vediamo che il 50% degli oggetti Hashtable$Entry si trova nell'heap , mentre la seconda riga ci punta alla classe MemLeak . Pertanto, la perdita di memoria è causata da una tabella hash utilizzata all'interno della classe MemLeak .

perdita di memoria della tabella hash

Infine, osserva il Java Heap subito dopo il nostro OutOfMemoryError in cui le giovani e le vecchie generazioni sono completamente piene .

errore di memoria

Conclusione

Le perdite di memoria sono tra i problemi più difficili da risolvere delle applicazioni Java, poiché i sintomi sono vari e difficili da riprodurre. Qui, abbiamo delineato un approccio graduale alla scoperta delle perdite di memoria e all'identificazione delle loro fonti. Ma soprattutto, leggi attentamente i tuoi messaggi di errore e presta attenzione alle tracce del tuo stack: non tutte le perdite sono così semplici come sembrano.

Appendice

Insieme a Java VisualVM, ci sono molti altri strumenti in grado di eseguire il rilevamento delle perdite di memoria. Molti rilevatori di perdite operano a livello di libreria intercettando le chiamate alle routine di gestione della memoria. Ad esempio, HPROF , è un semplice strumento a riga di comando in bundle con Java 2 Platform Standard Edition (J2SE) per la profilazione dell'heap e della CPU. L'output di HPROF può essere analizzato direttamente o utilizzato come input per altri strumenti come JHAT . Quando lavoriamo con le applicazioni Java 2 Enterprise Edition (J2EE), esistono numerose soluzioni di analisi di dump dell'heap più semplici, come IBM Heapdumps per i server delle applicazioni Websphere.