ZGC Internals: Colored Pointers, Generational Collection in Java 21
Digging into colored pointers, load barriers, forwarding tables and how they work together in ZGC
ZGC Internals: Colored Pointers, Generational Collection in Java 21
Digging into colored pointers, load barriers, forwarding tables and how they work together in ZGC
The Z Garbage Collector (ZGC) is a scalable, ultra-low-latency collector designed to keep pause times under 1ms, regardless of heap size. With the release of Java 21, Generational ZGC has transitioned from experimental to a production-ready feature, significantly improving throughput while maintaining its industry-leading latency benchmarks.
To understand why ZGC is a “game-changer,” we must look under the hood at its two core pillars: Colored Pointers and Load Barriers.
1. The Magic of Colored Pointers
In traditional collectors, metadata about an object (e.g., whether it has been marked or moved) is stored in the object header. ZGC takes a radical approach: it stores this metadata directly within the 64-bit object pointer.
On 64-bit platforms, modern CPUs typically use only 48 bits for virtual memory addresses. JVMs can use either 42 bits for 4TB heap or 44 bits for 16TB heap. ZGC utilizes the remaining unused bits to “color” the pointer with metadata:
- Bits 0–41: Object Address (supporting up to 4TB).
- Bits 42–45: Metadata Bits (the “Colors”).
- Bits 46–63: Unused/Zero.
Bits 42–45 are mapped as below
64-bit colored pointer layout:
63 48 47 46 45 44 43 42 41 0
┌──────────┬──────────┬────┬────┬────┬─────────────────────────┐
│ zeros │ unused │ M1 │ M0 │Rem │Fin │ heap address │
└──────────┴──────────┴────┴────┴────┴────┴────────────────────┘
canonical color bits (42 bits = 4 TB)
zeros
Bit 45 = M1 (Marked1)
Bit 44 = M0 (Marked0)
Bit 43 = Remapped (Rem)
Bit 42 = Finalizable (Fin)
Note on exact bit positions: The positions above reflect the ZGC design targeting 4 TB heaps. The exact offsets depend on the configured max heap size and are defined in
src/hotspot/share/gc/z/zAddress.hppin the OpenJDK source. The conceptual layout — four color bits above the address, below the canonical-zero region — is consistent across versions.
- Marked0 / Marked1: Used to distinguish live objects in alternating GC cycles.
- Remapped: Indicates if the pointer is up-to-date or if the object has moved.
- Finalizable: Indicates the object is reachable only via a finalizer.
Multi-Mapping and the Forwarding Table
ZGC uses two complementary mechanisms to make colored pointers work without software address translation on every memory access.
Multi-mapping
A colored pointer such as 0x0000_0400_0002_0000 (M0 bit set, object at 0x2000) is not a raw machine address. If handed directly to the CPU, it would dereference the wrong location. ZGC solves this by mapping the same physical memory page to multiple virtual addresses simultaneously — one per color view:
//Physical page containing the Customer object: pseudo example
void* physical_memory = allocate_physical(size);
map_virtual(0x0001000000000000, physical_memory); // Marked0 Range
map_virtual(0x0002000000000000, physical_memory); // Marked1 Range
map_virtual(0x0004000000000000, physical_memory); // Remapped Range
mapped to 0x0000_0000_0002_0000 (no color — raw address)
mapped to 0x0000_0400_0002_0000 (M0 bit set at bit 44)
mapped to 0x0000_0800_0002_0000 (M1 bit set at bit 45)
mapped to 0x0000_1000_0002_0000 (Remapped bit set at bit 43)
These mappings are established at JVM startup via mmap calls. Because all four virtual addresses map to the same physical page, a CPU load from any of them reads the same bytes. The colored pointer is a valid virtual address; it routes through a different page table entry to the same physical destination.
The forwarding table
When ZGC actually relocates an object — moves it to a new heap address — something must track the old-to-new address mapping for the duration of concurrent relocation. This is the forwarding table: a compact hash map, one per region in the relocation set, mapping old object offsets to new heap addresses.
The Load Barrier: The “Heartbeat” of ZGC
If ZGC moves an object concurrently while the application is running, how does the application avoid using a stale reference? The answer is the Load Barrier.
A load barrier is a small piece of code injected by the JIT compiler into the application whenever a reference is loaded from the heap.
; Pseudo-assembly for a ZGC Load Barrier
mov rax, [r10] ; Load the reference from memory
test rax, [r15] ; Compare against Global "Good Mask" (stored in r15)
jnz slow_path ; If bits don't match the current GC phase, fix it
- Check: When a thread loads a pointer, the barrier checks its “color.”
- Fix: If the color indicates the object has been relocated (the Remapped bit is missing), the barrier intervenes.
- Heal: It looks up the new address in a forwarding table, updates the pointer in memory, and returns the correct reference.
The “Self-Healing” Mechanism
Above “self-healing” mechanism ensures that only the first thread to access a stale pointer pays the small penalty of fixing it. Subsequent accesses use the updated, “healed” pointer at full speed.
If the pointer’s color doesn’t match the current “Good Mask” (the global JVM state), the slow_path is triggered:
// Conceptual Slow Path Logic
Object* zgc_load_barrier_slow_path(Object** location) {
Object* old_ref = *location;
// 1. Check Forwarding Table: Has the object moved?
Object* new_ref = forwarding_table.lookup(old_ref);
// 2. Mark if necessary: If we are in the Marking Phase
if (ZGlobalPhase == MARKING) {
zgc_mark_object(new_ref);
}
// 3. Heal: Update the pointer in the heap with the 'Good' color
*location = make_colored(new_ref, GOOD_MASK);
return new_ref;
}
The Global JVM State Driving Staleness
ZGC maintains a JVM-wide variable — ZGlobalPhase — that holds the currently expected color. It takes one of three values: M0, M1, or Remapped.
In Generational ZGC there are in fact two phase variables — one for the Young generation and one for the Old generation — because minor and major collections run independently and can be at different phases simultaneously. For clarity we will treat them as one in the examples below and note where they diverge.
The load barrier performs one comparison on every reference read:
if (pointer.colorBits != ZGlobalPhase) {
slowPath(pointer);
}
If the color bits match the global, the pointer is current — fast path, use it directly. If not, slow path.
Following picture tries to illustrates how slow path is taken when stale color is detected on global state change

Color pointers and Global state signal
How millions of references become stale instantly
When ZGC advances from one phase to the next — say from mark (M0) to relocation (Remapped) — it writes one new value to ZGlobalPhase. It does not touch any reference on the heap. Every M0-colored reference is now stale, not because anything changed in the reference, but because the global has moved on. The work of fixing stale references is paid lazily — each reference is healed exactly once, the first time it is read after the phase flip.
Why two mark bits are necessary
If ZGC used only one mark bit it could not distinguish a reference colored M (live, this cycle) from a reference left over from the previous cycle. Both would pass the load barrier — the leftover could point at an object that moved in a prior cycle and was never updated. This will be clear when we run through code example at the end.
The GC Phases
Generational ZGC runs two kinds of collection cycles, independently:
- Minor collection — collects the Young generation only. Runs frequently.
- Major collection — collects the entire heap (Young + Old). Runs less frequently.
Both share the same phase structure internally. The phases for a single collection cycle are:
- Pause Mark Start — STW
- Concurrent Mark — concurrent
- Pause Mark End — STW
- Concurrent Mark Free — concurrent
- Concurrent Prepare for Relocation — concurrent
- Pause Relocate Start — STW
- Concurrent Relocate — concurrent
- Concurrent Remap — concurrent (overlaps with next cycle’s mark phase)
In a minor collection, phases 2–8 operate only on Young generation regions. In a major collection, they operate on the full heap. The STW pauses have the same character in both cases: they process only GC roots, not the full object graph, and remain sub-millisecond regardless of heap size or collection type.
5. Which Phases Cause Stop-the-World Pauses
ZGC has exactly three STW pauses per collection cycle — minor or major. Each is bounded to sub-millisecond duration because each processes only GC roots, not the object graph.
Pause Mark Start
All application threads are briefly stopped to scan GC roots: thread stacks, static fields, and JNI references. In a minor collection, only roots that point into the Young generation are relevant. In a major collection, all roots are scanned. The global phase variable is flipped to the current mark color (M0 or M1) at this point.
Pause Mark End
After concurrent marking completes, a brief synchronization resolves references modified by application threads during concurrent marking. The vast majority of the object graph has already been processed; very little remains. In practice this pause is often shorter than Pause Mark Start.
Pause Relocate Start
Before concurrent relocation begins, GC roots are rescanned to find which roots point into the selected relocation set. The global phase variable is flipped to Remapped at this point. Same scope as Pause Mark Start; similarly short.
The three pauses are short for the same reason: they only process roots. All graph traversal, object movement, and reference updating happens concurrently.
Walking the Example: Five Steps Across a Minor GC Cycle
We will use following code sample to explain the various states of GC and color pointer staleness and bit updates
class Customer {
String name;
}
class BankAccount {
Customer customer; // ← this reference is our subject
double balance;
}
We trace the customer field in BankAccount across a complete minor GC cycle in Generational ZGC. The Customer object starts in the Young generation (Eden) at address 0x2000. In one cycle it is evacuated to Survivor space at 0x9000. We assume:
ZGlobalPhasefor the Young generation drives the load barrier for this example.- The BankAccount object is in the Old generation (already tenured).
- The
customerfield is therefore a cross-generational reference — Old pointing to Young — and is tracked in the remembered set.
Step 0 — Initial state (object just allocated)
BankAccount @ 0x1000 (Old generation)
customer field: 0x0000_0000_0002_0000
Pointer bits:
[63–48: zeros] [Fin=0] [Rem=0] [M1=0] [M0=0] [address=0x2000]ZGlobalPhase (Young): not yet set
The Customer object was just allocated in Eden. All four color bits are zero. The reference from BankAccount to Customer is an Old-to-Young cross-generational reference. The store barrier, when this field was written, added bankAccount.customer to the remembered set so the minor GC can find it without scanning the entire Old generation.
Step 1 — Minor GC begins: Pause Mark Start
ZGlobalPhase (Young) ← M0
The JVM briefly stops all threads. GC roots pointing into the Young generation are scanned — including entries from the remembered set, which surfaces the bankAccount.customer reference as a root for marking. The global (Young) phase flips to M0. Threads resume.
The customer field still holds color 0000. It is now stale — 0000 ≠ M0 — but has not yet been discovered because no thread has read it since the pause.
Step 2 — Concurrent Mark: application thread reads bankAccount.customer
A thread executes bankAccount.customer.name. The load barrier fires:
- Read pointer:
0x0000_0000_0002_0000. Color = 0000. - Compare to Young
ZGlobalPhase= M0. Mismatch → slow path. - Is
0x2000in the relocation set? No — we are in the mark phase. - Barrier sets M0 = 1 in the pointer. Writes
0x0000_0400_0002_0000back intobankAccount.customer. Records Customer as live in the GC's mark data. - Returns
0x2000. Thread readsname→ "Alice".
Pointer after step 2:
[63–48: zeros] [Fin=0] [Rem=0] [M1=0] [M0=1] [address=0x2000]
Subsequent reads during this mark phase hit the fast path: color = M0 = global.
Step 3 — Pause Relocate Start, then Concurrent Relocate: Customer NOT moved
Suppose the region containing Customer was not selected for evacuation this minor cycle.
ZGlobalPhase (Young) ← Remapped
The global flips to Remapped. Our M0-colored pointer is stale again. When the thread next reads bankAccount.customer:
- Color = M0, global = Remapped. Mismatch → slow path.
- Is
0x2000in the relocation set? No. - Object did not move. Barrier clears M0, sets Rem = 1. Writes
0x0000_1000_0002_0000back.
Pointer after step 3:
[63–48: zeros] [Fin=0] [Rem=1] [M1=0] [M0=0] [address=0x2000]
Remapped = 1: address confirmed stable as of this relocation phase.
Step 4 — Concurrent Relocate: Customer IS evacuated to Survivor space
Now consider the scenario where Customer’s Eden region was selected for evacuation. A GC thread copies Customer from 0x2000 (Eden) to 0x9000 (Survivor 0) and records the mapping:
Forwarding table (Young, region containing 0x2000):
0x2000 → 0x9000
The object’s age field in the mark word is incremented from 0 to 1. When the thread reads bankAccount.customer:
- Color = M0, global = Remapped. Mismatch → slow path.
- Is
0x2000in the relocation set? Yes. - Forwarding table lookup → new address
0x9000. - Barrier writes
0x0000_1000_0009_0000(address0x9000, Rem = 1) back intobankAccount.customer. - The remembered set entry is updated to reflect the new address.
Pointer after step 4:
[63–48: zeros] [Fin=0] [Rem=1] [M1=0] [M0=0] [address=0x9000]
The reference is self-healed. Customer is now in Survivor space. The BankAccount-to-Customer entry in the remembered set continues to track this cross-generational reference — it is now 0x1000 → 0x9000.
Step 5 — Next minor GC cycle begins: Pause Mark Start
ZGlobalPhase (Young) ← M1
The global flips to M1 for the new cycle. Our Remapped pointer is stale. When read:
- Color = Remapped, global = M1. Mismatch → slow path.
- Is
0x9000in the relocation set? No — fresh mark phase. - Barrier sets M1 = 1, clears Rem. Writes back. Customer confirmed live for this cycle.
If Customer survives enough minor GC cycles to reach the tenuring threshold, the next evacuation will copy it into the Old generation instead of Survivor space. At that point, the remembered set entry 0x1000 → Customer is removed (both objects are now Old), and the Old-generation phase variable drives the color bits for that reference going forward.
Pointer after step 5:
[63–48: zeros] [Fin=0] [Rem=0] [M1=1] [M0=0] [address=0x9000]
10. Pointer color state table
The table below captures the full state of bankAccount.customer across the five steps, assuming the Customer is evacuated to Survivor space in step 4:
Press enter or click to view image in full size

