O mecanismo de coleta de lixo da plataforma Java aumenta muito a produtividade do desenvolvedor, mas um coletor de lixo mal implementado pode consumir recursos de aplicação em excesso. Neste terceiro artigo da série de otimização de performance da JVM, Eva Andreasson oferece aos iniciantes em Java uma visão geral do modelo de memória da plataforma Java e do mecanismo GC. Ela então explica porque a fragmentação (e não GC) é o principal “gotcha!” do desempenho da aplicação Java, e porque a coleta de lixo e compactação geracional são atualmente as principais (embora não as mais inovadoras) abordagens para gerenciar a fragmentação de pilhas de lixo em aplicações Java.
A coleta de lixo (GC) é o processo que visa liberar memória ocupada que não é mais referenciada por nenhum objeto Java alcançável, e é uma parte essencial do sistema de gerenciamento de memória dinâmica da máquina virtual Java (JVM’s). Em um ciclo típico de coleta de lixo, todos os objetos que ainda são referenciados, e portanto alcançáveis, são mantidos. O espaço ocupado pelos objetos previamente referenciados é liberado e recuperado para permitir a alocação de novos objetos.
Para entender a coleta de lixo e as várias abordagens e algoritmos de GC, você deve primeiro saber algumas coisas sobre o modelo de memória da plataforma Java.
- Otimização da performance da JVM: Leia a série
- Recolha de lixo e o modelo de memória da plataforma Java
- Dois tipos de coleta de lixo
- Ler a série de otimização de performance JVM
- Referência de coletores de contagem
- Tracing collectors
- Logítimos de rastreamento de coletores
- Copying collectors
- Downsides de coletores copiadores
- Electores de marcação e varredura
- Mais sobre tamanhos TLAB
- Downsides of mark-and-sweep collectors
- Implementações de mark-and-sweep
- Relectores paralelos
Otimização da performance da JVM: Leia a série
- Parte 1: Visão geral
- Parte 2: Compiladores
- Parte 3: Coleta de lixo
- Parte 4: Compactação simultânea de GC
- Parte 5: Escalabilidade
Recolha de lixo e o modelo de memória da plataforma Java
Quando você especifica a opção de inicialização -Xmx
na linha de comando da sua aplicação Java (por exemplo: java -Xmx:2g MyApp
) a memória é atribuída a um processo Java. Esta memória é chamada de Java heap (ou apenas heap). Este é o espaço dedicado de endereço de memória onde todos os objetos criados pelo seu programa Java (ou às vezes a JVM) serão alocados. Como seu programa Java continua rodando e alocando novos objetos, a pilha Java (significando que o espaço de endereços) irá preencher.
Eventualmente, a pilha Java estará cheia, o que significa que um thread de alocação não consegue encontrar uma seção consecutiva grande o suficiente de memória livre para o objeto que ele deseja alocar. Nesse ponto, a JVM determina que uma coleta de lixo precisa acontecer e notifica o coletor de lixo. Uma coleta de lixo também pode ser acionada quando um programa Java chama System.gc()
. Usar System.gc()
não garante uma coleta de lixo. Antes que qualquer coleta de lixo possa começar, um mecanismo de GC determinará primeiro se é seguro iniciar a coleta de lixo. É seguro iniciar uma coleta de lixo quando todas as threads ativas da aplicação estão em um ponto seguro para permitir isso, por exemplo, simplesmente explicar que seria ruim iniciar a coleta de lixo no meio de uma alocação de objetos em andamento, ou no meio da execução de uma seqüência de instruções otimizadas da CPU (veja meu artigo anterior sobre compiladores), pois você pode perder o contexto e, assim, bagunçar os resultados finais.
Um coletor de lixo nunca deve recuperar um objeto ativamente referenciado; fazer isso quebraria a especificação da máquina virtual Java. Um coletor de lixo também não é obrigado a coletar imediatamente objetos mortos. Os objetos mortos são eventualmente coletados durante os ciclos subseqüentes de coleta de lixo. Embora existam muitas maneiras de implementar a coleta de lixo, estas duas suposições são verdadeiras para todas as variedades. O verdadeiro desafio da coleta de lixo é identificar tudo que está vivo (ainda referenciado) e recuperar qualquer memória não referenciada, mas fazê-lo sem impactar as aplicações em execução mais do que o necessário. Um coletor de lixo assim tem dois mandatos:
- Para rapidamente liberar memória não referenciada a fim de satisfazer a taxa de alocação de uma aplicação para que ela não fique sem memória.
- Para recuperar a memória enquanto minimiza o impacto na performance (por exemplo, latência e rendimento) de uma aplicação em execução.
Dois tipos de coleta de lixo
No primeiro artigo desta série eu toquei nas duas principais abordagens à coleta de lixo, que são a contagem de referência e o rastreamento dos coletores. Desta vez vou detalhar mais em cada abordagem e depois introduzir alguns dos algoritmos usados para implementar coletores de rastreamento em ambientes de produção.
Ler a série de otimização de performance JVM
- Otimização de performance JVM, Parte 1: Visão geral
- Otimização de performance JVM, Parte 2: Compiladores
Referência de coletores de contagem
Referência de coletores de contagem, Parte 2: Compiladores Assim que a contagem de um objeto se torna zero, a memória pode ser imediatamente recuperada. Este acesso imediato à memória recuperada é a maior vantagem da abordagem de contagem de referência para a coleta de lixo. Há muito pouca sobrecarga quando se trata de segurar a memória não referenciada. Manter todas as contagens de referência atualizadas pode ser bastante caro, no entanto.
A principal dificuldade com coletores de contagem de referência é manter as contagens de referência precisas. Outro desafio bem conhecido é a complexidade associada ao manuseio de estruturas circulares. Se dois objetos se referirem um ao outro e nenhum objeto vivo se referir a eles, sua memória nunca será liberada. Ambos os objetos permanecerão para sempre com uma contagem não zerada. A recuperação de memória associada a estruturas circulares requer uma grande análise, o que traz um custo elevado para o algoritmo, e portanto para a aplicação.
Tracing collectors
Tracing collectors are based on the assumption that all live objects can be found by iteratively tracing all references and subsequent references from an initial set of known to be live objects. O conjunto inicial de objetos vivos (chamados objetos raiz ou apenas raízes) são localizados através da análise dos registros, campos globais e quadros de pilha no momento em que uma coleta de lixo é acionada. Após a identificação de um conjunto inicial de objetos vivos, o coletor de rastreamento segue as referências desses objetos e os coloca em fila para serem marcados como vivos e, posteriormente, ter suas referências rastreadas. A marcação de todos os objetos referenciados encontrados ao vivo significa que o conjunto conhecido ao vivo aumenta com o tempo. Este processo continua até que todos os objectos referenciados (e portanto todos os objectos vivos) sejam encontrados e marcados. Uma vez que o coletor de rastreamento tenha encontrado todos os objetos vivos, ele irá recuperar a memória restante.
Rastrear coletores diferem dos coletores de contagem de referência na medida em que eles podem lidar com estruturas circulares. A captura com a maioria dos coletores de rastreamento é a fase de marcação, que implica uma espera antes de poder recuperar a memória não referenciada.
Relectores de rastreamento são mais comumente usados para gerenciamento de memória em linguagens dinâmicas; eles são de longe os mais comuns para a linguagem Java e têm sido comprovados comercialmente em ambientes de produção por muitos anos. Vou focar no rastreamento de coletores para o restante deste artigo, começando com alguns dos algoritmos que implementam esta abordagem de coleta de lixo.
Logítimos de rastreamento de coletores
Copiar e mark-and-sweep garbage collection não são novos, mas ainda são os dois algoritmos mais comuns que implementam o rastreamento de coleta de lixo hoje em dia.
Copying collectors
Tradicional copying collectors use a from-space and a to-space — isto é, dois espaços de endereço definidos separadamente da pilha de lixo. No ponto de coleta de lixo, os objetos vivos dentro da área definida como from-space são copiados para o próximo espaço disponível dentro da área definida como to-space. Quando todos os objetos vivos dentro do espaço de origem são movidos para fora, todo o espaço de origem pode ser recuperado. Quando a alocação começa novamente, ela começa a partir do primeiro local livre no to-space.
Em implementações mais antigas deste algoritmo os locais de troca do espaço e do to-space, significando que quando o to-space está cheio, a coleta de lixo é acionada novamente e o to-space se torna o to-space, como mostrado na Figura 1.
Mais implementações modernas do algoritmo de cópia permitem que espaços de endereço arbitrários dentro do heap sejam atribuídos como espaço para (to-space) e espaço de origem (from-space). Nestes casos eles não têm necessariamente que trocar de localização uns com os outros; ao contrário, cada um se torna outro espaço de endereço dentro do heap.
Uma vantagem de copiar coletores é que os objetos são alocados juntos firmemente no to-space, eliminando completamente a fragmentação. Fragmentação é uma questão comum com a qual outros algoritmos de coleta de lixo lutam; algo que discutirei mais adiante neste artigo.
Downsides de coletores copiadores
Coopying collectors are usually stop-the-world collectors, meaning that no application work can be executed for as long as the garbage collection is in cycle. Numa implementação stop-the-world, quanto maior a área a ser copiada, maior será o impacto no desempenho da sua aplicação. Isto é uma desvantagem para as aplicações que são sensíveis ao tempo de resposta. Com um coletor de cópia, você também precisa considerar o pior cenário possível, quando tudo está ao vivo no espaço. Você sempre tem que deixar espaço suficiente para que objetos ao vivo sejam movidos, o que significa que o espaço de destino deve ser grande o suficiente para hospedar tudo no espaço de origem. O algoritmo de cópia é ligeiramente ineficiente devido a esta restrição.
Electores de marcação e varredura
Os JVMs mais comerciais implementados em ambientes de produção empresarial executam colectores de marcação e varredura (ou marcação), que não têm o impacto de desempenho que os colectores de cópia têm. Alguns dos coletores de marcação mais famosos são CMS, G1, GenPar e DeterministicGC (ver Recursos).
Um coletor de marcação e varredura traça referências e marca cada objeto encontrado com um bit “ao vivo”. Normalmente um bit definido corresponde a um endereço ou em alguns casos a um conjunto de endereços na pilha. O bit vivo pode, por exemplo, ser armazenado como um bit no cabeçalho do objeto, um bit vetor, ou um bit mapa.
Após tudo ter sido marcado ao vivo, a fase de varredura entrará em ação. Se um coletor tem uma fase de varredura, basicamente inclui algum mecanismo para atravessar a pilha novamente (não apenas o conjunto ao vivo mas todo o comprimento da pilha) para localizar todos os pedaços não marcados de espaços de endereços de memória consecutivos. A memória não-marcada é livre e recuperável. O colecionador então conecta esses pedaços não marcados em listas livres organizadas. Pode haver várias listas livres em um coletor de lixo — geralmente organizadas por tamanhos de pedaços. Algumas JVMs (como JRockit Real Time) implementam coletores com heurísticas que dinamicamente fazem listas de tamanho baseado em dados de perfil da aplicação e estatísticas de tamanho de objeto.
Quando a fase de varredura estiver completa a alocação começará novamente. Novas áreas de alocação são alocadas a partir das listas livres e os pedaços de memória podem ser combinados com tamanhos de objetos, médias de tamanho de objetos por ID de thread, ou com os tamanhos TLAB ajustados à aplicação. Ajustar o espaço livre mais próximo ao tamanho do que sua aplicação está tentando alocar otimiza a memória e poderia ajudar a reduzir a fragmentação.
Mais sobre tamanhos TLAB
TLAB e particionamento TLA (Thread Local Allocation Buffer ou Thread Local Area) são discutidos em JVM performance optimization, Parte 1.
Downsides of mark-and-sweep collectors
A fase de marcação depende da quantidade de dados ao vivo no seu heap, enquanto a fase de varredura depende do tamanho do heap. Como você tem que esperar até que ambas as fases de marcação e varredura estejam completas para recuperar a memória, este algoritmo causa desafios de tempo de pausa para pilhas maiores e conjuntos de dados maiores ao vivo.
Uma maneira de ajudar aplicações que consomem muita memória é usar opções de ajuste de GC que acomodam vários cenários e necessidades de aplicações. O ajuste pode, em muitos casos, ajudar pelo menos a adiar qualquer uma dessas fases de se tornar um risco para a sua aplicação ou acordos de nível de serviço (SLAs). (Um SLA especifica que a aplicação irá atender a certos tempos de resposta da aplicação — ou seja, latência). O ajuste para cada alteração de carga e modificação da aplicação é uma tarefa repetitiva, porém, como o ajuste só é válido para uma carga de trabalho específica e taxa de alocação.
Implementações de mark-and-sweep
Existem pelo menos duas abordagens comercialmente disponíveis e comprovadas para implementar a coleta de mark-and-sweep. Uma é a abordagem paralela e a outra é a abordagem concorrente (ou majoritariamente concorrente).
Relectores paralelos
Recolha paralela significa que os recursos atribuídos ao processo são utilizados em paralelo para fins de recolha de lixo. A maioria dos coletores paralelos implementados comercialmente são coletores monolíticos stop-the-world — todos os fios da aplicação são interrompidos até que todo o ciclo de coleta de lixo esteja completo. A parada de todos os fios permite que todos os recursos sejam utilizados de forma eficiente em paralelo para finalizar a coleta de lixo através das fases de marcação e varredura. Isto leva a um nível muito alto de eficiência, geralmente resultando em altas pontuações em pontos de referência de rendimento, como a SPECjbb. Se a produtividade é essencial para sua aplicação, a abordagem paralela é uma excelente escolha.