GC tuning
The honest answer to “how do I tune the GC?” on a modern JDK is: pick the right one, then mostly leave it alone. The default (G1) is well-tuned; reaching for flags before reading the logs is how you make things worse.
Pick a GC
Section titled “Pick a GC”JDK 21 ships three production GCs. Choose by pause-time tolerance, not gut feel:
| GC | Default? | Pause time | Heap range | Use when… |
|---|---|---|---|---|
| G1 | yes | ~10–200 ms | 4 GB – ~64 GB | Default. Balanced. Don’t switch without a reason. |
| ZGC | no | sub-ms (<1 ms) | ~8 GB – multi-TB | Pause-sensitive services (APIs, gateways), large heaps. |
| Shenandoah | no | sub-ms (<10 ms) | similar to ZGC | Same niche as ZGC; OpenJDK distros that ship it. |
| Parallel | no | seconds (stop-the-world) | small heaps | Batch/throughput jobs where pauses don’t matter. |
| Serial | no | seconds | tiny | Containers <100 MB, CLI tools. Picked automatically. |
# G1 (default — no flag needed)java -XX:+UseG1GC -jar app.jar
# ZGC — generational since JDK 21, prefer itjava -XX:+UseZGC -XX:+ZGenerational -jar app.jar
# Shenandoahjava -XX:+UseShenandoahGC -jar app.jarHeuristic. If p99 latency matters and you have ≥4 GB heap, try ZGC. Otherwise stay on G1.
Set the heap correctly
Section titled “Set the heap correctly”Bigger sin than the wrong GC: wrong heap sizing.
# Pin min == max. JVM won't waste cycles growing the heap; behavior is predictable.-Xms4g -Xmx4gIn containers, prefer the percentage flags so the JVM follows the cgroup memory limit instead of the host’s:
-XX:InitialRAMPercentage=70 -XX:MaxRAMPercentage=70Leave ~25–30% of the container memory for non-heap (metaspace, direct buffers, native code, the GC itself). A JVM OOMKilled by the kernel rarely leaves a useful heap dump.
Read the logs first, tune second
Section titled “Read the logs first, tune second”Enable unified GC logging — the format is stable since JDK 11:
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=20MWhat to look for:
- Pause time (
Pause Young (Normal),Pause Full) — the actual stop-the-world duration. This is the number that hurts. - Allocation rate — climbing rate over hours means a leak or a workload shift; not a tuning problem.
- “to-space exhausted” / “Allocation Failure” under G1 — heap too small or
bursty allocation; raise
-Xmxbefore fiddling with regions. Full GCon G1 or ZGC — should be never in steady state. Seeing one means the heap can’t keep up, or you ranSystem.gc(). Add-XX:+DisableExplicitGCto rule out the latter.
Free, faster than eyeballing logs: drop gc.log into
GCeasy or use jdk.jfr flight recordings:
# 60-second recording, no perceptible overhead.jcmd <pid> JFR.start duration=60s filename=app.jfrjcmd <pid> JFR.dump filename=app.jfrOpen the .jfr in JDK Mission Control.
Common knobs (only after the logs justify them)
Section titled “Common knobs (only after the logs justify them)”# G1: target max pause (default 200 ms). Lower = more frequent, smaller GCs.-XX:MaxGCPauseMillis=100
# G1: region size. Default auto-picks based on heap; only override if you have# huge object allocations triggering humongous-object regions.-XX:G1HeapRegionSize=16m
# ZGC: cap concurrent GC threads on small containers.-XX:ConcGCThreads=2Resist the temptation to copy a flag list from a 2017 blog post. Most of those flags either no-op on modern JDKs or pessimize the default heuristics.
When tuning won’t help
Section titled “When tuning won’t help”These are application problems, not GC problems:
- Allocation rate too high — profile and reduce churn (object pooling, primitive arrays, avoiding boxing in hot paths).
- Long-lived objects that survive into old gen and force mixed/old collections — usually a cache without bounds.
- Memory leak —
Full GChappens, frees little, repeats. Take a heap dump (Thread dump covers the sibling diagnostic) and find the retained set in MAT or JDK Mission Control.