Por que eu devo ler este artigo:O artigo trata de apresentar o funcionamento dos collectors das JVMs Oracle JRockit (utilizada pelo servidor de aplicações Java EE WebLogic), IBM Virtual Machine for Java (utilizada pelo servidor de aplicações Java EE WebSphere), as que implementam a JSR-1 (também conhecida como Real-Time Specification for Java ou RTSJ) e Dalvik (utilizada por dispositivos Android).

O tema é muito pertinente a desenvolvedores Java que trabalham com alguma das JVMs mencionadas acima, para entender mais a fundo as consequências da utilização de collectors frente a cenários particulares. Além disso, desenvolvedores Java que não trabalham com alguma destas JVMs poderão aprender como que estas opções solucionam os mesmos problemas de Garbage Collection que a JVM HotSpot de maneira diferente, acrescentando à sua bagagem de conhecimento.


Guia do artigo:

O artigo começa apresentando a JVM Oracle JRockit e suas configurações de memória heap, seus collectors e como utilizá-los. Em seguida, apresenta também a JVM IBM Virtual Machine for Java, suas configurações de memória heap, seus collectors e como utilizá-los. A partir daqui, apresenta a especificação JSR-1 e seus conceitos com relação ao desenvolvimento de aplicações de tempo real em Java, e demonstra o funcionamento de 5 collectors de tempo real. Ainda, apresenta a JVM Dalvik e seus desafios com relação a Garbage Collection, além de seu collector e melhorias com respeito ao gerenciamento de memória.

Saiba mais Anterior: Garbage Collection: Entendendo e otimizando

No artigo anterior da série “Entendendo e otimizando o Garbage Collection”, foram explicados fundamentos e detalhes sobre o processo de Garbage Collection em Java e o funcionamento dos collectors da JVM HotSpot 6 e 7. Além disso, foi apresentado como otimizar seu comportamento por meio de opções da JVM.

Apesar da maior parte dos desenvolvedores utilizar a JVM HotSpot, há outras JVMs no mercado que também sofrem dos mesmos problemas relacionados com Garbage Collection anteriormente apresentados. Estas JVMs apresentam diferentes interpretações para certos problemas, surgindo assim soluções criativas para cenários particulares.

A intenção deste artigo é dar continuidade a este estudo, agora abordando JVMs como Oracle JRockit (utilizada pelo servidor de aplicações Java EE WebLogic), IBM Virtual Machine for Java (utilizada pelo servidor de aplicações Java EE WebSphere), a especificação JSR-1 (também conhecida como Real-Time Specification for Java ou RTSJ) e finalmente a JVM Dalvik (utilizada por dispositivos Android).

Collectors daJVM JRockit (WebLogic)

A JVM Oracle JRockit, projetada especialmente para o servidor de aplicações Java EE WebLogic, é uma JVM comercial considerada de alta performance. Atualmente, ela está sendo integrada com a JVM HotSpot para o lançamento de uma nova JVM, possivelmente no JDK 8. Com relação a Garbage Collection, funciona de maneira um pouco diferente que a HotSpot.

Garbage collector dinâmico

A JVM JRockit possui um modo de Garbage Collection dinâmico, que combinará heurísticas e otimizará a performance de acordo com as necessidades da aplicação. A partir da definição do perfil do collector, a sua escolha acontecerá em tempo de execução, de forma parecida com Ergonomics da JVM HotSpot.

Este modo pode ser explicitamente escolhido utilizando a opção da JVM: -XgcPrio:<perfilDoCollector>, onde perfilDoCollector pode ser:

  • throughput: otimização para maximização do throughput da aplicação;
  • pausetime: otimização para a minimização do tempo de pausa;
  • deterministic: otimização para tempos de pausa muito curtos e determinísticos.

Funcionamento geral

A JVM JRockit possui duas configurações possíveis da memória heap. A primeira delas é generacional, assim como na HotSpot, sendo dividida conforme a Figura 1, obtida do blog about:performance.

Gerações da memória heap na JVM
JRockit
Figura 1. Gerações da memória heap na JVM JRockit.

Aqui, a Young generation é chamada de Nursery, sendo subdividida em duas áreas: Keep Area, que não sofre collections e recebe os objetos após serem alocados; e a área remanescente, onde todos os seus objetos podem ser promovidos para Tenured, diferentemente dos espaços Survivor da HotSpot. JRockit promove um objeto para Tenured quando ele sobrevive a duas Minor collections. Por ser generacional, esta configuração busca fornecer tempo de pausa baixo.

Não há Permanent generation em JRockit. Todas as classes, strings e constantes são alocadas em Young ou Tenured generation. Por um lado, fica mais fácil de configurar e há mais memória para as outras generations; mas por outro lado, classes, strings e constantes podem ser coletadas quando não usadas novamente, o que pode introduzir problemas de performance.

Além disso, há a configuração de memória heap contínua, não classificando objetos por seu tempo de vida. Em certas situações, como tarefas em lote que prezam por alto throughput, esta configuração apresenta melhor performance.

A configuração contínua vem configurada para ser utilizada como padrão, o que não é ideal, pois a maioria das aplicações é fortemente baseada em arquitetura Web, onde há muitos objetos que sobrevivem por pouco tempo, sendo assim mais vantajoso utilizar uma configuração de memória heap generacional, para obter tempos de pausa mais baixos que na configuração de memória heap contínua.

A JVM JRockit possui collectors Parallel e Concurrent assim como a HotSpot, mas aqui cada um destes pode ser utilizado com a configuração de memória heap generacional ou contínua, a partir da versão R27.2. Estes collectors são abordados a seguir.

Parallel em memória heap contínua

O collector Parallel não apresenta grandes diferenças quanto ao seu homônimo da JVM HotSpot. Quando configurado em memória heap contínua, seu processo de collection é feito com a pausa de todas as threads Java e então a execução de diversas threads paralelas para realizar collections completas em toda a memória heap.

Assim como em HotSpot, preza por alto throughput ao invés de baixo tempo de pausa. Além disso, é mais adequado para aplicações que mantêm objetos na memória por muito tempo.

Este collector pode ser explicitamente escolhido utilizando a opção da JVM: -Xgc:singlepar.

Parallel em memória heap generacional

Na memória heap generacional, novos objetos são primeiramente alocados na generation Nursery, até esta ficar cheia. Quando isto acontece, o collector Parallel pausa todas as threads Java para uma minor collection paralela em Nursery, utilizando diversas threads. O mesmo acontece com a generation Tenured, porém aqui ocorre uma major collection.

Assim como em HotSpot, preza por alto throughput ao invés de baixo tempo de pausa. Além disso, é mais adequado para aplicações que alocam muitos objetos que sobrevivem por pouco tempo, visto que as collections durarão menos, conseguindo assim melhorar o tempo de pausa.

Este collector pode ser explicitamente escolhido utilizando a opção da JVM: -Xgc:genpar.

Mostly Concurrent em memória heap contínua

O collector Mostly Concurrent da JVM JRockit apresenta algumas diferenças importantes com relação ao collector Concurrent da JVM HotSpot, como a sua divisão de fases, que são:

  • Marcação inicial: todas as threads da aplicação são suspensas para a marcação do primeiro nível de objetos alcançáveis a partir das raízes (objetos diretamente acessíveis pela memória heap);
  • Marcação concorrente: as threads da aplicação são retomadas, e inicia-se concorrentemente o processo de navegação e marcação dos objetos alcançáveis pelos objetos marcados na etapa anterior. Não é garantido que todos os objetos alcançáveis da memória heap serão marcados nesta etapa, pois uma vez que este processo é concorrente com a aplicação, novos objetos podem ter sido criados a partir de objetos que já foram visitados desde o início desta etapa, e assim acabam ficando sem serem marcados como alcançáveis;
  • Pré-limpeza: as threads da aplicação continuam em execução, todas as mudanças na memória heap durante a fase de Marcação concorrente são identificadas e todos os novos objetos alcançáveis são marcados;
  • Marcação final: todas as threads da aplicação são suspensas novamente, todas as mudanças na memória heap durante a fase de Pré-limpeza são identificadas e todos os novos objetos alcançáveis são marcados;
  • Sweeping em concorrência: as threads da aplicação são retomadas, e inicia-se concorrentemente o processo de sweeping da memória heap, mas diferentemente da JVM HotSpot, ocorre em duas etapas. Inicialmente o sweeping é feito na primeira metade da memória heap, e quando isto acontece, novos objetos só podem ser alocados na segunda metade. Após uma curta pausa para sincronização, acontece o sweeping na segunda metade, seguida por outra curta pausa para sincronização.

