Analisi della memoria Java

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.

Percorso della radice GC

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.

Albero dei dominatori

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.

Visualizzazione AHAT che mostra le istanze

Fai clic sul corso per trovare tutte le istanze.

Visualizzazione AHAT che mostra le istanze di MainActivity Fai clic sull'istanza MainActivity per esaminarla.

AHAT che mostra i dettagli di un'istanza

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.

Percorso di esempio AHAT dalla radice GC e dalle dimensioni dell'oggetto

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.

Anteprima bitmap AHAT

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.

  1. Azione: in Memory Lab, tocca Fai trapelare un'attività. Viene avviato LeakedActivity che si auto-infiltra intenzionalmente.
  2. Dump: acquisisci un dump dell'heap.
  3. Analizza: fai clic su Perdite di attività nella barra laterale di AHAT.
  4. Verifica: AHAT elencherà com.android.memorylab.LeakedActivity come trapelato perché il relativo campo mDestroyed è true (a indicare che il ciclo di vita dell'attività è terminato), ma è ancora raggiungibile da una radice GC.

Pagina AHAT Activity Leaks

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

  1. 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 .
    
  2. Azione: tocca Alloca memoria Java(10 MB) più volte nell'app.

  3. 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 .
    
  4. 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.hprof
    
  5. Panoramica 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.

Panoramica di AHAT con Delta

  1. 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 MainActivity in alto con un delta positivo elevato.

Visualizzazione con accesso root di AHAT con delta

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

  1. 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/.MainActivity
    
  2. Azione: tocca Alloca memoria Java(10 MB) alcune volte.

  3. Dump: acquisisci un dump dell'heap ed estrailo.

  4. Analizza: apri il dump in AHAT. Vai a un'istanza byte[] di grandi dimensioni. ad esempio, esamina MainActivity → mJavaAllocations (ArrayList) → elementData (Object[]) → elemento dell'array [0].

  5. Verifica: nella visualizzazione dell'istanza, controlla la sezione Sito di allocazione. Verrà visualizzata l'analisi dello stack completa che porta a MainActivity.allocateJava.

Sito di allocazione AHAT

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:

  1. Apri la pagina Allocazioni e filtra per java.lang.String.
  2. Quando confronti due dump dell'heap con --baseline, controlla se i conteggi delle istanze java.lang.String e i byte totali aumentano in modo sproporzionato dopo l'idratazione di un feed o il caricamento di una cache locale.
  3. 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 un LruCache<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 heap com.android.art (Java) e libc.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:

  1. Base: stato di inattività.
  2. Java Churn: allocazioni temporanee che vengono immediatamente raccolte come spazzatura.
  3. Allocazione Java persistente: allocazione di oggetti Java che rimangono in memoria.
  4. Allocazione bitmap: allocazione di asset grafici di grandi dimensioni (che si trovano nella memoria heap/memoria video nativa).
  5. Recupero: liberazione di tutte le risorse allocate.

1. Lanciare e preparare

  1. 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.

  1. 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
    EOF
    
  2. Attiva 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_ALL
    
  3. Alternativa (strumento CLI): puoi anche avviare il profilo utilizzando lo script heap_profile direttamente, 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

UI di Perfetto che mostra la baseline della fase 1

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 di Heap 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.

UI di Perfetto che mostra l'abbandono nella fase 2 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.

Perfetto UI che mostra le allocazioni Java della fase 2

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.allocateJava che contribuisce alle dimensioni mantenute.

UI Perfetto che mostra l'allocazione Java
persistente della fase 3 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.

Perfetto UI che mostra le allocazioni Java della fase 3

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).

UI di Perfetto che mostra l'allocazione bitmap della fase 4 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.

UI di Perfetto che mostra le allocazioni native della fase 4

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 un System.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.

L'interfaccia utente di Perfetto che mostra il recupero della fase 5

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

  1. Baseline First: esegui sempre un dump dell'heap "di base" dopo l'inizializzazione dell'app, ma prima di eseguire l'azione che stai testando.
  2. 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.
  3. 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 →