Java Garbage Collector — Por que precisamos conhecê-lo?

Guilherme Biff Zarelli
luizalabs
9 min readFeb 20, 2022

--

O objetivo deste artigo é descrever como a JVM lida com o gerenciamento de memória das nossas aplicações, quais são os tipos de Garbage Collectors atualmente existentes e como escolher o melhor tipo para determinada situação.

O Garbage Collector (GC) é uma forma de realizar o gerenciamento automático de memória. Podemos dizer, que é um processo capaz de localizar objetos utilizados ou não na memória heap, remover os objetos não utilizados e compactá-los após a liberação de espaço. Muitas implementações de um Garbage Collector, possuem as seguintes operações básicas descritas a seguir:

  • Alocação de memória do objeto;
  • Buscar objetos em uso (Mark);
  • Liberar objetos que não estão em uso (Sweep);
  • Prevenção de fragmentação (Compact).

Em Java os objetos são criados quando necessário, e quando não estão mais em uso, a JVM através do GC remove automaticamente os objetos e libera memória— bem diferente das linguagens de programação C ou C++ (exceto se for utilizado reference counting) onde é necessário gerenciar esse processo programaticamente.

Gerenciamento de memória

Não há como falarmos sobre GC sem antes introduzirmos um pouco da arquitetura de gerenciamento de memória da JVM e o que o Garbage Collector tem a ver com isso. A imagem abaixo ilustra bem essa arquitetura, nela é possível observer as diferenças existentes entre a JVM Process pré Java 8 e a JVM Process Java 8, nesse artigo daremos foco apenas para versões do Java 8+

Imagem do livro: Hands-On High Performance with Spring 5

O Garbage Collector do Java trabalha em cima da memória heap, na qual é dividida logicamente em dois grandes espaços para algumas das principais implementações de GC:

A Young Generation composta por dois blocos, o Eden e Survivor (S0 e S1) e a Old Generation (Também chamada de Tenured).

Imagem do livro: Hands-On High Performance with Spring 5

Young Generation

Dividida entre Eden e Survivor, o Eden é o local onde novos objetos são alocados ao serem construídos (new). Quando o espaço Eden for preenchido, um evento do GC (ex: Minor GC) poderá ser acionado para um ciclo de coleta, objetos que não são mais utilizados serão removidos e os que ainda estão em uso, serão movidos para o Survivor ou para a Old Gen, dependendo de seu ciclo de coleta (tenuring threshold — default 15).

Old Generation

A Old Generation é onde os objetos sobreviventes aos ciclos de coleta da Young Gen serão armazenados. Um evento do GC, como o Full GC será acionado, quando o espaço se encher ou um Concurrent GC (se aplicável) será agendado quando o heap começar a se encher.

Fragmentação

Para finalizar a sessão de gerenciamento de memória, o GC também fica responsável por lidar com o problema de fragmentação que normalmente ocorre após a operação de Mark and Sweep.

A desfragmentação dos espaços vazios ocorre na operação de compactação (Compact), que pode ser executada após as operações Mark and Sweep ou em algumas implementações de GC pode ser feita uma passagem separada, assim, a eficiência do espaço de memória para alocação pode ser aumentada.

Compactação de memória | Fonte: Plumbr — Java Garbage Collection Handbook

Principais Implementações

A JVM possui várias implementações de Garbage Collector: Serial, Parallel, G1, Z etc.

Cada coletor é otimizado para uma situação, e não necessariamente um coletor seja melhor que o outro, mas dependendo do objetivo do seu software é possível escolher o mais adequado.

Coletor vs Otimização | Fonte OpenJDK

Nesta seção iremos entender suas principais características, qual é usada por default nas distintas versões do Java, e como alterná-las se necessário.

Serial GC (Memory Footprint)

O Serial Collector utiliza uma única thread para executar todo o trabalho do Garbage Collector. Como não há uma sobrecarga de comunicação entre as threads (em relação a um Parallel Collector) seu mecanismo é muito eficiente, porém, com uma pausa maior de execução.

O Serial Collector não consegue tirar proveito de hardwares multiprocessados, neste caso, seu uso é adequado em equipamentos com apenas um core de processamento, ou em softwares com pequenos conjuntos de dados (até aproximadamente 100MB, segundo a documentação da Oracle).

Representação de execução do Serial GC nas threads de um Software

Nota: O termo Stop-The-World (STW) apresentado na imagem é usado para representar uma das causas da execução de um evento do GC, no qual, ele pausa todas as threads da aplicação para completar sua ação com eficiência.

O Serial GC é otimizado para baixo consumo de memória (Memory footprint) e pequenos tamanhos de heap, ele está disponível em todas as versões do Java e é selecionado por padrão apenas em determinadas configurações de hardware e sistemas operacionais, ou pode ser habilitado explicitamente com o argumento: -XX:+UseSerialGC

Parallel GC (Throughput)

Podemos dizer que o Parallel GC (também conhecido como Throughput Collector) é similar ao Serial GC com a diferença de que o Parallel utiliza várias threads para realizar suas operações, assim, em ambientes com vários núcleos de processamento o tempo de pausa em relação ao Serial será menor. Porém, o Parallel GC não funcionará tão bem quanto o Serial em ambientes que possuírem apenas um núcleo de processamento devido ao overhead causado pela sincronização das threads.

Comparação de execução do Serial GC e Parallel GC

Tendo como principal objetivo aumentar o Throughput da aplicação, o número de threads utilizadas no Parallel GC depende da quantidade disponíveis de hardware threads (quantidade física de CPUs) da máquina, basicamente para hardwares com 8 ou mais CPUs utiliza-se por default uma fração de 5/8, já em hardwares com quantidade inferior a 8 CPUs, é utilizado a própria quantidade como valor. Caso queira alterar esse valor podemos utilizar o seguinte argumento: -XX:ParallelGCThreads=<N>

O Parallel GC é a melhor escolha para uma aplicação na qual o throughput é mais importante que a latência, ele foi definido como default no Java 8 e substituído pelo G1 GC no Java 9. Caso queira habilitá-lo em outras versões utilize o argumento: -XX:+UseParallelGC

G1 GC (Throughput / Latency Balance)

O objetivo do G1 GC é minimizar o tempo de pausa (Latency) do GC e garantir o máximo de rendimento possível (Throughput) sem configuração adicional. Seu modelo fornece uma solução para usuários que executam aplicativos que exigem grandes heaps (não exigido), com uma latência de execução do GC limitada, ou seja, tempo de pausa (Ou as conhecidas STW) estável, configurável e previsível.

O G1 GC possui um conceito diferente de gerenciamento de memória no qual a heap é dividida em regiões de determinados tamanhos que variam de 1MB a 32MB. Por default o tamanho de cada região é baseado no tamanho máximo do heap que é calculado para renderizar aproximadamente 2048 regiões.

Representação da alocação de memória na heap usando o G1 GC | Fonte: Oracle

Sua estrutura permite que o GC evite coletar todo o heap de uma vez só.

A G1 executa a fase de marcação global de forma concorrente para determinar o tempo de vida dos objetos em todo heap. Após a marcação, o G1 sabe quais regiões estão com mais lixo e coleta-las primeiro (por isso o nome: Garbage First). Para melhor explicação das fases de coleta do G1 GC veja no tópico “The G1 Garbage Collector Step by Step” do artigo da Oracle linkado.

O G1 usa um modelo de previsão de pausa para atender a uma meta de tempo definida pelo usuário (Latency Balance) e seleciona o número de regiões a serem coletadas com base na meta de tempo de pausa especificada. Dessa forma o G1 bem configurado tende a evitar coletas completas (Full GC). Podemos configurar o tempo máximo de pausa do G1 GC com o seguinte argumento: -XX:MaxGCPauseMillis=200 (200 é o valor default). Vale salientar que o tempo definido é atendido com uma alta probabilidade, mas não é uma verdade absoluta.

