Spring Boot Memory & GC Tuning: From OOM to Low-Latency Production Practices
This guide covers JVM memory structure, garbage collector selection (G1, ZGC, Parallel), GC log analysis, OOM diagnosis techniques, and practical parameter templates for Spring Boot services to resolve memory leaks, reduce GC pauses, and optimize production performance.
1. Three Real Incidents: Four Faces of OOM
Memory problems fall into four distinct categories; mixing them makes tuning impossible:
Heap OOM: java.lang.OutOfMemoryError: Java heap space — too many or too large objects, most common.
Metaspace OOM: Metaspace — excessive class loading, typical with dynamic proxies, CGLIB, hot deployment.
GC Overhead Limit Exceeded: 98% CPU time in GC reclaiming only 2% heap — heap near death.
Direct Memory OOM: Direct buffer memory — Netty/NIO off-heap leaks (invisible in heap dumps, hardest to diagnose).
Key point: The second line of the error message tells you the direction. Heap OOM → analyze dump; Metaspace OOM → check classloaders; Direct memory OOM → check -XX:MaxDirectMemorySize and Netty leak detection. Wrong direction wastes all parameter tuning.
2. Memory Structure: Know Where Objects Live
Heap: Young generation (Eden + two Survivor spaces) + Old generation. Most objects allocated here; main GC battlefield.
Metaspace: Class metadata, JDK 8+ in native memory, default unlimited (bounded by physical RAM).
Stack: Per-thread, holds locals and call frames. StackOverflowError relates to recursion depth.
Direct Memory: ByteBuffer.allocateDirect, not constrained by -Xmx.
Typical Startup Parameters (4C8G Container)
-Xms4g -Xmx4g
# Fixed heap: avoid resize jitter
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# Cap metaspace: prevent classloader leaks eating machine
-XX:MaxDirectMemorySize=512m
# Cap off-heap: mandatory for Netty
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
# Auto-capture evidence on OOM: cheapest, highest valueHighlight: In containers JVM must respect cgroup limits. JDK 8u191+ enables UseContainerSupport by default; earlier JDK 8 needs -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap . Never set heap to 100% of container limit; reserve 25%-30% for metaspace, thread stacks, off-heap, and OS.
3. Three Collectors: Trade-off Only on Two Metrics
Collector choice is fundamentally a trade-off between throughput and pause time :
G1 (JDK 9+ default): Region-based, predictable pauses via -XX:MaxGCPauseMillis. Optimal default for 4G-32G heaps, answer for most Spring Boot services.
ZGC (JDK 15+ production ready, JDK 21 generational): Concurrent marking + concurrent relocation, pauses ~1ms regardless of heap size. Suits large heaps (32G+) and ultra-low latency; cost: slightly lower throughput, higher memory overhead.
Parallel (JDK 8 default): Throughput-first, unpredictable pauses. Still fit for batch/offline jobs.
G1 Key Parameters
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# Target pause (soft goal; G1 balances region count)
-XX:G1HeapRegionSize=8m
# Region size: increase when many large objects
# Avoid Humongous allocations
-XX:InitiatingHeapOccupancyPercent=45
# Old-gen occupancy threshold to start concurrent marking cycle
-XX:G1ReservePercent=10
# Reserved space to reduce to-space exhausted riskG1 tuning first principle: Set only a pause target; let G1 calculate the rest. Heap size, region count, young gen size are derived. Manually tuning YoungSize breaks adaptive logic.
When to Adopt ZGC
-XX:+UseZGC
-XX:+ZGenerational # JDK 21 generational ZGC
-Xms32g -Xmx32g
# ZGC recommends fixed heap with headroom
# It trades extra free space for low pausesDecision rule: If you are already building business-side compensations for P99 spikes (retries, circuit breakers), GC pauses are impacting SLA — consider ZGC. Otherwise G1 is sufficient; don't switch collectors for fashion.
4. Core Flow: Young GC to Full GC Escalation Path
An object's lifecycle drives collection pressure:
Eden allocation: New objects into Eden, TLAB fast-path.
Minor GC: Eden full → survivors copied to Survivor, age+1; Eden cleared.
Promotion: Age reaches threshold (default 15) or Survivor overflow → promote to old gen.
Concurrent marking: Old gen occupancy hits IHOP threshold → G1 starts concurrent marking cycle.
Mixed GC: Reclaim old-gen regions with high garbage ratio — G1's core advantage.
Full GC: Concurrent marking can't keep up with allocation rate (to-space exhausted / evacuation failure) → falls back to single-threaded Full GC, STW seconds.
Optimization goal is not "eliminate GC" but "prevent escalation to Full GC". Once Full GC appears, allocation rate exceeds reclamation rate — a capacity problem, not a parameter problem.
5. Source-Level Mechanisms: Reading GC Logs Is Reading the JVM
5.1 Three Lines to Understand GC Logs
# Enable unified logging (JDK 9+)
-Xlog:gc*,gc+heap=info:file=/data/gc.log:
time,uptime:filecount=10,filesize=50m
# Three key lines (example)
[gc,start] GC(128) Pause Young (Normal)
(G1 Evacuation Pause) 1024M->312M(4096M)
# before=pre-GC, after=post-GC, parentheses=total heap
[gc,phases] GC(128) Ext Root Scanning 2.1ms
# Phase timings, locate slowness
[gc,heap] GC(128) Eden: 512M->0M
Survivor: 64M->32M Old: 448M->280MThree must-watch metrics: pre/post occupancy (effectiveness), pause time (SLA impact), promotion rate (old-gen pressure) . Young GC with rising old gen = premature promotion or leak.
5.2 Object Promotion Decision Points
// HotSpot promotion logic (simplified)
// 1. Age threshold?
if (age >= MaxTenuringThreshold) promote();
// 2. Dynamic age judgment:
// sum of objects of certain age exceeds half of Survivor
// -> all objects above that age promoted directly
if (survivorUsage > targetSurvivorRatio) {
promoteObjectsAboveAge(currentAge);
}
// 3. Large objects directly to old gen
// In G1, > half region = HumongousThis explains a classic symptom: Survivor too small → objects survive <2 GCs → rapid old-gen fill → Full GC . So "increase heap" may not help; check Survivor size first.
5.3 Tri-Color Marking & Concurrent Missed References
// Tri-color abstraction for concurrent marking
WHITE unvisited (potential garbage)
GRAY visited, child refs not scanned
BLACK visited, child refs scanned
// Missed reference problem during concurrency:
// 1. Black object adds reference to white object
// 2. Gray-to-white reference deleted
// -> white object incorrectly deemed garbage
// Solutions:
// Incremental update -> record new references
// SATB snapshot (G1 uses) -> record old references before deletion
// Write barrier performs the recordingThis is the key to "why concurrent collectors still need STW": Initial mark and final mark must STW to guarantee consistent marking start/end points. ZGC uses colored pointers + read barriers to make those phases concurrent too, achieving sub-1ms pauses.
5.4 Reference Types & Four Leak Patterns
Strong reference: Never collected — unbounded static Map caches are top killer (recall caching article: local caches must have limits).
Soft reference: Collected under memory pressure — suitable for caches but reclamation timing unpredictable.
Weak reference: Collected next GC — ThreadLocalMap keys use this (values may still leak; must remove()).
Phantom reference: Only for reclamation notification — DirectByteBuffer Cleaner relies on it to free off-heap memory.
Highlight: Spring Boot high-frequency leak sources: unbounded static collections, ThreadLocal not removed (thread-pool reuse = permanent retention), listeners/callbacks unregistered, CGLIB dynamic proxies unlimited generation (Metaspace OOM).
6. OOM Diagnosis Four-Step Method
Don't just restart on OOM; follow these steps:
Preserve scene: -XX:+HeapDumpOnOutOfMemoryError always on; mount dump directory to persistent volume in containers.
Check dominance: Open dump in MAT, inspect Dominator Tree top 10 objects — 80% of issues visible immediately.
Find GC Root path: For suspicious objects, run Path to GC Roots (exclude weak/soft refs) to see who holds them.
Validate hypothesis: Combine with code and timeline (which feature deploy triggered growth), construct reproduction; don't just guess.
Container Dump Commands (No Restart)
# Manual heap dump in container
jcmd 1 GC.heap_dump /data/dump/heap.hprof
# Live heap usage
jcmd 1 GC.heap_info
# Class load count (metaspace issues)
jcmd 1 GC.class_stats | sort -k3 -r | head
# Real-time GC stats
jstat -gcutil 1 1000Real Case
An order service OOM weekly, 4G heap. Dump showed ConcurrentHashMap consuming 3.2G , Path to GC Roots pointed to a static "local idempotency cache" — keys only grew, accumulating 8M entries in a week. Fix: replace with Caffeine maximumSize(100k) + expireAfterWrite(2h), memory stabilized at 200MB. Root cause was not JVM parameters but missing cache bounds.
7. Low-Latency Engineering: From Parameters to Architecture
Low pauses require five layers, cheapest to costliest:
Allocate less: String concatenation in loops, misused log placeholders, frequent boxing — reducing allocation is cheapest optimization.
Control large objects: Single query 100k rows, large JSON serialization, full Excel export in memory — switch to streaming/pagination.
Match heap & collector: 4-32G use G1 with pause target; 32G+ with strict SLA use ZGC.
Escape analysis & stack allocation: Small, method-local objects JIT allocates on stack — don't over-optimize early, but avoid storing large objects in ThreadLocal.
Architectural peak-shaving: Batch → async (recall async article), export → task-based, cache hot reads (recall caching article).
Parameter Template (4C8G Container, Spring Boot Service)
-Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
-XX:MaxDirectMemorySize=512m
-XX:+HeapDumpOnOutOfMemoryError
-Xlog:gc*:file=/data/gc.log:time,uptime:filecount=10,filesize=50m8. Eight Common Pitfalls
Pitfall 1: -Xmx set to container limit. Metaspace+stacks+off-heap cause OOMKilled; reserve 25%-30%.
Pitfall 2: Xms!= Xmx. Dynamic heap resize adds pauses and jitter; production should fix them equal.
Pitfall 3: Watching GC count not promotion rate. Frequent fast Young GCs are fine; sustained old-gen growth is the problem.
Pitfall 4: Growing heap to 64G to "reduce GC". G1 pauses lengthen on large heaps, Full GC more painful. Either tune G1 regions or move to ZGC.
Pitfall 5: Missing HeapDumpOnOutOfMemoryError. OOM triggers container restart, scene lost forever.
Pitfall 6: Using jmap for large heap dumps. Causes seconds-to-minutes STW; prefer jcmd GC.heap_dump (still pauses but more controllable), or dump from a drained replica.
Pitfall 7: ThreadLocal not removed. Leaks accumulate under thread-pool reuse; can also root classloader leaks.
Pitfall 8: Writing GC logs to container local disk. Lost on restart; must mount persistent volume or stream to stdout for collector sidecar.
Three-Step Triage
Plot GC log "post-GC occupancy" curve — rising = leak; sawtooth = healthy.
Check for Full GC and trigger cause (to-space exhausted / allocation failure) to distinguish capacity vs parameter issues.
If dump exists, examine Dominator Tree; if not, enable dump parameters and wait for next occurrence — don't blindly tweak.
9. Learning Path & Interview Questions
9.1 Four-Stage Roadmap
Beginner (1 week): Memory regions, allocation & promotion, common OOM types & error meanings.
Intermediate (2 weeks): G1 parameters & GC log reading; MAT basics (Dominator Tree / Path to GC Roots).
Advanced (1 month): Tri-color marking & SATB, write barriers, G1 Region & Mixed GC scheduling, ZGC colored pointers.
Practice (ongoing): Add dump & GC logging to production services; do capacity estimation (QPS × object size × residence time); resolve one real leak.
9.2 Twelve Interview Questions
JVM memory regions? Where did method area go after JDK 8?
When does an object promote to old gen? What is dynamic age judgment?
Why is Full GC so much slower than Young GC?
How do G1 Regions and Mixed GC work?
Is
MaxGCPauseMillis</strong> a hard guarantee? How does G1 achieve it?</li><li>What is a Humongous object? What are its impacts?</li><li>Why does tri-color marking need STW? What problem does SATB solve?</li><li>Why can ZGC achieve 1ms pauses? What are the trade-offs?</li><li>Differences and typical uses of the four reference types?</li><li>Why does <code>ThreadLocalleak memory? Why must remove() be called?
Production OOM — your investigation steps?
What to watch when running JVM in containers?
9.3 One-Page Cheat Sheet
Four OOM types: heap / metaspace / GC overhead
/ direct buffer — check error line 2
Container quota: heap leaves 25%-30% for metaspace+stack+offheap
Param template: Xms=Xmx + G1 + pause 200ms
+ Metaspace cap + dump always on
Escalation chain: Minor -> Promotion -> Concurrent Mark
-> Mixed -> Full (avoid)
Read logs: post-GC occupancy rising = leak
Promotion high = Survivor small
Full GC present = capacity insufficient
Low latency: reduce alloc > control large obj > switch collector
> architectural peak-shaving
One sentence: Full GC is a capacity problem, not a parameter problemSummary: Memory & GC tuning order is always: first classify OOM type → then read GC logs to decide leak vs capacity → only then adjust parameters. Parameters only fix "layout"; they cannot fix "unbounded object growth". Next article shifts from single-node to deployment: Spring Boot Containerized Deployment — Docker Image Slimming & K8s Probe Configuration .
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
