Como dimensionar memória Java no Kubernetes (MaxRAMPercentage e OOMKill)

PT | EN
9 de junho de 2026 · 💬 Participe da Discussão
Se tem preguiça de ler, clique aqui pro TL;DR

Esse post nasceu de um problema real. Uma frota de aplicações Java rodando em Kubernetes tinha padronizado as JVMs com -XX:InitialRAMPercentage=75 -XX:MaxRAMPercentage=75 e dimensionava requests e limits de memória olhando container.memory.usage e working_set no observability. No papel, parecia certo: a JVM pega 75% do limite, sobra 25% pro resto, e a gente acompanha o RSS pra ajustar.

Na prática, dois sintomas apareceram:

  1. O RSS de todas as aplicações vivia colado em ~75% do limite — inclusive em ambientes de dev que mal recebiam tráfego. Não dava pra saber quem estava desperdiçando.
  2. Quando tentamos reduzir os limites com base nesse RSS, várias aplicações começaram a tomar OOMKill (exit 137).

Esse é um daqueles casos em que a métrica que você está olhando está mentindo — não porque está errada, mas porque você está interpretando a coisa errada. Vou documentar o que descobri investigando dados de ambientes reais e, no fim, montei uma PoC reproduzível (Docker + Java 21) que comprova cada afirmação com números medidos. Todo o código da PoC está no repositório público LucasBG0/poc-jvm-memory-containers que roda com um ./run.sh.

Os números “de produção” ao longo do texto vêm de uma frota real de apps Java em K8s, anonimizada. Os números “da PoC” são medidos na minha máquina e você consegue reproduzir.

O básico que todo mundo confunde

Antes de qualquer coisa, é preciso separar quatro flags que parecem fazer a mesma coisa e não fazem.

-Xms e -Xmx (absolutos)

São o tamanho inicial (-Xms) e máximo (-Xmx) do heap, em valores absolutos (-Xmx512m). O problema clássico em container: por muito tempo, a JVM não enxergava o cgroup e calculava esses valores com base na RAM da máquina inteira. Você botava um limite de 512 MiB no pod e a JVM achava que tinha 64 GiB pra brincar → OOMKill na primeira carga. Isso foi corrigido (JDK 8u191+ e 10+ são container-aware), mas o -Xms/-Xmx fixo continua sendo acoplado à sua mão: se alguém muda o limite do container e esquece de mudar o -Xmx, os dois desalinham.

-XX:MaxRAMPercentage (o teto, cgroup-aware)

Define o heap máximo como uma porcentagem da memória disponível (que, num container, é o limite do cgroup). Você muda o limite do pod e o heap acompanha sozinho. MaxRAMPercentage=75 num container de 768 MiB → heap máximo de 576 MiB.

-XX:InitialRAMPercentage (o inicial, cgroup-aware)

A mesma ideia, mas para o tamanho inicial do heap (o equivalente percentual do -Xms). É aqui que mora boa parte da confusão deste post: definir um InitialRAMPercentage alto não significa que sua app precisa daquilo — significa que a JVM vai comitar aquilo no boot.

-XX:MinRAMPercentage (a pegadinha)

Esse é o nome mais traiçoeiro do JDK. MinRAMPercentage não define um piso de heap, como o nome sugere. Ele só entra em ação quando a memória disponível é menor que 250 MiB, nesse caso, define o heap máximo como aquela porcentagem. Para qualquer container com memória acima de 250 MiB, o MinRAMPercentage é simplesmente ignorado e quem manda é o MaxRAMPercentage. Mais adiante eu provo isso com a PoC.

A PoC: o que ela mede

A PoC é um único programa Java (MemReport.java) que roda em “source mode” do Java 21, dentro de uma imagem eclipse-temurin:21-jdk, com um limite de container fixo via docker run --memory. Ele:

  • retém um live set controlado (30 MiB de byte[] que sobrevivem ao GC) — representa a memória que a app de fato precisa;
  • gera churn (lixo de vida curta) pra encher o eden;
  • tira dois retratos: BOOT (logo que sobe, antes de alocar) e POST (depois de reter o live set + churn);
  • força um System.gc() no fim e mede o live set pós-GC (old + survivor);
  • /sys/fs/cgroup/memory.current e memory.max de dentro do container pra reportar RSS e limite reais.

Rodei sete cenários, todos com o mesmo live set de 30 MiB, variando só as flags. Aqui está a tabela completa (valores em MiB):

