How marking actually runs#
TL;DRthe 30-second version
- Marking keeps a to-do list. Objects are white (not reached), grey (on the list) or black (done, never looked at again).
- If the program hides a white object inside a black one and cuts every other path to it, concurrent marking frees a live object.
- G1 prevents that with a snapshot-at-the-beginning (SATB) write barrier: every overwritten reference is recorded and marked.
- The price is floating garbage and, in G1, two usually short pauses, at the start and end of marking.
The GC keeps a to-do list of objects it has found but not yet looked inside:
put every root's target on the to-do list, and mark it
while the to-do list is not empty:
take object X off the list
for each reference field in X:
if the target is not marked:
mark it
put it on the to-do listAt any moment during that walk, every object is in one of three states. GC literature names them with colours:
| Colour | Meaning |
|---|---|
| White | Not marked. The GC hasn't reached it yet. Still white when marking ends means it gets freed. |
| Grey | Marked and on the to-do list. Its fields haven't been read yet. |
| Black | Marked, and its fields have already been read. In the basic walk, the GC never looks at it again. |
Everything hangs on that last row. When the program is stopped, nothing can change an object after the GC reads it, so the rule is safe. But when the program keeps running, it can change an object right after the GC has finished with it, and the GC, following its rule, never notices.
The failure, step by step#
Start with A already black, B grey, and C white. B points to C.
| Step | Who acts | Action | What happens |
|---|---|---|---|
| 1 | — | — | A (black) · B (grey) → C (white) |
| 2 | Program | A.f = C; | A → C. A is black, so the GC won't re-read A and won't see this. |
| 3 | Program | B.g = null; | B no longer points to C. |
| 4 | GC | Takes B off the to-do list and reads its fields | Finds nothing. C stays white. |
| 5 | GC | The to-do list is empty, so marking ends | C is white, so C is freed. |
| 6 | Program | Uses A.f | Points to freed memory, which may already hold another object. |
That's the exact bug garbage collection exists to prevent, and the collector caused it. But which of the program's two moves is to blame? Replay with one at a time:
- Only step 2 (A.f = C): B still points to C, so when the GC reads B it finds C and marks it. No bug.
- Only step 3 (B.g = null): nothing points to C any more, so C really is garbage, and freeing it is correct. No bug.
Neither move alone does harm; the bug needs both. A reference to a white object is hidden inside an object the GC has finished with, and every path the GC could still use to reach it is cut. Prevent either condition, and the bug can't happen.
The fix: record every reference that gets overwritten#
G1, the default collector on most machines, prevents the second condition. It uses another write barrier, like the card-marking one from Part 3, except this one runs before the store, and only while marking is in progress:
// you wrote: B.g = null;
// the JIT emits:
if (marking_in_progress) {
old = B.g // the reference about to be lost: C
if (old != null)
add old to this thread's queue
}
B.g = nullThe GC keeps emptying those queues and marks every object in them grey. Replay the failing timeline: at step 3, the barrier catches C and queues it, the GC marks it, and when marking ends C is black, so it survives. Step 6 finds C exactly where it should be.
Why is that enough? The only way to hide an object from the GC is to cut a path to it, and the only way to cut a path is to overwrite a reference. Every overwrite is now caught, so every object that was reachable when marking started gets marked, however the program rearranges things afterwards. That's why the technique is called snapshot-at-the-beginning, or SATB.
PredictOnly step 3 happens: B.g = null, and nothing else ever pointed to C. Is C freed in this marking cycle?
No. The SATB barrier still catches C when B.g is overwritten, so C is marked and survives this cycle, even though it's real garbage. It's freed in the next cycle. That's floating garbage.
That's the price. SATB keeps everything that was reachable when marking began, plus everything allocated during marking, so garbage that dies during marking survives one extra cycle. Being too careful wastes a little memory for a while; being too eager corrupts data.
There's a second approach, which prevents the first condition instead: record where every reference store happens, and have the GC rescan those places before marking ends. That's called incremental update; the now-removed CMS (Concurrent Mark Sweep) collector worked that way, by dirtying cards.
And objects created during marking? The GC never traced them, but they're obviously in use. G1 remembers, for each part of the heap, where the allocated space ended when marking began, and anything allocated beyond that point is treated as live for this cycle. That boundary is called top-at-mark-start, or TAMS.
Why short pauses remain#
- At the start, the GC must read the roots, and they include local variables on every thread's stack. Locals change constantly and have no barrier, because a barrier on every local-variable write would be far too expensive. So the GC stops the threads briefly to read the roots, then lets them continue.
- At the end, there's another pause, called remark, to process the last entries still sitting in each thread's queue.
In G1, both are usually short, because neither walks the whole heap. The long part, the walk itself, now runs alongside your program. (Part 6 shows a collector that scans thread stacks concurrently too.)
But marking only tells the GC what's live. It frees nothing. Getting most of the space back still means moving objects, and doing that while the program runs is the next problem.
What concurrent marking costs#
| You get | You pay |
|---|---|
| No long marking pause, even with gigabytes of live data | A check on every reference store, which only does work while marking runs |
| Correctness while the program rearranges references | Floating garbage: some dead objects survive one extra cycle |
| Marking that overlaps your program's work | GC threads use CPU alongside your threads, and marking must finish before the heap fills |
If this comes up in an interview#
What is tri-colour marking?
Marking with three states: white (not reached), grey (found, fields not yet read) and black (fully scanned, never revisited).
What is SATB?
Snapshot-at-the-beginning: a pre-write barrier that records the old value of every overwritten reference during marking, so everything reachable when marking began gets marked.
What is floating garbage?
Objects that die during a marking cycle but are still kept, because SATB keeps everything reachable when marking began and everything allocated during marking. They're freed in the next cycle.
Gotchas
- Concurrent doesn't mean free. Marking threads take CPU from your program while they run.
- If the program allocates faster than marking can finish, the heap fills before the cycle ends, and the collector falls back to a long stop-the-world collection (Part 5).
- Floating garbage means a little extra headroom is needed: a heap sized exactly to live data has no room for it.
Under the hood
- In G1, the start-of-marking pause piggybacks on a young-generation pause. Remark also processes weak and soft references and can unload classes, which is why it is occasionally the longer one; a short cleanup step follows it.
- The SATB queues are per-thread buffers; a full buffer is handed to the GC and a fresh one is taken, so the barrier rarely does more than a few instructions.
- The marker that separates old from new allocations is called TAMS, top-at-mark-start.
References & further reading
- Jones, Hosking, Moss — The Garbage Collection Handbook (2nd ed.) — tri-colour invariants, SATB, incremental update
- Oracle — Garbage-First (G1) Garbage Collector (JDK 21 tuning guide) — concurrent marking, remark
- Dijkstra et al. — On-the-Fly Garbage Collection: An Exercise in Cooperation (1978) — where the three colours come from