Caçando vazamentos de memória Java

Publicados: 2022-03-11

Programadores inexperientes geralmente pensam que a coleta automática de lixo do Java os libera completamente de se preocupar com o gerenciamento de memória. Este é um equívoco comum: enquanto o coletor de lixo faz o melhor que pode, é perfeitamente possível que até mesmo o melhor programador seja vítima de vazamentos de memória incapacitantes. Deixe-me explicar.

Um vazamento de memória ocorre quando as referências de objeto que não são mais necessárias são mantidas desnecessariamente. Esses vazamentos são ruins. Por um lado, eles colocam uma pressão desnecessária em sua máquina à medida que seus programas consomem mais e mais recursos. Para piorar as coisas, detectar esses vazamentos pode ser difícil: a análise estática geralmente se esforça para identificar precisamente essas referências redundantes, e as ferramentas de detecção de vazamento existentes rastreiam e relatam informações refinadas sobre objetos individuais, produzindo resultados difíceis de interpretar e sem precisão.

Em outras palavras, os vazamentos são muito difíceis de identificar ou identificados em termos específicos demais para serem úteis.

Na verdade, existem quatro categorias de problemas de memória com sintomas semelhantes e sobrepostos, mas causas e soluções variadas:

  • Desempenho : geralmente associado à criação e exclusão excessiva de objetos, longos atrasos na coleta de lixo, troca excessiva de páginas do sistema operacional e muito mais.

  • Restrições de recursos : ocorre quando há pouca memória disponível ou sua memória está muito fragmentada para alocar um objeto grande - isso pode ser nativo ou, mais comumente, relacionado ao heap Java.

  • Vazamentos de heap Java : o vazamento de memória clássico, no qual objetos Java são criados continuamente sem serem liberados. Isso geralmente é causado por referências de objetos latentes.

  • Vazamentos de memória nativa : associados a qualquer utilização de memória em crescimento contínuo que esteja fora do heap Java, como alocações feitas por código JNI, drivers ou mesmo alocações JVM.

Neste tutorial de gerenciamento de memória, focarei nos vazamentos de heaps Java e descreverei uma abordagem para detectar esses vazamentos com base em relatórios Java VisualVM e utilizando uma interface visual para analisar aplicativos baseados em tecnologia Java enquanto eles estão em execução.

Mas antes que você possa evitar e encontrar vazamentos de memória, você deve entender como e por que eles ocorrem. ( Nota: se você tiver um bom controle sobre os meandros dos vazamentos de memória, você pode pular adiante. )

Vazamentos de memória: uma cartilha

Para começar, pense no vazamento de memória como uma doença e no OutOfMemoryError do Java (OOM, para resumir) como um sintoma. Mas, como em qualquer doença, nem todos os OOMs implicam necessariamente em vazamentos de memória : um OOM pode ocorrer devido à geração de um grande número de variáveis ​​locais ou outros eventos semelhantes. Por outro lado, nem todos os vazamentos de memória necessariamente se manifestam como OOMs , especialmente no caso de aplicativos de desktop ou aplicativos cliente (que não são executados por muito tempo sem reinicializações).

Pense no vazamento de memória como uma doença e no OutOfMemoryError como um sintoma. Mas nem todos os OutOfMemoryErrors implicam em vazamentos de memória e nem todos os vazamentos de memória se manifestam como OutOfMemoryErrors.

Por que esses vazamentos são tão ruins? Entre outras coisas, o vazamento de blocos de memória durante a execução do programa geralmente degrada o desempenho do sistema ao longo do tempo, pois blocos de memória alocados, mas não utilizados, terão que ser trocados quando o sistema ficar sem memória física livre. Eventualmente, um programa pode até esgotar seu espaço de endereço virtual disponível, levando ao OOM.

Decifrando o OutOfMemoryError

Conforme mencionado acima, o OOM é uma indicação comum de um vazamento de memória. Essencialmente, o erro é gerado quando não há espaço suficiente para alocar um novo objeto. Por mais que tente, o coletor de lixo não consegue encontrar o espaço necessário e o heap não pode ser expandido mais. Assim, surge um erro, juntamente com um rastreamento de pilha.

O primeiro passo para diagnosticar seu OOM é determinar o que o erro realmente significa. Isso parece óbvio, mas a resposta nem sempre é tão clara. Por exemplo: O OOM está aparecendo porque o heap Java está cheio ou porque o heap nativo está cheio? Para ajudá-lo a responder a essa pergunta, vamos analisar algumas das possíveis mensagens de erro:

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

