Entendendo o modelo de Memória do Java

Um passo importante para todo Javeiro é entender como funciona o endereçamento de memória de JVM, mesmo que ela faça o trabalho para você, é necessário saber como se comporta para saber como otimizar sua aplicação. Já que, durante a inicialização, cada aplicativo Java gera vários objetos na memória do computador. Só que é um recurso finito, então saber usar de forma eficiente faz parte das nossas atribuições.

Para começarmos, precisamos lembrar que o Java incorpora gerenciamento automático de memória. Ou seja, ele lida com a alocação e desalocação de memória automaticamente, aliviando nós programadores do fardo de gerenciar manualmente os recursos de memória. Isso ajuda a simplificar as tarefas de gerenciamento de memória para desenvolvedores e reduz a probabilidade de erros relacionados à memória, como vazamentos de memória ou ponteiros pendentes.


Mas, apesar da Java Virtual Machine (JVM) ser responsável pelo gerenciamento de memória e limpeza de recursos em um aplicativo, problemas como vazamentos ou escassez de memória não são incomuns. Estes problemas não podem ser resolvidos apenas pela JVM e muitas vezes necessitam de intervenção humana.

Em um programa Java, a memória é dividida em duas seções principais: o Heap e o Metaspace (anteriormente conhecido como PermGen no Java 8).

Metaspace

Vamos começar entendendo o que é o Metaspace, ela é uma região de memória onde o Java que armazena informações estáticas pertencentes a uma aplicação, incluindo metadados associados a classes carregadas. Ao contrário de seu antecessor, PermGen (Java 8-), o Metaspace não possui um limite de tamanho explícito por padrão e se expande dinamicamente. No entanto, sem um limite explícito definido, o tamanho do Metaspace é implicitamente restringido pela memória de sistema disponível na máquina hospedada.

E se você quiser mexer nas configurações do Metaspace, essas são as flags e o que afeta no seu ambiente:

  • -XX:MetaspaceSize — Aqui você configura o tamanho mínimo do Metaspace. Interessante usar esse carinha quando você já sabe quanto ela vai ocupar.
  • -XX:MaxMetaspaceSize — Aqui você configura o tamanho máximo de memória que essa região pode ocupar.
  • -XX:MinMetaspaceFreeRatio — Especifica a porcentagem mínina reservada de memória após a passagem do Garbage Collector.
  • -XX:MaxMetaspaceFreeRatio — Especifica o máximo de porcentagem reservada de memória após a passagem do Garbage Collector.

Heap

É aqui que começa a ficar interessante a brincadeira com memória no Java, a heap é onde a memória é alocada dinamicamente e os objetos são armazenados durante a execução de um programa Java. É uma região da memória do computador gerenciada pela Java Virtual Machine (JVM). A heap é usado para alocar memória para objetos criados usando a palavra-chave “new”, bem como para estruturas de dados como arrays e coleções.

A heap é divida basicamente em duas áreas principais:

  • Young Generation: esta área é sub-dividida em espaço Eden e dois espaços Survivor (De e Para). Os objetos são inicialmente alocados no espaço Eden. Quando o espaço do Éden fica cheio, uma pequena coleta de lixo é acionada e os objetos sobreviventes são movidos para um dos espaços dos Sobreviventes (S0 e S1). Objetos que sobrevivem a vários ciclos de Garbage Collection nos espaços Sobreviventes são eventualmente promovidos para a Old Generation.
  • Old Generation: Esta área também é conhecida como Tenured Generation. É usada para armazenar objetos de longa vida que sobreviveram a vários ciclos de garbage collection na Young Generation. Sempre que ocorre um Major GC, é normalmente aqui que ele ocorre para recuperar memória de objetos inacessíveis.

Vamos entender como isso funciona ?

Quando os objetos são inicialmente criados, eles são alocados no espaço Éden. Durante a execução do Garbage Collector, os objetos que ainda estão vivos (ou seja, em uso) são movidos para um dos espaços do Sobrevivencia (Survivors). Os espaços “Survivors” são utilizados como áreas de armazenamento temporário de objetos que sobreviveram a um ciclo de coleta de lixo e ainda não foram promovidos à Old Generation.

Os espaços de Suvivor trabalham juntos em um algoritmo de cópia. Quando um “Minor GC” acontece, os objetos ainda em uso que estão na Eden ou no Survivor S0, eles são copiados para o espaço adjacente, ou seja, da Eden para S0, da S0 para S1. Então objetos que a cada coleta ainda estão em uso ficam transitando entre os espaços, até chegarem a S1, e aqueles que ainda sobreviverem até lá, são promovidos para a Old Generation.

Você deve estar se perguntando, porque ter dois espaços de Survivor em vez de jogar direto da Eden para a Old ? Na verdade o propósito aqui é performance, e endereçar a necessidade de ter que ficar armazenando objetos por muito tempo. Esse mecanismo minimiza a sobrecarga associada ao processo de Garbage Collection e ajuda a aumentar a eficiencia do processo de manutenção da memória.

E o tamanho da heap pode ser ajustado usando flags como -Xms (tamanho inicial da heap) e -Xmx (tamanho máximo da heap). E saber fazer o ajuste destes parametros é essencial para a otimização de performance e estabilidade das aplicações.

