2024 · 03 11 min read

GC Tuning Is Not About the GC

For three weeks, a platform engineering team iterated on G1 Garbage Collector configurations. The service, a high-throughput Java API, was experiencing tail latency spikes. SREs systematically adjusted JVM flags, changing MaxGCPauseMillis from 200 to 100 and then to 50, altering G1 region sizes, adjusting survivor ratios, and expanding the heap from 8 gigabytes to 16 gigabytes. Each attempt yielded confusing results. The p99 tail latency spikes persisted, and CPU utilization steadily climbed with every new flag added. The team was tuning the collector in the dark.

The breakthrough came when they stopped looking at the collector and started profiling memory allocations. Using a low-overhead Java Flight Recorder profile, the team discovered that a logging statement on a hot request path was implicitly evaluating the toString() method of a large metadata object graph on every request. This single line of code generated over 150 megabytes per second of garbage. Once this logging line was corrected, the allocation rate dropped by 90%, the tail latency spikes disappeared, and the JVM returned to stable operation. The weeks spent tuning GC flags was wasted effort because they treated a code symptom rather than its root cause.

The Generational Hypothesis

The fundamental mechanics of JVM garbage collectors are built upon the generational hypothesis. This empirical observation states that in most software applications, the vast majority of allocated objects die shortly after creation. A typical request handling pipeline instantiates short lived objects, such as database query result rows, JSON parsers, and string builders, which become unreachable as soon as the HTTP response is written. Only a tiny fraction of objects (such as connection pools, configuration maps, and cache stores) survive long enough to transition into the old generation.

To exploit this behavior, JVM heaps are divided into young and old generations. Young generation collections (minor GCs) are frequent but fast, copying surviving objects to survivor spaces. Old generation collections (major or concurrent GCs) are less frequent but more expensive because they must process larger volumes of memory. If your application's allocation rate is low, minor GCs are rare, and the garbage collector has little work to do. If your allocation rate is high, the young generation fills up rapidly, forcing frequent collections and accelerating the rate at which objects are prematurely promoted to the old generation.

The Formula of GC Overhead

Many developers treat garbage collection pause time as an independent variable controlled by JVM flags. In reality, GC pause duration and CPU consumption are downstream consequences of your allocation rate and object lifetime distribution. We can frame this relationship with a simple mental model published in JVM performance journals in late 2025:

math
GC CPU Overhead = Allocation Rate (MB/s) * Collection Cost (CPU seconds/MB)

The collection cost is a factor of your collector choice (G1, ZGC, or Parallel) and the hardware architecture. The variable you actually control in your code is the allocation rate. If you double your allocation rate, you double the CPU cycles lost to garbage collection, regardless of which collector flags you configure. Tweaking GC parameters without addressing allocation rate is an attempt to optimize the division of labor between young and old generations without reducing the total work required.

The Diagnostics Sequence

To optimize JVM performance, you must follow a structured diagnostics sequence. Tuning flags must always be the final step, not the first:

  1. Measure baseline allocation rate and GC pause behavior: Collect performance metrics under production load using Java Flight Recorder (JFR) or async-profiler. Never make decisions based on local micro-benchmarks or synthetic tests.
  2. Identify hot allocation call sites: Analyze the profiling data to find the specific methods and classes generating the highest volume of temporary objects.
  3. Fix the code: Modify the application code to eliminate unnecessary allocations, reuse objects, or move data off-heap.
  4. Re-measure to confirm improvement: Validate that the code changes have reduced the allocation rate and lowered the GC pause frequency.
  5. Tune the collector: If latency targets are still violated after code level fixes, begin adjusting collector parameters.
  6. Validate changes before wide rollout: Test any configuration flag changes under production load using canary deployments. Never copy flags from a blog post without validating them on your own workload.

Diagnostic Commands: JFR and async-profiler

To identify where memory is being allocated, you must use low-overhead profiling tools that are safe to run in production environments.

1. Java Flight Recorder (JFR)

JFR is integrated into the OpenJDK JVM and has a latency impact of under 1%. You can start an allocation profile on a running JVM process using the jcmd utility:

shell
jcmd <PID> JFR.start name=alloc profile=settings=profile duration=120s filename=alloc.jfr

Once the JFR recording completes, you can extract the allocation event statistics. The key events to analyze are jdk.ObjectAllocationInNewTLAB (allocation in a thread local allocation buffer) and jdk.ObjectAllocationOutsideTLAB (allocation directly in the young generation, indicating larger objects):

shell
jfr print --events=jdk.ObjectAllocationInNewTLAB,jdk.ObjectAllocationOutsideTLAB alloc.jfr | head -n 50

2. async-profiler

For finding hot allocation paths, `async-profiler` is often preferred because it avoids JVM Safepoint bias. You can profile allocations by targeting the alloc event, which monitors memory allocation in New TLABs and outside TLABs. The command below generates an interactive HTML flame graph:

shell
./asprof -e alloc -d 120 -f alloc_flame.html <PID>

Open the generated HTML file in a browser. The flame graph displays the call stacks. The width of each bar corresponds to the total volume of memory allocated by that method and its callees. Focus your optimization efforts on the widest bars at the top of the stack.

