Artigo original: Garbage Collection in Java – What is GC and How it Works in the JVM

Em um artigo anterior (em inglês), escrevi sobre a Máquina Virtual do Java (JVM) e expliquei sua arquitetura. Como parte do componente de ferramenta de execução, eu também falei brevemente sobre o Coletor de Lixo do Java (em inglês, Garbage Collector, ou GC).

Neste artigo, você vai aprender mais sobre o Garbage Collector, como ele funciona, os tipos variados de GC presentes no Java e suas vantagens. Também comentarei alguns dos novos Coletores de Lixo experimentais que estão disponíveis nas últimas versões do Java.

O que é a coleta de lixo em Java?

A coleta de lixo é o processo de recolher a memória não utilizada em tempo de execução, destruindo os objetos inutilizados.

Em linguagens como C e C++, o programador é responsável tanto pela criação quanto pela destruição dos objetos. Às vezes, o programador pode esquecer de destruir objetos que não estão sendo utilizados. A memória alocada para eles, por isso, não é liberada. A memória utilizada pelo sistema continua crescendo e, em algum momento, não haverá memória disponível no sistema para alocar seus objetos. O nome dado a tal processo é "memory leak" (vazamento de memória).

Após um certo ponto, a memória suficiente não se encontra disponível para a criação de objetos. O programa inteiro, então, finaliza anormalmente devido a um erro do tipo OutOfMemoryError.

Você pode usar métodos como free() em C e delete() em C++ para realizar operações de coleta de lixo. Em Java, porém, a coleta de lixo acontece automaticamente durante o ciclo de vida de um programa. Isso elimina a necessidade de desalocar memória e, portanto, evita vazamentos de memória.

O Garbage Collector do Java é o processo no qual os programas em Java realizam o gerenciamento automático de memória. Programas em Java são compilados em bytecode, podendo ser executados na Máquina Virtual do Java (Java Virtual Machine).

Quando estes programas são executados na JVM, objetos são criados na memória heap, que é a porção da memória dedicada ao programa.

No decorrer do ciclo de vida de uma aplicação em Java, objetos são criados e dispensados. Ao final, alguns objetos não são mais necessários. É possível dizer que, a qualquer ponto, a memória heap consiste em dois tipos de objetos:

  • Vivos (Live) – estes objetos estão sendo utilizados e referenciados em algum lugar
  • Mortos (Dead) – estes objetos não estão sendo utilizados ou não estão sendo referenciados em lugar algum.

O coletor de lixo encontra esses objetos que não estão sendo utilizados e os exclui para salvar memória.

Como desreferenciar um objeto em Java

O objetivo principal do coletor de lixo é salvar espaço na memória heap por meio da destruição de objetos que não contêm uma referência. Quando não existem referências a um objeto, a JVM pressupõe que o mesmo está morto (dead) e que não é mais necessário. Então, a memória heap ocupada por este objeto pode ser desocupada.

Existem diversas formas pelas quais as referências de um objeto podem ser desfeitas para transformá-lo em um candidato à coleta de lixo. Algumas delas são:

Transformar uma referência em nula

Estudante estudante = new Estudante();
estudante = null;

Atribuir uma referência à outra

Estudante estudanteUm = new Estudante();
Estudante estudanteDois = new Estudante();
estudanteUm = estudanteDois; // agora o primeiro objeto referenciado por estudanteUm está disponível para garbage collection

Utilizar um objeto anônimo

registrar(new Estudante());

Como funciona o Garbage Collection em Java?

O Garbage Collection do Java é um processo automático. O programador não precisa marcar explicitamente os objetos a serem excluídos.

A implementação da coleta de lixo se encontra na JVM (Máquina Virtual do Java). Cada JVM pode implementar sua própria versão de Garbage Collection. Porém, essa versão deve atender aos padrões da especificação da JVM de trabalhar com objetos presentes na memória heap, marcar ou identificar os objetos inalcançáveis, e destruí-los com compactação.

