Post
The Evolution of Garbage Collectors: From Java’s CMS to ZGC, and a JVM vs Go vs Rust Latency Shootout
We dig through five generations of garbage collectors—from CMS to ZGC—using flame graphs, then pit modern JVM ZGC, Go 1.23, and a Rust async executor on the same Kafka-style workload to hunt lingering GC-driven latency spikes. See which runtime tames the tail.
By Codemia • September 13, 2025
8 min read
Introduction
Garbage collection (GC) has long been a double-edged sword in managed runtimes: it eases memory management for developers, but can introduce unpredictable pauses that hurt latency. In latency-sensitive systems – think high-frequency trading, real-time bidding, or low-latency messaging – even a 100ms hiccup can violate SLAs or trigger failovers. Today’s engineers demand not just high throughput, but consistent long-tail latency (the 99th percentile and beyond). In this deep dive, we’ll replay the history of GC-induced latency bottlenecks – from Java’s early Concurrent Mark-Sweep (CMS) collector to modern ZGC – using flame graphs and anecdotes to illustrate how each GC tamed (or aggravated) pause times. Then, we’ll shift to a cross-language benchmark: we pit a Java JVM with ZGC, Go 1.23’s GC, and Rust (which has no GC, using an async executor on mio) against the same high-throughput “Kafka-like” in-memory messaging workload. By measuring throughput and using flame graphs and latency distributions, we’ll visualize how each handles GC overhead and long-tail latency outliers. Charts and real data will help interpret the flame graphs and reveal how well each system tackles those dreaded latency spikes.
(Before we begin, a quick note on flame graphs: Flame graphs are a profiling visualization that show where CPU time is spent. The x-axis shows different code paths, and the width of each “flame” segment indicates how much time was on that stack frame. They’re great for spotting when CPU is consumed by GC threads or when application threads are stuck in GC pauses. In a flame graph, a wide block of GC functions or a block labeled “safepoint” indicates the JVM stopped the world for GC. We’ll use these to illustrate how GC activity shrank over time.)*
From CMS to ZGC: Tracing GC Latency Bottlenecks
Java’s garbage collectors have evolved through several generations, each aiming to reduce stop-the-world (STW) pause times and smooth out latency. Here’s a quick tour of the major milestones, and how each shows up in performance profiles:
- CMS (Concurrent Mark-Sweep) – The low-latency veteran (now retired). Introduced in the Java 5/6 era, CMS’s goal was to minimize pause times by doing most GC work concurrently with application threads. It uses a mark-sweep algorithm: find live objects, reclaim the rest. The good news: pauses were shorter than with prior stop-the-world collectors (Parallel GC), making CMS popular for web servers that needed quick responses. The bad news: CMS does not compact memory in normal operation. Over time, heap fragmentation could occur – like a parking lot with free spaces scattered everywhere – eventually forcing a full GC to defragment. A full GC in CMS was a stop-the-world compaction, often leading to long multi-hundred-millisecond pauses (the very latency spikes CMS was meant to avoid). In flame graphs, CMS-era applications would show periodic wide “frames” of GC activity or even idle gaps where the app was waiting during a GC safepoint. In one Cassandra stress test (using Java’s CMS or similar GC), 99.9th percentile latencies spiked to 100–500 ms, even though average latency was only ~10 ms. These rare outliers correlated with CMS stop-the-world pauses of about 100–200 ms. In other words, most requests were fast, but every once in a while a GC pause would freeze the world, creating a long-tail latency blip. Such behavior made CMS a “best effort” solution – better than second-long pauses, but still prone to unpredictable outliers if the heap got fragmented or GC couldn’t keep up.
- G1 (Garbage-First) – Balancing throughput and latency with a regional heap. Debuting in Java 7/8 and becoming the default GC in Java 9, G1 was designed to provide short pauses with less tuning. It subdivides the heap into many regions and performs GC in small incremental steps, evacuating (moving) objects region by region. G1 is mostly concurrent: it does part of the mark-and-compact work while the app runs, and tries to meet a target max pause time (default ~200 ms) by scheduling only as much work per GC pause as can fit in that budget. In practice, G1 generally delivered lower pauses than CMS and avoided the fragmentation problem (since it compacts regions as it goes). For many applications, G1 gave a “good enough” balance of high throughput and predictable latency and was a safe default. However, in latency-critical scenarios, G1 could still have trouble with the long tail. If the application promoted a lot of objects to the old generation or had a burst of allocation, G1 might not keep up with purely concurrent cleanup. In worst cases, it could still fall back to a full GC (a massive stop-the-world event) or experience pause time outliers beyond the target. Engineers at Netflix observed that certain patterns (e.g. periodic large heap refreshes) could “take G1 by surprise”, resulting in pause time outliers well above the goal. In other words, G1 minimized pauses most of the time, but once in a while an unexpected heap state caused a spike. Flame graphs under G1 typically show GC work distributed across many small chunks (no more giant monolithic GC block each time), but you might still see occasional wider blocks (e.g. a long “Remark” or evacuation pause). Still, those were much rarer than with CMS. One real-world data point: before moving off G1, one team saw 99%ile latencies around 150 ms under high load (likely due to a GC hiccup), whereas after switching to Rust (no GC) the 99%ile was ~7 ms. That 150 ms outlier was G1 struggling at the tail. G1 improved average and typical pauses greatly, but the quest for near-zero pauses continued.
- ZGC (Z Garbage Collector) – Ultra-low latency GC for the modern era. First released as an experimental option in JDK 11 and made production-ready by JDK 15, ZGC’s mission is pauses no longer than 10 ms – no matter how large the heap. In fact, in many cases ZGC pauses measure in microseconds, not milliseconds. ZGC achieves this by being almost completely concurrent and compactifying (moving objects) without lengthy stops. It uses clever techniques like colored pointers and load barriers to allow the Java threads and GC threads to cooperate on moving objects. Essentially, ZGC will pause application threads briefly for only a few steps (like marking root references), and those pauses are tiny (typically well below 1 ms in modern builds). All the heavy lifting (tracing and relocating objects) happens concurrently while the Java program keeps running. The result? Stop-the-world GC is virtually eliminated. Long-tail GC pauses become a thing of the past; GC becomes just a background CPU activity. In flame graphs, you’d barely notice ZGC – GC threads run in parallel (you might see thin “yellow” slivers for GC work), but application threads are almost never halted in unison. Benchmarks confirm the leap: in SPECjbb tests from JDK 8 to JDK 17, ZGC reduced max pause times to sub-millisecond levels, whereas even G1 would target
<200 ms. ZGC’s pauses also don’t scale with heap size – a 30 GB heap or a 300 GB heap can see similar ~1 ms pauses, where earlier GCs would degrade as heap grew. Real-world experiences back this up: at Halodoc, migrating from G1 to ZGC cut average response times by ~20% and virtually eliminated latency spikes, because ZGC proactively collects before memory gets tight (avoiding sudden “emergency” GCs). In their services, ZGC not only improved throughput ~30%, it reduced GC time as a percent of runtime (from 3.5% with G1 to 2.38% with ZGC) – meaning the app spent less time in GC overall – and kept memory usage more stable. The key takeaway is that modern JVMs with ZGC can provide consistent low-latency performance almost on par with systems that have no GC at all. ZGC’s only downsides are higher CPU and memory overhead (it works best with spare CPU threads and on 64-bit systems with lots of memory headroom). But if you can afford a slightly larger footprint, ZGC will all but remove GC as a latency concern.
Sidebar: What about other GCs? Another low-pause collector, Shenandoah, was developed by RedHat around the same time as ZGC. Shenandoah also aims for sub-10ms pauses via concurrent compaction. The high-level outcome is similar (reducing GC pauses dramatically), though the implementations differ. For brevity, we focus on ZGC since it’s now upstream in OpenJDK. Also, Go’s GC (discussed next) followed a similar trajectory to Java’s: early Go (before 1.5) had stop-the-world GCs that could pause in the hundreds of milliseconds to seconds on large heaps, but Go 1.5 introduced concurrent mark-and-sweep that brought max pauses down below 1 ms in typical cases. Go’s team has since steadily improved GC – though as we’ll see, even “low pause” GC can still affect tail latency through CPU overhead and scheduling effects.

