Typical day at work. Suddenly, all hell breaks loose.
Yes, your Java application decided to go on strike. Response times degraded. Then CPU spiked, and finally, it threw an OutOfMemoryError and crashed. Now it’s time for you to get your hands dirty. Yes, we know what to do, don’t we? Let’s crack those knuckles, flex our bodies, and get ready to tackle the situation. It’s either us or the Java application on strike.
First thing we need: check the JVM settings. Because that’s exactly where the culprit usually hides.
Here’s the uncomfortable truth: the JVM doesn’t come production-ready out of the box. The default settings are designed to be safe and broadly compatible, not optimized for the traffic, memory pressure, and latency demands your production environment throws at it daily. Running a high-traffic Java application on default JVM settings is a bit like showing up to a marathon in flip-flops. Technically possible, but you’re going to have a bad time.
This is where tuning JVM with XX flags for production becomes one of the most impactful things you can do. The right flags can mean the difference between an application that buckles under load and one that hums along, even during peak traffic.
In this guide, we’ll walk you through the essential JVM flags every production deployment should have, from the foundational –Xms and -Xmx heap settings, via GC algorithm selection, to the critical -XX flags that give you control, visibility, and stability. We’ll also cover what not to do, because the most common tuning mistakes are surprisingly easy to make.
Active incident or proactive hardening: either way, this guide has you covered. Let’s get into it.
Understanding JVM Memory Before You Tune
Here’s a quick recap on the JVM Memory regions we have already explained on our other blogs:

