JVM Garbage Collection Tuning: 10 Proven Patterns to Stop GC Pauses in Production

Optimize JVM performance with 10 proven GC tuning patterns. Learn to reduce pause times, pick the right collector, and fix memory issues. Start tuning smarter today.

JVM Garbage Collection Tuning: 10 Proven Patterns to Stop GC Pauses in Production

You started your career building a simple web app that took a couple of seconds to respond, and nobody complained. Then traffic grew. Pauses became half‑second hiccups, then multi‑second freezes. Users noticed. Your boss noticed. You started digging into logs and found the culprit: garbage collection.

I’ve been there. The first time I saw a ten‑second GC pause in production, I panicked. I thought the JVM was broken. It wasn’t. I just didn’t understand how the garbage collector worked. Over the years I learned that tuning GC is not about magic flags. It’s about knowing what your application does with memory and picking the right knobs.

Let me walk you through ten patterns that have saved my applications more than once. Each pattern comes with a concrete example and a personal story. I’ll write them as if we’re sitting together debugging a real problem.

First, a warning: do not change any flag without measuring first. GC tuning is like adjusting the tension on a guitar string – one quarter turn too far and the whole thing sounds awful. Always start a flight recording, enable GC logging, and watch what happens before and after each change.


When I first saw a five‑second pause in a chat server, I reached for the default collector. The default on most modern JDKs is G1GC. It works well for most things, but not everything.

I remember a batch job that processed millions of records overnight. With G1GC, it took eight hours. The pauses were tiny, but the overhead was killing throughput. I switched to the parallel collector by adding -XX:+UseParallelGC. The same job finished in five hours. No more pause worries because a batch job doesn’t care about a two‑second stop every minute.

For my chat server, though, pauses were the enemy. I switched to ZGC (available in JDK 15+). The pauses dropped from hundreds of milliseconds to under one millisecond. The CPU usage went up, but the users stopped complaining.

Here’s how you pick:

  • If you need low latency and have a large heap (tens of GB), use ZGC or Shenandoah.
  • If you have a moderate heap (up to about 16 GB) and want sub‑second pauses, use G1GC.
  • If you run batch jobs and want maximum throughput, use the parallel collector.
# For a responsive web service
-XX:+UseZGC

# For a typical microservice with 8 GB heap
-XX:+UseG1GC

# For offline data processing
-XX:+UseParallelGC

Don’t guess. Run your application under realistic load with each collector. Measure the 99th percentile latency and the total CPU time. One collector will win.


G1GC tries to keep your pauses under a target you set. The default is 200 milliseconds. I once lowered it to 50 milliseconds because my boss wanted “instant” responses. The result? G1GC started doing more work per pause, so the GC cycles happened more often. Total GC time went up, and the app’s throughput dropped. The 99th percentile got worse, not better.

The goal is a soft target. G1 will do its best, but if your allocation rate is too high, it can’t meet that target. You’ll see “to‑space overflow” errors in the log.

Start with the default. Run a load test. Look at the GC logs and see what actual pause times are. If they are consistently above your target, don’t just lower the goal. Instead, consider increasing the heap or reducing the allocation rate (by tuning your code). If they are far below the target, you might raise the goal to improve throughput.

# Start here
-XX:MaxGCPauseMillis=200

# If you see pauses < 100ms, you could try 300ms to reduce GC overhead

But never set it so low that G1 starts doing many small pauses. That burns CPU for no gain.


Heap size is the single most influential tuning parameter. I used to set -Xms small and let the JVM grow. Then I noticed that the heap would shrink after a GC, only to grow again under load, causing more GCs. Set -Xms equal to -Xmx to avoid that dance.

For G1GC, the heap is divided into regions. The region size depends on the total heap. For an 8 GB heap, regions are about 2 MB each. If your application allocates many large objects (like large arrays), you want larger regions so each object fits in one region. Use -XX:G1HeapRegionSize to set it explicitly.