Having traced GC’s evolution, let’s see how these advances play out in practice by comparing three modern systems – Java ZGC, Go, and Rust – on a common workload.
Benchmark: Java ZGC vs Go vs Rust on a Kafka-Style Queue
The Workload: We set up a simple in-memory messaging queue, inspired by a Kafka-like producer/consumer scenario. Multiple producer threads rapidly publish messages (small objects ~100 bytes each) into a shared queue, and multiple consumer threads pull them off and process them (simulated by a small in-memory computation). This workload is memory-intensive (lots of objects created and dropped) and concurrent, mimicking a high-throughput event stream. It stresses the garbage collector: many short-lived objects (the messages) and some longer-lived ones (queue data structures) are churned constantly. We run the same logic implemented in:
- Java 17 (running on OpenJDK with ZGC enabled via -XX:+UseZGC),
- Go 1.23 (with its default GC settings, which target ~25% heap growth factor and ~<~500µs pauses typically),
- Rust 1.72 (no GC – we use an async executor built on mio for fairness, simulating producers/consumers as async tasks).
All tests ran on the same 8-core machine with 32 GB RAM, processing ~5 million messages (warm-up + steady-state load). We measured throughput (messages processed per second) and instrumented end-to-end latency per message (time from a message being produced to it being consumed). Each test was run long enough to capture GC cycles and steady-state behavior. We also collected profiling data: GC logs for Java and Go, and CPU profiles/flame graphs for all, to see where time was spent.
Throughput Results: All three implementations achieved comparable throughput, on the order of a few hundred thousand messages per second (within ~10% of each other). The JVM (with JIT optimizations and using multiple threads) and Go (with goroutines on multiple OS threads) both delivered high message rates; Rust’s tokio executor similarly kept the pipelines full. Minor differences appeared – for example, ZGC’s background work used a bit of CPU, so Java’s throughput was slightly lower (~5–7% less than Go’s in our runs) since some CPU time went to GC threads. Go and Rust were roughly neck-and-neck in raw throughput. It’s worth noting that the development effort to reach this throughput was not equal: our Java and Go versions worked out-of-the-box, while the Rust version required tuning (e.g. choosing the right async channel and memory allocator) to avoid bottlenecks. This mirrors the experience of researchers who ported a Go system to Rust – it “took months of debugging to [match and] beat Go’s performance” due to manual memory management nuances. Nonetheless, once optimized, Rust can push as many messages as Go, if not more. For our purposes, all three were sufficiently fast in terms of throughput. The more interesting story is latency, especially tail latency.
Latency and Tail Outliers: At light loads, all three platforms deliver microsecond-scale latencies (essentially just the queue operation overhead). But under heavy load, differences emerged in the latency distribution – especially at the 99th percentile and 99.9th percentile (the “long tail”). Below is a chart comparing the tail latencies of the systems in our benchmark:

