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.

Share

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.

RegionWhat lives thereControlled by
HeapObject instances and arrays (Young + Old gen)-Xms / -Xmx
Young GenEden + two Survivor spaces; most objects die here-Xmn / -XX:NewRatio
Old GenLong-lived objects that survived Young GCremainder of heap
MetaspaceClass metadata, method bytecode, interned strings (Java 8+)-XX:MaxMetaspaceSize
Code CacheJIT-compiled native code-XX:ReservedCodeCacheSize
Thread StacksOne stack per thread (default 512 KB–1 MB)-Xss
Direct / Off-heapNIO ByteBuffers, Unsafe allocations, JVM internalsOS limit / -XX:MaxDirectMemorySize
Total process memory = Heap + Metaspace + Code Cache + (threads × stack) + direct memory. In containers set -Xmx to no more than 75–80% of the container memory limit to leave room for non-heap regions.

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:

  • -Xms<size>: initial (minimum) heap size. Set equal to -Xmx to avoid resize overhead in production.
  • -Xmx<size>: maximum heap size. The most important single JVM flag.
  • -Xmn<size>: fixes the Young Generation size (use instead of -XX:NewRatio when you need precision).
  • -XX:NewRatio=N: Old:Young ratio. Default 2 means Old = 2×Young. Lower values give more Eden space (reduces promotion frequency).
  • -XX:SurvivorRatio=N: Eden:Survivor ratio. Default 8 means each Survivor = Eden/8. Raise if many objects survive but die before tenuring.
Shell
# 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"
Size suffixes: k / K (kilobytes), m / M (megabytes), g / G (gigabytes). Always use g for heap: it reads more clearly and avoids arithmetic errors.

Choosing 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).

CollectorFlagPause modelBest for
Serial-XX:+UseSerialGCStop-the-world all phasesSingle-core containers, tiny heaps (<256 MB)
Parallel-XX:+UseParallelGCStop-the-world, multi-threadedBatch jobs: maximise throughput, tolerate pauses
G1GC-XX:+UseG1GC (default Java 9+)Mostly concurrent, bounded pausesGeneral-purpose API servers, heaps 4–32 GB
ZGC-XX:+UseZGCSub-millisecond, fully concurrentLow-latency APIs, very large heaps (>32 GB)
Shenandoah-XX:+UseShenandoahGCSub-millisecond, concurrent compactionLow-latency with Red Hat JDKs; similar to ZGC
Shell
# 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:

  • -XX:MaxGCPauseMillis=N: 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:G1HeapRegionSize=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:G1NewSizePercent / -XX:G1MaxNewSizePercent: floor and ceiling for Young Gen as % of heap (defaults 5% / 60%).
  • -XX:InitiatingHeapOccupancyPercent=N (IHOP, default 45): heap occupancy % that triggers a concurrent marking cycle. Lower it if you see Full GCs.
  • -XX:G1MixedGCCountTarget=N: how many mixed GC pauses to spread old-gen collection over (default 8).
  • -XX:ParallelGCThreads / -XX:ConcGCThreads: STW and concurrent GC thread counts (auto-calculated from CPU count).
Shell
# 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
Start with only -XX:MaxGCPauseMillis and heap size. Add other flags only after you have GC log data showing what G1 is actually doing. Over-tuning without data often makes things worse.

ZGC & 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.

FlagDefaultPurpose
-XX:+UseZGCoffEnable ZGC
-XX:+ZGenerationaloff (Java 21 default on Java 23+)Enable generational ZGC for much better throughput
-XX:SoftMaxHeapSize== -XmxZGC tries to keep heap below this: leaves headroom for spikes
-XX:ZCollectionInterval=N0 (disabled)Force a GC cycle every N seconds even when idle
-XX:ZUncommitDelay=N300 sSeconds before ZGC returns unused heap pages to OS
-XX:+UseShenandoahGCoffEnable Shenandoah (alternative to ZGC)
-XX:ShenandoahGCMode=iusatbIncremental-update mode: lower allocation spike sensitivity
Shell
# 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.

  • -XX:MetaspaceSize=N: initial commit size and the first GC trigger threshold (default ~20–25 MB). Not a minimum: just the first resize point.
  • -XX:MaxMetaspaceSize=N: hard cap. Without this, a class-loader leak will consume all native memory. Set it to 256–512 MB for most apps.
  • -XX:CompressedClassSpaceSize=N: the region for compressed class pointers (default 1 GB, requires -XX:+UseCompressedClassPointers).
Shell
# 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
Class-loader leaks are the most common Metaspace exhaustion cause. If -XX:MaxMetaspaceSize causes frequent GC on class metadata, look for frameworks creating new ClassLoader instances on every request (e.g., Groovy script engines, hot-reload in production).

Unified GC Logging

Java 9+ replaced the old -verbose:gc / -XX:+PrintGCDetails flags with a unified logging framework (-Xlog). This gives structured, filterable output that feeds directly into tools like GCEasy and GCViewer.

Shell
# 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:

  • gc: basic pause summary (type, pause time, heap before/after)
  • gc,heap: heap occupancy after each collection
  • gc,phases: G1 phase breakdown (marking, evacuation, cleanup)
  • gc,humongous: G1 humongous object allocations (>½ region size)
  • safepoint: all stop-the-world pauses including non-GC ones (deoptimisation, thread dumps)

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.

Shell
# 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
FlagPurpose
-XX:+UseContainerSupportRead cgroup limits instead of host specs (default on Java 10+)
-XX:MaxRAMPercentage=75.0Set Xmx as % of container memory limit
-XX:InitialRAMPercentage=50.0Set Xms as % of container memory limit
-XX:ActiveProcessorCount=NOverride CPU count (useful when CPU quota is fractional)
In Kubernetes, prefer -XX:MaxRAMPercentage over absolute -Xmx. It adapts automatically if you change the pod memory limit without redeploying a new image.

JIT & 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.

  • -XX:+TieredCompilation: enabled by default; C1 compiles quickly, C2 optimises hot methods deeply.
  • -XX:CompileThreshold=N: invocations before a method is JIT-compiled (default 10,000 with tiered off). With tiered compilation, thresholds per tier take over.
  • -XX:ReservedCodeCacheSize=N: max code cache size (default 240–256 MB). If it fills up the JVM de-optimises compiled code. Raise for large codebases.
  • -Xshare:dump / -Xshare:on: Class Data Sharing (CDS). Dump the JVM class archive once, then load it mapped read-only on subsequent startups to save 200–500 ms.
Shell
# 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:

ConcernFlagRecommended value (8 GB, 4-core API)
Heap-Xms-Xms8g (= -Xmx)
-Xmx-Xmx8g
-Xmn-Xmn2g (25% of heap)
G1GC-XX:+UseG1GCalways explicit
-XX:MaxGCPauseMillis100
-XX:InitiatingHeapOccupancyPercent35
Metaspace-XX:MetaspaceSize128m
-XX:MaxMetaspaceSize256m
GC log-Xlog:gc*:file=gc.log:time,tags:filecount=5,filesize=20malways on in production
Container-XX:+UseContainerSupportalways
-XX:MaxRAMPercentage75.0
Code Cache-XX:ReservedCodeCacheSize256m