GC stutters in Java games occur when the JVM’s garbage collector (GC) pauses all application threads to reclaim memory, interrupting your game loop mid-frame and producing the hitches players notice. To stop them, you need to profile your GC pauses with Java Flight Recorder (JFR), switch to a low-pause collector like ZGC, and tune your heap size flags to match your game’s memory profile.
This guide walks you through each step with concrete JVM flags and version-specific callouts.
- Profile GC pauses using JFR to identify pause sources
- Select ZGC or G1GC based on heap size and Java version
- Configure heap sizing flags to reduce GC frequency
- Reduce per-frame object allocation to lower GC pressure
- Validate improvements using GC logs and a second JFR recording
Why GC Pauses Show Up as Frame Stutters
A stop-the-world (STW) pause is exactly what it sounds like: the JVM halts every application thread, including your game loop, to perform GC work. Nothing renders. Nothing updates. The game freezes for the pause duration, then resumes. At 60fps, your frame budget is 16.6ms. A 50ms STW pause drops three frames at once, and players feel it immediately.
Minor GC pauses collect the young generation (short-lived objects) and typically run in under 5ms with a well-tuned heap. Major or full GC pauses collect the old generation and can run for tens or hundreds of milliseconds. Full GCs are the ones that cause visible hitches. The fix requires both profiling to find which type is hitting you and tuning to reduce the impact. Adding flags blindly without profiling first is how you spend two hours making things worse.
Profiling GC Pauses with Java Flight Recorder
Java Flight Recorder (JFR) is a low-overhead profiling tool built into the JDK since Java 11 (available without a commercial license from Java 11 onward; earlier access existed in Java 8u262+ under commercial terms). JFR records JVM events, including every GC pause, its duration, its cause, and the heap state before and after. Overhead is low enough to run during real gameplay sessions, which matters because GC stutters often only appear under actual game load.
Enabling JFR on Game Startup
Add this flag to your JVM launch command:
-XX:StartFlightRecording=duration=120s,filename=game.jfr
Run a two-minute in-game session that covers normal gameplay, including any sections where you’ve noticed stutters. The recording writes to game.jfr when the duration expires.
Reading the Recording in JDK Mission Control
Open the .jfr file in JDK Mission Control (JMC), the companion analysis tool for JFR recordings. Navigate to the GC view. You’re looking for two signals: pause duration spikes above your frame budget, and allocation rate trends that precede them. A sudden allocation spike followed by a pause spike tells you that object churn in a specific game phase is triggering collections. That’s your starting point for both collector tuning and allocation reduction.
Picking the Right Garbage Collector
The default collector in Java 9 and later is G1GC (Garbage-First Garbage Collector), which balances throughput and pause time but isn’t optimized for sub-millisecond pauses. For a game loop with a 16ms frame budget, you need a concurrent collector that does most GC work alongside the application rather than stopping it.
| Collector | Min Java Version | Typical Max Pause | Best Heap Range | Game Loop Fit |
|---|---|---|---|---|
| G1GC | Java 9 (default) | 50-200ms | Under 4GB | Acceptable with tuning |
| ZGC | Java 15 (production) | Under 1ms | 4GB and above | Preferred for 60fps |
| Shenandoah | Java 15 (production) | Under 10ms | Any size | Good alternative to ZGC |
ZGC became production-ready in Java 15 and targets sub-millisecond STW pauses by doing concurrent marking and relocation alongside running application threads. Generational ZGC, added in Java 21, improves throughput further by separating young and old generation collection. Shenandoah, available in OpenJDK builds from Java 12 and production-ready in Java 15, takes a similar concurrent approach and performs well on smaller heaps where ZGC’s memory overhead is a concern.
The trade-off is real: concurrent collectors use additional CPU cycles to run GC work alongside your game. If your game is already CPU-bound on the render thread, that overhead matters. In our experience, ZGC’s CPU cost is acceptable for most desktop game workloads, but you should verify with your JFR data after switching.
Heap Sizing Flags That Reduce GC Frequency
An undersized heap causes GC to run constantly. An oversized heap causes longer individual pauses when GC finally runs. The goal is a heap large enough to hold your working set comfortably, with the initial and maximum sizes set equal to prevent heap resizing pauses during gameplay.
Core Heap Flags
# Set initial and max heap equal to prevent resize pauses -Xms2g -Xmx2g # For G1GC: set a soft pause target in milliseconds (default is 200ms) -XX:MaxGCPauseMillis=16 # Control young generation size (larger = fewer promotions to old gen) -XX:NewRatio=2
Setting -XX:MaxGCPauseMillis=16 tells G1GC to target pauses under 16ms. It’s a soft target, not a guarantee, but it shifts G1GC’s behavior toward smaller, more frequent collections rather than large infrequent ones. Heap sizing is iterative. Use your JFR allocation rate data to adjust, not a one-time guess.
Working ZGC Flag Set for Java 21
# Use generational ZGC (Java 21+) -XX:+UseZGC -XX:+ZGenerational # Lock heap size to prevent resize pauses -Xms2g -Xmx2g # Pre-fault heap memory at startup so OS doesn't page during gameplay -XX:+AlwaysPreTouch # Block System.gc() calls from libraries triggering full GC mid-game -XX:+DisableExplicitGC
-XX:+AlwaysPreTouch pre-faults all heap pages at JVM startup, so the OS doesn’t page in memory during a critical gameplay moment. It extends startup time but eliminates a common source of unexpected pauses. For Java 11-14, drop -XX:+ZGenerational and add -XX:+UnlockExperimentalVMOptions before -XX:+UseZGC.
Reducing Object Allocation in the Game Loop
The most durable fix is reducing the volume of short-lived objects your game creates per frame. Fewer allocations mean less GC pressure regardless of which collector you’re running. This is where JFR allocation profiling pays off directly.
Common Per-Frame Allocation Hotspots
- Vector math temporaries: creating a new
Vector2orVector3per physics or movement calculation - Event objects: allocating new event instances per frame for input or collision systems
- String concatenation in debug paths:
"Entity " + id + " at " + xcreates multiple objects per call - Iterator allocations: for-each loops over
ArrayListallocate an iterator object each iteration - Particle and bullet instances: spawning new objects for short-lived game entities
The fix for vector temporaries is mutation rather than allocation. Instead of Vector2 vel = new Vector2(dx, dy) inside your update loop, pre-allocate a single Vector2 instance and call a set(dx, dy) method on it each frame. For bullets and particles, an object pool works well: maintain a fixed collection of pre-allocated instances, check one out when needed, and return it when done.
Object pools add complexity. An object not properly reset before reuse causes subtle bugs that are hard to trace. Use pooling where your JFR allocation data confirms the allocation rate is high enough to justify it, not everywhere by default.
Validating the Fix with GC Logs
After applying your tuning changes, enable GC logging to confirm the impact:
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m
In the log output, look for pause duration per GC event and whether old generation collections are occurring. Your target state: minor GC pauses under 5ms, no full GC events during normal gameplay, and GC frequency low enough that pauses don’t cluster within a single second. Run a second JFR recording after tuning and compare pause duration distributions against your baseline recording in JMC to confirm improvement.
When GC Tuning Isn’t Enough
If your allocation rate is extremely high (hundreds of megabytes per second), even ZGC will struggle to keep up. The root fix is reducing allocations, not changing collectors. Memory leaks that cause heap growth over time will eventually force a full GC regardless of which collector you’re using. JFR’s heap live set tracking helps detect this: if your live set grows steadily over a long session, you have a leak.
Escape analysis (-XX:+DoEscapeAnalysis, enabled by default since Java 8) lets the JIT compiler eliminate some heap allocations automatically for objects that don’t escape their creating method. It helps, but it’s not a substitute for explicit pooling in hot paths where the JIT can’t prove escape status.
Frequently Asked Questions
Does ZGC work with Java 11?
ZGC is available in Java 11 but requires -XX:+UnlockExperimentalVMOptions -XX:+UseZGC because it was still experimental. Production-ready ZGC arrived in Java 15. Generational ZGC, the version with the best throughput characteristics, requires Java 21.
How do I reduce GC pause time without changing the collector?
Set -Xms and -Xmx equal to prevent heap resizing, set -XX:MaxGCPauseMillis=16 for G1GC, and reduce per-frame allocation by profiling hotspots with JFR. Collector selection matters, but heap sizing and allocation reduction often produce the biggest gains.
Can I run JFR during a live game session without hurting performance?
Yes. JFR’s design goal is sub-1% overhead in most workloads. Running it during actual gameplay is the right approach because GC stutters often only appear under real load, not in synthetic benchmarks.
What Java version do I need for JFR without a commercial license?
Java 11 and later include JFR as part of the open-source JDK with no commercial license required. Java 8u262 introduced JFR to OpenJDK 8 builds, but availability varies by distribution.
How do I know if my heap size is too small?
Open your JFR recording in JDK Mission Control and check the heap occupancy graph. If heap usage consistently hits 80-90% of your -Xmx value before GC runs, your heap is undersized. Increase -Xms and -Xmx together and re-record.
Apply This to Your Game Today
Start with JFR. Add -XX:StartFlightRecording=duration=120s,filename=game.jfr to your launch script, play a session that reproduces your stutters, and open the result in JDK Mission Control. The GC pause view will tell you whether you’re fighting minor collections, old generation promotions, or full GCs. That answer determines whether you need a collector switch, heap resizing, or allocation reduction. Don’t guess. The data is there; you just have to collect it.
Jodie Bird is the founder and principal author of the Java Limit website, a dedicated platform for sharing insights, tips, and solutions related to Java and software development. With years of experience in the field, Jodie leads a team of seasoned developers who document their collective knowledge through the Java Limit journal.