No geral, o collector Mostly Concurrent da JVM JRockit realiza mais pausas que o Concurrent da JVM HotSpot, porém suas fases de remarcação são mais curtas.

Na memória heap contínua, realiza a maioria de seu trabalho concorrentemente com a aplicação Java. Assim como em HotSpot, preza por baixo tempo de pausa ao invés de alto throughput. Além disso, é mais adequado para aplicações que mantêm objetos na memória por muito tempo.

Este collector pode ser explicitamente escolhido utilizando a opção da JVM: -Xgc:singlecon.

Concurrent em memória heap generacional

Na memória heap generacional, novos objetos são primeiramente alocados na generation Nursery, até esta ficar cheia. Quando isto acontece, o collector Mostly Concurrent pausa todas as threads Java para mover os objetos alcançáveis para a generation Tenured. Com relação à generation Tenured, dispara uma major collection quando esta ficar cheia.

Assim como em HotSpot, preza por baixo tempo de pausa ao invés de alto throughput. Além disso, é mais adequado para aplicações que alocam muitos objetos que sobrevivem por pouco tempo, visto que as collections durarão menos, conseguindo assim melhorar o tempo de pausa.

Este collector pode ser explicitamente escolhido utilizando a opção da JVM: -Xgc:gencon.

Fase de compactação

Uma peculiaridade da JVM JRockit é que todos os collectors que agem na generation Tenured realizam compactação, até mesmo o Mostly Concurrent. A compactação é realizada de modo incremental em porções da memória heap.

Desta maneira, não há muitos problemas de fragmentação como na JVM HotSpot.

Collectors da JVM IBM (WebSphere)

A IBM Virtual Machine for Java (JVM IBM), utilizada pelo servidor de aplicações Java EE WebSphere, é mais uma JVM comercial. Com relação a Garbage Collection, funciona de maneira bem parecida com a JVM JRockit.

Funcionamento geral

A JVM IBM possui configurações de memória heap contínua e generacional, e seu padrão também é a configuração contínua. No entanto, a configuração generacional é mais próxima da JVM HotSpot do que da JVM JRockit, conforme apresenta a Figura 2, também obtida do blog about:performance.

Gerações da memória heap na JVM IBM
Figura 2. Gerações da memória heap na JVM IBM.

Nesta configuração da memória heap, a generation Young é também chamada de Nursery, e os espaços Allocate e Survivor são similares aos espaços Eden e Survivor de HotSpot. Objetos em collection são copiados múltiplas vezes entre estes dois espaços antes de serem promovidos para a generation Tenured. Além disso, não há generation Permanent.

Collectors

A JVM IBM possui collectors Parallel e Concurrent assim como HotSpot e JRockit, e também um collector projetado para máquinas com mais de 16 processadores, chamado de Subpool. A escolha do collector é dada pela opção da JVM -Xgcpolicy:<collector>, onde collector pode ser:

  • gencon: busca amenizar a questão de alto throughput versus baixo tempo de pausa, utilizando diferentemente os collectors disponíveis para objetos em Nursery e Tenured. Indicado para aplicações que alocam muitos objetos que sobrevivem por pouco tempo;
  • optavgpause: utiliza o collector Concurrent. Indicado para aplicações que consomem grandes quantidades de memória heap e que necessitam ter alta responsividade;
  • optthruput: utiliza o collector Parallel. Indicado para aplicações que realizam processamento intenso e não necessitam ter alta responsividade. É a opção padrão;
  • subpool: utiliza o collector Subpool. Indicado para máquinas com mais de 16 processadores. É descrito abaixo.

Subpool

O collector Subpool usa um algoritmo de alocação de objetos para prover maior performance neste processo. É projetado para melhorar sistemas de multiprocessadores simétricos com mais de 16 processadores.

Este collector tenta predizer o tamanho de futuras alocações baseando-se nas alocações anteriores. Ao alocar um objeto, pedaços de memória livre são escolhidos utilizando um método de “melhor escolha”, ao invés do método “primeiro pedaço disponível” utilizado nos outros collectors. Ainda, Subpool busca minimizar o tempo de duração de um lock na memória heap, aumentando assim a disponibilidade da memória heap para as threads de alocação.

Fase de compactação

Na JVM IBM, a fase de compactação é opcional. Há certo conservadorismo contra a utilização de compactação por esta aumentar o tempo do processo como um todo, e desta forma o padrão é não utilizá-la.

Para explicitamente escolher a não-compactação, utilize a opção da JVM: -Xnocompactgc.

Para explicitamente escolher a compactação, utilize a opção da JVM: -Xcompactgc. Se nem esta opção for utilizada e nem a anterior, ainda assim a compactação poderá ocorrer em situações extremas, como se não houver espaço disponível para alocar objetos após a fase de sweep.

Collectors de JVMs RTSJ (tempo real)

A JSR-1, ou especificação RTSJ para JVMs, dita o comportamento de aplicações Java em contextos de tempo real, introduzindo novas funcionalidades para suportar operações críticas como priorização de threads, novas áreas de memória e novos algoritmos de Garbage Collection.

Funcionamento geral

Em sistemas de tempo real, é muito importante priorizar threads, pois apesar de não ser possível garantir que todas as tarefas sejam finalizadas no tempo desejado, é possível elencar certas tarefas mais prioritárias que devem ser cumpridas a todo custo. Assim, outras tarefas menos prioritárias serão vitimadas em favor destas. Na especificação RTSJ, há três categorias de threads:

  • Thread comum, subclasse de java.lang.Thread, que não participa de atividades de tempo real;
  • Real-time thread (RTT), subclasse de javax.realtime.RealtimeThread, que é utilizada nas atividades de tempo real com acesso à memória heap. Cada RTT pertence a um nível de prioridade, o que permite à JVM dar preferência ao processamento de RTTs mais prioritárias; No-heap real-time thread (NHRT), subclasse de javax.realtime.NoHeapRealtimeThread, que por sua vez é subclasse de RTT, é utilizada em atividades intensas de tempo real, sem acesso à memória heap. Assim, não está sujeita às demoras causadas pelas pausas de Garbage Collection, visto que não manipulará objetos que podem ser coletados na memória heap. Ao invés disso, NHRTs podem utilizar outras duas áreas da memória, scoped e immortal, para alocar memória de maneira mais previsível.

A ordem de prioridade destas categorias de threads, da mais prioritária para a menos prioritária, é: NHRT, RTT e Thread comum. É importante salientar que cada uma destas categorias também permite níveis internos de prioridade.

Além da priorização de threads, outra característica desejada em sistemas de tempo real é a disponibilidade de diferentes áreas de memória para a alocação de objetos, com relação a sua finalidade. Assim, dependendo do propósito do objeto em questão, ele terá comportamento diferenciado quanto ao seu limite de alocação e Garbage Collection. A especificação RTSJ define três áreas de memória:

  • Memória heap: similar à memória heap das JVMs convencionais, sofre Garbage Collection e suas pausas, e pode ser utilizada por threads comuns e RTTs;
  • Memória immortal: área que não sofre Garbage Collection, ou seja, todo objeto alocado nela nunca será reclamado pelo sistema. É similar ao modo de gerenciamento de memória em C/C++, onde se utiliza malloc() e free(), e pode ser utilizada por threads comuns, RTTs e NHRTs;
  • Memória scoped: área que também não sofre Garbage Collection, porém os objetos são alocados com um tempo de vida conhecido. Assim, toda a área da memória scoped pode ser reclamada no final deste tempo de vida, tal como objetos temporários em um método que está terminando. Áreas de memória scoped possuem um tamanho máximo pré-definido.