Reading the Results: TLABs and Humongous Allocations

When reviewing your allocation profiles, you must distinguish between the fast path and slow path of JVM allocation.

Thread Local Allocation Buffers (TLABs)

To avoid lock contention when multiple threads allocate memory simultaneously, the JVM assigns each thread a dedicated chunk of the young generation called a TLAB. When a thread requests memory (for example, creating a short lived DTO), the JVM allocates it out of the thread's TLAB. This is a fast path operation requiring no synchronization. Only when a TLAB fills up does the thread synchronize with the main heap to acquire a new TLAB.

If an object is too large to fit in a TLAB, the JVM bypasses the buffer and allocates it directly in the shared young generation. This slow path requires synchronization and adds latency. Look closely at jdk.ObjectAllocationOutsideTLAB events in your JFR logs. These events indicate that your objects are larger than your average TLAB size, indicating a need to optimize object sizes or adjust JVM TLAB configurations.

G1 Humongous Allocations

In the G1 collector, the heap is divided into equal sized regions. Any object that requires more than 50% of a G1 region's size is classified as a "humongous allocation." These objects bypass the young generation entirely and are allocated directly into the old generation. This behavior is documented in JVM performance guides from late 2025.

Humongous allocations are expensive. Because they sit in the old generation, they are not collected during minor GCs. Frequent humongous allocations force G1 to start concurrent marking cycles early or trigger full GC pauses, degrading tail latency. You can identify these events in your GC logs by enabling unified GC logging (-Xlog:gc+phases=debug) and searching for "Humongous" entries. Match the byte size of the allocation in your JFR profile with the GC log entries to find the class triggering the humongous allocation.

Common Hot Allocation Offenders

The table below lists the most common allocation anti patterns found in Java applications, along with their performance impact:

Anti-Pattern Operational Cost / Mechanism Common Fix
Autoboxing in Hot Loops Converting a primitive (int) to its boxed object (Integer) instantiates a new wrapper object on every iteration, causing massive object churn. Use primitive collections (e.g. Trove, Eclipse Collections) or design loops to use primitives directly.
String Concatenation Using the + operator in request paths creates intermediate StringBuilder and String objects for every concatenated block. Use pre-sized StringBuilder structures or pre-compile format patterns.
byte[] Churn from Serialization Serializing large JSON payloads or database rows to byte arrays on every request creates massive arrays that must be collected immediately. Use streaming serialization APIs (like Jackson streaming) to write directly to socket output streams.
BigDecimal in Tight Loops BigDecimal is an immutable object. Every arithmetic operation (add, multiply) allocates a new instance. Perform calculations using scaled long primitives where possible, or instantiate BigDecimals only for final outputs.
Stream Pipeline Overuse Java Streams are elegant but allocate intermediate pipeline stage objects, iterator wrappers, and lambda contexts on every execution. Use standard imperative loops inside high frequency, latency-critical request paths.
Accidental toString() Calls Evaluating log lines containing string concatenations (e.g. log.debug("User data: " + userObj)) evaluates the string even if the debug level is disabled. Use parameterized logging (log.debug("User data: {}", userObj)) to defer string evaluation.

Fix Levers Before Touching GC Flags

Before modifying any JVM configuration flags, apply these code level optimizations to reduce memory pressure:

  1. Buffer Reuse: Reuse reusable buffers (such as serialization byte arrays or compression streams) using ThreadLocal pools or object arenas to avoid allocating new buffers on every request.
  2. Primitive Collections: Replace boxed generic collections (like Map<Integer, Double>) with primitive collections to eliminate autoboxing overhead.
  3. Flatten DTO Chains: Avoid creating nested Data Transfer Objects (DTOs) that exist only to pass data through application layers. Flatten your data structures to minimize allocation depth.
  4. Move Caches Off-Heap: If your application maintains large, long lived caches, move them off-heap using direct ByteBuffers or off-heap cache libraries like Netty, Agrona, or Chronicle. This removes the cache memory from the garbage collector's responsibility entirely.

Tuning the Collector

If your allocation rate is optimized but GC pauses still violate your tail latency SLA, you can begin tuning the collector flags. The table below outlines the primary collectors and their key configurations, verified against OpenJDK documentation in late 2025:

Collector Name Target Workload SLA Key Tuning Levers Default JDK Version
Parallel GC Pure batch throughput. No latency constraints. -XX:ParallelGCThreads=n (set worker threads) Default on JDK 8
G1 GC General purpose. Balanced latency and throughput. -XX:MaxGCPauseMillis=200 (adaptive pause target)
-XX:InitiatingHeapOccupancyPercent=45 (starts concurrent cycle)
Default since JDK 9
ZGC / GenZGC Strict low pause latency (sub-millisecond target). -XX:+UseZGC -XX:+ZGenerational (enables generational ZGC)
-XX:ZAllocationSpikeTolerance=n (handles sudden load)
Production ready on JDK 17 / 21

Validation Methodology

When validating a GC configuration change, never analyze GC pause histograms in isolation. A configuration that reduces average pause times can sometimes degrade overall throughput or increase CPU usage. Implement these validation metrics on your dashboard: