Späť na blog

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:

KomponentPercento Container Limitu
Heap50-60%
Direct Memory10-15%
Thread Stacks10-15%
Metaspace, Code Cache, GC15-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

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Java OOMKilled So Stabilným Heapom: Native Memory, Direct Buffers a glibc Arenas". https://www.michal-drozd.com/sk/blog/java-native-memory-oomkilled/ (Publikované 20. januára 2025).