Fig: The JVM Memory Model
The two primary divisions of JVM memory are the heap and native memory. Native memory is sometimes referred to as non-heap.
Heap memory is managed by the JVM, and its size can be configured using command line arguments when the JVM is invoked. Heap space is regularly cleaned and compressed by the garbage collector.
The heap is divided into two main areas: the Young Generation (YG) and the Old Generation (OG). YG is generally smaller, and all new objects are created in this space. Since many objects in Java are short-lived, such as variables required for the duration of a single transaction, the YG is cleaned frequently. This is very fast, since the area is small. Any objects that survive a few cycles of garbage collection are deemed to be long-lived, and are moved to the OG. The OG is cleaned less frequently.
Native memory is managed by the operating system, and has several different memory pools:
- Metaspace holds metadata related to classes, byte code for methods and other static data. In older JVMs this information was held in an area called the PermGen.
- Thread Space contains the stack space for each running thread.
- Code Cache contains pre-compiled machine code for ‘hot spots’ – frequently used methods.
- Direct Buffers allow access to operating-system managed buffers for faster I/O.
- GC is an area reserved for the garbage collector’s internal use.
- JNI is used by the Java Native Interface.
- Misc is reserved for the JVM’s internal use.
For a more comprehensive cover of JVM internals, I’d suggest watching JVM Explained in 10 Minutes.
What are -Xms and -Xmx in Java?
Let’s look at the two most important flags relating to the heap.
The first flag is -Xms. This flag sets the initial heap size. This is the amount of memory that the Java Virtual Machine claims from the operating system when your application starts.
The second flag is -Xmx. This flag sets the maximum heap size. This is the limit beyond which the Java Virtual Machine will not allow the heap to grow, no matter how much memory your application needs.
If the -Xms flag is set low, then the JVM will spend early runtime constantly resizing the heap upward, triggering unnecessary GC cycles in the process. On the other hand, if the -Xmx flag is set low, then you will get performance problems culminating in an OutOfMemoryError. Of the 9 types of OutOfMemoryErrors in Java, two of them are likely to be caused by setting -Xmx too low.
If you set the -Xmx flag too high, you will waste memory or, worse, cause long GC pause times. Hence, getting the right -Xms and -Xmx flags is a crucial foundation of everything else, in this guide.
For a deeper dive into what -Xms and -Xmx are, how they interact, and their default values across different JVM versions, this is worth a read: Xms and Xmx in Java — Educative
Choosing the Right GC Algorithm
Now that we have refreshed our minds about the JVM memory region and basics of the -Xmx and -Xms flags, let’s move to the next crucial step: that is, to choose the right GC algorithm. Take a look at the comparison of those GC algorithms to help you choose the best algorithm for your environment.
This table below contains the main collectors and their enabling flags for your reference:
| GC Algorithm | When to use it? | Supported Java Versions | How to enable it? |
| Serial GC | Not recommended because of single threaded nature | Java 1.2 and above | -XX:+UseSerialGC |
| Parallel GC | Recommended if you want to achieve high GC throughput and OK with consistent GC pauses are regular intervals | Java 5 and above | -XX:+UseParallelGC |
| CMS GC | Not recommended, as it’s removed from Java 14 version. | Java 1.4.2 to 13 | -XX:+UseConcMarkSweepGC |
| G1 GC | Recommended if heap size is <32gb and want to strike balance between throughput and pause time. | Java 7 and above | -XX:+UseG1GC |
| Shenandoah GC | Recommended for large heap (say more than >32gb) and can tolerate high CPU consumption | OpenJDK 8u, 11 and above | -XX:+UseShenandoahGC |
| ZGC | Recommended for large heap (say more than >32gb) and running on latest version of Java (say > java 21). Beware of allocation stalls. | Java 11 and above | -XX:+UseZGC |
| Epsilon GC | Can be used for experimentation purpose and in Performance Labs, but not in production | Java11 and above | -XX:+UseEpsilonGC |
Key XX Flags for GC Tuning
Now that you have picked the right GC algorithm for your environment, the next step is to identify the XX flags for GC tuning. Here’s a table that can help you with it:
| JVM Flag | What It Does | Default Value | When to Use |
| -Xmx | Sets maximum heap size | Platform-dependent | Always set explicitly in production |
| -XX:MaxMetaspaceSize | Caps the Metaspace memory region | Unlimited (bounded by RAM) | Always set to prevent runaway metadata consumption |
| -XX:+Use<name>GCNB: <name> as per above table | Selects the GC algorithm | Parallel GC (Java 8), G1 (Java 9+) | When you need to select a different GC algorithm |
| -XX:ParallelGCThreads=n(Not relevant for Serial GC and ZGC) | Sets threads for the parallel (stop-the-world) phase of GC | CPU-count-based formula | When running multiple JVMs on the same host to prevent thread overload, and especially when running in a container |
| -XX:ConcGCThreads=n(G1 GC, CMS, ZGC, Shenandoah only) | Sets threads for the concurrent (background) phase of GC | Derived from ParallelGCThreads | When GC concurrent phases are slow or CPU contention is high |
| -XX:InitiatingHeapOccupancyPercent (G1 GC and CMS only) | Triggers G1 marking cycle at this heap occupancy % | 45% | Lower it to start GC earlier and avoid Full GCs |
| -XX:-UseAdaptiveSizePolicy (Parallel GC only) | Disables dynamic resizing of Young/Old gen | Enabled | When you want fixed, predictable generation sizing |
| -XX:MaxGCPauseMillis (G1 GC only) | Sets a target for maximum GC pause time | 200ms (G1 default) | When your application has strict latency SLAs |
The article Garbage Collection in Java contains links to detailed tuning guides for each GC algorithm.
We have also listed out hundreds of JVM arguments that can come in handy for you. Also, if you are looking for the top “cherry on the cake” kind of JVM arguments then I recommend you to check out the blogs on 7 JVM arguments of Highly Effective Applications and Java Performance Tuning: Adjusting GC Threads for Optimal Results.
Essential Diagnostic and Safety Flags
Alright, so we’ve picked our GC algorithm and dialed in the tuning flags. But here’s the thing. Even the most beautifully tuned JVM will misbehave at some point. When it does, you need to be ready. These diagnostic and safety flags don’t improve performance directly, but they are the difference between resolving an incident in 20 minutes and spending 4 hours flying blind.
Think of them as your production safety net. No excuses for not having these enabled.
-XX:+HeapDumpOnOutOfMemoryError and -XX:HeapDumpPath
When your application throws an OutOfMemoryError, the worst thing that can happen is losing the evidence. This flag pair ensures the JVM automatically captures a heap dump the moment things go sideways, with no manual intervention required.
-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/path/to/dumps/heapdump.hprof
Always point -XX:HeapDumpPath to a directory with enough disk space and write permissions. Also, when working in containers, make sure it’s dumped to persistent storage. Once the dump is captured, you can analyze it using a tool like HeapHero to identify memory leaks, oversized objects, and the exact root cause of the OOM. For a deeper understanding of what a heap dump is, what it contains, and how it differs from a GC log and thread dump, this is a great read: What is GC Log, Thread Dump and Heap Dump?
-XX:OnOutOfMemoryError
This allows you to collect further diagnostic information if the application throws an OutOfMemoryError. You can specify an operating system command, or more often a script. It will run before the JVM shuts down.
-XX:OnOutOfMemoryError=mydiagnostics.sh
You can use it to get useful diagnostics, such as thread dumps and details of other applications running at the same time. You can write your own, or use this free open-source diagnostic script.
GC Logging
If you’re running a production JVM without GC logging enabled, you are essentially operating without a black box recorder. GC logs are the single most important artifact for diagnosing memory pressure, long pause times, and GC throughput degradation — and they carry negligible overhead.
For Java 9 and above, use the unified logging system:
-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
For Java 8 and below, use:
-XX:+PrintGCDetails-XX:+PrintGCDateStamps-XX:+UseGCLogFileRotation-XX:NumberOfGCLogFiles=5-XX:GCLogFileSize=20m-Xloggc:/path/to/gc.log
Once collected, run your GC logs through GCeasy to get a visual breakdown of heap usage, pause times, and throughput. This is very much faster than reading raw logs manually.
-XX:NativeMemoryTracking=summary
Heap is only one part of the JVM’s memory footprint. If your process is consuming more memory than -Xmx alone would suggest, native memory could be the culprit – Metaspace bloat, thread stack growth, code cache expansion, or direct buffer overuse. This flag enables the JVM’s Native Memory Tracking feature so you can inspect exactly where memory is going outside the heap.
-XX:NativeMemoryTracking=summary
Once enabled, you can pull a snapshot at any time using:
jcmd <pid> VM.native_memory summary
A word of caution though. This flag adds a small but measurable overhead (roughly 5–10%), so summary mode is the right balance for production. Reserve detail mode for targeted investigations in staging.
Common Tuning Mistakes to Avoid
Alright, we’ve covered what you should do. Now let’s talk about what trips people up, because some of the most damaging JVM misconfigurations are also the most common ones.
Mistake #1: Setting -Xmn or -XX:NewRatio with G1GC
When you use -Xmn or -XX:NewRatio with G1GC you are limiting what G1 can do. G1GC is supposed to change the generation sizes on its own to meet the pause goal you set with -XX:MaxGCPauseMillis. If you override this your pause goal does not mean anything. If you are using G1GC you should let it manage the generation sizing: that is the point of using G1GC.
Mistake #2: Running Without GC Logs in Production
There is no excuse for not having GC logs. GC logs are not heavy and do not slow down your system. They are very useful when things go wrong. Without GC logs you cannot tell if your application is having problems with GCs, the heap is too small or the pause times are getting longer. You should always have GC logs enabled.
Mistake #3: Setting Heap Too Large Without Testing
A bigger heap might seem safer, but then if the heap is too large, the garbage collector has to look through vast amounts of data, which means the pause times will be longer. Let’s say there was a case where the Young Generation was set to 14.65 GB and the average GC pause times were over 12 seconds. When they changed the Young Generation size, the pause times went down to under 140 ms, which’s a 98 percent improvement. You should always test the heap size to see how it affects the garbage collector, rather than just making assumptions.
Mistake #4: Ignoring Containerized CPU Limits
This one catches teams off guard when moving to Kubernetes or Docker. By default, the JVM calculates GC thread counts based on host CPU count, not the container’s CPU limit. On a 128-CPU host, each JVM might spawn as many as 100 GC threads. Run multiple JVMs on that host and you have hundreds of GC threads competing for the same CPU. This results in excessive context switching, contention, and degraded GC performance across the board. Always explicitly set -XX:ParallelGCThreads and -XX:ConcGCThreads in containerized deployments.
Mistake #5: Letting JIT Compiler Threads Run Unchecked
The JVM uses C1 and C2 compiler threads to JIT-compile hot code at runtime. Under heavy load or during startup, these threads can spike CPU significantly. You can control the count using -XX:CICompilerCount=N, where one-third goes to C1, the rest to C2. If you are seeing unexplained CPU spikes that aren’t coming from your application or GC threads, the compiler threads are worth investigating.
The pattern across all these mistakes is the same: wrong assumptions, missing data, and settings that made sense in isolation but fell apart in production. So what we need to do to avoid these mistakes is tune with evidence, not with guesswork.
Interesting Case Studies Worth Reading
These real-world incidents show exactly what happens when JVM tuning goes wrong, and how teams fixed it:
| Case Study | What Happened | Key Takeaway |
| How an Insurance Company Improved Throughput with Java GC Tuning | Application was intermittently unresponsive for hours daily. GC log analysis revealed back-to-back Full GC events, each taking ~2 seconds. | GC logs are non-negotiable. The fix was only possible because the data existed. |
| JVM C1, C2 Compiler Thread – High CPU Consumption | Unexplained CPU spikes traced back to JIT compiler threads running unchecked under load. | JIT compiler threads need to be accounted for, especially on CPU-constrained environments. |
A Production-Ready JVM Flag Template
The following examples illustrate how different garbage collectors are typically configured for specific workload scenarios.
| Workload Scenario | GC Algorithm | Sample JVM Configuration |
| Process Controller: Small device, single CPU, small heap | Serial GC | java -XX:+UseSerialGC -Xms50M -Xmx50M -XX:MaxMetaspaceSize=10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=log\my_system\myprog.hprof -XX:OnOutOfMemoryError=my_diagnostics.bat -Xlog:gc*:file=log\my_system\myprog.log:timeMy_Prog |
| Warehousing: Large overnight batch job | Parallel GC | java -XX:+UseParallelGC -Xms500M -Xmx500M -XX:MaxMetaspaceSize=100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=log\my_system\myprog.hprof -XX:OnOutOfMemoryError=my_diagnostics.bat -Xlog:gc*:file=log\my_system\myprog.log:timeMy_Prog |
| Microservice supplying product information: Low pause, running on container, medium heap | G1GC | java -XX:+UseG1GC -Xms1G -Xmx1G -XX:MaxMetaspaceSize=200m -XX:ParallelGCThreads=20 -XX:ConcGCThreads=10 -XX:MaxGCPauseMillis=10 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=log\my_system\myprog.hprof -XX:OnOutOfMemoryError=my_diagnostics.bat -Xlog:gc*:file=log\my_system\myprog.log:timeMy_Prog |
| Financial Trading: Very large heap, low and highly predictable latency critical, using Oracle JVM | ZGC | java -XX:+UseZGC -Xms15G -Xmx25G -XX:MaxMetaspaceSize=1G-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=log\my_system\myprog.hprof -XX:OnOutOfMemoryError=my_diagnostics.bat -Xlog:gc*:file=log\my_system\myprog.log:timeMy_Prog |
| Robotics: Medium to large heap, low latency, using OpenJDK | Shenandoah | java -XX:+UseShenandoahGC -Xms15G -Xmx25G -XX:MaxMetaspaceSize=1G -XX:InitiatingHeapOccupancyPercent=40 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=log\my_system\myprog.hprof -XX:OnOutOfMemoryError=my_diagnostics.bat -Xlog:gc*:file=log\my_system\myprog.log:timeMy_Prog |
Note: CMS and Epsilon collectors are not included here. CMS has been deprecated in Java 9 and removed in Java 14, while Epsilon is a no-op garbage collector used only for testing or short-lived workloads, making them unsuitable for most production systems.
Pre-configuration Checklist for GC Tuning
Before tuning your garbage collector, gather the following information about your application and environment. Tuning without these inputs often leads to incorrect assumptions and unstable configurations.
- Application Context
- Application name
- Application purpose (API service, batch processing, streaming job, etc.)
- Performance Goals
- Decide your primary priority:
- Throughput
- Latency
- Balanced performance
- Target latency (acceptable pause time)
- Throughput expectations
- Decide your primary priority:
- Performance & Monitoring Data: Collect this information from production monitoring tools or load tests.
- Peak heap usage
- Total memory usage
- Average object allocation rate
- Number of user threads at peak load
- Infrastructure Resources
- Understand the hardware or container limits available to the JVM.
- Number of CPU cores available
- Available RAM
- JVM version being used
Note: GC tuning should always start with real production or load-test data. Without these inputs, JVM tuning often becomes guesswork rather than engineering.
A Few Important Reminders Before You Deploy
This is a starting point, not a finish line. Every application has a different memory profile, thread count, and latency requirement. Run this template, capture your GC logs, analyse them with GCeasy, and iterate.
Adjust heap values to your environment. The -Xms4g / -Xmx4g values here are illustrative. Your actual sizing should be based on your application’s observed heap usage under load and not a round number someone picked years ago.
In containers, always set thread counts explicitly. Do not let the JVM auto-detect CPU count from the host. Set -XX:ParallelGCThreads and -XX:ConcGCThreads based on the CPU limit assigned to your container.
Use the full JVM arguments reference. For a comprehensive list of the most useful JVM flags and what they do, bookmark the JVM Arguments Master Sheet. It is the most complete reference you will find in one place.
How to Verify Your Tuning is Working
You’ve applied the flags and restarted the application. Now verify, because tuning without validation is just guesswork again except with extra steps. The GC logs actually give you a continuous view of memory behaviour under real production load, such as:
- Allocation pressure;
- Pause durations;
- GC overhead.
- Heap usage.
That is exactly what you need to confirm your tuning is working, or to catch it quietly regressing.
The Sawtooth Pattern: Heap usage should rise gradually as objects are allocated, then drop sharply when GC kicks in: a clean repeating sawtooth. An upward creep that never drops is a memory leak. Constant jagged spikes mean GC is struggling.
GC Throughput Above 98%: This measures the percentage of time your application is doing actual work versus time spent on GC. Anything below 98% means your heap, algorithm, or allocation rate needs attention.
Pause Times Within Your Goal: If you set -XX:MaxGCPauseMillis=200 in G1 GC , your observed pauses should consistently honour that. With other algorithms, they should be within your target range. Frequent Full GC events in a G1 deployment are a red flag and they should be rare to nonexistent.
Reading GC logs manually across thousands of events is tedious and error-prone. For a complete walkthrough on how to interpret GC logs and extract meaningful signals, this is your go-to guide: How to Analyze GC Logs in Java
Quick Verification Checklist
| What to Check | Healthy Signal | Warning Sign |
| Heap usage pattern | Clean sawtooth, consistent drops | Upward creep, no recovery |
| GC throughput | 98% or above | Below 95% |
| GC pause times | Within MaxGCPauseMillis goal | Frequent spikes above target |
| Full GC frequency | Rare to none | Recurring Full GC events |
| Metaspace usage | Stable, well within MaxMetaspaceSize | Continuously growing |
| Consecutive GC events | None | Back-to-back GC events |
Conclusion
That’s a wrap. Let me quickly run you through what we learnt from this blog.
We started with fundamentals: JVM memory regions and getting -Xms / -Xmx right. Everything else builds on that. Then we covered GC selection, critical -XX tuning flags, and the diagnostic/safety options that protect you in production. We highlighted the tuning mistakes that quietly destabilize systems, provided a production-ready template, and showed how to validate that your changes are actually working.
The key takeaway: JVM tuning is not a task. It’s a feedback loop.
Your application’s memory profile today may not be the same six months from now. Traffic patterns change, features ship, data volumes grow. What works brilliantly at 10,000 requests per hour may buckle at 100,000. The engineers who stay ahead of production incidents are not the ones who found the perfect configuration at the beginning: they are the ones who kept looking at the data.
Enable your GC logs. Analyse them regularly with GCeasy. Capture heap dumps when things go wrong and investigate them with HeapHero. Keep the JVM Arguments Master Sheet bookmarked for reference. And when you hit a thread or CPU issue, fastThread has you covered.
The JVM flags give you a whole new level of control. I recommend that you use them deliberately, verify obsessively, and iterate without ego. That is what production-grade JVM tuning actually looks like.
Now go fix that application on strike. You’re now battle-ready. Let’s win this war.

Share your Thoughts!