Performance · 03

JVM Memory & Garbage Collection

OrderService answers most requests in under a millisecond, yet every few hundred milliseconds a request takes tens or hundreds. This lab reads the GC log to find out why, measures six collectors on the same allocation-heavy workload, changes the heap size, cuts the allocation rate in the code, accounts for the memory a container really needs (heap is not the whole process), and hunts three classic memory leaks with a class histogram. Every number comes from programs run on this machine; the host was shared and busy, which the page says openly and which is itself a lesson.

12
Chapters
6
Collectors measured
48
Timed GC runs
3
Leak anatomies
01
Chapter One - The Problem

Where the p99 Goes: a GC Pause Timeline

The scenario is OrderService: a Java 21 service that builds an order response (about 200 line items, serialised to JSON, roughly a hundred kilobytes of short-lived garbage per request) and keeps a large catalog cache of five million product objects (about 1.1 GB live) in memory. GcLab reproduces that shape in plain Java: four request threads, requests arriving on a fixed schedule (an open model, so a pause is charged to every request that should have started during it), JDK 21.0.2 on a 12-core laptop-class machine.

Real G1 log, default settings: every stop-the-world pause during 12 s of steady load (3 GB heap, 1.1 GB live)
0 20 40 +0s +2s +4s +6s +8s +10s time into the steady-state run (each bar = one stop-the-world pause) pause (ms)
1 Problem
▶
Open-loop run at 4,800 req/s, G1 with default settings: p50 is 0.3 ms but p99 is 69.4 ms (median of 3 runs; individual runs ranged 5.3-170.9 ms).
2 Hypothesis
▶
Requests are cheap; the tail is stop-the-world GC. Young pauses are proportional to how much live data must be copied, and our cache replaces entries constantly, so promoted data keeps the old generation busy.
3 Measurement
▶
-Xlog:gc:file=gc.log plus latency percentiles from the load generator. Compare pause times in the log with the latency tail.
4 Diagnosis
▶
The log shows 6 pauses in the 12-second steady window (12.9 ms average, 20.4 ms max); the tail lines up with those pauses, not with CPU saturation.
5 Optimization (one variable)
▶
Chapters 3-8 change exactly one thing at a time: the collector, the heap size, the allocation volume.
6 Verification
▶
Each chapter ends with a before/after table from real runs.
Key insight: GC is a street sweeper that must close the whole street while it works. If the street is small or mostly empty, the closure is short. If the sweeper has to carry every parked car (live objects) to another street first, the closure is long. Collector choice decides how much of the sweeping can happen while traffic keeps moving.
⚠ Common mistakes
  • Judging GC from average latency or CPU. Pauses are short and rare; they live in p99/p99.9. Always keep the GC log on in production (-Xlog:gc*:file=gc.log:time,uptime,level,tags costs almost nothing).
  • Benchmarking on a busy machine and trusting one run. Across three repetitions of the same G1 configuration the p99 ranged from 5.3 to 170.9 ms here because other processes shared the CPU. This is why every table on this page shows medians and ranges (see Benchmarking on noisy neighbours).
02
Chapter Two - Mental Model

Heap, Non-Heap and the Path of an Object

Two diagrams explain almost every GC symptom: where memory lives in the process, and how an object travels from allocation to old generation.

JVM process memory map: the heap is only one box
One JVM process (what the container cgroup counts) = heap + a lot more Java heap (-Xms / -Xmx / MaxRAMPercentage) Eden S0 S1 Young generation: new objects, TLAB bump-pointer Old generation (tenured: caches, long-lived state) humongous Metaspace class metadata (native) unbounded unless MaxMetaspaceSize Code cache JIT-compiled code ReservedCodeCacheSize = 240 MB (tiered) Thread stacks -Xss / ThreadStackSize = 1 MB per thread x 200 Tomcat threads Direct buffers NIO / Netty / gRPC MaxDirectMemorySize default = max heap GC data structures remembered sets, card tables, marking bitmaps (G1 ~ few % of heap) Compiler / Symbols / Arena C1/C2 working memory, symbol table interned strings table Native libs / malloc arenas zlib, TLS (OpenSSL/JNI), glibc arena fragmentation (MALLOC_ARENA_MAX) cgroup limit (docker --memory / Kubernetes limits.memory / ECS memory) if the SUM of everything above exceeds it, the kernel OOM-killer ends the process: "OOMKilled", exit code 137
Allocation path: TLAB, Eden, survivors, old generation
How one new Order(...) becomes garbage (or survives): the generational path Thread-local TLAB private slice of Eden allocation = bump a pointer, no lock ~ a few ns Eden fills at the allocation rate (MB/s) full => young GC dead objects cost 0 Survivor S0/S1 live objects are COPIED; age +1 per young GC MaxTenuringThreshold = 15 (default) Old generation promoted: cache entries, sessions, big graphs cleaned by mixed / concurrent / full GC weak generational hypothesis: most objects die young => young GC cost ~ LIVE objects, not garbage Premature promotion (the expensive mistake) young gen too small / survivor overflow => short-lived objects reach old gen => old gen fills with garbage => more mixed / full collections Allocation rate is the lever young GCs per second = allocation rate / Eden size halve the garbage per request and you halve the GC frequency, whatever collector you run
Allocation is a pointer bump
▶
Each thread owns a TLAB (thread-local allocation buffer) carved from Eden, so new is a few instructions and takes no lock. Garbage costs nothing to create... until Eden is full.
Eden full -> young collection (stop-the-world)
▶
The collector copies live objects to survivor space or promotes them. Cost is proportional to live objects in the young generation, not to garbage - which is why short-lived garbage is cheap and caches that churn are not.
Old generation fills -> concurrent mark + mixed/full GC
▶
G1 starts concurrent marking when the heap passes InitiatingHeapOccupancyPercent (default 45%). If it cannot keep up, it falls back to a full, single-pause compaction - the pauses people remember.
Allocation rate sets GC frequency
▶
Young GCs per second = allocation rate / Eden size. In this lab G1 (3 GB heap, 1,600 req/s) allocated about 350 MB/s and ran a young collection every 6.0 s.
The process is bigger than the heap
▶
Metaspace, code cache, thread stacks, direct buffers and GC bookkeeping live outside -Xmx. The container limit applies to all of it (chapter 7).
RegionControlled byDefault on this machine (JDK 21.0.2, 31.7 GB RAM, 12 cores)Fails with
Java heap-Xms / -Xmx / -XX:MaxRAMPercentageMaxHeapSize = 7,932 MB (25% of RAM); InitialHeapSize 496 MBOutOfMemoryError: Java heap space
Metaspace-XX:MaxMetaspaceSize (unlimited by default)CompressedClassSpaceSize = 1 GB reservedOutOfMemoryError: Metaspace
Code cache-XX:ReservedCodeCacheSize240 MB with tiered compilationJIT stops: "CodeCache is full. Compiler has been disabled"
Thread stacks-Xss / -XX:ThreadStackSize1,024 KB per threadOutOfMemoryError: unable to create native thread
Direct buffers-XX:MaxDirectMemorySizeDefaults to the max heap sizeOutOfMemoryError: Direct buffer memory
GC structurescollector, heap sizeG1 region size 4 MB here; remembered sets + marking bitmapsNative memory growth, rarely a direct error
Shell - verify the defaults yourself (real output from this machine)
$ java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage|ThreadStackSize|ReservedCodeCacheSize|MaxTenuringThreshold|MaxGCPauseMillis' uintx MaxGCPauseMillis = 200 {product} {default} size_t MaxHeapSize = 8317304832 {product} {ergonomic} # 7,932 MB = 25% of 31,726 MB double MaxRAMPercentage = 25.000000 {product} {default} uintx MaxTenuringThreshold = 15 {product} {default} uintx ReservedCodeCacheSize = 251658240 {pd product} {ergonomic} intx ThreadStackSize = 1024 {pd product} {default}
03
Chapter Three - Lab: Read the Log

Reading -Xlog:gc: Pause, Cause, Before, After

Before changing a single flag, read what the collector is already telling you. A GC log line answers four questions: why the pause happened, how long it stopped the application, how much heap it freed, and how often it recurs.

1 Problem
▶
p99 spikes in the tens to hundreds of milliseconds with no CPU saturation.
2 Hypothesis
▶
Pauses, not computation. Expect the log to show young collections of similar length every few seconds.
3 Measurement
▶
java -Xms3g -Xmx3g -XX:+UseG1GC -Xlog:gc:file=gc.log GcLab 5000000 open 4 12 400; read the log; compute allocation rate from before/after sizes.
4 Diagnosis
▶
See annotated excerpt below. Heap goes from 2,8xx MB before to ~1.1 GB after each pause: ~1.1 GB is the live set (the cache), the rest is garbage.
5 Optimization (one variable)
▶
No change yet - diagnosis only. The next chapters change the collector, the heap size and the code.
6 Verification
▶
Diagnosis is verified in chapter 4 when a different collector shrinks the stop times.
Real GC log (G1 default, steady state, trimmed) - -Xlog:gc
[8.543s][info][gc] GC(13) Pause Young (Normal) (G1 Evacuation Pause) 2905M->1083M(3072M) 7.852ms [10.245s][info][gc] GC(14) Pause Young (Normal) (G1 Evacuation Pause) 2903M->1086M(3072M) 11.527ms [11.949s][info][gc] GC(15) Pause Young (Normal) (G1 Evacuation Pause) 2902M->1088M(3072M) 20.394ms [13.629s][info][gc] GC(16) Pause Young (Normal) (G1 Evacuation Pause) 2896M->1090M(3072M) 10.948ms [15.316s][info][gc] GC(17) Pause Young (Normal) (G1 Evacuation Pause) 2894M->1093M(3072M) 13.929ms [16.995s][info][gc] GC(18) Pause Young (Normal) (G1 Evacuation Pause) 2893M->1096M(3072M) 12.832ms
Reading a GC log line
Anatomy of one G1 log line [ 9.431s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 2881M->1112M(3072M) 4.012ms uptime / decorations correlate with latency spikes and deploy times Pause Young (Normal) what kind of pause and why: Eden was full heap BEFORE -> AFTER after = live set (~1.1 GB cache) before-after = garbage freed stop time (ms) every request in flight waits this long allocation rate = (before_n - after_n-1) / (t_n - t_n-1) live set = the "after" value, once the cache is warm GC overhead = sum(pause ms) / wall ms (rule of thumb: keep below ~5%)
Shell - the most useful logging flags
# always-on, cheap, rotated java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m -jar app.jar # one line per pause: -Xlog:gc # phase breakdown per pause: -Xlog:gc+phases=debug # why did the heap resize: -Xlog:gc+ergo*=debug # time all threads were stopped (works for every collector): -Xlog:safepoint
What you see in the logWhat it meansNext step
Pause Young (Normal) (G1 Evacuation Pause)Eden full; live young objects copied; a normal cycleIf frequent: reduce allocation rate or enlarge heap
Pause Young (Concurrent Start)Young pause that also starts concurrent marking (old gen passed IHOP)Normal; many of these means old gen is filling
Pause Young (Mixed)Young + some old regions reclaimed after markingHealthy G1 steady state
Pause Full (G1 Compaction Pause)G1 could not keep up (or humongous/to-space exhaustion): single-threaded-ish full GCRed flag: heap too small, leak, or IHOP too high
To-space exhausted / Evacuation FailureNo room to copy survivorsMore headroom (-Xmx), lower IHOP, fewer humongous objects
Allocation Stall (ZGC)Application thread waited because GC could not reclaim fast enoughLarger heap or more concurrent GC threads
⚠ Common mistakes
  • Reading the "Pause" duration only. Total time stopped also includes time to reach the safepoint (a long counted loop can delay it). Use -Xlog:safepoint to see "Reaching safepoint" separately.
  • Logging to stdout in a container without timestamps: you cannot correlate pauses with a latency spike. Add time,uptime decorations.
04
Chapter Four - Lab: Choose a Collector

Six Collectors, One Workload

Same code, same heap (3 GB), same load; one variable changes: the collector. Each configuration ran three times with the GC log on. Two load levels tell different stories: a saturating closed loop (how much work can the JVM do?) and an unsaturated open loop at 1,600 req/s (how long are the stops when the machine is not overloaded?).

1 Problem
▶
G1 tail latency is acceptable here but not great; is another collector better for OrderService?
2 Hypothesis
▶
Serial/Parallel: highest throughput, longest pauses. G1: balanced. ZGC/Shenandoah: shortest pauses, but they spend CPU and need headroom.
3 Measurement
▶
Flags: -XX:+UseSerialGC, -XX:+UseParallelGC, -XX:+UseG1GC, -XX:+UseZGC, -XX:+UseZGC -XX:+ZGenerational (JDK 21), -XX:+UseShenandoahGC; metrics: p50/p95/p99, throughput, stopped time from -Xlog:safepoint.
4 Diagnosis
▶
See the table: the stop-time column comes straight from the safepoint log; p99 comes from the load generator. Differences between collectors are visible, the spread between repetitions is large (shared host).
5 Optimization (one variable)
▶
Pick the collector by the metric you are paid for: p99 -> generational ZGC (or tuned G1); throughput on batch -> Parallel; tiny containers -> Serial.
6 Verification
▶
Compare against G1 in the table; confirm later under a deployment-like heap.
Lab result: p99 latency at 1,600 req/s (unsaturated), median of 3 runs
Serial 27.4 ms p99.9 74.5 ms Parallel 50.5 ms p99.9 88.6 ms G1 (default) 6.9 ms p99.9 34.6 ms ZGC (non-gen) 4.7 ms p99.9 13.6 ms ZGC generational 0.9 ms p99.9 2.5 ms Shenandoah 9.7 ms p99.9 33.2 ms Same 3 GB heap, same workload; only the collector flag changes. Shared host: read the ranking, not the decimals.
Lab result: maximum throughput (closed loop, 4 threads), requests/s, median of 3 runs
Serial 10246 /s range 10058-13606 Parallel 12700 /s range 9982-18282 G1 (default) 11164 /s range 10247-17993 ZGC (non-gen) 4438 /s range 3245-6194 ZGC generational 12055 /s range 8976-14552 Shenandoah 4077 /s range 2663-5049 Higher is better. Non-generational concurrent collectors pay for re-marking the whole 1.1 GB live set every cycle.
CollectorMax throughput /s (median, range)p99 ms @1,600/s (median, range)Max stopped ms (safepoint log)Total stopped ms in 12 sp99 ms @4,800/s (overload for some)
Serial10246 (10058-13606)27.4 (6.4-77.5)116.37552.879
Parallel12700 (9982-18282)50.5 (0.2-140.5)96.86387.8217
G1 (default)11164 (10247-17993)6.9 (0.2-13.3)19.3226.469
ZGC (non-gen)4438 (3245-6194)4.7 (0.3-10.7)0.040.24850
ZGC generational12055 (8976-14552)0.9 (0.3-9.5)0.281.04
Shenandoah4077 (2663-5049)9.7 (1.3-200.1)4.4410.07727
Real excerpt - raw lines from matrix.sh (closed loop, rep 1) and results of one open run
RESULT mode=closed threads=4 requests=163273 throughput=13606/s p50=0.17ms p95=0.32ms p99=0.62ms p99.9=40.30ms max=138.46ms RESULT mode=open threads=4 requests=57601 throughput=4800/s p50=0.28ms p95=42.59ms p99=79.47ms p99.9=101.77ms max=109.78ms RESULT mode=closed threads=4 requests=122951 throughput=10246/s p50=0.27ms p95=0.36ms p99=0.80ms p99.9=52.50ms max=150.25ms RESULT mode=open threads=4 requests=57601 throughput=4800/s p50=3.48ms p95=858.68ms p99=1272.10ms p99.9=1341.66ms max=1361.95ms
Collector families on a timeline
Where the application is stopped (red) vs running (green) - schematic, not to scale Parallel / Serial STW STW (full) whole young+old work done with ALL app threads stopped G1 young young young mixed young concurrent mark young + mixed pauses are STW and bounded by target; marking is concurrent ZGC / Shenandoah concurrent mark / relocate / remap (costs CPU, needs headroom) only tiny root-scan pauses; mark + relocate run concurrently with the app

What the data says. At the unsaturated rate the collectors that reclaim memory concurrently show the shortest stops, and generational ZGC is the only concurrent collector that also keeps G1-class throughput. The older non-generational ZGC and Shenandoah were consistently the slowest in the closed loop (see the throughput chart: every one of the three repetitions was lower than every G1 repetition), because with a 1.1 GB live set each cycle must re-mark everything while the application keeps allocating; at 4,800 req/s - above their capacity - the open-loop queue exploded (median p99 of 4850 ms for ZGC and 7727 ms for Shenandoah, from the first matrix). That is overload, not a pause: when a concurrent collector cannot keep up it stalls allocating threads ("Allocation Stall").

CollectorHow it worksPause behaviourCost / when to chooseVersion notes
SerialSingle-threaded copying young + mark-compact oldPauses grow with heap, all threads stoppedSmallest footprint: 1 CPU, tiny containers (JVM picks it when it sees 1 CPU - verified below)-XX:+UseSerialGC
ParallelMulti-threaded young + full GC, generationalLongest pauses for big heaps; best throughputBatch jobs, throughput-first servicesDefault up to JDK 8
G1Region-based, young/mixed evacuation, concurrent marking, pause targetBounded by MaxGCPauseMillis=200 (soft goal)Good default for 2+ CPUs and heaps of a few GBDefault since JDK 9 on server-class machines
ZGCConcurrent mark/relocate with coloured pointers and load barriersSub-millisecond root pauses, independent of heap sizeNeeds spare CPU and heap headroom; best for very large heaps / strict p99Production since JDK 15; generational mode via -XX:+ZGenerational in JDK 21; generational is the default in JDK 23; non-generational mode removed in JDK 24
ShenandoahConcurrent evacuation with load-reference barriersVery short pauses, mostly independent of heap sizeSimilar trade-offs to ZGC; available in OpenJDK builds such as Temurin (not in every vendor build)-XX:+UseShenandoahGC
Shell - which collector does the JVM choose on its own? (measured here)
$ java -XX:ActiveProcessorCount=1 -XX:+PrintFlagsFinal -version | grep -E ' Use(Serial|G1|Parallel)GC ' # -> UseSerialGC = true $ java -XX:ActiveProcessorCount=4 -XX:+PrintFlagsFinal -version | grep -E ' Use(Serial|G1|Parallel)GC ' # -> UseG1GC = true # HotSpot treats a machine as "server class" with >= 2 CPUs and >= ~1.8 GB; below that it picks Serial. # (The CPU rule is what we verified; the memory rule is general HotSpot behaviour - confirm with your real container limits.)
⚠ Production gotchas
  • Choosing ZGC/Shenandoah because "low pause" sounds better. On a 2-vCPU container the concurrent threads compete with request threads; throughput and p50 can get worse. Measure with your live-set size and CPU quota.
  • Tuning flags before measuring allocation rate. A collector cannot save you from 1 GB/s of garbage; chapter 6 shows the code change is worth more than the flag.
  • Mixing collectors in CPU-limited containers. ParallelGCThreads and ConcGCThreads follow the active processor count (ParallelGCThreads = 10 on this 12-core box); a CFS quota of 1 CPU without -XX:ActiveProcessorCount awareness leads to oversized GC pools (see the AWS compute page).
05
Chapter Five - Lab: Heap Sizing

Heap Size: Headroom Is a Performance Feature

The second variable that decides GC cost is how much room the collector has. A garbage collector does work proportional to the live set; the more free heap it has, the less often it must run. The classic mistake in both directions: a heap barely larger than the live set (constant collections), and a giant heap "to be safe" (longer pauses, more memory, higher bill).

1 Problem
▶
A 1.1 GB cache in a 1.3 GB heap: GC runs back-to-back.
2 Hypothesis
▶
GC frequency = allocation rate / free space. With ~200 MB free and ~100+ MB/s of garbage, collections happen several times a second and old-gen reclamation (mixed/full) becomes constant.
3 Measurement
▶
Same GcLab run (G1, 4,800 req/s) with -Xms = -Xmx of 1.3, 1.8, 3 and 6 GB; count pauses and total stopped time in the 12 s steady window of the GC logs.
4 Diagnosis
▶
Number of pauses and total stopped time fall as the heap grows; once free space is several times the live set the curve flattens.
5 Optimization (one variable)
▶
Change only -Xmx (and -Xms equal to it, to avoid resize pauses).
6 Verification
▶
See table and chart. Pause count and total stopped time (from the logs) are the robust metrics; the throughput and p99 columns swing with host load (compare the ranges), so do not read ranking into them.
Heap (-Xms = -Xmx)Pauses in 12 s (median)Total stopped msLongest pause msMax throughput /s (closed loop)p99 ms @4,800/s open (median, range)
1.3 GB (live set 1.1 GB: 85% full)761808839571 (8269-9571)2422.4 (25.4-2422.4)
1.8 GB305804416375 (11007-16375)77.8 (11.8-77.8)
3 GB (our baseline)731912111164 (10247-17993)69.4 (5.3-170.9)
6 GB42899116355 (9861-16355)183.4 (0.3-183.4)
Lab result: total GC stopped time in the 12 s steady window by heap size (median of 2 runs), ms
1.3 GB 1807.6 ms 1.8 GB 579.59 ms 3 GB 319.049 ms 6 GB 289.267 ms G1, same workload. Red = too tight: the collector is permanently busy.
Shell - sizing flags (real output: how the JVM derives MaxHeapSize)
$ java -XX:MaxRAMPercentage=25 -XX:+PrintFlagsFinal -version | grep MaxHeapSize size_t MaxHeapSize = 8317304832 # 25% of 31,726 MB $ java -XX:MaxRAMPercentage=50 -XX:+PrintFlagsFinal -version | grep MaxHeapSize size_t MaxHeapSize = 16634609664 # 50% $ java -XX:MaxRAMPercentage=75 -XX:+PrintFlagsFinal -version | grep MaxHeapSize size_t MaxHeapSize = 24964497408 # 75% # containers: the JVM reads the cgroup limit instead of host RAM (UseContainerSupport, on by default): # limit 2 GB, default 25% -> 512 MB heap (usually too little) # limit 2 GB, MaxRAMPercentage=65 -> ~1.3 GB heap (leaves ~700 MB for non-heap; chapter 7)
A practical sizing recipe
  • Measure the live set: the "after" value of a mixed/full collection in steady state (here ~1.1 GB).
  • Set -Xmx to roughly 2.5-4 x the live set for G1 (more for ZGC/Shenandoah), with -Xms = -Xmx on servers.
  • Re-check: pauses per minute, longest pause, and GC overhead (stopped ms / wall ms). Stop when extra heap no longer shortens the log.
  • Prefer -XX:MaxRAMPercentage in containers so the heap follows the limit, but keep it well under 100% (chapter 9).
⚠ Production gotchas
  • -Xmx equal to the container limit. Metaspace, code cache, stacks and direct buffers push the process past the cgroup limit: OOMKilled with a healthy-looking heap (chapter 7).
  • Giant heaps for G1 without a pause target. Evacuation pauses grow with live data in young+mixed regions; a 64 GB heap with a few GB live is fine, a 64 GB heap with 40 GB live is not.
  • -Xms much smaller than -Xmx. The heap grows by Full or young GCs with resize decisions in the log (-Xlog:gc+ergo*); servers should pin both.
06
Chapter Six - Lab: Allocation Rate

Cut the Garbage Before Tuning the Collector

Chapters 4-5 tuned the collector and the heap. The cheapest GC tuning is not creating the garbage. OrderService's response builder is deliberately wasteful, like many real ones: it copies every line item into a temporary list, concatenates strings for titles, and calls String.format (which builds a Formatter, a regex-free parser and several temporary strings) once per line item.

1 Problem
▶
Each response allocates 224 KB (measured with ThreadMXBean), i.e. about 350 MB/s at only 1,600 req/s; G1 had to run 2 young collections in the 12 s window.
2 Hypothesis
▶
Most of those bytes are intermediates (a list of 200 LineItem objects, concatenated title strings, Formatter garbage). Removing them reduces allocation rate, GC frequency and CPU at the same time.
3 Measurement
▶
AllocProbe reads ThreadMXBean.getThreadAllocatedBytes around 50,000 calls (the JSON output is byte-for-byte the same length); young collections counted from the GC log; throughput in the closed loop; GcLabLean.java differs from GcLab.java only in handleRequest.
4 Diagnosis
▶
Per response: 223.7 KB before, 38.1 KB after (5.9x less); at 1,600 req/s that is 350 MB/s vs 60 MB/s.
5 Optimization (one variable)
▶
One code change: build the JSON directly into a pre-sized StringBuilder, no intermediate list, no String.format.
6 Verification
▶
See table: allocation rate, young collections per 12 s, closed-loop throughput.
Java - before (GcLab)
List<LineItem> list = new ArrayList<>(); for (int i = 0; i < items; i++) { Product p = CATALOG.get((seed * 31 + i * 7) % CATALOG.size()); list.add(new LineItem(p.id(), p.name() + "/" + i, 1 + (i % 5), p.prices()[i % 4] * 1.07)); // concat + object } StringBuilder sb = new StringBuilder(); // grows 16 -> ... -> 40 KB for (LineItem li : list) { sb.append("{\"sku\":").append(li.sku()).append(",\"title\":\"").append(li.title()) .append("\",\"qty\":").append(li.qty()).append(",\"price\":").append(String.format("%.2f", li.price())).append("},"); }
Java - after (GcLabLean)
StringBuilder sb = new StringBuilder(items * 96); // pre-sized: no regrowth copies for (int i = 0; i < items; i++) { Product p = CATALOG.get((seed * 31 + i * 7) % CATALOG.size()); long cents = Math.round(p.prices()[i % 4] * 1.07 * 100); sb.append("{\"sku\":").append(p.id()).append(",\"title\":\"").append(p.name()).append('/').append(i) .append("\",\"qty\":").append(1 + (i % 5)).append(",\"price\":").append(cents / 100).append('.') .append(cents % 100 < 10 ? "0" : "").append(cents % 100).append("},"); // no String.format, no temp list }
Before (GcLab)After (GcLabLean)Change
Allocated per response (KB, ThreadMXBean)223.738.15.9x lower
Allocation rate @1,600 req/s (MB/s)350605.9x lower
Young GCs in 12 s window (median)20
Max throughput /s, closed loop (median, range)16810 (8652-17741)58273 (55487-79693)3.5x
p99 ms @1,600 req/s open (median, range)1.1 (0.3-10.4)1.2 (0.1-3.9)noisy
Lab result: allocation rate, MB/s (median of 3 runs, G1 3 GB)
before 350 MB/s after (lean) 60 MB/s Allocated bytes per response x 1,600 req/s. Same JSON output (16,070 chars), same catalog, same collector, same heap.
Where garbage hides in a Spring service
  • String.format, + concatenation in loops, repeated stream() pipelines on hot paths, boxing in Map<Long,...>, regex Pattern.compile per call, Jackson ObjectMapper created per request, copying entity lists into DTO lists into response lists.
  • Find it: JFR allocation profiling (jdk.ObjectAllocationSample) shows the top allocation stacks - see the Profiling Toolbox.
  • Do not micro-optimise cold code: only the top few allocation sites by bytes matter.
⚠ Production gotchas
  • Pooling objects to "avoid GC". Object pools make objects long-lived, push them into old gen and add contention; short-lived allocation is cheap on the JVM.
  • Reusing a StringBuilder / byte[] across threads. Not thread-safe; use per-request locals or a pool with clear ownership.
07
Chapter Seven - Lab: Native Memory

Beyond the Heap: Direct Buffers, Stacks, Metaspace, Code Cache

The container limit applies to the whole process. A service with -Xmx512m can legitimately use 900 MB: Netty/NIO direct buffers, 500 thread stacks, metaspace, the JIT code cache, GC remembered sets and native libraries are all outside the heap. NativeLab allocates 200 MB of heap objects, 300 direct buffers of 1 MB and 500 threads, then asks the JVM where the memory went with Native Memory Tracking.

1 Problem
▶
A pod with -Xmx512m and a 1 GB memory limit is OOMKilled (exit code 137) although heap usage graphs look fine.
2 Hypothesis
▶
Non-heap memory (direct buffers, thread stacks, metaspace, GC data) pushes the process RSS beyond the cgroup limit; the heap never overflowed.
3 Measurement
▶
java -XX:NativeMemoryTracking=summary ... then jcmd PID VM.native_memory summary; RSS from /proc/self/status at each step.
4 Diagnosis
▶
RSS grew with direct buffers and threads while heap usage barely moved (table below). NMT attributes the memory by category.
5 Optimization (one variable)
▶
Budget non-heap explicitly: set -XX:MaxDirectMemorySize, bound thread counts, and set MaxRAMPercentage so heap + non-heap fits the limit.
6 Verification
▶
RSS stays below the limit with measured headroom.
Real output - NativeLab (RSS after each step)
start: VmRSS: 46176 kB after 200 MB heap: VmRSS: 254472 kB after 300 MB direct: VmRSS: 564016 kB after 500 threads: VmRSS: 651232 kB heap used (Runtime): 439 MB 1000
Real output - jcmd PID VM.native_memory summary (selected lines, JVM started with -Xms1g -Xmx1g)
Total: reserved=3358607KB, committed=1515147KB malloc: 338639KB #28676 mmap: reserved=3019968KB, committed=1176508KB - Java Heap (reserved=1048576KB, committed=1048576KB) - Class (reserved=1048814KB, committed=366KB) - Thread (reserved=541403KB, committed=54667KB) - Code (reserved=247767KB, committed=7627KB) - GC (reserved=77304KB, committed=77304KB) - Compiler (reserved=181KB, committed=181KB) - Internal (reserved=1136KB, committed=1136KB) - Other (reserved=307210KB, committed=307210KB) - Symbol (reserved=1148KB, committed=1148KB) - Shared class space (reserved=16384KB, committed=12952KB, readonly=0KB)
Lab result: committed memory by NMT category (MB) - heap is not the largest non-GC consumer
Java heap (committed) 1024 MB Direct buffers ("Other") 300 MB GC structures 75 MB Thread stacks (527 threads) 53 MB Code cache 7 MB RSS at the end of the run was 651 MB: committed (reserved) is not touched, and heap pages count only when written.
Category (NMT)Grows withTypical surpriseControl
Java Heaplive set + headroom-Xms commits address space; RSS counts only touched pages unless -XX:+AlwaysPreTouch-Xmx, MaxRAMPercentage
Threadnumber of platform threads527 threads = 53 MB committed, 540 MB reserved (1 MB each). 200 Tomcat + 100 pool + ... adds up-Xss, bounded pools, virtual threads (stacks on heap)
Other (direct buffers)NIO/Netty/gRPC buffersNot visible in heap graphs; 300 x 1 MB = 307 MB here-XX:MaxDirectMemorySize
Class / Metaspaceloaded classes (proxies, generated code, hot redeploy)Leaks with dynamic class generation / classloaders-XX:MaxMetaspaceSize (fail fast)
CodeJIT-compiled methodsUp to 240 MB reserved; "CodeCache is full"-XX:ReservedCodeCacheSize
GCheap size, collectorG1/ZGC bookkeeping measured 75 MB here for a 1 GB heapsmaller heap, collector choice
Container memory budget: heap + non-heap + headroom = limit
Memory budget of a 2 GB container (example budget - illustrative, inputs from the NMT lab) heap 1228 stacks 250 0 2048 MB = cgroup limit small slices (code 60, direct 100, free 110) are labelled in the arithmetic block above; the heap is 60% of the box - not 100%. Past the right edge the kernel OOM-killer terminates the JVM (exit 137).
⚠ Production gotchas
  • Reading "heap used" as "memory used". Graph RSS (container_memory_working_set_bytes) next to heap; the gap is non-heap.
  • glibc malloc arenas. Many threads calling native code can fragment memory (RSS grows, NMT does not show it). Known mitigations: MALLOC_ARENA_MAX=2 or jemalloc - not run here.
  • NMT in production. summary mode has small overhead (single-digit percent range reported by the JDK docs); detail costs more. Turn it on for a diagnosis window, not by default.
08
Chapter Eight - Lab: Memory Leaks

Three Leaks, One Class Histogram

A leak is memory that is still reachable but will never be used again. The collector cannot help; it correctly keeps what is referenced. LeakLab implements three leaks that appear in real Spring services and runs each with -Xmx256m until it dies.

Anatomy of a leak: a reachable path from a root
Why the collector cannot free a leak: everything is reachable from a GC root GC roots static fields live thread stacks ThreadLocal maps (Thread) JNI refs static HashMap CACHE RequestKey #1 RequestKey #2 OrderSummary + 2 KB OrderSummary + 2 KB ... 118,547 entries when the 256 MB heap ran out The collector is correct: these objects ARE reachable. The bug is the reference, not the GC. Fix = cut the edge (evict, remove(), unregister), then the whole subtree becomes garbage at once.
1 Problem
▶
Heap usage graph climbs for hours, GC runs more and more often, then OutOfMemoryError; restarts "fix" it.
2 Hypothesis
▶
Something holds references to per-request data: an unbounded cache, listeners never removed, or per-thread state that outlives the request.
3 Measurement
▶
Class histogram of live objects: jcmd PID GC.class_histogram (forces a full GC, so only reachable objects are listed); take two histograms minutes apart and diff; heap dump + MAT dominator tree for the owner.
4 Diagnosis
▶
The histogram shows the same class counts rising together (OrderSummary, RequestKey, byte[]); the GC log shows a rising floor.
5 Optimization (one variable)
▶
Bound or remove the reference: size-limited cache with eviction, deregister listeners, ThreadLocal.remove() in a finally block.
6 Verification
▶
After the fix the post-GC floor is flat and the histogram counts stop rising.
Real output - leak 1 (static map with identity-keyed entries): heap usage, one line per second
requests=8240 heapUsed=19 MB requests=16280 heapUsed=38 MB requests=24980 heapUsed=57 MB requests=33470 heapUsed=77 MB requests=41180 heapUsed=95 MB requests=47540 heapUsed=101 MB requests=53980 heapUsed=116 MB requests=60160 heapUsed=129 MB requests=66120 heapUsed=142 MB requests=72280 heapUsed=155 MB requests=79160 heapUsed=170 MB requests=84570 heapUsed=183 MB requests=90910 heapUsed=198 MB requests=96900 heapUsed=210 MB requests=103080 heapUsed=227 MB requests=108680 heapUsed=240 MB requests=112968 heapUsed=241 MB requests=118547 heapUsed=252 MB CAUGHT: java.lang.OutOfMemoryError: Java heap space
Real GC log of the leaking process: heap AFTER each collection (MB) - the floor never comes back down
0 50 100 150 200 250 0 4 8 12 16 20 seconds since first GC MB live after GC after-GC heap (MB) -Xmx256m. A healthy service has a flat floor (sawtooth around a constant); a leak is a staircase. Last point = Full GC that could not free anything.
Real output - jcmd PID GC.class_histogram taken 5 s into the run (top rows)
1472365: num #instances #bytes class name (module) ------------------------------------------------------- 1: 101286 96262688 [B (java.base@21.0.2) 2: 48326 1546432 java.util.HashMap$Node (java.base@21.0.2) 3: 45910 1469120 LeakLab$OrderSummary 4: 55292 1327008 java.lang.String (java.base@21.0.2) 5: 45910 1101840 LeakLab$RequestKey 6: 424 452656 Ljdk.internal.vm.FillerArray; (java.base@21.0.2)
Java - leak 1: the cache that never hits
static final Map<Object, OrderSummary> CACHE = new HashMap<>(); static class RequestKey { final long id; RequestKey(long id) { this.id = id; } } // no equals/hashCode -> identity CACHE.put(new RequestKey(n), new OrderSummary(n, "cust-" + (n % 1000), new byte[2048])); // every call adds a NEW entry
Real output - leak 2 (listeners registered per request, never unregistered): class histogram
1472927: num #instances #bytes class name (module) ------------------------------------------------------- 1: 41439 66409072 [B (java.base@21.0.2) 2: 31980 511680 LeakLab$$Lambda/0x0000740a40000bf8 3: 314 379360 Ljdk.internal.vm.FillerArray; (java.base@21.0.2)
Real output - leak 3 (ThreadLocal holding 8 MB in 64 pool threads that never end)
worker threads=1 heapUsed=11 MB worker threads=2 heapUsed=21 MB worker threads=3 heapUsed=30 MB ... worker threads=26 heapUsed=238 MB worker threads=27 heapUsed=247 MB worker threads=28 heapUsed=253 MB Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.OutOfMemoryError: Java heap space
LeakRootWhat the histogram showsFixSpring-world example
Unbounded static cachestatic field -> HashMapHashMap$Node + your key and value classes climbing togetherCaffeine/LRU with maximumSize + expireAfterWrite; stable equals/hashCodeHand-rolled "cache" Map in a @Service, keyed by an object without equals
Listener / callback never removedlong-lived publisher -> list of lambdasLambda classes ($$Lambda) and captured objectsUnregister in a finally block / use weak listenersApplicationListener registered dynamically, WebSocket sessions, Guava EventBus
ThreadLocal in pooled threadsThread (immortal) -> threadLocals mapBig byte[]/StringBuilder instances ~ number of pool threadstry { ... } finally { tl.remove(); }; avoid per-thread buffersMDC/security context not cleared, SimpleDateFormat caches, large per-thread buffers
Classloader leakmetaspace, not heapGrowth in OutOfMemoryError: Metaspace; many duplicate classesFix redeploy/proxy generation; set MaxMetaspaceSizeDynamic proxies, hot redeploy in app servers (not run here)
Shell - the leak-hunting runbook (all commands run above)
jcmd PID GC.class_histogram | head -20 # top classes by bytes (forces a full GC: only live objects) jcmd PID GC.heap_dump /tmp/heap.hprof # then open in Eclipse MAT: Leak Suspects, dominator tree java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps ... # capture evidence at the moment of death java -XX:+ExitOnOutOfMemoryError ... # let the orchestrator restart a dying JVM cleanly # watch the post-GC floor, not the peak: -Xlog:gc -> look at the "after" number over hours
⚠ Production gotchas
  • Calling System.gc() or raising -Xmx to "fix" it. A bigger heap only delays the same death and lengthens GC pauses.
  • Heap dumps on a live production pod without checking size and disk. A dump is roughly the heap size and pauses the JVM while it is written.
  • Assuming "OOM = leak". It can also be a legitimately larger live set (more tenants, bigger result sets): compare the floor with the workload, not just its slope.
09
Chapter Nine - Containers

From Measurements to a Container Memory Budget

Putting the pieces together for an OrderService task on ECS/Fargate or an EKS pod. The method is arithmetic you can check with NMT, not guesswork.

Worked example - sizing a 2 GB container (the NMT numbers from chapter 7 as inputs)
container limit 2048 MB - heap (MaxRAMPercentage=60 -> 1,228 MB) -1228 # live set + 2.5-4x headroom decides this (chapter 5) - metaspace + class space (typical Spring) -150 # check: jcmd PID VM.native_memory - code cache in use -60 # up to 240 MB reserved - thread stacks: 200 Tomcat + 50 other -250 # 1 MB each reserved; committed is lower - direct buffers (Netty / HTTP client) -100 # cap with -XX:MaxDirectMemorySize=128m - GC structures + native libs + safety -150 # chapter 7: GC category was 77 MB for a 1 GB heap = remaining headroom ~110 MB # if this goes negative -> OOMKilled # values are an example budget, not measurements from a Spring service
Shell - flags for a container-friendly JVM
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=60 -XX:InitialRAMPercentage=60 -XX:MaxDirectMemorySize=128m \ -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps -Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=20m" # collector: leave the JVM's ergonomic choice (G1 on 2+ CPUs, Serial on 1) unless measurement says otherwise # CPU limits and active processor count: see /performance/aws-compute/
DecisionDefaultWhy change itVerify with
MaxRAMPercentage25%Default wastes 75% of the container on a heap-only serviceGC log after-values vs heap size; RSS vs limit
InitialRAMPercentage1.5625%Avoid early heap resizes: set equal to max on servers-Xlog:gc+ergo*
MaxDirectMemorySize= max heapNetty-heavy services can double memory silentlyNMT "Other" category
CollectorG1 (2+ CPUs) / Serial (1 CPU)Latency goals; very large heapsChapter 4 method on your workload
GC loggingoffEvidence for every future incidentPause timeline as in chapter 1
⚠ Production gotchas
  • One-size flag copy/paste. A flag set tuned for a 16 GB host applied to a 1 GB container leads to OOMKilled. Flags belong with the container spec.
  • Ignoring that JDK 21 ZGC/G1 behave differently under CPU quota. Concurrent GC threads share the CFS quota with request threads (see the AWS compute page for throttling).
10
Chapter Ten - Try It Yourself

The Programs and Commands

Everything above is plain Java; compile with javac and run with JDK 21. Absolute numbers will differ on your machine - what should reproduce is the shape: pause timelines, the ranking by stopped time, the effect of headroom, the staircase of a leak.

Shell - the whole experiment
javac GcLab.java GcLabLean.java LeakLab.java NativeLab.java # one run: <catalog objects> <closed|open> <threads> <seconds> <req/s per thread> java -Xms3g -Xmx3g -XX:+UseG1GC -Xlog:gc:file=g1.log GcLab 5000000 open 4 12 400 java -Xms3g -Xmx3g -XX:+UseZGC -XX:+ZGenerational -Xlog:gc,safepoint:file=zgen.log GcLab 5000000 open 4 12 400 java -Xms3g -Xmx3g -XX:+UseShenandoahGC GcLab 5000000 closed 4 12 400 # leaks (256 MB heap) with a histogram taken from another shell java -Xmx256m LeakLab map & jcmd $(pgrep -f LeakLab) GC.class_histogram | head -12 # native memory java -Xmx1g -Xms1g -XX:NativeMemoryTracking=summary NativeLab & jcmd PID VM.native_memory summary
Java - GcLab.java (complete, as run)
import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** * GcLab - an OrderService-shaped allocation workload. * - a long-lived "catalog cache" (live set) that the GC must keep marking * - each request builds ~200 line items and serialises them to a JSON-ish string (short-lived garbage) * - 1 request in 2 (5 entries) replaces a cache entry (so old-gen objects die over time) * Modes: closed = N threads loop flat out (throughput) * open = each thread fires at a fixed schedule, latency measured from the INTENDED start * (so a GC pause is charged to every request that should have started during it) */ public class GcLab { record Product(long id, String name, double[] prices) {} record LineItem(long sku, String title, int qty, double price) {} static final ConcurrentHashMap<Integer, Product> CATALOG = new ConcurrentHashMap<>(); static void fillCatalog(int n) { for (int i = 0; i < n; i++) CATALOG.put(i, newProduct(i)); } static Product newProduct(long i) { return new Product(i, "product-" + i + "-" + Long.toHexString(i * 2654435761L), new double[] {i, i + 1, i + 2, i + 3}); } static String handleRequest(int seed, int items) { List<LineItem> list = new ArrayList<>(); for (int i = 0; i < items; i++) { Product p = CATALOG.get((seed * 31 + i * 7) % CATALOG.size()); list.add(new LineItem(p.id(), p.name() + "/" + i, 1 + (i % 5), p.prices()[i % 4] * 1.07)); } StringBuilder sb = new StringBuilder(); sb.append("{\"orderId\":").append(seed).append(",\"items\":["); for (LineItem li : list) { sb.append("{\"sku\":").append(li.sku()).append(",\"title\":\"").append(li.title()) .append("\",\"qty\":").append(li.qty()).append(",\"price\":").append(String.format("%.2f", li.price())).append("},"); } sb.append("]}"); if (seed % 2 == 0) for (int r = 0; r < 5; r++) CATALOG.put((seed * 17 + r * 104729) % CATALOG.size(), newProduct(seed + r)); return sb.toString(); } static volatile int sink; public static void main(String[] a) throws Exception { int catalogN = Integer.parseInt(a[0]); // live objects String mode = a[1]; // closed | open int threads = Integer.parseInt(a[2]); int seconds = Integer.parseInt(a[3]); int ratePerThread = a.length > 4 ? Integer.parseInt(a[4]) : 250; long t0 = System.nanoTime(); fillCatalog(catalogN); System.out.printf("catalog filled: %d products in %d ms%n", catalogN, (System.nanoTime() - t0) / 1_000_000); // short warm-up (not measured) long warmEnd = System.nanoTime() + 4_000_000_000L; int s = 0; while (System.nanoTime() < warmEnd) sink += handleRequest(s++, 200).length(); long[][] lat = new long[threads][]; long[] counts = new long[threads]; Thread[] ts = new Thread[threads]; long start = System.nanoTime() + 200_000_000L; long end = start + seconds * 1_000_000_000L; for (int t = 0; t < threads; t++) { final int id = t; ts[t] = new Thread(() -> { long[] l = new long[seconds * (mode.equals("open") ? ratePerThread : 20000) + 1000]; int n = 0; int seed = id * 1_000_003; if (mode.equals("open")) { long interval = 1_000_000_000L / ratePerThread; long intended = start + id * (interval / threads); while (intended < end && n < l.length) { long now; while ((now = System.nanoTime()) < intended) { long w = intended - now; if (w > 200_000) LockSupportPark(w - 100_000); } sink += handleRequest(seed++, 200).length(); l[n++] = System.nanoTime() - intended; // charged from INTENDED start intended += interval; } } else { while (System.nanoTime() < start) { } while (System.nanoTime() < end && n < l.length) { long b = System.nanoTime(); sink += handleRequest(seed++, 200).length(); l[n++] = System.nanoTime() - b; } } lat[id] = Arrays.copyOf(l, n); counts[id] = n; }); ts[t].start(); } for (Thread t : ts) t.join(); int total = 0; for (long[] l : lat) total += l.length; long[] all = new long[total]; int k = 0; for (long[] l : lat) { System.arraycopy(l, 0, all, k, l.length); k += l.length; } Arrays.sort(all); double secs = seconds; System.out.printf("RESULT mode=%s threads=%d requests=%d throughput=%.0f/s p50=%.2fms p95=%.2fms p99=%.2fms p99.9=%.2fms max=%.2fms%n", mode, threads, total, total / secs, pct(all, .50), pct(all, .95), pct(all, .99), pct(all, .999), all[all.length - 1] / 1e6); } static void LockSupportPark(long ns) { java.util.concurrent.locks.LockSupport.parkNanos(ns); } static double pct(long[] sorted, double p) { int idx = (int) Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1); return sorted[Math.max(idx, 0)] / 1e6; } }
Java - LeakLab.java (complete, as run)
import java.util.*; import java.util.concurrent.*; /** Three classic leaks in an OrderService-shaped program. Usage: java LeakLab map|listener|threadlocal */ public class LeakLab { record OrderSummary(long id, String customer, byte[] payload) {} // Leak 1: a static "cache" with no eviction, keyed by an object WITHOUT equals/hashCode static final Map<Object, OrderSummary> CACHE = new HashMap<>(); static class RequestKey { final long id; RequestKey(long id) { this.id = id; } } // identity equals -> never hits // Leak 2: listeners registered per request, never unregistered interface OrderListener { void onOrder(long id); } static final List<OrderListener> LISTENERS = new ArrayList<>(); // Leak 3: ThreadLocal buffer in a pool whose threads never die, never remove()d static final ThreadLocal<byte[]> BUF = new ThreadLocal<>(); static long used() { Runtime r = Runtime.getRuntime(); return (r.totalMemory() - r.freeMemory()) >> 20; } public static void main(String[] a) throws Exception { String mode = a[0]; System.out.println("pid=" + ProcessHandle.current().pid() + " mode=" + mode); ExecutorService pool = Executors.newFixedThreadPool(64); long id = 0; long lastPrint = System.nanoTime(); try { while (true) { final long n = id++; switch (mode) { case "map" -> CACHE.put(new RequestKey(n), new OrderSummary(n, "cust-" + (n % 1000), new byte[2048])); case "listener" -> { byte[] ctx = new byte[2048]; LISTENERS.add(x -> { if (x == ctx.length) System.out.println(); }); } case "threadlocal" -> { pool.submit(() -> { BUF.set(new byte[8 << 20]); }).get(); // 8 MB per worker thread, never removed System.out.printf("worker threads=%d heapUsed=%d MB%n", n + 1, used()); Thread.sleep(100); } } if (!mode.equals("threadlocal") && id % 10 == 0) Thread.sleep(1); if (!mode.equals("threadlocal") && System.nanoTime() - lastPrint > 1_000_000_000L) { lastPrint = System.nanoTime(); System.out.printf("requests=%d heapUsed=%d MB%n", id, used()); } } } catch (OutOfMemoryError e) { CACHE.clear(); LISTENERS.clear(); System.out.println("CAUGHT: " + e); } } }
Things to try next
  • Run GcLab with -Xlog:gc+phases=debug and find which phase (Object Copy, Update RS, Scan RS) dominates a G1 young pause.
  • Raise the catalog to 10,000,000 products and watch the live set (and the pause) grow; then halve it.
  • Add -XX:MaxGCPauseMillis=50 and see whether G1 trades throughput for pauses; compare stopped time, not just p99.
  • Fix leak 1 with a LinkedHashMap subclass overriding removeEldestEntry and confirm the post-GC floor flattens.
11
Chapter Eleven - Interview Angles

Memory and GC Questions with Evidence

  • Q: How do you decide between G1, ZGC and Parallel?
    By the metric you pay for. Throughput-first batch work: Parallel. General services on 2+ CPUs: G1 (default). Strict p99 on big heaps with spare CPU: generational ZGC (JDK 21: -XX:+ZGenerational; default in JDK 23). Measure stopped time from -Xlog:safepoint on your live set: here max stops were ~19 ms (G1, median) vs under 1 ms (ZGC) vs ~100 ms (Parallel/Serial).
  • Q: What is the generational hypothesis and why does it matter for tuning?
    Most objects die young, so a young collection copies only the small live fraction and its cost depends on live objects, not garbage. It means short-lived garbage is cheap while long-lived churn (a cache that keeps replacing entries) is expensive - and that allocation rate sets collection frequency.
  • Q: p99 spikes every few seconds but CPU is fine. First step?
    Turn on GC logging and line the pause timestamps up with the latency spikes. If they match, change one variable at a time: reduce allocation, add heap headroom, then consider another collector.
  • Q: Why can a pod be OOMKilled with a half-empty heap?
    The cgroup limit covers heap + metaspace + code cache + thread stacks + direct buffers + GC structures + native libraries. NMT showed 300 MB of direct buffers, 75 MB of GC structures and 53 MB of thread stacks outside a 1 GB heap in our lab.
  • Q: What do -Xms, -Xmx and MaxRAMPercentage do and what are good values?
    -Xms/-Xmx set initial/max heap; MaxRAMPercentage (default 25) sets max as a fraction of the container/host RAM. For servers pin initial = max and leave 30-45% of the container for non-heap.
  • Q: How do you find a memory leak without a profiler?
    Watch the post-GC floor in the GC log, take two jcmd PID GC.class_histogram snapshots minutes apart and diff, then a heap dump analysed in MAT (dominator tree) to find the owning reference.
  • Q: What does a Full GC in the log mean?
    The collector failed to reclaim fast enough (heap too small, promotion failure, humongous allocation, leak) and fell back to a single full compaction. Fix the cause: headroom, allocation rate or leak - not the pause.
  • Q: Does G1's MaxGCPauseMillis guarantee a pause limit?
    No, it is a soft goal G1 tries to meet by sizing the young generation and choosing regions; a large live set or high allocation rate will exceed it. Our G1 median max stop was ~19 ms against the default 200 ms goal, with outliers.
  • Q: Why are ThreadLocals dangerous in pooled threads?
    Pool threads live as long as the JVM, so values left in a ThreadLocal live too (8 MB x 28 threads exhausted a 256 MB heap in our run). Always remove() in finally; with virtual threads avoid heavy per-thread state.
12
Chapter Twelve - Summary

Cheat Sheet and Recap

Cheat sheet
SymptomLikely causeEvidenceFix
p99 spikes every few seconds, CPU lowYoung/mixed GC pausesGC log pause timestamps = latency spikesReduce allocation rate; more headroom; ZGC/tuned G1
Constant GC, rising CPU, "Pause Full" in logHeap too small or leakAfter-GC floor near -XmxIncrease heap; find leak with histogram
OOMKilled, heap graphs fineNon-heap memoryNMT summary; RSS vs heapBudget non-heap; MaxDirectMemorySize; fewer threads
Staircase in post-GC heapLeakClass histogram diff; heap dumpBound caches; unregister; ThreadLocal.remove()
Throughput drops after switching to ZGC/ShenandoahBarrier + concurrent CPU cost, no headroomclosed-loop throughput; allocation stallsMore heap/CPU, or generational ZGC / G1
Many tiny young GCsHigh allocation rateAllocation rate from log; JFR allocation profileRemove garbage in hot paths
∑ JVM memory and GC - what to remember
  • GC cost follows live data and allocation rate, not garbage volume. Garbage is cheap until Eden fills.
  • Read the log first: pause type, before->after, stop time. In our run G1 stopped for 6 pauses (12.9 ms average) in 12 s.
  • Compare collectors by stopped time from -Xlog:safepoint: Serial/Parallel stopped for ~100 ms, G1 ~19 ms, ZGC/Shenandoah only a few ms or less (median of max stops, shared host).
  • Concurrent non-generational collectors need headroom and CPU; with a 1.1 GB live set they had the lowest throughput. Generational ZGC (JDK 21 opt-in) fixed that in our runs.
  • Headroom is performance: a heap barely above the live set means constant collection.
  • Allocate less first: removing intermediates and String.format cut the allocation rate 5.9x.
  • The container limit covers the whole process - budget heap + metaspace + code + stacks + direct + GC.
  • A leak is a reachable reference. Watch the post-GC floor; histogram, diff, heap dump.
Related pages: