O artigo apresenta conceitos e métricas de Garbage Collection em Java, além da categorização da memória heap em generations. Em seguida, apresenta a JVM HotSpot 6 e seu funcionamento geral com relação à Garbage Collection, assim como a sua personalização através de opções da JVM. A partir daqui, explica sobre cada collector, seu funcionamento, suas opções de personalização e suas implementações, fazendo também um comparativo final mostrando quando é mais adequado utilizar cada um dos collectors disponíveis. Ainda, apresenta a JVM HotSpot 7 e seu novo collector, seu funcionamento e opções de personalização.
Guia do artigo:
- Garbage Collection em Java
- Generations
- Collectors da JVM HotSpot 6
- Ergonomics
- Funcionamento geral
- JVM HotSpot
- Serial
- Parallel
- Concurrent (CMS)
- Incremental Concurrent (i-CMS)
- Escolhendo o melhor collector
- Collectors da JVM HotSpot 7
- Garbage First
Um assunto constantemente deixado de lado atualmente, porém de extrema importância, é a devida configuração do processo de Garbage Collection. É comum de se ver pouco esforço quanto a este respeito, e assim aparecem muitas consequências que seriam evitáveis em um processo de Garbage Collection bem configurado.
Tipicamente, é incomum ver um desenvolvedor que se preocupe o suficiente com isto, pois é muito provável que seu ambiente de desenvolvimento não possua as mesmas necessidades que um ambiente de produção, e desta forma ele não sente bem os flagelos causados por um Garbage Collection mal configurado: as famigeradas pausas e demoras.
Todavia, é possível otimizar a maioria dos cenários, visto que cada aplicação tem uma maneira única quanto à utilização de memória, sendo assim possível buscar as configurações a serem utilizadas para obter-se o máximo possível do processo de Garbage Collection.
A intenção deste artigo é demonstrar aos desenvolvedores a importância de entender e otimizar o processo de Garbage Collection. Assim, faz-se necessário um estudo preliminar sobre os conceitos e algoritmos disponíveis em diferentes versões da Java Virtual Machine (JVM) mais conhecida atualmente, a JVM HotSpot, o que será abordado neste primeiro artigo da série “Entendendo e otimizando o Garbage Collection”.
Conceitos de Garbage Collection em Java
A linguagem Java possui gerenciamento automático de memória, controlando sua alocação e desalocação. A desalocação de memória é suportada pelo processo conhecido por Garbage Collection.
Esta abordagem difere-se das linguagens tradicionais como C++, onde a memória dinâmica é alocada e desalocada explicitamente, o que costumava ser problemático devido à utilização de ponteiros de memória, o que possibilita problemas como vazamentos de memória e bugs de ponteiros para regiões da memória que continham objetos que já foram desalocados.
Em Java, a alocação e desalocação de memória acontece de maneira automática, controlada e transparente ao desenvolvedor, substituindo a utilização de ponteiros de memória por referências de objetos, evitando assim os vazamentos de memória e bugs de ponteiros. Desta forma, a linguagem Java é considerada mais segura neste aspecto.
Em contrapartida, este gerenciamento automático de memória consome recursos computacionais quanto à decisão sobre a desalocação, fato já sabido pelo desenvolvedor que realizava isto explicitamente em C++. Além disso, este processo é não-determinístico, ou seja, não há garantias sobre quando acontecerá a desalocação, se ela vier a acontecer.
Antes de explicar mais detalhes sobre este processo, eis alguns conceitos iniciais:
- Memória heap: espaço reservado pela JVM para a alocação de objetos em memória. Toda alocação de objeto em Java é realizada na memória heap. Da mesma forma, toda vez que um objeto é desalocado, a memória utilizada por este retorna como memória disponível para a heap;
- Collection: processo automático de gerenciamento da memória heap, baseado em duas atividades: busca de objetos que não são mais acessíveis, e desalocação dos recursos utilizados por estes objetos;
- Collector: algoritmo que realiza uma collection;
- Throughput (vazão): porcentagem de tempo de execução não utilizada em collections, ou seja, teoricamente é o total de tempo de execução disponível para a aplicação, considerado sobre longos períodos de tempo;
- Pausa: momento de tempo onde a aplicação fica não-responsiva porque uma collection está acontecendo.
Tipicamente, throughput e pausa representam um trade-off em Garbage Collection, ou seja, quanto mais esforço se investe em maximizar o throughput, menos se tem em minimizar o tempo de pausa, e vice-versa.
Em determinadas situações, a existência de pausas não caracteriza um problema crítico, como por exemplo em um servidor web, onde o cliente já está ciente que precisará esperar por uma resposta, e assim as pausas de Garbage Collection podem ser disfarçadas pela latência de rede, de maneira que o usuário não consiga perceber que a demora aconteceu por causa de Garbage Collection. Assim, uma boa ideia seria utilizar um collector que busque maximizar o throughput da aplicação.
Em outras situações, a existência de pausas representa um problema crítico, como por exemplo em uma aplicação gráfica e interativa, onde mesmo pequenas pausas serão percebidas pelo usuário e podem afetar sua experiência com a aplicação. Assim, uma boa ideia seria utilizar um collector que busque minimizar o tempo de pausa.
O gráfico da Figura 1, obtido do website da Oracle, representa a porcentagem de throughput perdida em aplicações que gastam diferentes quantidades de tempo com collections, à medida que mais processadores são adicionados ao sistema. Por exemplo, a linha vermelha representa uma aplicação que gasta 1% do tempo fazendo collections e a linha roxa representa outra aplicação que gasta 10%. Ambas as aplicações não apresentam perda considerável de throughput em um sistema com 1 processador, pois no gráfico ambas as linhas estão próximas de 1 de throughput quando temos 1 processador. No entanto, quando operam com 32 processadores, a primeira aplicação apresenta uma perda de mais de 20% de throughput (pois a linha vermelha está um pouco abaixo de 0.8 quando temos 32 processadores), e a segunda aplicação possui mais de 75% de throughput perdido (pois a linha roxa está um pouco acima de 0.2 quando temos 32 processadores).
O objetivo deste gráfico é demonstrar como questões pouco perceptíveis em sistemas com um processador podem tornar-se verdadeiros gargalos quando as escalamos a sistemas grandes. No entanto, é possível melhorar este cenário com a seleção do algoritmo mais apropriado de Garbage Collection, além da realização de ajustes personalizados.
Algoritmo Mark and Sweep
Um objeto é considerado “garbage” quando não é mais acessível de qualquer referência do programa em execução. O primeiro desafio é identificar tais objetos, para em um segundo momento reclamar a memória previamente ocupada por estes objetos.
O primeiro algoritmo criado para Garbage Collection é conhecido como Mark and Sweep. Até hoje, derivações deste algoritmo são extensivamente utilizadas.
O algoritmo Mark and Sweep é composto por duas fases: a fase Mark, onde todos os objetos acessíveis do sistema são visitados e marcados como tal, e logo depois a fase Sweep, onde todos os objetos que não foram marcados como acessíveis são reclamados.
Ainda, uma collection que utiliza o algoritmo Mark and Sweep irá suspender temporariamente a execução do programa, enquanto o algoritmo realiza seu trabalho. Assim que todos os objetos não referenciados são reclamados, a execução do programa é retomada. Esta característica é conhecida como stop the world, ou como diria Raulzito, “pare o mundo que eu quero descer”.
Uma collection que visita todos os objetos acessíveis do sistema é denominada Full Garbage Collection. Em Java, logo se percebe que realizar frequentemente Full Garbage Collections não é uma boa ideia, visto o tempo que seria gasto para tal devido a grande quantidade de objetos que são criados ao longo da execução de cada programa.
Generations
Como uma alternativa ao algoritmo original de Mark and Sweep, surgiram os algoritmos generacionais de Garbage Collection. Estes algoritmos baseiam-se na observação que a maioria dos objetos sobrevive por um curto período de tempo. É uma característica comuns na maioria das aplicações que pode ser utilizada para minimizar o esforço anteriormente gasto pelos algoritmos mais ingênuos.
A Figura 2, também obtida do website da Oracle, representa uma distribuição do tempo de vida médio de objetos, onde o eixo X é a quantidade de bytes alocados, e o eixo Y é a quantidade de bytes em execução em um determinado momento. Nota-se que após um breve pico, o número de bytes em execução cai drasticamente, o que significa que tal soma de objetos foi reclamada pouco tempo após ser alocada. Isto é comum de se ver em objetos que são criados para serem utilizados dentro de métodos curtos e loops. Em contrapartida, alguns objetos permanecem em execução por muito tempo, por exemplo objetos presentes em transações longas ou acesso a banco de dados.
Assim foi idealizada a noção de generations (gerações) de objetos, que servem para dividir os objetos pelo critério “tempo de existência”. Cada generation representa uma ou mais separações físicas ou lógicas do espaço de memória e possui um determinado limite que, quando atingido, desencadeia uma collection. Confira os três tipos possíveis de generation:
- Young generation(geração jovem): contém objetos desde sua criação até certo patamar, onde se espera uma queda brusca na quantidade de bytes em execução, conforme a Figura 2. Representa de fato a grande maioria dos objetos. Quando esta geração alcança o seu limite, acontece uma minor collection, ou coleção menor, onde apenas os objetos pertencentes a Young generation são coletados. Tipicamente, minor collections são rápidas, coletam muitos objetos e consomem poucos recursos da JVM;
- Tenured generation (>geração efetivada): contém objetos que sobreviveram a minor collections, o que significa que seu tempo de existência é significativamente grande. Quando esta geração alcança o seu limite, acontece uma major collection, ou coleção maior, onde todos os objetos da memória heap são coletados. Tipicamente, major collections são pesadas, podem não coletar muitos objetos e consomem muitos recursos da JVM;
- Permanent generation (geração permanente): contém objetos necessários à execução da JVM, como classes, métodos e interfaces. Esta geração também sofre major collections, embora isto raramente faça uma diferença significativa.
Collectors da JVM HotSpot 6
A JVM HotSpot, que é fornecida no download do Java Runtime Environment (JRE) ou Java Development Kit (JDK) pelo site da Oracle, em sua versão 6.0, possui alguns collectors que são estudados a seguir.
Ergonomics
Desde a versão 5.0, a JVM HotSpot possui uma funcionalidade conhecida como Ergonomics, que é a escolha automática de certas opções de linha de comando na inicialização da JVM. Esta escolha é baseada no porte da máquina na qual a JVM está rodando, o que sugere características da aplicação. Por exemplo: aplicações mais pesadas devem rodar em máquinas mais potentes.
De maneira geral, Ergonomics seleciona:
- O algoritmo de Garbage Collection;
- O tamanho da memória heap;
- O compilador de tempo de execução.
Tal escolha automática de um algoritmo de Garbage Collection geralmente resulta em ganhos de performance, mas não é possível garantir que esta seja a melhor escolha possível. Certos tipos de aplicação que possuam uma utilização de memória muito particular podem necessitar de escolhas explícitas para alcançar o nível de performance esperado.
Funcionamento geral
A JVM HotSpot 6 divide a memória heap conforme a Figura 3, obtida do blog about:performance.
Nesta divisão, a Young generation é novamente dividida em três pedaços: espaço Eden, onde a maioria dos objetos é inicialmente alocada, e dois espaços Survivor, onde objetos são copiados ao sobreviver a collections.
Tipicamente, objetos em Eden que sobreviveram à primeira collection são copiados a um dos espaços Survivor, e a cada nova collection que sobreviverem continuarão a ser copiados entre os espaços Survivor, até serem considerados maduros o suficiente para serem copiados para a Tenured generation. O objetivo após a cópia é sempre deixar Eden e um dos espaços Survivor vazios. Esta forma de Garbage Collection é conhecida como copy collection.
Na Tenured generation, não há cópia, mas sim liberação de memória, por algoritmos tipicamente derivados de Mark and Sweep. No entanto, quando há liberação de memória, fica-se sujeito a problemas de fragmentação de memória.
Em termos de memória heap, fragmentação causa alocação lenta, longa duração da fase Sweep e possibilidade de OutOfMemoryError quando os espaços entre objetos são menores que o suficiente para a alocação de novos objetos, conforme demonstrado na Figura 4, obtida do blog about:performance.
Para tal, é necessário mais uma fase, chamada de Compact, onde é realizada a desfragmentação pela compactação do espaço disponível na memória heap, também demonstrado na Figura 4.
Configurando a JVM HotSpot
Antes de entrar no estudo minucioso dos collectors da JVM HotSpot, é importante apresentar algumas opções da JVM para personalizar a configuração da memória heap e as generations:
- -Xms<N>: Especifica o tamanho inicialmente reservado da memória heap em N megabytes;
- -Xmx<N>: Especifica o tamanho máximo da memória heap em N megabytes;
- -XX:MinHeapFreeRatio=<N>: Especifica a porcentagem mínima de espaço livre da memória heap. Se o espaço livre vier a ser menor que N%, o tamanho da memória heap será aumentado para garantir esta porcentagem de espaço livre mínimo;
- -XX:MaxHeapFreeRatio=<N>: Especifica a porcentagem máxima de espaço livre da memória heap. Se o espaço livre vier a ser maior que N%, o tamanho da memória heap será diminuído para garantir esta porcentagem de espaço livre máximo;
- -XX:NewRatio=<N>: Especifica a proporção de tamanho 1:N entre Young generation e o resto da memória heap. Por exemplo, se N=3, então a proporção será 1:3, ou seja, a Young generation ocupará 1/4 do espaço total da memória heap;
- -XX:NewSize=<N>: Especifica o tamanho inicialmente reservado da Young generation em N megabytes. É uma alternativa a -XX:NewRatio pois pode ser difícil estimar este tamanho em proporção 1:N;
- -XX:MaxNewSize=<N>: Especifica o tamanho máximo da Young generation em N megabytes;
- -XX:SurvivorRatio=<N>: Especifica a proporção de tamanho 1:N entre cada espaço Survivor e Eden. Por exemplo, se N=6, então a proporção será 1:6, ou seja, cada espaço Survivor ocupará 1/8 do espaço total da Young generation (pois há dois espaços Survivor);
- -XX:PermSize=<N>: Especifica o tamanho inicialmente reservado da Permanent generation em N megabytes;
- -XX:MaxPermSize=<N>: Especifica o tamanho máximo da Permanent generation em N megabytes.
Além disto, há as seguintes opções para imprimir logs sobre Garbage Collection:
- -verbosegc: Imprime uma linha no console a cada collection realizada, no formato [GC <tamanho da memória heap antes da collection> -> <tamanho da memória heap após a collection> (<tamanho máximo da memória heap>), <tempo de pausa> secs];
- -XX:+PrintGCDetails: Similar a -verbosegc, mas inclui mais informações como os detalhes da execução de cada collector;
- -XX:+PrintGCTimeStamps: Quando usado com -XX:+PrintGCDetails mostra os horários em que cada collection foi realizada;
- -XX:+PrintGCDateStamps: Quando usado com -XX:+PrintGCDetails mostra as datas em que cada collection foi realizada;
- -XX:+PrintReferenceGC: Quando usado com -XX:+PrintGCDetails mostra estatísticas de objetos de referência fraca, como WeakReference, SoftReference e PhantomReference;
- -XX:+PrintTenuringDistribution: Imprime uma linha no console a cada collection realizada a respeito da utilização dos espaços Survivor e um threshold indicando quantas vezes um objeto pode ser copiado dentro da Young generation antes de ser considerado apto para pertencer à Tenured generation.
A JVM HotSpot 6 até a update 13 possui três collectors de Garbage Collection: Serial, Parallel e Concurrent, que serão estudados a seguir.
Serial
O collector Serial foi a escolha padrão da JVM até surgir Ergonomics (antes do Java 5), e hoje continua sendo a escolha certa para a maioria das aplicações pequenas.
É baseado em uma única thread para realizar todo o trabalho de Garbage Collection. Por um lado, isto é vantajoso devido ao fato que não há gasto de processamento com sincronização e comunicação entre threads, mas por outro lado é desvantajoso pois não aproveita de fato a utilização de múltiplos processadores quando os mesmos existem no hardware atual.
Assim sendo, o collector Serial é mais adequado em máquinas com único processador, ou máquinas com múltiplos processadores que processem uma quantidade pequena de dados (até aproximadamente 100 MB).
O ponto fraco deste algoritmo é o tamanho da pausa, que tende a ser muito grande comparado aos outros algoritmos disponíveis.
Outra questão a ser observada é relacionada com a Lei de Amdahl. Segundo a Lei de Amdahl, o ganho de performance na utilização de múltiplos processadores é limitado pela fração de tempo no qual o processamento paralelo pode ser utilizado.
Em outras palavras, sempre que houver uma porção de código que não pode ser paralelizado (como um método synchronized, por exemplo), o tempo disponível para o processamento paralelo será menor, e assim o ganho de performance diminuirá. Assim, outro fator a favor da utilização do collector Serial é quando uma aplicação possui muito código não-paralelizável.
Há duas implementações deste collector:
- Serial: atua na Young generation utilizando uma única thread. É do tipo copy collector (copia os objetos alcançáveis para outra região da memória heap, como um dos espaços Survivor ou a Tenured generation, e libera a memória da região toda);
- SerialOld: atua na Tenured generation utilizando uma única thread. É do tipo mark-sweep-compact (marca os objetos inalcançáveis, libera a memória e em seguida a compacta).
O collector Serial pode ser explicitamente escolhido utilizando a opção da JVM: -XX:UseSerialGC. Esta opção seleciona as implementações Serial (para Young generation) e SerialOld (para Tenured generation).
Parallel
O collector Parallel (também conhecido como collector Throughput) realiza collections em paralelo, otimizando o tempo de processamento significativamente. O objetivo deste collector é maximizar o throughput da aplicação, abrindo mão de minimizar o tempo de pausa, apesar de que as pausas são significantemente menores que o collector Serial em máquinas com múltiplos processadores. Foi projetado para trabalhar com quantidade média a grande de dados.
Há três implementações deste collector:
- Parallel Scavenge: atua na Young generation utilizando várias threads e é do tipo copy collector;
- ParNew: também atua na Young generation utilizando várias threads e é do tipo copy collector. Seu diferencial é que foi otimizado para o uso com o collector Concurrent;
- Parallel Old: atua na Tenured generation utilizando várias threads e é do tipo compacting collector (algoritmo derivado de Mark and Sweep que realiza compactação ao mesmo tempo em que opera).
Uma particularidade da implementação Parallel Old é que ele não compacta toda a região Tenured collection todas as vezes, apenas compacta a sub-região que necessite mais.
O collector Parallel pode ser explicitamente escolhido utilizando uma entre as seguintes opções da JVM:
- -XX:UseParallelGC: seleciona as implementações Parallel Scavenge (para Young generation) e Serial Old (para Tenured generation);
- -XX:UseParNewGC: seleciona as implementações ParNew (para Young generation) e Serial Old (para Tenured generation);
- -XX:UseParallelOldGC: seleciona as implementações Parallel Scavenge (para Young generation) e Parallel Old (para Tenured generation).
É possível personalizar seu funcionamento com as seguintes opções da JVM:
- -XX:ParallelGCThreads=<N>: Especifica o número de threads e Garbage Collection a serem utilizadas. Por padrão, o collector Parallel utilizará X threads de Garbage Collection em uma máquina com X processadores. Tipicamente, em uma máquina com 1 processador, o collector Parallel terá performance pior que o collector Serial. Em uma máquina com 2 processadores ou mais, com uma quantidade média a grande de dados, o collector Parallel já se sobressai;
-
-XX:MaxGCPauseMillis=<N>: Especifica a pausa máxima desejada. Por padrão, não há pausa máxima desejada previamente definida.
A utilização desta opção faz com que o tamanho da memória heap e outros parâmetros sejam ajustados para tentar manter as pausas menores ou iguais a N milissegundos, podendo assim afetar o throughput da aplicação. Contudo, não há garantias que o tempo de pausa será menor ou igual a N milissegundos em todas as execuções;
- -XX:GCTimeRatio=<N>.: Especifica a razão de tempo total para Garbage Collection na aplicação, segundo a fórmula 1 / (1 + <N>). Por exemplo, -XX:GCTimeRatio=19 define a razão de 1/20 ou 5% como o tempo total para Garbage Collection na aplicação;
- -XX:YoungGenerationSizeIncrement=<Y>.: Especifica a porcentagem de incremento quando o tamanho da Young generation aumenta. Por padrão, é 20%;
- -XX:TenuredGenerationSizeIncrement=<T>.: Especifica a porcentagem de incremento quando o tamanho da Tenured generation aumenta. Por padrão, é 20%;
- -XX:AdaptiveSizeDecrementScaleFactor=<D>.: Especifica o fator D para calcular a porcentagem de decremento quando o tamanho de alguma generation diminui. Tal porcentagem é calculada como X / D, onde X é a porcentagem de incremento. Por padrão, a porcentagem de decremento é 5%;
- -XX:DefaultInitialRAMFraction=<N>: Especifica o fator N para calcular o tamanho inicial da memória heap, que é igual a R / N, onde R é o tamanho da memória RAM da máquina. Por padrão, N é 64;
- -XX:DefaultMaxRAMFraction=<N>: Especifica o fator N para calcular o tamanho máximo da memória heap, que é calculada como o valor mínimo entre 1 GB ou R / N, onde R é o tamanho da memória RAM da máquina. Por padrão, N é 4;
- -XX:-UseGCOverheadLimit: Desabilita o disparo de OutOfMemoryError quando mais de 98% do tempo total é usado em Garbage Collection, sobrando menos de 2% para a aplicação.
Concurrent (CMS)
O collector Concurrent (também conhecido como CMS, que significa Concurrent Mark and Sweep) também realiza collections em paralelo, assim como o collector Parallel. O objetivo deste collector é minimizar o tempo de pausa, mesmo que as pausas aconteçam com maior frequência, abrindo mão de maximizar o throughput da aplicação. Foi projetado para aplicações que necessitem ter um baixo tempo de pausa, e que além disso utilizem quantidade média a grande de dados que permaneçam um bom tempo em execução (formando assim uma grande Tenured generation).
Uma particularidade deste collector é que a maior parte do processo de Garbage Collection acontece ao mesmo tempo em que a aplicação é executada. Desta forma, haverá um maior consumo de processamento, o que poderá afetar o throughput.
O processo ocorre da seguinte maneira:
- Pausa para marcação inicial: todas as threads da aplicação são suspensas para a marcação do primeiro nível de objetos alcançáveis a partir das raízes (objetos diretamente acessíveis pela memória heap). Esta pausa é breve, e não utiliza múltiplas threads;
- Marking em concorrência: as threads da aplicação são retomadas, e inicia-se concorrentemente o processo de navegação e marcação dos objetos alcançáveis pelos objetos marcados na etapa anterior. Não é garantido que todos os objetos alcançáveis da memória heap serão marcados nesta etapa, pois uma vez que este processo é concorrente com a aplicação, novos objetos podem ter sido criados a partir de objetos que já foram visitados desde o início desta etapa, e assim acabam ficando sem serem marcados como alcançáveis;
- Pausa para remarcação: todas as threads da aplicação são suspensas novamente, e agora todos os objetos alcançáveis são revisitados para a marcação de novos objetos alcançáveis que não foram visitados na marcação inicial. Eventuais objetos que foram marcados inicialmente mas agora se tornaram inalcançáveis permanecerão marcados, mas serão coletados na próxima collection. Esta pausa é consideravelmente maior que a pausa inicial, e pode utilizar múltiplas threads;
- Sweeping em concorrência: as threads da aplicação são retomadas, e inicia-se concorrentemente o processo de sweeping da memória heap.
Por ter este funcionamento, o collector Concurrent não realiza compactação. Desta forma, fica-se sujeito à fragmentação de memória, que pode gerar um problema de alocação quando o espaço disponível entre os blocos de memória é insuficiente para a alocação de um objeto. Quando isto acontecer, entrará em ação um outro collector que realizará uma Major collection com direito a compactação da memória heap no final de seu ciclo.
O collector Concurrent pode ser explicitamente escolhido utilizando a opção da JVM: -XX:UseConcMarkSweepGC. Esta opção seleciona as implementações ParNew (para Young generation), CMS e Serial Old (ambos para Tenured generation). Neste caso, primeiramente tentará utilizar CMS, mas se houver problemas de fragmentação, utilizará Serial Old.
É possível personalizar seu funcionamento com a seguinte opção da JVM:
- -XX:CMSInitiatingOccupancyFraction=<N>: Especifica a porcentagem de ocupação da Tenured generation necessária para disparar uma collection. Por padrão, este valor é aproximadamente 92%.
Incremental Concurrent (i-CMS)
O collector Concurrent possui um modo onde as fases concorrentes acontecem de forma incremental, chamado de Incremental Concurrent (também conhecido como i-CMS ou Train). Neste modo, quando múltiplas threads estão trabalhando no processo de Garbage Collection, o trabalho a ser feito é dividido em pequenas porções que são agendadas para acontecer entre Minor collections. Foi projetado para oferecer baixo tempo de pausa sem consumir muito throughput, sendo ideal para máquinas com número pequeno de processadores (como 1 ou 2).
Uma particularidade deste modo é o uso de duty cycle para controlar a quantidade de trabalho que deve ser realizado antes do collector devolver o processador para a aplicação. Duty cycle é a porcentagem de tempo de Minor collections que este collector pode utilizar.
O processo ocorre da seguinte maneira:
- Pausa para marcação inicial: idêntico ao CMS, onde todas as threads da aplicação são suspensas para a marcação de todos os objetos alcançáveis a partir das raízes;
- Marking em concorrência: idêntico ao CMS, as threads da aplicação são retomadas, e inicia-se concorrentemente o processo de navegação e marcação de objetos alcançáveis usando um ou mais processadores;
- Remarking em concorrência: separa-se um processador para fazer a remarcação concorrente dos objetos que foram modificados desde a etapa anterior;
- Pausa para remarcação: todas as threads da aplicação são suspensas novamente, e agora todos os objetos alcançáveis que foram modificados desde a última vez que foram examinados são revisitados para marcação;
- Sweeping em concorrência: as threads da aplicação são retomadas, e inicia-se concorrentemente o processo de sweeping da memória heap, usando um processador;
- Resizing em concorrência: redimensiona o tamanho da memória heap e prepara as estruturas de dados para a próxima collection, usando um processador.
O collector Incremental Concurrent pode ser explicitamente escolhido utilizando as opções da JVM: -XX:UseConcMarkSweepGC e -XX:+CMSIncrementalMode. Estas opções selecionam as implementações ParNew (para Young generation), i-CMS e Serial Old (ambos para Tenured generation). Novamente, primeiramente tentará utilizar i-CMS, mas se houver problemas de fragmentação, utilizará Serial Old.
É possível personalizar seu funcionamento com as seguintes opções da JVM:
- -XX:+CMSIncrementalPacing: Habilita automatic pacing, que é a estimativa automática do duty cycle baseado em estatísticas da JVM. Por padrão, é habilitado;
- -XX:+CMSIncrementalDutyCycle=<N>: Especifica a porcentagem de tempo entre Minor collections quando o collector pode executar. Se automatic pacing está habilitado, especifica apenas o valor inicial. Por padrão, é 10;
- -XX:CMSIncrementalSafetyFactor=<N>: Especifica a porcentagem de uma margem de segurança que será adicionada ao tempo de execução das Minor collections. Por padrão, é 10;
- -XX:CMSIncrementalOffset=<N>: Especifica a porcentagem na qual o duty cycle tem seu início intencionalmente atrasado. Por padrão, é 0;
- -XX:CMSExpAvgFactor=<N>: Especifica a porcentagem usada para pesar a amostra atual quando computar médias exponenciais para as estatísticas de collections concorrentes. Por padrão, é 25.
Escolhendo o melhor collector
Conforme já discutido, o melhor collector para uma aplicação depende de vários fatores, como o porte da máquina a ser utilizada, a maneira que a aplicação utiliza a memória heap em termos de alocação, o tempo de vida dos objetos, a importância de maximizar o throughput ou minimizar o tempo de pausa, entre outros fatores. Assim, é necessário um estudo mais aprofundado para identificá-lo.
No entanto, é proposto aqui um ponto de partida para identificar o collector mais adequado. Em seguida, sugere-se analisar o tempo gasto com collection por meio de opções da JVM, realizar testes de performance e testar diferentes configurações buscando cada vez mais otimização.
A Figura 5 resume os intervalos de pausa entre os três collectors apresentados. Cada seta representa uma thread; as setas de cor azul são threads da aplicação e as setas de cor laranja são threads de collectors.
Como regra geral, a menos que seja muito importante ter um tempo de pausa baixo, é recomendado primeiramente deixar a JVM escolher e configurar o collector a ser utilizado através de Ergonomics.
Se a performance não estiver suficiente, recomenda-se o seguinte:
- Se a aplicação possuir uma quantidade de dados pequena (até aproximadamente 100 MB), utilize o collector Serial;
- Se a aplicação for executada em um único processador e não houver restrições quanto ao tempo de pausa, utilize o collector Serial;
- Se for prioridade explorar o máximo possível de performance e pausas de um segundo ou mais forem aceitáveis, deixe a JVM selecionar o collector ou utilize o collector Parallel;
- Se o tempo de resposta for mais importante que o throughput e pausas devam ser menores que um segundo, utilize o collector Concurrent. Se, além disso, apenas um ou dois processadores estiverem disponíveis, utilize o collector Incremental Concurrent.
Note que estas recomendações não garantem que os tempos de pausa sempre serão menores que um segundo ou que o throughput sempre será alto, visto que a JVM HotSpot é não-determinística, ou seja, ela trabalha buscando uma boa combinação geral de fatores a troco de abstrair certos detalhes internos do programador. Para ter garantias reais, é necessário utilizar outros tipos de JVM, como por exemplo, as que implementem a JSR-1, também conhecida como Real-Time Specification for Java (RTSJ).
Se o collector recomendado não alcançar a performance desejada, a primeira personalização a ser feita é o ajuste do tamanho da memória heap. Além disso, pode-se alterar os tamanhos das generations. Este é um fator muito sensível, pois uma Young generation muito grande pode aumentar o throughput mas prejudicar o tempo de pausa, visto que as collections que acontecerem nesta generation demorarão mais. Em contrapartida, uma Young generation muito pequena diminuirá o tempo de pausa, mas prejudicará o throughput.
Se ainda assim não houver sucesso com relação à performance, busque utilizar o collector Concurrent para reduzir tempos de pausa e o collector Parallel para aumentar o throughput em uma máquina com múltiplos processadores.
Collectors da JVM HotSpot 7
A JVM HotSpot 7 basicamente manteve os collectors Serial e Parallel, porém decidiu substituir Concurrent por Garbage First, um novo collector considerado como o próximo estágio de evolução dos algoritmos generacionais, ao menos em teoria.
Garbage First
O collector Garbage First (também conhecido como G1) é generacional e realiza collections em paralelo, assim como seus antecessores Parallel e Concurrent. O objetivo deste collector é possibilitar simultaneamente alto throughput e alta probabilidade de cumprir tempos de pausa pré-definidos (Garbage First é, portanto considerado um collector suave de tempo real). Foi projetado para sistemas com múltiplos processadores e com grande quantidade de memória.
Para alcançar seu objetivo, o collector Garbage First particiona fisicamente a memória heap em regiões de mesmo tamanho. Deste modo a separação entre generations é meramente lógica. Algumas regiões serão atribuídas a Young generation, outras serão a Tenured e as restantes a Permanent.
Assim como Concurrent, Garbage First possui uma fase de marcação concorrente, onde busca identificar as regiões cheias de objetos não-alcançáveis, que seriam idealmente coletadas primeiro. Para tal, é calculado o índice de liveliness destas regiões, o que representa a quantidade de objetos alcançáveis que cada região contém no momento.
O processo de collection é feito através de pausas para evacuation, onde Garbage First seleciona determinadas regiões, identifica os objetos sobreviventes dentro destas regiões, os copia para outras regiões e finalmente reclama o espaço total das regiões primeiramente selecionadas. Ao selecionar regiões, dá prioridade àquelas com menor liveliness, ou seja, com mais objetos a serem coletados. Seu nome Garbage First surgiu por causa desta ideia, que busca maximizar a quantidade de objetos coletados por cada execução de collection, o que significa uma otimização no processo convencional de collection.
A maioria das pausas para evacuation coleta regiões pertencentes à Young generation, tal como os outros collectors, mas por vezes algumas regiões pertencentes à Tenured generation são também selecionadas junto com as primeiras para serem coletadas na mesma pausa para evacuation.
Outra particularidade é a respeito de Garbage First conseguir cumprir tempos de pausa pré-definidos com alta probabilidade de acerto, devido ao fato que a granularidade das collections é por região e não por generation, tendo desta forma objetivos menores e menos propensos a atrasos. É muito mais preciso estimar o tempo de collection de uma região em comparação a toda a generation, e assim Garbage First utiliza esta estimativa para decidir quantas regiões deverão ser coletadas para cumprir o tempo de pausa desejado pelo usuário. Além disso, Garbage First tem autorização para diminuir um pouco o throughput em favor do cumprimento mais preciso deste tempo de pausa.
O collector Garbage First visa substituir Concurrent por resolver dois problemas que o último possui: fragmentação e baixo determinismo com relação ao tempo de pausas (pois quando a fragmentação chega ao seu limite, será necessário chamar um collector como SerialOld para coletar e compactar a memória heap toda, ocorrendo inesperadamente um tempo de pausa muito alto). O primeiro problema é resolvido por compactação, visto que Garbage First é um compacting collector, e o segundo problema é resolvido pela carga menor de trabalho de cada collection, visto que apenas determinadas regiões são coletadas por vez.
Garbage First foi introduzido na JVM HotSpot 6 update 14 de forma experimental e pode ser explicitamente escolhido utilizando as opções da JVM: -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC. Na JVM HotSpot 7 apenas o segundo parâmetro é necessário.
É possível personalizar seu funcionamento com as seguintes opções da JVM:
- -XX:MaxGCPauseMillis=<P>: Especifica o tempo de pausa máximo desejado, em milissegundos;
- -XX:GCPauseIntervalMillis=<I>: Especifica o intervalo desejado de tempo de execução da aplicação que permitirá o tempo de pausa máximo especificado acima, em milissegundos. Por exemplo, se I=200ms e P=20ms, significa que a cada 200ms de execução da aplicação, o collector deverá utilizar no máximo 20ms de tempo de pausa;
- -XX:+G1YoungGenSize=<N>: Especifica o tamanho da Young generation, em megabytes;
- -XX:+G1ParallelRSetUpdatingEnabled -XX:+G1ParallelRSetScanningEnabled: Estes parâmetros permitem aproveitar o máximo possível de Garbage First, mas, no entanto podem produzir uma rara situação de concorrência chamada condição de corrida (race condition) e resultar em erro.
Um último detalhe é que Garbage First é muito mais verboso que os outros collectors da JVM HotSpot quando utilizando a opção -XX:+PrintGCDetails, pois pretende fornecer mais informações para troubleshooting.
Conclusão
Este artigo visou demonstrar que, embora apresentem diferenças marcantes, os collectors da JVM HotSpot buscam resolver o mesmo problema, porém apresentando certas especialidades adequadas a determinados cenários. Basta saber identificar o collector mais adequado, experimentá-lo e analisar seu desempenho a fim de otimizar seu funcionamento por meio de opções da JVM. É também importante verificar se todos os objetivos de um determinado collector estão em conformidade com as necessidades do cenário em questão.
A seguir, as próximas partes desta sequência de artigos pretendem explorar collectors de outras JVMs, em particular as JVMs de tempo real; apresentar como desenvolver código para otimizar a utilização de memória e facilitar a vida do collector; e como analisar a situação da memória heap e do Garbage Collection por meio de ferramentas especializadas. Até breve!
Links
- Documentação oficial da Oracle sobre Garbage Collection na JVM HotSpot 6.
- Implementações de collectors da JVM HotSpot 6.
- Post do blog about:performance que explica como Garbage Collection se comporta em diferentes JVMs.
- Documentação oficial da Oracle sobre o collector Garbage First.
- Explicação detalhada do algoritmo Mark and Sweep.