JVM Tuning skill for Claude Code
GC logs and JFR first, then a short flag set with reasons. Claude starts by recording the JDK version, collector, heap, container limits and a stated latency goal, then gathers GC logs and a Flight Recorder capture under real load. It classifies the problem, such as humongous allocations, an undersized heap, native memory overrun or CPU throttling, fixes allocation causes before flags, and ends with a short flag set where every entry has a reason.
- name
jvm-tuning - size183 lines · ≈2,600 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Tunes JVM performance from evidence — GC logs, JDK Flight Recorder, async-profiler, and native memory tracking — choosing G1, generational ZGC, Parallel, or Serial for the latency goal, sizing heap and non-heap memory for containers with MaxRAMPercentage, diagnosing long pauses, humongous allocations, OOM kinds, and CPU throttling, and trimming startup with CDS and AOT caches on JDK 21 and 25. Use when a Java, Kotlin, or Scala service has GC pauses, high GC CPU, OOMKilled pods, slow startup, or when reviewing JVM flags in a Dockerfile or Helm chart. Try asking
- Our Spring Boot pods keep getting OOMKilled but there is no OutOfMemoryError in the logs
- Review the JVM flags in this Dockerfile — half of them look ancient
- G1 pauses hit 800 ms during traffic peaks. Should we switch to ZGC?
Use it when
- A JVM service shows long GC pauses or high GC CPU
- Pods are OOMKilled or throttled and heap settings are unclear
- Choosing between G1, ZGC, Parallel or Serial for a workload
- Reviewing JAVA_OPTS in a Dockerfile, Helm chart or startup script
Skip it when
- You need to find what retains memory in a heap dump — use memory-leak-hunting
- The slowness is in database queries the JVM is waiting on — use sql-query-tuning
How to check the result
Rerun the same load for long enough to see several old-gen cycles and compare pause p99, GC CPU share, RSS against the container limit and service latency.
The common mistake it prevents
Leaving MaxRAMPercentage at its 25% default in a dedicated container, or setting Xmx near the container limit and forgetting metaspace, threads and direct buffers.
The JVM is tuned when there is a written goal (p99 GC pause, throughput, memory footprint, startup time), GC logs and a JFR recording from production-like load show the goal is met, and the flag set is short enough that every flag has a reason. Most “tuning” is removing stale flags copied from old blog posts and sizing memory correctly for the container.
Step 0 — what is actually running
java -version # JDK major; 21 and 25 are the current LTS lines
jcmd <pid> VM.command_line # flags as launched
jcmd <pid> VM.flags -all | grep -E 'Use.*GC |MaxHeapSize|MaxRAMPercentage|ActiveProcessorCount'
jcmd <pid> GC.heap_info
java -XX:+PrintFlagsFinal -version | grep -E 'UseG1GC|UseZGC|UseSerialGC|MaxHeapSize' # ergonomic defaults on this box
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null # container limits (cgroup v2)
Write down: JDK version, GC in use, heap max, container memory and CPU limits, and the service’s latency/throughput goal. If any is unknown, find out before changing flags.
Workflow
- [ ] 1. Inventory version, flags, limits, goal (Step 0)
- [ ] 2. Turn on GC logging and capture a JFR recording under realistic load
- [ ] 3. Classify the problem (table below)
- [ ] 4. Fix allocation or code causes first; change one flag at a time
- [ ] 5. Re-run the same load; compare pause p99, GC CPU %, RSS, service latency
- [ ] 6. Record the final flag set with a reason per flag
Evidence collection
# GC log (unified logging, JDK 9+), rotated
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
# Continuous JFR with low overhead; dump on demand
-XX:StartFlightRecording=disk=true,maxage=1h,settings=default
jcmd <pid> JFR.dump name=1 filename=/tmp/now.jfr
# Targeted 2-minute profile recording
jcmd <pid> JFR.start name=perf settings=profile duration=120s filename=/tmp/perf.jfr
jfr summary /tmp/perf.jfr
jfr view hot-methods /tmp/perf.jfr # JDK 21+
jfr view gc-pauses /tmp/perf.jfr
jfr print --events jdk.GarbageCollection,jdk.ObjectAllocationSample /tmp/perf.jfr | head -100
# async-profiler for flame graphs (CPU, allocation, locks, wall clock)
asprof -d 30 -e cpu -f /tmp/cpu.html <pid>
asprof -d 30 -e alloc -f /tmp/alloc.html <pid>
Open .jfr files in JDK Mission Control for allocation pressure, lock contention, and
hot methods. Flag catalogs, GC log reading, and AOT cache commands are in
references/jvm-flags.md — read it when writing or reviewing a
flag set or interpreting a GC log line.
Classify the problem
| Evidence | Likely cause | First move |
|---|---|---|
Pause Full (G1 Compaction Pause) or Pause Full lines | Old gen fills: heap too small for live set, or leak | Measure live set (old gen after mixed/full GC); grow heap to ~3× live set or fix the leak (memory-leak-hunting) |
Humongous regions, G1 Humongous Allocation causes | Objects ≥ half a G1 region (big arrays, buffers, JSON blobs) | Stream/chunk large payloads; or raise -XX:G1HeapRegionSize (power of two; max 32 MB before JDK 18, 512 MB since) |
To-space exhausted / evacuation failure | Allocation burst outruns G1 | More heap headroom; reduce allocation rate; check for humongous |
| Young pauses frequent and short, GC CPU > ~10% | High allocation rate | Allocation flame graph; cut churn in the hot path |
| p99 pause target missed with G1 on a large heap (> ~16 GB) | Pause scales with live data copied | Try generational ZGC |
| Pauses fine but throughput low on batch jobs | GC concurrency overhead | Parallel GC |
Long pauses that are not GC (safepoint log shows long time-to-safepoint) | Counted loops, JNI, page faults | Safepoint log; -XX:+AlwaysPreTouch for page-fault stalls |
Pod OOMKilled (exit 137) without a Java OutOfMemoryError | Native memory over the container limit | Native Memory Tracking; lower heap share |
OutOfMemoryError: Java heap space | Heap too small or leak | Heap dump on OOM, retained-size analysis |
OutOfMemoryError: Metaspace | Classloader leak (redeploys, dynamic proxies) | Class histogram over time, classloader retention |
OutOfMemoryError: Direct buffer memory | NIO/Netty buffers | -XX:MaxDirectMemorySize, check buffer release |
unable to create native thread | Thread count vs pids/memory limit | Thread dump; bounded pools or virtual threads |
| CPU throttling in container metrics, slow under load | CPU quota below what GC + JIT threads need | Raise CPU limit to ≥ 2 or set -XX:ActiveProcessorCount and GC threads deliberately |
Choose the collector
| Goal | Collector | Notes |
|---|---|---|
| General services, balanced (default) | G1 (-XX:+UseG1GC) | -XX:MaxGCPauseMillis default 200 ms; set a realistic target (50–200) rather than resizing generations by hand |
| p99 pauses ≤ ~1 ms, heaps from a few GB up | ZGC (-XX:+UseZGC) | Generational by default since JDK 23, the only mode since JDK 24. Costs some throughput and needs more heap headroom |
| Batch throughput, pauses irrelevant | Parallel (-XX:+UseParallelGC) | Highest throughput on multi-core |
| Tiny containers (< 2 CPUs, < ~1.7 GB) | Serial | The JVM picks this automatically below its “server-class” threshold — often by surprise |
| Low pause on OpenJDK builds that ship it | Shenandoah (-XX:+UseShenandoahGC) | Generational mode is a product feature in JDK 25; not in every vendor build |
Pick the collector explicitly in production. Ergonomics choose Serial when the JVM
sees fewer than 2 CPUs or under ~1792 MB of memory, which surprises teams that set small
container limits. (Newer JDKs may change this default; confirm with -Xlog:gc startup
output, which prints Using G1 / Using Serial.)
Memory sizing for containers
The container limit must cover heap + everything else:
container limit ≥ Xmx + metaspace + code cache (~240 MB max) + threads × stack (≈1 MB)
+ direct/NIO buffers + GC native structures + JIT + libc/malloc overhead
- Default
MaxRAMPercentageis 25% — far too small for a dedicated service container. Start at-XX:MaxRAMPercentage=75for heaps of a few GB; go lower (60–70) for thread-heavy or Netty/off-heap-heavy services; measure, do not guess. - For latency-sensitive services set initial = max (
-XX:InitialRAMPercentageequal to max, or-Xms=-Xmx) and consider-XX:+AlwaysPreTouchso pages are faulted at startup, not during the first traffic. - Measure non-heap with Native Memory Tracking:
-XX:NativeMemoryTracking=summary # ~5-10% overhead; enable for the investigation
jcmd <pid> VM.native_memory summary
jcmd <pid> VM.native_memory baseline # later: VM.native_memory summary.diff
- Size the heap from the live set: old-gen occupancy after a full or mixed collection. 2.5–4× live set is a comfortable G1 heap; ZGC wants more headroom.
- JDK 25 makes compact object headers a product option (
-XX:+UseCompactObjectHeaders), shrinking object-heavy heaps noticeably; it is off by default — test it under load. - Always add for diagnosis:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps(writable volume) and-XX:+ExitOnOutOfMemoryErrorso the orchestrator restarts a broken process instead of leaving it half-alive.
CPU and containers
- The JVM reads cgroup v1/v2 CPU and memory limits (cgroup v2 since JDK 15 and late 11/8
updates). Fractional quotas round;
-XX:ActiveProcessorCount=Noverrides. - With a 1-CPU quota, GC and JIT compiler threads compete with request threads; expect throttling under load. Give JVM services at least 2 CPUs of limit, or accept Serial GC and slower warm-up knowingly.
Startup and warm-up
- Class Data Sharing is on by default for JDK classes; AppCDS / the AOT cache extends it to application classes. JDK 24 added AOT class loading and linking; JDK 25 simplified cache creation to a single training run and added AOT method profiling. Commands are in the reference file.
- Warm-up matters for latency SLOs: route a little traffic or run a warm-up script before marking the pod ready, so the JIT compiles hot paths before full load.
Gotchas
- Flags removed in modern JDKs fail startup or are ignored: CMS (
UseConcMarkSweepGC, removed JDK 14),UseParNewGC,PermSize/MaxPermSize,AggressiveOpts,-XX:-UseContainerSupporthacks. Delete them. - Setting
-XmnorNewRatiowith G1 disables its pause-time-driven young sizing. -Xmxlarger than the container limit, orMaxRAMPercentage=90+with a large thread pool, gets the pod OOMKilled with no Java error.-XX:+DisableExplicitGCalso disables theSystem.gc()that direct-buffer cleanup relies on in some libraries; prefer-XX:+ExplicitGCInvokesConcurrentif explicit GCs hurt.- Microbenchmarks without warm-up and dead-code protection lie — use JMH.
- Virtual threads (JDK 21+) make thread-per-request cheap, but
synchronizedblocks pinned carrier threads until JDK 24; on JDK 21 look forjdk.VirtualThreadPinnedevents. - Tuning GC to hide an allocation hotspot: a 3× allocation cut beats any flag.
Verification
- Same load, before and after: GC pause p50/p99/max, GC CPU %, allocation rate,
promotion rate (from GC log or
jfr view gc-pauses), service p99 latency, RSS vs limit. - No
Pause Full, no evacuation failures, no humongous storms during the run. - RSS stays below ~90% of the container limit at peak (NMT summary explains the rest).
- Run long enough to include several old-gen cycles (often 30–60 min), not just the first five minutes.
Output
**Service:** orders-api, JDK 21, 4 CPU / 4 GiB pod, goal p99 GC pause < 50 ms
**Before:** G1, MaxRAMPercentage=25 (1 GiB heap), Full GC every ~6 min (1.4 s), live set ~700 MB
**Changes:** MaxRAMPercentage=70 (2.8 GiB); removed -Xmn512m and -XX:+UseConcMarkSweepGC leftovers;
MaxGCPauseMillis=50; chunked 40 MB report export (was humongous)
**After (60 min load test):** no Full GC; young p99 18 ms; GC CPU 3.1%; RSS 3.5/4 GiB
**Flag set:** -XX:+UseG1GC -XX:MaxRAMPercentage=70 -XX:MaxGCPauseMillis=50
-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=20m
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps -XX:+ExitOnOutOfMemoryError Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/jvm-flags.md
# JVM flag catalog, GC log reading, and AOT cache commands
Read when writing or reviewing a JVM flag set, interpreting GC log lines, or setting up
startup caching. Targets JDK 21 and 25 (current LTS lines). Verify any flag on the
installed JDK with `java -XX:+PrintFlagsFinal -version | grep <Flag>`; unknown `-XX`
flags abort startup.
## Baseline flag set for a containerized service
```bash
JAVA_TOOL_OPTIONS="\
-XX:+UseG1GC \
-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=70 \
-XX:MaxGCPauseMillis=100 \
-Xlog:gc*,safepoint:file=/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
-XX:StartFlightRecording=disk=true,maxage=1h,settings=default,dumponexit=true,filename=/logs/exit.jfr \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps \
-XX:+ExitOnOutOfMemoryError"
```
Every flag beyond this needs evidence from a GC log or recording.
## Flags by area
### Collector selection
| Flag | Use |
|---|---|
| `-XX:+UseG1GC` | Default on server-class machines; set explicitly anyway |
| `-XX:+UseZGC` | Generational ZGC (default mode JDK 23; `-XX:+ZGenerational` obsolete from JDK 24) |
| `-XX:+UseParallelGC` | Throughput batch jobs |
| `-XX:+UseSerialGC` | Single-CPU, small-heap containers, CLIs |
| `-XX:+UseShenandoahGC` + `-XX:ShenandoahGCMode=generational` | Generational Shenandoah (product in JDK 25, vendor builds permitting) |
### Heap sizing
| Flag | Use |
|---|---|
| `-XX:MaxRAMPercentage=N` | Max heap as % of container memory (default 25) |
| `-XX:InitialRAMPercentage=N` | Initial heap %; equal to max for steady latency |
| `-Xms` / `-Xmx` | Absolute sizes; override the percentages |
| `-XX:+AlwaysPreTouch` | Fault in heap pages at startup (slower start, no first-traffic stalls) |
| `-XX:SoftMaxHeapSize` | ZGC: soft target below Xmx to return memory when idle |
### G1 specifics
| Flag | Use |
|---|---|
| `-XX:MaxGCPauseMillis=N` | Pause goal (default 200); G1 sizes young gen to meet it |
| `-XX:G1HeapRegionSize=N` | Larger regions reduce humongous allocations |
| `-XX:InitiatingHeapOccupancyPercent=N` | Start marking earlier if old gen fills before marking completes (G1 adapts this by default) |
| `-XX:G1ReservePercent=N` | More to-space reserve against evacuation failure |
| Avoid `-Xmn`, `-XX:NewRatio` | Pin young size and defeat the pause goal |
### Threads and CPU
| Flag | Use |
|---|---|
| `-XX:ActiveProcessorCount=N` | Override detected CPUs (fractional quotas, shared nodes) |
| `-XX:ParallelGCThreads=N`, `-XX:ConcGCThreads=N` | Only when GC threads demonstrably fight request threads |
| `-Xss512k` | Smaller platform thread stacks for thread-heavy apps (test deep recursion) |
### Non-heap limits
| Flag | Use |
|---|---|
| `-XX:MaxMetaspaceSize=N` | Cap metaspace to surface classloader leaks early |
| `-XX:ReservedCodeCacheSize=N` | JIT code cache (default up to 240 MB with tiered compilation) |
| `-XX:MaxDirectMemorySize=N` | NIO direct buffers (defaults to max heap size) |
| `-XX:NativeMemoryTracking=summary` | Enable NMT for `jcmd VM.native_memory` |
### Diagnostics
| Flag / command | Use |
|---|---|
| `-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps` | Heap dump on OOM (needs disk ≈ heap size) |
| `-XX:+ExitOnOutOfMemoryError` | Exit so the orchestrator restarts the process |
| `-XX:+UseCompactObjectHeaders` | JDK 25 product option; smaller objects; off by default |
| `jcmd <pid> GC.class_histogram` | Live object counts by class (triggers full GC) |
| `jcmd <pid> GC.heap_dump /dumps/h.hprof` | Heap dump (pauses the app; large file) |
| `jcmd <pid> Thread.print` | Thread dump (deadlocks, pool exhaustion) |
| `jcmd <pid> Thread.dump_to_file -format=json /tmp/t.json` | Includes virtual threads (JDK 21+) |
## Removed or obsolete flags to delete on sight
`-XX:+UseConcMarkSweepGC`, `-XX:+UseParNewGC`, `-XX:+CMSClassUnloadingEnabled`,
`-XX:PermSize`, `-XX:MaxPermSize`, `-XX:+AggressiveOpts`, `-XX:+UseCGroupMemoryLimitForHeap`,
`-XX:+UnlockExperimentalVMOptions` (unless a specific experimental flag is justified),
`-XX:+PrintGCDetails` / `-XX:+PrintGCDateStamps` / `-Xloggc:` (replaced by `-Xlog:gc*`),
`-XX:+ZGenerational` (obsolete once generational became the only ZGC mode).
## Reading G1 log lines
```
[12.345s][info][gc] GC(41) Pause Young (Normal) (G1 Evacuation Pause) 1520M->410M(2048M) 14.2ms
[98.765s][info][gc] GC(87) Pause Young (Concurrent Start) (G1 Humongous Allocation) 1700M->900M(2048M) 22.0ms
[99.100s][info][gc] GC(88) Concurrent Mark Cycle 310.5ms
[140.2s][info][gc] GC(95) Pause Young (Mixed) (G1 Evacuation Pause) 1400M->780M(2048M) 31.0ms
[300.1s][info][gc] GC(140) Pause Full (G1 Compaction Pause) 2040M->1890M(2048M) 1450.3ms
```
- `before->after(committed)`: the "after" of mixed/full collections approximates the live
set. Full GC freeing little (2040M→1890M) means the heap is too small or there is a leak.
- `(G1 Humongous Allocation)` as a cause: large objects trigger cycles — find them in the
JFR `jdk.ObjectAllocationSample` / async-profiler alloc flame graph.
- `To-space exhausted` or `Evacuation Failure`: not enough free regions to copy into.
- GC CPU share ≈ sum of pause times + concurrent work / wall time; above ~10% means
allocation pressure or an undersized heap.
ZGC logs `Garbage Collection (...)` cycles with mostly concurrent phases; watch for
`Allocation Stall` lines — they mean the application outran the collector (add heap or
cut allocation).
## AOT cache (startup)
JDK 24 (JEP 483), three steps:
```bash
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar # training run, exercise the app
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar app.jar
java -XX:AOTCache=app.aot -jar app.jar # production
```
JDK 25 (JEP 514) adds a one-step creation flag for the training run:
```bash
java -XX:AOTCacheOutput=app.aot -jar app.jar # training run writes the cache on exit
java -XX:AOTCache=app.aot -jar app.jar
```
JDK 25 (JEP 515) also stores method profiles in the cache so the JIT warms faster. The
cache must be created with the same JDK build and the same classpath as production;
build it in the Docker image build stage with a representative training workload.
On JDK 21 use AppCDS (`-XX:ArchiveClassesAtExit=app.jsa`, then `-XX:SharedArchiveFile=app.jsa`).
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.