Garbage Collection em Java (4)

Publicado em 23/01/2015

Último de uma série de artigos sobre Garbage Collection em Java, traduzidos dos originais publicados por Richard Warburton no seu blog. Richard é autor do livro da editora O'Reilly Java 8 Lambdas. (Tradução e publicação autorizadas pelo autor.)

G1: o Primeiro Lixeiro

O coletor G1 foi o último a ser implementado na JVM. É um coletor que vem recebendo suporte desde Java 7, Update 4, e a equipe da Oracle responsável pelo projeto GC tem declarado publicamente sua esperança de conseguir pausas curtas para a coleta de lixo quando o G1 estiver plenamente concluído. Esta é a continuação dos meus artigos anteriores sobre Garbage Collection (Coleta de Lixo):

  1. Visão geral da GC na HotSpot
  2. Garbage Collectors em Paralelo
  3. Marcação e Limpeza Concorrentes (CMS)

O Problema: Grandes Heaps Significam Longas Pausas

O coletor de Marcação e Limpeza Concorrentes (Concurrent Mark and Sweep - CMS), é atualmente o coletor recomendado para pausas curtas, mas infelizmente seus intervalos de pausa aumentam com a quantidade de objetos vivos na região Tenured. Isso significa que, enquanto é relativamente fácil conseguir pausas curtas em heaps pequenas, os intervalos começam a aumentar quando se tem heaps da ordem de dezenas ou centenas de Gigabytes.

Além disso, o CMS não desfragmenta a heap, de forma que em algum momento irá ocorrer uma falha no modo concorrente (Concurrent Mode Failure - CMF), disparando uma GC completa. Uma vez entrando neste cenário de GC completa, pode-se esperar uma pausa na linha de tempo, a grosso modo, de 1 segundo por Gigabyte de objetos vivos. Utilizando o CMS, uma heap de 100 GB pode se tornar uma bomba-relógio com uma pausa de um minuto e meio pronta para explodir a qualquer momento...

Granada

Um bom ajuste na GC pode cuidar desse inconveniente, mas às vezes apenas empurra o problema com a barriga. Uma Falha do Modo Concorrente (CMF) e a subsequente GC completa são inevitáveis numa linha de tempo suficientemente longa, a menos que você pertença àquele pequeno grupo de pessoas que deliberadamente evitam preencher o espaço Tenured.

Esquema da Heap de G1

O Coletor G1 tenta separar os intervalos de pausa de uma coleta individual, do tamanho total da heap, dividindo-a em diferentes regiões. Cada região tem um tamanho fixo, entre 1MB e 32MB, e a JVM é capaz de criar um total de 2.000 regiões.

Regiões da heap

Você deve estar lembrado, da leitura dos artigos anteriores, que os outros coletores dividem a heap nos grupos de memória denominados Eden, Survivor e Tenured. O G1 mantem a mesma categoria de agrupamentos, mas ao invés deles serem blocos de memória contíguos, cada região é logicamente categorizada num desses grupos.

Há ainda um outro tipo de região — a região enorme (humongous region). Ela foi projetada para armazenar objetos que possuem tamanho maior do que a maioria — por exemplo, um array muito longo. Qualquer objeto maior do que 50% do tamanho da região é armazenado na humongous region. Isso é feito tomando-se múltiplas regiões normais, localizadas contiguamente na memória, e tratando-as logicamente como uma única região.

Regiões tipadas da heap

Conjuntos Memorizados

Claro que pouco adiantaria dividir a heap em regiões se fosse preciso percorrer toda ela para encontrar os objetos marcados como vivos. O primeiro passo para resolver isso é subdividir as regiões em segmentos de 512 Bytes, chamados "cartas" (cards). Cada carta tem um registro de 1 byte na tabela de marcação.

Cada região possui um conjunto memorizado ou RSet — que é o conjunto de cartas que foram registradas. Uma carta é um conjunto memorizado se um objeto de outra região, armazenado dentro da carta, aponta para um objeto dentro desta região.

Sempre que um mutator registra a referência de um objeto, uma barreira de registro é usada para atualizar o conjunto memorizado. Nos bastidores, o conjunto memorizado é dividido em diferentes coleções, de forma que diferentes threads podem operar sem restrição, mas conceitualmente todas as coleções são parte do mesmo conjunto memorizado.

Marcação Concorrente

