The idea: a minor GC must not read the old generation
A minor GC collects only the young generation (eden and the survivor spaces). It finds live young objects by following references from the roots: thread stacks, static fields, JNI handles. But an old object can also point to a young one. If the GC ignored that reference, it would free a live object. If it traced the whole old generation to find such references, a minor GC would be as slow as a full GC.
HotSpot's answer is the card table: a cheap, rough record of where old→young references might be. The GC treats every object on a dirty card as an extra root and skips the rest of the old generation.
Cards and the card table
The heap is cut into cards of 512 bytes (GCCardSizeInBytes, default 512). The card table is a byte array with one entry per card, so the card of address a is card_table[a >> 9]. In HotSpot a clean card is 0xFF and a dirty card is 0x00. On the page the old generation is 0x10000–0x10FFF, so its cards are 128 to 135.
| Old generation | Cards | Card table size |
|---|---|---|
| 4 KB (this page) | 8 | 8 bytes |
| 1 GB | 2,097,152 | 2 MB |
| 32 GB | 67,108,864 | 64 MB |
Walking 2 MB of card bytes is far cheaper than following every reference in 1 GB of objects, and a clean card costs one byte read.
The post-write barrier
The JIT and the interpreter add a few instructions after every reference store into a heap object, the post-write barrier:
C.f = y; // putfield
card_table[address_of(C.f) >> 9] = 0; // mark the card dirty
In machine code it is a shift and a byte store (the table base is pre-adjusted so no subtraction is needed). The Serial and Parallel barrier does not test the value: storing null, an old object or a young object all dirty the card, because a test would cost more than the byte store. (Demo 1: old→young store dirties one card; Demo 3: old→old store, card scanned for nothing.) With -XX:+UseCondCardMark the barrier first checks whether the card is already dirty, which avoids cache-line ping-pong between cores writing nearby cards.
Minor GC with dirty cards as roots
- Stop the world and mark from the stack roots.
- Walk the card table. Skip clean cards. For each dirty card, scan the objects on it; every reference into the young generation is a root.
- Copy the live young objects to the empty survivor space and fix the references to them. Eden is then empty; whatever was not reached is gone.
- Clean the scanned cards, except those whose objects still point into the young generation (now the survivor space): they stay dirty for the next minor GC.
A dirty card does not mean an old→young reference exists, only that something on it was written. Setting the field to null leaves a dirty card that the GC scans for nothing, then cleans, and the young object it used to reference dies (Demo 2: field set to null, young object dies). When eden is full, new Object() fails and the same GC runs first (Demo 4: eden full, allocation failure GC).
What goes wrong without the barrier
If a store skipped the barrier, its card would stay clean. The minor GC would not scan it, would not see that C points to Y3, and would free Y3 with the rest of eden. C.f would then hold the address of free memory, which the next allocation reuses: a dangling pointer and silent heap corruption (Demo 5: missing barrier frees a live object). That is why every collector that collects part of the heap needs a barrier, and why sun.misc.Unsafe stores go through it too.
G1: remembered sets built from cards
G1 splits the heap into regions (1–32 MB) and still uses 512-byte cards, but adds a remembered set per region: the set of cards in other regions that may point into it. Its post-write barrier filters more: a store within the same region, a null store, or a store into a young region does nothing. Otherwise the card is dirtied and queued; concurrent refinement threads scan queued cards and add them to the remembered sets of the regions they point into. A young GC then scans only the remembered sets of the young regions (and the cards still queued), and a mixed GC uses the remembered sets of the old regions it evacuates. The barrier is more expensive, but GC pauses no longer scale with the size of the old generation. See Java Garbage Collectors: Parallel GC, G1, ZGC for regions and mixed GCs.
| Serial / Parallel | G1 | |
|---|---|---|
| barrier | always dirty the card | filter, dirty, enqueue |
| where old→young refs are kept | one global card table | a remembered set per region |
| young GC reads | the whole card table + dirty cards | young regions' remembered sets |
Go uses a different kind of write barrier for a different job: keeping concurrent marking correct (Go's Garbage Collector). How objects get to the heap in the first place is on Stack vs Heap in Java.
What the page leaves out
Tenuring by age (MaxTenuringThreshold; here an object is promoted only when the survivor space is full); objects larger than a card and object arrays, which are scanned in card-sized chunks; the precise object-start lookup (block offset table) needed to find the first object on a card; young-to-young and stack-to-young being found by copying (Cheney scan); parallel card scanning by GC worker threads; G1's pre-write (SATB) barrier for concurrent marking; and ZGC and Shenandoah, whose generational modes use their own barriers and remembered-set bitmaps.
See where this page fits on the Java & the JVM mind map.