JVM Metaspace OOM v Kubernetes: Prečo MaxMetaspaceSize Nestačí
Jedinny OOM, ktory nas stale zabijal, bol metaspace, nie heap. “Nastavil som -XX:MaxMetaspaceSize=256m ale pod je stále OOMKilled.” Príčina: Metaspace je off-heap pamäť ktorá rastie s načítanými triedami, váš container limit nemá dostatočnú rezervu, a JVM neuvoľňuje nepoužívané triedy.
Prostredie: JVM 11+, Kubernetes s memory limits, Spring Boot/microservices, dynamické načítavanie tried (reflection, proxies, Groovy)
Problém
Záhadné OOM
Životný cyklus podu:
T+0:00 Pod štartuje, spotreba pamäte: 400MB
Heap: 256MB, Metaspace: 80MB, Ostatné: 64MB
T+1:00 Traffic narastá, viac code paths sa vykonáva
Metaspace: 120MB (načítava viac tried)
T+4:00 Metaspace: 200MB (proxies, reflection, lambdy)
T+8:00 Metaspace: 280MB → MaxMetaspaceSize dosiahnutý!
java.lang.OutOfMemoryError: Metaspace
Alebo horšie: Container limit dosiahnutý prvý
OOMKilled (exit code 137) - žiadna Java chyba!
Problém s Matematikou Containera
# Vaša Kubernetes konfigurácia
resources:
limits:
memory: "512Mi"
requests:
memory: "512Mi"
# Vaše JVM flagy
JAVA_OPTS: "-Xmx256m -XX:MaxMetaspaceSize=256m"
# Čo si myslíte:
# Heap (256MB) + Metaspace (256MB) = 512MB ✓
# Čo sa skutočne deje:
# Heap: 256MB
# Metaspace: až 256MB
# Code cache: 48MB (default)
# Thread stacks: 1MB × N threadov
# Direct buffers: závisí
# JNI/native: závisí
# GC overhead: závisí
# Spolu: 256 + 256 + 48 + 50 + X = 610MB+ → OOMKilled!
Príčina
Čo Je v Metaspace
Obsah Metaspace:
┌─────────────────────────────────────────────────────────────┐
│ Metaspace │
│ ├─ Metadáta tried (Klass štruktúry) │
│ ├─ Metadáta metód (bytecode, JIT compiled code refs) │
│ ├─ Constant pools │
│ ├─ Anotácie │
│ ├─ Method counters a profiling dáta │
│ └─ Statické premenné (presunuté z PermGen v Java 8) │
│ │
│ Čo spôsobuje RAST: │
│ - Spring proxies (CGLib/ByteBuddy) │
│ - Hibernate entity proxies │
│ - Lambda výrazy (každá je trieda!) │
│ - Reflection-heavy frameworky │
│ - Groovy/scripting engines │
│ - Dynamické generovanie tried │
│ - ClassLoader leaky │
└─────────────────────────────────────────────────────────────┘
Prečo Sa Triedy Neuvoľňujú
// Triedy môžu byť uvoľnené len keď je ich ClassLoader nedosiahnuteľný
// Toto sa v aplikačných serveroch stáva zriedka
// Spring Boot: Jeden aplikačný ClassLoader
// → Triedy načítané raz, nikdy neuvoľnené
// Bežný ClassLoader leak pattern:
public class CacheWithClassRef {
// Táto cache drží Class referencie
private static Map<String, Class<?>> classCache = new HashMap<>();
// Triedy v cache NIKDY nemôžu byť uvoľnené
// Aj keď odstránite záznamy, ak niečo referencuje Class...
}
// ThreadLocal držiaci inštanciu triedy:
private static ThreadLocal<MyService> service = new ThreadLocal<>();
// Ak thread pool znovupoužíva thready, triedy zostávajú načítané
Diagnostika
Kontrola Spotreby Metaspace
# Získanie Metaspace metrík z JVM
kubectl exec pod-name -- jcmd 1 VM.metaspace
# Výstup:
# Total: reserved=280MB, committed=260MB, used=245MB
# Class: reserved=256MB, committed=240MB, used=230MB
# ├─ Classloader: 1234 tried, 180MB použitých
# ├─ Classloader: 567 tried, 45MB použitých
# └─ ...
# Počet načítaných tried
kubectl exec pod-name -- jcmd 1 VM.classloaders
# Alebo cez JMX
kubectl exec pod-name -- jcmd 1 GC.class_stats
# Zoznam všetkých načítaných tried s veľkosťami
kubectl exec pod-name -- jcmd 1 VM.class_hierarchy
Monitoring Načítavania Tried
// JVM flagy pre viditeľnosť načítavania tried
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
-Xlog:class+load=info
-Xlog:class+unload=info
// V logoch uvidíte:
// [class,load] com.example.Proxy$123 source: __JVM_DefineClass__
// Veľa dynamicky generovaných tried = rast Metaspace
Nájdenie ClassLoader Leakov
# Heap dump a analýza ClassLoaderov
kubectl exec pod-name -- jcmd 1 GC.heap_dump /tmp/heap.hprof
kubectl cp pod-name:/tmp/heap.hprof ./heap.hprof
# V Eclipse MAT alebo VisualVM:
# 1. Nájdite všetky ClassLoader inštancie
# 2. Skontrolujte retained size každého
# 3. Hľadajte duplikátne triedy načítané rôznymi loadermi
Riešenie
Možnosť 1: Správne Dimenzovanie Pamäte Containera
# Započítajte VŠETKY JVM pamäťové regióny
resources:
limits:
memory: "768Mi" # Dajte viac rezervy
requests:
memory: "768Mi"
# JVM flagy s explicitnými limitmi
env:
- name: JAVA_OPTS
value: >-
-Xmx256m
-XX:MaxMetaspaceSize=128m
-XX:ReservedCodeCacheSize=64m
-XX:MaxDirectMemorySize=64m
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=50.0
# Rozpočet pamäte:
# Heap: 256MB (Xmx)
# Metaspace: 128MB (MaxMetaspaceSize)
# Code cache: 64MB (ReservedCodeCacheSize)
# Direct memory: 64MB (MaxDirectMemorySize)
# Thread stacks: ~100MB (100 threadov × 1MB)
# JVM overhead: ~50MB
# Spolu: ~662MB s rezervou do 768MB
Možnosť 2: Povolenie Uvoľňovania Tried
# JVM flagy pre podporu uvoľňovania tried
-XX:+CMSClassUnloadingEnabled # Pre CMS (deprecated)
-XX:+ClassUnloading # Pre G1/ZGC (default on)
-XX:+ClassUnloadingWithConcurrentMark # Pre G1
# Agresívnejšie Metaspace GC
-XX:MinMetaspaceFreeRatio=10 # Default 40
-XX:MaxMetaspaceFreeRatio=30 # Default 70
# Vynútenie zmenšenia Metaspace po uvoľnení
-XX:+ShrinkHeapInSteps
Možnosť 3: Zníženie Dynamického Generovania Tried
// Spring: Preferujte interfaces pred class proxying
@Service
public class MyServiceImpl implements MyService {
// Interface proxy (JDK dynamic proxy) vs
// Class proxy (CGLib - generuje viac tried)
}
// Spring Boot konfigurácia
spring.aop.proxy-target-class=false # Používajte JDK proxies
// Hibernate: Znížte generovanie proxy
spring.jpa.properties.hibernate.bytecode.use_reflection_optimizer=false
spring.jpa.properties.hibernate.bytecode.provider=none
// Lambda optimalizácia - znovupoužitie namiesto inline
// ZLE: Každá lambda je nová trieda
list.forEach(item -> process(item));
// LEPŠIE: Method reference (môže zdieľať triedu)
list.forEach(this::process);
// NAJLEPŠIE pre hot paths: Znovupoužitie functional interface inštancie
private final Consumer<Item> processor = this::process;
list.forEach(processor);
Možnosť 4: ClassLoader Izolácia
// Pre plugin systémy alebo scripting engines
// Použite samostatné ClassLoadery ktoré môžu byť garbage collected
public class PluginLoader {
public void loadAndExecute(String pluginJar) {
// URLClassLoader môže byť zatvorený a GC'd
try (URLClassLoader loader = new URLClassLoader(
new URL[]{new URL("file:" + pluginJar)},
null // Bez parenta - izoluje triedy
)) {
Class<?> plugin = loader.loadClass("com.plugin.Main");
plugin.getMethod("run").invoke(plugin.getDeclaredConstructor().newInstance());
}
// Po try bloku je loader zatvorený
// Triedy môžu byť uvoľnené pri ďalšom GC
}
}
Možnosť 5: Native Memory Tracking
# Povolenie NMT pre zobrazenie všetkých pamäťových regiónov
-XX:NativeMemoryTracking=summary # alebo detail
# Získanie rozpisu pamäte
kubectl exec pod-name -- jcmd 1 VM.native_memory summary
# Výstup zobrazuje:
# Total: reserved=1500MB, committed=800MB
# - Java Heap: 256MB
# - Class (Metaspace): 180MB
# - Thread: 120MB (120 threadov)
# - Code: 50MB
# - GC: 30MB
# - Internal: 20MB
# - Symbol: 15MB
# ...
Monitoring
groups:
- name: jvm-metaspace
rules:
- alert: MetaspaceHigh
expr: |
jvm_memory_used_bytes{area="nonheap", id="Metaspace"} /
jvm_memory_max_bytes{area="nonheap", id="Metaspace"} > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "Spotreba Metaspace nad 85%"
- alert: ClassLoadingRate
expr: |
rate(jvm_classes_loaded_classes_total[5m]) > 100
for: 30m
labels:
severity: warning
annotations:
summary: "Vysoká rýchlosť načítavania tried - možný leak"
- alert: ContainerOOMRisk
expr: |
container_memory_working_set_bytes /
container_spec_memory_limit_bytes > 0.9
for: 5m
labels:
severity: critical
annotations:
summary: "Container blízko memory limitu - riziko OOMKill"
Checklist
## JVM Metaspace OOM Kubernetes
### Diagnostika
- [ ] Skontrolujte spotrebu Metaspace: jcmd 1 VM.metaspace
- [ ] Spočítajte načítané triedy: jcmd 1 VM.classloaders
- [ ] Povoľte NMT: -XX:NativeMemoryTracking=summary
- [ ] Sledujte trendy načítavania tried v čase
### Dimenzovanie Containera
- [ ] Vypočítajte celkovú JVM pamäť (heap + metaspace + codecache + threads + overhead)
- [ ] Nastavte container limit 20-30% nad JVM total
- [ ] Explicitne nastavte MaxMetaspaceSize
- [ ] Explicitne nastavte ReservedCodeCacheSize
### Zníženie Metaspace
- [ ] Preferujte JDK proxies pred CGLib
- [ ] Znovupoužívajte lambda inštancie v hot paths
- [ ] Skontrolujte ClassLoader leaky v heap dumpe
- [ ] Zatvárajte/dispose ClassLoadery keď sú hotové
Záver
Poučenie: MaxMetaspaceSize limituje Metaspace ale nezabraňuje container OOM. Musíte započítať VŠETKY JVM pamäťové regióny do vášho container limitu a aktívne spravovať načítavanie tried.
Kľúčové princípy:
- Container limit > Heap + Metaspace + CodeCache + Threads + Overhead
- Triedy sa zriedka uvoľňujú - raz načítané, zostávajú
- Dynamické proxies a lambdy vytvárajú triedy - každá stojí Metaspace
- Používajte NMT pre pochopenie skutočnej spotreby pamäte
Súvisiace Články
- Java Native Memory OOMKilled - Off-heap memory problémy
- Kubernetes OOM Killer Memory Limits - Container memory management
Súvisiace články
Java OOMKilled So Stabilným Heapom: Native Memory, Direct Buffers a glibc Arenas
Heap metriky vyzerajú dobre, GC je spokojný, ale kontajner stále umiera. Vinník: native memory z direct buffers, JNI a glibc memory allocator fragmentácia.
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.
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.
Redis Cluster Migrácia Slotov: Dočasná Explózia Pamäte
Redis nody OOMKilled počas rebalancingu clustra. Príčina: migrácia slotov kopíruje kľúče do cieľa pred zmazaním zo zdroja, dočasne zdvojnásobuje využitie pamäte.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.