Finalmente, visto que Garbage Collection é um processo não-determinístico, sistemas de tempo real que dependem deste processo precisam de meios para prevenir as pausas de modo a cumprir os deadlines de suas tarefas com grande confiabilidade. Apesar de não definir Garbage Collection de tempo real, a especificação RTSJ provê alguns collectors para adicionar mais determinismo ao processo: Work-based, Time-based e Henriksson, abordados a seguir. Na sequência, serão apresentados dois collectors de tempo real comerciais: Real-time garbage collector e Metronome.

Work-based

O collector Work-based realiza collections na própria thread, ao mesmo tempo em que a thread aloca objetos. Sua vantagem é seu senso de justiça, de forma que as threads que alocam muitos objetos precisarão gastar um tempo maior para realizar collections, ao invés de dividir este gasto de tempo com outras threads. Sua desvantagem é que a quantidade de tempo que a thread gastará com collections é variável, podendo fazer com que a thread não cumpra seus deadlines, conforme demonstrado na Figura 3, obtida do website da Oracle.

A Figura 3 mostra três threads, P1, P2 e P3, sendo que a primeira é a mais prioritária e a última é a menos prioritária. As barras listradas representam a execução de atividades das threads, as barras vermelhas representam a execução de alocações de objetos ao mesmo tempo em que collections e as barras azuis representam a suspensão da execução de uma thread em favor de outra mais prioritária. Nota-se que a thread P1 não cumpriu seu deadline devido a um atraso causado pela collection que ocorreu quando P1 alocava objetos.

Algoritmo work-based
Figura 3. Algoritmo work-based.

Time-based

O collector Time-based agenda uma porção fixa de tempo para collection a cada intervalo de tempo. Sua vantagem é dividir o custo de Garbage Collection igualmente por todos os intervalos de tempo. Sua desvantagem é que não há relação direta entre o tempo gasto por collection e a quantidade de memória reclamada, então esta abordagem também pode causar atrasos inesperados, conforme demonstrado na Figura 4, também obtida do website da Oracle.

A Figura 4 utiliza os mesmos elementos visuais que a Figura 3. Agora, há uma thread dedicada para Garbage Collection, com prioridade mais alta, que é executada a cada intervalo de tempo. A cada collection, todas as threads P1, P2 ou P3 são suspensas, fazendo com que elas eventualmente não cumpram seus deadlines.

Algoritmo time-based
Figura 4. Algoritmo time-based.

Henriksson

O collector de Henriksson é uma modificação do algoritmo Work-based, onde as collections associadas com alocações em uma thread crítica são adiadas para acontecerem depois do término da thread crítica, visto que a thread collector possui prioridade menor que as threads críticas. Sua vantagem é que as threads com alta prioridade podem cumprir deadlines menores que nos outros algoritmos, mesmo utilizando a memória heap. Sua desvantagem é que as threads com baixa prioridade sofrerão todas as pausas de Garbage Collection, conforme demonstrado na Figura 5, também obtida do website da Oracle.

A Figura 5 utiliza os mesmos elementos visuais que as Figuras 3 e 4. Repare que as threads críticas sempre cumprem seus deadlines, adiando seu processo de collection para imediatamente após seu término. No entanto, ao fazer isto as threads não-críticas são vitimadas em seu favor.

Algoritmo de Henriksson
Figura 5. Algoritmo de Henriksson.

Real-time garbage collector

A JVM Java Real-Time System (Java RTS), que é a implementação comercial da Oracle para RTSJ, inclui um collector de tempo real denominado Real-time garbage collector (RTGC), baseado no collector de Henriksson. Este collector roda em uma ou mais RTTs, possuindo prioridade menor que todas as NHRTs e até algumas RTTs, de forma que sempre poderá ser suspenso em favor de threads críticas.

O Real-time garbage collector baseia-se em um limiar de prioridade, sendo que todas as threads com prioridade maior que a do limiar de prioridade serão consideradas críticas, e assim este collector será suspenso em favor destas threads. Há dois tipos de limiar de prioridade: inicial e máximo. Real-time garbage collector inicia suas atividades considerando o limiar de prioridade inicial, mas se os recursos de memória ficarem escassos, irá aumentar o limiar de prioridade até seu máximo.

A Figura 6 ilustra esta classificação em ordem de prioridade, onde são consideradas críticas todas as NHRTs e todas as RTTs com prioridade acima do limiar máximo de prioridade, em seguida as RTTs com prioridades entre os limiares máximo e inicial, e finalmente as threads comuns.

Classificação do limiar de prioridades no Real-time
garbage collector
Figura 6. Classificação do limiar de prioridades no Real-time garbage collector.

Um diferencial de Real-time garbage collector é que ele é completamente concorrente, podendo ser suspenso a qualquer momento, e além disso não há fases do tipo stop the world, onde todas as threads são suspensas durante uma collection.

Metronome

A JVM IBM WebSphere Real-time inclui o collector de tempo real Metronome, que permite cumprir objetivos como tempo de pausa baixos e throughput desejado. Para isto, Metronome divide o tempo de Garbage Collection em porções chamadas de quanta, e define seu fluxo de trabalho nas seguintes etapas:

  1. Suspender a aplicação;
  2. Executar uma porção da collection;
  3. Retomar a aplicação após um período muito curto e determinístico.

Esta abordagem elimina a imprevisibilidade de collectors tradicionais. No entanto, apenas este fluxo de trabalho não é o suficiente em sistemas de tempo real, pois o throughput deve ser alto de qualquer maneira. A Figura 7 ilustra esta necessidade, onde uma aplicação, cujo tempo de pausa para collection é de 1 milissegundo, só consegue ser executada por 0,1 milissegundo entre cada pausa. Desta forma, o progresso da aplicação será muito baixo, e os propósitos de tempo real provavelmente não serão cumpridos.

Tempos
de pausa baixos, porém throughput menor
ainda
Figura 7. Tempos de pausa baixos, porém throughput menor ainda.

É então necessário certo nível de determinismo para o throughput. Para tal, Metronome garante uma porcentagem de tempo de processamento para a execução da aplicação, e o uso do tempo restante será decidido pelo collector se utilizará para collections ou disponibilizará para a aplicação.

Assim, é possível cumprir tempos de pausa muito curtos para sistemas de tempo real mais delicados.

Collectors daJVM Dalvik (Android)

Os dispositivos Android possuem uma JVM particular, chamada Dalvik, que foi projetada para trabalhar sob diversas restrições destes dispositivos, como velocidade de processamento limitada, memória limitada, inexistência de espaço para swap e execução em formato sandbox.

Cada aplicação Android é executada em seu próprio processo do sistema operacional, em sua própria instância da JVM Dalvik. Assim, Dalvik foi também projetada para permitir diversas instâncias no mesmo dispositivo com eficiência.

Por consequência desta arquitetura, a memória do dispositivo é dividida em porções independentes para cada processo, cada um contendo sua própria memória heap e seu próprio processo de Garbage Collection.

Esta divisão de memória deve ser respeitada pelas estratégias de Garbage Collection em dispositivos Android. Tais estratégias devem ser independentes por aplicação, mesmo que mais de uma aplicação esteja compartilhando uma mesma região de memória.

Mark and Sweep

Até a versão FroYo (2.2 ou API 8) do Android, o collector padrão foi uma implementação simples e não-generacional de Mark and Sweep.

