G1 sâu — region, SATB, remembered set và humongous object
Vì sao G1 fallback sang Full GC 847ms? Mổ cơ chế G1: heap chia region, SATB concurrent marking, remembered set cross-region, humongous object và Full GC.
TL;DR: G1 (Garbage-First, default Java 9+) chia heap thành ~2048 region đều nhau (1-32MB), vai trò Eden/Survivor/Old/Humongous đổi linh hoạt. Thay vì quét toàn old gen, G1 dọn các region nhiều rác nhất ("garbage first") để giữ pause trong soft target MaxGCPauseMillis=200. Marking chạy concurrent nhờ SATB: write barrier đẩy reference cũ vào queue để GC không bỏ sót object. Để collect một region không cần quét toàn heap, remembered set ghi reference cross-region trỏ vào (maintain bởi card table barrier). Khi allocation vượt tốc độ evacuation, G1 fallback sang Full GC (mark-compact toàn heap, STW dài — nguồn của pause 847ms). Object vượt 50% region là humongous, chiếm nguyên region và dễ gây Full GC sớm.
Service recommendation API, SLA 99.9% latency dưới 100ms, heap 24GB, dùng G1 default. Mỗi đêm khoảng 2h sáng, một dòng log xuất hiện:
[02:14:33][gc] GC(187) Pause Full (Ergonomics) 24576M->18432M(24576M) 847.234ms
Full GC 847ms phá SLA: 99.9% latency trong 1 giờ chỉ có 3.6 giây budget, một pause 847ms xài hết 23% budget trong một lần. Muốn sửa, phải chẩn đoán được vì sao G1 rơi vào Full GC — mà muốn vậy phải mổ cơ chế bên dưới: region, SATB, remembered set, mixed collection và humongous object.
Bài 06b đã chọn G1 theo SLA và đọc GC log. Bài này giải thích cơ chế G1 đủ sâu để lý luận về tuning thay vì copy-paste flag.
1. Region-based — nền tảng của G1
Một hình dung xuyên suốt: heap như nhà kho chia nhiều ô kệ bằng nhau, G1 quản lý bộ nhớ y như quản lý kho.
| Nhà kho | G1 |
|---|---|
| Ô kệ đều nhau, đổi công năng linh hoạt; dọn ô bẩn nhất trước | region (1-32MB) + garbage first |
| Sổ ghi "ai để đồ trỏ vào ô này" | remembered set — reference cross-region trỏ vào |
| Chụp ảnh hàng tồn lúc mở cửa để không sót | SATB — snapshot reference graph lúc mark bắt đầu |
| Kiện quá khổ chiếm nguyên (nhiều) ô liền nhau | humongous (vượt 50% region) |
Bài 06 mô tả heap generational cứng: young và old là hai vùng cố định. G1 phá cấu trúc đó: heap chia thành nhiều region nhỏ đều nhau, mỗi region có thể mang vai trò bất kỳ.
G1 nhắm khoảng 2048 region; region size tính từ heap / 2048, làm tròn về luỹ thừa của 2 và kẹp trong khoảng 1MB đến 32MB. Heap 16GB → region 8MB; heap 64GB → region 32MB (chạm trần).
Heap G1 (moi o la 1 region, nhan doi vai tro qua thoi gian):
+------+------+------+------+------+------+------+
| Eden | Old | Old | Eden | Surv | Old | Eden |
+------+------+------+------+------+------+------+
| Eden | Hum | Old | Old | Eden | Surv | Old |
+------+------+------+------+------+------+------+
Lợi ích so với generation cố định: partial collection (collect subset region nhiều rác nhất, không phải toàn old gen → pause ngắn, kiểm soát được); vai trò linh hoạt (region đổi Eden → Survivor → Old → Free tuỳ nhu cầu, không khoá cứng tỉ lệ); và compact từng region nhỏ dễ hơn compact toàn heap.
2. SATB — concurrent marking không bỏ sót
Để giữ pause thấp, G1 chạy marking đồng thời với app (concurrent). Nhưng có một cạm bẫy: nếu GC đang mark mà app thread đổi reference graph, GC có thể bỏ sót object còn live. SATB (Snapshot-At-The-Beginning) giải quyết.
Ý tưởng: chụp một snapshot logic của reference graph lúc marking bắt đầu. Mọi object live tại thời điểm snapshot được coi là live suốt cycle này, kể cả khi app xoá reference sau đó (sẽ dọn ở cycle sau).
Bài toán cụ thể: GC đã duyệt qua A nhưng chưa duyệt tới B; app thread xoá A.ref = B rồi gán C.ref = B (C đã duyệt xong). Không có SATB, B lọt lưới — bị coi là rác dù còn live. SATB đảm bảo B được mark.
Cơ chế: write barrier của G1. Mỗi khi app ghi một reference field, barrier đẩy giá trị cũ vào SATB queue trước; GC đọc queue và mark object cũ là live.
App thread: field.ref = newObject // ghi reference field
SATB barrier: satbQueue.push(oldValue) // day gia tri cu -> GC mark live
Chi phí: mỗi reference write tốn thêm một buffer write (khoảng 5-10 ns, amortized). Đây là write barrier — tốn khi ghi, free khi đọc; đó là lý do G1 hợp workload đọc nhiều hơn ghi. (So sánh write vs read barrier nằm ở bài 12b.)
3. Remembered Set — collect một region không cần quét cả heap
Khi G1 collect region R, nó phải biết region nào khác đang trỏ vào R để cập nhật pointer sau khi dịch object. Quét toàn heap để tìm thì mất hết lợi ích partial collection. Remembered Set (RSet) giải quyết: mỗi region giữ một RSet ghi lại các reference cross-region trỏ vào nó.
Collect R lúc này chỉ cần quét RSet của R (kích thước nhỏ) thay vì toàn heap — đây là điều biến "garbage first" thành khả thi.
RSet được maintain bởi card table write barrier: mỗi lần app ghi reference field, JVM đánh dấu một card (vùng 512 byte của heap) là "dirty"; một refinement thread chạy concurrent đọc card dirty và cập nhật RSet tương ứng.
Chi phí của G1 (cân nhắc khi tune): write barrier (SATB + card) ~5-10 ns mỗi reference write; refinement thread ~1 core khi chạy; RSet ~5-10% memory.
4. Cycle của G1
flowchart TD
A["Young GC (STW)"] --> B["Concurrent Marking (SATB)"]
B --> C["Remark (STW ngan)"]
C --> D["Cleanup (STW ngan)"]
D --> E["Mixed GC (STW)"]
E --> F{"Old con ap luc?"}
F -->|"Thap"| A
F -->|"Cao: khong kip"| G["Full GC (STW dai)"]
style A fill:#93C5FD
style B fill:#6EE7B7
style G fill:#FCA5A5- Young GC (STW): evacuation — copy object còn sống từ Eden + Survivor sang region mới rồi bỏ trống region cũ. Pause ngắn (~10-50ms).
- Concurrent Marking: mark live trong Old, chạy song song với app nhờ SATB. Không pause.
- Remark (STW ngắn): drain nốt SATB queue, hoàn tất mark.
- Cleanup (STW ngắn): xác định region hoàn toàn rỗng để thu hồi ngay.
- Mixed GC (STW): dọn Young + một số Old region nhiều rác nhất. Đây là chỗ "garbage first" phát huy — chọn region đem lại nhiều free nhất trong pause budget.
5. Humongous object là gì và vì sao gây Full GC?
Object có kích thước vượt 50% region size được G1 xử lý riêng: gọi là humongous, cấp phát vào một hoặc nhiều region liên tiếp dành riêng (humongous region), không share với object khác.
byte[] buffer = new byte[50 * 1024 * 1024]; // 50MB: humongous neu region 32MB
Vì sao humongous gây rắc rối:
- Humongous sống không được dọn trong Young GC thông thường — chỉ clean khi concurrent cycle chạy (ít thường xuyên hơn) → tạo áp lực Old gen. (Humongous đã chết thì G1 eager-reclaim thu hồi được ngay trong Young GC.)
- Cần vùng region liên tiếp — nếu heap phân mảnh (region trống rải rác), không tìm được vùng đủ lớn liền mạch → humongous allocation fail → Full GC.
- Lãng phí: object 9MB trong region 16MB bỏ trống 7MB cuối region.
Cách giảm: tăng region size (-XX:G1HeapRegionSize=32m → ngưỡng humongous lên 16MB), dùng DirectByteBuffer off-heap cho buffer lớn, hoặc pool và tái sử dụng buffer.
6. Full GC — vì sao G1 fallback, và cách chống
Quay lại Full GC 847ms đầu bài (heap 24GB, 2h sáng — thường là lúc batch job chạy). Dựa trên cơ chế đã học, tự viết ra: điều gì khiến G1 buộc phải fallback từ mixed GC sang Full GC toàn heap? Chốt giả thuyết rồi đọc tiếp.
Full GC trong G1 xảy ra khi mixed GC không dọn Old gen kịp so với allocation rate — G1 hết đường và fallback sang mark-compact toàn heap. Ba nguyên nhân chính:
- Concurrent marking không kịp: allocation spike (batch import 2h sáng) vượt tốc độ marking → Old đầy trước khi marking xong → không đủ thông tin chọn region → Full GC.
- Humongous allocation fail: cần region liên tiếp cho object lớn nhưng heap phân mảnh → Full GC để compact.
- Evacuation failure: Young GC cần copy object nhưng không còn region free → Full GC giải phóng.
Full GC của G1 là mark-compact parallel từ JDK 10 (JEP 307) — không còn single-thread chậm như đời đầu, nhưng vẫn STW toàn heap nên pause dài với heap lớn. Pause 847ms còn được bài 11 — safepoint bổ sung: TTSP (time-to-safepoint) cộng thêm vào con số GC log báo cáo.
Chống Full GC (áp cho scenario đầu bài):
- Start marking sớm hơn:
-XX:InitiatingHeapOccupancyPercent=35(default 45) — marking bắt đầu khi occupancy Old gen đạt 35% thay vì 45%, có nhiều thời gian hơn trước khi Old đầy. - Giữ heap buffer lớn hơn:
-XX:G1ReservePercent=15(default 10). - Tăng GC thread:
-XX:ConcGCThreads. - Tách batch spike ra process riêng với Parallel GC, để batch import không phá GC của online service.
7. Tuning G1 thực chiến
Config khởi điểm gói lại các cơ chế vừa học — không copy-paste mù, mỗi flag ứng một cơ chế bài đã mổ:
java -XX:+UseG1GC -Xms16g -Xmx16g \ # Xms = Xmx cho server
-XX:MaxGCPauseMillis=100 \ # soft pause target
-XX:InitiatingHeapOccupancyPercent=35 \ # start concurrent mark som
-XX:G1HeapRegionSize=16m \ # region size (auto hoac manual)
-Xlog:gc*=info:file=gc.log:time,uptime \ # GC logging bat buoc
MyApp
8. Pitfall tổng hợp
❌ Nhầm 1: Coi MaxGCPauseMillis là cam kết cứng.
-XX:MaxGCPauseMillis=10 # G1 KHONG the dam bao 10ms voi heap lon -> tu lam kho GC
✅ Đây là soft target. Đặt quá thấp buộc G1 quét ít region mỗi cycle → GC dày hơn, throughput sụt, Old dồn lại. Cần pause dưới 10ms thì đổi collector (ZGC), không ép G1.
❌ Nhầm 2: Bỏ qua humongous object khi profile.
List<byte[]> chunks; // moi chunk 20MB, region 16MB -> chiem 2 region lien tiep
✅ Buffer lớn dùng DirectByteBuffer hoặc tăng region size; monitor Humongous regions trong GC log.
9. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JEP 248: G1 làm default GC (Java 9) — rationale chọn G1 làm default.
- JEP 307: Parallel Full GC for G1 (Java 10) — Full GC của G1 chuyển từ single-thread sang parallel.
- HotSpot GC Tuning Guide — "Garbage-First (G1)" — chương G1 chính thức: region, marking, mixed GC, tuning flag.
- Shipilev — "JVM Anatomy Quarks #13: Intergenerational Barriers" — mổ card table write barrier ở mức instruction bằng benchmark.
Ghi chú: Chương G1 trong GC Tuning Guide giải thích từng flag kèm khi nào chỉnh — đọc trước khi tune. Quark #13 (Intergenerational Barriers) của Shipilev cho thấy chi phí thật của card table barrier ở mức instruction, hữu ích khi lý luận throughput.
10. Liên hệ các bài khác
- Chọn collector theo SLA và đọc GC log — khi nào chọn G1 và cách đọc log young/mixed/full để xác nhận.
- GC — reachability và ba thuật toán nền — mark-copy (young evacuation) và mark-compact (Full GC) mà G1 dùng.
- ZGC và Shenandoah — khi pause STW của G1 không đủ thấp, read barrier cho phép compaction concurrent; so sánh write vs read barrier nằm ở đó.
- Safepoint và stop-the-world — vì sao pause thực tế dài hơn con số GC log báo (TTSP cộng thêm).
11. Tóm tắt
- Region-based: heap chia ~2048 region (1-32MB), vai trò Eden/Survivor/Old/Humongous linh hoạt; G1 dọn region nhiều rác nhất trước ("garbage first").
- SATB: write barrier đẩy reference cũ vào queue để concurrent marking không bỏ sót object live lúc snapshot.
- Remembered Set: reference cross-region trỏ vào một region → collect chỉ quét RSet, không toàn heap (card table barrier + refinement thread).
- Cycle: Young GC → Concurrent Marking → Remark → Cleanup → Mixed GC; fallback Full GC (mark-compact toàn heap, parallel từ JDK 10) khi Old đầy nhanh hơn evacuation. Chống bằng IHOP thấp hơn, G1ReservePercent cao hơn, tách batch.
- Humongous (vượt 50% region): chiếm nguyên (nhiều) region liền nhau, humongous sống dễ gây fragmentation → Full GC. Giảm bằng region lớn hơn / DirectByteBuffer.
MaxGCPauseMillislà soft target — ép quá thấp phản tác dụng; cần dưới 10ms thì đổi ZGC.
12. Tự kiểm tra
Q1SATB là gì và vì sao G1 cần write barrier để duy trì nó?▸
SATB (Snapshot-At-The-Beginning) giữ concurrent marking đúng khi app đổi reference graph lúc GC đang mark. Invariant: mọi object live tại thời điểm marking bắt đầu phải được mark live, kể cả nếu app xoá reference sau đó.
Không có SATB, nếu app xoá A.ref = B (GC đã duyệt A) rồi thêm C.ref = B (C đã duyệt xong), B lọt lưới → coi là rác dù còn live. Write barrier thực hiện SATB: mỗi lần app ghi reference field, barrier đẩy giá trị cũ vào SATB queue; GC mark object cũ live. Tốn khi ghi, free khi đọc → là write barrier, hợp workload đọc nhiều.
Q2Remembered Set giải quyết vấn đề gì, và không có nó thì partial collection còn khả thi không?▸
Khi collect region R, G1 phải biết region nào trỏ vào R để cập nhật pointer sau khi dịch object; RSet của R ghi lại chính các reference cross-region đó. Không có RSet, G1 phải quét toàn heap mỗi lần collect một region — mất sạch lợi ích partial collection. RSet đưa chi phí từ O(heap) xuống O(kích thước RSet) nên "garbage first" mới khả thi, đổi lại tốn ~5-10% memory + card table barrier + refinement thread.
Q3G1 fallback sang Full GC khi nào? Nêu ba nguyên nhân và cách chống chính.▸
Full GC xảy ra khi mixed GC không dọn Old kịp allocation rate. Ba nguyên nhân: (1) marking không kịp — allocation spike vượt tốc độ marking, Old đầy trước khi marking xong; (2) humongous allocation fail — cần region liên tiếp nhưng heap phân mảnh; (3) evacuation failure — Young GC không còn region free.
Chống: hạ InitiatingHeapOccupancyPercent (45 → 35) để marking sớm; tăng G1ReservePercent (10 → 15) làm buffer; giảm humongous; tách allocation spike sang process riêng. Full GC hơn 1 lần/giờ là dấu hiệu cần can thiệp.
Q4Humongous object là gì và tại sao nhiều humongous object đẩy nhanh tới Full GC?▸
Humongous là object vượt 50% region size (region 16MB → object lớn hơn 8MB), cấp phát vào region liên tiếp dành riêng. Đẩy nhanh Full GC vì: humongous sống không được dọn trong Young GC thông thường (chỉ clean khi concurrent cycle chạy) → áp lực Old; cần vùng liên tiếp nên khi heap phân mảnh → humongous allocation fail → Full GC; và lãng phí phần cuối region. Giảm bằng tăng region size hoặc DirectByteBuffer off-heap.
Q5Trong một mixed collection, G1 chọn old region nào để dọn và dựa vào dữ liệu nào? Vì sao cách chọn đó xứng tên "garbage first"?▸
Concurrent marking để lại cho mỗi old region một ước lượng liveness (bao nhiêu byte còn sống). Ở mỗi mixed collection, G1 xếp old region theo tỉ lệ rác — ít live nhất trước — rồi nhặt dần vào collection set tới khi chạm pause budget (MaxGCPauseMillis).
Vì luôn ưu tiên region cho nhiều free nhất trên mỗi đơn vị công evacuation, mỗi pause thu hồi được nhiều nhất — đúng nghĩa "garbage first", thay vì quét đều toàn Old. Region quá nhiều live (copy không đáng) bị bỏ qua để khỏi phí pause budget.
Q6Region-based của G1 khác gì heap generational cố định, và lợi ích chính là gì?▸
Heap cố định (Serial/Parallel) chia young/old thành hai vùng liền mạch tỉ lệ cứng; G1 chia ~2048 region nhỏ đều nhau, vai trò Eden/Survivor/Old/Humongous linh hoạt.
Lợi ích chính là partial collection có kiểm soát pause: G1 dọn subset region nhiều rác nhất ("garbage first") thay vì toàn old gen, nên giữ được pause trong soft target bằng cách điều chỉnh số region quét. Vai trò linh hoạt còn tránh lãng phí khi tỉ lệ young/old thực lệch cấu hình cứng, và compact từng region nhỏ dễ hơn compact toàn heap.
Bài tiếp theo: ZGC và Shenandoah — read barrier và concurrent compaction
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
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