O que são as roots de Garbage Collection em Java?

Coletores de lixo funcionam sob o conceito de Garbage Collection Roots (GC Roots) para identificar objetos vivos e mortos.

Exemplos dessas roots de Garbage Collection são:

  • Classes executadas pelo executor de classes do sistema (com exceção dos executores de classes customizadas)
  • Threads ativas
  • Variáveis locais e parâmetros dos métodos em execução
  • Variáveis locais e parâmetros de métodos JNI (Java Native Interface)
  • Referências globais de JNI
  • Objetos utilizados como monitores para sincronização
  • Objetos mantidos da coleta de lixo pela JVM para seus fins

O coletor de lixo percorre todo o grafo de objetos na memória, começando pelas roots de Garbage Collection e referências posteriores às roots de outros objetos.

image-76

Etapas de coleta de lixo em Java

Uma implementação padrão de coletor de lixo envolve três etapas:

Marcar objetos como vivos

Nessa etapa, o GC identifica todos os objetos vivos na memória ao percorrer o grafo de objetos.

Quando o GC "visita" um objeto, o marca como acessível – logo, ele está vivo. Todo objeto que o GC visita é marcado como vivo. Todos os objetos que não podem ser visitados pelas roots do GC são lixo e considerados como candidatos para coleta.

image-82

Se livrar de objetos mortos

Após a fase de marcação, temos um espaço na memória que é ocupado tanto por objetos vivos (visited), quanto por objetos mortos (unvisited). A fase de "livração" libera fragmentos de memória que contêm objetos mortos.

image-83

Compactar os objetos restantes na memória

Os objetos mortos que são removidos durante a fase de liberação não precisam necessariamente estar um ao lado do outro. Portanto, você pode acabar tendo espaços de memória fragmentados.

A memória, então, pode ser compactada depois que o coletor de lixo excluir os objetos mortos, para que os objetos remanescentes estejam em um bloco contíguo no começo da memória heap.

O processo de compactação faz com que seja mais fácil alocar memória para novos objetos de forma sequencial.

image-85

O que é a coleta de lixo geracional em Java?

Os coletores de lixo em Java implementam uma estratégia chamada coleta de lixo geracional, que categoriza objetos por idade.

Ter que marcar e compactar todos os objetos em uma JVM é ineficiente. Quanto mais e mais objetos são alocados, a lista de objetos cresce, levando a um tempo de coleta mais longo. A análise empírica de aplicações têm mostrado que a maioria dos objetos em Java têm vida curta.

ObjectLifetime
Fonte: oracle.com

No exemplo acima, o eixo Y representa o número de bytes alocados. O eixo X representa o tempo. Como você pode ver, menos e menos objetos permanecem alocados com o passar do tempo.

Na verdade, a maioria dos objetos têm uma vida muito curta, como foi demonstrado pelos altos valores no lado esquerdo do gráfico. É por isso que o Java categoriza e realiza coleta de lixo de acordo com as gerações de objetos.

A área da memória heap na JVM é dividida em três seções:

image-70

Geração jovem

Objetos recentemente criados começam na geração jovem (Young Generation). Esta geração é subdividida em:

  • Espaço Éden (EdenSpace) – todos os novos objetos começam aqui, e a memória inicial é alocada neles.
  • Espaço de sobreviventes (FromSpace e ToSpace) – objetos são movidos daqui para o Éden após um ciclo de coleta.

Quando objetos são coletados a partir da Geração Jovem, é um evento menor de coleta de lixo.

Quando o Espaço Éden é preenchido por objetos, um evento menor de Garbage Collection é realizado. Todos os objetos mortos são excluídos e todos os objetos vivos são movidos para um dos Espaços de sobreviventes.  Coletas de lixo menores também verificam os objetos em um espaço de sobreviventes e os movem para outro espaço.