Uma particularidade desta implementação é a maneira de lidar com a questão da memória compartilhada com outras aplicações. Quando um objeto é considerado alcançável a partir dos objetos raízes, ele recebe alguns bits de marcação para identificá-lo como alcançável, mas estes bits não são armazenados na mesma memória heap que o próprio objeto. Como a memória compartilhada é somente para leitura, seria necessário haver permissão de escrita para armazenar os bits de marcação juntamente com os próprios objetos, e como isto não é possível, os bits de marcação não podem ser armazenados junto com os objetos.

Este collector, por sua natureza, possui fortes desvantagens: é do tipo stop the world, as collections visitam a memória heap toda e o tempo de pausa é elevado, possivelmente maior que 100 milissegundos. Para aplicações que utilizam pesadamente recursos gráficos interativos, como jogos animados e editores gráficos, este tempo de pausa chega a ser até inaceitável.

Concurrent

Na versão Gingerbread (2.3 ou API 9) do Android foi introduzido o collector Concurrent (CMS), que trabalha da mesma maneira que o seu homônimo presente na JVM HotSpot, apresentado na Parte 1 desta sequência de artigos.

As principais vantagens são: baixo tempo de pausa, agora sendo tipicamente menor que 5 milissegundos; collections parciais, pois Concurrent é um collector generacional, ocorrendo a maioria das collections apenas na Young generation; e collections concorrentes, com relação à Tenured generation, de maneira similar à JVM HotSpot.

Aplicações que utilizam pesadamente recursos gráficos interativos podem agora contar com animações mais suaves e capacidade de resposta maior. No entanto, ainda há perdas não-determinísticas em Frames Per Second (FPS), visto que este não é um collector de tempo real. Para os interessados, há estudos sobre a viabilidade de implementar sistemas em tempo real em Android, como por exemplo no artigo “Evaluating Android OS for Embedded Real-Time Systems”, disponível na seção Links.

Bitmaps

Outra melhoria recente relacionada com Garbage collection foi a manipulação de bitmaps na memória, que veio com a versão Honeycomb (3.0 ou API 11) do Android.

Até então, bitmaps não eram carregados na memória da JVM Dalvik, ou seja, eram carregados em memória nativa do sistema operacional e apenas referenciados pela primeira, conforme a Figura 8. Nesta figura, a barra azul representa a memória da JVM Dalvik, a barra verde representa a memória nativa e a área laranja representa a referência para a memória nativa.

Configuração de bitmaps na memória, antes da versão Honeycomb
Figura 8. Configuração de bitmaps na memória, antes da versão Honeycomb.

Neste caso, após seu uso, a liberação da memória é complexa e propensa a erros. Uma alternativa é destruir explicitamente o objeto em memória nativa e configurar o objeto Java para collection, o que é um processo muito caro além de verboso; outra opção é utilizar o método finalize(), que seria chamado quando o objeto estivesse quase sendo coletado, o que é desaconselhado e com certeza não-determinístico. A propósito, a Parte 3 desta sequência de artigos vai explicar mais a fundo sobre o método finalize().

Na versão Honeycomb, a manipulação de bitmaps acontece na memória da JVM Dalvik, conforme a Figura 9. Nesta figura, os elementos visuais são os mesmos que os da Figura 8.

Configuração de bitmaps na memória, na versão Honeycomb
Figura 9. Configuração de bitmaps na memória, na versão Honeycomb.

Agora, após seu uso a memória do bitmap é liberada no processo de collection. Como a manipulação de bitmaps é geralmente pesada para os recursos disponíveis, esta melhoria é de grande valia em termos de memória, utilizando mais racionalmente o Garbage Collection.

Os collectors e configurações de JVM e memória heap aqui apresentados diferem muito, principalmente porque cada uma destas JVMs tem um escopo definido e particular. No entanto, todos eles exercitam os mesmos conceitos, e é notório a forma com que estas diversas implementações lidam com os mesmos problemas.

Em quase todas elas pode-se notar a questão de alto throughput versus baixo tempo de pausa, com exceção das JVM de tempo real, onde esta questão não é mais importante, mas sim como garantir tempos de pausa determinísticos.

Problemas com Garbage Collection

O artigo trata de explicar sobre problemas comuns em códigos Java que ignoram o funcionamento do Garbage Collection, como escrever código para otimizar este processo e como analisar a situação da memória heap em tempo de execução.

O tema é muito pertinente a qualquer desenvolvedor Java, pois por mais que as consequências de programas que não contribuem com o processo de Garbage Collection não apareçam muito em ambiente de desenvolvimento, certamente farão a diferença em ambiente de produção. É importante entender os motivos que levam à deterioração do Garbage Collection e como evitar estas situações, fazendo uso de boas práticas para tal.

O artigo começa revendo opções da JVM para logar as atividade do processo de Garbage Collection e também apresentando a ferramenta JConsole para visualização gráfica da situação da memória heap. Em seguida, disserta sobre vazamentos de memória e mostra um exemplo de código com este problema, analisando sua execução na ferramenta JConsole. Por fim, apresenta outros assuntos como o método finalize(), referências fracas e conclui sugerindo algumas boas práticas.

Nos artigos anteriores da série “Entendendo e otimizando o Garbage Collection”, foram explicados fundamentos e detalhes sobre o processo de Garbage Collection em Java e o funcionamento dos collectors das JVMs HotSpot 6 e 7, Oracle JRockit, IBM Virtual Machine for Java, Dalvik e as que implementam a JSR-1 (também conhecida como Real-Time Specification for Java ou RTSJ). Além disso, foi apresentado como otimizar seu comportamento por meio de opções da JVM.

Tendo em mente estes conhecimentos, é importante saber como tirar um bom proveito deste processo, visto que o Garbage Collection não faz milagre algum se o código se comportar de maneira arredia. Assim, é possível codificar em Java e, ao mesmo tempo, ajudar o collector a obter o máximo de aproveitamento possível.

A intenção deste artigo é demonstrar como o desenvolvedor deve escrever código em Java que não prejudique o processo de Garbage Collection, utilizar de boas práticas para tal e, além disso, saber analisar a situação da memória heap e das collections em tempo de execução. Estes conhecimentos são muito importantes para o desenvolvimento de aplicações eficientes em termos de performance e economia de recursos como memória.

Erratas de artigos anteriores

No primeiro artigo desta série, algumas opções da JVM para escolher explicitamente o collector desejado vieram incorretas. Gentileza do leitor considerar as seguintes opções:

  • Para escolher o collector Serial, a opção correta é -XX:+UseSerialGC.
  • Para escolher o collector Parallel, as opções corretas são:
    • -XX:+UseParallelGC: para as implementações Parallel Scavenge (para Young generation) e Serial Old (para Tenured generation);
    • -XX:+UseParNewGC: para as implementações ParNew (para Young generation) e Serial Old (para Tenured generation);
    • -XX:+UseParallelOldGC: para as implementações Parallel Scavenge (para Young generation) e Parallel Old (para Tenured generation).
  • Para escolher o collector Concurrent, a opção correta é -XX:+UseConcMarkSweepGC;
  • Para escolher o collector Incremental Concurrent, as opções corretas são: -XX:+UseConcMarkSweepGC e -XX:+CMSIncrementalMode;
  • As opções -XX:DefaultInitialRAMFraction=<N> e -XX:DefaultMaxRAMFraction=<N> foram desativadas a partir da versão 6.0_18 da JVM HotSpot;
  • A opção -XX:CMSIncrementalDutyCycle=<N> especifica a porcentagem de tempo entre Minor collections quando o collector pode executar.

Analisando resultados de Garbage Collection e memória heap

No primeiro artigo desta sequência foram apresentadas opções para imprimir logs sobre o Garbage Collection. Neste artigo, vamos exercitar um pouco a análise destes logs.