A fim de identificar quais objetos na heap estão vivos, o G1 executa, na maioria das vezes, uma marcação concorrente dos objetos vivos.

  • Fase de Marcação — O objetivo da fase de marcação é encontrar quais os objetos dentro da heap que estão vivos. Para registrar os objetos que estão vivos, o G1 usa um bitmap de marcação — que armazena um único bit para cada 64 bits na heap. Todos os objetos são rastreados a partir de suas raízes, marcando-se as áreas com objetos vivos no bitmap de marcação. Isso é feito, na maioria das vezes, de forma concorrente, mas há uma Pausa Inicial de Marcação, similar ao CMS, na qual o aplicativo é pausado e o primeiro nível de filhos dos objetos raízes são rastreados. Depois que isso se completa, as threads do mutator recomeçam. O G1 precisa manter um registro atualizado de tudo o que estiver vivo na heap, uma vez que ela não está sendo limpa na mesma pausa da fase de marcação.
  • Fase de Remarcação — O objetivo da fase de remarcação é atualizar a informação da fase de marcação sobre os objetos vivos. A primeira coisa a fazer é decidir quando remarcar. Ela é disparada com base no preenchimento de uma percentagem da heap. Isso é calculado pegando-se a informação da fase de marcação e o número de alocações desde então, o que informa ao G1 se a percentagem requerida foi atingida. O G1 usa então a barreira de registro mencionada acima para anotar as mudanças na heap e armazená-las numa série de buffers de mudanças. Os objetos nos buffers de mudanças são registrados no bitmap de marcação concorrentemente. Quando a percentagem de preenchimento é atingida, as threads do mutator são pausadas novamente e os buffers de mudanças são processados, marcando seus objetos vivos.
  • Fase de limpeza — Neste ponto, o G1 sabe quais são os objetos que estão vivos. Uma vez que o seu foco são regiões que possuem a maior parte do espaço disponível, seu próximo passo é trabalhar o espaço livre numa determinada região, contando os objetos vivos. Isso é calculado a partir do bitmap de marcação, e as regiões são ordenadas tomando-se por base em quais delas é mais útil coletar. As regiões coletadas são armazenadas no que é conhecido como um conjunto de coleção (collection set) ou CSet.

Evacuação

Similarmente à abordagem feita pela Geração Jovem Hemisférica na GC em Paralelo e dos coletores CMS, os objetos mortos não são coletados. Ao invés disso, os objetos vivos são evacuados de uma região e toda ela é então considerada livre.

O G1 recupera os objetos vivos de uma forma inteligente — ele não tenta recuperar todos os objetos vivos num único ciclo, mas focaliza regiões potencialmente com o maior espaço passível de recuperação e apenas essas são evacuadas. Seu trabalho nas regiões-alvo é feito calculando a proporção de objetos vivos dentro das regiões e selecionando a região com a proporção mais baixa de objetos vivos.

Os objetos são evacuados e a região vai sendo liberada, o que ocorre simultaneamente em diversas outras regiões. Isso significa que o G1 compacta os dados enquanto executa a GC. Isto é operado em paralelo em múltiplas threads. Os "GC em Paralelo" tradicionais também fazem isso, mas o CMS não.

Da mesma forma como no CMS e no GC em Paralelo, há o conceito de tenuring. Isso quer dizer que os objetos jovens ficam "velhos" se sobreviverem a coletas suficientes. Este número é chamado limiar de tenuring. Se uma região geracional jovem sobrevive ao limiar de tenuring e retem objetos vivos o suficiente para evitar que sejam evacuados, então a região é promovida. Primeiro para a região Survivor e depois para a Tenured. Ela nunca é evacuada.

Falha de Evacuação

Infelizmente, o G1 ainda pode encontrar um cenário similar ao de uma Falha do Modo Concorrente (CMF), disparando uma GC Completa que "para o mundo". Isso é chamado uma Falha de Evacuação e ocorre quando não há regiões livres. A inexistência de regiões livres significa que não há para onde evacuar os objetos.

Teoricamente, as Falhas de Evacuação são menos prováveis de acontecer no G1 do que as Falhas do Modo Concorrente no CMS. Isso porque o G1 compacta suas regiões em tempo real ao invés de esperar que ocorra uma falha para implementar uma compactação.

Conclusões

A despeito das compactações e dos esforços para pausas curtas, o G1 não garante ser sempre o campeão e todas as tentativas de adotá-lo devem ser acompanhadas de uma medição objetiva da performance dos alvos e análise dos logs de GC. A metodologia necessária para isso está fora do escopo deste artigo, mas espero abordá-la futuramente.

Em termos de algorítimo, existem desafios enfrentados pelo G1 que os outros coletores da HotSpot não encontram. Especialmente o ônus de manter conjuntos memorizados. O GC em Paralelo ainda é o coletor recomendado para maior rendimento, e em muitas circunstâncias o CMS se sai melhor do que o G1.

Ainda é muito cedo para dizer se o G1 será o grande vencedor na disputa com o coletor CMS, mas em algumas situações ele já está trazendo benefícios para os desenvolvedores que o utilizam. Com o tempo veremos se as limitações de desempenho do G1 são realmente limites ou se a equipe de desenvolvimento apenas precisa de um esforço de engenharia maior para solucionar os problemas.

Meus agradecimentos a John Oliver, Tim Monks e Martijn Vergurg por fazerem a revisão dos rascunhos deste e dos artigos anteriores sobre GC.

Compartilhe esta postagem