cenáriolimitheap max (Xmx)committed BOOTRSS BOOTcommitted POSTRSS POSTheap used POSTnon-heap committedthreadslive set (pós-GC)
default76819227851921758221664
init-max-7576857657610457623921221664
init-max-75-pretouch76857657664557665421221664
low-init-max-7576857611810857639218721664
xmx-xms76851251211451223116021664
minram-small20010025671001227021664
minram-large768576219257637918821664
oom-xmx-acima-do-limit600exit 137

Vou destrinchar cada lição usando essa tabela.

Armadilha #1: InitialRAMPercentage comita, mas não é “uso”

Olhe a coluna committed BOOT (heap comitado logo no boot, antes de qualquer alocação):

  • default (Initial padrão ~1,5%): 27 MiB
  • low-init-max-75 (Initial=15%): 118 MiB
  • init-max-75 (Initial=75%): 576 MiB
  • xmx-xms (-Xms512m): 512 MiB

Ou seja: InitialRAMPercentage/-Xms controla quanto heap a JVM reserva e comita já no startup, independente de a app precisar. Com Initial=75, a JVM comita 576 MiB de heap antes da app fazer qualquer coisa útil.

Mas — e esse “mas” é o coração do problema — comitar não é tocar. Repare na coluna RSS BOOT: mesmo comitando 576 MiB de heap, o init-max-75 boota com RSS de só 104 MiB, praticamente igual ao default (85 MiB). O kernel só conta no RSS as páginas que foram realmente acessadas (page fault). Heap comitado e não tocado é endereço reservado, não memória física.

Aqui está o retrato de boot do cenário init-max-75, direto do log da PoC:

### BOOT (before allocating)
  container limit (cgroup) : 768 MiB
  container RSS   (cgroup) : 120 MiB
  heap max (effective Xmx) : 576 MiB
  heap used                : 10 MiB
  heap committed           : 576 MiB   <-- committed 75% of limit

Então por que em produção o RSS vivia colado em 75%?

Duas razões, e a PoC mostra as duas.

(a) AlwaysPreTouch — o RSS sobe imediatamente. Se a JVM sobe com -XX:+AlwaysPreTouch (comum em setups que fixam o heap pra ter latência previsível), ela toca todas as páginas comitadas no boot. Veja o cenário init-max-75-pretouch: RSS BOOT salta de 104 para 645 MiB. Aí sim o RSS reflete o committed, não a demanda. Era esse o mecanismo em dev: mesmo sem tráfego, o RSS já nascia alto no boot.

(b) Heap fixo + carga real — o RSS sobe gradualmente. Com Initial = Max, o heap nunca encolhe, e à medida que a app trabalha (alocação, evacuação de GC), as páginas vão sendo tocadas até o RSS encostar no comitado e ficar lá. Em produção, com tráfego contínuo por dias, é exatamente o que acontece: o RSS satura em ~75% e fica. Esse é o mecanismo dominante em ambientes com carga real e sem AlwaysPreTouch.

O resultado é o mesmo dos dois jeitos: working_set/container.memory.usage deixam de refletir a demanda real e passam a marcar ~75% do limite pra todo mundo. Dimensionar request por esse número é dimensionar pelo seu próprio MaxRAMPercentage, não pela necessidade da app.

Armadilha #2: o “uso real” é o live set pós-GC, e ele é invariante

Se o RSS mente, qual número não mente? O live set: o que sobra no heap depois de um GC, ou seja, os objetos que a app realmente segura.

A JVM divide o heap em gerações. A identidade é exata:

heap_used = eden + survivor + old
  • eden: onde objetos novos nascem. É churn — lixo de vida curta que o GC varre. Cresce e encolhe junto com o heap disponível.
  • survivor + old: o que sobreviveu ao GC. Esse é o live set — a memória que a app de fato retém.

A prova está na PoC. Em todos os sete cenários que rodaram até o fim, o live set pós-GC deu exatamente 64 MiB (última coluna), porque é sempre a mesma app retendo os mesmos blocos. (Retive 30 arrays de 1 MiB, mas cada um estoura uma região do G1 e arredonda pra duas → ~60 MiB de objetos humongous + classes retidas ≈ 64 MiB; o detalhe não importa, o que importa é que é constante.) A configuração de heap não muda o que a app precisa — só muda quanto de espaço sobra em volta.

