Gerenciamento de Memória no Java
O gerenciamento automático de memória do Java pode esconder do engenheiro alguns detalhes que podem ser relevantes em certos casos de uso, como no desenvolvimento de Microsserviços e também de aplicações Serverless. Abordaremos neste artigo alguns aspectos do gerenciamento de memória do Java que podem ser relevantes para o desenvolvimento de serviços e aplicações headless. Para o desenvolvimento de aplicações interativas com UI, outras abordagens podem ser necessárias/relevantes.
Este artigo está divido em duas partes. Nesta primeira parte, veremos alguns aspectos mais gerais do Gerenciamento de Memória do Java. Na segunda parte, a ser publicada nas próximas semanas, abordaremos aspectos sobre o Gerenciamento de Memória do Java rodando em containers Docker.
Para acessar a segunda parte do artigo, utilize o link abaixo:
Parte 2 - Gerenciamento de Memória no Java - Containers Docker
Memória de um Processo
Tomaremos como base a plataforma Linux, que apesar de semelhante em muitos aspectos ao Windows, é uma das plataformas mais fáceis para deploy de aplicações não-interativas. No Linux, a memória de um processo pode ser dividida em 3 grandes classes:
- Data: dados manipulados pelo processo
- Text ou code: memória onde o código de máquina/executável do programa é armazenado
- Stack: memória reservada para uso do stack do programa
Olhando do ponto de vista de um programa clássico em C, teremos o seguinte mapeamento:
Tipo de dados C | Local no processo |
---|---|
Stack, chamadas entre funções (por thread) | Stack |
Inicialização de variáveis/estruturas | Data |
Variáveis globais - Heap | Data |
Variáveis locais - Stack | Stack |
Memória dinâmica - malloc | Data |
Constantes | Geralmente Data, às vezes Text |
Código executável | Text |
O total de memória consumido por um processo pode ser aproximado de maneira prática para a soma da memória utilizada pelas 3 grandes classes, ou então pela soma da memória utilizada pelos tipos de dados se o processo for analisado sob a ótica de um programa clássico C.
Memória de um Processo Java/JVM
Apesar de todos os níveis de abstração e gerenciamentos automáticos, o gerenciamento de memória de um programa Java também pode ser avaliado sob a ótica do gerenciamento de memória de um programa C. Inclusive as principais Máquinas Virtuais Java são escritas em C++, incluindo partes em assembler e outras linguagens.
Dessa forma, a memória de um processo Java pode ser separada nos seguintes grandes grupos:
Recurso da JVM | Grupo de memória JVM | Local no processo |
---|---|---|
Variáveis - Heap | Heap | Data |
Byte code das classes | Metaspace | Data |
Código executável JIT | Metaspace | Seção especial executável |
Código executável da JVM | Metaspace | Text |
Dados da JVM | Metaspace | Data |
Memória nativa/dinâmica | Metaspace | Data |
Stack (por thread) | Stack | Stack |
Dependendo da implementação da JVM, este mapeamento pode ser alterado de forma a obter diferentes níveis de desempenho/flexibilidade durante a execução. Seguiremos com a análise considerando um mapeamento como o exposto acima, e eventuais diferenças podem ser ajustadas seguindo a abordagem apresentada.
Por padrão, a JVM clássica ajusta um limite inicial de 1/4 da memória total do sistema para o grupo Heap do processo Java, deixando livre o tamanho dos outros grupos (na verdade, os outros grupos também possuem limites iniciais, mas estes são ajustados com muita folga, então podem ser considerados sem limites para todos os efeitos práticos). O valor de limite de 1/4 para o grupo Heap também pode variar, dependendo da quantidade de memória do sistema: para valores de memória do sistema abaixo de 512Mb de RAM, o limite do grupo Heap é ajustado entre 1/4 (512Mb) e 1/2 (128Mb) do total, em vez de fixo em 1/4.
Dessa forma, ao executar um processo Java em um computador com 8Gb de RAM, a JVM vai ajustar um limite no grupo Heap de 2Gb de RAM. Se o programa Java tentar alocar um conjunto de variáveis maior do que 2Gb de RAM, será disparada uma Exception do tipo java.lang.OutOfMemoryError no contexto da thread que está tentando ultrapassar o limite de alocação de memória. Cabe salientar que, antes de disparar a Exception, a JVM ainda tem uma chance de tentar liberar memória que não está mais em uso, através da execução do Garbage Collector.
Para controlar explicitamente o limite de memória do grupo Heap, pode ser usado o parâmetro -Xmx
da JVM:
Parâmetro | Resultado |
---|---|
-Xmx1G -Xmx1g |
Ajusta o limite do grupo Heap para 1Gb de RAM |
-Xmx256M -Xmx256m |
Ajusta o limite do grupo Heap para 256Mb de RAM |
-Xmx256 |
Ajusta o limite para 256 bytes - provavelmente um erro! |
java -Xmx512M -jar programa.jar |
Executa o programa.jar com o grupo Heap limitado a 512Mb de RAM |
Para verificar o limite do grupo Heap (e outros parâmetros de configuração da JVM) pode ser usado o comando a seguir:
$ java -XX:+PrintFlagsFinal -version | grep MaxHeap
uintx MaxHeapFreeRatio = 100
uintx MaxHeapSize := 4169138176
openjdk version "1.8.0_265"
OpenJDK Runtime Environment (build 1.8.0_265-b01)
OpenJDK 64-Bit Server VM (build 25.265-b01, mixed mode)
Exemplo 1
- Um processo Java está em execução com limite do grupo Heap de 1000Mb de RAM.
- No Heap do processo existem 800Mb alocados em dados de variáveis, sendo que 500Mb dessas variáveis já não estão mais em uso (por exemplo, o método já retornou, ou não existem mais referências para elas).
- Uma thread tenta alocar um vetor de 600Mb. Neste momento, o processo precisa aumentar o tamanho do Heap para 1400Mb, sendo 800Mb (tamanho atual) + 600Mb (nova alocação). A alocação falha porque o tamanho ultrapassa o limite de 1000Mb.
- Em resposta a falha de alocação, o gerenciador de memória do Java executa o Garbage Collector. O Garbage Collector analisa as referências das variáveis alocadas, e detecta que 500Mb dessas variáveis já não estão mais em uso, e então libera a memória alocada por estas variáveis.
- O Heap do processo, com tamanho alocado de 800Mb, agora possui 500Mb de espaço livre, e neste momento pode atender com sucesso a requisição da thread para alocar um vetor de 600Mb, através do aumento do tamanho do Heap para 900Mb.
Exemplo 2
- Um processo Java está em execução com limite do grupo Heap de 1000Mb de RAM.
- No Heap do processo existem 800Mb alocados em dados de variáveis, sendo que 100Mb dessas variáveis já não estão mais em uso (por exemplo, o método já retornou, ou não existem mais referências para elas).
- Uma thread tenta alocar um vetor de 600Mb. Neste momento, o processo precisa aumentar o tamanho do Heap para 1400Mb, sendo 800Mb (tamanho atual) + 600Mb (nova alocação). A alocação falha porque o tamanho ultrapassa o limite de 1000Mb.
- Em resposta a falha de alocação, o gerenciador de memória do Java executa o Garbage Collector. O Garbage Collector analisa as referências das variáveis alocadas, e detecta que 100Mb dessas variáveis já não estão mais em uso, e então libera a memória alocada por estas variáveis.
- O Heap do processo, com tamanho alocado de 800Mb, agora possui 100Mb de espaço livre, e neste momento ainda não é possível atender com sucesso a requisição da thread para alocar um vetor de 600Mb.
- Como ainda não é possível atender a requisição de alocação de memória, a requisição é considerada como uma falha de alocação de memória.
- A JVM dispara a Exception do tipo
OutOfMemoryError
no contexto da thread que está tentando alocar a memória sem sucesso. Caso seja a única thread do processo e a Exception não seja tratada, o processo é encerrado com a mensagem de erro de falta de memória.
O uso do Heap em um processo Java é o principal recurso sob controle do desenvolvedor, mas a memória alocada pelos outros recursos também pode variar ou ser controlada direta/indiretamente através de algumas das seguintes atividades:
- Byte code das classes: o espaço alocado é proporcional ao tamanho e quantidade de classes usada pelo projeto, e pode expandir durante a geração dinâmica de código, por exemplo, via atividade de Aspect-oriented Programming.
- Código executável JIT: depende muito da abordagem de cada tipo de JVM, mas é uma área que não varia muito em tamanho durante a execução do processo.
- Código executável da JVM: código executável fixo que compõe a parte executável da própria JVM, juntamente com o código das bibliotecas do sistema operacional usadas, e em geral não varia durante a execução.
- Dados da JVM: dados internos/de controle da própria JVM, não variam muito durante a execução.
- Memória nativa/dinâmica: memória que pode ser alocada por chamadas nativas, e/ou usada por bibliotecas e outros componentes nativos, além de buffers para IO. Pode variar bastante dependendo da interação entre o processo e o sistema operacional, por exemplo, no acesso à rede e ao disco.
- Stack: o espaço alocado é proporcional à quantidade de threads criadas/em uso pelo processo, incluindo
threads pré-alocadas em
ThreadPools
. A profundidade de chamadas também afeta negativamente o uso de memória.
Metaspace em um processo Java
O conceito de Metaspace foi criado a partir da versão 8 do Java, e substituiu o conceito de PermGen
,
que era uma área reservada para dados permanentemente alocados da JVM (em contraste ao Heap).
Em geral, a documentação das JVMs não deixa claro o exato tamanho e composição do Metaspace e quais componentes
de fato são alocados dentro deste espaço de memória. Uma simplificação comumente usada para um processo Java é
de que o espaço alocado pelo Metaspace é a diferença entre a memória total ocupada e o valor alocado pelo Heap.
Essa simplificação nem sempre é correta, ainda mais se levarmos em conta o espaço ocupado pelo Stack, que supostamente não faz parte do Metaspace. Contudo, essa simplificação ajuda a obter uma visão panorâmica geral do uso de memória de um processo Java, bem próxima do uso real, e ainda ajuda a simplificar a complexidade das diversas áreas especiais de memória do processo.
Assim como o Heap, o Metaspace também pode ser limitado através do parâmetro -XX:MaxMetaspaceSize
da JVM
clássica. Contudo, diferentemente do Heap, que está diretamente sob controle do desenvolvedor e do código em
execução, a natureza da memória alocada pelo Metaspace, em geral, não pode ser determinada facilmente sem o uso
de Profiling de Memória ou outras ferramentas de instrumentação que ajudem a mostrar os valores médios de
memória em uso pelo Metaspace durante o processo em teste de carga, e por isso mesmo a configuração padrão
do parâmetro é “sem limite”.
Mesmo assim, uma das principais características do Metaspace é que seu tamanho cresce até determinado ponto em que todos os módulos envolvidos foram ativados e usados algumas vezes, e depois seu valor estabiliza em certo patamar que, normalmente, não é ultrapassado, a menos que haja um leak de uso de algum componente presente no Metaspace (por exemplo a criação dinâmica de código/classes).
Próximos Passos
Na parte 2 deste artigo, veremos alguns detalhes do comportamento do gerenciamento de memória de um processo Java rodando dentro de containers Docker, bem como algumas abordagens para configuração de memória, e também dicas de como tratar os problemas de memória mais comuns.
Para acessar a segunda parte do artigo, utilize o link abaixo:
Parte 2 - Gerenciamento de Memória no Java - Containers Docker