JVM Tuning: Heap Sizing, GC Selection & Key JVM Flags
Getting the JVM to behave well under production load is about choosing the right settings before problems appear. This article walks through the memory areas you can control, how to size the heap correctly, which garbage collector fits your workload, and the most impactful flags for G1GC, ZGC, and container deployments, with GC log analysis to verify every decision.
Contents
JVM Memory Areas
The HotSpot JVM divides memory into several distinct regions. Understanding each one is essential before you change any flag, because a flag that helps one area can starve another.
| Region | What lives there | Controlled by |
|---|---|---|
| Heap | Object instances and arrays (Young + Old gen) | -Xms-Xmx |
| Young Gen | Eden + two Survivor spaces; most objects die here | -Xmn-XX:NewRatio |
| Old Gen | Long-lived objects that survived Young GC | remainder of heap |
| Metaspace | Class metadata, method bytecode, interned strings (Java 8+) | -XX:MaxMetaspaceSize |
| Code Cache | JIT-compiled native code | -XX:ReservedCodeCacheSize |
| Thread Stacks | One stack per thread (default 512 KB–1 MB) | -Xss |
| Direct / Off-heap | NIO ByteBuffers, Unsafe allocations, JVM internals | OS limit / -XX:MaxDirectMemorySize |
Heap Sizing: -Xms, -Xmx & Young Generation
Setting the heap size correctly is the single highest-impact JVM tuning action. Too small causes frequent GC; too large causes long pause times and wastes memory. Key heap flags:
: initial (minimum) heap size. Set equal to-Xms<size> to avoid resize overhead in production.-Xmx : maximum heap size. The most important single JVM flag.-Xmx<size> : fixes the Young Generation size (use instead of-Xmn<size> when you need precision).-XX:NewRatio : Old:Young ratio. Default 2 means Old = 2×Young. Lower values give more Eden space (reduces promotion frequency).-XX:NewRatio=N : Eden:Survivor ratio. Default 8 means each Survivor = Eden/8. Raise if many objects survive but die before tenuring.-XX:SurvivorRatio=N
# Production recommendation: lock min == max, tune Young explicitly
java -Xms4g -Xmx4g \
-Xmn1g \
-XX:SurvivorRatio=6 \
-jar app.jar
# Verify actual sizes at startup
java -Xms4g -Xmx4g -XX:+PrintFlagsFinal -version 2>&1 | grep -E "HeapSize|NewSize|SurvivorRatio"
kKmMgGgChoosing a Garbage Collector
Java 21 ships four production-grade collectors. The right choice depends on whether your workload is more sensitive to throughput (batch, data processing) or latency (APIs, trading, real-time).
| Collector | Flag | Pause model | Best for |
|---|---|---|---|
| Serial | -XX:+UseSerialGC | Stop-the-world all phases | Single-core containers, tiny heaps (<256 MB) |
| Parallel | -XX:+UseParallelGC | Stop-the-world, multi-threaded | Batch jobs: maximise throughput, tolerate pauses |
| G1GC | -XX:+UseG1GC | Mostly concurrent, bounded pauses | General-purpose API servers, heaps 4–32 GB |
| ZGC | -XX:+UseZGC | Sub-millisecond, fully concurrent | Low-latency APIs, very large heaps (>32 GB) |
| Shenandoah | -XX:+UseShenandoahGC | Sub-millisecond, concurrent compaction | Low-latency with Red Hat JDKs; similar to ZGC |
# Explicitly enable G1GC (default since Java 9 but explicit is clearer)
java -XX:+UseG1GC -jar app.jar
# ZGC for low-latency services (Java 15+ generational ZGC available Java 21)
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar
G1GC Tuning Flags
G1GC divides the heap into fixed-size regions (1–32 MB each) and picks the regions with the most garbage to collect first: hence "Garbage-First". It targets a pause goal rather than a fixed Young Generation size. The most important flags:
: pause goal in milliseconds (default 200). G1 tries to meet this but it is a hint, not a hard limit. Aggressive values (<50 ms) may reduce throughput.-XX:MaxGCPauseMillis=N : region size in MB (1–32, power of two). Auto-computed from heap size; set manually only when you have many large objects.-XX:G1HeapRegionSize=N /-XX:G1NewSizePercent : floor and ceiling for Young Gen as % of heap (defaults 5% / 60%).-XX:G1MaxNewSizePercent (IHOP, default 45): heap occupancy % that triggers a concurrent marking cycle. Lower it if you see Full GCs.-XX:InitiatingHeapOccupancyPercent=N : how many mixed GC pauses to spread old-gen collection over (default 8).-XX:G1MixedGCCountTarget=N /-XX:ParallelGCThreads : STW and concurrent GC thread counts (auto-calculated from CPU count).-XX:ConcGCThreads
# Typical G1GC production configuration for a 4-core API server with 8 GB heap
java -XX:+UseG1GC \
-Xms8g -Xmx8g \
-XX:MaxGCPauseMillis=100 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1HeapRegionSize=8m \
-XX:G1NewSizePercent=20 \
-XX:G1MaxNewSizePercent=40 \
-jar app.jar
-XX:MaxGCPauseMillisZGC & Shenandoah Flags
ZGC (Java 15+ production, Java 21 generational) performs all expensive work concurrently with application threads using colored pointer tricks. Its pauses are typically under 1 ms regardless of heap size, making it ideal for latency-sensitive workloads. Shenandoah uses a similar approach with Brooks forwarding pointers and is available on Red Hat-based JDKs.
| Flag | Default | Purpose |
|---|---|---|
-XX:+UseZGC | off | Enable ZGC |
-XX:+ZGenerational | off (Java 21 default on Java 23+) | Enable generational ZGC for much better throughput |
-XX:SoftMaxHeapSize | == -Xmx | ZGC tries to keep heap below this: leaves headroom for spikes |
-XX:ZCollectionInterval=N | 0 (disabled) | Force a GC cycle every N seconds even when idle |
-XX:ZUncommitDelay=N | 300 s | Seconds before ZGC returns unused heap pages to OS |
-XX:+UseShenandoahGC | off | Enable Shenandoah (alternative to ZGC) |
-XX:ShenandoahGCMode=iu | satb | Incremental-update mode: lower allocation spike sensitivity |
# ZGC — low-latency service (Java 21)
java -XX:+UseZGC -XX:+ZGenerational \
-Xms16g -Xmx16g \
-XX:SoftMaxHeapSize=14g \
-XX:ZCollectionInterval=120 \
-jar app.jar
# Shenandoah — alternative for compatible JDKs
java -XX:+UseShenandoahGC \
-XX:ShenandoahGCHeuristics=adaptive \
-Xms8g -Xmx8g \
-jar app.jar
Metaspace Tuning
Metaspace holds class metadata: method bytecode, constant pools, and field/method descriptors. Unlike PermGen (pre-Java 8), Metaspace grows dynamically from native memory. Without a cap it can grow unboundedly in applications that use heavy reflection, proxies (Spring, Hibernate), or dynamic class generation.
: initial commit size and the first GC trigger threshold (default ~20–25 MB). Not a minimum: just the first resize point.-XX:MetaspaceSize=N : hard cap. Without this, a class-loader leak will consume all native memory. Set it to 256–512 MB for most apps.-XX:MaxMetaspaceSize=N : the region for compressed class pointers (default 1 GB, requires-XX:CompressedClassSpaceSize=N ).-XX:+UseCompressedClassPointers
# Cap Metaspace to prevent unbounded growth from class-loader leaks
java -XX:MetaspaceSize=128m \
-XX:MaxMetaspaceSize=256m \
-jar app.jar
# Diagnose Metaspace at runtime
jcmd <pid> VM.metaspace
jcmd <pid> GC.heap_info
-XX:MaxMetaspaceSizeUnified GC Logging
Java 9+ replaced the old -verbose:gc-XX:+PrintGCDetails-Xlog
# Recommended GC log configuration for production
java -Xlog:gc*:file=/var/log/app/gc.log:time,level,tags:filecount=5,filesize=20m \
-jar app.jar
# Quick console GC summary during development
java -Xlog:gc::time -jar app.jar
# Include safepoint pauses (important for diagnosing stop-the-world beyond GC)
java -Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags \
-jar app.jar
Key log tags to watch:
: basic pause summary (type, pause time, heap before/after)gc : heap occupancy after each collectiongc,heap : G1 phase breakdown (marking, evacuation, cleanup)gc,phases : G1 humongous object allocations (>½ region size)gc,humongous : all stop-the-world pauses including non-GC ones (deoptimisation, thread dumps)safepoint
Container-Aware Settings
Running Java in Docker or Kubernetes without container awareness causes the JVM to read host CPU and memory, leading to over-sized thread pools and under-sized heap relative to the container limit. Since Java 10 the JVM is container-aware by default, but a few flags ensure correct behaviour.
# Explicitly enable container awareness (default since Java 10, but make it visible)
java -XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:MinRAMPercentage=25.0 \
-jar app.jar
# Limit GC threads to actual container CPU quota (avoid spawning 32 threads on a 2-core pod)
java -XX:+UseContainerSupport \
-XX:ActiveProcessorCount=2 \
-XX:MaxRAMPercentage=75.0 \
-jar app.jar
| Flag | Purpose |
|---|---|
-XX:+UseContainerSupport | Read cgroup limits instead of host specs (default on Java 10+) |
-XX:MaxRAMPercentage=75.0 | Set Xmx as % of container memory limit |
-XX:InitialRAMPercentage=50.0 | Set Xms as % of container memory limit |
-XX:ActiveProcessorCount=N | Override CPU count (useful when CPU quota is fractional) |
-XX:MaxRAMPercentage-XmxJIT & Startup Flags
The JIT compiler (C1 + C2) optimises hot code at runtime. A handful of flags control compilation aggressiveness, tiering, and startup warm-up behaviour.
: enabled by default; C1 compiles quickly, C2 optimises hot methods deeply.-XX:+TieredCompilation : invocations before a method is JIT-compiled (default 10,000 with tiered off). With tiered compilation, thresholds per tier take over.-XX:CompileThreshold=N : max code cache size (default 240–256 MB). If it fills up the JVM de-optimises compiled code. Raise for large codebases.-XX:ReservedCodeCacheSize=N /-Xshare:dump : Class Data Sharing (CDS). Dump the JVM class archive once, then load it mapped read-only on subsequent startups to save 200–500 ms.-Xshare:on
# Create App CDS archive (Java 13+)
java -XX:ArchiveClassesAtExit=app-cds.jsa -jar app.jar && \
# Use the archive on subsequent starts
java -XX:SharedArchiveFile=app-cds.jsa -jar app.jar
# Disable C2 for serverless / short-lived processes (faster startup, lower peak perf)
java -XX:TieredStopAtLevel=1 -jar app.jar
JVM Flags Quick Reference
All the flags from this article in one place, grouped by concern:
| Concern | Flag | Recommended value (8 GB, 4-core API) |
|---|---|---|
| Heap | -Xms | -Xms8g |
-Xmx | -Xmx8g | |
-Xmn | -Xmn2g | |
| G1GC | -XX:+UseG1GC | always explicit |
-XX:MaxGCPauseMillis | 100 | |
-XX:InitiatingHeapOccupancyPercent | 35 | |
| Metaspace | -XX:MetaspaceSize | 128m |
-XX:MaxMetaspaceSize | 256m | |
| GC log | -Xlog:gc*:file=gc.log:time,tags:filecount=5,filesize=20m | always on in production |
| Container | -XX:+UseContainerSupport | always |
-XX:MaxRAMPercentage | 75.0 | |
| Code Cache | -XX:ReservedCodeCacheSize | 256m |