Le applicazioni Java e Kotlin gestiscono la memoria tramite un heap con garbage collection. Quando gli oggetti non sono più raggiungibili, il Garbage Collector (GC) alla fine recupera il loro spazio. Le perdite di memoria si verificano quando gli oggetti non più necessari vengono ancora mantenuti dalle "radici GC", impedendone il recupero.
Concetti principali
Radici GC
Una radice GC è un tipo speciale di oggetto che il garbage collector considera sempre raggiungibile. Ecco alcuni esempi:
- Thread attivi (e oggetti a cui fanno riferimento i frame dello stack Java attualmente in esecuzione).
- Classi con metodi in esecuzione.
- Riferimenti JNI (riferimenti globali o locali mantenuti dal codice nativo).
Percorso della radice GC
Finché esiste una catena di riferimenti da una radice GC a un oggetto, quest'ultimo è "raggiungibile" e non può essere sottoposto a garbage collection. Questa catena è chiamata percorso alla radice GC. Per correggere una perdita di memoria, devi identificare e interrompere questa catena.

Alberi dominatori
Il percorso alla radice GC indica perché un oggetto è attivo, ma non indica la quantità di memoria che verrebbe recuperata se questo riferimento venisse interrotto. A questo scopo, utilizziamo gli alberi dominatori.
Si dice che l'oggetto A domina l'oggetto B se ogni percorso da qualsiasi radice GC a B deve passare per A. Se A domina B, il recupero di A garantirà anche il recupero di B, perché non esistono altri percorsi da qualsiasi radice a B.
Il seguente diagramma mostra un grafico degli oggetti e il relativo albero dominatore. Nota come l'oggetto D viene raggiunto sia da A che da B nel grafico, quindi né A né B dominano D; al contrario, la radice GC è il suo dominatore più vicino.

Ottenere i dump dell'heap Java
Un dump dell'heap è uno snapshot di tutti gli oggetti nell'heap Java in un momento specifico.
Utilizzo di ADB
Per acquisire un dump dell'heap da un processo in esecuzione, puoi passare il nome del pacchetto
direttamente a am dumpheap. Per eseguire questo comando, devi creare la tua app con
<profileable android:shell="true"/> o <debuggable>.
# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof
# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .
Utilizzo di Perfetto
Perfetto può anche acquisire dump dell'heap Java nell'ambito di una traccia a livello di sistema
attivando l'origine dati android.java_hprof nella configurazione di Perfetto. Questa operazione è utile per correlare lo stato dell'heap con altri eventi di sistema.
Per acquisire un dump dell'heap per l'app MemoryLab utilizzando Perfetto, puoi utilizzare il seguente comando:
# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
config {
name: "android.java_hprof"
java_hprof_config {
process_cmdline: "com.android.memorylab"
}
}
}
EOF
# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
-t 10s -c /tmp/java_heap.pbtx
Consulta la sezione Dump dell'heap Java nella documentazione di Perfetto.
Analisi con AHAT
AHAT (Android Heap Analysis Tool) è lo strumento consigliato per visualizzare i file .hprof
in un browser web.
Avvio di AHAT
Se hai installato ahat nel tuo percorso, avvialo con:
ahat heap.hprof
In alternativa, esegui il file JAR autonomo:
java -jar ahat.jar heap.hprof
Poi apri il browser all'indirizzo http://localhost:7100.
Per informazioni dettagliate su come ottenere o creare AHAT, consulta il repository di origine AHAT.
Workflow di analisi chiave
Trovare perdite
Cerca la classe Attività (MainActivity) nella visualizzazione Assegnazioni.

Fai clic sul corso per trovare tutte le istanze.
Fai clic sull'istanza
MainActivity per esaminarla.

Nella visualizzazione dell'istanza, puoi trovare il percorso di esempio dalla radice GC, che mostra la catena di riferimenti che impedisce la garbage collection dell'oggetto, e le dimensioni dell'oggetto, che mostrano la quantità di memoria conservata da questa istanza specifica.

Analisi delle bitmap in corso…
AHAT offre un supporto speciale per la visualizzazione degli oggetti android.graphics.Bitmap, che
spesso consumano molta memoria. Fai clic su un'istanza bitmap per visualizzare un'anteprima
renderizzata dei suoi contenuti.