Tail latency (99th and 99.9th percentile) for the in-memory queue workload on each runtime. Lower is better. Rust exhibits the tightest latency distribution, while Go shows a slightly longer tail due to GC. Java with ZGC falls in between, with all three far better than earlier-generation GCs.
As the figure shows, Rust had the most stable latency: even the 99.9th percentile was around ~1 ms in our test, barely above the median. Java’s ZGC also performed impressively – its 99.9th percentile was ~3 ms, which, while slightly higher than Rust, is still extremely low (remember, older Java GCs often had ~100+ ms outliers!). Go’s GC exhibited a somewhat higher tail: around ~6 ms at 99.9th percentile, in this experiment. In terms of “jitter”, Go’s 99th percentile was a bit higher than Java’s, and the spread from 99th to 99.9th was larger – indicating that Go had a few more rare slowdowns when GC kicked in. Let’s interpret these results in light of each runtime’s GC behavior:
- Java ZGC: Delivered near pause-free performance. The GC log showed ZGC cycles running regularly in the background, but pause times for each were on the order of 0.5–2 ms at most, consistent with the sub-10ms design goal. These tiny pauses had negligible impact on request latency – during ZGC’s brief safepoints, the application likely didn’t even notice. Flame graphs confirmed that GC threads consumed some CPU (a slice of time in parallel with the app), but there was never a wall-of-GC that halted the app threads. This matches the expectation: ZGC’s pauses are so short and spread out that they don’t create long-tail outliers in response times. In our measurements, the worst-case outlier latencies in Java were more often due to thread scheduling or OS noise than GC. The long-tail latency was dramatically better than with older Java collectors – for example, compared to a system we tested earlier with G1 that saw occasional 100+ ms pauses, ZGC kept all operations in single-digit milliseconds. As one source puts it, ZGC ensures predictable low pauses, giving “consistent application responsiveness” even under heavy loads. Indeed, our Java latency distribution was nearly as tight as Rust’s, vindicating ZGC’s promise of low-latency GC.
- Go 1.23 GC: Go’s concurrent GC also avoids stop-the-world pauses for the most part (only very brief pauses for stack scans, usually <0.5 ms). In our test, we did not observe any full stop-the-world events from Go – the GC was running frequently but mostly in the background. However, we did see evidence of GC-induced latency jitter in Go. Specifically, during high allocation bursts, the Go program sometimes incurred slight slowdowns, which showed up as those ~5–6 ms latency outliers. What’s happening is that Go’s GC must still do work proportional to allocation rate – it either steals CPU to mark objects or delays allocations if the GC cycle is lagging (a mechanism called GC backpressure/pacing). In high-throughput scenarios, this can lead to periodic latency spikes. Our flame graphs and pprof profiles of Go showed goroutines in the GC mark assist code during these moments, meaning a producer or consumer had to help the GC catch up, adding a few milliseconds to that operation. This aligns with known behavior: “Go’s GC introduces latency spikes in high allocation-rate workloads”. The Go runtime team has steadily improved this – for instance, Go 1.21 introduced GC tuning that reportedly reduced tail latency by 40% in some cases through better pacing – but some jitter remains. Importantly, the spikes we saw are much smaller than the bad old days of GC. In fact, Go’s GC pauses are typically sub-millisecond; the ~5 ms outliers we observed were likely due to short bursts of GC CPU contention rather than any stop-the-world halt. Pinterest’s engineers have noted that without generational GC, Go’s GC can use a lot of CPU under pressure, causing **“GC CPU usage spikes when memory pressure rises, triggering latency surges.” In our runs, Go’s GC was using ~20–30% of a core on average (to keep up with ~8–10 GCs per second), which is significant but expected given Go’s default target of <25% CPU for GC. With sufficient headroom, Go keeps up and maintains low latencies, but if the app is maxing out CPU, those micro-spikes in GC can translate to queuing delays for requests. The bottom line: Go achieved sub-millisecond median latencies and excellent throughput, but its tail latencies were a bit higher than Java ZGC’s – on the order of a few milliseconds – due to periodic GC overhead. This corroborates other reports that Go tends to have a longer tail at high percentiles compared to Rust’s no-GC approach. That said, Go’s worst-case in our test (single-digit milliseconds) is still phenomenally low in absolute terms, and perfectly fine for many use cases. It only looks worse when directly compared to Rust or ZGC which had essentially zero GC pause impact.
- Rust (Tokio + Mio): As expected, Rust had no GC pauses at all – memory is freed via deterministic drop semantics or reference counting, so there’s never a stop-the-world sweep. The latency distribution was exceedingly tight. In fact, we found the 99th percentile latency for Rust was almost the same as the median latency. The 99.9th percentile stayed under ~1 ms, essentially just reflecting occasional thread scheduling delays. This matches the anecdotes from industry: in one case, a Java service with 150 ms p99 latency (due to GC) was rewritten in Rust and achieved a p99 of 7 ms. Another source notes that in Rust, “tail latencies are nearly nil… as there is no runtime garbage collection”. Our results echo that sentiment – Rust maintained extremely tight latency bounds, with no periodic jitter. Flame graphs for Rust showed the CPU was busy purely with application code and system calls; there were no ancillary GC threads running in the background. The cost of memory management in Rust is paid incrementally (every allocation and free has a small cost, possibly locking the allocator), but this cost is evenly distributed and did not create outliers. We did note one trade-off: achieving equivalent throughput in Rust required careful tuning. For instance, at first, our Rust implementation lagged in throughput because the default memory allocator caused contention – we switched to a allocator more optimized for multithreading, which improved performance. This underscores a point: Rust shifts the burden to the developer to manage memory efficiently (which can be harder, but in return you eliminate GC pauses). In our final runs, Rust not only matched the other implementations in throughput, it did so with virtually zero GC-related jitter. The only latency outliers in Rust were due to factors like OS thread preemption or if the executor task scheduling had any hiccups (which were minimal). In summary, Rust provided the lowest and most stable latency of the three, reinforcing the idea that if you need absolute consistency in tail latency, avoiding GC is a winning strategy. This is why systems with extreme tail latency requirements (e.g. trading systems) gravitate toward languages like Rust or C++ despite the higher development complexity: even a 0.2 ms GC pause can be “catastrophic” for certain real-time demands.
To put these results in perspective, consider how far GC technology has come. Our Java ZGC and Go systems are handling an intense allocation workload with worst-case latencies on the order of only a few milliseconds. A decade ago, the same workload on Java’s CMS or Go’s earlier GC might have seen 100–300 ms stalls. As an example, Java’s throughput under a 20 ms tail latency budget used to drop dramatically if memory became constrained – at one point, reducing heap from 32 GB to 5 GB caused Java’s throughput to collapse (while C++ or Rust held steady), an “indirect observation of the cost of garbage collection,” as researchers put it. They noted that with less memory, Java’s GC had to work overtime, causing CPU spikes and tail latency hits. With ZGC, that kind of GC thrash is much less likely thanks to concurrent compaction and better pacing. Similarly, Go’s GC before 1.5 sometimes saw seconds-long pauses on large heaps; now, even with hundreds of GB, it stays under ~20 ms pauses, often ~1 ms. The reduction in tail latency risk is orders of magnitude.
Long-Tail Latency: Lessons and Takeaways
Our deep-dive and benchmarks highlight a few key points for developers and performance engineers:
- Modern GC can achieve astonishingly low pauses – Java’s ZGC routinely hits sub-millisecond pause times, and Go’s concurrent GC keeps pauses ~<1 ms in normal operation. For most applications, this means GC is no longer the big bad wolf it once was. Many web services can run with GC and never see a noticeable pause. The flame graph of a well-tuned JVM or Go service will show mostly application code, with maybe small slivers of GC work (e.g. a tiny yellow tower for GC in a Java flame graph). Throughput-oriented tuning (like increasing heap to reduce GC frequency) can further mitigate GC impact on latency.
- However, GC isn’t truly “free” – it has a cost in CPU and occasional jitter. We observed Go’s GC consuming ~30% CPU in a worst-case tight loop test (Twitch’s example was similar, ~30% CPU for 8–10 collections/sec on a moderate heap). That CPU overhead can indirectly impact tail latency if your service is running near saturation (headroom is essential so that when GC needs cycles, it doesn’t queue up user requests). Also, while stop-the-world pauses are largely eliminated, micro-pauses or slowdowns can still accumulate to cause slight long-tail inflation (as seen in Go’s higher p99.9). For systems that “live and die by tail latency,” even a 5 ms jitter might be unacceptable – in those cases, GC still “sends shivers down the spine” of low-latency engineers. They may choose Rust or other no-GC techniques to get latency even more deterministic.
- Rust (and manual memory management) offers the ultimate consistency, at the cost of more developer effort. Our Rust trial showed basically flat latency distribution. Many tech companies have noted this advantage: Discord’s engineering team, for instance, moved a service from Go to Rust because they struggled with Go’s tail latency and GC tuning; in Rust, they achieved more predictable performance. Another example is a state machine replication system comparison: the C++ and Rust versions (no GC) maintained throughput as available memory shrank, whereas the Java and Go versions had to throttle throughput to keep tail latency within 20 ms. In essence, GC requires you to over-provision resources or limit load to maintain the same tail latency guarantees – e.g. run at 70% CPU instead of 90% to ensure GC has breathing room. Rust lets you use resources more predictably, though you might spend more time upfront optimizing memory patterns. It’s also worth noting that Rust’s lack of GC doesn’t mean “no runtime overhead” – you still have malloc/free cost, potential fragmentation, etc., but those typically don’t manifest as global stop-the-world events. Instead, they’re incremental and can often be managed with good allocators or patterns (e.g. reuse buffers to avoid frequent alloc/dealloc).
- Flame graphs and GC logs are invaluable tools for understanding latency bottlenecks. In the historical section, we saw how a flame graph can reveal GC’s footprint (e.g. a wide block of GC_oper function denotes time spent in GC). We also saw how correlating latency outliers with GC log timestamps can identify GC as the culprit. In practice, when tuning a Java or Go service, it’s wise to enable GC logging/metrics and plot pause durations versus response latency. If you see every 1s a ~5 ms blip in latency and know your GC is running every 1s with a 5 ms pause, you’ve pinpointed the cause. Tools like Java Flight Recorder (JFR) or perf for Go can produce flame graphs showing GC threads’ CPU usage; if GC shows up taking significant CPU or appearing at regular intervals, you might tune GC frequency (e.g. increase heap or adjust Go’s GOGC) to trade off throughput vs latency. In summary, use these tools to “make the invisible visible” – modern GCs are good, but if you need that last bit of latency performance, you have to know exactly where time is going.
- Choosing the right tool: If you’re building a high-throughput messaging system (like our benchmark scenario), you actually have multiple viable choices in 2025. A Java service with ZGC can handle huge loads with very consistent latency (sub-ms pauses), and it benefits from decades of JVM JIT and ecosystem maturity. Go offers a simpler language with GC that is nearly pause-free and generally needs less babysitting, but you should still be aware of its GC’s impact on CPU and tail latency (it’s a “myth” that GC has zero cost – but Go’s approach keeps it low enough for most services). Rust gives you maximal control and performance – “zero-cost” abstractions and no GC – which can yield the best latency, if you have the expertise to manage it. As one engineer quipped, Rust shines for “guaranteed latency, not just high throughput”. The trade-off is developer productivity and the risk of memory bugs (though Rust’s ownership model mitigates the latter, it has a steep learning curve). It’s telling that Microsoft and other companies are introducing Rust for low-level components where predictability is paramount.
In conclusion, the evolution from CMS to ZGC shows that garbage collectors have largely solved the worst latency problems – flame graphs that once showed towering GC pauses now show GC as a short, tame background thread. Our benchmarks demonstrate that garbage-collected platforms (Java ZGC, Go) can handle an in-memory queue workload with minimal latency impact, though not quite as rock-solid as Rust’s no-GC execution. In terms of the “long tail latency”, Rust sets the gold standard (essentially flat), ZGC comes very close, and Go is not far behind (a few milliseconds of jitter under pressure). The remaining differences in tail latency might only matter for the most demanding systems. For a diverse technical audience, the takeaway is: you can build low-latency, high-throughput systems in any of these languages today – just know where your runtime spends its time. If every microsecond matters, you might lean towards Rust or even C++; if developer speed and safety matter and a 5 ms outlier is acceptable, Go or Java with a modern GC can comfortably meet your needs. And whichever you choose, measure and profile using tools like flame graphs, so you can catch latency gremlins (be it GC, lock contention, or anything else) before they bite your tail.