Como analisar a memória Java

Os aplicativos Java e Kotlin gerenciam a memória usando um heap coletado por lixo. Quando os objetos não estão mais acessíveis, o coletor de lixo (GC) eventualmente recupera o espaço deles. Os vazamentos de memória ocorrem quando objetos que não são mais necessários ainda são mantidos por "raízes de GC", impedindo que sejam recuperados.

Principais conceitos

Raízes de GC

Uma raiz de GC é um tipo especial de objeto que o coletor de lixo trata como sempre acessível. Por exemplo:

  • Linhas de execução ativas (e objetos referenciados nos frames de pilha Java em execução no momento).
  • Classes com métodos em execução ativa.
  • Referências JNI (referências globais ou locais mantidas pelo código nativo).

Caminho para a raiz do GC

Enquanto houver uma cadeia de referências de uma raiz de GC para um objeto, esse objeto será "acessível" e não poderá ser coletado como lixo. Essa cadeia é chamada de caminho para a raiz do GC. Para corrigir um vazamento de memória, é necessário identificar e interromper essa cadeia.

Caminho para a raiz do GC

Árvores dominadoras

Embora o caminho para a raiz de GC informe por que um objeto está ativo, ele não informa quanta memória seria recuperada se essa referência fosse quebrada. Para isso, usamos árvores dominadoras.

O objeto A domina o objeto B se todos os caminhos de qualquer raiz do GC para B passarem por A. Se A dominar B, a recuperação de A também vai garantir que B possa ser recuperado, porque não há outros caminhos de qualquer raiz para B.

O diagrama a seguir mostra um gráfico de objetos e a árvore de dominadores correspondente. Observe como o objeto D é alcançado por A e B no gráfico. Portanto, nem A nem B dominam D. Em vez disso, a raiz do GC é o dominador mais próximo.

Árvore de dominadores

Como obter heap dumps do Java

Um heap dump é um instantâneo de todos os objetos no heap Java em um momento específico.

Como usar a ADB

Para capturar um despejo de heap de um processo em execução, transmita o nome do pacote diretamente para am dumpheap. Para executar esse comando, crie o app com <profileable android:shell="true"/> ou <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 .

Como usar o Perfetto

O Perfetto também pode capturar despejos de heap Java como parte de um rastreamento em todo o sistema ao ativar a fonte de dados android.java_hprof na configuração do Perfetto. Isso é útil para correlacionar o estado do heap com outros eventos do sistema.

Para capturar um heap dump do app MemoryLab usando o Perfetto, use o comando a seguir:

# 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

Consulte: Heap dumps do Java na documentação do Perfetto.

Analisar com o AHAT

A AHAT (Android Heap Analysis Tool) é a ferramenta recomendada para visualizar arquivos .hprof em um navegador da Web.

Como começar a usar o AHAT

Se você tiver o ahat instalado no seu caminho, inicie-o com:

ahat heap.hprof

Ou execute o jar independente:

java -jar ahat.jar heap.hprof

Em seguida, abra o navegador em http://localhost:7100.

Para detalhes sobre como conseguir ou criar o AHAT, consulte o repositório de origem do AHAT.

Principais fluxos de trabalho de análise

Encontrar vazamentos

Pesquise sua classe de atividade (MainActivity) na visualização Alocações.

Visualização do AHAT mostrando instâncias

Clique na classe para encontrar todas as instâncias.

Visualização do AHAT mostrando instâncias de MainActivity Clique na instância MainActivity para inspecioná-la.

AHAT mostrando os detalhes de uma instância

Na visualização da instância, você encontra o Caminho de amostra da raiz do GC, que mostra a cadeia de referências que impede a coleta de lixo do objeto, e o Tamanho do objeto, que mostra quanta memória está sendo retida por essa instância específica.

Caminho de amostra do AHAT da raiz de GC e do tamanho do objeto

Como analisar bitmaps

O AHAT tem suporte especial para visualizar objetos android.graphics.Bitmap, que geralmente consomem muita memória. Clique em uma instância de bitmap para ver uma prévia renderizada do conteúdo.

Visualização de bitmap do AHAT

Página "Vazamentos de atividade"

O AHAT tem uma visualização especializada para identificar atividades vazadas, que são um dos vazamentos de memória mais comuns e impactantes no Android.

  1. Ação: no MemoryLab, toque em Vazar uma atividade. Isso inicia LeakedActivity, que vaza intencionalmente.
  2. Despejo: gere um heap dump.
  3. Analisar: clique em Vazamentos de atividade na barra lateral do AHAT.
  4. Verificar: o AHAT vai listar com.android.memorylab.LeakedActivity como vazado porque o campo mDestroyed é verdadeiro (indicando que o ciclo de vida da atividade terminou), mas ainda é possível acessá-lo de uma raiz do GC.

