Java Internals & Concurrency/ZGC và Shenandoah — read barrier và concurrent compaction
67/75
Bài 67 / 75~14 phútJVM InternalsMiễn phí lượt xem

ZGC và Shenandoah — read barrier và concurrent compaction

Vì sao G1 phải STW khi compact còn ZGC/Shenandoah thì không? Read vs write barrier, colored pointer, load-reference barrier, generational ZGC qua các JDK.

TL;DR: Khác biệt cốt lõi giữa G1 và ZGC/Shenandoah là loại barrier. G1 dùng write barrier (tốn khi ghi, free khi đọc) — đủ cho SATB/RSet nhưng không compact concurrent được: app đọc reference cũ sau khi GC dời object thì thấy stale data, nên G1 phải STW lúc compact. ZGC/Shenandoah dùng read barrier (tốn khi đọc, ~10-15% throughput): mọi load đi qua barrier, redirect pointer tới object đã dời, nhờ đó compaction concurrent với app — pause dưới 10ms bất kể cỡ heap. ZGC hiện thực bằng colored pointer + load barrier; Shenandoah bằng load-reference barrier (từng dùng Brooks pointer, bỏ từ JDK 13). Generational ZGC: opt-in JDK 21 (JEP 439), default JDK 23 (JEP 474), non-gen gỡ ở JDK 24 (JEP 490).

Bài 12 kết ở Full GC 847ms của G1 — pause STW dài vì G1 phải dừng app khi compact toàn heap. Nếu SLA đòi p99 dưới 10ms, không flag G1 nào cứu được: bản chất G1 compact trong STW. Câu hỏi: collector nào compact mà không cần dừng app, và nhờ cơ chế gì?

Câu trả lời nằm ở một lựa chọn thiết kế: read barrier thay vì write barrier. Bài này mổ chính trục đó — vì sao read barrier (ZGC/Shenandoah) cho phép concurrent compaction mà write barrier (G1) không — rồi soi cách ZGC và Shenandoah hiện thực read barrier theo hai lối khác nhau (colored pointer vs load-reference barrier).

1. Write barrier vs read barrier — trục quyết định

Một hình dung trước khi vào cơ chế: coi GC như bưu điện xử lý khi object "chuyển nhà" (bị dời sang địa chỉ mới). Write barrier giống dán nhãn địa chỉ mới ngay lúc gửi — nhận thư (đọc) thì nhanh, nhưng ai cầm phong bì ghi địa chỉ cũ thì thất lạc. Read barrier giống tra sổ chuyển tiếp ngay lúc giao — mỗi lần giao (đọc) tốn công tra sổ, nhưng thư luôn tới đúng nhà mới dù địa chỉ trên phong bì đã cũ.

Bưu điện khi object "chuyển nhà"Barrier trong GC
Dán nhãn lúc GỬI, rồi giao thẳngWrite barrier: chặn lúc ghi, đọc free
Tra sổ chuyển tiếp lúc GIAORead barrier: chặn lúc đọc, redirect địa chỉ mới
Cầm phong bì địa chỉ cũ → thất lạcĐọc pointer cũ sau khi GC dời → stale (G1 phải STW)
Sổ chuyển tiếp luôn trả nhà mớiForwarding table → luôn tới object đã dời (concurrent OK)

Bài 12 đã gặp write barrier của G1: mỗi lần app ghi reference field, barrier chạy (đẩy SATB queue, đánh dirty card). Đọc reference thì free. Đây là lựa chọn hợp lý cho G1 vì mục tiêu của nó là track mutation.

Nhưng write barrier có một giới hạn cứng khi muốn compact concurrent:

💡 Đoán trước khi đọc

GC muốn dời object X sang địa chỉ mới trong khi app vẫn chạy. Với write barrier (chỉ chặn lúc ghi), điều gì xảy ra khi app đọc một reference cũ trỏ tới X ngay sau khi GC vừa dời? Vì sao đó là lý do G1 phải STW khi compact? Viết ra suy luận rồi đọc tiếp.

Vấn đề: nếu GC đã copy X sang địa chỉ mới nhưng app đọc reference cũ (không qua barrier vì write barrier chỉ chặn lúc ghi), app sẽ thấy địa chỉ cũ — stale data. Không có điểm chặn nào ở lúc đọc để redirect. Vì thế G1 buộc phải stop-the-world trong lúc compact: dừng mọi app thread, dời object, sửa hết pointer, rồi cho chạy lại.

Read barrier đảo ngược: tốn khi đọc reference, ít tốn khi ghi. Mỗi lần app load một reference từ heap, barrier chạy: kiểm tra reference có trỏ tới object đã dời không; nếu có → tra bảng forwarding, trả địa chỉ mới, app tiếp tục với pointer đúng mà không biết fixup vừa xảy ra. Nhờ đó GC dời object concurrent với app — không cần STW để compact.