Tenha a seguinte sequência como exemplo:

  1. O Éden tem todos os objetos (vivos e mortos)
  2. Uma Coleta de Lixo menor ocorre – todos os objetos mortos são removidos do Éden. Todos os objetos vivos são movidos para o S1 (FromSpace). O Éden e o S2 agora estão vazios.
  3. Objetos são criados e adicionados ao Éden. Alguns objetos neste espaço e no S1 se tornam mortos.
  4. Outro evento menor de GC ocorre – todos os objetos mortos são removidos do Éden e do S1. Todos os objetos vivos são movidos para o S2 (ToSpace). Eden e S1 agora estão vazios.

Portanto, em qualquer momento, um dos espaços de sobreviventes está vazio. Quando os objetos sobreviventes alcançam uma certa "experiência" em serem movidos entre os espaços de sobreviventes diversas vezes, eles são transferidos à Geração Velha.

Você pode usar a flag `-Xmn` para definir o tamanho da Geração Jovem.

Geração velha

Objetos que são mais velhos são eventualmente movidos da Geração Jovem para a Geração Velha (Old Generation). Esta geração também é referenciada como Tenured Generation, e contêm objetos que continuaram nos espaços de sobreviventes por um longo tempo.

Há um limite definido para a promoção de um objeto que decide quantos ciclos de coleta de lixo ele pode sobreviver antes de ser movido para a Geração Antiga.

Quando objetos são coletados da Geração Antiga, chamamos de evento maior de coleta de lixo.

Você pode usar as flags -Xms e -Xmx para determinar o tamanho inicial e máximo da memória heap.

Já que o Java utiliza coleta de lixo geracional, quanto mais eventos de coleta um objeto sobrevive, mais ele é promovido na heap. Ele começa na Geração Jovem e eventualmente termina na Tenured Generation se conseguir sobreviver por tempo suficiente.

Considere o seguinte exemplo para entender a promoção de objetos entre espaços e gerações:

Quando um objeto é criado, ele primeiro fica alocado no Espaço Éden da Geração Jovem. Quando um evento menor de coleta de lixo ocorre, os objetos vivos são promovidos ao FromSpace. Quando um novo evento menor ocorre, tanto os objetos vivos do Éden e do FromSpace são movidos para o ToSpace.

Este ciclo continua por um número específico de vezes. Se o objeto continuar sendo referenciado até esse ponto, o próximo ciclo de coleta de  lixo vai movê-lo para o espaço da Geração Antiga.

Geração permanente

Metadados de classes e métodos são alocados na Geração Permanente (Permanent Generation). Ela é utilizada pela JVM durante o tempo de execução com base nas classes em uso pela aplicação. Classes que não estão mais em uso nesta Geração podem ser coletadas pelo Garbage Collector.

Você pode usar as flags -XX:PermGen e -XX:MaxPermGen para determinar o tamanho inicial e máximo da Permanent Generation.

MetaSpace

A partir do Java 8, o espaço de memória MetaSpace substituiu o espaço PermGen. A implementação difere da PermGen, e este espaço da Heap agora é redimensionado automaticamente.

Isso evita o problema de aplicações ficarem sem memória devido ao tamanho limite do espaço PermGen da memória Heap. A memória MetaSpace pode ser coletada e as classes que não estão mais em uso podem ser automaticamente coletadas quando o MetaSpace alcança seu tamanho limite.

Tipos de Garbage Collector na Máquina Virtual do Java

A coleta de lixo faz com que o Java seja uma linguagem eficiente no quesito de uso de memória porque remove os objetos sem referência da memória heap e libera espaço para novos objetos.

A Máquina Virtual do Java tem oito tipos de coletores de lixo. Vamos conhecer cada um deles com mais detalhe.

GC serial

Esta é a implementação mais simples do Garbage Collector e foi feita para aplicações pequenas que estão sendo executadas em ambientes single-threaded. Todos os eventos de coleta de lixo são conduzidos em série em uma thread. A compactação da memória é executada após cada coleta.

image-68

Quando a aplicação é executada, ocorre um evento "stop the world" (pare o mundo) onde toda a aplicação é pausada. Já que a aplicação inteira é congelada durante a coleta de lixo, o GC serial não é recomendado em aplicações de larga escala, onde a latência baixa é uma necessidade.

O argumento da JVM para utilizar o Garbage Collector serial é -XX:+UeSerialGC.

GC paralelo

O coletor paralelo é feito para aplicações com conjuntos de dados de médio a largo porte que rodam em multiprocessadores ou hardware multi-threaded. Essa é a implementação padrão do Garbage Collector da JVM e também é conhecida como Throughput Collector.

Múltiplas threads são utilizadas para um evento menor de coleta de lixo na Geração Jovem. Uma única thread é utilizada para eventos maiores de coleta na Geração Velha.

image-66

Executar o GC paralelo também ocasiona o evento "stop the world" e a aplicação é pausada. Já que esta implementação é mais apropriada em um ambiente multi-threaded, ela pode ser usada quando muitas atividades são feitas e pausas longas são aceitáveis, como, por exemplo, executar uma tarefa em lote.

O argumento na JVM para executar o GC paralelo é -XX:+UseParallelGC.

GC paralelo antigo

Essa é a versão padrão do GC paralelo desde o Java 7u4. É a mesma implementação do GC paralelo, exceto pelo fato de utilizar threads múltiplas tanto para a Geração Jovem quanto para a Velha.

O argumento na JVM para executar o Garbage Collector paralelo antigo é -XX:+UseParallelOldGC.

GC CMS (Concurrent Mark Sweep)

Essa implementação também é conhecida como o coletor de pausa baixa simultânea. Múltiplas threads são usadas como coletas de lixo menores usando o mesmo algoritmo do paralelo. Coletas de lixo maiores são multi-threaded, como no GC paralelo antigo, mas o CSM roda de forma simultânea aos processos da aplicação para minimizar os eventos "stop the world".

image-67

Por causa disso, o coletor CSM usa mais CPU do que as outras implementações de GC. Se você pode alocar mais CPU em prol de melhor desempenho, o CMS é uma escolha melhor que o paralelo. Não há compactação nesta implementação.

O argumento na JVM para usar o Garbage Collector de Concurrent Mark Sweep (em português, algo como "varredura de marca concorrente") é -XX:+UseConcMarkSweepGC.

GC G1 (Garbage First)

O G1GC tinha a intenção de substituir o CMS e foi criado para aplicações multi-threaded que possuem um tamanho grande de memória heap disponível (acima de 4GB). É implementado de modo paralelo e simultânea, como o CMS, mas funciona de maneira um pouco diferente debaixo dos panos em comparação com os garbage collectors anteriores.

Apesar do G1 também ser geracional, ele não separa espaços para as gerações Jovem e Velha. Na verdade, cada geração é um conjunto de espaços, o que permite alterar o tamanho da Geração Jovem de modo flexível.

Ele particiona a heap em um conjunto de espaços de tamanhos iguais (1MB a 32MB - dependendo do tamanho da heap) e utiliza múltiplas threads para as escanear. Uma região pode ser Velha ou Jovem em qualquer período de tempo durante a execução do programa.

Após o período de marcação ser completo, o G1 sabe quais espaços contêm mais objetos que precisam ser coletados. Se o usuário está interessado em tempos de pausa mínimos, o G1 pode escolher evacuar apenas alguns espaços. Se o usuário não se preocupa com isso ou definiu um tempo de pausa grande, o G1 pode escolher incluir mais espaços.

Já que o G1CC identifica os espaços com a maior quantidade de lixo e realiza a coleta naqueles espaços primeiro, ele é chamado de Garbage First (lixo primeiro).

image-88

Além dos espaços de memória Eden, Sobreviventes e Velho, existem mais dois tipos de espaços presentes no G1CC:

  • Humongous (Enorme) – usado para objetos grandes (maiores que 50% do tamanho da heap)
  • Available (Disponíveis) – usado para espaços não utilizados ou não alocados.