A colleague had a service that allocated a lot of 10 MB byte arrays. With default region size, each object spanned five regions, causing fragmentation. We increased region size to 8 MB. The object fitted in one region. Fragmentation dropped and pause times improved.

-Xms8g -Xmx8g -XX:G1HeapRegionSize=8m

Also tune the young generation size. In G1, -XX:G1NewSizePercent and -XX:G1MaxNewSizePercent control the nursery. If your application creates many short‑lived objects, you want a bigger nursery so they die before they are promoted to old gen. Start with 5% and 60%, then adjust based on GC logs.


I once ran a high‑frequency trading application that used ZGC. The concurrent phases took too long during peak load, causing the application threads to stall waiting for a GC cycle to finish. The default number of concurrent GC threads was too low for that machine (16 cores, but the code was heavily single‑threaded).

I increased -XX:ConcGCThreads from the default 4 to 8. The concurrent marking finished faster. The pauses stayed the same, but the total time spent in GC decreased. Application latency improved.

For CPU‑bound applications, adding more concurrent threads can hurt because they compete with your worker threads. But for I/O‑bound or idle applications, more concurrent threads are beneficial.

-XX:ConcGCThreads=8

Watch the “Concurrent Mark” and “Concurrent Relocation” times in the GC log. If they take longer than a few milliseconds, consider adding threads.


You cannot tune what you cannot see. Enable unified GC logging on every production instance. Keep enough files to cover a full day’s operation.

I once helped a team that had no logs. They changed flags randomly. After we enabled logging, we saw that their application was allocating 2 GB per second of temporary objects. The GC was running constantly. No flag could fix that. They had to rewrite a piece of code.

Here’s a reliable set of flags:

-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=50m

This writes GC events to a rotating set of files. Each file up to 50 MB, keep ten files. Use tools like GCViewer to parse them. Look for:

  • High allocation rate (if the log shows Allocation Rate).
  • Long pause times (anything above your target).
  • Frequent full GCs (those are rare but bad).
  • Premature promotions (young objects moving to old gen).

These patterns become obvious once you see them in a log.


String deduplication is a free lunch for apps with many duplicate strings. It’s on by default in G1 since JDK 8u20. It finds duplicate String objects in the old generation and makes them share the same backing character array.

I had a log aggregator that stored millions of identical log level strings (“INFO”, “WARN”). With dedup enabled, old gen usage dropped by 15%. That meant fewer mixed GC cycles. The application stayed responsive longer.

You can also use Application Class Data Sharing (AppCDS) to shrink class metadata. During a training run, create an archive:

java -XX:ArchiveClassesAtExit=myapp.jsa -jar myapp.jar

Then use it in production:

java -XX:SharedArchiveFile=myapp.jsa -jar myapp.jar

This reduces memory for class data, which means less work for the GC.


Premature promotion is when objects that should die young survive a few collections and get promoted to old gen. This happens when the young generation is too small. I saw this in a microservice that processed small JSON requests. The nursery was 500 MB. Each request created a few MB of temporary data that lived for two seconds. Those temporary objects survived the first collection because the nursery was full and some had to be promoted. Over time, old gen grew, triggering more GC cycles.

The fix was to increase the max young generation size from 60% to 80% of the heap. The temporary objects now fit entirely in the young generation and got collected without promotion. Old gen stayed stable. Mixed collections became rare.

Use -XX:+PrintTenuringDistribution (or -Xlog:gc+age=trace on modern JDK) to see object ages. If most objects survive to high ages (e.g., 15), your young generation might be too small.

-XX:G1MaxNewSizePercent=80

But be careful: a huge young generation means longer young pauses. Monitor pause times.


The concurrent marking in G1 starts when a certain percentage of the heap is occupied. That threshold is controlled by -XX:InitiatingHeapOccupancyPercent. The default is 45%.

If you see marking starting too early (frequent concurrent cycles that don’t recover much), increase the threshold to 60 or 70. If you see marking starting too late (after a full GC because the heap was exhausted), lower it to 35.

