Como dimensionar memória Java no Kubernetes (MaxRAMPercentage e OOMKill)
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:
- O RSS de todas as aplicações vivia colado em ~75% do limite — inclusive
em ambientes de
devque mal recebiam tráfego. Não dava pra saber quem estava desperdiçando. - 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); - lê
/sys/fs/cgroup/memory.currentememory.maxde 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ário | limit | heap max (Xmx) | committed BOOT | RSS BOOT | committed POST | RSS POST | heap used POST | non-heap committed | threads | live set (pós-GC) |
|---|---|---|---|---|---|---|---|---|---|---|
default | 768 | 192 | 27 | 85 | 192 | 175 | 82 | 21 | 6 | 64 |
init-max-75 | 768 | 576 | 576 | 104 | 576 | 239 | 212 | 21 | 6 | 64 |
init-max-75-pretouch | 768 | 576 | 576 | 645 | 576 | 654 | 212 | 21 | 6 | 64 |
low-init-max-75 | 768 | 576 | 118 | 108 | 576 | 392 | 187 | 21 | 6 | 64 |
xmx-xms | 768 | 512 | 512 | 114 | 512 | 231 | 160 | 21 | 6 | 64 |
minram-small | 200 | 100 | 25 | 67 | 100 | 122 | 70 | 21 | 6 | 64 |
minram-large | 768 | 576 | 21 | 92 | 576 | 379 | 188 | 21 | 6 | 64 |
oom-xmx-acima-do-limit | 600 | — | — | — | — | exit 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 MiBlow-init-max-75(Initial=15%): 118 MiBinit-max-75(Initial=75%): 576 MiBxmx-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 limitEntã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 MiBinit-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) ejvm.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.2Pegava 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:
non_heap_committed, nãonon_heap_used. O Metaspace e o Code Cache reservam (committed) blocos um pouco acima do que usam e quase nunca devolvem. É ocommittedque conta no RSS, não oused. A diferença de um para outro é pouca, mas vale a pena por conservadorismo utilizarnon_heap_committed.- 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:
| termo | métrica | papel |
|---|---|---|
live_heap_pico | max(old_gen_size + survivor_size) | heap que a app retém |
occ | — | ocupação-alvo do heap (live como fração do heap) |
non_heap_committed | max(non_heap_memory_committed) | Metaspace + Code Cache (reservado) |
direct.used | max(buffer_pool.direct.used) | DirectByteBuffer (nativo) |
thread_count × 1 MiB | max(thread_count) | pilhas de thread (nativo; assume platform threads — com virtual threads o overhead de stack é negligenciável) |
N MiB | constante calibrada | overhead 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
-javaagente 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 MiBnominal: a pilha real de cada thread (padrão-Xss1mno 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:

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 × 100O 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 MiBE 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 oMinRAMPercentage.minram-large(container de 768 MiB): heap max = 576 MiB = 75% de 768 → oMinRAMPercentagefoi ignorado, governou oMaxRAMPercentage.
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:
- Não dimensione memória de JVM por
container.memory.usage/working_setse você usaInitialRAMPercentagealto ouAlwaysPreTouch. Esses números marcam ~MaxRAMPercentagedo limite, não a demanda. - Meça o uso real pelo live set pós-GC:
old_gen_size + survivor_size. Eden é churn, não soma. - 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. ONdepende do que roda no container — calibre medindoRSS_estável − heap_committed − non_heap_committed − direct − threads×1MiB. 150 MiB funcionou nessa frota; a sua pode ser diferente. request = limit(Guaranteed) para JVM — ela cresce até o teto.MaxRAMPercentagevs-Xmxfixo:MaxRAMPercentageacompanha o limite e te dá guardrail. Se usar-Xmx, ponha um guardrail explícito no CI/Helm.MinRAMPercentagequase nunca é o que você quer. Só atua em containers com pouca memória (a PoC demonstra com 200 MiB).- Cuidado com
MaxRAMPercentagealto em apps pequenas:heap_max + non_heap_committedpode 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.shO 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 doInitial/Maxno committed, no RSS e no pico de heap;xmx-xms— a forma explícita, equivalente ao percentual;minram-small/minram-large— a pegadinha doMinRAMPercentage;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.