O argumento na JVM para usar o Garbage Collector G1 é -XX:+UseG1GC .

Garbage Collector Epsilon

O Epsilon é um coletor de lixo "do-nothing" (que "não faz nada") que foi lançado como parte da JDK 11. Ele gerencia a alocação de memória, mas não implementa nenhum mecanismo de aproveitamento de memória. Uma vez que a memória heap disponível está cheia, a JVM para de funcionar.

Ele pode ser usado para aplicações que são sensíveis a latências extremas, onde os desenvolvedores sabem o footprint exato da memória da aplicação, ou até mesmo possuem aplicações que são (quase) garbage-free (livres de lixo). A utilização do GC Epsilon em qualquer outro cenário é desencorajado.

O argumento na JVM para utilizar o Garbage Collector Epsilon é -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC.

Shenandoah

O Shenandoah é um GC novo que foi lançado como parte do JDK 12. A sua vantagem principal sobre o Garbage Collector G1 é que ele realiza mais ciclos de coleta de lixo de maneira simultânea às threads da aplicação. O G1 pode evacuar seus espaços na heap apenas quando a aplicação é pausada, enquanto o Shenandoah pode realocar objetos simultaneamente com a aplicação.

Shenandoah pode compactar objetos vivos, coletar o lixo e mandar RAM de volta ao sistema operacional quase imediatamente após detectar memória livre. Já que tudo isso ocorre simultaneamente enquanto a aplicação roda, o Shenandoah é mais intensivo no quesito do uso de CPU.

O argumento na JVM para utilizar o Garbage Collector Shenandoah é -XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC.

ZGC

O ZGC é outro Garbage Collector que foi lançado como parte da JDK 11 e foi melhorado na JDK 12. Ele foi criado para aplicações que requerem baixa latência (pausas de menos de 10ms) e/ou que utilizam um grande espaço da memória heap (multi-terabytes).

Os principais objetivos do ZGC são a baixa latência, escalabilidade e facilidade de uso. Para alcançar isso, o ZGC permite que uma aplicação em Java continue rodando enquanto ele executa todas as operações de Garbage Collection. Por padrão, o ZGC libera a memória não utilizada e a retorna ao sistema operacional.

Logo, o ZGC traz uma melhora significativa sobre outros Garbage Collectors tradicionais ao prover tempos de pausa extremamente baixos (tipicamente dentro de 2ms).

figure2_600w
Fonte: oracle.com

O argumento na JVM para utilizar o Garbage Collector Shenandoah é -XX:+UnlockExperimentalVMOptions -XX:+UseZGC.

Observação: tanto o Shanandoah quanto o ZGC estão sob planos de passarem a ser features de produção e serem removidos do estágio de experimentação na JDK 15.

Como escolher o Garbage Collector certo?

Se a sua aplicação não tem tempos de pausa estritos, você deve somente rodar a sua aplicação e deixar a JVM escolher o coletor certo para você.

Na maior parte do tempo, as configurações padrão devem funcionar bem. Se necessário, você pode ajustar o tamanho da heap para melhorar o desempenho. Se o desempenho ainda não alcançar suas expectativas, você pode alterar o coletor de lixo padrão de acordo com as necessidades do seu projeto:

  • Serial – se a aplicação tem um conjunto de dados pequeno (aproximadamente 100MB) e/ou vai rodar em um único processador sem ter requisitos de tempo de pausa.
  • Paralelo – se o melhor desempenho da aplicação for a prioridade e não houver requisitos para tempos de pausa ou pausas de um segundo ou mais serão aceitáveis.
  • CMS/G1 – se o tempo de resposta é mais importante que a taxa de processamento e as pausas de coleta de lixo devem ser mantidas abaixo de aproximadamente um segundo.
  • ZGC – se o tempo de resposta for a prioridade maior, e/ou você está usando grande parte da heap.

