← Back to list

ZGC Internals: Colored Pointers, Generational Collection in Java 21

Digging into colored pointers, load barriers, forwarding tables and how they work together in ZGC

Surinder · 2026-03-22 15:29 · 10 claps · 10.0 min read
#zgc #jvm #garbage-collection #java
Open on Medium ↗

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.hpp in the OpenJDK source. The conceptual layout — four color bits above the address, below the canonical-zero region — is consistent across versions.

  1. Marked0 / Marked1: Used to distinguish live objects in alternating GC cycles.
  2. Remapped: Indicates if the pointer is up-to-date or if the object has moved.
  3. 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
  1. Check: When a thread loads a pointer, the barrier checks its “color.”
  2. Fix: If the color indicates the object has been relocated (the Remapped bit is missing), the barrier intervenes.
  3. 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

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:

  1. Pause Mark Start — STW
  2. Concurrent Mark — concurrent
  3. Pause Mark End — STW
  4. Concurrent Mark Free — concurrent
  5. Concurrent Prepare for Relocation — concurrent
  6. Pause Relocate Start — STW
  7. Concurrent Relocate — concurrent
  8. 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:

  • ZGlobalPhase for the Young generation drives the load barrier for this example.
  • The BankAccount object is in the Old generation (already tenured).
  • The customer field 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:

  1. Read pointer: 0x0000_0000_0002_0000. Color = 0000.
  2. Compare to Young ZGlobalPhase = M0. Mismatch → slow path.
  3. Is 0x2000 in the relocation set? No — we are in the mark phase.
  4. Barrier sets M0 = 1 in the pointer. Writes 0x0000_0400_0002_0000 back into bankAccount.customer. Records Customer as live in the GC's mark data.
  5. Returns 0x2000. Thread reads name → "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:

  1. Color = M0, global = Remapped. Mismatch → slow path.
  2. Is 0x2000 in the relocation set? No.
  3. Object did not move. Barrier clears M0, sets Rem = 1. Writes 0x0000_1000_0002_0000 back.
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:

  1. Color = M0, global = Remapped. Mismatch → slow path.
  2. Is 0x2000 in the relocation set? Yes.
  3. Forwarding table lookup → new address 0x9000.
  4. Barrier writes 0x0000_1000_0009_0000 (address 0x9000, Rem = 1) back into bankAccount.customer.
  5. 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:

  1. Color = Remapped, global = M1. Mismatch → slow path.
  2. Is 0x9000 in the relocation set? No — fresh mark phase.
  3. 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

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