Tuning the JVM garbage collector

GC tuning has a bad reputation, and often for good reason. You regularly see command lines weighed down with a dozen -XX: flags copied from a forum, half of which have done nothing for three JVM releases. The truth is that a modern GC is tuned first by answering one simple question: what are you trying to optimise?

There are three goals, and they partly pull against each other: throughput (the share of time spent in your code rather than in the GC), latency (how long pauses last) and memory footprint. You can’t push all three to the limit at once. Until you’ve picked one, no flag means anything.

Start by measuring

Before touching anything, turn on GC logging. Since JDK 9 everything goes through unified logging, and the old flags like -XX:+PrintGCDetails are deprecated:

-Xlog:gc*:file=gc.log:time,uptime,level,tags

That file tells you everything you need: how often collections run, how long pauses last, the heap size before and after each cycle, and whether Full GCs are firing. A tool like GCeasy or JDK Mission Control turns it into readable graphs in seconds. Without this data you’re tuning blind.

The two numbers to look at first: the percentage of time spent in GC (more than a few percent and something is off) and the P99 of pause times against your latency budget.

Choose the right collector

Since JDK 9 the default collector is G1. It’s a solid generalist: it aims for a balance between throughput and latency and holds reasonable pauses on heaps of a few gigabytes. For the vast majority of services the default is the right call and there’s nothing to change.

The cases where you move away from it:

The useful instinct: only leave G1 if the logs show a real pause problem it can’t hold, or if you’re on a huge heap. Otherwise you trade proven simplicity for complexity that buys you nothing.

Size the heap

This is the setting with the most impact, well ahead of the rest. Two rules:

-Xms4g -Xmx4g   # set Xms = Xmx

Setting -Xms equal to -Xmx stops the JVM from spending startup time resizing the heap, and guarantees the memory is reserved up front. In a container, prefer a percentage of available RAM over a hard value:

-XX:MaxRAMPercentage=75.0

The JVM has been aware of cgroup limits since JDK 10 (and 8u191), so it reads the container’s memory allocation correctly. Don’t fill the heap right up to the container ceiling: you need room for metaspace, thread stacks and native buffers, otherwise the kernel’s OOM killer steps in, and it shows no mercy.

A heap that’s too small multiplies collections and eventually triggers Full GCs; a heap that’s too large wastes RAM and lengthens the pauses that sweep all the live memory. The right size is in the logs: aim for a post-GC occupancy that leaves headroom without being excessive.

The G1 levers that matter

Once the heap is sized, the useful G1 flags fit on one hand. The main one:

-XX:MaxGCPauseMillis=200   # pause target, 200 ms by default

It’s a target, not a guarantee. G1 adjusts how much it collects per cycle to get close to it. Dropping it too aggressively (say 20 ms) forces more frequent collections and eats into throughput. Adjust in small steps while watching the real effect in the logs.

Watch out for humongous objects: in G1, any object larger than half a region is allocated separately, in dedicated regions, and is handled poorly. If your logs show a lot of them, it usually points to large arrays or buffers, and it’s often better to revisit the code than the region size (-XX:G1HeapRegionSize).

The real tuning is in the code

Here’s the most important idea in this article: most problems blamed on the GC are really allocation problems. A GC that runs too much is almost always code producing too many temporary objects. No flag will fix a loop that creates millions of short-lived objects; at best it moves the cost around.

Rather than stacking up -XX: flags, measure where the heap gets allocated. async-profiler’s alloc mode points straight at the responsible code paths, and the result reads like any other flamegraph. That’s the subject of two dedicated articles: Profiling the JVM with async-profiler and How to read a flamegraph.

Lowering the allocation rate relieves the GC far more reliably than any collector setting. It’s less flashy than pasting ten flags onto a command line, but it works.

In short

Pick your goal, turn on the logs, keep G1 unless you have a measured reason to switch, size the heap with care, and chase allocations in the code before reaching for exotic flags. The best GC tuning is often the one you never had to do.