Uma introdução ao ZGC: um coletor de lixo JVM de baixa latência experimental e escalável

Uma introdução ao ZGC: um coletor de lixo JVM de baixa latência experimental e escalável

1. Introdução

Hoje, não é incomum que os aplicativos atendam a milhares ou até milhões de usuários simultaneamente. Tais aplicativos precisam de enormes quantidades de memória. No entanto, gerenciar toda essa memória pode afetar facilmente o desempenho do aplicativo.

Para solucionar esse problema, o Java 11 apresentou o Z Garbage Collector (ZGC) como um coletor de lixo experimental ( GC).

Neste tutorial, veremos como o ZGC consegue manter baixos tempos de pausa mesmo em pilhas com vários terabytes .

2. Conceitos principais

Para entender como o ZGC funciona, precisamos entender os conceitos básicos e a terminologia por trás de https://www..com/java-memory-management-interview-questions [gerenciamento de memória] e https://www..com/jvm- coletores de lixo [coletores de lixo].

2.1. Gerenciamento de memória

Memória física é a RAM que nosso hardware fornece.

O sistema operacional (SO) aloca espaço de memória virtual para cada aplicativo.

Obviamente, armazenamos memória virtual na memória física e o sistema operacional é responsável por manter o mapeamento entre os dois. Esse mapeamento geralmente envolve aceleração de hardware.

2.2. Multi-Mapeamento

Multi-mapeamento significa que existem endereços específicos na memória virtual, que apontam para o mesmo endereço na memória física. Como os aplicativos acessam dados através da memória virtual, eles não sabem nada sobre esse mecanismo (e não precisam).

Efetivamente, mapeamos vários intervalos da memória virtual para o mesmo intervalo na memória física:

À primeira vista, seus casos de uso não são óbvios, mas veremos mais adiante que o ZGC precisa que ele faça sua mágica. Além disso, fornece alguma segurança porque separa os espaços de memória dos aplicativos.

2.3. Realocação

Como usamos a alocação dinâmica de memória, a memória de um aplicativo médio fica fragmentada ao longo do tempo. É porque quando liberamos um objeto no meio da memória, permanece uma lacuna de espaço livre. Com o tempo, essas lacunas se acumulam e nossa memória parecerá um tabuleiro de xadrez feito de áreas alternadas de espaço livre e usado.

Obviamente, poderíamos tentar preencher essas lacunas com novos objetos. Para fazer isso, devemos procurar na memória espaço livre que seja grande o suficiente para armazenar nosso objeto. Fazer isso é uma operação cara, especialmente se precisarmos fazer isso toda vez que quisermos alocar memória. Além disso, a memória ainda estará fragmentada, pois provavelmente não conseguiremos encontrar um espaço livre com o tamanho exato de que precisamos. Portanto, haverá lacunas entre os objetos. Obviamente, essas lacunas são menores. Além disso, podemos tentar minimizar essas lacunas, mas ele usa ainda mais poder de processamento.

A outra estratégia é realocar com freqüência objetos de áreas de memória fragmentada para liberar áreas em um formato mais compacto . Para ser mais eficaz, dividimos o espaço da memória em blocos. Realocamos todos os objetos em um bloco ou nenhum deles. Dessa forma, a alocação de memória será mais rápida, pois sabemos que existem blocos vazios inteiros na memória.

2.4. Coleta de lixo

Quando criamos um aplicativo Java, não precisamos liberar a memória que alocamos, porque os coletores de lixo fazem isso por nós. Em resumo, o GC observa quais objetos podemos acessar de nosso aplicativo através de uma cadeia de referências e libera aqueles que não podemos acessar .

Um GC precisa rastrear o estado dos objetos no espaço de heap para executar seu trabalho. Por exemplo, um estado possível é alcançável. Isso significa que o aplicativo mantém uma referência ao objeto. Essa referência pode ser transitiva. A única coisa que importa é que o aplicativo possa acessar esses objetos por meio de referências. Outro exemplo é finalizável: objetos que não podemos acessar. Esses são os objetos que consideramos lixo.

Para isso, os coletores de lixo têm várias fases.

2.5. Propriedades da fase do GC

As fases do GC podem ter propriedades diferentes:

*uma fase* paralela * pode ser executada em vários encadeamentos do GC
*uma fase* serial * é executada em um único encadeamento
*uma fase* stop-the-world * não pode ser executada simultaneamente com o código do aplicativo
*uma fase* simultânea * pode ser executada em segundo plano, enquanto nosso aplicativo faz seu trabalho
*uma fase* incremental *pode terminar antes de terminar todo o seu trabalho e continuar mais tarde