Página de vazamentos de atividade do AHAT

Comparar heap dumps

Comparar dois despejos de heap é uma das maneiras mais eficientes de identificar problemas de memória. Ao comparar um despejo de linha de base "limpo" com um despejo feito após a execução de algumas ações, é possível ver imediatamente quais objetos foram acumulados.

Exercício: identificação de vazamentos por diferenciação

  1. Baseline: inicie o MemoryLab e faça um heap dump de referência:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof
    adb pull /data/local/tmp/base.hprof .
    
  2. Ação: toque em Alocar memória Java(10 MB) várias vezes no app.

  3. Final: faça um segundo heap dump:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof
    adb pull /data/local/tmp/leaked.hprof .
    
  4. Comparar: inicie o AHAT com o segundo despejo como principal e o primeiro como a referência:

    java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprof
    
  5. Visão geral da análise: a página Visão geral agora inclui uma coluna Δ (Delta). Você vai notar um grande delta positivo para o heap app, indicando um crescimento significativo da memória.

Visão geral do AHAT com Delta

  1. Detalhar: clique em rooted no menu. Esta página mostra objetos acessíveis das raízes de GC, classificados pelo tamanho retido. Você vai ver MainActivity na parte de cima com um grande delta positivo.

Visualização com delta do AHAT com acesso root

Gravando stack traces de alocação

Embora o Caminho de amostra da raiz do GC informe por que um objeto ainda está ativo, ele não informa como ele foi criado. Os rastreamentos de pilha de alocação fornecem a linha exata de código que alocou um objeto.

Conceito e compensações: gravar o rastreamento de pilha de cada alocação é caro em termos de computação e consome muita memória. Em um app de produção grande, isso pode tornar o app quase inutilizável. No entanto, o MemoryLab é um aplicativo pequeno o suficiente para que possamos ativar esse rastreamento com segurança e identificar a origem das alocações.

Exercício: identificar a origem de matrizes de bytes

  1. Comece com o rastreamento: force a interrupção do MemoryLab e reinicie-o com a flag --track-allocation. Aumente a profundidade da pilha padrão para capturar mais contexto.

    # 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. Ação: toque em Allocate Java Memory(10MB) algumas vezes.

  3. Despejar: gere e extraia um heap dump.

  4. Analisar: abra o despejo no AHAT. Navegue até uma instância grande do byte[]. Por exemplo, inspecione MainActivity → mJavaAllocations (ArrayList) → elementData (Object[]) → elemento de matriz [0].

  5. Verificar: na visualização da instância, consulte a seção Site de alocação. Ele vai mostrar o stack trace completo que leva a MainActivity.allocateJava.

Site de alocação do AHAT

Como encontrar strings duplicadas e excesso de hidratação

Mesmo quando um app não tem vazamentos clássicos de raízes de GC, o heap Java ativo pode ser inchado por milhares de instâncias java.lang.String duplicadas criadas durante a deserialização de JSON, Protobuf, Cursor ou banco de dados Room. Chaves repetidas, strings de status, rótulos de categoria ou URLs geralmente são alocados novamente em cada resposta de rede ou consulta de banco de dados. Em grandes apps de feed, mensagens e conteúdo, as strings duplicadas representam rotineiramente de 30% a 60% da memória String ativa.

Para inspecionar strings duplicadas no AHAT:

  1. Abra a página Alocações e filtre por java.lang.String.
  2. Ao comparar dois heap dumps com --baseline, verifique se as contagens de instâncias java.lang.String e o total de bytes crescem de forma desproporcional após a hidratação de um feed ou o carregamento de um cache local.
  3. Navegue pela tabela de instâncias java.lang.String (classificada por tamanho ou valor) para identificar valores de string idênticos mantidos em vários objetos de modelo na memória.
  • Solução: evite chamar String.intern() indiscriminadamente em entradas arbitrárias de usuário ou de rede, porque a tabela de strings internadas do tempo de execução é global e pode introduzir disputa de bloqueio ou reter strings por mais tempo do que o necessário. Em vez disso, remova duplicidades de strings de domínio de alta frequência durante a desserialização usando um cache de remoção de duplicidades limitado e no escopo (como um LruCache<String, String> no seu analisador ou adaptador) ou represente conjuntos fixos de valores como enums ou constantes inteiras.

Analisar a dinâmica da memória Java (perfil combinado)