A opção da JVM -verbosegc imprime uma linha no console a cada collection realizada, no seguinte formato: [GC <tamanho da memória heap antes da collection> -> <tamanho da memória heap após a collection> (<tamanho máximo da memória heap>), <tempo de pausa> secs].

A utilidade deste logger é possibilitar o acompanhamento da utilização da memória ao longo da execução de um programa. Além disso, dá informações para ajudar o desenvolvedor a entender por que ocorre cada Major collection, aqui denominada de Full GC. É importante entender a situação da memória que causou uma Major collection, pois muitas vezes isto pode revelar uma situação que pode ser melhorada em termos de código.

JConsole

O JDK da JVM HotSpot inclui uma ferramenta visual denominada JConsole, que utiliza a especificação Java Management Extensions (JMX) para prover informações sobre performance e consumo de recursos em tempo de execução de aplicativos Java. Esta ferramenta pode conectar-se a aplicações locais através de seu número de Process ID (PID) e também a aplicações remotas através do endereço do servidor e uma porta disponível.

JConsole é uma ferramenta poderosa que fornece informações detalhadas sobre a utilização de memória, threads ativas, classes carregadas, informações gerais sobre a JVM e Managed Beans. Aqui, iremos utilizar apenas as informações sobre a memória. A Figura 1 mostra a aba Memory do JConsole conectado a uma aplicação local.

Aba
Memory do JConsole
Figura 1. Aba Memory do JConsole.

Neste exemplo, pode-se notar várias informações interessantes:

  • O gráfico da linha azul mostra a utilização de toda a memória heap. Percebe-se aqui que o consumo de memória vai crescendo linearmente desde 0 até chegar a um patamar de aproximadamente 1.2 GB. Permanece estacionário por um tempo e depois vai liberando a memória linearmente até 0 novamente;
  • Na parte de baixo, do lado esquerdo, são exibidas as seguintes informações:
    • Time: instante de tempo atual;
    • Used: quantidade de memória em uso atualmente, incluindo objetos alcançáveis e não-alcançáveis;
    • Committed: quantidade de memória disponível para a JVM atualmente. Pode ser definida inicialmente pela opção da JVM -Xms, podendo crescer até a quantidade máxima reservada de memória. Sempre será maior ou igual à quantidade Used;
    • Max: quantidade máxima reservada de memória para a JVM. Pode ser definida pela opção da JVM -Xmx. Sempre será maior ou igual à quantidade Committed;
    • GC time: tempo cumulativo gasto em Garbage Collection e o número total de collections, para cada collector.
  • Na parte de baixo, do lado direito, são exibidas cinco barras verdes representando a utilização de memória dentro de cada partição da memória, a saber:
    • A primeira barra representa o espaço Eden da geração Young. Pertence à memória heap;
    • A segunda barra representa os espaços Survivor da geração Young. Pertence à memória heap;
    • A terceira barra representa a geração Tenured. Pertence à memória heap;
    • A quarta barra representa o espaço Code cache, cuja memória é destinada a compilações e armazenamento de código nativo. Não pertence à memória heap;
    • A quinta barra representa a geração Permanent. Não pertence à memória heap.
  • Ainda, na parte de cima, do lado direito, há o botão Perform GC, que quando clicado disparará uma Full GC.

Com este ferramental apresentado já estamos preparados para compreender melhor as situações de otimização de Garbage Collection analisadas a seguir.

Vazamentos de memória

Vazamento de memória é um tipo de manipulação inadequada de memória por um programa, quando este aloca espaço em memória para realizar alguma tarefa, mas não consegue devolver este espaço em memória ao sistema operacional após o término desta tarefa. São comuns em linguagens de programação onde o desenvolvedor deve gerenciar a memória da aplicação, como a linguagem C, onde objetos são alocados com a função malloc() e devem depois ser devolvidos ao sistema operacional com a instrução free().

Você deve estar pensando: “Ok, linguagens de programação como Java isentam a responsabilidade de gerenciamento de memória do desenvolvedor, então não devo me preocupar com vazamentos de memória”. Será verdade? A resposta é simples: não.

É verdade que o gerenciamento de memória é todo realizado pela JVM. No caso, a alocação é feita pela JVM diretamente na memória heap, e a desalocação é feita pelo Garbage Collector. No entanto, o desenvolvedor pode muito bem atrapalhar este processo, e assim causar outra espécie de vazamento de memória, que acontece quando muitos objetos em memória tornam-se inacessíveis pelo código em execução, ocupando memória desnecessariamente.

Nesta modalidade de vazamento de memória, os danos são menores, mas ainda muito perigosos. Não há erros de ponteiros inválidos ou coisas do tipo, mas há a possibilidade de esgotamento de memória devido à retenção não-intencional de objetos em memória.

O problema acontece quando objetos em desuso permanecem referenciados por outros objetos. Assim, o Garbage Collector não pode coletar tais objetos, visto que eles ainda são alcançáveis a partir dos objetos raízes da memória heap. Se o desenvolvedor não remover tais referências, os objetos permanecerão ocupando espaço da memória heap e comprometendo todo o sistema em termos de performance e quantidade de memória disponível para outras aplicações.

Há duas situações que podem provocar vazamento de memória em Java: 1) quando a ocupação da memória cresce sem parar até causar OutOfMemoryError; 2) quando a ocupação da memória permanece alta até o escopo de uma grande quantidade de objetos finalizar, incidindo sobre o Garbage Collector muito trabalho para liberar uma grande quantidade de memória, podendo causar pausas maiores na aplicação.

Vazamentos de memória em Java podem ser evitados com certos cuidados do desenvolvedor. Alguns deles serão abordados a seguir.

Escopo inadequado

Uma prática equivocada é a de armazenar objetos como atributos de uma classe quando isto não é necessário, apenas por comodidade do desenvolvedor. Isto acontece geralmente para não ter que passar estes objetos por parâmetro a todos os métodos que o utilizam, visto que todos os métodos podem acessar qualquer atributo de uma classe. O problema é que o custo desta decisão é manter esta referência de objeto durante todo o tempo de vida do objeto pai. Da mesma maneira, existem inúmeros outros cenários que causam problemas similares por problema de escopo inadequado.

A seguir, é ilustrado um cenário onde esta simples decisão fez aumentar o consumo da memória em 300%. Considere o problema de calcular tabuadas em larga escala, utilizando uma matriz, e escrever os resultados em arquivo texto. Para ilustrar melhor o problema, vamos quebrar o cálculo em quatro etapas, onde cada etapa calculará as tabuadas por fatores de 1 a 10, 11 a 20, 21 a 30 e 31 a 40, e escreverá os resultados em um arquivo texto. O objetivo é analisar a diferença de uma implementação com vazamento de memória por problema de escopo inadequado e uma implementação sem este problema.

Começamos criando a interface Tabuada, a ser aplicada por ambas as implementações, na Listagem 1.

Listagem 1. Código da interface Tabuada.

  package br.com.javamagazine.gc;
   
  import java.io.IOException;
   
  public interface Tabuada {
   
     public abstract void calcularTabuadas(String arquivoTabuada, int quantidadeNumeros, 
           int... fatores) throws IOException;
   
  }

Agora, criaremos duas classes para implementar esta interface. Começamos pela classe TabuadaComVazamento, que é a implementação com vazamento de memória por problema de escopo inadequado, na Listagem 2. Note que a matriz de tabuadas está sendo armazenada como um atributo da classe, e desta forma não é necessário passá-la nos argumentos dos métodos criarTabuadas() e imprimirTabuadas(). A propósito, a matriz é de objetos Long apenas para ilustrar a utilização de objetos e sua instanciação, mas obviamente a melhor escolha seria utilizar o tipo primitivo long.