color state table
The core design principle
ZGC moves GC metadata from the object — where G1 stores it in the mark word — into the reference, where ZGC stores it in the pointer’s color bits. This single shift enables concurrent relocation: the load barrier can fix up a stale reference on the fly, without requiring the application to stop keeping the sub-millisecond pause guarantee intact.
Configuring ZGC
ZGC is designed to be autonomous. The most critical configuration is simply the max heap size: java -XX:+UseZGC -XX:+ZGenerational -Xmx16G -Xms16G -Xlog:gc*
If you have come so far, thank you! It was a difficult one! Out of all the garbage collectors I have studied, this one I found hardest to grasp. If you find something off, please leave out a comment. It will help me fix my understanding. Thanks for reading!
This is part-3 of garbage collector series. Previous articles : G1 Deep Dive and Parallel Garbage Collector
Resources
메타데이터
- post_id
- 2bbf65726e99
- slug
- zgc-internals-colored-pointers-generational-collection-in-java-21-2bbf65726e99
- url
- https://medium.com/@rednirus/zgc-internals-colored-pointers-generational-collection-in-java-21-2bbf65726e99
- canonical_url
- https://medium.com/@rednirus/zgc-internals-colored-pointers-generational-collection-in-java-21-2bbf65726e99
- author_url
- https://medium.com/@rednirus
- status
- ok
- fetched_at
- 2026-06-14 11:28:49