Vantagens do Garbage Collection

Existem múltiplos benefícios da coleta de lixo em Java.

Primeiramente, ela deixa o seu código mais simples. Você não precisa se preocupar com a alocação correta de memória e ciclos de liberação. Você para de usar um objeto no seu código e a memória que ele utiliza vai ser automaticamente liberada em algum momento.

Programadores que trabalham em linguagens sem Garbage Collection (como C e C++) devem implementar gerenciamento de memória manual em seus códigos.

Este processo também faz com que o Java seja eficiente em termos de memória, pois o coletor de lixo remove os objetos não referenciados da memória heap. Isso libera espaço na heap para acomodar novos objetos.

Enquanto alguns programadores argumentam em favor do gerenciamento de memória manual ao invés de Garbage Collection, o GC é agora um componente padrão em diversas linguagens de programação populares.

Para cenários onde o Garbage Collector impacta negativamente o desempenho, o Java oferece diversas opções para melhorar sua eficiência.

Melhores práticas de Garbage Collection

Evite disparos manuais

Além dos mecanismos básicos de coleta de lixo, um dos pontos mais importantes para entender a respeito de Garbage Collection no Java é que ele não é determinístico. Isso significa que não tem como prever quando a coleta de lixo vai ocorrer durante o tempo de execução.

É possível incluir uma sugestão à JVM no seu código para que ela inicie o coletor de lixo com os métodos System.gc() ou Runtime.gc(), mas isso não dá garantia de que o coletor realmente vai ser executado.

Utilize ferramentas de análise

Se você não tem memória suficiente para rodar sua aplicação, você vai lidar com problemas de lentidão, um longo período de coleta de lixo, eventos "stop the world" e, por fim, erros do tipo out of memory. Isso pode indicar que o tamanho da sua heap é muito pequeno, mas também que você pode ter um vazamento de memória na sua aplicação.

Você pode ter o auxílio de uma ferramenta de monitoramento como o jstat ou Java Flight Recorder para analisar se o uso da memória heap cresce indefinidamente, o que pode indicar um bug no seu código.

Configurações padrão são suas amigas

Se você estiver executando uma aplicação Java pequena e independente, você muito provavelmente não precisa alterar as configurações do Garbage Collector. As configurações padrão devem dar conta do trabalho.

Utilize flags na JVM para ajustes

A melhor forma de ajustar a Garbage Collection de acordo com as suas necessidades é utilizando flags na JVM. Flags podem configurar o GC que vai ser utilizado (Serial, G1 ou outros), o tamanho inicial e máximo da memória heap, o tamanho dos espaços da heap (Geração Jovem, Velha) e muito mais.

Escolha o coletor apropriado

A natureza da aplicação que está passando por ajustes é um guia inicial muito eficiente para as configurações do coletor. Por exemplo, o GC paralelo é eficiente, mas vai causar eventos "stop the world" frequentes, fazendo com que ele seja mais apropriado para processamento em back-end, onde as longas pausas para coleta de lixo são aceitáveis.

Por outro lado, o Garbage Collector CMS foi criado para minimizar pausas, sendo ideal para aplicações baseadas na Web, onde a responsividade é importante.

Conclusão

Neste artigo, discutimos Garbage Collection em Java, como funciona e seus diferentes tipos.

Para muitas aplicações simples, Garbage Collection não é algo que um programador Java precise considerar. Contudo, para programadores que querem melhorar suas habilidades em Java, é importante entender como esse processo funciona.

Este conceito também é muito popular em entrevistas técnicas, tanto para cargos de back-end júnior, quanto sênior.

Agradecemos por sua companhia até aqui. Esperamos que tenha gostado do artigo. Você pode se conectar com o autor pelo LinkedIn, onde ele fala regularmente sobre assuntos como tecnologia e a vida. Dê uma olhada também em outros artigos do autor (em inglês) e no canal do autor no YouTube. Boa leitura. 🙂️