Listagem 2. Código da classe TabuadaComVazamento.

  package br.com.javamagazine.gc;
   
  import java.io.BufferedWriter;
  import java.io.FileWriter;
  import java.io.IOException;
   
  public class TabuadaComVazamento implements Tabuada {
     private Long[][] matrizTabuadas;
   
     @Override
     public void calcularTabuadas(String arquivoTabuada, int quantidadeNumeros, int... fatores) 
           throws IOException {
        matrizTabuadas = new Long[quantidadeNumeros][fatores.length];
        criarTabuadas(quantidadeNumeros, fatores);
        imprimirTabuadas(arquivoTabuada, fatores);
     }
   
     private void criarTabuadas(int quantidadeNumeros, int... fatores) {
        for (int i = 0; i < quantidadeNumeros; i++) {
           for (int j = 0; j < fatores.length; j++) {
              matrizTabuadas[i][j] = new Long(i * fatores[j]);
           }
        }
     }
   
     private void imprimirTabuadas(String arquivoTabuada, int... fatores) throws IOException {
        BufferedWriter bw = new BufferedWriter(new FileWriter(arquivoTabuada));
        for (int i = 0; i < matrizTabuadas.length; i++) {
           for (int j = 0; j < fatores.length; j++) {
              bw.append(i + "*" + fatores[j] + "=" + matrizTabuadas[i][j] + "\t");
           }
        }
        bw.close();
     }
  }

Em seguida criamos a classe TabuadaSemVazamento, que é a implementação sem vazamento de memória, na Listagem 3. Note que a matriz de tabuadas está sendo armazenada como uma variável local do método calcularTabuadas(), sendo assim necessário passá-la nos argumentos dos métodos criarTabuadas() e imprimirTabuadas().

Listagem 3. Código da classe TabuadaSemVazamento.

  package br.com.javamagazine.gc;
   
  import java.io.BufferedWriter;
  import java.io.FileWriter;
  import java.io.IOException;
   
  public class TabuadaSemVazamento implements Tabuada {
   
     public void calcularTabuadas(String arquivoTabuada, int quantidadeNumeros, int... fatores)
           throws IOException {
        Long[][] matrizTabuadas = new Long[quantidadeNumeros][fatores.length];
        criarTabuadas(matrizTabuadas, quantidadeNumeros, fatores);
        imprimirTabuadas(arquivoTabuada, matrizTabuadas, fatores);
     }
   
     private void criarTabuadas(Long[][] matrizTabuadas, int quantidadeNumeros, int... fatores) {
        for (int i = 0; i < quantidadeNumeros; i++) {
           for (int j = 0; j < fatores.length; j++) {
              matrizTabuadas[i][j] = new Long(i * fatores[j]);
           }
        }
     }
   
     private void imprimirTabuadas(String arquivoTabuada, Long[][] matrizTabuadas, int... fatores)
           throws IOException {
        BufferedWriter bw = new BufferedWriter(new FileWriter(arquivoTabuada));
        for (int i = 0; i < matrizTabuadas.length; i++) {
           for (int j = 0; j < fatores.length; j++) {
              bw.append(i + "*" + fatores[j] + "=" + matrizTabuadas[i][j] + "\t");
           }
        }
        bw.close();
     }
  }

Para exercitar estas classes, codificamos CalculadorDeTabuadas, que contém o método main(). Veja a Listagem 4. Sua chamada pela linha de comando pode ser:

  • CalculadorDeTabuadas br.com.javamagazine.gc.TabuadaSemVazamento para utilizar a implementação sem vazamento de memória;
  • CalculadorDeTabuadas br.com.javamagazine.gc.TabuadaComVazamento para utilizar a implementação com vazamento de memória.
Listagem 4. Código da classe CalculadorDeTabuadas.

  package br.com.javamagazine.gc;
   
  import java.io.BufferedReader;
  import java.io.InputStreamReader;
  import java.lang.management.ManagementFactory;
   
  public class CalculadorDeTabuadas {
   
     public static void main(String[] args) throws Exception {
        // PID para JConsole
        if (ManagementFactory.getRuntimeMXBean() != null) {
           System.out.println("PID = " + ManagementFactory.getRuntimeMXBean().getName());
        }
   
        // Argumentos
        if (args.length != 1) {
           System.out.println("Uso: CalculadorDeTabuadas <classe implementacao de Tabuada>");
           return;
        }
        @SuppressWarnings("unchecked")
        Class<Tabuada> implementacaoDeTabuada = (Class<Tabuada>) Class.forName(args[0]);
        int tamanhoDaTabuada = 1000000;
   
        // Pausa inicial
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        br.readLine();
   
        // Primeiro cálculo e pausa
        System.out.println("Primeiro calculo: 1 a 10");
        Tabuada tabuada1 = (Tabuada) implementacaoDeTabuada.newInstance();
        tabuada1.calcularTabuadas("tabuada1.txt", tamanhoDaTabuada, new int[] {
           1, 2, 3, 4, 5, 6, 7, 8, 9, 10 });
        br.readLine();
   
        // Segundo cálculo e pausa
        System.out.println("Segundo calculo: 11 a 20");
        Tabuada tabuada2 = (Tabuada) implementacaoDeTabuada.newInstance();
        tabuada2.calcularTabuadas("tabuada2.txt", tamanhoDaTabuada, new int[] {
           11, 12, 13, 14, 15, 16, 17, 18, 19, 20 });
        br.readLine();
   
        // Terceiro cálculo e pausa
        System.out.println("Terceiro calculo: 21 a 30");
        Tabuada tabuada3 = (Tabuada) implementacaoDeTabuada.newInstance();
        tabuada3.calcularTabuadas("tabuada3.txt", tamanhoDaTabuada, new int[] {
           21, 22, 23, 24, 25, 26, 27, 28, 29, 30 });
        br.readLine();
   
        // Quarto cálculo e pausa
        System.out.println("Quarto calculo: 31 a 40");
        Tabuada tabuada4 = (Tabuada) implementacaoDeTabuada.newInstance();
        tabuada4.calcularTabuadas("tabuada4.txt", tamanhoDaTabuada, new int[] {
           31, 32, 33, 34, 35, 36, 37, 38, 39, 40 });
        br.readLine();
     }
  }

Agora vamos para a parte mais interessante: executar e analisar os resultados! Está descrito aqui um pequeno passo-a-passo para analisar este cenário, mas quando você for testar este código, tenha em mente que os resultados certamente serão um pouco diferentes, visto que cada JVM roda em um sistema operacional diferente, com configurações de hardware diferentes e em processadores diferentes.

A propósito, estes resultados foram obtidos em um Dell Latitude E6510 com Windows 7 Professional 64-bits Service Pack 1, processador Intel Core i7 Q720 @1.6 GHz, 8 GB de memória RAM e JVM HotSpot 64-bit versão 1.6.0_26.