Agora olhe o efeito colateral perverso na coluna heap used POST (pico de heap usado durante o churn):

  • default (heap de 192 MiB): pico de 82 MiB
  • init-max-75 (heap de 576 MiB): pico de 212 MiB

Mesma app, mesmo live set de 64 MiB, mas o pico de heap_used é 2,5x maior só porque o heap é maior. Por quê? Heap maior → o GC roda menos vezes → mais lixo flutuante (eden + objetos mortos ainda não coletados) se acumula entre as coletas. Esse é mais um motivo pelo qual olhar o pico de heap_used (ou o RSS, que segue o heap tocado) superestima a necessidade real. O número honesto é o vale pós-GC: old + survivor.

As métricas que sobrevivem à distorção, então, são:

  • jvm.gc.old_gen_size + jvm.gc.survivor_size → live set (heap retido);
  • jvm.non_heap_memory (Metaspace, Code Cache, Compressed Class) → fora do heap, cresce sob demanda;
  • jvm.buffer_pool.direct.used (DirectByteBuffer) e jvm.thread_count (≈ 1 MiB de pilha por thread) → memória nativa, off-heap, mas que conta no RSS.

E as que você deve parar de usar pra dimensionar enquanto Initial for alto: container.memory.usage, working_set e jvm.heap_memory_committed — todas infladas.

Armadilha #3: a que cobra a conta — OOMKill 137

Aqui está a parte que custou caro. De posse do “uso real”, a primeira tentativa foi a fórmula intuitiva:

request = limit = uso_real_live × 1.2

Pegava old + survivor + non_heap_used + direct + threads, multiplicava por 1,2 de folga, e cortava o limite. Parecia ótimo no dashboard: economia de ~50%.

Resultado em dev/qa: uma série de aplicações tomando OOMKill (exit 137).

A causa raiz tinha duas partes, ambas ignoradas pela fórmula ingênua:

  1. non_heap_committed, não non_heap_used. O Metaspace e o Code Cache reservam (committed) blocos um pouco acima do que usam e quase nunca devolvem. É o committed que conta no RSS, não o used. A diferença de um para outro é pouca, mas vale a pena por conservadorismo utilizar non_heap_committed.
  2. Overhead nativo invisível. Estruturas internas do GC e do JIT, page cache e, principalmente, agentes de APM/monitoramento (Datadog Agent, New Relic, AppDynamics, Elastic APM…) — nada disso aparece nas métricas jvm.*, mas tudo ocupa RSS. Reconciliando contra o RSS real nessa frota, esse resíduo nativo era de 66–254 MiB (média ~130 MiB). A constante de 150 MiB foi o valor que funcionou pra esse conjunto de serviços; o número certo pra sua frota depende do que está rodando dentro do container. O jeito de calibrar está descrito na seção da fórmula, mais abaixo.

Some os dois e dá pra ver por que limites cortados “na fórmula live × 1,2” batiam abaixo do piso físico da JVM e morriam.

A PoC reproduz o exit 137 de forma determinística no cenário oom-xmx-acima-do-limit: um container de 600 MiB com -Xms700m -Xmx700m -XX:+AlwaysPreTouch. O heap configurado (700 MiB) não cabe no container (600 MiB), e o AlwaysPreTouch tenta tocar tudo no boot:

>> scenario: oom-xmx-acima-do-limit  (--memory=600m)  flags: -Xms700m -Xmx700m -XX:+AlwaysPreTouch
   [!] container exited with code 137 (137 = OOMKill)

É o mesmo mecanismo, mais explícito: quando heap_max + non_heap_committed + nativo ultrapassa o limit, o kernel mata. A diferença é que em produção isso acontecia silenciosamente porque ninguém somava o non-heap e o nativo na conta.

A fórmula que sobrou

Depois dos 137, a fórmula de sizing virou esta (política request = limit, ou seja, QoS Guaranteed — a JVM tende a crescer até o teto, então não adianta deixar request < limit):

limit = live_heap_pico / occ
      + non_heap_committed
      + direct.used
      + thread_count × 1 MiB
      + N MiB   (resíduo nativo: calibrado por frota — veja abaixo)

Os termos, mapeados nas métricas que sobrevivem à distorção:

termométricapapel
live_heap_picomax(old_gen_size + survivor_size)heap que a app retém
occocupação-alvo do heap (live como fração do heap)
non_heap_committedmax(non_heap_memory_committed)Metaspace + Code Cache (reservado)
direct.usedmax(buffer_pool.direct.used)DirectByteBuffer (nativo)
thread_count × 1 MiBmax(thread_count)pilhas de thread (nativo; assume platform threads — com virtual threads o overhead de stack é negligenciável)
N MiBconstante calibradaoverhead nativo sem métrica direta

