Tune Code Before Your Garbage Collector
Prioritize Code Tuning Over Garbage Collector Tweaking
Understanding the inner workings of Core Java is the secret to developing applications that are not only simpler but significantly faster.
The Core Thesis
Optimizing how your application handles memory allocations can yield far greater performance gains than simply swapping your Garbage Collector (GC). More importantly, picking a GC first optimizing your code can actually change which GC is the most efficient for your specific workload.
The Benchmark Scenario
To demonstrate this, a latency benchmark was conducted measuring the time from a MarketDataSnapshot to a NewOrderSingle.
- Throughput: messages per second.
- Duration: 30 minutes.
- Tooling:
JLBHtestingChronicle-FIX.
The experiment compared a system performing redundant work (logging every message via SLF4J) against a streamlined system (relying on Chronicle Queue for internal recording).
Phase 1: The Unoptimized Benchmark
In this version, a single SLF4J log line was added to every message sent. While one line seems negligible, at high frequencies in a low-latency environment, the impact is massive.
RTT Latency Distribution (with SLF4J logging)
| GC Option | () | () |
|---|---|---|
| Parallel (Large Eden, no large pages) | 16.86 | 20,480 |
| ZGC (no large pages) | 12.21 | 19,694 |
| G1 + COH (no large pages) | 12.02 | 20,349 |
| G1 (2 MiB large pages) | 13.94 | 20,021 |
| Shenandoah Generational (no large pages) | 12.30 | 19,235 |
Note: The values are in thousands of microseconds, meaning the worst 1 in 10,000 events take roughly . At , this happens times per second.
At this stage, one might conclude that Shenandoah is the superior choice and Parallel GC should be avoided.
Phase 2: Assessing Disk I/O Impact
Since I/O is a common source of jitter, the logs were moved to /dev/shm (a RAM-backed file system) to see if the storage medium was the culprit.
RTT Latency Distribution (logging to /dev/shm)
| GC Option | () | () |
|---|---|---|
| Parallel (Large Eden, no large pages) | 34.50 | 10,994 |
| ZGC (no large pages) | 10.86 | 9,617 |
| G1 + COH (no large pages) | 10.90 | 10,043 |
| G1 (2 MiB large pages) | 10.64 | 11,682 |
| Shenandoah Generational (no large pages) | 10.32 | 9,552 |
While the for Parallel GC looks like an outlier here, the trend remains: Shenandoah still looks attractive, but the act of changing the I/O path provided a more significant boost than changing the GC.
Phase 3: Removing Redundant Logging
Because Chronicle-FIX already records messages internally, the SLF4J logs were entirely redundant. In high-performance coding, we avoid standard logging in favor of Chronicle-Queue.
Our Logging Policy:
- No
INFOlevel logging after the application has started. -
ERROR/WARNlevels are enabled by default. -
DEBUGlogging is disabled by default. - Every log line must be strictly justified.
RTT Latency Distribution (Chronicle-Queue recording only)
| GC Option | () | () |
|---|---|---|
| Parallel (Large Eden, no large pages) | 7.14 | 8.94 |
| ZGC (no large pages) | 7.82 | 9.36 |
| G1 + COH (no large pages) | 8.30 | 12.4 |
| G1 (2 MiB large pages) | 8.59 | 154.4 |
| Shenandoah Generational (no large pages) | 7.83 | 233.7 |
The Result
The dropped by about , but the plummeted by three orders of magnitude. Most strikingly, the "best" GC flipped: Parallel GC is now the winner, while Shenandoah performed the worst at the level.
Final Conclusion
The ideal Garbage Collector is not a universal constant; it is dependent on your specific workload.
- Optimize the workload first.
- Then choose the GC.
Doing this ensures you don't waste time tuning a GC to compensate for inefficient code, and it ensures you pick the GC that actually fits your optimized profile.
🖼️ Visual Evidence

📚 Other Popular Posts
🤖 Comparing AI for Code Optimization
Different AI models provide varied results when optimizing the same snippet. The best strategy is to query multiple AIs and select the most elegant solution.
Example Challenge: Optimizing a formatOffset method.
private static String formatOffset(int millis) {
String sign = millis < 0 ? "-" : "+";
int saveSecs = Math.abs(millis) / 1000;
int hours = saveSecs / 3600;
int mins = ((saveSecs / 60) % 60);
int secs = (saveSecs % 60);
if (secs == 0) {
if (mins == 0) {
return sign + twoDigitString(hours);
}
return sign + twoDigitString(hours) + twoDigitString(mins);
}
return sign + twoDigitString(hours) + twoDigitString(mins) + twoDigitString(secs);
}
🔍 Java Object Sizes Demystified
Measuring memory in Java is tricky because the JVM abstracts raw memory. However, for low-latency systems, understanding Compressed Oops (Ordinary Object Pointers) and Compact Object Headers is vital for reasoning about the actual memory footprint of your objects.