Vamos primeiro analisar o caso com vazamento de memória. Para isso:

  • Abra um console, vá para o diretório onde estão os arquivos .class e digite:
    
    java -verbosegc
    br.com.javamagazine.gc.CalculadorDeTabuadas
    br.com.javamagazine.gc.TabuadaComVazamento;
    
  • Deve aparecer uma linha com o PID e uma pausa. No estudo de exemplo, foi obtido: PID = 6528@AC-TGARCIA-PC. Isto significa que o PID é 6528. Se não aparecer esta linha, obtenha o número do processo a partir do seu sistema operacional. No Windows, utilize o comando tasklist /v e procure pelo processo java.exe. Em sistemas baseados em Unix, utilize o comando ps -A | grep java;
  • Abra outro console e digite: jconsole <seu PID>. No estudo de exemplo, foi digitado: jconsole 6528. A interface gráfica do JConsole será aberta. Agora, clique na aba Memory. Observe que o gráfico, se existir, representará valores baixos de consumo de memória. Observe também os valores de memória Used, Committed e Max. No estudo de exemplo: Used = 9.843 KB, Committed = 125.504 KB e Max = 1.862.336 KB, o que significa que de toda a memória reservada para a aplicação, cerca de 7% está disponível para uso e 0,5% está sendo utilizada. As barras de utilização do espaço Eden, espaço Survivor e geração Tenured estão vazias, visto que não houve alocação de objetos até então;
  • Volte para o primeiro console e aperte qualquer tecla para disparar o primeiro cálculo de tabuada. Alterne para a janela do JConsole, mantendo o primeiro console visível, e veja a memória sendo ocupada no gráfico e nas barras verdes, ao mesmo tempo em que várias collections acontecem e são logadas no primeiro console. No exemplo: Used = 642.146 KB e Committed = 895.936 KB. Ainda, ocorreram 18 Minor collections (utilizando a implementação PS Scavenge) e 3 Major collections (utilizando a implementação PS MarkSweep). A barra de utilização do espaço Eden está por volta de 15%, o espaço Survivor está por volta de 5% e a geração Tenured está por volta de 20%;
  • Repita o passo 4 novamente para disparar o segundo cálculo de tabuada. No estudo de exemplo: Used = 869.793 KB e Committed = 1.176.000 KB. Ainda, ocorreram mais 11 Minor collections e mais 1 Major collection. A barra de utilização do espaço Eden está por volta de 20%, o espaço Survivor está por volta de 50% e a geração Tenured está por volta de 45%;
  • Repita o passo 4 novamente para disparar o terceiro cálculo de tabuada. No estudo de exemplo: Used = 905.019 KB e Committed = 1.656.000 KB. Ainda, ocorreram mais 15 Minor collections e mais 2 Major collections. A barra de utilização do espaço Eden está por volta de 5%, o espaço Survivor está vazio e a geração Tenured já está na casa dos 60%;
  • Repita o passo 4 novamente para disparar o quarto cálculo de tabuada. No estudo de exemplo: Used = 1.202.844 KB e Committed = 1.862.000 KB. Ainda, ocorreram mais 2 Minor collections e mais 13 Major collections. A barra de utilização do espaço Eden está por volta de 15%, o espaço Survivor está vazio e a geração Tenured já está na casa dos 80%;
  • Para encerrar, clique no botão Perform GC e observe o log do primeiro console. No estudo de exemplo, o ganho foi ínfimo. A memória em uso foi reduzida para 1.172.731 KB, provavelmente coletando aqueles 15% que estavam no espaço Eden, mas não houve modificação alguma na geração Tenured de acordo com a barra do JConsole.

Várias conclusões podem ser tomadas aqui. Primeiramente, percebe-se que a geração Tenured nunca diminui de tamanho, ou seja, todos os objetos copiados para lá permanecem até o final do programa. Voilá, agora você está oficialmente apresentado ao vazamento de memória!

Este vazamento acontece simplesmente porque o aplicativo mantém as referências para todos os objetos que compõem cada matriz, ou seja, 1.000.000 de objetos Long[ ] (cada um representando uma linha da matriz) e mais 10.000.000 de objetos Long (cada um representando uma célula da matriz). Esta referência é mantida desnecessariamente, por pura comodidade do desenvolvedor.

Além disso, outras observações interessantes são que, pelo menos no estudo de exemplo, a memória Committed chegou ao seu máximo (igualou-se a Max), ou seja, possivelmente um quinto cálculo de tabuada ocasionaria um OutOfMemoryError. Ainda, no quarto cálculo de tabuada, houveram muito mais Major collections que Minor collections, ou seja, o tempo de pausa foi altamente impactado.

Vamos agora analisar o caso sem vazamento de memória. Para isso:

  1. Abra um console, vá para o diretório onde estão os arquivos .class e digite:
    
    java -verbosegc
    br.com.javamagazine.gc.CalculadorDeTabuadas
    br.com.javamagazine.gc.TabuadaSemVazamento; 
    
  2. Veja o PID ou obtenha-o a partir do seu sistema operacional;
  3. Abra outro console e digite: jconsole <seu PID>. A interface gráfica do JConsole será aberta. Agora, clique na aba Memory. No estudo de exemplo: Used = 10.498 KB, Committed = 125.504 KB e Max = 1.862.336 KB. As barras de utilização do espaço Eden, espaço Survivor e geração Tenured, assim como no exemplo anterior, estão vazias;
  4. Volte para o primeiro console e aperte qualquer tecla para disparar o primeiro cálculo de tabuada. Alterne para a janela do JConsole, mantendo o primeiro console visível, e veja a memória sendo ocupada no gráfico e nas barras verdes, ao mesmo tempo em que várias collections acontecem e são logadas no primeiro console. No estudo de exemplo: Used = 331.371 KB e Committed = 808.384 KB. Ainda, ocorreram 19 Minor collections (utilizando a implementação PS Scavenge) e 3 Major collections (utilizando a implementação PS MarkSweep). A barra de utilização do espaço Eden está por volta de 30%, o espaço Survivor está vazio e a geração Tenured está por volta de 20%;
  5. Repita o passo 4 novamente para disparar o segundo cálculo de tabuada. No estudo de exemplo: Used = 351.284 KB e Committed = 781.120 KB. Ainda, ocorreram mais 11 Minor collections e mais 1 Major collection. As barras de utilização de memória permanecem com os mesmos valores do passo anterior, pois agora as collections conseguiram coletar os objetos utilizados no primeiro cálculo, e a memória foi ocupada novamente pela mesma ordem de quantidade de objetos no segundo cálculo;
  6. Repita o passo 4 novamente para disparar o terceiro cálculo de tabuada. No estudo de exemplo: Used = 382.737 KB e Committed = 756.992 KB. Ainda, ocorreram mais 13 Minor collections e mais 1 Major collection. A barra de utilização do espaço Eden está por volta de 15%, o espaço Survivor está cheio e a geração Tenured está por volta de 20%;
  7. Repita o passo 4 novamente para disparar o quarto cálculo de tabuada. No estudo de exemplo: Used = 406.036 KB e Committed = 1.064.064 KB. Ainda, ocorreram mais 15 Minor collections e mais 1 Major collection. A barra de utilização do espaço Eden está por volta de 50%, o espaço Survivor está vazio e a geração Tenured está por volta de 20%;
  8. Por fim, clique no botão Perform GC e observe o log do primeiro console. No estudo de exemplo, o ganho foi total. A memória em uso foi reduzida para 862 KB, o que significa que esta Major collection coletou tudo o que tinha nas generations Young e Tenured. Assim, as barras de memória ficaram vazias.

Neste caso, nota-se que a geração Tenured sempre ficou na casa dos 20%. Além disso, o aumento do consumo de memória Used a cada cálculo de tabuada é muito menor em comparação com o caso do vazamento de memória. Para tal, o código ficou um pouco mais trabalhoso, ao requerer do desenvolvedor passar a matriz por parâmetro para dois métodos, mas certamente vale a pena ter esse trabalho extra.

É muito recomendável testar este código em diferentes máquinas, diferentes configurações de hardware e até mesmo em diferentes sistemas operacionais. Teste no seu computador pessoal, no computador do seu serviço, num servidor que você tenha acesso, e até mesmo naquele computador velho da sua tia que ela sempre te chama para consertar. Provavelmente aparecerão situações diferentes e edificantes.

Se houver um cenário muito diferente, fique à vontade para me enviar um e-mail comentando o cenário ocorrido. Eu me disponibilizo para estudar o cenário e lhe dar um parecer, o que também será uma ótima experiência para mim.

Bloco try/catch sem finally

Outro fator que causa vazamentos de memória em Java é a utilização de blocos try/catch sem finally. O problema ocorre quando o fluxo de execução pode ser desviado por uma exceção e pode não executar todas as linhas de código dentro do bloco try, impossibilitando certas referências de serem removidas. Veja a Listagem 5.

Listagem 5. Exemplo de bloco try/catch sem finally.

  try {
     mailbox.addActionListener(meuListener);
     mailbox.fireMail();
     mailbox.removeActionListener(meuListener);
  }
  catch (Exception e) {
     // tratar exceção e
  }

