Getting Java’s garbage collection right in production is one of those tasks that seems deceptively simple until you’re staring at a dashboard full of latency spikes. I’ve spent countless hours tuning JVMs, and the difference between a well-tuned system and a default configuration isn’t just incremental; it’s transformative. It’s the difference between a smooth, responsive application and one that grinds to a halt at the worst possible moment.
The heap is your JVM’s memory workbench. I always start by setting the initial and maximum sizes to the same value. This prevents the JVM from spending cycles dynamically resizing the heap during operation, which can introduce unpredictable pauses.
-Xms4g -Xmx4g
For many applications, I find a balanced ratio between the young generation (where new objects are born and most die young) and the old generation (where long-lived objects reside) is crucial. The NewRatio
parameter controls this.
-XX:NewRatio=2
This setting means the old generation will be approximately twice the size of the young generation. It’s a good starting point for applications with a mix of short and long-lived objects. The key is to watch your object lifetime patterns. If most objects die young, you might want a larger young generation. If many objects survive, a larger old generation could be more efficient.
Choosing the right garbage collector is not a one-size-fits-all decision. It’s a strategic choice based on your application’s personality. Is it a low-latency trading system where every millisecond counts? Or a batch processing engine where throughput is king?
For applications demanding ultra-low pause times, even with very large heaps, I’ve had great success with ZGC.
-XX:+UseZGC -Xmx16g
ZGC is designed to keep pause times below 10 milliseconds, regardless of heap size. It’s a marvel of engineering for modern applications. For a more general-purpose workload seeking a balance between throughput and latency, G1 GC is my usual go-to. It provides predictable pause times while maintaining good overall throughput.
If you can’t measure it, you can’t improve it. This old adage is especially true for garbage collection. Enabling detailed logging is the first step to understanding what’s really happening inside your JVM.
Modern JDKs offer a powerful unified logging framework.
-Xlog:gc*=debug:file=gc.log:time,uptime,level,tags
This command gives you a wealth of information written to a gc.log
file. It timestamps each event and tags it with the garbage collector phase. I make this a standard part of my production setup. The data is invaluable.
Once you have the logs, you need to read them. Tools like GCViewer or online services like GCEasy can parse these logs and visualize the data. You can see trends in pause times, identify memory leaks, and understand your application’s allocation rate. It turns a cryptic text file into a clear story of your application’s memory health.
While logs are great for post-mortem analysis, sometimes you need real-time insight. This is where programmatic access to GC metrics shines. You can integrate this data directly into your monitoring and alerting systems.
import java.lang.management.ManagementFactory;
import java.lang.management.GarbageCollectorMXBean;
import java.util.List;
public class GCMonitor {
public static void printGcStats() {
List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean bean : gcBeans) {
System.out.println(bean.getName() +
": Count=" + bean.getCollectionCount() +
", Time=" + bean.getCollectionTime() + "ms");
}
}
}
This simple code snippet can be called periodically to track how often and for how long each garbage collector is running. A sudden spike in collection count or time is a clear signal that something has changed and warrants investigation.
The young generation is divided into one Eden space and two Survivor spaces. Objects are first allocated in Eden. When Eden fills up, a minor GC occurs. surviving objects are moved to one Survivor space. This process repeats. Objects that survive multiple cycles here are promoted to the old generation.
The SurvivorRatio
parameter controls the size of the Survivor spaces relative to Eden.
-XX:SurvivorRatio=8
This means each Survivor space will be 1/8th the size of Eden. You also want to control how full the Survivor spaces can get before forcing promotions with TargetSurvivorRatio
.
-XX:TargetSurvivorRatio=90
If you see objects being promoted to the old generation too quickly, it often means the Survivor spaces are too small or the tenure threshold is too low. Tuning these can significantly reduce the burden on the old generation.
Large objects can be troublesome. The JVM tries to allocate them in the young generation, but if they’re too big, they can cause fragmentation and inefficient collection. It’s often better for them to go directly to the old generation.
The PretenureSizeThreshold
parameter allows you to define what “large” means for your application.
-XX:PretenureSizeThreshold=1048576
This setting tells the JVM that any object larger than 1MB should be allocated directly in the old generation. This is particularly useful for applications that frequently work with large arrays or data buffers, preventing them from clogging up the young generation.
When using a concurrent collector like G1, a “Concurrent Mode Failure” is a bad sign. It means the collector couldn’t finish reclaiming memory in the old generation before the application needed it, forcing a full, expensive “stop-the-world” GC.
The key to avoiding this is to start the concurrent marking cycle earlier. This is controlled by the InitiatingHeapOccupancyPercent
(IHOP).
-XX:InitiatingHeapOccupancyPercent=45
The default is often 45, but if you’re seeing concurrent mode failures, I might lower this to 40 or even 35. It tells the G1 collector to start its background marking cycle when the old generation is less full, giving it more breathing room to finish before the application runs out of memory.
The Metaspace, which replaced the Permanent Generation (PermGen), holds class metadata. If not managed, it can be a source of memory leaks, especially in applications that dynamically generate and load classes.
It’s critical to set bounds on the Metaspace.
-XX:MaxMetaspaceSize=256m -XX:MetaspaceSize=64m
MetaspaceSize
is the initial size. When this is reached, the JVM will resize it and trigger a GC. MaxMetaspaceSize
is the hard limit. Without it, the Metaspace could grow indefinitely, eventually leading to an OutOfMemoryError
. Setting these parameters protects your application from class loader related memory issues.
You can give the JVM goals and let its ergonomics engine figure out the best way to achieve them. The MaxGCPauseMillis
parameter tells the JVM your desired maximum pause time target.
-XX:MaxGCPauseMillis=200
This is a goal, not a guarantee. The JVM will try to keep most pauses below 200ms. The GCTimeRatio
specifies the desired ratio of GC time to application time.
-XX:GCTimeRatio=99
This value is calculated as 1 / (1 + GCTimeRatio)
. So a ratio of 99 means the goal is to spend no more than 1% of the total time in garbage collection. The JVM will use these two goals to dynamically adjust heap sizes and other internal parameters.
Sometimes the automatic tuning needs a nudge in the right direction. To understand why the JVM is making certain decisions, you can enable diagnostic flags.
-XX:+PrintAdaptiveSizePolicy -XX:+PrintTenuringDistribution
PrintAdaptiveSizePolicy
logs the reasons behind the JVM’s resizing decisions for the heap and its generations. PrintTenuringDistribution
shows a histogram of object ages in the Survivor spaces before a collection. This is incredibly useful for fine-tuning the Survivor ratios and tenure threshold, showing you exactly how objects are surviving and being promoted.
All this tuning is not a set-it-and-forget-it operation. It’s an iterative process. I always start by establishing a performance baseline under a realistic load. Then, I change one parameter at a time. Making multiple changes simultaneously makes it impossible to know which one provided a benefit or caused a regression.
After each change, I run the same load test and compare the results against the baseline. I look at key metrics: average and maximum pause times, throughput, and overall memory footprint. Only when I’m confident a change is stable and beneficial do I consider rolling it out to a production environment. Even then, I do it cautiously, watching the monitoring systems closely for any unexpected behavior. Tuning garbage collection is a continuous conversation with your JVM, and listening to what it tells you is the most important skill of all.