G1’s Own Throughput Fix: JEP 522 and the Quiet Death of “G1 Trades Throughput for Latency”
For a decade and a half, “G1 gives up some throughput for lower pause times” has been treated as a law of nature rather than an engineering choice. JEP 522 shipped in JDK 26 and quietly demonstrated that a large slice of that gap was never inherent to concurrent collection at all, it was one specific data structure.
A previous piece on this site walked through the standard framing of G1 as the JVM’s balanced compromise: better pause times than Parallel, worse raw throughput, take the trade or switch collectors. That framing was accurate when it was written, and it is worth being precise about what has actually changed rather than declaring the whole idea dead. JEP 522, G1 GC: Improve Throughput by Reducing Synchronization, did not eliminate G1’s throughput cost. It found out that a meaningful chunk of that cost was an implementation artifact from 2011, not a law of concurrent garbage collection, and removed it.
Where the Original Tax Came From
G1 reclaims memory by copying live objects into fresh regions and reclaiming the regions they vacated. To find every reference that needs updating after a move, G1 tracks cross-region pointers in a card table, updated by small pieces of injected code called write barriers every time a program stores a reference into a field. Scanning that table during a pause is normally cheap. The problem shows up in write-heavy applications, where the card table can grow fast enough to blow through G1’s pause-time goal on its own, so G1 runs background optimizer threads to keep the table trimmed between pauses.
Those optimizer threads and the application’s own threads were both touching the same card table, and correctness demanded they coordinate. That coordination is what made G1’s write barriers slow: on x64, the injected barrier code ran to roughly 50 instructions per reference store, most of it synchronization and filtering logic rather than the actual bookkeeping. Every one of those instructions ran on every reference assignment in the program, which is exactly the kind of cost that shows up as “G1 has lower throughput” in a benchmark without ever showing up as a single identifiable bottleneck.
What JEP 522 Actually Does
The fix, credited to Ivan Walulya and Thomas Schatzl, is architecturally almost embarrassingly simple once stated: give the application and the optimizer threads two separate card tables instead of one. Application threads write to the first table with no synchronization at all, since nothing else is touching it. Optimizer threads work the second table, which starts empty. When G1 judges that scanning the first table during the next pause would blow the pause-time budget, it atomically swaps which table is “active,” application threads start filling the now-empty one, and the optimizer threads get to work on the full one without any further coordination with the mutator.
The JEP’s own alternatives section is worth reading for what it rules out. An OS-level atomic table swap was prototyped and dropped for requiring platform-specific code with no proportional benefit. Keeping the old auxiliary tracking structure and only trimming the barrier around it was tried and limited how much the barrier could actually shrink. A dual-mode G1, one mode as before and a faster mode with Parallel-style barriers, was rejected because it would let users select a mode at startup, and anyone motivated enough to pick the faster mode might as well run Parallel GC outright. The dual-card-table design won because it removed the synchronization without asking anyone to choose anything.


Reference-heavy applications, the ones that were paying the coordination tax hardest, see throughput gains of 5 to 15 percent. Applications that rarely mutate reference fields still pick up as much as 5 percent purely from the barrier code getting smaller and faster to execute, with a secondary benefit of slightly shorter GC pauses since the new card table is a more efficient structure than the auxiliary one it replaces. The memory cost is small and explicit: each card table costs about 0.2 percent of heap capacity, so a second table adds roughly 2MB of native memory per 1GB of Java heap, a trade the JEP argues is easily justified given G1 removed larger internal structures of its own in JDK 20 and 21.
| Before JEP 522 | After JEP 522 | |
|---|---|---|
| Card table structure | One shared table, application and optimizer threads both write to it | Two tables; application writes one, optimizer works the other, roles swap atomically |
| Write barrier synchronization | Fine-grained locking between mutator and refinement threads | None; each side owns its own table |
| x64 write barrier size | ~50 instructions | ~12 instructions |
| User-facing change | â | None; no new flags, existing -XX:G1ConcRefinementThreads still works |
Is the Old Framing Actually Dead?
Not entirely, and it is worth resisting the urge to overstate this. The JEP is explicit in its own non-goals that matching Parallel or Serial GC’s raw throughput was never the target, and G1 still does more concurrent work alongside the application than a stop-the-world collector does by design, which means some coordination cost is structurally unavoidable. The headline claim that “G1 trades throughput for latency” survives at that architectural level.
What does not survive is the assumption that sat underneath it for years: that the specific size of that gap was fixed by the nature of concurrent collection rather than by a particular data structure OpenJDK engineers happened to pick in 2011. A single shared card table requiring fine-grained mutator-refiner synchronization was one implementation decision among several the JEP’s authors list as considered and rejected. It shipped, it became the thing every G1-versus-Parallel benchmark measured, and it got treated as the cost of doing business rather than as a target for future work. JEP 522 is the demonstration that it was the latter. The gap is smaller now, on the same hardware, running the same collector, because someone rethought one internal structure rather than accepting the tax as permanent.
What to actually check after upgrading to JDK 26
- Nothing needs to be enabled. JEP 522 is on by default for every G1 workload on JDK 26 and later, with no new JVM flags to set.
- Re-baseline your GC logs rather than trusting old benchmarks; look specifically at Scan RS and Update RS phase durations, which should shrink under high mutation load.
-XX:G1ConcRefinementThreads andÂ-XX:-G1UseConcRefinement still work exactly as before; nothing about their meaning changed, only the internal structure they operate on.- Expect the biggest gains in write-heavy services, caches, and anything doing heavy graph or collection mutation; expect smaller, still real gains everywhere else from the shrunken barrier alone.
- Don’t expect G1 to out-throughput Parallel GC on a pure batch workload. That was explicitly ruled out as a goal, and it still is not what this change is for.
What We Learned
JEP 522 replaced G1’s single, mutator-and-refiner-shared card table with two independent tables that swap roles atomically, removing the fine-grained synchronization that had made G1’s write barriers roughly four times larger than they needed to be on x64. The result, now shipped and default in JDK 26, is a 5 to 15 percent throughput gain in write-intensive applications and up to 5 percent even elsewhere, for a native memory cost of about 2MB per gigabyte of heap and zero configuration changes. The old claim that G1 inherently trades throughput for latency is not wrong at the architectural level, since G1 still coordinates more with the application than a stop-the-world collector does.
What is wrong is treating the size of that trade as fixed. A meaningful share of it turned out to be a fifteen-year-old data-structure choice, and once OpenJDK engineers targeted it directly, the gap closed by double digits without anyone having to give up G1’s pause-time behavior to get there.