Observe que todas as técnicas acima têm seus pontos fortes e fracos. Por exemplo, digamos que temos uma fase que pode ser executada simultaneamente com nosso aplicativo. Uma implementação serial dessa fase requer 1% do desempenho geral da CPU e é executada por 1000ms. Por outro lado, uma implementação paralela utiliza 30% da CPU e conclui seu trabalho em 50ms.

Neste exemplo, a solução* paralela usa mais CPU em geral, porque pode ser mais complexa e ter que sincronizar os threads *. Para aplicativos pesados ​​da CPU (por exemplo, tarefas em lote), é um problema, pois temos menos poder de computação para realizar trabalhos úteis.

Obviamente, este exemplo possui números inventados. No entanto, é claro que todos os aplicativos têm suas características, portanto, eles têm diferentes requisitos de GC.

Para descrições mais detalhadas, visite https://www..com/java-memory-management-interview-questions [nosso artigo sobre gerenciamento de memória Java].

3. Conceitos do ZGC

O ZGC pretende fornecer as fases de parada do mundo o mais breve possível. Ele é alcançado de tal maneira que a duração desses tempos de pausa não aumenta com o tamanho da pilha. Essas características tornam o ZGC uma boa opção para aplicativos de servidor, onde grandes montões são comuns e são necessários tempos de resposta rápidos aos aplicativos.

Além das técnicas de GC testadas e testadas, o ZGC apresenta novos conceitos, que serão abordados nas próximas seções.

Mas, por enquanto, vamos dar uma olhada na imagem geral de como o ZGC funciona.

3.1. Imagem Grande

O ZGC possui uma fase chamada marcação, onde encontramos os objetos alcançáveis. Um GC pode armazenar informações de estado do objeto de várias maneiras. Por exemplo, poderíamos criar um _Map, _ onde as chaves são endereços de memória e o valor é o estado do objeto nesse endereço. É simples, mas precisa de memória adicional para armazenar essas informações. Além disso, manter esse mapa pode ser desafiador.

*O ZGC usa uma abordagem diferente: armazena o estado de referência como os bits da referência.* É chamado de cor de referência. Mas assim temos um novo desafio. Definir bits de uma referência para armazenar metadados sobre um objeto significa que várias referências podem apontar para o mesmo objeto, já que os bits de estado não contêm nenhuma informação sobre a localização do objeto. Multimapping para o resgate!

Também queremos diminuir a fragmentação da memória. O ZGC usa a realocação para conseguir isso. Mas, com um monte grande, a realocação é um processo lento. Como o ZGC não deseja longos períodos de pausa, faz a maior parte da mudança em paralelo com o aplicativo. Mas isso apresenta um novo problema.

Digamos que temos uma referência a um objeto. O ZGC a realoca e ocorre uma troca de contexto, onde o encadeamento do aplicativo é executado e tenta acessar esse objeto por meio do endereço antigo. O ZGC usa barreiras de carga para resolver isso. Uma barreira de carga é um pedaço de código que é executado quando um thread carrega uma referência do heap - por exemplo, quando acessamos um campo não primitivo de um objeto.

No ZGC, as barreiras de carga verificam os bits de metadados da referência. Dependendo desses bits, o ZGC pode executar algum processamento na referência antes de obtê-lo. Portanto, pode produzir uma referência totalmente diferente. Chamamos isso de remapeamento.

3.2. Marcação

O ZGC quebra a marcação em três fases.

A primeira fase é uma fase de parar o mundo. Nesta fase, procuramos referências de raiz e as marcamos. Referências raiz são os pontos de partida para alcançar objetos na pilha , por exemplo, variáveis ​​locais ou campos estáticos. Como o número de referências raiz geralmente é pequeno, essa fase é curta.

A próxima fase é simultânea. Nesta fase, percorremos o gráfico do objeto, começando pelas referências raiz. Marcamos todos os objetos que alcançamos. Além disso, quando uma barreira de carga detecta uma referência não marcada, ela também a marca.

A última fase também é uma fase de parar o mundo para lidar com alguns casos extremos, como referências fracas.

Neste ponto, sabemos quais objetos podemos alcançar.

O ZGC usa os bits de metadados marked0 e marked1 para marcar.

3.3. Coloração de referência

Uma referência representa a posição de um byte na memória virtual. No entanto, não precisamos necessariamente usar todos os bits de uma referência para fazer isso - alguns bits podem representar propriedades da referência . É o que chamamos de cor de referência.

Com 32 bits, podemos endereçar 4 gigabytes. Como hoje em dia é comum que um computador tenha mais memória que isso, obviamente não podemos usar nenhum desses 32 bits para colorir. Portanto, o ZGC usa referências de 64 bits. Significa O ZGC está disponível apenas em plataformas de 64 bits:

As referências ZGC usam 42 bits para representar o próprio endereço. Como resultado, as referências ZGC podem endereçar 4 terabytes de espaço em memória.

Além disso, temos 4 bits para armazenar estados de referência:

  • finalizable bit - o objeto só é acessível através de um finalizador

  • remap bit - a referência está atualizada e aponta para a localização atual do objeto (consulte a realocação)

  • marked0 e marked1 bits - são usados ​​para marcar objetos alcançáveis

Também chamamos esses bits de metadados. No ZGC, precisamente um desses bits de metadados é 1.

3.4. Realocação

No ZGC, a realocação consiste nas seguintes fases:

  1. Uma fase simultânea, que procura por blocos, queremos realocar e os coloca no conjunto de realocação.

  2. Uma fase de parar o mundo realoca todas as referências de raiz no conjunto de realocação e atualiza suas referências.

  3. Uma fase simultânea realoca todos os objetos restantes no conjunto de realocação e armazena o mapeamento entre os endereços antigos e novos na tabela de encaminhamento.

  4. A reescrita das referências restantes ocorre na próxima fase de marcação. Dessa forma, não precisamos percorrer a árvore de objetos duas vezes. Como alternativa, as barreiras de carga também podem fazê-lo.

3.5. Remapeamento e Barreiras de Carga

Observe que, na fase de realocação, não reescrevemos a maioria das referências aos endereços realocados. Portanto, usando essas referências, não acessaríamos os objetos que desejávamos. Pior ainda, podíamos acessar o lixo.

O ZGC usa barreiras de carga para resolver esse problema. Barreiras de carga corrigem as referências que apontam para objetos realocados com uma técnica chamada remapeamento.

Quando o aplicativo carrega uma referência, ele dispara a barreira de carga, que segue as seguintes etapas para retornar a referência correta:

  1. Verifica se o remap bit está definido como 1. Nesse caso, significa que a referência está atualizada, portanto podemos devolvê-la com segurança.

  2. Em seguida, verificamos se o objeto referenciado estava no conjunto de realocação ou não. Caso contrário, significa que não queremos realocá-lo. Para evitar essa verificação na próxima vez que carregarmos essa referência, definimos o remap bit como 1 e retornamos a referência atualizada.

  3. Agora sabemos que o objeto que queremos acessar era o alvo da realocação. A única questão é se a mudança ocorreu ou não? Se o objeto foi realocado, pularemos para a próxima etapa. Caso contrário, a realocamos agora e criamos uma entrada na tabela de encaminhamento, que armazena o novo endereço para cada objeto realocado. Depois disso, continuamos com o próximo passo.

  4. Agora sabemos que o objeto foi realocado. Ou pelo ZGC, nós na etapa anterior, ou pela barreira de carga durante uma ocorrência anterior desse objeto. Atualizamos esta referência para o novo local do objeto (com o endereço da etapa anterior ou pesquisando na tabela de encaminhamento), configuramos o bit remap e retornamos a referência.

E é isso, com as etapas acima, garantimos que cada vez que tentamos acessar um objeto, obtemos a referência mais recente a ele. Como toda vez que carregamos uma referência, ela dispara a barreira de carga. Portanto, diminui o desempenho do aplicativo. Especialmente na primeira vez que acessamos um objeto realocado. Mas este é um preço que devemos pagar se quisermos tempos de pausa curtos. E como essas etapas são relativamente rápidas, elas não afetam significativamente o desempenho do aplicativo.

4. Como habilitar o ZGC?

Podemos ativar o ZGC com as seguintes opções de linha de comando ao executar nosso aplicativo:

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

Observe que, como o ZGC é um GC experimental, levará algum tempo para se tornar oficialmente suportado.

5. Conclusão

Neste artigo, vimos que o ZGC pretende oferecer suporte a tamanhos grandes de heap com baixo tempo de pausa no aplicativo.

Para atingir esse objetivo, ele usa técnicas, incluindo referências coloridas de 64 bits, barreiras de carga, realocação e remapeamento.