How I Tuned G1GC in Java 21 to Eliminate Latency Spikes
A Practical Guide to Garbage Collection Logs, Heap Sizing, and Production JVM Stability
How I Tuned G1GC in Java 21 to Eliminate Latency Spikes
A Practical Guide to Garbage Collection Logs, Heap Sizing, and Production JVM Stability

Google AI studio by Author
1. The Latency Problem Was Not CPU
The system was a high-throughput REST API running on Java 21. CPU was stable at 40–50%. Memory looked “fine” in dashboards. Yet p99 latency would spike from 40ms to 2–3 seconds multiple times per hour.
The cause wasn’t load. It was garbage collection.
By default, G1 Garbage Collector in Java 21 is well-tuned for general workloads. But “general” doesn’t mean low-latency API under burst traffic with uneven allocation rates.
The first thing I did was enable proper GC logging. Not minimal logging. Full structured logs.
JAVA_OPTS="
-Xms8g
-Xmx8g
-XX:+UseG1GC
-Xlog:gc*,gc+heap=info,gc+age=trace:file=/var/log/app/gc.log:tags,uptime,time,level
"
If you don’t have GC logs, you’re guessing. And guessing at JVM behavior in production is how you lose weeks.
2. Reading G1 Logs the Right Way
Once logging was enabled, I analyzed the logs using:
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram
And offline analysis tools like:
java -jar gcviewer.jar gc.log
What I was looking for:
- Pause times for Young GC
- Mixed GC frequency
- Full GC events
- Promotion failures
- Humongous allocations
Typical problematic log snippet:
[2.345s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 512M->380M(8192M) 450.123ms
A 450ms pause during peak traffic is catastrophic for a low-latency API.
The heap wasn’t exhausted. But evacuation pauses were longer than expected.
The root cause: region pressure and survivor space mis-sizing.
3. Fixing Heap Sizing First
Before touching exotic flags, I standardized heap configuration.
I always set:
-Xms8g
-Xmx8g
Equal initial and max heap.
Why?
Heap resizing introduces stop-the-world pauses. On burst workloads, automatic expansion creates unpredictable latency.
I also validated region size:
-XX:+UnlockExperimentalVMOptions
-XX:G1HeapRegionSize=16m
Default region size is chosen automatically, but with an 8GB heap, I wanted fewer, larger regions to reduce region tracking overhead.
After change:
- Fewer regions
- More predictable evacuation
- Slight reduction in GC frequency
Not dramatic, but measurable.
4. Tuning Young Generation Pressure
The real issue was excessive promotion to old generation during traffic spikes.
I inspected survivor age distribution:
-Xlog:gc+age=trace
Output showed many objects surviving multiple cycles but dying shortly after promotion.
Classic short-lived burst pattern.
I tuned:
-XX:MaxGCPauseMillis=100
-XX:InitiatingHeapOccupancyPercent=30
-XX:G1NewSizePercent=40
-XX:G1MaxNewSizePercent=60
Key changes:
- Increased young generation size
- Started concurrent marking earlier
Effect:
- Reduced premature promotion
- Reduced mixed GC pressure
- Stabilized young GC pause times under 120ms
G1 respects MaxGCPauseMillis as a target, not a guarantee. But it adjusts region selection accordingly.
After tuning, average young GC dropped from 300–400ms to 80–110ms.
5. Controlling Humongous Allocations
Another hidden latency source was humongous objects.
In G1, any object larger than 50% of region size becomes humongous and is allocated directly in old generation.
With 16MB regions, anything >8MB triggered this path.
We discovered large JSON payload buffers being materialized as byte arrays.
Quick validation:
jcmd <pid> GC.class_histogram | grep '\[B'
Large [B entries confirmed byte arrays dominating allocation.
Two fixes:
Application-level:
public byte[] serialize(Object obj) {
return objectMapper.writeValueAsBytes(obj);
}
Was replaced with streaming:
public void writeToStream(Object obj, OutputStream out) throws IOException {
objectMapper.writeValue(out, obj);
}
JVM-level safeguard:
-XX:G1HeapRegionSize=8m
Smaller regions reduce humongous threshold.
After eliminating large buffer allocations, mixed GC cycles dropped significantly.
6. Stabilizing Mixed GC Cycles
Mixed GC was the source of unpredictable pauses.
Log example:
Pause Young (Mixed) 2048M->1600M(8192M) 900.234ms
Mixed GC cleans both young and old regions. If old gen occupancy is high, mixed cycles become expensive.
Fixes applied:
-XX:G1MixedGCLiveThresholdPercent=85
-XX:G1HeapWastePercent=5
-XX:G1MixedGCCountTarget=8
Why:
- Lower live threshold means fewer regions selected
- Targeted mixed cycles distributed cleanup across more cycles
Instead of rare massive mixed pauses, I got smaller, more frequent, predictable ones.
Latency spikes disappeared because pauses were amortized.
7. Thread Allocation Rate Matters More Than Heap Size
Heap size alone does not fix allocation storms.
I used:
jstat -gc <pid> 1000
And monitored allocation rate during traffic bursts.
Findings:
- Allocation rate doubled under specific endpoints
- Caused by excessive object creation in request parsing
Fix required code-level refactoring:
Before:
public Map<String, Object> parse(String json) {
return new ObjectMapper().readValue(json, Map.class);
}
After:
private static final ObjectMapper MAPPER = new ObjectMapper();
public Map<String, Object> parse(String json) throws IOException {
return MAPPER.readValue(json, Map.class);
}
Avoiding per-request mapper instantiation reduced allocation pressure significantly.
GC tuning without fixing allocation hotspots is temporary relief.
8. Preventing Full GC at All Costs
In Java 21, Full GC under G1 is rare but devastating.
I searched logs for:
Pause Full (G1 Compaction Pause)
If you see this regularly, something is wrong.
Common causes:
- Metaspace exhaustion
- Promotion failure
- Explicit
System.gc()
I disabled explicit GC:
-XX:+DisableExplicitGC
And ensured metaspace had headroom:
-XX:MaxMetaspaceSize=512m
Also monitored native memory:
jcmd <pid> VM.native_memory summary
Full GC events dropped to zero after stabilizing old generation occupancy and reducing humongous allocations.
That alone eliminated multi-second pauses.
9. Final Stable Configuration
After iterative tuning, the final stable configuration looked like this:
-Xms8g
-Xmx8g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:InitiatingHeapOccupancyPercent=30
-XX:G1NewSizePercent=40
-XX:G1MaxNewSizePercent=60
-XX:G1MixedGCCountTarget=8
-XX:G1HeapWastePercent=5
-XX:+DisableExplicitGC
-Xlog:gc*,gc+heap=info:file=/var/log/app/gc.log:time,uptime,level,tags
Results:
- p99 latency reduced from 2–3 seconds to <150ms
- No Full GC in weeks
- Predictable young GC pauses
- Stable memory footprint
The most important lesson: GC tuning is not about memorizing flags. It’s about reading logs, understanding allocation behavior, and adjusting based on evidence.
In Java 21, G1 Garbage Collector is mature and capable. But it assumes reasonable allocation patterns and stable heap sizing.
Latency spikes weren’t random. They were the JVM reacting to allocation pressure and old generation imbalance.
Once I treated GC logs as production telemetry instead of noise, the system became predictable.
And predictability is what real JVM stability looks like.
메타데이터
- post_id
- 58ad18dae1eb
- slug
- how-i-tuned-g1gc-in-java-21-to-eliminate-latency-spikes-58ad18dae1eb
- url
- https://medium.com/javarevisited/how-i-tuned-g1gc-in-java-21-to-eliminate-latency-spikes-58ad18dae1eb
- canonical_url
- https://medium.com/javarevisited/how-i-tuned-g1gc-in-java-21-to-eliminate-latency-spikes-58ad18dae1eb
- author_url
- https://medium.com/@michaelpreston515
- status
- ok
- fetched_at
- 2026-06-17 08:20:12