E como podemos ajustar a heap, vamos a algumas dicas:

  • Configure o tamanho inicial e máximo do tamanho da heap (-Xms, -Xmx).
  • Ajuste o tamanho da Young Generation (-Xmn).
  • Escolha o algoritmo de Garbage Collection que se adequa a seu Workload (-XX:+UseSerialGC, -XX:+UseParallelGC, -XX:+UseG1GC).
  • Configure o GC para usar threads paralelas (-XX:ParallelGCThreads).
  • Ligue a adaptabilidade de tamanho de policies (-XX:+UseAdaptiveSizePolicy).
  • Ligue os logs do GC (-Xloggc).
  • Deixe configurado para gerar no heap dump no caso de estouro de memória (-XX:+HeapDumpOnOutOfMemoryError).
  • Use compressão nos ponteiros de objetos (-XX:+UseCompressedOops).
  • Se usar o G1GC, faça o ajuste fino dele (-XX:G1HeapRegionSize, -XX:MaxGCPauseMillis).
  • -XX:NewSize: Especifique o tamanho mínimo para a Eden na Young Generation.
  • -XX:MaxNewSize: Especifique o tamanho máximo para a Eden na Young Generation.

E se você estiver se perguntando: Porque monitorar a Memória no Java ?

A primeira resposta para isso é, sem isso você não consegue otimizar o Java. Então monitorar o comportamento da memória vai te ajudar na saúde da aplicação, a melhorar a performance, e manter a estabilidade do sistema em ambientes produtivos. Então vamos aos pontos principais:

  1. Identificar Memory Leaks (Vazamentos de Memória): Não é porque o Java tem um controle de memória que você deve ignorar que isso não pode acontecer. Monitar o uso da memória vai ajudar a detectar onde você pode ter errado no código e ter objetos que não estão sendo coletados de maneira correta, conexões abertas esquecidas e muito mais. Ajude a prevenir error de OutofMemory.
  2. Otimizar a Performance: Como você vai saber quais são os valores para configurar de memória para as regiões ou mesmo qual é o algoritmo de GC que você deveria estar utilizando? Monitorar a memória vai permitir que você entenda o comportamento da sua aplicação e saber quais valores e configurações aplicar para levar a sua aplicação a melhor performance que ela pode atingir.
  3. Prevenir erros por Falta de Memória: Simples, se você monitora, você sabe o que está acontecendo e consegue se preparar e prevenir que o sistema pare por exaustão de memória.
  4. Fazer o Troubleshooting de performance da aplicação: As métricas de memória vão te ajudar a entender o comportamento da sua aplicação, e saber se você precisa alterar o código ou alguma configuração na JVM.

Show, entendi que preciso monitorar, mas Como ?

Basicamente temos quatro maneiras de monitorar o uso de memória do Java e com isso olhar para como a sua aplicação se comporta, então vamos as maneiras:

JVM Tools: Ferramentas feitas justamente para analisar o comportamento da JVM como: Java VisualVM, Java Mission Control, ou o que já vem com o Java, o jstat, vão te auxiliar a monitorar o uso de memória, a atividade do Garbage Collector, tirar heap dumps, analisar metricas de memória e CPU.

Ferramentas de Monitoração: Aqui tem uma Gama de produtos como usar o Prometheus e Grafana, New Relic, Datadog, ou qualquer coisa nessa direção que permita que você visualize as métricas de uso de memória das aplicações e vizualize em um Dashboard.

Heap Dumps: Faça a analise dos Dumps de memória que você gerar a partir do erros de memória usando ferramentas como o Eclipse Memory Analyzer (MAT), VisualVM, Eclipse Jifa. Os Heap Dumps nos fornecem uma informação detalhada do que aconteceu na memória para uma analise “post-mortem” e entender o que levou a exaustão da memória.

Logging e Alertas: Não esqueça de configurar mecanismos de logging e de alertas para o consumo de memória, principalmente quando eles atingirem marcas que ofereçam perigo para a aplicação e ambiente. Você precisa ser avisado antes de acontecer para poder agir a tempo. Aqui entra usar o bom e velho Log4j, monitorias do ambiente produtivo que você estiver usando e tudo que puder lhe ajudar a analises mais detalhadas.


Monitorar o uso de memória Java em ambientes produtivos é crucial para detectar vazamentos de memória, otimizar o desempenho, prevenir OutOfMemoryErrors, solucionar problemas de desempenho e garantir a estabilidade e confiabilidade geral das suas aplicações. Ao monitorar o uso da memória de maneira eficaz, você pode gerenciar proativamente os recursos de memória, identificar possíveis problemas antecipadamente e otimizar o desempenho do aplicativo.

Você que desenvolve em Java precisa estar atento a isso e garantir que sua aplicação vai se comportar da melhor maneira possível.

Referências:

https://medium.com/@alxkm/java-memory-model-3b973e84dc8c

https://www.digitalocean.com/community/tutorials/java-jvm-memory-model-memory-management-in-java

https://dip-mazumder.medium.com/java-memory-model-a-comprehensive-guide-ba9643b839e

https://jenkov.com/tutorials/java-concurrency/java-memory-model.html

Publicado por

serlopes

Nerd, geek, músico de garagem, gamer e pai. Curte tudo que envolve esse universo maravilhoso e muita música, desde que seja de qualidade.

Deixe um comentário