On This Page
I had a program with 200 MB of old objects that nobody touched, a 128 MB young generation, and a young GC that took 0.23 ms. Then I made 300,000 of those old objects point at one new object. Same heap. Same live set. Same garbage per round. The pause went to 17.35 ms.
OpenJDK 21.0.12.1, Linux x86_64, 2 vCPU, -Xms1g -Xmx1g -Xmn128m, 30 rounds, average of young pauses after 6 warm-up GCs
Serial Parallel G1
0 old->young stores 0.23 ms 1.11 ms 1.55 ms
1,000 stores, 1 card each 0.32 ms 1.26 ms 1.37 ms
100,000 stores 6.76 ms 7.06 ms 11.86 ms
300,000 stores 17.35 ms 22.41 ms 27.55 ms
Nothing in that table is about how much is alive or how big Old Gen is. The only thing that moved is the number of places in Old Gen where somebody wrote a reference to a young object. That cost is the whole subject of this post, and the structure that keeps it bounded is a remembered set: a record of "these places in the old part of the heap might point into the part you're about to collect". Without one, a young GC would have to walk the entire old generation to be safe.
The short version: a bitmap of where old objects changed
A young collection only wants to look at young objects. But a young object can be alive because an old object points at it, and the collector can't know that without looking at the old object. Looking at all of Old Gen defeats the point of having a young generation.
So the JVM keeps a cheap side record. Every time your code stores a reference, a few extra instructions (the write barrier, a small snippet the JIT compiler inlines after reference stores) note which chunk of the heap changed. The chunk is a card, 512 bytes by default, and the record is a card table, one byte per card. At the next young GC, the collector scans the dirty cards and ignores the rest.
That's the whole idea in Serial and Parallel. G1 keeps the card table and adds a second level, per-region remembered sets, because G1 collects arbitrary regions and not only "the young part". The rest of this post is what that costs and where it surprised me. It took me a good chunk of two days to separate what I'd read from what I could actually make a log print, so I've marked which is which.
I covered how young collections and promotion work in object promotion in Java and G1's region model in G1 explained. This is the bookkeeping underneath both.
Dirty some cards and watch the pause move
What you need. OpenJDK 21.0.12.1 (Ubuntu package; Temurin 21 should match), javac, bash, awk. Two vCPUs and 8 GB here. No dependencies. I didn't run JDK 17, 25 or 26, so everything below that's a log line is from 21.
The code. Rs2.java builds two million small old objects, forces them into Old Gen, then in each round makes k of them point at one fresh object and produces about 100 MB of garbage so a young GC happens.
public class Rs2 {
static class Node { Node next; byte[] pad = new byte[64]; }
static volatile Object sink;
public static void main(String[] a) throws Exception {
int oldN = Integer.parseInt(a[0]);
int k = Integer.parseInt(a[1]); // stores per round (old -> young)
int stride = Integer.parseInt(a[2]); // distance between stored-into old objects
int rounds = Integer.parseInt(a[3]);
Node[] old = new Node[oldN];
for (int i = 0; i < oldN; i++) old[i] = new Node();
System.gc(); System.gc(); // push everything to Old
for (int r = 0; r < rounds; r++) {
Node y = new Node();
for (int j = 0, i = 0; j < k && i < oldN; j++, i += stride)
old[i].next = y; // the line that matters: old -> young pointer
for (int c = 0; c < 100000; c++) sink = new byte[1024]; // ~100 MB garbage
}
}
}
Steps.
javac Rs2.java
# 1. Serial, no old->young stores (k=0)
java -Xms1g -Xmx1g -Xmn128m -XX:+UseSerialGC -Xlog:gc:file=r2.log Rs2 2000000 0 1 30
awk '/Pause Young/{ms=$NF; sub(/ms/,"",ms); if(++n>6){s+=ms;m++}} END{printf "avg %.2f ms over %d\n",s/m,m}' r2.log
# expect: ~0.2 ms
# 2. Same, 100,000 stores, one per 5 old objects (about one per card)
java -Xms1g -Xmx1g -Xmn128m -XX:+UseSerialGC -Xlog:gc:file=r2.log Rs2 2000000 100000 5 30
# expect: several ms. Swap in -XX:+UseParallelGC or -XX:+UseG1GC for the other columns.
# 3. Look inside G1's pause
java -Xms1g -Xmx1g -Xmn128m -XX:+UseG1GC -Xlog:gc,gc+phases=debug,gc+remset=trace:file=g1p.log Rs2 2000000 100000 5 12
grep -E "Dirty Cards|Scanned Cards|Found Roots|Merged Cards|Scan Heap Roots|Visited cards" g1p.log | tail -8
Forcing levers. -Xmn128m keeps young GCs frequent so you see dozens in seconds. A production young gen is gigabytes. The ratio of dirty cards to everything else in the pause is what carries over, not the absolute milliseconds.
Confirmation signal. Here's step 3 for one G1 pause (GC 10):
[0.840s][debug][gc,remset] GC(10) Visited cards 101696 Total dirty 108544 (93.69%) ...
[0.853s][debug][gc,phases] GC(10) Merged Cards: Sum: 16, Workers: 2
[0.853s][debug][gc,phases] GC(10) Dirty Cards: Sum: 101542, Workers: 2
[0.853s][debug][gc,phases] GC(10) Scan Heap Roots (ms): Avg: 8.74, Max: 10.88
[0.853s][debug][gc,phases] GC(10) Scanned Cards: Sum: 101542, Workers: 2
[0.853s][debug][gc,phases] GC(10) Found Roots: Sum: 100003, Workers: 2
[0.853s][info ][gc,phases] GC(10) Evacuate Collection Set: 12.07ms
I stored 100,000 references. G1 scanned 101,542 cards and "found roots" 100,003 times. That's the program's stores, counted back to me. Of a 12 ms evacuation, about 9 ms was scanning those cards.
If your numbers for k=0 and k=300,000 look the same, check that System.gc() really ran (the log should show two Pause Full), otherwise the "old" objects are still young and there's nothing to remember.
Environment differences. On more cores the scan splits across more workers and the absolute numbers drop. Heap sizes that change G1's region size change the count of cross-region stores but not this effect.
Cleanup. rm *.log *.class. Small.
My first version of this lab was wrong
I started with a simpler program that allocated a new Node for every old object it dirtied. Pauses came out at 74 ms and the whole run took 14 seconds. I wrote "dirty cards scale with old-gen size" in my notes and moved on. It was nonsense: that version was also promoting and copying two million fresh objects per round. Storing one shared young object into many old slots, so that only the card marking varies, is what isolates the effect.
The old-to-young problem, and the byte per card that solves it
Mark-and-copy of the young generation starts from roots: thread stacks, static fields, JNI handles. Those are the places the collector can enumerate cheaply. See GC roots and reachability for the full list. A young object reachable only through an old object isn't reachable from any of those, so the collector needs a second set of roots: every old-to-young reference.
Finding them by scanning Old Gen is a full-heap cost on a minor collection. Instead:
- The heap is divided into 512-byte cards (
GCCardSizeInBytes, product, default 512 on 21 per-XX:+PrintFlagsFinal). - A card table has one byte per card. A 1 GB heap has 2,097,152 cards, so the table is 2 MB. That matches the "2 MB per 1 GB of heap" figure in JEP 522.
- After every reference store the compiled code marks the card holding the written-to field: roughly
table[address >> 9] = dirty. Shift, store. This is the write barrier. - At a young GC, the collector walks the card table, and for each dirty card scans the objects on that card for pointers into the young generation. Those pointers are extra roots. A card that no longer holds a young pointer is cleaned.
Notice what the barrier doesn't know. It doesn't check whether the stored value is young. It doesn't check whether the holder is old. Marking too many cards is safe; missing one loses a live object. So the barrier is fast and imprecise, and the cost shows up at GC time as scanning cards that turn out to hold nothing interesting.
Old gen: [card 0][card 1][card 2][card 3] ... 512 bytes each
^
old.next = youngObj (reference store)
| barrier: table[addr>>9] = dirty
Card table: [ 0 ][ 0 ][ D ][ 0 ] ... 1 byte each
Young GC: roots (stacks, statics, JNI) + every dirty card scanned for young pointers
One more piece of vocabulary. A remembered set is the general idea (a record of incoming references to the part being collected). A card table is one way to store it, a coarse global one. G1's remembered sets are the other way: one per region, listing which cards elsewhere in the heap point into that region. I'll come back to why G1 needs both.
Three implementations, and where JDK 26 changes G1's
Serial and Parallel use the plain card table above. One table, an unconditional store in the barrier, and young GC scans the dirty ones. I'm describing that from how I remember CardTableBarrierSet and the card-marking code in the compiler backends; I did not re-read them this run. The numbers in my table are consistent with it: no per-region structure, scan cost proportional to dirty cards.
G1 has to be able to collect any set of regions, including old ones in a mixed collection (see G1 explained). A single global "dirty" bit isn't enough, because for a given collection set the question is "which cards point into these regions". So each region keeps a remembered set of incoming cards, and G1's post-write barrier does more work. Per a master's thesis that describes it step by step (secondary source; I did not read g1BarrierSetAssembler_x86.cpp this run), the barrier:
- exits if the holder and target are in the same region (an XOR and a shift),
- exits on a null store,
- exits if the card is a young-region card (young regions are always collected, so nothing to record),
- issues a store-load memory fence,
- exits if the card is already dirty,
- otherwise dirties the card and puts it on a thread-local queue, which hands full buffers to the concurrent refinement threads.
Refinement threads scan queued cards, find the cross-region references, and add them to the target region's remembered set, but only if that region is tracked. Which brings me to the surprise.
The thing I assumed wrong. I expected a G1 remembered set to be full of old-to-young entries after my lab. It's empty. The gc+remset+exit=trace summary at JVM exit said this:
0 occupied cards represented.
0 ( 0.0%) entries by 51 (51) Young regions
0 ( 0.0%) entries by 201 Old regions
The 101,542 cards in the GC(10) log reached the scan through the Dirty Cards counter, not through remembered-set merging (Merged Cards was 16 across both workers). I don't have a source-level explanation of exactly which path handles young-targeted cards; I'm reporting what the two counters did. What I did find: JDK-8180415, "Rebuild remembered sets during the concurrent cycle", moved G1 to keeping old-region remembered sets only for regions it plans to evacuate in mixed collections, and rebuilding them using the concurrent mark. The commit describes a "Rebuild Remembered Sets" phase and says sets are kept "only until the mixed collections that use them finish". The bug page I could read didn't state a fix release; the 2018 date and the bug's "b10" fragment point at the JDK 11 line, but check before you quote me.
I tried to make an old-to-old remembered set show up (two million cross-region old-to-old stores, then a System.gc() with -XX:+ExplicitGCInvokesConcurrent). The exit summary showed 11 or 12 occupied cards. That's consistent with "not tracked", but my regions were fully live, so none were mixed-GC candidates, and I can't claim more than that from this lab.
Naming drift. If you learned G1 tuning from older posts you'll look for Update RS and Scan RS lines. I grepped for them on 21 and found nothing. On 21 the phases are Merge Heap Roots (with Log Buffers, Dirty Cards, Skipped Cards under it) and Scan Heap Roots. I believe the older names are from JDK 17 and earlier, but I didn't run 17.
Generational ZGC (JEP 439, JDK 21) can't skip the old generation either, and it does it with a store barrier plus its own remembered set. I haven't read that implementation; ZGC explained is where it'll live.
JEP 522 (JDK 26, status Completed). G1's barrier and its refinement threads used to synchronise with each other, which made the barrier long and slow. The JEP adds a second card table. Application threads write the first one with no synchronisation. Refinement threads work on the second, which starts empty. If scanning the active table in a pause would blow the pause goal, G1 swaps the two atomically using a thread-local handshake (see safepoints). The JEP claims the x64 barrier goes from about 50 instructions to about 12, throughput up 5 to 15% for reference-write-heavy applications and up to 5% for the others, and about 0.2% of heap in native memory per table. No new flags; -XX:-G1UseConcRefinement and -XX:G1ConcRefinementThreads=<n> keep working.
JDK 8 Serial/Parallel/CMS: card table. G1: card table + per-region remsets for every region
JDK 9 G1 default (JEP 248); unified logging
~JDK 11 G1 rebuilds old-region remsets during concurrent mark (JDK-8180415; release unverified)
JDK 14 CMS removed (JEP 363)
JDK 21 Generational ZGC (JEP 439); what I ran
JDK 25 LTS; no JEP 522
JDK 26 JEP 522: second card table, lighter G1 barrier
Where it hurts: barrier cost, wide dirty sets, and wrong lessons
The barrier isn't free, and G1's is the heaviest. I ran a store loop (Bar.java): a hot loop storing a young object into an old array of references, best of ten repetitions, one run each:
-XX:+UseSerialGC 0.76 ns/store
-XX:+UseParallelGC 0.84 ns/store
-XX:+UseG1GC 8.57 ns/store
-XX:+UseZGC (JDK 21 default) 0.30 ns/store
-XX:+UseZGC -XX:+ZGenerational 0.85 ns/store
This is a worst case I built on purpose: every store crosses regions, the card is already dirty, and the store-load fence in G1's barrier fires every time. Real programs don't do 100 million reference stores into one array. It's still the cleanest picture I have of why JEP 522 exists. Non-generational ZGC has no post-write barrier at all, which fits its 0.30. Treat these as one box, one JIT, one afternoon.
-XX:+UseCondCardMark (product, default false on 21). The usual story: check the card before dirtying it, so two threads writing near each other don't ping-pong the same cache line. My two-thread test on 2 vCPU gave noisy and contradictory numbers, including cases where it was slower. I'm not going to tell you it helps or doesn't. Measure on your own hardware if you see false sharing.
Wide, sparse dirty sets. The table shows the cost isn't about how much Old Gen holds. 300,000 widely spread stores cost 17 to 28 ms; 1,000 stores cost nothing. A big mutable structure in Old Gen that keeps getting fresh young objects stored into it (a cache holding recent request state, a long-lived map you update per request, an array of references you rewrite every tick) makes every young GC pay. That's an allocation pattern problem and shows up as young pauses that grow with write volume, not with heap size or live set.
Misreading it as promotion. Both inflate young pauses. Premature promotion shows Tenured or old regions growing at each young GC. Card scanning doesn't grow Old at all. In the G1 log, look at Scan Heap Roots and the Scanned Cards sum before you look at Object Copy.
Misreading it as a long pause with another cause. If the pause is long and Scan Heap Roots is small, it's something else; the triage tree is in long GC pauses.
Turning the few knobs that exist
Short list on purpose. Cause before cure: the cost is proportional to dirty cards, so the only lasting fix is fewer dirty cards.
| What | Where | Status / default (JDK 21) | What it does |
|---|---|---|---|
| Change the code | yours | n/a | Stop storing young references into big old structures every cycle. Batch updates, swap in a fresh structure, or keep per-request state out of the shared one. |
-XX:GCCardSizeInBytes |
all collectors | product, 512 | Card size. I didn't test other values; don't touch it without measuring. |
-XX:G1ConcRefinementThreads |
G1 | product, 2 (ergonomic) | How many threads drain dirty-card queues. More threads can offload the pause; they cost CPU. |
-XX:G1RSetUpdatingPauseTimePercent |
G1 | product, 10 | Share of the pause target G1 allows for processing leftover dirty cards. |
-XX:+UseCondCardMark |
Serial, Parallel, others | product, false | Check-before-write on the card. See above; unproven here. |
Not a fix: raising -Xmx. Bigger heap doesn't change how many cards you dirty. Switching collector changes barrier cost, as the store-loop numbers show, but if the real issue is wide writes into Old Gen you'll move the cost without removing it.
What to log, and what to retest on upgrade
Leave -Xlog:gc*:file=gc.log:time,uptime,level,tags on with rotation. For this topic, switch gc+phases to debug when you're investigating; it prints one line per phase per pause, a few dozen lines, so keep it on only while you look. I did not measure its overhead.
What to watch in G1: Scan Heap Roots as a share of Evacuate Collection Set, and the Scanned Cards sum. A trend upward with flat heap occupancy points at write patterns. Syntax for the tags is in the -Xlog field guide.
Across versions: 17 to 21 changes nothing here that I know of beyond what I couldn't confirm (the Update RS rename). 21 to 25: no JEP 522 in 25. A move to 26 or later (27 shipped September 2026 per press coverage I read for the G1 article) brings the new barrier, so re-measure your throughput and your pause breakdown; the pause logs may no longer show the same refinement lines. I haven't run 26.
Keep four things
- A young GC scans dirty cards, not Old Gen. The cost is the number of cards your code dirtied, not how big Old Gen is.
- 0.23 ms to 17.35 ms in my lab came from 300,000 old-to-young stores and nothing else.
- G1 pays for this at store time too, with a heavier barrier than Parallel or Serial, which is the cost JEP 522 attacks in JDK 26.
- On 21, G1 young-targeted cards arrived as
Dirty Cards, not as remembered-set entries. Look atScan Heap Rootsbefore you blame copying.
Quick answers about remembered sets and card tables
What is a remembered set in the JVM? A record of references coming into the part of the heap being collected, so the collector doesn't scan everything else to find them. Serial and Parallel use one global card table; G1 adds per-region remembered sets for the regions it plans to evacuate.
What is a card table in Java GC? A byte array with one byte per 512-byte chunk of the heap (the default on JDK 21). The write barrier marks a card dirty when a reference inside it changes, and young collections scan only dirty cards in the old generation.
Does the write barrier slow my code down? Yes, slightly. In my one-machine store loop it was about 0.8 ns per reference store on Serial and Parallel and about 8.6 ns on G1 in an adversarial case. JEP 522 (JDK 26) claims 5 to 15% throughput gains for write-heavy applications.
Why do my young GC pauses grow while the heap stays the same size? Check for more old-to-young stores. See minor vs major vs full GC for how each pause type is built, and read Scan Heap Roots first.