Write barrier (G1)Read barrier (ZGC, Shenandoah)
Tốn chi phí khiGhi reference (đọc free)Đọc reference (redirect nếu cần)
Concurrent compactionKhông (phải STW)
Overhead throughput~5% (ghi ít hơn đọc)~10-15% (đọc nhiều hơn ghi trong code thực)

2. ZGC — colored pointer và load barrier

ZGC (production Java 15, JEP 377) hiện thực read barrier bằng colored pointer: nhúng metadata vào chính bit của pointer 64-bit.

Trong scheme non-generational cổ điển (JDK 11-20): pointer dùng 42 bit thấp làm địa chỉ (4TB không gian đánh địa chỉ), 4 bit metadatafinalizable, remapped, marked1, marked0 — và phần bit cao bỏ trống. ZGC multi-map cùng một trang vật lý vào nhiều địa chỉ ảo ứng với các màu (nên phải dự trữ dải địa chỉ lớn; trần heap 4TB của scheme 42-bit này nâng lên 16TB từ JDK 13 khi trường offset mở rộng).

Colored pointer ZGC non-generational (Linux x86-64):
[ bit cao: bo trong ][ 4 bit metadata: F R M1 M0 ][ 42 bit dia chi ]

Bốn bit trên mã hoá trạng thái GC của object (đã mark, đã relocate + remap, finalizable). Ở mỗi phase, một tổ hợp bit là "màu tốt" (good color); load barrier chỉ cần kiểm pointer có màu tốt không, nếu không thì rẽ vào slow path fixup.

📚 Lưu ý version

Generational ZGC (JDK 21+) thiết kế lại colored-pointer, bỏ multi-mapping. Ý tưởng không đổi (metadata trong pointer + load barrier check khi đọc), nhưng bố cục bit cụ thể khác scheme cổ điển ở trên.

; x86 pseudo-assembly - ZGC load barrier
mov rax, [rbx + offset]     ; load reference
test rax, [bad_mask]        ; pointer co "mau xau" (can fixup)?
jnz slow_path               ; co -> tra forwarding table, cap nhat
; fast path: mau tot, dung tiep

Phase của ZGC — gần như concurrent hoàn toàn

flowchart TD
    A["Pause: Mark Start (~1ms): scan GC root"] --> B["Concurrent Mark: traverse heap, mark live"]
    B --> C["Pause: Mark End (~1ms): finalize mark"]
    C --> D["Pause: Relocate Start (~1ms): chot relocation set"]
    D --> E["Concurrent Relocate: copy object, ghi forwarding table"]
    E --> F["Ket thuc cycle (remap phan con lai gop vao Mark cycle sau)"]
    style A fill:#FCD34D
    style C fill:#FCD34D
    style D fill:#FCD34D
    style B fill:#6EE7B7
    style E fill:#6EE7B7

Ba pause ngắn (Mark Start, Mark End, Relocate Start — mỗi cái ~1ms, chỉ set cờ và chốt tập việc); mọi việc nặng (traverse heap, copy object, sửa pointer) đều concurrent. Pointer cũ được load barrier sửa lazy khi app đọc; phần còn sót được remap gộp vào pha Mark của chu kỳ kế tiếp, chứ không phải một phase quét riêng cuối cycle này.

3. Shenandoah — từ Brooks pointer tới load-reference barrier

Shenandoah (Red Hat, JEP 189, OpenJDK Java 12; production JEP 379 Java 15) cũng nhắm pause dưới 10ms bằng read barrier, nhưng cách hiện thực khác ZGC.

Brooks pointer (Shenandoah 1.0 — nay là lịch sử): phiên bản đầu thêm một forwarding word 8 byte vào mỗi object, bình thường trỏ về chính object; khi GC relocate, forwarding word của bản cũ trỏ tới địa chỉ mới. Mọi lần đọc đi qua forwarding word nên luôn nhận data đúng. Giá phải trả: một dereference thừa mỗi lần đọc 8 byte/object (heap overhead ~5-15%).

Shenandoah 2.0 (JDK 13+): bỏ Brooks pointer, chuyển sang load-reference barrier (LRB) — barrier chỉ chèn khi load reference từ heap (chủ yếu hoạt động lúc evacuation, tức lúc GC copy object sống ra region mới), và forwarding ghi vào mark word (word tiêu đề sẵn có trong header mỗi object — xem bài 09) của bản cũ thay vì word riêng (không tốn 8 byte/object nữa). Kết quả: heap overhead giảm mạnh, throughput cải thiện rõ.

Ưu điểm Shenandoah còn giữ so với ZGC: không phụ thuộc bit của pointer, nên chạy được cả khi kiến trúc/OS chiếm hết dải địa chỉ pointer.

