Como melhorar a eficiência de aplicativos Java executados no Kubernetes
Este post tem como propósito ajudar os desenvolvedores na configuração da JVM, visando aprimorar o desempenho e otimizar a utilização dos recursos disponíveis.
Número de processadores
Ao executarmos uma aplicação num cluster Kubernetes, é uma boa prática definir a quantidade de recursos que devem ser alocados ao container onde aplicação é executada, vide exemplo a seguir:
apiVersion: v1
kind: Deployment
metadata:
name: blog
spec:
selector:
matchLabels:
app: blog
replicas: 2
template:
metadata:
labels:
app: blog
spec:
containers:
- name: blog
image: gasparbarancelli/blog:latest
ports:
- containerPort: 8080
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1024Mi"
cpu: "1000m"
Ao aplicarmos a configuração acima, o Kubernetes garante que o Pod será executado num Node que tenha ao menos 512mb de memória e 500m de CPU disponível, mas também permite que o container utilize mais recursos, podendo chegar até o limite estabelecido de 1024mb de memória e 1000m de CPU.
Quando a JVM identifica que está sendo executada num container, ela realiza a leitura dos valores estabelecidos na configuração do POD para determinar quantos processadores estão disponíveis para a aplicação. Em geral, qualquer valor até
millicores é indentificado por uma máquina de processador único. Qualquer valor entre 1000m
e 1001m
é identificado como uma máquina de processador duplo, de 2000m
e 2001
três processadores, e assim por diante. Essas informações estão disponíveis por meio da API Runtime.getRuntime().availableProcessors(). Esse valor também pode ser usado por alguns dos GCs simultâneos para configurar seus threads. Outras APIs, bibliotecas e estruturas também podem usar essas informações para configurar pools de threads.3000
Os valores de CPU estabelecidos no Pod estão relacionados ao tempo que um processo gasta na CPU e não ao número de CPUs disponíveis para o processo. A maioria das aplicações que construímos devem fazer uso de múltiplas Threads, principalmente quando são executadas num cluster Kubernetes que possui Nodes com processadores super potentes. Mesmo que o container tenha um limite de um CPU, a JVM pode ser instruída a ver dois ou mais processadores disponíveis, para que seus processos sejam executados em múltiplas Threads.
Para informar a JVM sobre o número exato de processadores que ela deve ver em um ambiente Kubernetes, use o seguinte argumento na inicialização da JVM:
-XX:ActiveProcessorCount=N
Garbage Collector
Como sabemos, a coleta automática de lixo é o processo de examinar a memória heap, identificando quais objetos estão em uso e quais não estão e excluindo os objetos não utilizados. Um objeto em uso, ou um objeto referenciado, significa que alguma parte do seu programa ainda mantém um ponteiro para esse objeto. Um objeto não utilizado, ou objeto não referenciado, não é mais referenciado por nenhuma parte do seu programa. Portanto, a memória usada por um objeto não referenciado pode ser recuperada.
A JVM ao longo dos anos vem aprimorando seus coletores de lixo, bem como, implementando novos coletores para os mais diferentes tipos de aplicações e ambientes. Esse post abordará quatro coletores: SerialGC, ParallelGC, G1GC e ZGC.
SerialGC:
É otimizado para baixo consumo de memória (Memory footprint) e pequenos tamanhos de heap, está disponível em todas as versões do Java. Seu uso é adequado em ambientes com apenas um core de processamento, ou em aplicações com pequenos conjuntos de dados. Pode-se forçar o uso desse coletor adicionando o argumento: -XX: +UseSerialGC
ParalleIGC:
Podemos dizer que o Paralle/ GC é similar ao Seria/ 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. Contudo, o Paralle/ 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
O Paralle/ 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 habilita-o em outras versões utilize o argumento: -XX: +UseParalleiGC
GIGC:
O objetivo do G1GC é 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.
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 G1GC com o seguinte argumento XX:MaxGCPauseMillis-200 (200 é o valor default).
O G1GC é a melhor escolha para uma aplicação que necessita de um bom throuchout com uma latência 'controlada, ele foi definido como default a partir do Java 9 e ainda permanece. Para definir o G1 como coletor padrão, basta utilizarmos o seguinte argumento: -XX: +UseG1GC
ZGC:
O Z Garbage Collector, é um coletor de lixo escalável de baixa latência projetado para não ter pausas acima de 10ms.
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).
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»)
Um dos pontos a ser observado é o tamanho do Heap alocado. Dependendo da JVM e de quanta memória existe no host, um percentual será estabelecido como valor máximo de Heap.
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 entre o Java 11 e 15 é necessário adicionar o seguinte argumento: -XX+UnlockExperimentalVMOptions
Escolha o GC adequado para sua aplicação
Sempre que uma aplicação Java é iniciada, um processo chamado Ergonomics da JVM coleta dados do host e define qual o Garbage Collector, tamanho do heap e tipo de compilador deve ser usado pela aplicação. Esse processo já existe muito antes do advento de containers, portanto, temos que tomar um certo cuidado e verificar se o Ergonomics foi assertivo em suas definições quando executamos aplicações em containers.
Quando uma aplicação Java é executada num host que possui 2 ou mais processadores e mais de 1792mb de memória a JVM vai optar por definir um Garbage Collector que seja MultiThread, caso contrario ela utilizará o coletor Serial que é SingleThread.
Como mencionado anteriormente, quando executamos nossas aplicações num cluster Kubernetes que possui Nodes super modernos e potentes, devemos usufruir desses recursos que são disponibilizados. Consequentemente a isso, muitas das vezes se faz necessária a definição manual de qual coletor de lixo nossa aplicação deve utilizar.
Para auxiliar na escolha do coletor, vamos tomar como base algumas condições: versão da JVM, tamanho do heap, ambiente Single ou multi thread, dentre outras.
Serial | Parallel | G1 | Z | |
---|---|---|---|---|
Número de processadores |
1 |
2+ |
2+ |
2+ |
Multi-threaded |
Não |
Sim |
Sim |
Sim |
Java Heap Size |
< 4GB |
< 4GB |
> 4GB |
> 4GB |
Pausas |
Sim |
Sim |
Sim |
Sim (< 1ms) |
Overhead |
Mínimo |
Mínimo |
Moderado |
Moderado |
Tail-latency Effect |
Alto |
Alto |
Alto |
Baixo |
JDK version |
Todas |
Todas |
JDK 8+ |
JDK 15+ |
Importante deixar claro que a tabela apresentada não deve ser levada como verdade absoluta. O ideal é que sejam efetuados testes de carga na aplicação, simulando o uso real do sistema, colhendo métricas para que seja mais assertivo a escolha do coletor de lixo.
Tamanho da Heap
A JVM divide a memória em duas principais categorias: NonHeap e Heap.
NonHeap
A memória NonHeap é uma área de armazenamento importante para várias estruturas de dados usadas pelo JVM. A memória NonHeap é usada para armazenar informações que são necessárias para a execução do programa. Podemos dividi-la nas seguintes subcategorias:
-
Compressed class space: Usada para armazenar informações das classes que foram carregadas.
-
Thread: Memória usada por Threads na JVM.
-
Code cache: Memória usada pelo compilador JIT armazenar o output.
-
GC: Armazena dados usados pelo Garbage Collector.
-
Symbol: Armazena símbolos como nomes de campos, assinaturas de métodos e strings internas.
-
Internal: Armazena outros dados internos que não se encaixam nas outras subcategorias.
A memória NonHeap tem menor probabilidade de variar sob carga. Depois que uma aplicação tiver carregado todas as classes que usará e o compilador JIT estiver totalmente aquecido, as coisas se estabeleceram em um estado estável.
Heap
A memória heap é o espaço onde os objetos Java são alocados durante a execução do programa. Se o tamanho da memória heap não for suficientemente grande para alocar todos os objetos necessários, ocorrerá uma exceção de "OutOfMemoryError" e o programa será encerrado abruptamente. Por outro lado, se a memória heap for muito grande, isso pode levar a um consumo excessivo de recursos do sistema, o que pode afetar negativamente o desempenho do programa e do sistema como um todo.
Como mencionado anteriormente, o processo Ergonomics da JVM é responsável por definir o tamanho da Heap. Para isso, ele leva em consideração a quantidade de memória que foi disponibilizada para o container.
A tabela a seguir mostra o tamanho de heap máximo padrão, dependendo da quantidade de memória disponível:
Memória disponível | Tamanho máximo de heap padrão |
---|---|
Até 256MB |
50% da memória disponível |
256 MB a 512 MB |
~127 MB |
Mais de 512 MB |
25% da memória disponível |
O Ergonomics sabe que existem outros fatores que afetam a disponibilidade de memória, como outros processos em execução, fragmentação da memória, tamanho da paginação, entre outros.
É importante encontrar um equilíbrio entre o tamanho da memória heap e o consumo de recursos do sistema. Isso pode ser feito ajustando a configuração do tamanho da memória heap através das opções de linha de comando da JVM, como "-Xmx" (que define o tamanho máximo da memória heap) e "-XX:MaxRAMPercentage" (que limita a quantidade máxima de memória que o processo da JVM pode alocar com base em uma porcentagem da memória disponível no container). O ajuste adequado dessas opções pode ajudar a evitar problemas de "OutOfMemoryError", garantir um desempenho ideal do programa, bem como, diminuição de custos quando for mal dimensionada.
Alguns estudos indicam que Microsserviço Java, quando executados em containers podem utilizar como valor máximo de heap até 75% do valor total de memória. Para realizar essa configuração, basta adicionar o seguinte argumento na inicialização da JVM:
Java 9+: -XX:MaxRAMPercentage=75
Java 8: -XX:MaxRAMPercentage=75.0
Conclusão
Este post teve como objetivo ajudar desenvolvedores a melhorar o desempenho e aprimorar a utilização de recursos disponíveis em aplicativos Java que são executados em containers no Kubernetes.
Sabemos que é importante definir a quantidade de recursos que serão alocados ao container em que a aplicação será executada, definir o tamanho da memória Heap, bem como informar à JVM o número exato de processadores que ela deve ver em um ambiente Kubernetes. Além disso, é possível escolher um dos coletores de lixo, como o SerialGC, o ParallelGC, o G1GC ou o ZGC, dependendo das necessidades específicas de sua aplicação. Com aprimoramentos em sua configuração, sua aplicação terá melhor desempenho e funcionará de forma mais eficiente.