Como calibrar o resíduo nativo (N)

N não tem uma métrica JVM dedicada porque ele vive fora do heap e fora do non-heap gerenciado. Ele é, na prática, a diferença entre o RSS medido e tudo o que você consegue somar diretamente:

N  ≈  RSS_estável  −  heap_committed  −  non_heap_committed  −  direct.used  −  (threads × 1 MiB)

O RSS estável é o memory.current do cgroup (ou container_memory_usage_bytes no Prometheus) lido quando a app está aquecida e sob carga representativa, mas sem usar InitialRAMPercentage alto nem AlwaysPreTouch — senão o RSS reflete heap comitado e não tocado, e o N calculado fica inflado artificialmente. Use a configuração com InitialRAMPercentage baixo (ex.: 25%) para essa medição. (Nota: o kubelet usa working_set — que desconta page cache inativo — para decisões de eviction, mas o OOMKill do kernel é disparado quando memory.current atinge memory.max, ou seja, o memory.current é a métrica correta para calibrar o risco de OOM.)

Os principais componentes que compõem esse resíduo:

  • Agente de APM (Datadog Agent, New Relic, AppDynamics, Elastic APM…): o agente Java attacha como um -javaagent e aloca memória nativa própria — de 30 a 100+ MiB dependendo do agente e do nível de instrumentação ativo.
  • Exporter de métricas (Prometheus JMX Exporter, Micrometer…): menor impacto, mas não zero.
  • Thread stacks nativas além do 1 MiB nominal: a pilha real de cada thread (padrão -Xss1m no Linux) mais as estruturas de kernel associadas.
  • Overhead interno do GC: o G1GC mantém estruturas de card table, remembered sets e bitmaps de marcação que escalam com o tamanho do heap (tipicamente 1–5% do heap max).
  • Page cache e buffers de I/O do kernel: arquivos mapeados por mmap, buffers de rede — o kernel conta no RSS do processo.

Na frota que originou este post, N = 150 MiB cobriu bem a maioria dos serviços (range real: 66–254 MiB). Se você usa um agente de APM pesado ou tem muitos threads, meça e ajuste; 150 MiB é um ponto de partida, não uma constante universal. O pior caso é subestimar: você vai ver OOMKill. O segundo pior é superestimar muito: você desperdiça memória mas a app não morre.

Com as métricas coletadas da JVM, dá pra montar um dashboard que aplica essa fórmula automaticamente e exibe a recomendação de request/limit por serviço:

Dashboard com métricas JVM e recomendação de request/limit calculada

Por que dividir por occ?

O heap precisa ser maior que o live set pra caber a alocação de eden entre GCs, o espaço de trabalho de evacuação do G1 e picos. Regra prática: o live set não deve passar de ~70% do heap, senão o GC entra em thrashing (full GCs encadeados → CPU alta → OOM por GC overhead limit).

  • occ = 0,70 (agressivo): heap = live × 1,43. Economiza mais, GC mais frequente.
  • occ = 0,60 (recomendado): heap = live × 1,67. Mais folga, menos GC, um pouco mais de memória.

O trade-off central é memória ↔ CPU/segurança. Ficamos com 0,60 como padrão.

Duas formas equivalentes de aplicar

O heap-alvo (live/occ) é o mesmo; muda só como você escreve:

(A) Percentual:

-XX:InitialRAMPercentage=<P> -XX:MaxRAMPercentage=<P>
onde P = (live/occ) / limit × 100

O heap acompanha o limite e nunca passa dele — o MaxRAMPercentage te dá esse guardrail de graça.

(B) Explícito:

-Xms<live/occ> -Xmx<live/occ>

Direto, mas desacoplado do limite. Exige um guardrail no Helm/CI garantindo que -Xmx + non_heap + nativo ≤ limit, senão você cai no exit 137 do cenário da PoC.

Exemplo numérico. Suponha que a fórmula completa deu limit = 512 MiB e o live set pico é 120 MiB com occ = 0,60:

heap_alvo = 120 / 0,60 = 200 MiB
P = 200 / 512 × 100 ≈ 39%
→ -XX:InitialRAMPercentage=39 -XX:MaxRAMPercentage=39