4. Câu chuyện generational — ZGC và Shenandoah qua các JDK

ZGC và Shenandoah đời đầu không generational: mọi object cùng một space, không tận dụng generational hypothesis (bài 06) nên quét toàn bộ mỗi cycle và throughput thấp hơn G1 đáng kể. Generational ZGC thêm young/old generation, thu hẹp gap throughput mà vẫn giữ pause dưới 10ms. Dòng thời gian:

  • JDK 21 (JEP 439) — generational opt-in: phải bật -XX:+UseZGC -XX:+ZGenerational; non-generational vẫn mặc định.
  • JDK 23 (JEP 474) — generational thành mặc định; non-generational bị deprecate.
  • JDK 24 (JEP 490)gỡ hẳn non-generational: obsolete cờ ZGenerational.

Generational Shenandoah đi sau: JEP 404 từng bị rút khỏi JDK 21, ship lại experimental ở JDK 24 (-XX:ShenandoahGCMode=generational); JEP 521 (JDK 25) bỏ nhãn experimental nhưng chưa làm nó mặc định.

5. Khi nào chọn ZGC, khi nào Shenandoah?

💡 Tự chọn trước

Bạn cần pause dưới 10ms, nhưng chạy trên một nền tảng mà JVM không dùng được colored pointer (kiến trúc/OS chiếm hết dải địa chỉ pointer). Trước khi đọc bảng dưới, tự chọn: ZGC hay Shenandoah, và vì sao lựa chọn kia bị loại?

Tiêu chíG1ZGCShenandoah
BarrierWriteRead (colored pointer + load barrier)Read (load-reference barrier)
Concurrent compactKhông (STW)
Pause thực tế50-200msDưới ~2msDưới ~10ms
Throughput overhead~5%~10-15%~10-15%
Phụ thuộc bit pointerKhôngCó (colored pointer)Không
GenerationalJDK 21 opt-in, 23 defaultExperimental JDK 24

Đáp án: Shenandoah — nó dùng forwarding trong mark word (không đụng bit pointer) nên độc lập với dải địa chỉ; ZGC phụ thuộc bit trống trong pointer nên nền tảng này loại nó. Còn lại, trên OpenJDK upstream với heap lớn, ZGC (generational từ JDK 21) thường là mặc định hợp lý.

6. Pitfall tổng hợp

Nhầm 1: Switch sang ZGC mà không bù throughput.

# G1, 10 server: 100k RPS -> ZGC cung 10 server: ~85k RPS (giam ~15%, thieu ~2 server)
java -XX:+UseZGC -Xmx24g MyApp

✅ Đo throughput trước/sau; cấp thêm ~15% CPU/instance để bù read-barrier overhead.

Nhầm 2: Dùng ZGC cho heap nhỏ "cho pause thấp". ✅ Overhead ~10-15% không bù được với heap dưới 2GB — dùng G1 hoặc Serial (xem bài 06b).

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: Đọc "Motivation" của JEP 439 để hiểu vì sao generational quan trọng với ZGC. Bố cục colored-pointer đổi giữa non-gen và generational — nắm ý tưởng (metadata trong pointer + load barrier), đừng nhớ vị trí bit như hằng số bất biến.

8. Liên hệ các bài khác

9. Tóm tắt

  • Khác biệt cốt lõi G1 vs ZGC/Shenandoah là loại barrier. Write barrier (G1) tốn khi ghi, free khi đọc — không chặn được app đọc pointer cũ khi GC dời object → phải STW compact. Read barrier (ZGC/Shenandoah) tốn khi đọc (~10-15% throughput), redirect pointer tới object đã dời → compaction concurrent, pause dưới 10ms mọi cỡ heap.
  • ZGC: colored pointer (metadata trong bit pointer; scheme non-gen cổ điển 42 bit địa chỉ + 4 bit metadata) + load barrier. Generational ZGC (JDK 21+) thiết kế lại pointer.
  • Shenandoah: load-reference barrier; Brooks pointer (8 byte/object) là đời đầu, bỏ từ JDK 13 (forwarding vào mark word). Không phụ thuộc bit pointer → chạy được nơi ZGC không.
  • Generational ZGC: opt-in JDK 21 (JEP 439), default JDK 23 (JEP 474), non-gen gỡ JDK 24 (JEP 490). Generational Shenandoah: experimental JDK 24 (JEP 404), non-experimental JDK 25 (JEP 521), chưa default.
  • ZGC/Shenandoah không miễn phí: đổi throughput lấy latency — chỉ chọn khi latency là ràng buộc thật, và cấp thêm máy để bù.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao write barrier (G1) không cho phép concurrent compaction còn read barrier (ZGC) thì có?

