Atenção: esse artigo tem uma palestra complementar. Clique e assista!

De que se trata o artigo:

Examinamos os problemas de gerenciamento de memória e de recursos em Java, investigando várias “receitas” de bugs como leaks de memória ou retenção indevida de recursos, e aprendendo a evitar estes problemas.


Para que serve:

O gerenciamento correto e eficiente de memória e recursos é uma necessidade de qualquer aplicação, especialmente aquelas destinadas a executar continuamente durante muito tempo, ou aquelas que devem enfrentar picos de atividade intensa e apresentar boa escalabilidade. Leaks de memória podem resultar em problemas que vão desde mau desempenho até crashes da VM com OutOfMemoryError. Ineficiência no gerenciamento de recursos podem causar problemas difíceis de diagnosticar, como a observação que uma aplicação não consegue escalar acima de uma taxa modesta de transações simultâneas mesmo com o servidor aparentemente pouco carregado.


Em que situação o tema é útil:

Bugs relacionados à alocação de memória e manipulação de recursos são inaceitáveis em sistemas de “missão crítica”, mas devem ser evitados em qualquer tipo de aplicação. Uma das dificuldades de combater tais bugs, especialmente os de gerenciamento de memória, é que devido ao recurso de GC do Java, muitos desenvolvedores dão pouca atenção ao problema, achando que a JVM irá magicamente dar conta do recado. Infelizmente não é tão simples assim; neste artigo, exploramos alguns anti-patterns que com freqüência resultam em leaks de memória e outros problemas.

Na evolução das linguagens de programação, grandes avanços como a técnica de GC (Garbage Collection) são geralmente interpretados como “o fim de todos os seus problemas”, pelo menos de uma ampla categoria como os problemas relacionados ao gerenciamento de memória. Mas no longo prazo essa esperança sempre acaba em decepção, pelo menos parcial, pois nem todos os problemas atacados pela nova tecnologia terminam totalmente. Vamos fazer uma rápida análise dos efeitos da GC, em comparação com linguagens com gerenciamento manual de memória como C/C++:

Corrupção do heap: totalmente eliminado (devido parcialmente à GC, e parcialmente a outras características de linguagens gerenciadas);

Leaks de memória: mais raros, mas não eliminados;

Consumo de memória além do necessário: pouco melhorado. A melhoria vem da programação mais simples, especialmente para objetos com ciclo de vida complexo (referenciados de muitos lugares, alocados e liberados em lugares e momentos distantes). A GC permite a cada parte do código liberar referências para objetos utilizados tão logo estes deixem de ser localmente úteis, e quando não houver mais nenhuma referência, a VM detecta isso e remove o objeto. Já em programas com memória manual, quando é complicado determinar quando um objeto pode ser deletado, isso quase sempre resulta numa retenção muito conservadora do objeto, que só é deletado muito depois do último momento em que foi realmente usado;

Leaks de recursos: “recursos” são objetos que exigem procedimentos especiais de desalocação, por exemplo a liberação de um socket, conexão de pool, descritor de arquivo ou algum outro recurso gerenciado pelo sistema operacional, que a JVM não saberá desalocar automaticamente como parte da GC. É possível ter um leak do recurso, seja temporário ou permanente, mesmo para objetos que foram coletados por GC.

Além disso, é comum que surjam novos tipos de problemas que são efeito colateral da nova tecnologia, como:

Pausas de GC: como o coletor trabalha “em batch”, embora o custo total por objeto seja bem menor que na opção de memória manual, quando esse custo é concentrado por que milhares ou milhões de objetos são liberados de uma só vez, isso pode ser um problema;

Problemas misteriosos envolvendo tuning e ajustes de GC: por exemplo, é possível sofrer uma OutOfMemoryException quando existe muito mais memória livre que o exigido por uma solicitação de alocação. E entre problemas menos explícitos, pode-se ter perdas importantes de desempenho devido à simples falta dos parâmetros ideais para tunar a VM;

Consumo de memória além do necessário: também há cenários em que isso piora com GC. Para objetos de ciclo de vida simples (caso oposto ao anterior), o gerenciamento manual de memória permite deletar o objeto imediatamente após seu último uso, evitando a retenção de memória por um único nanossegundo. Já com GC, é preciso esperar pela próxima coleta;

Retenção de recursos além do necessário: a aplicação pode desalocar corretamente o recurso, mas só muito tempo após seu último uso. Isso tem sintomas não graves mas desagradáveis, por exemplo um arquivo pode permanecer bloqueado para escrita por muito tempo ao invés de ser desbloqueado imediatamente após a aplicação tê-lo utilizado, e uma conexão que já foi utilizada pode permanecer indisponível para outras transações que precisam de conexões de um pool de tamanho limitado.

Falando primeiro de memória, os pontos fracos da GC são conhecidos dos pesquisadores, que os vêm combatendo furiosamente ao longo dos anos, com algoritmos cada vez melhores: GC generacional, incremental, concorrente, real-time; além de outras facilidades como auto-tuning na VM ou ferramentas para diagnóstico e profiling. Mas nenhum dos problemas acima pode ser considerado totalmente resolvido.

Temos que concluir que GC não é uma tecnologia mágica, uma “bala de prata”, e provavelmente nunca será. Assim, o desenvolvedor deve assumir pelo menos uma parte da responsabilidade para garantir que sua aplicação não sofra de problemas relacionados ao gerenciamento de memória.

Em muitos artigos passados entrei a fundo em tecnologia de GC, mostrando os vários algoritmos utilizados pelas JVMs, em especial pelo Sun HotSpot. Assim, o leitor poderá com justiça me atribuir alguma culpa de promover a atitude de fiar-se demais na tecnologia da VM – “atualize para o último beta do JDK, use o parâmetro avançado de tuning –XX:+ResolvaTodosProblemasDeGC, e seus problemas acabaram!!!”. Para compensar, apresento agora uma coletânea de dicas, técnicas e know-how na camada da aplicação – a maioria, resultado de uma dura experiência otimizando e corrigindo bugs de gerenciamento de memória em Java. Ao mesmo tempo, falaremos também de gerenciamento de recursos, tema que compartilha muitas dificuldades com o gerenciamento de memória e também compartilha de algumas soluções.

Leaks de Memória

Vamos começar pelo mais crítico: leaks, ou “vazamentos”, de memória. É importante alguma formalidade – o que é exatamente um leak? Não recorrerei nem à Wikipedia, nem a algum livro ou paper acadêmico; proponho uma definição que acho bastante precisa e pragmática:

Um leak é a retenção por tempo excessivo, por um objeto útil, de uma referência inútil.

Observe que não estou falando em reter objetos inúteis, e sim referências inúteis. Numa linguagem com GC, onde o programador só pode (e deve) anular referências, mas não pode (nem deve) deletar objetos, é esse ponto de vista que precisa ser utilizado.

Figura 1. Exemplo de leak

A Figura 1 explica a definição com um exemplo. Temos um editor, que gerencia um documento, e criou um diálogo para impressão do documento. Mas após o diálogo ter sido usado e fechado, o objeto Editor manteve uma referência para o objeto DlgImpressao. Esta referência é inútil, pois nunca mais será usada novamente (se quisermos imprimir outro documento, ou mesmo imprimir novamente o mesmo documento, o editor cria um novo diálogo, sobrescrevendo aquela referência). Isso é um leak, pois impede o GC de remover o objeto inútil DlgImpressao.

...
Quer ler esse conteúdo completo? Tenha acesso completo