Introduction
JVM tuning flags have existed since the first JDK in 1996, but the modern taxonomy of -X flags (non-standard, since Java 1.2 in November 1998), -XX flags (advanced, since Java 5 in September 2004), and the jcmd diagnostic command (Java 7, July 2011, JEP 287 by Daniel Fuchs) gives production Java engineers fine control over the heap, stack, and GC. JEP 387 Elastic Metaspace (Java 16, March 2021 by Thomas Stuefe) added the -XX:MetaspaceSize and -XX:MaxMetaspaceSize flags for class metadata, and JEP 382 unified logging with -Xlog in Java 9 (September 2017, JEP 158 by Markus Groenlund). For a Java learner, JVM tuning flags are the bridge between writing Java code and operating a Java service in production, because the right flag choice is the difference between a service that handles 10,000 requests per second and one that falls over at 1,000.
Understanding JVM tuning flags also explains the difference between standard flags (documented in the JVM specification), -X non-standard flags (common across all JVMs but not part of the spec), and -XX implementation-specific flags (HotSpot-specific, may change between versions). Interviewers ask about JVM tuning because every senior Java engineer has been on a phone call where a service fell over and the post-mortem found a missing -Xmx or a default -XX:MaxDirectMemorySize that was too low. The flags are also the level at which observability tools (JFR, JConsole, async-profiler) integrate with the JVM.
What is JVM Tuning?
JVM tuning is the practice of setting command-line flags that control the heap, stack, GC, JIT, class metadata, and runtime behaviour of a Java process. The flags fall into three categories: standard flags (like -cp and -Dproperty=value, documented in the java(1) man page), -X non-standard flags (like -Xmx, -Xms, -Xss, -Xmn), and -XX advanced flags (like -XX:+UseG1GC, -XX:MaxGCPauseMillis=200, -XX:+PrintGCDetails). Standard flags are stable across JVM vendors; -X flags are typically stable across HotSpot, OpenJ9, and GraalVM; -XX flags are implementation-specific and the canonical reference is the HotSpot source code's src/hotspot/share/runtime/globals.hpp file.
JVM tuning is categorised as runtime configuration management, distinct from application configuration (which is application-level, in .properties files or environment variables). The defining feature is that JVM flags affect the entire JVM process before any application code runs, so they are set on the command line (java -XX:... MyApp) or in the JAVA_TOOL_OPTIONS environment variable (read by all JVMs) or the JDK_JAVA_OPTIONS environment variable (Java 9+, preferred for the java launcher). The trade-off is that bad flag choices can corrupt or destabilise the JVM (e.g., setting -XX:+UseLargePages without OS support causes the JVM to refuse to start).
How JVM Tuning Flags Work - Step by Step
Walk through the parsing of a typical command line: java -Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+UseZGC -Dapp.config=/etc/app.yaml -jar app.jar. Here is exactly what the JVM launcher does.
Step 1: The java launcher (a small C program in the JDK bin directory) parses argv and separates JVM flags (starting with -X, -XX, or -D) from the main class or -jar target and application arguments. Standard launcher flags like -cp, -jar, --module-path are consumed by the launcher; everything else is forwarded to the JVM.
Step 2: JVM flag parsing is split into -D (system property, stored in java.lang.System.getProperties), -X (handled by the launcher), and -XX (handled by the JVM's arguments module). -XX flags come in three sub-forms: -XX:+FlagName (boolean true), -XX:-FlagName (boolean false), and -XX:FlagName=value (key-value). Unknown -XX flags cause the JVM to refuse to start with "Unrecognized option".
Step 3: Heap flags are resolved early: -Xmx4g sets the maximum heap to 4 gigabytes (the unit suffixes k/K, m/M, g/G are accepted), -Xms4g sets the initial heap to 4 gigabytes, -Xss512k sets the default thread stack to 512KB. The launcher validates that -Xmx is at least -Xms and that both fit in the addressable memory.
Step 4: GC flags are resolved: -XX:+UseG1GC selects G1 as the collector (default on Java 9+), -XX:+UseZGC selects ZGC, -XX:+UseParallelGC selects the throughput-oriented Parallel collector. -XX:MaxGCPauseMillis=100 sets the soft pause-time target. The last -XX:+Use... flag wins, so -XX:+UseG1GC followed by -XX:+UseZGC on the same command line selects ZGC.
Step 5: After parsing, the JVM creates the heap, the JIT compiler(s), the GC threads, the class loader, and the JIT's compiled-method table. The application's main method is then invoked.
Step 6: At runtime, the JVM exposes all parsed flags to management code via RuntimeMXBean.getInputArguments() (a List<String>) and to the jcmd diagnostic command via "VM.flags". Both let you verify that the JVM actually applied the flags you specified, which is critical for production post-mortems.
Java Program for JVM Tuning Flags
package com.example.jvmtuning;
import java.lang.management.ClassLoadingMXBean;
import java.lang.management.CompilationMXBean;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryUsage;
import java.lang.management.RuntimeMXBean;
import java.lang.management.ThreadMXBean;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class JvmTuningFlagsDemo {
public static void printInputArguments() {
RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
List<String> args = runtime.getInputArguments();
System.out.println("=== JVM input arguments ("
+ args.size() + " total) ===");
for (String a : args) {
System.out.println(" " + a);
}
System.out.println("Start time: " + new java.util.Date(
runtime.getStartTime()));
System.out.println("Uptime: " + TimeUnit.MILLISECONDS.toSeconds(
runtime.getUptime()) + " s");
System.out.println("JVM name: " + runtime.getVmName());
System.out.println("JVM vendor: " + runtime.getVmVendor());
System.out.println("JVM version: " + runtime.getVmVersion());
System.out.println("Spec version: " + runtime.getSpecVersion());
}
public static void printHeap() {
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
MemoryUsage heap = mem.getHeapMemoryUsage();
MemoryUsage nonHeap = mem.getNonHeapMemoryUsage();
System.out.println("=== Heap ===");
System.out.printf(" used=%,d KB committed=%,d KB max=%,d KB init=%,d KB%n",
heap.getUsed() / 1024,
heap.getCommitted() / 1024,
heap.getMax() / 1024,
heap.getInit() / 1024);
System.out.println("=== Non-Heap (Metaspace, Code Cache, Compressed) ===");
System.out.printf(" used=%,d KB committed=%,d KB max=%,d KB init=%,d KB%n",
nonHeap.getUsed() / 1024,
nonHeap.getCommitted() / 1024,
nonHeap.getMax() / 1024,
nonHeap.getInit() / 1024);
}
public static void printMemoryPools() {
List<MemoryPoolMXBean> pools =
ManagementFactory.getMemoryPoolMXBeans();
System.out.println("=== Memory Pools ===");
for (MemoryPoolMXBean p : pools) {
MemoryUsage u = p.getUsage();
System.out.printf(" %-25s type=%-9s used=%,9d KB committed=%,9d KB max=%,9d KB%n",
p.getName(),
p.getType(),
u.getUsed() / 1024,
u.getCommitted() / 1024,
(u.getMax() == -1 ? -1 : u.getMax() / 1024));
}
}
public static void printGcBeans() {
List<GarbageCollectorMXBean> gcs =
ManagementFactory.getGarbageCollectorMXBeans();
System.out.println("=== GC beans ===");
for (GarbageCollectorMXBean gc : gcs) {
System.out.printf(" %-25s count=%-5d time=%-6d ms%n",
gc.getName(),
gc.getCollectionCount(),
gc.getCollectionTime());
}
}
public static void printRuntimeStats() {
ThreadMXBean threads = ManagementFactory.getThreadMXBean();
ClassLoadingMXBean cls = ManagementFactory.getClassLoadingMXBean();
CompilationMXBean jit = ManagementFactory.getCompilationMXBean();
Runtime rt = Runtime.getRuntime();
System.out.println("=== Runtime ===");
System.out.println("Available processors: "
+ rt.availableProcessors());
System.out.println("Free memory: " + rt.freeMemory() / 1024 + " KB");
System.out.println("Total memory: " + rt.totalMemory() / 1024 + " KB");
System.out.println("Max memory: " + rt.maxMemory() / 1024 + " KB");
System.out.println("Live threads: " + threads.getThreadCount());
System.out.println("Peak threads: " + threads.getPeakThreadCount());
System.out.println("Daemon threads: " + threads.getDaemonThreadCount());
System.out.println("Total loaded classes: "
+ cls.getTotalLoadedClassCount());
System.out.println("Currently loaded classes: "
+ cls.getLoadedClassCount());
System.out.println("Unloaded classes: "
+ cls.getUnloadedClassCount());
if (jit != null) {
System.out.println("JIT compiler: " + jit.getName());
System.out.println("Total JIT time: "
+ jit.getTotalCompilationTime() + " ms");
}
}
public static void printSystemProperties() {
System.out.println("=== Selected system properties ===");
Map<String, String> props = System.getenv();
java.util.Properties sp = System.getProperties();
for (String key : new String[]{"java.home", "java.class.path",
"java.io.tmpdir", "file.encoding",
"user.timezone", "os.name", "os.arch",
"user.country", "sun.java.command",
"sun.boot.library.path"}) {
System.out.println(" " + key + " = " + sp.getProperty(key));
}
System.out.println("JAVA_TOOL_OPTIONS env: "
+ (System.getenv("JAVA_TOOL_OPTIONS") == null
? "(not set)"
: System.getenv("JAVA_TOOL_OPTIONS")));
System.out.println("JDK_JAVA_OPTIONS env: "
+ (System.getenv("JDK_JAVA_OPTIONS") == null
? "(not set)"
: System.getenv("JDK_JAVA_OPTIONS")));
}
public static void main(String[] args) {
printInputArguments();
System.out.println();
printHeap();
System.out.println();
printMemoryPools();
System.out.println();
printGcBeans();
System.out.println();
printRuntimeStats();
System.out.println();
printSystemProperties();
}
}
Code Explanation - Line by Line
The printInputArguments method uses RuntimeMXBean.getInputArguments() which returns the exact list of -X, -XX, and -D flags the JVM parsed at startup. This is the canonical way to verify which flags are actually applied to a running JVM, because it reads from the same internal structures jcmd and JConsole read from. The method also prints the JVM start time, uptime, name (HotSpot 64-Bit Server VM), vendor (Oracle Corporation or Adoptium), version, and spec version (the JSR-924 spec version number).
The printHeap method queries MemoryMXBean.getHeapMemoryUsage and getNonHeapMemoryUsage. The heap MemoryUsage object exposes init, used, committed, and max. The non-heap region contains Metaspace (class metadata, -XX:MetaspaceSize), Code Cache (JIT-compiled code, -XX:ReservedCodeCacheSize), and the Compressed Class Space (the part of Metaspace used when -XX:+UseCompressedOops is on). printMemoryPools walks the MemoryPoolMXBeans which give the per-region breakdown: on a Java 17 G1 JVM you see "G1 Eden Space", "G1 Survivor Space", "G1 Old Gen", "Metaspace", "Compressed Class Space", and "CodeHeap".
The printGcBeans method walks the GarbageCollectorMXBeans; each bean exposes a name (e.g., "G1 Young Generation" or "ZGC Cycles"), a collection count, and a total collection time in milliseconds. The printRuntimeStats method queries the ThreadMXBean for live and peak thread counts, the ClassLoadingMXBean for total and currently loaded class counts, and the CompilationMXBean for total JIT compilation time. The printSystemProperties method prints the most useful java.* system properties and the JAVA_TOOL_OPTIONS and JDK_JAVA_OPTIONS environment variables which can silently inject flags into a JVM launcher.
Interview Questions and Answers
Q1. What is the difference between -X and -XX JVM flags?
-X flags are non-standard flags common across JVM vendors; the most common are -Xmx (max heap), -Xms (initial heap), -Xss (thread stack size), and -Xmn (young generation size). -XX flags are advanced implementation-specific flags used to toggle boolean features (-XX:+UseG1GC, -XX:+PrintGCDetails) or set numeric values (-XX:MaxGCPauseMillis=200). The Java launcher documents -X flags but -XX flags are implementation-specific and the canonical reference is the HotSpot source.
Q2. What does -XX:+UseCompressedOops do and when is it on?
UseCompressedOops tells the JVM to use 32-bit pointers instead of 64-bit pointers for object references in the heap when the heap is below 32GB, which saves memory and improves cache locality. It is on by default on HotSpot for any heap below 32GB since Java 7, and the JVM uses a clever address translation scheme (heap base + 32-bit offset) to allow the compressed pointers to address the whole heap. Above 32GB the JVM disables compressed oops automatically.
Q3. What are -Xms, -Xmx, and -Xms=-Xmx, and why set them equal in production?
-Xms is the initial heap size, -Xmx is the maximum heap size. Setting them equal in production (e.g., -Xms4g -Xmx4g) means the JVM allocates the full heap at startup and never resizes it; this avoids the GC pauses that occur when the heap grows, simplifies capacity planning, and lets you give the JVM more memory than the OS will swap. Setting -Xms smaller than -Xmx lets the heap grow on demand but risks periodic latency spikes.
Q4. What is the difference between -XX:MetaspaceSize and -XX:MaxMetaspaceSize?
Metaspace is the native heap region that stores class metadata (replacing the old PermGen from Java 7 and earlier). -XX:MetaspaceSize is the high-water mark at which a Metaspace GC is first triggered; below this size no Metaspace GC occurs. -XX:MaxMetaspaceSize is the hard cap above which OutOfMemoryError: Metaspace is thrown; the default is unlimited (limited only by native memory). Setting MaxMetaspaceSize in production protects against class-loader leak scenarios that would otherwise exhaust native memory.
Q5. What is the difference between JAVA_TOOL_OPTIONS and JDK_JAVA_OPTIONS?
JAVA_TOOL_OPTIONS is an environment variable read by every JVM since Java 5; it is parsed and prepended to the command-line arguments of every java invocation. JDK_JAVA_OPTIONS (Java 9+) is read only by the java launcher (not by other JDK tools like javac) and is the preferred way to inject JVM flags because it does not affect tooling. Both are useful for containers and systemd services where you cannot edit the launch command.
Q6. How does the -Xlog unified logging flag differ from the old -XX:+PrintGCDetails?
-XX:+PrintGCDetails (and its cousins PrintGC, PrintGCTimeStamps, PrintGCDateStamps) were HotSpot-specific GC logging flags deprecated in Java 9 and removed in Java 14. -Xlog is the unified logging framework (JEP 158) that lets you configure logging for any subsystem (gc, gc+heap, jit, classloader, os, thread) with -Xlog:tagcombination:file=path:time:level:decorators. -Xlog:gc*:file=gc.log:time prints all GC events with timestamps to gc.log.
Q7. Your Java 21 service runs in a Kubernetes pod with a 4GB memory limit. The JVM was started with -Xmx3g -XX:MaxMetaspaceSize=256m but the pod was OOM-killed after two hours. Walk through the diagnosis.
The pod was killed because the JVM process exceeded the container's 4GB limit, but -Xmx3g + 256m Metaspace + 384MB thread stacks + 256MB Code Cache + 256MB DirectByteBuffers + JVM internal structures easily exceeds 4GB. Diagnosis steps: first run the JVM with Native Memory Tracking (-XX:NativeMemoryTracking=detail, then jcmd <pid> VM.native_memory summary) to see the breakdown; second check -XX:MaxDirectMemorySize which defaults to nearly -Xmx and which NIO libraries (Netty, gRPC) can exhaust; third check the thread count and -Xss (default 1MB on 64-bit means 1000 threads = 1GB of stack memory); fourth check the JIT Code Cache (-XX:ReservedCodeCacheSize defaults to 240MB). The fix is to set -XX:MaxDirectMemorySize=256m, lower -XX:ReservedCodeCacheSize if appropriate, set -XX:MaxRAMPercentage=70 instead of -Xmx to let the JVM size itself relative to the container limit, and leave headroom of at least 25% between -Xmx and the container limit.
Sample Input and Output
Input: $ java -XX:+UseG1GC -Xmx4g -Xms4g -XX:MaxGCPauseMillis=100 -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -Dapp.config=/etc/app.yaml com.example.jvmtuning.JvmTuningFlagsDemo Output: === JVM input arguments (8 total) === -XX:+UseG1GC -Xmx4g -Xms4g -XX:MaxGCPauseMillis=100 -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -Dapp.config=/etc/app.yaml -XX:+UseCompressedOops Start time: Mon Jan 15 10:30:00 EST 2024 Uptime: 2 s JVM name: Java HotSpot(TM) 64-Bit Server VM JVM vendor: Oracle Corporation JVM version: 21.0.1+12-LTS-29 Spec version: 21 === Heap === used=8,123 KB committed=4,194,304 KB max=4,194,304 KB init=4,194,304 KB === Non-Heap (Metaspace, Code Cache, Compressed) === used=4,521 KB committed=6,144 KB max=-1 KB init=2,560 KB === Memory Pools === G1 Eden Space type=HEAP used= 6,144 KB committed= 4,096 KB max= 4,096 KB G1 Survivor Space type=HEAP used= 512 KB committed= 512 KB max= 512 KB G1 Old Gen type=HEAP used= 1,467 KB committed=2,097,152 KB max=2,097,152 KB Metaspace type=NON_HEAP used= 3,512 KB committed= 4,096 KB max= -1 KB Compressed Class Space type=NON_HEAP used= 412 KB committed= 512 KB max= 1,024 KB CodeHeap 'non-nmethods' type=NON_HEAP used= 123 KB committed= 256 KB max= 5,832 KB CodeHeap 'profiled nmethods' type=NON_HEAP used= 891 KB committed= 1,024 KB max=118,784 KB CodeHeap 'non-profiled nmethods' type=NON_HEAP used= 234 KB committed= 512 KB max=118,784 KB === GC beans === G1 Young Generation count=0 time=0 ms G1 Old Generation count=0 time=0 ms === Runtime === Available processors: 8 Free memory: 4,123,456 KB Total memory: 4,194,304 KB Max memory: 4,194,304 KB Live threads: 5 Peak threads: 5 Daemon threads: 3 Total loaded classes: 1456 Currently loaded classes: 1456 Unloaded classes: 0 JIT compiler: HotSpot 64-Bit Tiered Compilers Total JIT time: 23 ms === Selected system properties === java.home = /usr/lib/jvm/java-21-openjdk java.class.path = . java.io.tmpdir = /tmp file.encoding = UTF-8 user.timezone = America/New_York os.name = Linux os.arch = amd64 user.country = US sun.java.command = com.example.jvmtuning.JvmTuningFlagsDemo sun.boot.library.path = /usr/lib/jvm/java-21-openjdk/lib JAVA_TOOL_OPTIONS env: (not set) JDK_JAVA_OPTIONS env: (not set)
The output shows that the JVM received exactly the eight input arguments expected (including the auto-enabled -XX:+UseCompressedOops), the heap is sized to the full 4GB requested, the Metaspace cap is reflected, and the GC beans show the G1 young and old generation collectors. The runtime stats section shows the JIT compiler, processor count, and thread count which the JVM tuning flags indirectly affect.
Common Mistakes to Avoid
Setting -Xmx to the entire container memory limit is a classic mistake: the JVM process needs additional memory for Metaspace, thread stacks, JIT Code Cache, NIO direct buffers, and JVM internals, so a 4GB container with -Xmx4g is guaranteed to OOM-kill. Always leave 25-30% headroom, or use -XX:MaxRAMPercentage=70 (Java 10+) which sizes the heap as a percentage of the container limit.
Using -XX:+PrintGCDetails in production is a deprecated and removed flag: on Java 14+ it prints a warning and is ignored. Use -Xlog:gc*:file=gc.log:time:level which is the unified logging replacement and which is far more configurable. Audit your startup scripts for old GC logging flags before upgrading the JDK.
Forgetting -XX:MaxDirectMemorySize when using Netty or gRPC is dangerous: the default direct memory size is approximately -Xmx, so a 4GB heap allows 4GB of direct buffers, which combined with the heap itself can exhaust container memory. Set -XX:MaxDirectMemorySize=512m or lower to bound the direct memory footprint.
Finally, mixing -XX:+Use... flags on one command line is a recipe for confusion: only the last one wins, but earlier flags may still affect sub-components like the card-table or remembered-set implementation. Use exactly one -XX:+Use... flag per JVM and rely on JEP 291 jcmd <pid> VM.flags to verify which collector is active.
Real-World Applications
Every production Java service is launched with at least -Xms, -Xmx, and the GC flags. Spring Boot applications packaged as executable JARs typically run with -Xmx1g to -Xmx4g and the default G1 collector. Kafka brokers run with -Xmx6g and tuned -XX:+UseG1GC plus -XX:InitiatingHeapOccupancyPercent=35. Elasticsearch nodes typically run with -Xms31g -Xmx31g (just below the compressed-oops cutoff) and either G1 or CMS-on-older-versions. Cassandra is shipped with G1 by default since 4.0. Hadoop and Spark use the -XX:+UseParallelGC throughput collector on the JVM and ZGC on the off-heap storage layer.
Container environments like Kubernetes add another layer of flags: -XX:+UseContainerSupport (default on since Java 10, JEP 346) makes the JVM read cgroup limits, and -XX:InitialRAMPercentage / -XX:MaxRAMPercentage are the recommended way to size the heap as a percentage of the container limit rather than hard-coding gigabytes. Spring Boot's layered JAR, Jib, and Buildpacks all default to MaxRAMPercentage=75 (Buildpacks) or hard-coded 1GB (older Spring Boot 2.x starters).
Observability tooling depends on JVM flags: JFR requires -XX:StartFlightRecording=filename=...,jfr or the jcmd <pid> JFR.start; async-profiler requires the -agentpath:libasyncProfiler.so flag; the JVMTI agent interface requires -agentpath: or -agentlib:. The Crashpad agent used by Sentry's Java SDK is loaded with -agentpath:. The JVM flag surface is the integration point for every diagnostic and observability tool in the Java ecosystem.
Conclusion
JVM tuning flags control the heap, stack, GC, JIT, class metadata, and runtime behaviour of a Java process. The three categories are standard flags (-cp, -D), -X non-standard flags (-Xmx, -Xms, -Xss), and -XX advanced implementation-specific flags (-XX:+UseG1GC, -XX:MaxGCPauseMillis). The RuntimeMXBean.getInputArguments() method is the canonical way to verify which flags a running JVM actually applied. Next study the -Xlog unified logging framework, then JFR and jcmd for production diagnostics, and finally the JIT compiler tuning flags (-XX:CompileThreshold, -XX:+TieredCompilation, -XX:+UseGraal) which control how the JVM turns bytecode into native code.
Comments
Comments
Post a Comment