Java OOMKilled So Stabilným Heapom: Native Memory, Direct Buffers a glibc Arenas
Heap bol v pohode a pod aj tak zomrel. “Heap je stabilný na 2GB, kontajner limit je 4GB, ale stále dostávame OOMKilled.” Toto bol tiket, ktorý mi pristál na stole od nášho payments tímu. Urobili všetko správne podľa pravidiel—nastavili -Xmx dobre pod container limit, monitorovali GC pauzy, držali heap usage pod kontrolou. Napriek tomu ich servis stále umieral každých pár hodín, vždy s OOM od kernela, nikdy od JVM.
Chýbajúca pamäť sa ukázala byť tichým vrahom mnohých Java aplikácií v kontajneroch: native memory. Direct buffers z Netty, thread stacks z unbounded executora, Metaspace rast z class loading a tendencia glibc držať uvoľnenú pamäť spoločne konzumovali extra 2GB, ktoré sa nikdy neobjavili v žiadnej heap metrike.
Tento problém je obzvlášť frustrujúci pretože Java developeri sú trénovaní sledovať heap. Nastavíme -Xmx, monitorujeme garbage collection, profilujeme s VisualVM alebo JFR. Žiadny z týchto nástrojov prominentne neukazuje native memory konzumpciu. Môžete mať perfektne vyladený heap zatiaľ čo váš proces ticho narastá smerom k container limitu.
Prostredie: Java 17, Spring Boot, Kubernetes so 4GB container limit, Netty-based HTTP klient
Problém
Heap Vyzerá Perfektne
Symptómy sú šialené. Každá metrika, ktorú viete skontrolovať, vyzerá dobre:
# JVM metriky hovoria že všetko je v poriadku
Heap: 2GB / 2.5GB (-Xmx2560m)
GC: G1, žiadne full GCs, pause times <50ms
Metaspace: 150MB (stabilné)
# Ale kontajner stále umiera
kubectl describe pod my-app
# Last State: OOMKilled
# dmesg na node ukazuje:
# memory cgroup out of memory: Killed process 12345 (java)
JVM je spokojné. GC je spokojné. Heap usage je v limitoch. Napriek tomu Linux stále zabíja váš proces. Toto je vaša prvá stopka že problém s pamäťou nie je v heape vôbec.
Kde Je Pamäť?
Rozložím vám kam pamäť skutočne ide v typickej Java aplikácii:
Container memory budget (4GB):
Čo si nakonfiguroval:
├── -Xmx2560m (Heap max) = 2.5 GB
└── Očakávaná rezerva = 1.5 GB (vyzerá dosť!)
Čo je skutočne použité:
├── Heap (aktuálne) = 2.2 GB
├── Metaspace = 150 MB
├── Thread stacks (500 threads × 1MB) = 500 MB
├── Direct buffers = 800 MB ← Skryté!
├── JIT code cache = 240 MB
├── Native memory (JNI/Unsafe) = 200 MB ← Skryté!
└── glibc arena overhead = 400 MB ← Skryté!
─────────
Celkom: = 4.5 GB
Container limit: 4 GB → OOMKilled!
“Skryté” položky sú vrahovia. Neobjavujú sa v štandardných heap metrikách, napriek tomu konzumujú skutočnú pamäť, ktorá sa počíta do vášho cgroup limitu. Keď súčet prekročí alokáciu vášho kontajnera, Linux OOM killer ukončí váš proces—a JVM to nikdy nevidí prichádzať pretože nikdy nebolo blízko OutOfMemoryError z Java perspektívy.
Pochopenie Native Memory
Pred tým ako sa pustíme do riešení, pochopme každý komponent native memory konzumpcie.
Direct Buffers (Off-Heap)
Direct buffers sú Java mechanizmus na alokáciu pamäte mimo garbage-collected heap. Sú esenciálne pre high-performance I/O pretože sa vyhnú kopírovaniu dát medzi Java heap a native memory pri komunikácii s operačným systémom.
// Netty, gRPC a veľa knižníc používa direct buffers
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
// Tieto NIE SÚ počítané do heapu!
// Default limit: rovnaký ako -Xmx, ale aditívny
// Skontroluj aktuálne využitie direct bufferov:
// Cez JMX: java.nio:type=BufferPool,name=direct
// Bežné zdroje:
// - Netty PooledByteBufAllocator
// - gRPC message buffers
// - NIO file operations
// - Compression knižnice
Problém je že veľa knižníc používa direct buffers agresívne. Netty, networking knižnica pod Spring WebFlux, gRPC a mnohými ďalšími frameworkmi, pooluje direct buffers pre performance. Zaneprázdnený Netty server môže držať stovky megabajtov direct buffers aj keď je nečinný, čakajúc na znovupoužitie.
Defaultne JVM povoľuje direct buffer alokáciu do -Xmx. Takže s -Xmx2560m by ste teoreticky mohli alokovať ďalších 2.5GB direct buffers—ďaleko za tým čo váš kontajner zvládne. Na rozdiel od heap memory, direct buffer limity sa automaticky neupravujú pre kontajnery.
glibc Memory Arenas
Toto je možno najzákernejší zdroj memory bloatu a nie je to ani chyba Javy. Keď vaše JVM alokuje native memory (cez JNI, Unsafe alebo dokonca interné operácie), používa systémový C library allocator—typicky glibc na Linuxe.
# glibc vytvára arenas pre multi-threaded apps
# Každá arena môže držať uvoľnenú pamäť
# Default arenas = 8 × CPU cores
# S 8 jadier = 64 arenas
# Každá arena môže držať megabajty uvoľnenej pamäte
# Pamäť vyzerá "uvoľnená" pre Javu
# Ale glibc ju nevrátilo OS
# RSS stále rastie!
# Skontroluj arena nastavenia:
cat /proc/$(pgrep java)/environ | tr '\0' '\n' | grep MALLOC
# Často nenastavené, používa defaulty
glibc memory allocator vytvára oddelené “arenas” pre rôzne thready na redukciu lock contention. Toto je skvelé pre performance, ale každá arena udržiava vlastný free list. Keď je pamäť uvoľnená, často zostáva v arene namiesto toho aby bola vrátená operačnému systému. S mnohými threadmi (bežné v Java aplikáciách) môžete mať desiatky arén, každá drží megabajty “uvoľnenej” pamäte.
Videl som prípady kde glibc arena fragmentácia tvorila 30-40% RSS procesu. Pamäť bola technicky uvoľnená z Java perspektívy, ale glibc ju stále držalo a Linux ju počítal do cgroup limitu.
Thread Stack Akumulácia
Každý Java thread vyžaduje vlastný stack pre method call frames, lokálne premenné a iný execution kontext. Defaultná veľkosť stacku je typicky 1MB na thread.
// Každý thread používa ~1MB stack defaultne
// 500 threads = 500MB mimo heap
// Spočítaj thready:
jcmd <pid> Thread.print | grep "^\"" | wc -l
// Bežné príčiny thread explózie:
// - Blocking I/O bez správnych poolov
// - Unbounded executor services
// - One-thread-per-request vzory
Servis s 500 threadmi konzumuje 500MB len pre stacky—pol gigabajtu, ktoré sa nikdy neobjaví v heap metrikách. Počet threadov môže postupne rásť: pomalá závislosť spôsobuje radenie requestov, každý čakajúci request drží thread a než sa nazdáte máte thread explóziu, ktorá konzumuje všetku dostupnú pamäť.
Ďalší Native Memory Konzumenti
Okrem veľkej trojice niekoľko ďalších zdrojov konzumuje native memory:
Metaspace: Uchováva class metadata. Zvyčajne stabilné, ale môže rásť s dynamickým class loadingom (napr. veľa Groovy skriptov, ťažká reflection).
Code Cache: JIT-kompilovaný kód žije tu. Typicky 240MB s defaultnými nastaveniami, ale môže rásť s veľkými aplikáciami.
GC Data Structures: Garbage collector potrebuje vlastnú pamäť na sledovanie objektov. G1 používa asi 5-10% veľkosti heap pre metadata.
Internal JVM Structures: Symbol tables, string intern pools a iné JVM internals.
Diagnostika
Krok 1: Native Memory Tracking
Java vstavaný Native Memory Tracking (NMT) je najhodnotnejší nástroj pre pochopenie kam pamäť ide:
# Zapni NMT (vyžaduje JVM reštart)
java -XX:NativeMemoryTracking=summary -jar app.jar
# Získaj report:
jcmd <pid> VM.native_memory summary
# Výstup ukazuje:
# Total: reserved=5GB, committed=4.2GB
#
# - Java Heap: 2560MB
# - Thread: 524MB (500 threads)
# - Code: 245MB
# - GC: 180MB
# - Internal: 156MB
# - Symbol: 32MB
# - Native Memory Tracking: 12MB
# - Arena Chunk: 1MB
# - Direct Buffer: 820MB ← Tu to je!
NMT report rozkladá pamäť podľa kategórií, uľahčujúc identifikáciu ktorá oblasť konzumuje neočakávané množstvá. Poznámka: NMT samotný má asi 5-10% overhead, takže ho nebeží v produkcii permanentne—zapnite ho pri debugovaní memory problémov.
Môžete tiež porovnávať pamäť v čase:
# Vytvor baseline
jcmd <pid> VM.native_memory baseline
# Neskôr porovnaj s baseline
jcmd <pid> VM.native_memory summary.diff
Krok 2: Skontroluj Direct Buffers
Direct buffer usage sa dá dotazovať programaticky cez JMX:
// Cez JMX programaticky
import java.lang.management.ManagementFactory;
import java.lang.management.BufferPoolMXBean;
for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
System.out.println(pool.getName() + ": " +
pool.getMemoryUsed() / 1024 / 1024 + "MB used, " +
pool.getCount() + " buffers");
}
// Výstup:
// direct: 820MB used, 12543 buffers
// mapped: 0MB used, 0 buffers
Ak vidíte stovky megabajtov v direct bufferoch, to je pravdepodobne významný prispievateľ k vášmu memory problému. Počet bufferov môže tiež odhaliť leaky—ak akumulujete tisíce malých bufferov, niečo ich možno neuvoľňuje správne.
Krok 3: Monitoruj RSS vs Heap
Vzťah medzi RSS (skutočné využitie pamäte) a heapom vám povie či máte off-heap problém:
# Porovnaj container RSS s heapom
# RSS = skutočná pamäť použitá procesom
# Získaj RSS (v KB):
cat /proc/$(pgrep java)/status | grep VmRSS
# Získaj heap cez jstat:
jstat -gc <pid>
# Ak RSS >> Heap, máš off-heap konzumpciu
Ak váš heap je 2GB ale RSS je 3.5GB, ten 1.5GB rozdiel je native memory. Sledujte tento pomer v čase—rastúci rozdiel indikuje native memory leak.
Riešenie
Možnosť 1: Limituj Direct Memory
Najokamžitejšia oprava je explicitne ohraničiť direct buffer alokáciu:
# Explicitne limituj direct buffer alokáciu
java -XX:MaxDirectMemorySize=256m -Xmx2560m -jar app.jar
# Celkom = Heap + DirectMemory + Metaspace + Threads + Overhead
# 2560m + 256m + 256m + 500m + 500m = ~4GB
Toto zabraňuje nekontrolovanej direct buffer alokácii. Ak sa vaša aplikácia pokúsi alokovať viac, dostane OutOfMemoryError s jasnou správou o direct bufferoch—oveľa lepšie ako mysteriózny OOMKill.
Vyberte limit podľa potrieb vašej aplikácie. Network-heavy servisy (veľa súbežných spojení, veľké payloady) potrebujú viac. CPU-bound servisy s minimálnym I/O môžu použiť menej.
Možnosť 2: Skroť glibc Arenas
Redukcia počtu glibc arén dramaticky znižuje memory fragmentáciu:
# Kubernetes deployment
env:
- name: MALLOC_ARENA_MAX
value: "2" # Limituj na 2 arenas namiesto 8×cores
# Alebo použi jemalloc/tcmalloc namiesto
- name: LD_PRELOAD
value: "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"
Nastavenie MALLOC_ARENA_MAX=2 je často najvplyvnejšia zmena pre redukciu native memory overhead. Môže mierne zvýšiť lock contention pre memory alokáciu, ale v praxi je dopad na throughput zanedbateľný pre väčšinu aplikácií.
Alternatívne použite jemalloc alebo tcmalloc namiesto glibc allocatora. Tieto allocatory sú navrhnuté pre multi-threaded aplikácie a majú lepšie správanie pri vracaní pamäte. Veľa organizácií používa jemalloc ako štandard pre JVM kontajnery.
Možnosť 3: Použi Container-Aware JVM Nastavenia
Moderné JVM (Java 10+) sú container-aware, ale mali by ste to overiť a správne nakonfigurovať:
# Java 17+ je container-aware defaultne
# Ale over že limity sú detekované:
java -XX:+PrintFlagsFinal -version | grep -E "(MaxHeapSize|MaxRAM)"
# Ak bežíš v kontajneri:
java -XX:MaxRAMPercentage=75 -jar app.jar
# Použije 75% container memory pre heap
# Nechá 25% pre off-heap
Používanie MaxRAMPercentage namiesto fixných -Xmx hodnôt sa adaptuje na zmeny veľkosti kontajnera. Keď zmeníte veľkosť kontajnera, heap sa automaticky upraví.
Odporúčanie 75% necháva miesto pre všetku non-heap memory, ktorú sme diskutovali. Niektoré organizácie používajú 60% pre Netty-heavy aplikácie, ktoré potrebujú významný priestor pre direct buffers.
Možnosť 4: Monitoruj a Alertuj
Nastavte monitoring na zachytenie memory divergencie pred tým ako spôsobí OOMKills:
# Prometheus alert pre memory divergenciu
groups:
- name: java-memory
rules:
- alert: JavaNativeMemoryLeak
expr: |
(container_memory_working_set_bytes{container="my-app"} -
jvm_memory_used_bytes{area="heap"}) > 1500000000
for: 30m
labels:
severity: warning
annotations:
summary: "Off-heap memory rastie: {{ $value | humanize }}"
- alert: DirectBufferHigh
expr: |
jvm_buffer_memory_used_bytes{id="direct"} > 500000000
for: 15m
labels:
severity: warning
annotations:
summary: "Direct buffer usage > 500MB"
Prvý alert zachytáva akýkoľvek významný rozdiel medzi container memory a heapom—znak native memory konzumpcie. Druhý špecificky sleduje direct buffer usage.
Možnosť 5: Konfiguruj Netty Buffer Pools
Ak Netty je významný konzument memory, vylaďte jeho allocator:
// Spring Boot application.properties
spring.netty.leak-detection=paranoid
// Limituj Netty pooled allocator
System.setProperty("io.netty.allocator.maxOrder", "9"); // 2MB chunks vs 16MB default
// Alebo použi unpooled allocator (pomalší ale bez fragmentácie)
System.setProperty("io.netty.allocator.type", "unpooled");
Nastavenie maxOrder kontroluje veľkosť chunks v Netty pooled allocatore. Menšie chunks znamenajú menej plytvania pamäťou ale mierne viac allocation overhead. Pre memory-constrained prostredia je tento trade-off zvyčajne hodný.
Povolenie leak detection (paranoid mode) pomáha identifikovať buffer leaky vo vývoji. Nebeží to v produkcii—má významný overhead—ale je to neoceniteľné pre nájdenie zdroja buffer akumulácie.
Checklist
## Java Native Memory OOM
### Symptómy
- [ ] Container OOMKilled ale heap vyzerá stabilne
- [ ] RSS >> heap veľkosť
- [ ] GC metriky vyzerajú zdravé
- [ ] Stáva sa postupne časom
### Diagnostika
- [ ] Zapni NMT: -XX:NativeMemoryTracking=summary
- [ ] Skontroluj direct buffer usage cez JMX
- [ ] Spočítaj thready: jcmd <pid> Thread.print
- [ ] Porovnaj RSS vs heap
### Riešenia
- [ ] Nastav -XX:MaxDirectMemorySize
- [ ] Nastav MALLOC_ARENA_MAX=2
- [ ] Použi -XX:MaxRAMPercentage pre heap sizing
- [ ] Limituj Netty buffer pool veľkosti
- [ ] Monitoruj off-heap vs container limit
Záver
Lekcia po rokoch debugovania týchto problémov: Java pamäť ≠ heap. Direct buffers, thread stacks, Metaspace, JIT code cache a native allocator overhead môžu ľahko konzumovať 50%+ pamäte kontajnera bez zobrazenia v akejkoľvek heap metrike.
Tu je moje pravidlo pre sizing Java kontajnerov:
| Komponent | Percento Container Limitu |
|---|---|
| Heap | 50-60% |
| Direct Memory | 10-15% |
| Thread Stacks | 10-15% |
| Metaspace, Code Cache, GC | 15-20% |
S 4GB kontajnerom to znamená:
- Heap: 2-2.4GB
- Direct: 400-600MB
- Threads: 400-600MB
- Ostatné: 600-800MB
Zapnite NMT vo vašej ďalšej debugging session a pozrite sa kam pamäť skutočne ide. Čísla vás možno prekvapia—a určite vám pomôžu nastaviť presnejšie container limity.
Súvisiace články
- Kubernetes OOM Killer Memory Limity - Container memory accounting
- Java Profiling v Hardened K8s - Debugging v produkcii
Súvisiace články
JVM Metaspace OOM v Kubernetes: Prečo MaxMetaspaceSize Nestačí
Pod OOMKilled napriek nastavenému MaxMetaspaceSize. Príčina: Metaspace rastie mimo heap, container memory limit nepočíta s tým, a triedy sa neuvoľňujú.
JVM Native Memory v Kubernetes: Prečo Pod Dostane OOMKilled s 50% Heap
Heap je 50% plný ale pod dostane OOMKilled. Ukážem ako sledovať native memory (Metaspace, threads, NIO) a zabrániť container memory problémom.
Kubernetes OOM Killer: Prečo Kontajner Zomiera pri 50% Pamäte
Kontajner má 4GB memory limit ale OOM kill pri 2GB used. Kernel buffers, page cache a cgroup accounting triky spôsobujú skoré OOMKills. Tu je celý obraz.
RSS Contracts: Ako prestat zabijat Java pody v Kubernetes (OOMKilled) testovanim RSS ako API
Cgroup RSS budgety, CI sampling a runtime headroom ti chytia JVM memory regresie skor, nez trafia produkciu.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.