Seu uso é bastante recomendado caso a sua aplicação possua ciclos de Full GC muito frequentes ou com tempos muito altos. Ele também é recomendado caso haja muita variação na taxa de alocação de objetos ou ‘promoção’ também é recomendado. Entretanto se sua aplicação for throughput-bound ou estiver consumindo 100% da CPU e se as durações das pausas do GC não forem um problema, o Parallel GC pode ser uma opção a ser testada.

O G1 GC é a melhor escolha para uma aplicação que necessita de um bom throughput com uma latência ‘controlada’, ele foi definido como default a partir do Java 9 e ainda o permanece até o Java 17, porém no Java 15 foi liberado o ZGC para produção, no qual falaremos no próximo tópico. Para definir o G1 como coletor padrão em outras versões do Java (7 e 8), basta utilizarmos o seguinte argumento:
-XX:+UseG1GC

ZGC (Low Latency)

O Z Garbage Collector, é um coletor de lixo escalável de baixa latência projetado para atender aos seguintes objetivos descritos a seguir:

  • Tempos máximos de pausa de sub-milissegundos (Para JDK <16 o max GC pause-time é na casa de 10ms, no JDK ≥16 fica abaixo de 1ms);
  • Os tempos de pausa não aumentam com o tamanho de heap, live-set ou root-set (Isso para o JDK ≥=16);
  • Lidar com heaps que variam de 8 MB a 16 TB de tamanho;
  • Redução máxima de throughput de 15%.

O ZGC trabalha de maneira totalmente diferente das implementações anteriores, ele é um coletor que trabalha apenas com ‘uma geração (generation)’, ou seja, não possui a divisão de Young e Old Generation. De forma similar ao G1 GC ele possui o conceito de ‘regiões’, denominadas de ZPages, não com divisões por tipo, mas sim por tamanho: Small (2M), Medium (32MB) e Large (N*2MB).

Representação das regiões da heap gerenciadas pelo ZGC | Fonte: Packt

O ZGC trabalha de forma concorrente, ou seja, sua execução é feita junto com as threads do sistema, isso faz com que a coleta de lixo tenha baixo impacto no response time da aplicação (baixa latência).

Representação das fases do ZGC | Fonte: OpenJDK

Diversas funcionalidades fazem o ZGC ser um incrível coletor, uma delas é a possibilidade de gerenciar dinamicamente suas threads (funcionalidade incluída no Java 17). O ZGC também é capaz de devolver memória não utilizada ao sistema operacional, muito útil em sistemas que o consumo de memória da máquina é algo preocupante, importante salientar que a memória nunca será diminuída abaixo do tamanho mínimo do heap configurado (-Xms<size>).

Definir um tamanho máximo de heap é muito importante ao utilizar o ZGC (-Xmx<size>), porque o comportamento do coletor depende da variação da taxa de alocação e de quanto do conjunto de dados está ativo. O ZGC funciona melhor com um heap maior, mas desperdiçar memória desnecessária também é ineficiente, portanto, é necessário ajustar o equilíbrio entre o uso de memória e os recursos disponíveis para coleta de lixo.

O ZGC ainda não é um coletor default, ele foi disponibilizado no Java 11 como experimental e liberado para produção a partir do Java 15, ele é a melhor escolha se sua aplicação for focada em baixa latência, para habilitá-lo, basta utilizar o seguinte argumento: -XX:+UseZGC , para versões anteriores ao Java 15 precisamos adicionar o seguinte argumento: -XX+UnlockExperimentalVMOptions

Conclusão

Para muitos, o conhecimento da JVM e as implementações de Garbage Collector não são fundamentais, muitos pesquisam apenas para ‘tuning’ ou não sabem que existem diferentes implementações, porém, vimos que conhecê-las é fundamental, nenhuma implementação é melhor que a outra, cada uma é para um determinado problema, e conhecê-las nos ajudará a entregar cada vez mais software com qualidade.

Nesse artigo, introduzimos seus principais tipos e características, mas, há muito a se falar sobre Garbage Collector, em um próximo artigo, serão abordados assuntos como monitoramento e logs, e quem sabe até mesmo sobre tuning. Espero que tenham gostado, até o próximo. 👋

Referências

--

--