Contents

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.

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:

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

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

# 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 (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
# 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 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.

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

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.

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

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

The JIT compiler (C1 + C2) optimises hot code at runtime. A handful of flags control compilation aggressiveness, tiering, and startup warm-up behaviour.

# 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

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