Para ter uma visão completa do comportamento da memória de um aplicativo, combine contadores de memória, atividade de linhas de execução e criação de perfil de alocação com base em callstacks em um único rastreamento do Perfetto. Isso permite correlacionar métricas de memória em todo o sistema (como RSS e tamanho do heap) com a execução de código e sites de alocação específicos.

Vamos usar uma configuração combinada que permite:

  • Contadores de memória (linux.process_stats): pesquisa RSS e outras métricas de memória.
  • ATrace (categorias dalvik, memory e sched): captura estados de encadeamento e eventos de GC.
  • Heapprofd (android.heapprofd): tem como destino os heaps com.android.art (Java) e libc.malloc (nativo) com despejos contínuos a cada 5 segundos.

Exercício: análise combinada de memória

Neste exercício, vamos executar o app MemoryLab e realizar uma sequência de operações de memória para observar diferentes padrões no rastreamento:

  1. Referência: estado de inatividade.
  2. Java Churn: alocações temporárias que são imediatamente coletadas como lixo.
  3. Alocação persistente de Java: alocar objetos Java que permanecem na memória.
  4. Alocação de bitmap: alocar recursos gráficos grandes (que ficam no heap nativo/memória gráfica).
  5. Reivindicação: liberação de todos os recursos alocados.

1. Lançamento e preparação

  1. Force a interrupção e reinicie o app para garantir um estado limpo:

    adb shell am force-stop com.android.memorylab
    adb shell am start -W -n com.android.memorylab/.MainActivity
    

2. Iniciar rastreamento e acionar sequência

Vamos iniciar um rastreamento de 40 segundos e acionar os eventos de memória usando comandos am broadcast.

  1. Inicie o rastreamento:

    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. Acione a sequência (execute estes comandos no terminal do host enquanto o rastreamento está em execução, respeitando o tempo sugerido):

    # 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 (ferramenta de linha de comando): também é possível iniciar o perfil usando o script heap_profile diretamente, segmentando heaps Java e nativos com dumps contínuos:

    external/perfetto/tools/heap_profile -n com.android.memorylab \
      --heaps com.android.art,libc.malloc \
      -c 5000 \
      -d 40000 \
      -o java_memory_profile
    

3. Analisar o rastreamento combinado

Abra o java_memory.perfetto-trace coletado na interface do Perfetto.

Principais rastreamentos no Perfetto

Antes de analisar a linha do tempo, localize estas faixas essenciais para o processo de com.android.memorylab:

  1. mem.rss.anon (RSS anônimo): encontrado na seção Memória do processo. Essa faixa mede a memória física (RAM) alocada ao processo pelo SO. Ele representa o consumo de memória real.
  2. Heap size (KB): também na seção Memória. Esse é um contador específico do Dalvik/ART que representa o espaço de endereço virtual reservado para o heap Java. Ele reflete o limite de heap interno da VM, que flutua à medida que os objetos são alocados e a coleta de lixo é executada.
  3. HeapTaskDaemon: encontrado na lista de linhas de execução do processo. Essa é a linha de execução em segundo plano em que o coletor de lixo do ART realiza a maior parte do trabalho. A atividade aqui indica passes ativos do GC.
  4. Despejos de alocação contínua (heapprofd): mostrados como fatias coloridas ao longo da linha do tempo superior. Cada fatia representa uma duração no tempo. Ao clicar em uma única fatia ou selecionar um período, você pode inspecionar o Flamegraph (no painel de baixo) para com.android.art (alocações Java) ou libc.malloc (alocações nativas) e ver o que foi alocado durante esse período.

Análise cronológica de fases

Vamos analisar o rastreamento em ordem cronológica para ver como essas faixas interagem durante cada fase do exercício.

Fase 1: baseline (0s a 5s)
  • O que está acontecendo: o app está inativo, aguardando comandos.
  • Status do rastreamento:
    • mem.rss.anon: linha reta na referência (normalmente entre 60 e 80 MB, dependendo do dispositivo).
    • Heap size (KB): linha reta, correspondente à alocação de heap inicial do Java.
    • HeapTaskDaemon: inativo (nenhum intervalo mostrando a execução).
    • Despejos de alocação: mostram alocações de referência mínimas.

Interface do Perfetto mostrando o valor de referência da Fase 1

Fase 2: rotatividade de alocação do Java (5 a 15 segundos)
  • O que está acontecendo: o AllocationChurnThread é iniciado, alocando e descartando repetidamente matrizes de 1 MB.
  • Status do rastreamento:
    • Heap size (KB): mostra um padrão em dente de serra rápido. O tamanho do heap aumenta à medida que as alocações se acumulam e cai acentuadamente quando o GC é executado.
    • HeapTaskDaemon: mostra atividade quase constante, com intervalos de execução alinhados perfeitamente com as quedas no padrão de dente de serra Heap size.
    • mem.rss.anon: rastreia a atividade de heap do Java.
    • Heap dumps de alocação do Java: ao selecionar intervalos nessa faixa, são mostradas alocações de heap com.android.art.

Interface do Perfetto mostrando a rotatividade da Fase 2 As amostras de alocação revelam AllocationChurnThread como o alocador principal, com todas as alocações compartilhando a mesma callstack que aponta para a lambda dentro de MainActivity.java.

Interface do Perfetto mostrando alocações do Java na Fase 2

Fase 3: alocação persistente de Java (15 a 20 segundos)
  • O que está acontecendo: alocamos 10 MB de objetos Java e mantemos uma referência a eles em mJavaAllocations.
  • Status do rastreamento:
    • Heap size (KB): o valor de referência dos degraus em forma de serra aumenta em aproximadamente 10 MB.
    • mem.rss.anon: aumenta em aproximadamente 10 MB, já que o SO precisa fazer backup dessa alocação persistente com novas páginas físicas.
    • Heap dumps de alocação do Java: ao selecionar intervalos nessa faixa, são mostradas alocações de heap com.android.art.
    • Despejos de alocação (flamegraph): a inspeção do heap com.android.art do despejo feito nesta janela mostra um novo caminho de alocação de MainActivity.allocateJava, que contribui para o tamanho retido.

Interface do Perfetto mostrando alocação persistente de Java da Fase 3 Selecione uma amostra de alocação que cubra uma duração que se sobreponha ao aumento de 10 MB para a alocação permanente. Você vai notar que os callstacks de alocação divergem para dois sites diferentes. Um é responsável pela mesma rotatividade de alocação de curta duração que vimos antes, e o outro pela nova alocação de longa duração.

Interface do Perfetto mostrando alocações Java da Fase 3

Fase 4: alocação de bitmap (20 a 30 segundos)
  • O que está acontecendo: também alocamos 20 MB de bitmaps.
  • Status do rastreamento:
    • Heap size (KB): igual a antes.
    • mem.rss.anon: mostra um aumento significativo de aproximadamente 20 MB, correspondente às alocações nativas dos dados de pixels do bitmap.
    • Despejos de alocação (flamegraph): dessa vez, o foco está nas fatias do heap libc.malloc (nativo).

Interface do Perfetto mostrando a alocação de bitmap da fase 4 Os callstacks de alocação nativa revelam a alocação de bitmap originada de bibliotecas gráficas nativas. Esse é um bom caso de uso para o rastreamento de alocação nativa, já que você não verá essas alocações de bitmap no heap do Java.

Interface do Perfetto mostrando alocações nativas da Fase 4

Fase 5: recuperação (30 a 40 segundos)
  • O que está acontecendo: acionamos FREE_ALL, limpando referências a todas as alocações e bitmaps persistentes do Java, seguidos por um System.gc() explícito.
  • Status do rastreamento:
    • Heap size (KB): volta ao nível de referência.
    • mem.rss.anon: diminui novamente, mostrando o SO recuperando as páginas físicas.
    • HeapTaskDaemon: mostra uma explosão final de atividade ao processar a coleta de lixo.

Interface do Perfetto mostrando a recuperação da Fase 5

Monitorar OOMs históricos (ApplicationExitInfo)

Capturar um LMK no momento em que ele ocorre é ótimo para depuração ativa, mas para telemetria de campo, use a API ApplicationExitInfo. Isso permite que o app descubra por que ele foi encerrado em uma sessão anterior.

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
    }
}

Práticas recomendadas

  1. Primeiro a referência: sempre faça um heap dump de referência depois que o app for inicializado, mas antes de realizar a ação que você está testando.
  2. Use a página de vazamentos de atividade do AHAT: o AHAT inclui uma página dedicada Vazamentos de atividade que identifica automaticamente instâncias de atividade que foram destruídas, mas ainda estão na memória. Essa é geralmente a maneira mais rápida de encontrar vazamentos comuns.
  3. Verificar o caminho para raízes de GC: para qualquer objeto vazado, use a visualização Caminho da raiz no AHAT para entender exatamente qual referência o mantém ativo (por exemplo, um campo estático, uma linha de execução de longa duração ou um listener registrado).

← Ferramentas | ↑ Para cima | Bitmaps →