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

- 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ò linh hoạt; SATB write barrier giữ snapshot nhất quán; RSet theo dõi cross-region reference để collect không phải quét toàn heap.
- Cycle: Young GC → Concurrent Marking → 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 và
G1ReservePercentcao hơn. - Humongous (vượt 50% region): cần nhiều region liền nhau, sống lâu gây fragmentation → Full GC. Giảm bằng region lớn hơn; object off-heap dùng
DirectByteBuffer. MaxGCPauseMillislà soft target — ép quá thấp G1 thiếu thời gian thu gom, phản tác dụng. Khi SLA ép 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ó?
- Q2Remembered Set giải quyết vấn đề gì, và không có nó thì partial collection còn khả thi không?
- Q3G1 fallback sang Full GC khi nào? Nêu ba nguyên nhân và cách chống chính.
- Q4Humongous object là gì và tại sao nhiều humongous object đẩy nhanh tới Full GC?
- 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"?
- Q6Region-based của G1 khác gì heap generational cố định, và lợi ích chính là gì?
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