“espaço de pilha Java”

Essa mensagem de erro não implica necessariamente em um vazamento de memória. Na verdade, o problema pode ser tão simples quanto um problema de configuração.

Por exemplo, eu era responsável por analisar um aplicativo que produzia consistentemente esse tipo de OutOfMemoryError . Após alguma investigação, descobri que o culpado era uma instanciação de array que exigia muita memória; nesse caso, não foi culpa do aplicativo, mas sim, o servidor de aplicativos estava confiando no tamanho de heap padrão, que era muito pequeno. Resolvi o problema ajustando os parâmetros de memória da JVM.

Em outros casos, e para aplicativos de longa duração em particular, a mensagem pode ser uma indicação de que estamos mantendo involuntariamente referências a objetos , impedindo que o coletor de lixo os limpe. Este é o equivalente em linguagem Java de um vazamento de memória . ( Observação: APIs chamadas por um aplicativo também podem estar mantendo referências de objeto involuntariamente. )

Outra fonte potencial dessas OOMs “Java heap space” surge com o uso de finalizadores . Se uma classe tiver um método finalize , os objetos desse tipo não terão seu espaço recuperado no momento da coleta de lixo. Em vez disso, após a coleta de lixo, os objetos são enfileirados para finalização, o que ocorre posteriormente. Na implementação do Sun, os finalizadores são executados por um thread daemon. Se o encadeamento do finalizador não puder acompanhar a fila de finalização, o heap Java poderá ser preenchido e um OOM poderá ser gerado.

“Espaço PermGen”

Essa mensagem de erro indica que a geração permanente está cheia. A geração permanente é a área do heap que armazena objetos de classe e método. Se um aplicativo carregar um grande número de classes, o tamanho da geração permanente pode precisar ser aumentado usando a opção -XX:MaxPermSize .

Objetos java.lang.String internos também são armazenados na geração permanente. A classe java.lang.String mantém um conjunto de strings. Quando o método interno é invocado, o método verifica o pool para ver se uma string equivalente está presente. Se sim, é retornado pelo método interno; caso contrário, a string será adicionada ao pool. Em termos mais precisos, o método java.lang.String.intern retorna a representação canônica de uma string; o resultado é uma referência à mesma instância de classe que seria retornada se essa string aparecesse como literal. Se um aplicativo internar um grande número de strings, talvez seja necessário aumentar o tamanho da geração permanente.

Nota: você pode usar o comando jmap -permgen para imprimir estatísticas relacionadas à geração permanente, incluindo informações sobre instâncias de String internalizadas.

“O tamanho do array solicitado excede o limite de VM”

Esse erro indica que o aplicativo (ou APIs usadas por esse aplicativo) tentou alocar uma matriz maior que o tamanho do heap. Por exemplo, se um aplicativo tentar alocar uma matriz de 512 MB, mas o tamanho máximo do heap for 256 MB, um OOM será gerado com essa mensagem de erro. Na maioria dos casos, o problema é um problema de configuração ou um bug que ocorre quando um aplicativo tenta alocar um array massivo.

“Solicite <size> bytes para <motivo>. Sem espaço de troca?”

Esta mensagem parece ser um OOM. No entanto, a VM HotSpot lança essa aparente exceção quando uma alocação do heap nativo falha e o heap nativo pode estar próximo da exaustão. Estão incluídos na mensagem o tamanho (em bytes) da solicitação que falhou e o motivo da solicitação de memória. Na maioria dos casos, o <motivo> é o nome do módulo de origem que está relatando uma falha de alocação.

Se esse tipo de OOM for lançado, talvez seja necessário usar utilitários de solução de problemas em seu sistema operacional para diagnosticar ainda mais o problema. Em alguns casos, o problema pode nem estar relacionado ao aplicativo. Por exemplo, você pode ver este erro se:

  • O sistema operacional está configurado com espaço de troca insuficiente.

  • Outro processo no sistema está consumindo todos os recursos de memória disponíveis.

Também é possível que o aplicativo tenha falhado devido a um vazamento nativo (por exemplo, se algum código de aplicativo ou biblioteca está alocando memória continuamente, mas não consegue liberá-lo para o sistema operacional).

<motivo> <rastreamento de pilha> (método nativo)

Se você vir essa mensagem de erro e o quadro superior do rastreamento de pilha for um método nativo, esse método nativo encontrou uma falha de alocação. A diferença entre esta mensagem e a anterior é que a falha de alocação de memória Java foi detectada em um JNI ou método nativo em vez de no código Java VM.