(O limit é input da fórmula principal; o P é derivado depois de calcular o limit. Primeiro você dimensiona o container, depois calcula o percentual de heap que cabe nele.)

Nas apps reais de microserviços, esse P caiu pra faixa de 26–45% — bem longe dos 75% padronizados. Era esse o desperdício.

Armadilha #4: MaxRAMPercentage alto embute risco de OOM

Tem um detalhe estrutural perigoso. Como MaxRAMPercentage amarra o heap_max ao limite, em apps pequenas o piso da JVM já pode estourar o limite:

heap_max (75% de 768) = 576 MiB
+ non_heap_committed   = 227 MiB   (caso real)
= 803 MiB  >  limit de 768 MiB

E isso antes de contar o overhead nativo. Ou seja: com MaxRAMPercentage=75, uma app de non-heap gordo já nasce com o teto teórico acima do limite. Funciona enquanto o heap não enche — mas é uma bomba-relógio. A mitigação imediata em produção foi baixar Initial/Max de 75 → 65 nas apps mais apertadas, e depois aplicar a fórmula por serviço.

A pegadinha do MinRAMPercentage, provada

Voltando ao nome traiçoeiro. Compare os dois cenários da PoC, ambos com -XX:MinRAMPercentage=50 -XX:MaxRAMPercentage=75:

  • minram-small (container de 200 MiB): heap max = 100 MiB = 50% de 200 → quem governou foi o MinRAMPercentage.
  • minram-large (container de 768 MiB): heap max = 576 MiB = 75% de 768 → o MinRAMPercentage foi ignorado, governou o MaxRAMPercentage.

A regra: em containers com pouca memória (a PoC demonstra o corte com 200 MiB; o limiar exato varia com outros parâmetros da JVM), o MinRAMPercentage define o teto; acima disso, ele não faz nada. Na prática, para 99% dos containers de app, configurar MinRAMPercentage não tem efeito nenhum — e é fonte recorrente de confusão. Se você quer controlar o heap, o lever é o MaxRAMPercentage.

Checklist de boas práticas

O que ficou de aprendizado, resumido:

  1. Não dimensione memória de JVM por container.memory.usage/working_set se você usa InitialRAMPercentage alto ou AlwaysPreTouch. Esses números marcam ~MaxRAMPercentage do limite, não a demanda.
  2. Meça o uso real pelo live set pós-GC: old_gen_size + survivor_size. Eden é churn, não soma.
  3. Não esqueça do não-heap e do nativo. non_heap_committed + pilhas de thread + direct buffers + resíduo nativo (N). Foi ignorá-los que gerou os OOMKill 137. O N depende do que roda no container — calibre medindo RSS_estável − heap_committed − non_heap_committed − direct − threads×1MiB. 150 MiB funcionou nessa frota; a sua pode ser diferente.
  4. request = limit (Guaranteed) para JVM — ela cresce até o teto.
  5. MaxRAMPercentage vs -Xmx fixo: MaxRAMPercentage acompanha o limite e te dá guardrail. Se usar -Xmx, ponha um guardrail explícito no CI/Helm.
  6. MinRAMPercentage quase nunca é o que você quer. Só atua em containers com pouca memória (a PoC demonstra com 200 MiB).
  7. Cuidado com MaxRAMPercentage alto em apps pequenas: heap_max + non_heap_committed pode já passar do limite.

Apêndice: rodando a PoC

Tudo está em poc. Pré-requisito: Docker com cgroup v2 — roda na imagem eclipse-temurin:21-jdk).

git clone [email protected]:LucasBG0/poc-jvm-memory-containers.git
cd poc
./run.sh

O script builda a imagem, roda os sete cenários no mesmo limite de container e gera results.md (a tabela deste post) e um logs/<cenário>.log com os dois retratos de memória de cada execução. Os cenários cobrem:

  • default, init-max-75, init-max-75-pretouch, low-init-max-75 — o efeito do Initial/Max no committed, no RSS e no pico de heap;
  • xmx-xms — a forma explícita, equivalente ao percentual;
  • minram-small / minram-large — a pegadinha do MinRAMPercentage;
  • oom-xmx-acima-do-limit — o exit 137 determinístico.

Os números variam um pouco entre execuções (o RSS é instantâneo e oscila com o GC), mas os sinais determinísticos — heap comitado no boot, teto de heap, RSS com AlwaysPreTouch, live set pós-GC — são estáveis e contam a história toda.