I once had an application with a stable heap usage around 50%. With the default 45%, marking started immediately after every full GC and caused extra CPU overhead. I raised it to 60. Marking started less often. Throughput improved.

-XX:InitiatingHeapOccupancyPercent=60

Watch the log for “Pause Init Mark” messages. If they appear many times per minute, IHOP is too low. If they appear only after a full GC, it’s too high.


Thread stack size and object alignment are not directly GC flags, but they affect memory consumption and thus GC behaviour.

Each Java thread has a native stack. The default 1 MB per thread is generous. If your application has 2000 threads, that’s 2 GB of memory used just for stacks. Reducing to 256 KB saves 1.5 GB for more heap.

I reduced stack size on a chat server with 5000 concurrent connections (each connection had a thread). We went from 5 GB stacks to 1.25 GB. That extra 3.75 GB went to heap, reducing GC pressure.

-Xss256k

Be careful: if your application uses deep recursion, you need a larger stack. Test with your deepest call stack.

Object alignment controls how the JVM rounds object sizes. The default is 8 bytes. For heaps larger than 32 GB, sometimes using 16‑byte alignment (with -XX:ObjectAlignmentInBytes=16) can reduce fragmentation. But it may increase object sizes because of padding. Test first.


Every tuning change must be validated under load. I once made a change that reduced GC pause by 30% but increased lock contention. P95 latency got worse. If I hadn’t measured end‑to‑end latency, I would have shipped a regression.

Use JDK Flight Recorder to capture a profile during a load test. Then use JDK Mission Control to compare two runs.

-XX:StartFlightRecording=filename=before.jfr,settings=profile

After the change, start another recording.

Look at the “GC Pauses” tab. Note the number, duration, and frequency. Also look at CPU usage, allocation rate, and lock contention. A good tuning reduces GC time without increasing other bottlenecks.

The final advice: do not tune for the sake of tuning. GC problems are often symptoms of code problems. When you see a high allocation rate, fix the allocation. When you see large objects, think about object pooling or restructuring. GC tuning is last resort, not first.

Start with the right collector and a proper heap size. Enable logging. Measure. Then adjust one thing at a time. Over time, you learn the rhythm of your application’s memory. And then you can make it sing.

These ten patterns have helped me keep applications responsive under load. I hope they help you too. Now go enable GC logging before your next deployment. You’ll thank yourself later.


// Keep Reading

Similar Articles

Supercharge Java: AOT Compilation Boosts Performance and Enables New Possibilities
Java

Supercharge Java: AOT Compilation Boosts Performance and Enables New Possibilities

Java's Ahead-of-Time (AOT) compilation transforms code into native machine code before runtime, offering faster startup times and better performance. It's particularly useful for microservices and serverless functions. GraalVM is a popular tool for AOT compilation. While it presents challenges with reflection and dynamic class loading, AOT compilation opens new possibilities for Java in resource-constrained environments and serverless computing.

Read Article →
Java's Hidden Power: Unleash Native Code and Memory for Lightning-Fast Performance
Java

Java's Hidden Power: Unleash Native Code and Memory for Lightning-Fast Performance

Java's Foreign Function & Memory API enables direct native code calls and off-heap memory management without JNI. It provides type-safe, efficient methods for allocating and manipulating native memory, defining complex data structures, and interfacing with system resources. This API enhances Java's capabilities in high-performance computing and systems programming, while maintaining safety guarantees.

Read Article →
GraalVM: Supercharge Java with Multi-Language Support and Lightning-Fast Performance
Java

GraalVM: Supercharge Java with Multi-Language Support and Lightning-Fast Performance

GraalVM is a versatile virtual machine that runs multiple programming languages, optimizes Java code, and creates native images. It enables seamless integration of different languages in a single project, improves performance, and reduces resource usage. GraalVM's polyglot capabilities and native image feature make it ideal for microservices and modernizing legacy applications.

Read Article →