Se esse tipo de OOM for gerado, talvez seja necessário usar utilitários no sistema operacional para diagnosticar melhor o problema.

Falha de aplicativo sem OOM

Ocasionalmente, um aplicativo pode falhar logo após uma falha de alocação do heap nativo. Isso ocorre se você estiver executando um código nativo que não verifica os erros retornados pelas funções de alocação de memória.

Por exemplo, a chamada de sistema malloc retornará NULL se não houver memória disponível. Se o retorno de malloc não for verificado, o aplicativo poderá travar ao tentar acessar um local de memória inválido. Dependendo das circunstâncias, esse tipo de problema pode ser difícil de localizar.

Em alguns casos, as informações do log de erros fatais ou do despejo de memória serão suficientes. Se a causa de uma falha for determinada como falta de tratamento de erros em algumas alocações de memória, você deve procurar o motivo dessa falha de alocação. Como acontece com qualquer outro problema de heap nativo, o sistema pode estar configurado com espaço de troca insuficiente, outro processo pode estar consumindo todos os recursos de memória disponíveis etc.

Diagnosticando vazamentos

Na maioria dos casos, diagnosticar vazamentos de memória requer um conhecimento muito detalhado do aplicativo em questão. Aviso: o processo pode ser demorado e iterativo.

Nossa estratégia para caçar vazamentos de memória será relativamente simples:

  1. Identifique os sintomas

  2. Ativar coleta de lixo detalhada

  3. Ativar perfil

  4. Analise o rastreamento

1. Identifique os sintomas

Conforme discutido, em muitos casos, o processo Java eventualmente lançará uma exceção de tempo de execução OOM, um indicador claro de que seus recursos de memória foram esgotados. Nesse caso, você precisa distinguir entre um esgotamento de memória normal e um vazamento. Analisar a mensagem do OOM e tentar encontrar o culpado com base nas discussões fornecidas acima.

Muitas vezes, se um aplicativo Java solicita mais armazenamento do que o heap de tempo de execução oferece, isso pode ser devido a um design ruim. Por exemplo, se um aplicativo criar várias cópias de uma imagem ou carregar um arquivo em uma matriz, ele ficará sem armazenamento quando a imagem ou o arquivo for muito grande. Este é um esgotamento de recursos normal. O aplicativo está funcionando conforme projetado (embora esse design seja claramente estúpido).

Mas se um aplicativo aumentar constantemente sua utilização de memória enquanto processa o mesmo tipo de dados, você pode ter um vazamento de memória.

2. Ative a coleta detalhada de lixo

Uma das maneiras mais rápidas de afirmar que você realmente tem um vazamento de memória é habilitar a coleta de lixo detalhada. Os problemas de restrição de memória geralmente podem ser identificados examinando os padrões na saída do verbosegc .

Especificamente, o argumento -verbosegc permite que você gere um rastreamento sempre que o processo de coleta de lixo (GC) for iniciado. Ou seja, à medida que a memória está sendo coletada como lixo, os relatórios de resumo são impressos com erro padrão, dando a você uma noção de como sua memória está sendo gerenciada.

Aqui estão algumas saídas típicas geradas com a opção –verbosegc :

saída detalhada da coleta de lixo

Cada bloco (ou estrofe) neste arquivo de rastreamento do GC é numerado em ordem crescente. Para entender esse rastreamento, você deve examinar sucessivas estrofes de Falha de alocação e procurar memória liberada (bytes e porcentagem) diminuindo ao longo do tempo enquanto a memória total (aqui, 19725304) está aumentando. Estes são sinais típicos de esgotamento de memória.

3. Ative a criação de perfil

Diferentes JVMs oferecem diferentes maneiras de gerar arquivos de rastreamento para refletir a atividade de heap, que normalmente inclui informações detalhadas sobre o tipo e o tamanho dos objetos. Isso é chamado de criação de perfil do heap .

4. Analise o Rastreio

Este post se concentra no rastreamento gerado pelo Java VisualVM. Os rastreamentos podem vir em diferentes formatos, pois podem ser gerados por diferentes ferramentas de detecção de vazamento de memória Java, mas a ideia por trás deles é sempre a mesma: encontrar um bloco de objetos no heap que não deveria estar lá e determinar se esses objetos se acumulam em vez de liberar. De interesse particular são os objetos transitórios que são alocados toda vez que um determinado evento é acionado no aplicativo Java. A presença de muitas instâncias de objetos que deveriam existir apenas em pequenas quantidades geralmente indica um bug do aplicativo.

Finalmente, resolver vazamentos de memória requer que você revise seu código completamente. Aprender sobre o tipo de vazamento de objeto pode ser muito útil e acelerar consideravelmente a depuração.

Como funciona a coleta de lixo na JVM?

Antes de iniciarmos nossa análise de um aplicativo com um problema de vazamento de memória, vejamos primeiro como a coleta de lixo funciona na JVM.

A JVM usa uma forma de coletor de lixo chamada coletor de rastreamento , que opera essencialmente pausando o mundo ao seu redor, marcando todos os objetos raiz (objetos referenciados diretamente por threads em execução) e seguindo suas referências, marcando cada objeto que vê ao longo do caminho.

Java implementa algo chamado coletor de lixo geracional baseado na suposição de hipótese geracional, que afirma que a maioria dos objetos que são criados são descartados rapidamente , e os objetos que não são coletados rapidamente provavelmente permanecerão por algum tempo .

Com base nessa suposição, o Java particiona objetos em várias gerações. Aqui está uma interpretação visual:

Partições Java em várias gerações

  • Geração Jovem - É aqui que os objetos começam. Possui duas subgerações:

    • Eden Space - Os objetos começam aqui. A maioria dos objetos são criados e destruídos no Espaço Éden. Aqui, o GC faz Minor GCs , que são coletas de lixo otimizadas. Quando uma GC secundária é executada, quaisquer referências a objetos que ainda sejam necessárias são migradas para um dos espaços sobreviventes (S0 ou S1).

    • Survivor Space (S0 e S1) - Objetos que sobrevivem ao Éden acabam aqui. Existem dois deles, e apenas um está em uso a qualquer momento (a menos que tenhamos um sério vazamento de memória). Um é designado como vazio e o outro como vivo , alternando a cada ciclo de GC.

  • Geração Tenured - Também conhecida como a geração antiga (espaço antigo na Fig. 2), este espaço contém objetos mais antigos com vida útil mais longa (movidos dos espaços sobreviventes, se eles viverem por tempo suficiente). Quando este espaço é preenchido, o GC faz um Full GC , que custa mais em termos de desempenho. Se esse espaço crescer sem limite, a JVM lançará um OutOfMemoryError - Java heap space .

  • Geração Permanente - Uma terceira geração intimamente relacionada à geração tenured, a geração permanente é especial porque contém dados requeridos pela máquina virtual para descrever objetos que não possuem equivalência no nível da linguagem Java. Por exemplo, objetos que descrevem classes e métodos são armazenados na geração permanente.

Java é inteligente o suficiente para aplicar diferentes métodos de coleta de lixo a cada geração. A geração jovem é tratada usando um coletor de rastreamento e cópia chamado Parallel New Collector . Este colecionador pára o mundo, mas como a geração jovem é geralmente pequena, a pausa é curta.

Para obter mais informações sobre as gerações da JVM e como elas funcionam com mais detalhes, visite o Gerenciamento de memória na documentação da Java HotSpot Virtual Machine.

Detectando um vazamento de memória

Para encontrar vazamentos de memória e eliminá-los, você precisa das ferramentas adequadas de vazamento de memória. É hora de detectar e remover esse vazamento usando o Java VisualVM.

Perfilando remotamente o heap com Java VisualVM

VisualVM é uma ferramenta que fornece uma interface visual para visualizar informações detalhadas sobre aplicativos baseados em tecnologia Java enquanto eles estão em execução.

Com o VisualVM, você pode visualizar dados relacionados a aplicativos locais e aqueles executados em hosts remotos. Você também pode capturar dados sobre instâncias de software JVM e salvar os dados em seu sistema local.

Para se beneficiar de todos os recursos do Java VisualVM, você deve executar a Java Platform, Standard Edition (Java SE) versão 6 ou superior.

Relacionado: Por que você precisa atualizar para o Java 8 já

Ativando a Conexão Remota para a JVM

Em um ambiente de produção, geralmente é difícil acessar a máquina real na qual nosso código será executado. Felizmente, podemos criar o perfil de nosso aplicativo Java remotamente.

Primeiro, precisamos nos conceder acesso à JVM na máquina de destino. Para isso, crie um arquivo chamado jstatd.all.policy com o seguinte conteúdo:

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

Depois que o arquivo for criado, precisamos habilitar conexões remotas com a VM de destino usando a ferramenta jstatd - Virtual Machine jstat Daemon, da seguinte forma:

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

Por exemplo:

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

Com o jstatd iniciado na VM de destino, podemos nos conectar à máquina de destino e perfilar remotamente o aplicativo com problemas de vazamento de memória.

Conectando-se a um host remoto

Na máquina cliente, abra um prompt e digite jvisualvm para abrir a ferramenta VisualVM.

Em seguida, devemos adicionar um host remoto no VisualVM. Como a JVM de destino está habilitada para permitir conexões remotas de outra máquina com J2SE 6 ou superior, iniciamos a ferramenta Java VisualVM e conectamos ao host remoto. Se a conexão com o host remoto foi bem-sucedida, veremos os aplicativos Java que estão sendo executados na JVM de destino, conforme visto aqui:

executando no jvm de destino

Para executar um criador de perfil de memória no aplicativo, basta clicar duas vezes em seu nome no painel lateral.

Agora que estamos todos configurados com um analisador de memória, vamos investigar um aplicativo com um problema de vazamento de memória, que chamaremos de MemLeak .

MemLeak

Claro, existem várias maneiras de criar vazamentos de memória em Java. Para simplificar, definiremos uma classe para ser uma chave em um HashMap , mas não definiremos os métodos equals() e hashcode().

Um HashMap é uma implementação de tabela de hash para a interface Map e, como tal, define os conceitos básicos de chave e valor: cada valor está relacionado a uma chave única, portanto, se a chave para um determinado par chave-valor já estiver presente no HashMap, seu valor atual é substituído.

É obrigatório que nossa classe chave forneça uma implementação correta dos métodos equals() e hashcode() . Sem eles, não há garantia de que uma boa chave será gerada.

Ao não definir os métodos equals() e hashcode() , adicionamos a mesma chave ao HashMap repetidamente e, em vez de substituir a chave como deveria, o HashMap cresce continuamente, falhando em identificar essas chaves idênticas e lançando um OutOfMemoryError .

Aqui está a 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: o vazamento de memória não é devido ao loop infinito na linha 14: o loop infinito pode levar a um esgotamento de recursos, mas não a um vazamento de memória. Se tivéssemos implementado corretamente os métodos equals() e hashcode() , o código funcionaria bem mesmo com o loop infinito, pois teríamos apenas um elemento dentro do HashMap.

(Para os interessados, aqui estão alguns meios alternativos de (intencionalmente) gerar vazamentos.)

Usando Java VisualVM

Com o Java VisualVM, podemos monitorar a memória do Java Heap e identificar se seu comportamento é indicativo de um vazamento de memória.

Aqui está uma representação gráfica do analisador de heap Java do MemLeak logo após a inicialização (lembre-se de nossa discussão sobre as várias gerações):

monitorar vazamentos de memória usando java visualvm

Após apenas 30 segundos, a Velha Geração está quase cheia, indicando que, mesmo com um GC Completo, a Velha Geração está sempre crescendo, um sinal claro de um vazamento de memória.

Um meio de detectar a causa desse vazamento é mostrado na imagem a seguir ( clique para ampliar ), gerada usando Java VisualVM com um heapdump . Aqui, vemos que 50% dos objetos Hashtable$Entry estão no heap , enquanto a segunda linha nos aponta para a classe MemLeak . Assim, o vazamento de memória é causado por uma tabela de hash usada na classe MemLeak .

vazamento de memória da tabela de hash

Finalmente, observe o Java Heap logo após nosso OutOfMemoryError em que as gerações Young e Old estão completamente cheias .

erro de falta de memória

Conclusão

Vazamentos de memória estão entre os problemas de aplicativos Java mais difíceis de resolver, pois os sintomas são variados e difíceis de reproduzir. Aqui, descrevemos uma abordagem passo a passo para descobrir vazamentos de memória e identificar suas origens. Mas, acima de tudo, leia atentamente suas mensagens de erro e preste atenção em seus rastreamentos de pilha – nem todos os vazamentos são tão simples quanto parecem.

Apêndice

Junto com o Java VisualVM, existem várias outras ferramentas que podem realizar a detecção de vazamento de memória. Muitos detectores de vazamento operam no nível da biblioteca interceptando chamadas para rotinas de gerenciamento de memória. Por exemplo, HPROF , é uma ferramenta de linha de comando simples empacotada com o Java 2 Platform Standard Edition (J2SE) para criação de perfil de heap e CPU. A saída do HPROF pode ser analisada diretamente ou usada como entrada para outras ferramentas como JHAT . Quando trabalhamos com aplicativos Java 2 Enterprise Edition (J2EE), há várias soluções de analisador de heap dump que são mais amigáveis, como IBM Heapdumps para servidores de aplicativos Websphere.