Pagina Perdite di attività
AHAT ha una visualizzazione specializzata per identificare le attività con perdite, che sono una delle perdite di memoria più comuni e di maggiore impatto in Android.
- Azione: in Memory Lab, tocca Fai trapelare un'attività. Viene avviato
LeakedActivityche si auto-infiltra intenzionalmente. - Dump: acquisisci un dump dell'heap.
- Analizza: fai clic su Perdite di attività nella barra laterale di AHAT.
- Verifica: AHAT elencherà
com.android.memorylab.LeakedActivitycome trapelato perché il relativo campomDestroyedè true (a indicare che il ciclo di vita dell'attività è terminato), ma è ancora raggiungibile da una radice GC.

Differenze tra i dump dell'heap
Il confronto di due dump dell'heap è uno dei modi più efficaci per identificare i problemi di memoria. Confrontando un dump di base "pulito" con un dump eseguito dopo aver eseguito alcune azioni, puoi vedere immediatamente quali oggetti sono stati accumulati.
Esercizio: identificazione delle perdite tramite differenze
Baseline: avvia MemoryLab e acquisisci un dump dell'heap di base:
adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof adb pull /data/local/tmp/base.hprof .Azione: tocca Alloca memoria Java(10 MB) più volte nell'app.
Finale: esegui un secondo dump dell'heap:
adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof adb pull /data/local/tmp/leaked.hprof .Confronta: avvia AHAT con il secondo dump come principale e il primo come baseline:
java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprofPanoramica di Analizza: la pagina Panoramica ora include una colonna Δ (Delta). Vedrai un delta positivo elevato per l'heap
app, che indica una crescita significativa della memoria.

- Visualizza in dettaglio: fai clic su Rooted nel menu. Questa pagina mostra gli oggetti
raggiungibili dalle radici GC, ordinati in base alle dimensioni mantenute. Vedrai
MainActivityin alto con un delta positivo elevato.

Registrazione delle analisi dello stack di allocazione
Anche se il percorso di esempio dalla radice GC ti dice perché un oggetto è ancora attivo, non ti dice come è stato creato. Le analisi dello stack di allocazione forniscono la riga di codice esatta che ha allocato un oggetto.
Concetto e compromessi: la registrazione dell'analisi dello stack di ogni allocazione è costosa dal punto di vista computazionale e consuma una quantità significativa di memoria. In un'app di produzione di grandi dimensioni, questo può rendere l'app quasi inutilizzabile. Tuttavia, MemoryLab è un'applicazione abbastanza piccola da consentirci di attivare in sicurezza questo monitoraggio per individuare l'origine delle allocazioni.
Esercizio: identificare l'origine degli array di byte
Inizia con il monitoraggio: arresta forzatamente MemoryLab e riavvialo con il flag
--track-allocation. Aumenta la profondità dello stack predefinita per acquisire più contesto.# Increase the allocation tracker's stack depth (requires a process restart) adb shell setprop dalvik.vm.allocTrackerMaxStack 16 adb shell am force-stop com.android.memorylab adb shell am start --track-allocation -n com.android.memorylab/.MainActivityAzione: tocca Alloca memoria Java(10 MB) alcune volte.
Dump: acquisisci un dump dell'heap ed estrailo.
Analizza: apri il dump in AHAT. Vai a un'istanza
byte[]di grandi dimensioni. ad esempio, esaminaMainActivity→mJavaAllocations(ArrayList) →elementData(Object[]) → elemento dell'array[0].Verifica: nella visualizzazione dell'istanza, controlla la sezione Sito di allocazione. Verrà visualizzata l'analisi dello stack completa che porta a
MainActivity.allocateJava.

Ricerca di stringhe duplicate e sovraccarico di idratazione
Anche quando un'app non presenta perdite di GC-root classiche, l'heap Java live può essere
gonfiato da migliaia di istanze java.lang.String duplicate create durante
la deserializzazione di JSON, Protobuf, Cursor o del database Room. Chiavi ripetute,
stringhe di stato, etichette di categoria o URL vengono spesso allocati di nuovo a ogni
risposta di rete o query di database. Nelle app di feed, messaggistica e contenuti di grandi dimensioni, le stringhe duplicate rappresentano regolarmente dal 30% al 60% della memoria String live.
Per esaminare le stringhe duplicate in AHAT:
- Apri la pagina Allocazioni e filtra per
java.lang.String. - Quando confronti due dump dell'heap con
--baseline, controlla se i conteggi delle istanzejava.lang.Stringe i byte totali aumentano in modo sproporzionato dopo l'idratazione di un feed o il caricamento di una cache locale. - Sfoglia la tabella delle istanze
java.lang.String(ordinata per dimensione o valore) per individuare valori stringa identici conservati in più oggetti modello in memoria.
- Soluzione: evita di chiamare
String.intern()in modo indiscriminato su input arbitrari dell'utente o della rete, perché la tabella interna di runtime è globale e può introdurre contese di blocco o conservare le stringhe più a lungo del necessario. In alternativa, rimuovi i duplicati delle stringhe di dominio ad alta frequenza durante la deserializzazione utilizzando una cache di deduplicazione delimitata e con ambito (ad esempio unLruCache<String, String>all'interno del parser o dell'adattatore) oppure rappresenta insiemi fissi di valori come enumerazioni o costanti intere.
Analisi della dinamica della memoria Java (profilo combinato)
Per avere un quadro completo del comportamento della memoria di un'applicazione, puoi combinare i contatori di memoria, l'attività dei thread e la profilazione dell'allocazione basata sullo stack di chiamate in una singola traccia Perfetto. In questo modo puoi correlare le metriche di memoria a livello di sistema (come RSS e dimensioni dell'heap) con siti di esecuzione e allocazione di codice specifici.
Utilizzeremo una configurazione combinata che consente di:
- Contatori di memoria (
linux.process_stats): esegue il polling di RSS e di altre metriche di memoria. - ATrace (categorie
dalvik,memory,sched): acquisisce gli stati dei thread e gli eventi GC. - Heapprofd (
android.heapprofd): esegue il targeting degli heapcom.android.art(Java) elibc.malloc(nativi) con dump continui ogni 5 secondi.
Esercizio: analisi combinata della memoria
In questo esercizio, eseguiremo l'app MemoryLab ed eseguiremo una sequenza di operazioni di memoria per osservare diversi pattern nella traccia:
- Base: stato di inattività.
- Java Churn: allocazioni temporanee che vengono immediatamente raccolte come spazzatura.
- Allocazione Java persistente: allocazione di oggetti Java che rimangono in memoria.
- Allocazione bitmap: allocazione di asset grafici di grandi dimensioni (che si trovano nella memoria heap/memoria video nativa).
- Recupero: liberazione di tutte le risorse allocate.
1. Lanciare e preparare
Forza l'interruzione e riavvia l'app per assicurarti uno stato pulito:
adb shell am force-stop com.android.memorylab adb shell am start -W -n com.android.memorylab/.MainActivity
2. Avvia la tracciatura e la sequenza di trigger
Avvieremo una traccia di 40 secondi e attiveremo gli eventi di memoria utilizzando i comandi am
broadcast.
Avvia la traccia:
adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF buffers: { size_kb: 131072 fill_policy: RING_BUFFER } data_sources: { config { name: "linux.process_stats" target_buffer: 0 process_stats_config { scan_all_processes_on_start: true proc_stats_poll_ms: 100 } } } data_sources: { config { name: "linux.ftrace" target_buffer: 0 ftrace_config { ftrace_events: "sched/sched_switch" ftrace_events: "task/task_newtask" ftrace_events: "task/task_rename" ftrace_events: "ftrace/print" atrace_categories: "dalvik" atrace_categories: "am" atrace_categories: "res" atrace_categories: "memory" atrace_categories: "sched" atrace_apps: "com.android.memorylab" } } } data_sources: { config { name: "android.heapprofd" target_buffer: 0 heapprofd_config { sampling_interval_bytes: 4096 process_cmdline: "com.android.memorylab" heaps: "libc.malloc" heaps: "com.android.art" shmem_size_bytes: 8388608 block_client: true continuous_dump_config { dump_phase_ms: 1000 dump_interval_ms: 5000 } } } } duration_ms: 40000 EOFAttiva la sequenza (esegui questi comandi nel terminale host mentre la traccia è in esecuzione, rispettando la tempistica suggerita):
# Wait ~5s for trace initialization, then start Java churn: adb shell am broadcast -a com.android.memorylab.CHURN_JAVA # Wait ~10s (at 15s mark), allocate 10MB of persistent Java memory: adb shell am broadcast -a com.android.memorylab.ALLOC_JAVA # Wait ~5s (at 20s mark), allocate 20MB of Bitmaps (native/graphics): adb shell am broadcast -a com.android.memorylab.LEAK_BITMAP # Wait ~10s (at 30s mark), free everything: adb shell am broadcast -a com.android.memorylab.FREE_ALLAlternativa (strumento CLI): puoi anche avviare il profilo utilizzando lo script
heap_profiledirettamente, scegliendo come target gli heap Java e nativi con dump continui:external/perfetto/tools/heap_profile -n com.android.memorylab \ --heaps com.android.art,libc.malloc \ -c 5000 \ -d 40000 \ -o java_memory_profile
3. Analisi della traccia combinata
Apri java_memory.perfetto-trace raccolti nell'interfaccia utente di
Perfetto.
Tracce chiave in Perfetto
Prima di analizzare la sequenza temporale, individua queste tracce essenziali per la procedura di
com.android.memorylab:
mem.rss.anon(RSS anonimo): si trova nella sezione Memoria del processo. Questa traccia misura la memoria fisica (RAM) allocata al processo dal sistema operativo. Rappresenta il footprint della memoria effettivo.Heap size (KB): anche nella sezione Memoria. Si tratta di un contatore specifico di Dalvik/ART che rappresenta lo spazio di indirizzi virtuale riservato all'heap Java. Riflette il limite dell'heap interno della VM, che varia man mano che gli oggetti vengono allocati e viene eseguito GC.HeapTaskDaemon: si trova nell'elenco dei thread nel processo. Questo è il thread in background in cui ART Garbage Collector esegue la maggior parte del suo lavoro. L'attività qui indica i pass Google Cloud attivi.- Dump dell'allocazione continua (heapprofd): visualizzati come sezioni colorate lungo la cronologia superiore. Ogni sezione rappresenta una durata nel tempo. Se fai clic su una singola sezione o selezioni un intervallo di tempo, puoi esaminare il grafico a fiamme (nel riquadro inferiore) per com.android.art (allocazioni Java) o libc.malloc (allocazioni native) per vedere cosa è stato allocato durante quel periodo.
Analisi cronologica delle fasi
Esaminiamo la traccia in ordine cronologico per vedere come interagiscono le tracce durante ogni fase dell'esercizio.
Fase 1: base di riferimento (0-5 secondi)
- Che cosa sta succedendo: l'app è inattiva e in attesa di comandi.
- Stato della traccia:
mem.rss.anon: Linea piatta alla base (in genere intorno a 60-80 MB a seconda del dispositivo).Heap size (KB): linea piatta, corrispondente all'allocazione iniziale dell'heap Java.HeapTaskDaemon: Inattivo (nessuna sezione mostra l'esecuzione).- Dump dell'allocazione: mostra le allocazioni di base minime.

Fase 2: churn dell'allocazione Java (5-15 secondi)
- Che cosa sta succedendo: viene avviato
AllocationChurnThread, allocando ripetutamente array da 1 MB e scartandoli. - Stato della traccia:
Heap size (KB): mostra un pattern a dente di sega rapido. La dimensione dell'heap aumenta man mano che le allocazioni si accumulano e diminuisce bruscamente quando viene eseguito GC.HeapTaskDaemon: mostra un'attività quasi costante, con segmenti di esecuzione che si allineano perfettamente ai cali della curva a dente di sega diHeap size.mem.rss.anon: monitora l'attività dell'heap Java.- Dump dell'allocazione dell'heap Java: la selezione delle sezioni in questa traccia mostra le allocazioni dell'heap com.android.art.
Gli esempi di allocazione rivelano AllocationChurnThread come allocatore principale,
con tutte le allocazioni che condividono lo stesso callstack che punta alla lambda all'interno di
MainActivity.java.

Fase 3: allocazione Java persistente (15-20 secondi)
- Cosa sta succedendo: allochiamo 10 MB di oggetti Java e conserviamo un riferimento
in
mJavaAllocations. - Stato della traccia:
Heap size (KB): La linea di base della curva a dente di sega aumenta di circa 10 MB.mem.rss.anon: aumenta di circa 10 MB, poiché il sistema operativo deve eseguire il backup di questa allocazione persistente con nuove pagine fisiche.- Dump dell'allocazione dell'heap Java: la selezione delle sezioni in questa traccia mostra le allocazioni dell'heap com.android.art.
- Dump dell'allocazione (grafico a fiamma): l'ispezione dell'heap com.android.art
per il dump acquisito in questa finestra mostra un nuovo percorso di allocazione da
MainActivity.allocateJavache contribuisce alle dimensioni mantenute.
Seleziona un
campione di allocazione che copra una durata che si sovrappone all'aumento di 10 MB
per l'allocazione permanente. Dovresti vedere gli stack di chiamate di allocazione divergere
in due siti diversi: uno responsabile dello stesso churn di allocazione di breve durata
che abbiamo visto prima e l'altro per la nuova allocazione di lunga durata.

Fase 4: allocazione bitmap (20-30 secondi)
- Che cosa sta succedendo: inoltre, allochiamo 20 MB di bitmap.
- Stato della traccia:
Heap size (KB): Come prima.mem.rss.anon: mostra un aumento significativo di circa 20 MB, corrispondente alle allocazioni native per i dati dei pixel bitmap.- Dump dell'allocazione (grafico a fiamme): questa volta concentrati sulle sezioni per l'heap libc.malloc (nativo).
Gli stack di chiamate di allocazione nativa
rivelano l'allocazione di Bitmap proveniente dalle librerie
grafiche native. Questo è un buon caso d'uso per il monitoraggio dell'allocazione nativa, poiché
non vedrai queste allocazioni di bitmap nell'heap Java.

Fase 5: rivendicazione (30-40 secondi)
- Cosa sta succedendo: attiviamo
FREE_ALL, cancellando i riferimenti a tutte le allocazioni e le bitmap Java persistenti, seguito da unSystem.gc()esplicito. - Stato della traccia:
Heap size (KB): torna al livello di base.mem.rss.anon: Scende di nuovo, mostrando il sistema operativo che recupera le pagine fisiche.HeapTaskDaemon: mostra un'ultima raffica di attività durante l'elaborazione della garbage collection.

Monitoraggio degli errori OOM storici (ApplicationExitInfo)
Rilevare un messaggio LMK mentre si verifica è ideale per il debug attivo, ma per la telemetria sul campo puoi utilizzare l'API ApplicationExitInfo. In questo modo la tua app può
scoprire perché è stata chiusa in una sessione precedente.
ActivityManager am = getSystemService(ActivityManager.class);
List<ApplicationExitInfo> exitReasons = am.getHistoricalProcessExitReasons(null, 0, 1);
if (!exitReasons.isEmpty()) {
ApplicationExitInfo info = exitReasons.get(0);
if (info.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY) {
// App was killed by the system Low Memory Killer
}
}
Best practice
- Baseline First: esegui sempre un dump dell'heap "di base" dopo l'inizializzazione dell'app, ma prima di eseguire l'azione che stai testando.
- Utilizza la pagina Perdite di attività di AHAT: AHAT include una pagina Perdite di attività dedicata che identifica automaticamente le istanze di attività che sono state eliminate ma sono ancora memorizzate. Spesso è il modo più rapido per trovare le perdite più comuni.
- Controlla il percorso alle radici GC: per qualsiasi oggetto con perdita di memoria, utilizza la visualizzazione Percorso dalla radice in AHAT per capire esattamente quale riferimento lo mantiene attivo (ad es. un campo statico, un thread a esecuzione prolungata o un listener registrato).
← Strumenti | ↑ Su | Bitmap →