Hands-on lab: read GC logs, compare six collectors by stopped time, size the heap, cut allocation, account for native memory in containers and hunt leaks with real JDK 21 measurements.
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.
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)
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
Allocation path: TLAB, Eden, survivors, old generation
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).
Region
Controlled by
Default on this machine (JDK 21.0.2, 31.7 GB RAM, 12 cores)
Fails with
Java heap
-Xms / -Xmx / -XX:MaxRAMPercentage
MaxHeapSize = 7,932 MB (25% of RAM); InitialHeapSize 496 MB
OutOfMemoryError: Java heap space
Metaspace
-XX:MaxMetaspaceSize (unlimited by default)
CompressedClassSpaceSize = 1 GB reserved
OutOfMemoryError: Metaspace
Code cache
-XX:ReservedCodeCacheSize
240 MB with tiered compilation
JIT stops: "CodeCache is full. Compiler has been disabled"
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
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 log
What it means
Next step
Pause Young (Normal) (G1 Evacuation Pause)
Eden full; live young objects copied; a normal cycle
If 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 marking
Healthy G1 steady state
Pause Full (G1 Compaction Pause)
G1 could not keep up (or humongous/to-space exhaustion): single-threaded-ish full GC
Red flag: heap too small, leak, or IHOP too high
To-space exhausted / Evacuation Failure
No room to copy survivors
More headroom (-Xmx), lower IHOP, fewer humongous objects
Allocation Stall (ZGC)
Application thread waited because GC could not reclaim fast enough
Larger 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
Lab result: maximum throughput (closed loop, 4 threads), requests/s, median of 3 runs
Collector
Max throughput /s (median, range)
p99 ms @1,600/s (median, range)
Max stopped ms (safepoint log)
Total stopped ms in 12 s
p99 ms @4,800/s (overload for some)
Serial
10246 (10058-13606)
27.4 (6.4-77.5)
116.37
552.8
79
Parallel
12700 (9982-18282)
50.5 (0.2-140.5)
96.86
387.8
217
G1 (default)
11164 (10247-17993)
6.9 (0.2-13.3)
19.32
26.4
69
ZGC (non-gen)
4438 (3245-6194)
4.7 (0.3-10.7)
0.04
0.2
4850
ZGC generational
12055 (8976-14552)
0.9 (0.3-9.5)
0.28
1.0
4
Shenandoah
4077 (2663-5049)
9.7 (1.3-200.1)
4.44
10.0
7727
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
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").
Collector
How it works
Pause behaviour
Cost / when to choose
Version notes
Serial
Single-threaded copying young + mark-compact old
Pauses grow with heap, all threads stopped
Smallest footprint: 1 CPU, tiny containers (JVM picks it when it sees 1 CPU - verified below)
Concurrent mark/relocate with coloured pointers and load barriers
Sub-millisecond root pauses, independent of heap size
Needs spare CPU and heap headroom; best for very large heaps / strict p99
Production 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
Shenandoah
Concurrent evacuation with load-reference barriers
Very short pauses, mostly independent of heap size
Similar 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 ms
Longest pause ms
Max throughput /s (closed loop)
p99 ms @4,800/s open (median, range)
1.3 GB (live set 1.1 GB: 85% full)
76
1808
83
9571 (8269-9571)
2422.4 (25.4-2422.4)
1.8 GB
30
580
44
16375 (11007-16375)
77.8 (11.8-77.8)
3 GB (our baseline)
7
319
121
11164 (10247-17993)
69.4 (5.3-170.9)
6 GB
4
289
91
16355 (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
Shell - sizing flags (real output: how the JVM derives MaxHeapSize)
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 = newArrayList<>();
for (int i = 0; i < items; i++) {
Product p = CATALOG.get((seed * 31 + i * 7) % CATALOG.size());
list.add(newLineItem(p.id(), p.name() + "/" + i, 1 + (i % 5), p.prices()[i % 4] * 1.07)); // concat + object
}
StringBuilder sb = newStringBuilder(); // grows 16 -> ... -> 40 KBfor (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 = newStringBuilder(items * 96); // pre-sized: no regrowth copiesfor (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
}
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)
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
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
MDC/security context not cleared, SimpleDateFormat caches, large per-thread buffers
Classloader leak
metaspace, not heap
Growth in OutOfMemoryError: Metaspace; many duplicate classes
Fix redeploy/proxy generation; set MaxMetaspaceSize
Dynamic 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/
Decision
Default
Why change it
Verify with
MaxRAMPercentage
25%
Default wastes 75% of the container on a heap-only service
GC log after-values vs heap size; RSS vs limit
InitialRAMPercentage
1.5625%
Avoid early heap resizes: set equal to max on servers
-Xlog:gc+ergo*
MaxDirectMemorySize
= max heap
Netty-heavy services can double memory silently
NMT "Other" category
Collector
G1 (2+ CPUs) / Serial (1 CPU)
Latency goals; very large heaps
Chapter 4 method on your workload
GC logging
off
Evidence for every future incident
Pause 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)
*/
publicclassGcLab {
record Product(long id, String name, double[] prices) {}
record LineItem(long sku, String title, int qty, double price) {}
staticfinalConcurrentHashMap<Integer, Product> CATALOG = newConcurrentHashMap<>();
staticvoidfillCatalog(int n) {
for (int i = 0; i < n; i++) CATALOG.put(i, newProduct(i));
}
staticProductnewProduct(long i) {
returnnewProduct(i, "product-" + i + "-" + Long.toHexString(i * 2654435761L), newdouble[] {i, i + 1, i + 2, i + 3});
}
staticStringhandleRequest(int seed, int items) {
List<LineItem> list = newArrayList<>();
for (int i = 0; i < items; i++) {
Product p = CATALOG.get((seed * 31 + i * 7) % CATALOG.size());
list.add(newLineItem(p.id(), p.name() + "/" + i, 1 + (i % 5), p.prices()[i % 4] * 1.07));
}
StringBuilder sb = newStringBuilder();
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;
publicstaticvoidmain(String[] a) throws Exception {
int catalogN = Integer.parseInt(a[0]); // live objectsString mode = a[1]; // closed | openint 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 = newlong[threads][];
long[] counts = newlong[threads];
Thread[] ts = newThread[threads];
long start = System.nanoTime() + 200_000_000L;
long end = start + seconds * 1_000_000_000L;
for (int t = 0; t < threads; t++) {
finalint id = t;
ts[t] = newThread(() -> {
long[] l = newlong[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 = newlong[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);
}
staticvoidLockSupportPark(long ns) { java.util.concurrent.locks.LockSupport.parkNanos(ns); }
staticdoublepct(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 */
publicclassLeakLab {
record OrderSummary(long id, String customer, byte[] payload) {}
// Leak 1: a static "cache" with no eviction, keyed by an object WITHOUT equals/hashCodestaticfinalMap<Object, OrderSummary> CACHE = newHashMap<>();
staticclassRequestKey { finallong id; RequestKey(long id) { this.id = id; } } // identity equals -> never hits// Leak 2: listeners registered per request, never unregistered
interface OrderListener { voidonOrder(long id); }
staticfinalList<OrderListener> LISTENERS = newArrayList<>();
// Leak 3: ThreadLocal buffer in a pool whose threads never die, never remove()dstaticfinalThreadLocal<byte[]> BUF = newThreadLocal<>();
staticlongused() { Runtime r = Runtime.getRuntime(); return (r.totalMemory() - r.freeMemory()) >> 20; }
publicstaticvoidmain(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) {
finallong n = id++;
switch (mode) {
case "map" -> CACHE.put(newRequestKey(n), newOrderSummary(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 removedSystem.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
Symptom
Likely cause
Evidence
Fix
p99 spikes every few seconds, CPU low
Young/mixed GC pauses
GC log pause timestamps = latency spikes
Reduce allocation rate; more headroom; ZGC/tuned G1
Throughput drops after switching to ZGC/Shenandoah
Barrier + concurrent CPU cost, no headroom
closed-loop throughput; allocation stalls
More heap/CPU, or generational ZGC / G1
Many tiny young GCs
High allocation rate
Allocation rate from log; JFR allocation profile
Remove 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:
Measure First - percentiles, why averages hide GC pauses.