Write barrier chỉ chạy khi app ghi reference; đọc thì free. Khi GC dời object X trong lúc app chạy, app có thể đọc reference cũ trỏ tới X — không có điểm chặn để redirect, nên app thấy địa chỉ cũ (stale). Vì thế G1 phải STW lúc compact.

Read barrier chạy trên mỗi lần đọc. Khi GC dời X và ghi forwarding entry, lần đọc kế tiếp đi qua barrier, phát hiện pointer cũ, tra forwarding table và trả địa chỉ mới — app luôn thấy data đúng dù GC dời song song → compaction concurrent, không STW. Cái giá là barrier trên mọi read (~10-15% throughput).

Q2
Vì sao ZGC nhúng metadata GC vào chính bit của pointer, thay vì tra một bảng trạng thái bên ngoài? Cơ chế đó giúp fast-path của load barrier rẻ thế nào?

Nếu trạng thái GC của mỗi object (đã mark chưa, đã relocate chưa) nằm ở một bảng phụ, thì mọi lần đọc reference phải tra bảng đó — một lần truy cập bộ nhớ rời, dễ miss cache. ZGC thay vào đó nhúng vài bit metadata vào chính pointer 64-bit (scheme non-gen cổ điển: bit thấp là địa chỉ, vài bit mã hoá trạng thái như marked/remapped), nên trạng thái đi kèm pointer, sẵn trong thanh ghi ngay khi load.

Nhờ đó fast-path của load barrier chỉ là một phép test bit "màu tốt" ngay trên pointer vừa load — không chạm bộ nhớ phụ; chỉ khi "màu xấu" (object đã relocate, pointer chưa cập nhật) mới rẽ vào slow path tra forwarding table. Đó là lý do read barrier ZGC đủ rẻ để bật trên mọi lần đọc. Bố cục bit cụ thể đổi giữa non-gen và generational — nhớ ý tưởng, đừng nhớ vị trí bit.

Q3
Brooks pointer là gì, và vì sao Shenandoah bỏ nó từ JDK 13?

Brooks pointer (Shenandoah 1.0) là forwarding word 8 byte thêm vào mỗi object: bình thường trỏ về chính object, khi relocate thì bản cũ trỏ tới địa chỉ mới. Mọi lần đọc đi qua nó nên luôn nhận data đúng — nhưng tốn một dereference thừa mỗi lần đọc và 8 byte/object (heap overhead ~5-15%).

Từ JDK 13, Shenandoah 2.0 dùng load-reference barrier: bỏ forwarding word, ghi địa chỉ mới vào mark word bản cũ khi evacuate (hết 8 byte/object), barrier chỉ chèn lúc load từ heap. Kết quả: heap overhead giảm mạnh, throughput cải thiện.

Q4
Vì sao generational ZGC thu hẹp được throughput gap với G1 mà vẫn giữ pause dưới 10ms?

ZGC non-generational quét toàn bộ heap mỗi cycle và copy cả object sắp chết nên throughput thấp hơn G1. Generational khai thác generational hypothesis (đa số object chết trẻ): thu gom young thường xuyên mà rẻ (vùng nhỏ, phần lớn đã chết), chỉ chạm old khi cần → ít việc hơn hẳn mỗi cycle.

Pause vẫn dưới 10ms vì young lẫn old đều concurrent qua load barrier — không thêm STW work đáng kể, vẫn ba pause ngắn. (Rollout: opt-in -XX:+ZGenerational JDK 21, mặc định JDK 23.)

Q5
Vì sao read barrier có overhead throughput cao hơn write barrier?

Không phải vì mỗi barrier đắt hơn, mà vì tần suất: trong code thực, đọc reference nhiều hơn ghi rất nhiều (traversal, lookup, mọi field access là đọc; ghi reference thưa hơn).

Write barrier (G1) chỉ đóng thuế trên lần ghi (thưa) → ~5%. Read barrier (ZGC/Shenandoah) đóng thuế trên mọi lần đọc (dày, kể cả hot loop) → ~10-15%. Đánh đổi có chủ đích: throughput thấp hơn để lấy compact concurrent và pause dưới 10ms.

Q6
Khi nào chọn Shenandoah thay vì ZGC, dù cả hai đều đạt pause dưới 10ms?

ZGC nhúng metadata vào bit của pointer nên phụ thuộc còn bit trống trong địa chỉ; trên kiến trúc/OS chiếm hết dải địa chỉ, ZGC không chạy được, còn Shenandoah dùng forwarding trong mark word nên độc lập với bit pointer → vẫn chạy. Còn lại, trên OpenJDK upstream với heap lớn, ZGC (generational từ JDK 21) thường là mặc định hợp lý vì heuristic tự thích nghi, ít phải tune.

Bài tiếp theo: Mini-challenge: Debug memory leak với heap dump và JFR

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Mini-challenge: Debug memory leak với heap dump và JFR