Skip to content

GC tuning

  • performance

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.

JDK 21 ships three production GCs. Choose by pause-time tolerance, not gut feel:

GCDefault?Pause timeHeap rangeUse when…
G1yes~10–200 ms4 GB – ~64 GBDefault. Balanced. Don’t switch without a reason.
ZGCnosub-ms (<1 ms)~8 GB – multi-TBPause-sensitive services (APIs, gateways), large heaps.
Shenandoahnosub-ms (<10 ms)similar to ZGCSame niche as ZGC; OpenJDK distros that ship it.
Parallelnoseconds (stop-the-world)small heapsBatch/throughput jobs where pauses don’t matter.
SerialnosecondstinyContainers <100 MB, CLI tools. Picked automatically.
Terminal window
# G1 (default — no flag needed)
java -XX:+UseG1GC -jar app.jar
# ZGC — generational since JDK 21, prefer it
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar
# Shenandoah
java -XX:+UseShenandoahGC -jar app.jar

Heuristic. If p99 latency matters and you have ≥4 GB heap, try ZGC. Otherwise stay on G1.

Bigger sin than the wrong GC: wrong heap sizing.

Terminal window
# Pin min == max. JVM won't waste cycles growing the heap; behavior is predictable.
-Xms4g -Xmx4g

In containers, prefer the percentage flags so the JVM follows the cgroup memory limit instead of the host’s:

Terminal window
-XX:InitialRAMPercentage=70 -XX:MaxRAMPercentage=70

Leave ~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.

Enable unified GC logging — the format is stable since JDK 11:

Terminal window
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=20M

What 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 -Xmx before fiddling with regions.
  • Full GC on G1 or ZGC — should be never in steady state. Seeing one means the heap can’t keep up, or you ran System.gc(). Add -XX:+DisableExplicitGC to rule out the latter.

Free, faster than eyeballing logs: drop gc.log into GCeasy or use jdk.jfr flight recordings:

Terminal window
# 60-second recording, no perceptible overhead.
jcmd <pid> JFR.start duration=60s filename=app.jfr
jcmd <pid> JFR.dump filename=app.jfr

Open the .jfr in JDK Mission Control.

Common knobs (only after the logs justify them)

Section titled “Common knobs (only after the logs justify them)”
Terminal window
# 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=2

Resist 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.

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 leakFull GC happens, frees little, repeats. Take a heap dump (Thread dump covers the sibling diagnostic) and find the retained set in MAT or JDK Mission Control.