Definition
- Heap sizing –
-Xmsand-Xmxset the initial and maximum heap; in containers use-XX:MaxRAMPercentageso the heap tracks the cgroup limit. - GC selection –
-XX:+UseG1GC,-XX:+UseZGC, or-XX:+UseShenandoahGCpick the collector that fits your latency or throughput goal. - G1 tuning –
MaxGCPauseMillis,G1HeapRegionSize, andInitiatingHeapOccupancyPercentfine-tune G1, but only after GC logs justify a change. - Metaspace cap –
-XX:MaxMetaspaceSizeprevents unbounded native-memory growth from classloader leaks. - Failure diagnostics –
-XX:+HeapDumpOnOutOfMemoryErrorwrites a heap dump at the moment of OOM; pair with-XX:+ExitOnOutOfMemoryErrorfor clean Kubernetes restarts. - GC logging –
-Xlog:gc*records every GC event with timestamps; this replaced the old PrintGCDetails family in Java 9. - Introspection –
-XX:+PrintFlagsFinalprints every flag and its effective value after ergonomics are applied.
Think of the JVM as a bank branch and these flags as the settings on its control panel. Some set the size of the branch (heap sizing), some pick which cleaning crew works there (GC selection), some tune how that crew operates (G1 tuning), some cap the filing room (Metaspace), some set up the emergency camera that records a snapshot when the branch fails (heap dump on OOM), some turn on the activity log (GC logging), and one prints every current setting so you can audit the panel (PrintFlagsFinal).
Understanding the Concepts
What are JVM flags and why do they matter?
JVM flags are command-line options that configure how the Java Virtual Machine manages memory, garbage collection, diagnostics, and runtime behavior. In a banking production environment, choosing the right set of flags determines whether the system handles peak transaction load smoothly or crashes under pressure. A senior engineer knows which flag to reach for and why, rather than copying settings blindly from blog posts.
The flags fall into a few families: heap sizing, GC selection, G1 tuning, Metaspace control, failure diagnostics, GC logging, and introspection. Each family addresses a different operational concern.
// BankingApp.java -- launching with essential JVM flags
package com.bank.core;
public class BankingApp {
public static void main(String[] args) {
System.out.println("National Bank of India - Transfer Service started");
System.out.println("Max memory: " + Runtime.getRuntime().maxMemory() / (1024 * 1024) + " MB");
System.out.println("Available processors: " + Runtime.getRuntime().availableProcessors());
}
}
# Production launch with essential flags
java -Xms2g -Xmx2g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:MaxMetaspaceSize=256m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/bank/heapdumps/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/bank/gc.log:time,uptime,level,tags \
-jar bank-transfer-service.jar
How does heap sizing work?
-Xms sets the initial heap and -Xmx sets the maximum heap. Setting them equal avoids heap-resize churn during operation. In containers, prefer -XX:MaxRAMPercentage so the heap is a fraction of the detected container limit rather than a fixed number.
# Fixed heap -- good for bare-metal or known VM sizes
java -Xms4g -Xmx4g -jar bank-transfer-service.jar
# Container-aware heap -- adapts to the cgroup limit
java -XX:InitialRAMPercentage=75.0 \
-XX:MaxRAMPercentage=75.0 \
-XX:MinRAMPercentage=75.0 \
-jar bank-transfer-service.jar
# With a 4 GB container limit and MaxRAMPercentage=75.0:
Max heap = 4096 MB * 0.75 = 3072 MB
Remaining 1024 MB reserved for Metaspace, thread stacks, code cache, direct buffers
The percentage approach is safer in Kubernetes because if the pod memory limit changes from 4 GB to 8 GB, the heap scales automatically without a code or config change.
How do you select a garbage collector?
The choice depends on the workload's latency and throughput requirements.
| Flag | Collector | Best For | Pause Behavior |
|------|-----------|----------|----------------|
| -XX:+UseG1GC | G1 | Balanced throughput and latency | Short pauses, default since Java 9 |
| -XX:+UseZGC | ZGC | Sub-millisecond pauses, large heaps | Concurrent, generational from Java 23 |
| -XX:+UseShenandoahGC | Shenandoah | Low-pause concurrent collection | Concurrent, generational mode on Java 25 |
| -XX:+UseParallelGC | Parallel | Batch jobs, maximum throughput | Long pauses acceptable |
# Real-time payments service -- low latency matters
java -XX:+UseZGC -XX:MaxRAMPercentage=75.0 -jar bank-payments-service.jar
# Nightly interest calculation batch -- throughput matters
java -XX:+UseParallelGC -Xms8g -Xmx8g -jar bank-interest-batch.jar
# General-purpose account service -- balanced
java -XX:+UseG1GC -XX:MaxRAMPercentage=75.0 -jar bank-account-service.jar
How do you tune G1 GC?
G1 has three primary tuning knobs. You should only adjust these after reading GC logs, never speculatively.
-XX:MaxGCPauseMillis=200sets the soft pause target in milliseconds. The default is 200. Lowering it makes G1 collect more frequently in smaller increments.-XX:G1HeapRegionSize=16msets the region size. The humongous-object threshold is half the region size, so objects larger than half a region go straight to humongous regions.-XX:InitiatingHeapOccupancyPercent=45sets when concurrent marking begins. The default is 45 percent.
# G1 tuning for a latency-sensitive loan-approval service
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:G1HeapRegionSize=16m \
-XX:InitiatingHeapOccupancyPercent=35 \
-Xms4g -Xmx4g \
-jar bank-loan-service.jar
# Wrong: tuning G1 without reading logs first
java -XX:+UseG1GC -XX:MaxGCPauseMillis=10 -XX:G1HeapRegionSize=1m ...
# This over-tunes G1, causing excessive GC cycles and worse throughput
# Correct: start with defaults, enable GC logging, read the logs, then tune
java -XX:+UseG1GC -Xlog:gc*:file=gc.log:time,uptime,level,tags ...
# After reading gc.log, adjust only the flags the data justifies
How does the Metaspace cap work?
By default, Metaspace is unbounded and can grow until it exhausts native memory. This is dangerous in services that load many classes or leak classloaders. Capping it turns a slow native-memory creep into a fast, diagnosable Metaspace OutOfMemoryError.
# Cap Metaspace at 256 MB
java -XX:MaxMetaspaceSize=256m -jar bank-transfer-service.jar
// Classloader leak example -- each iteration loads classes that never unload
package com.bank.debug;
import java.net.URL;
import java.net.URLClassLoader;
public class MetaspaceLeakDemo {
public static void main(String[] args) throws Exception {
// Without MaxMetaspaceSize, this silently eats native memory
// With MaxMetaspaceSize=64m, it throws java.lang.OutOfMemoryError: Metaspace
while (true) {
URLClassLoader loader = new URLClassLoader(new URL[]{new URL("file:///tmp/")});
loader.loadClass("com.bank.model.Account");
// loader is never closed and never garbage collected
}
}
}
# Output with -XX:MaxMetaspaceSize=64m:
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
How do failure diagnostic flags work?
-XX:+HeapDumpOnOutOfMemoryError writes a heap dump the moment a heap OOM occurs. This is the single most valuable flag for post-mortem leak analysis because the evidence is captured automatically at the moment of failure. Pair it with -XX:+ExitOnOutOfMemoryError so the JVM terminates cleanly and Kubernetes restarts the pod.
# Production diagnostic flags
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/bank/heapdumps/transfer-service.hprof \
-XX:+ExitOnOutOfMemoryError \
-jar bank-transfer-service.jar
// Simulating OOM to verify heap dump capture
package com.bank.debug;
import java.util.ArrayList;
import java.util.List;
public class OomDemo {
public static void main(String[] args) {
List<byte[]> leak = new ArrayList<>();
while (true) {
// Simulate unbounded transaction cache
leak.add(new byte[1_048_576]); // 1 MB per iteration
}
}
}
# Run with small heap to trigger quickly:
java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./dump.hprof OomDemo
# Output:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to ./dump.hprof ...
Heap dump file created [67108864 bytes in 0.123 secs]
How does GC logging work?
The unified logging flag -Xlog:gc* records every GC event with timestamps. This replaced the old -XX:+PrintGCDetails family in Java 9. Without GC logs you are tuning blind.
# Unified GC logging (Java 9+)
java -Xlog:gc*:file=/var/log/bank/gc.log:time,uptime,level,tags \
-jar bank-transfer-service.jar
# Old-style GC logging (Java 8 only -- do not use on Java 9+)
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps \
-Xloggc:/var/log/bank/gc.log \
-jar bank-transfer-service.jar
# Sample unified GC log entry:
[2024-01-15T10:23:45.123+0530][12.456s][info][gc] GC(42) Pause Young (Normal)
(G1 Evacuation Pause) 1843M->412M(4096M) 8.234ms
The log tells you the GC type, how much memory was reclaimed, the heap before and after, and the pause duration. This data drives every tuning decision.
How does PrintFlagsFinal help with introspection?
-XX:+PrintFlagsFinal prints every flag and its effective value after the JVM applies defaults and ergonomics. This lets you confirm what the JVM actually decided rather than what you thought you set.
# Print all effective flags
java -XX:+PrintFlagsFinal -version 2>&1 | grep -E "MaxHeapSize|UseG1GC|MaxMetaspaceSize"
size_t MaxHeapSize = 4294967296 {product} {ergonomic}
bool UseG1GC = true {product} {ergonomic}
size_t MaxMetaspaceSize = 268435456 {product} {command line}
The {ergonomic} tag means the JVM chose that value automatically. The {command line} tag means you set it explicitly. This distinction is critical when debugging container memory issues.
# Native Memory Tracking -- see non-heap footprint
java -XX:NativeMemoryTracking=summary -jar bank-transfer-service.jar
# Then query with jcmd
jcmd <pid> VM.native_memory summary
What are common mistakes with JVM flags?
The most damaging mistake is over-tuning by copying flags from blog posts without understanding them.
# WRONG: copied from a blog, over-tunes everything
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=10 \
-XX:G1HeapRegionSize=1m \
-XX:ParallelGCThreads=32 \
-XX:ConcGCThreads=16 \
-XX:InitiatingHeapOccupancyPercent=15 \
-XX:G1MixedGCCountTarget=4 \
-XX:G1HeapWastePercent=1 \
-jar bank-transfer-service.jar
# Result: excessive GC cycles, CPU wasted on collection, worse throughput
# CORRECT: sensible baseline, tune only after reading logs
java -XX:+UseG1GC \
-Xms4g -Xmx4g \
-XX:MaxMetaspaceSize=256m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/bank/heapdumps/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/bank/gc.log:time,uptime,level,tags \
-jar bank-transfer-service.jar
# Then read gc.log and adjust ONLY what the data justifies
Another common mistake is using fixed -Xmx in containers instead of -XX:MaxRAMPercentage.
# WRONG: fixed heap in a container -- breaks when pod limit changes
java -Xmx3g -jar bank-transfer-service.jar
# If pod limit is 4 GB this works. If pod limit is changed to 2 GB, OOM-killed.
# CORRECT: percentage-based heap adapts to any pod limit
java -XX:MaxRAMPercentage=75.0 -jar bank-transfer-service.jar
When should you use each flag family?
- Every service: heap sizing, Metaspace cap, HeapDumpOnOutOfMemoryError, ExitOnOutOfMemoryError, GC logging. This is the non-negotiable baseline.
- Latency-sensitive services (payments, real-time transfers): switch from G1 to ZGC for sub-millisecond pauses.
- Batch jobs (interest calculation, statement generation): use ParallelGC for maximum throughput.
- Debugging sessions: add PrintFlagsFinal and NativeMemoryTracking to see what the JVM decided.
- G1 tuning: only after GC logs show a specific problem like humongous allocations or high initiating occupancy.
Visual Overview
JVM Flag Families ================== ┌─────────────────────────────────────────────────────────────────┐ │ JVM Startup Flags │ ├─────────────────┬──────────────────────────────────────────────┤ │ Heap Sizing │ -Xms / -Xmx or -XX:MaxRAMPercentage │ │ │ (containers prefer percentage-based) │ ├─────────────────┼──────────────────────────────────────────────┤ │ GC Selection │ UseG1GC │ UseZGC │ UseShenandoahGC │ │ │ │ UseParallelGC │ ├─────────────────┼──────────────────────────────────────────────┤ │ G1 Tuning │ MaxGCPauseMillis │ G1HeapRegionSize │ │ │ │ InitiatingHeapOccupancyPercent │ ├─────────────────┼──────────────────────────────────────────────┤ │ Metaspace │ -XX:MaxMetaspaceSize │ ├─────────────────┼──────────────────────────────────────────────┤ │ Failure Diag │ HeapDumpOnOutOfMemoryError │ HeapDumpPath │ │ │ │ ExitOnOutOfMemoryError │ ├─────────────────┼──────────────────────────────────────────────┤ │ GC Logging │ -Xlog:gc*:file=gc.log:time,uptime,level │ ├─────────────────┼──────────────────────────────────────────────┤ │ Introspection │ PrintFlagsFinal │ NativeMemoryTracking │ └─────────────────┴──────────────────────────────────────────────┘ Decision Flow for Container Deployments ======================================== Start │ ▼ Is it a container?──Yes──▶ Use MaxRAMPercentage (75%) │ Set request = limit in K8s No │ ▼ Use fixed -Xms = -Xmx │ ▼ Latency-sensitive?──Yes──▶ UseZGC (sub-ms pauses) │ No │ ▼ Batch job?──Yes──▶ UseParallelGC (max throughput) │ No │ ▼ UseG1GC (balanced default) │ ▼ Always add: MaxMetaspaceSize + HeapDump + ExitOnOOM + GC logging
Key Takeaway
The essential JVM flags group into a few families and a senior should know not just the names but why to reach for each. For heap sizing use -Xms and -Xmx on bare metal, or -XX:MaxRAMPercentage in containers so the heap tracks the cgroup limit automatically. For collector selection, G1 is the balanced default since Java 9, ZGC gives sub-millisecond pauses on large heaps, Shenandoah is another low-pause concurrent option, and ParallelGC maximizes throughput for batch jobs. For G1 tuning the three knobs are the pause target, the region size, and the initiating occupancy percent, but you only adjust them after GC logs justify it.
Beyond selection and sizing, treat a small set as a baseline on every service. Cap Metaspace with MaxMetaspaceSize so a classloader leak becomes a fast, clear error instead of a slow native-memory creep. Always set HeapDumpOnOutOfMemoryError so the evidence is captured automatically at the moment of failure, and pair it with ExitOnOutOfMemoryError so the JVM terminates cleanly and Kubernetes restarts the pod. Turn on unified GC logging because tuning without logs is guessing. Use PrintFlagsFinal to verify what the JVM actually decided.
At National Bank of India we standardized exactly this baseline across every service image and let latency-critical ones swap G1 for ZGC. During incidents the on-call engineer could read the GC log and the automatically captured heap dump instead of flying blind, and could run PrintFlagsFinal to verify the live configuration in seconds.
Remember one sentence: set a sensible baseline of heap sizing, Metaspace cap, heap dump on OOM, exit on OOM, and GC logging everywhere, and add collector-specific tuning flags only when the GC logs justify them.
---