Melhorar o desempenho da aplicação com estas técnicas avançadas de GC

A performance da aplicação está na vanguarda de nossas mentes, e a otimização da coleta de lixo é um bom lugar para fazer pequenos, mas significativos avanços

A coleta de lixo automatizada (junto com o JIT HotSpot Compiler) é um dos componentes mais avançados e mais valorizados do JVM, mas muitos desenvolvedores e engenheiros estão muito menos familiarizados com a coleta de lixo (GC), como ela funciona e como ela impacta o desempenho da aplicação.

Primeiro, para que serve o GC mesmo? A coleta de lixo é o processo de gerenciamento de memória para objetos na pilha de lixo. Como os objetos são alocados na pilha de lixo, eles passam por algumas fases de coleta – geralmente bastante rápidas, pois a maioria dos objetos na pilha de lixo tem vida útil curta.

Os eventos de coleta de lixo contêm três fases – marcação, exclusão e cópia/compactação. Na primeira fase, o GC percorre a pilha e marca tudo como objetos ao vivo (referenciados), objetos não referenciados ou espaço de memória disponível. Os objetos não referenciados são então excluídos, e os objetos restantes são compactados. Em coletas de lixo geracionais, os objetos “envelhecem” e são promovidos através de 3 espaços em suas vidas – Éden, espaço Survivor e espaço Tenured (Antigo). Esta mudança também ocorre como parte da fase de compactação.

Mas já chega disso, vamos à parte divertida!

Psst! Procurando uma solução para melhorar o desempenho da aplicação?OverOps ajuda as empresas a identificar não só quando e onde ocorrem lentidão, mas porquê e como elas ocorrem. Veja uma demonstração ao vivo para ver como funciona.

Conhecendo a Coleta de Lixo (GC) em Java

Uma das grandes coisas sobre GC automatizado é que os desenvolvedores não precisam realmente entender como ele funciona. Infelizmente, isso significa que muitos desenvolvedores NÃO entendem como ela funciona. Entender a coleta de lixo e os muitos GCs disponíveis, é um pouco como conhecer os comandos CLI do Linux. Você não precisa tecnicamente usá-los, mas saber e se sentir confortável em usá-los pode ter um impacto significativo na sua produtividade.

Apenas como com os comandos CLI, há o básico absoluto. ls comando para visualizar uma lista de pastas dentro de uma pasta pai, mv para mover um arquivo de um local para outro, etc. No GC, esses tipos de comandos seriam equivalentes a saber que existe mais de um GC para escolher, e que o GC pode causar problemas de desempenho. Claro que há muito mais a aprender (sobre o uso do Linux CLI E sobre coleta de lixo).

O propósito de aprender sobre o processo de coleta de lixo do Java não é apenas para iniciantes de conversas gratuitas (e chatas), o propósito é aprender como implementar e manter efetivamente o GC correto com o desempenho ideal para o seu ambiente específico. Saber que a coleta de lixo afeta a performance da aplicação é básico, e existem muitas técnicas avançadas para melhorar a performance do GC e reduzir seu impacto na confiabilidade da aplicação.

Perturbações de performance do GC

Fugas de memória –

Com o conhecimento da estrutura da pilha de lixo e como a coleta de lixo é feita, sabemos que o uso da memória aumenta gradualmente até que um evento de coleta de lixo ocorra e o uso caia de volta para baixo. Com um vazamento de memória, cada evento de GC limpa uma porção menor de objetos de pilha (embora muitos objetos deixados para trás não estejam em uso), então a utilização da pilha continuará a aumentar até que a memória da pilha esteja cheia e uma exceção OutOfMemoryError seja lançada. A causa para isso é que o GC apenas marca objetos não referenciados para exclusão. Portanto, mesmo que um objeto referenciado não esteja mais em uso, ele não será apagado da pilha. Existem alguns truques de codificação úteis para evitar isso que iremos cobrir um pouco mais tarde.

Continuo “Stop the World” Events –

Em alguns cenários, a coleta de lixo pode ser chamada de evento Stop the World porque quando ela ocorre, todos os threads da JVM (e portanto, o aplicativo que está rodando nela) são parados para permitir que o GC execute. Em aplicativos saudáveis, o tempo de execução do GC é relativamente baixo e não tem um efeito grande no desempenho do aplicativo.

Em situações subótimas, no entanto, os eventos Stop the World podem impactar muito o desempenho e a confiabilidade de um aplicativo. Se um evento GC exigir uma pausa Stop the World e levar 2 segundos para ser executado, o usuário final desse aplicativo experimentará um atraso de 2 segundos à medida que os threads rodando o aplicativo são interrompidos para permitir GC.

Quando ocorrem vazamentos de memória, os eventos Stop the World contínuos também são problemáticos. Como a cada execução do GC é purgado menos espaço de memória heap, leva menos tempo para que a memória restante seja preenchida. Quando a memória está cheia, a JVM aciona outro evento de GC. Eventualmente, a JVM estará executando eventos Stop the World repetidos causando grandes preocupações de desempenho.

CPU Usage –

E tudo se resume ao uso da CPU. Um dos principais sintomas de eventos contínuos de GC / Stop the World é um pico no uso da CPU. GC é uma operação computacionalmente pesada, e por isso pode levar mais do que a sua quota-parte de energia da CPU. Para GCs que executam threads simultâneos, o uso da CPU pode ser ainda maior. Escolher o GC certo para sua aplicação terá o maior impacto no uso da CPU, mas também há outras maneiras de otimizar para um melhor desempenho nesta área.

Nós podemos entender a partir destas preocupações de desempenho em torno da coleta de lixo que, por mais avançados que os GCs fiquem (e eles estão ficando bastante avançados), o calcanhar de aquiles deles permanece o mesmo. Alocações de objetos redundantes e imprevisíveis. Para melhorar o desempenho da aplicação, escolher o GC certo não é suficiente. Precisamos saber como o processo funciona, e precisamos otimizar nosso código para que nossos GCs não puxem recursos excessivos ou causem pausas excessivas em nossa aplicação.

Generational GC

Antes de mergulharmos nos diferentes GCs Java e seu impacto na performance, é importante entender o básico da coleta de lixo geracional. O conceito básico de GC geracional é baseado na idéia de que quanto mais tempo existe uma referência a um objeto na pilha, menos provável é que ele seja marcado para eliminação. Ao marcar objetos com uma “idade” figurativa, eles poderiam ser separados em diferentes espaços de armazenamento para serem marcados pelo GC com menos freqüência.

Quando um objeto é alocado na pilha, ele é colocado no que é chamado de espaço Eden. É onde os objetos começam, e na maioria dos casos é onde eles são marcados para serem apagados. Objetos que sobrevivem a essa etapa “comemoram um aniversário” e são copiados para o espaço Survivor. Este processo é mostrado abaixo:

Os espaços Eden e Survivor compõem o que é chamado de Geração Jovem. É aqui que a maior parte da ação ocorre. Quando (se) um objeto na Geração Jovem atinge uma certa idade, ele é promovido para o espaço Tenured (também chamado de Velho). O benefício de dividir as memórias dos Objetos com base na idade é que o GC pode operar em diferentes níveis.

Um GC Menor é uma coleção que foca apenas na Geração Jovem, ignorando efetivamente o espaço Tenured por completo. Geralmente, a maioria dos Objetos da Geração Jovem são marcados para exclusão e um GC Maior ou Completo (incluindo a Geração Velha) não é necessário para liberar a memória na pilha. Claro que um GC Maior ou Completo será acionado quando necessário.

Um truque rápido para otimizar a operação do GC com base nisso é ajustar o tamanho das áreas da pilha para melhor atender às necessidades de seus aplicativos.

Tipos de Coletores

Existem muitos GCs disponíveis para escolher, e embora o G1 tenha se tornado o GC padrão no Java 9, ele foi originalmente destinado a substituir o coletor CMS que é de Baixa Pausa, de modo que aplicativos rodando com coletores de Throughput podem ser mais adequados para ficar com seu coletor atual. Entender as diferenças operacionais, e as diferenças no impacto de desempenho, para coletores de lixo Java ainda é importante.

Coletores de rendimento

Melhor para aplicações que precisam ser otimizadas para alto rendimento e podem trocar maior latência para alcançá-lo.

Serial –

O coletor serial é o mais simples, e o que você menos provavelmente estará usando, pois foi projetado principalmente para ambientes de rosca única (por exemplo, 32 bits ou Windows) e para pequenas pilhas. Este coletor pode escalar verticalmente o uso de memória na JVM, mas requer vários GCs Maiores/Cheios para liberar recursos de pilha não utilizados. Isto causa pausas frequentes no Stop the World, o que o desqualifica para todos os fins de ser usado em ambientes voltados para o usuário.

Parallel –

Como seu nome descreve, este GC usa vários threads rodando em paralelo para varrer e compactar o heap. Embora o GC Paralelo use múltiplas roscas para a coleta de lixo, ele ainda pausa todas as roscas da aplicação enquanto está em execução. O coletor Paralelo é mais adequado para aplicativos que precisam ser otimizados para melhor rendimento e pode tolerar maior latência em troca.

Aplicações de coletores de baixa pausa

A maioria dos aplicativos voltados para o usuário exigirá um GC de baixa pausa, para que a experiência do usuário não seja afetada por pausas longas ou freqüentes. Estes GCs têm tudo a ver com otimização de resposta (tempo/evento) e forte desempenho a curto prazo.

Varredura de Marca Concorrente (CMS) –

Similar ao coletor Paralelo, o coletor de Marca Concorrente (CMS) utiliza múltiplos threads para marcar e varrer (remover) objetos não referenciados. Entretanto, este GC só inicia Stop the World events somente em duas instâncias específicas:

(1) ao inicializar a marcação inicial de raízes (objetos da geração antiga que são alcançáveis a partir de pontos de entrada de threads ou variáveis estáticas) ou quaisquer referências do método main(), e algumas mais