Neste exemplo, se o método mailbox.fireMail() levantar uma exceção, a linha mailbox.removeActionListener(meuListener) nunca será executada, e desta forma mailbox manterá uma referência a meuListener, o que impedirá meuListener de ser coletado, causando vazamento de memória.

A solução é sempre utilizar finally em casos como este, conforme a Listagem 6.

Listagem 6. Exemplo de bloco try/catch/finally.

  mailbox.addActionListener(meuListener);
  try {
     mailbox.fireMail();
  }
  catch (Exception e) {
     // tratar exceção e
  }
  finally {
     mailbox.removeActionListener(meuListener);
  }

Método finalize()

Outra fonte de problemas relacionados com memória e Garbage Collection em Java é o método finalize() da classe Object. Este método, presente em todos os objetos Java, foi criado com o objetivo de ser executado no momento anterior à desalocação de objetos. Embora seu objetivo principal seja possibilitar a limpeza não-relacionada com a memória heap, ou seja, a liberação de recursos nativos, este método tem sido utilizado equivocadamente. Por exemplo, desenvolvedores que vieram do C++ entenderam o método finalize() como os métodos destrutores do C++, utilizando-o para resetar os atributos dos objetos de maneira automática. Fazendo isto, o ciclo de vida do objeto é prolongado, assim como o momento em que o objeto é coletado, consumindo memória por mais tempo desnecessariamente.

Primeiramente, o método finalize() só deve ser utilizado para liberação de recursos nativos quando esta liberação não for urgente, ou seja, não é prejudicial se este recurso não for liberado imediatamente após o seu último uso. Se houver esta urgência, o método finalize() não é uma boa solução, pois ele pode ser chamado muito tempo depois, de forma não-determinística. Deste modo, o ideal é liberar os recursos explicitamente por métodos chamados imediatamente após a última utilização do recurso nativo, assim como é feito com recursos não-nativos, como o acesso a bancos de dados, arquivos e conexões de rede.

O ciclo de vida de um objeto que implemente o método finalize() é o seguinte:

  1. Quando o objeto é alocado, a JVM registra internamente que este é um objeto que possui um método finalize() personalizado, o que aumenta o seu tempo de alocação;
  2. Quando o Garbage Collector determina que o objeto é não-alcançável, ele transfere o objeto para a fila de finalização da JVM. Neste momento, todos os objetos referenciados por este objeto também são impedidos de serem coletados, já que eles devem ser acessíveis pelo método finalize();
  3. Mais tarde, a thread que cuida da fila de finalização da JVM irá recuperar o objeto, chamar seu método finalize() e registrar que o objeto foi finalizado;
  4. Quando o Garbage Collector redescobrir que o objeto é não-alcançável, apenas aí ele será coletado, assim como todos os seus objetos referenciados, se estiverem aptos para tal.

Por estes motivos, um objeto que implemente o método finalize() demorará mais para ser coletado, e atrasará a coleta de todos os objetos que ele referenciar. Ainda, se a fila de finalização da JVM estiver muito cheia, o objeto demorará ainda mais para ser finalizado, correndo o risco de nunca ser chamado caso a execução do programa ou da JVM seja interrompida. Desta forma, não é aconselhável fazer nenhum tipo de processamento essencial dentro do método finalize().

Outro problema é que a natureza não-determinística do processo de finalização de objetos não garante a ordem de finalização de objetos. Por exemplo: suponha que o objeto A depende do objeto B, e ambos implementam o método finalize(). Neste caso, B pode ser finalizado antes de A, mas se A precisar usar B dentro do seu método finalize(), ocorrerá um erro. A solução para este problema é garantir que o construtor de A crie uma referência direta para B. Deste modo, B não será finalizável até que A tenha sido finalizado.

Referências fracas

Este assunto já foi abordado no artigo “Sabe o que são referências fracas?”, publicado na Java Magazine 99, de autoria de Tiago Silva, sendo leitura obrigatória para atingir os objetivos deste artigo. Para quem ainda não leu, vale a pena mencionar aqui que referências fracas são artifícios para lidar com o problema que causa os vazamentos de memória: impossibilidade de coletar objetos inutilizados quando há referências a eles.

Assim, referências fracas são tipos especiais de referências de objetos que permitem sua coleta pelo Garbage Collector, evitando a retenção não-intencional de objetos em memória. São implementadas pelas classes SoftReference, WeakReference e PhantomReference. Há também a classe WeakHashMap, um HashMap cujas chaves são referências fracas, ou seja, não reterão os objetos valores em memória permanentemente.

Referências fracas são uma boa prática, porém devem ser usadas com parcimônia, visto que podem também introduzir problemas de referências indisponíveis se não forem empregadas corretamente.

Outras boas práticas

Em suma, para escrever código Java e ajudar o Garbage Collector a fazer o seu trabalho, deve-se ter em mente que os objetos devem sobreviver pelo mínimo tempo possível, no menor escopo possível e suas referências devem ser cuidadosamente controladas, só permanecendo as que forem realmente necessárias.

A seguir, destacamos mais algumas boas práticas para tirar melhor proveito do Garbage Collector.

Alocação de objetos

No JDK 1.0, a alocação de objetos era lenta. Assim, desde o princípio surgiram alternativas para evitar a alocação de muitos objetos, como o pooling. No entanto, em versões mais atuais da JVM HotSpot, a alocação é cada vez mais rápida e eficiente, sendo então desaconselhado evitar a alocação de muitos objetos para obter performance. O jeito que o Garbage Collector generacional funciona é muito apropriado para manipular objetos pequenos com baixo tempo de vida. Portanto é uma boa prática alocar muitos objetos pequenos sempre que for necessário.

Entretanto, isto não significa sinal verde para alocações desnecessárias. Lembre-se: mais alocações causarão collections mais frequentes. O objetivo aqui é encorajar a alocação de mais objetos com baixo tempo de vida ao invés de menos objetos com alto tempo de vida, para estimular mais Minor collections que Major collections.

Nular explicitamente

Nular referências assim que se tornarem desnecessárias é o caminho para ajudar o Garbage Collector a coletar o máximo possível de objetos. No entanto, isto não é necessário para variáveis locais dentro de pequenos escopos, como métodos. Não é uma boa prática nular toda e qualquer referência, pois pode impactar na performance do sistema, visto que o compilador JIT realiza otimizações que podem ser evitadas com este comportamento.

Evitar System.gc()

Invocar uma collection explicitamente é uma péssima ideia. O método System.gc() simplesmente dispara uma Major collection, ou seja, o tipo de collection que todos os collectors tentam evitar a qualquer custo, e só utilizar quando não há mais alternativas.

Se ainda assim você julgar necessário utilizar este método, lembre-se que você provocará elevados tempos de pausa na aplicação.

Conclusão

As boas práticas aqui apresentadas objetivam o melhor funcionamento do processo de Garbage Collection, no qual o desenvolvedor contribui de maneira consciente. Além disso, saber analisar a situação da memória heap e collections em tempo de execução abre as portas para a solução de inúmeros outros cenários que não foram descritos no artigo.

Esta sequência de artigos buscou cobrir diversos assuntos relacionados a Garbage Collection com um bom nível de profundidade. A expectativa com estes artigos é ajudar o leitor a compor o seu arsenal pessoal para atacar problemas relacionados a Garbage Collection e memória heap, questionando a correta utilização destes e fazendo reflexões.

Assim, é muito recomendada a continuidade deste estudo, visto que o ganho de desempenho e a economia de recursos como memória podem ser de muita importância em todo tipo de aplicação, particularmente em aplicações corporativas. Como auxílio, o autor está à disposição por e-mail para discussão, análise e estudo de cenários relacionados com os assuntos abordados aqui.

Até a próxima!

Confira também