(2) quando a aplicação mudou o estado do heap enquanto o algoritmo estava rodando simultaneamente, forçando-o a voltar atrás e fazer alguns toques finais para ter a certeza que tem os objectos certos marcados

G1 –

O primeiro colector de lixo (vulgarmente conhecido como G1) utiliza múltiplos fios de fundo para varrer através da pilha que se divide em regiões. Ele funciona escaneando aquelas regiões que contêm mais objetos de lixo primeiro, dando-lhe seu nome (Garbage first).

Esta estratégia reduz a chance da pilha se esgotar antes que os fios de fundo tenham terminado de escanear os objetos não utilizados, neste caso o coletor teria que parar a aplicação. Outra vantagem para o coletor G1 é que ele compacta a pilha em movimento, algo que o coletor CMS só faz durante a parada total das coletas do World.

Melhorando o desempenho do GC

A performance da aplicação é diretamente impactada pela frequência e duração das coletas de lixo, o que significa que a otimização do processo de GC é feita através da redução dessas métricas. Há duas formas principais de se fazer isso. Primeiro, ajustando os tamanhos das pilhas de lixo das gerações jovens e velhas, e segundo, reduzindo a taxa de alocação e promoção de objetos.

Em termos de ajuste de tamanhos de pilhas de lixo, não é tão simples quanto se poderia esperar. A conclusão lógica seria que aumentar o tamanho da pilha diminuiria a frequência de GC enquanto aumentava a duração, e diminuir o tamanho da pilha diminuiria a duração de GC enquanto aumentava a frequência.

O fato da questão, entretanto, é que a duração de um GC Menor depende não do tamanho da pilha, mas do número de objetos que sobrevivem à coleção. Isso significa que para aplicações que na maioria das vezes criam objetos de curta duração, o aumento do tamanho da geração jovem pode na verdade reduzir tanto a duração quanto a freqüência de um GC. No entanto, se o aumento do tamanho da geração jovem levar a um aumento significativo de objetos que precisam ser copiados em espaços de sobrevivência, as pausas de GC levarão mais tempo, levando a um aumento da latência.

3 Dicas para Escrever Código de GC-Eficiente

Dica #1: Prever Capacidades de Coleção –

Todas as coleções padrão Java, assim como a maioria das implementações customizadas e estendidas (como o Trove e a Goiaba do Google), usam matrizes subjacentes (tanto primitivas quanto baseadas em objetos). Como as arrays são imutáveis em tamanho uma vez alocadas, adicionar itens a uma coleção pode, em muitos casos, causar a queda de um array subjacente antigo em favor de um array maior recentemente alocado.

A maioria das implementações de coleções tenta otimizar esse processo de realocação e mantê-lo no mínimo amortizado, mesmo que o tamanho esperado da coleção não seja fornecido. Entretanto, os melhores resultados podem ser alcançados fornecendo a coleção com o tamanho esperado na construção.

Tip #2: Process Streams Directly –

Quando se processa fluxos de dados, como dados lidos de arquivos, ou dados baixados pela rede, por exemplo, é muito comum ver algo do tipo:

O resultado do array de bytes poderia então ser analisado em um documento XML, objeto JSON ou mensagem de Protocolo Buffer, para nomear algumas opções populares.

Ao lidar com arquivos grandes ou de tamanho imprevisível, esta é obviamente uma má idéia, pois nos expõe ao OutOfMemoryErrors caso a JVM não possa realmente alocar um buffer do tamanho do arquivo inteiro.

Uma maneira melhor de abordar isto é usar o InputStream (FileInputStream neste caso) apropriado e alimentá-lo diretamente no parser, sem primeiro ler tudo em um array de bytes. Todas as principais bibliotecas expõem APIs a fluxos parse diretamente, por exemplo:

Tip #3: Use Immutable Objects –

Immutability tem muitas vantagens. Uma que raramente recebe a atenção que merece é o seu efeito na recolha do lixo.

Um objecto imutável é um objecto cujos campos (e especificamente campos não-primitivos no nosso caso) não podem ser modificados depois do objecto ter sido construído.

Immutabilidade implica que todos os objectos referenciados por um recipiente imutável foram criados antes da construção do recipiente estar concluída. Em termos de GC: O contentor é pelo menos tão jovem quanto a referência mais jovem que contém. Isto significa que ao realizar ciclos de coleta de lixo em gerações jovens, o GC pode pular objetos imutáveis que se encontram em gerações mais velhas, pois sabe com certeza que eles não podem referenciar nada na geração que está sendo coletada.

Menos objetos a serem escaneados significam menos páginas de memória a serem escaneadas, e menos páginas de memória a serem escaneadas significam ciclos de GC mais curtos, o que significa pausas de GC mais curtas e melhor rendimento geral.

Para mais dicas e exemplos detalhados, veja este post cobrindo táticas profundas para escrever códigos mais eficientes na memória.

*** Enorme agradecimento a Amit Hurvitz do OverOps’ R&D Team por sua paixão e insight que entrou neste post!

Deixe um comentário