Heap generational — young, old và bump-the-pointer ở Eden
Vì sao heap chia young/old, object di chuyển Eden → Survivor → Old thế nào, và tại sao cấp phát object trong Eden nhanh ngang C nhờ bump-the-pointer + TLAB.
TL;DR: Heap là vùng lớn nhất của JVM, nơi mọi object sống. HotSpot chia heap thành young generation (Eden + hai Survivor S0/S1) và old generation dựa trên một quan sát thực nghiệm: đa số object chết trẻ. Object mới cấp phát vào Eden; sống qua vài lần GC minor thì được copy qua Survivor rồi cuối cùng promote sang Old. Cấp phát trong Eden nhanh gần bằng C nhờ bump-the-pointer: chỉ cần dời một con trỏ theo kích thước object, không cần free-list. Để nhiều thread cấp phát song song mà không tranh khoá, mỗi thread giữ một TLAB (Thread-Local Allocation Buffer) — một lát Eden riêng, bump con trỏ cục bộ, không đồng bộ hoá.
Một Integer chiếm bao nhiêu byte trong JVM 64-bit? Đoán phổ biến là 4 (vì int 4 byte). Thực tế 16 byte — object nào cũng đắt hơn data nó chứa vì phải mang theo header. (Vì sao đúng 16 byte, header gồm gì — bài 09 — Object header và Compressed OOP mổ chi tiết.) Hệ quả: tạo 100 triệu Integer ngốn khoảng 1.6 GB heap chứ không phải 400 MB. Object đổ vào đâu, sống ở đâu, được dọn thế nào — đó là câu chuyện của heap.
Bài này trả lời hai câu: heap được chia generational để làm gì, và vì sao cấp phát một object mới lại nhanh đến vậy.
1. Analogy — kho hàng chia khu mới và khu cũ
Hình dung heap là nhà kho chứa "vật phẩm" (object). Khu mới (young) là nơi hàng vừa nhập được xếp vào; nhân viên kiểm kê khu này rất thường xuyên vì phần lớn là bao bì, phiếu tạm dùng một lần rồi bỏ — quét nhanh, dọn nhiều. Khu cũ (old) chứa hàng tồn lâu, chứng tỏ còn giá trị; kiểm kê hiếm hơn vì ít khi có gì để bỏ. Món hàng sống sót qua nhiều lần kiểm kê ở khu mới sẽ được chuyển sang khu cũ. Chia kho làm hai để khỏi lục toàn bộ mỗi lần dọn — chỉ quét khu mới, nơi 90% rác nằm ở đó.
| Nhà kho | JVM heap |
|---|---|
| Khu mới (kiểm kê thường xuyên) | Young generation |
| Khu cũ (kiểm kê hiếm) | Old generation |
| Bàn nhập hàng | Eden |
| Kệ trung chuyển | Survivor S0 / S1 |
| Chuyển hàng sang khu cũ | Promotion |
| Một đợt kiểm kê khu mới | GC minor |
Object mới đổ vào Eden. Còn sống sau vài đợt GC minor thì được copy qua Survivor, rồi promote sang Old. Quét young nhanh và thường xuyên; quét old chậm và hiếm. Tách được như vậy vì đa số object chết trẻ.
2. Heap được chia young/old để làm gì?
Nền tảng là một quan sát thực nghiệm lặp lại qua nhiều workload: đa số object chết trẻ. Object cục bộ trong method, object trung gian của stream, DTO của một request — cấp phát xong dùng vài microsecond rồi không ai tham chiếu nữa. Chỉ thiểu số (cache, singleton, connection pool) sống lâu. Nếu đa số rác nằm ở nhóm "vừa sinh ra", tách riêng nhóm đó và quét thật thường xuyên sẽ dọn được phần lớn rác với chi phí thấp.
flowchart LR
subgraph Young[Young Generation]
E[Eden]
S0[Survivor S0]
S1[Survivor S1]
end
subgraph Old[Old Generation]
O[Object song lau]
end
E -->|"GC minor: copy object con song"| S0
S0 -->|"luan phien moi dot"| S1
S1 -->|"vuot tuning threshold"| O- Young generation gồm Eden (nơi cấp phát) và hai Survivor S0, S1. GC ở đây là GC minor — nhanh (thường dưới chục mili-giây) vì young nhỏ và phần lớn object đã chết.
- Old generation chứa object đã sống lâu. GC quét old (chu kỳ major/full) chậm hơn nhiều vì vùng lớn và object phần lớn còn sống.
Object di chuyển như sau: new Foo() cấp phát trong Eden; Eden đầy thì GC minor copy object còn sống sang một Survivor rồi xoá sạch Eden; các đợt sau, object sống được copy luân phiên giữa S0 và S1 (mô hình "from-space / to-space"). Mỗi lần survive, GC age tăng 1; vượt -XX:MaxTenuringThreshold (mặc định 15 — trần cứng vì age lưu trong 4 bit header) thì object promote sang Old. Survivor đầy sớm thì object promote trước hạn.
Copying GC cần một vùng "đọc" (from) và một vùng "ghi" (to) tách biệt để nén object còn sống thành khối liền mạch. Trong một đợt: Eden cộng Survivor đang dùng là "from", Survivor còn lại là "to". Sau đợt, hai Survivor đổi vai. Một Survivor duy nhất không có chỗ trống để nén sang.
Tỉ lệ vùng khác nhau theo collector. Với Serial và Parallel: -XX:NewRatio=2 (old gấp 2 young → young ≈ 1/3 heap) và -XX:SurvivorRatio=8 (Eden gấp 8 mỗi Survivor). Nhưng G1 — mặc định từ Java 9 — không dùng NewRatio; nó sizing young động theo pause-time goal, từ G1NewSizePercent=5 (tối thiểu 5% heap) tới G1MaxNewSizePercent=60 (tối đa 60%). Con số "young = 1/3 heap" chỉ đúng cho Serial/Parallel.
3. Vì sao cấp phát object nhanh gần bằng C?
Eden là vùng bộ nhớ liền mạch (contiguous). JVM giữ con trỏ top chỉ vào ranh giới đã-dùng/còn-trống:
Eden: [obj1][obj2][obj3] ^top ... vung trong ...
Cấp phát một object chỉ gồm: lấy địa chỉ tại top, cộng top lên đúng kích thước object, trả về địa chỉ. Đó là bump-the-pointer — không có bước "tìm ô trống vừa kích thước" như malloc phải làm với free-list. Chi phí vài lệnh máy, cỡ nanosecond, O(1), không syscall. Đây là lý do cấp phát object trong Java nhanh ngang (đôi khi hơn) malloc đã tune. Cái giá nằm phía sau: Eden đầy thì phải GC minor để dọn và nén lại — Java đánh đổi "cấp phát siêu nhanh" lấy "GC định kỳ".
Một app server có hàng trăm thread cùng cấp phát object đồng thời vào cùng một Eden. Nếu cả trăm thread cùng dời chung một con trỏ top, chuyện gì hỏng? JVM tránh nó bằng cách nào? Viết ra ý bạn nghĩ trước khi đọc tiếp.
4. TLAB — cấp phát song song không cần khoá thế nào?
Con trỏ top là tài nguyên dùng chung. Hai thread cùng đọc top, cùng cộng size, cùng ghi lại → ghi đè lên nhau (race), hai object đè chồng vùng nhớ. Cách "an toàn" ngây thơ là bọc mỗi lần bump bằng atomic CAS trên top — nhưng cấp phát xảy ra hàng triệu lần mỗi giây, CAS trên một con trỏ chung biến nó thành điểm nghẽn nóng (mọi core tranh một cache line).
Lời giải của HotSpot là TLAB — Thread-Local Allocation Buffer (bật mặc định, -XX:+UseTLAB): mỗi thread được cấp một lát Eden riêng. Cấp phát trong TLAB là bump con trỏ cục bộ của thread — không thread nào khác đụng, nên không cần đồng bộ hoá (fast path). Chỉ khi TLAB cạn, thread mới xin lát Eden mới (lúc này mới cần một atomic bump trên con trỏ Eden — nhưng hiếm hơn cấp phát object hàng nghìn lần).
flowchart TB
subgraph Eden
T1["TLAB thread 1<br/>bump cuc bo, khong khoa"]
T2["TLAB thread 2<br/>bump cuc bo, khong khoa"]
T3["TLAB thread 3<br/>bump cuc bo, khong khoa"]
F["vung Eden con lai"]
end
T1 -.->|"TLAB can: xin lat moi (atomic bump Eden)"| FNhờ TLAB, đường nóng của cấp phát vừa nhanh (bump-pointer) vừa không tranh khoá (thread-local). JVM tự điều chỉnh kích thước TLAB theo tốc độ cấp phát của từng thread. Object quá lớn không vừa TLAB (ví dụ mảng lớn) thì cấp phát thẳng vào Eden dùng chung, hoặc thẳng vào Old nếu quá khổ.
5. Cấu hình kích thước heap
java -Xms512m -Xmx4g MyApp
-Xms(initial size): dung lượng heap JVM xin lúc khởi động.-Xmx(max size): trần cứng. Heap chạm trần mà GC không dọn đủ chỗ →OutOfMemoryError: Java heap space.
Mẹo production: đặt -Xms bằng -Xmx cho server chạy dài — JVM không phải resize heap giữa chừng, hành vi GC ổn định hơn. Java heap space chỉ là một trong nhiều chữ ký OOM; mỗi vùng bộ nhớ hỏng theo cách riêng. Toàn bộ họ OOM (metaspace, stack, native/direct) được mổ ở bài 05b — Metaspace, stack và native memory.
6. Pitfall tổng hợp
❌ Nhầm 1: Tưởng -XX:NewRatio luôn điều khiển tỉ lệ young/old. G1 (mặc định) bỏ qua nó.
✅ NewRatio chỉ có hiệu lực với Serial/Parallel. G1 sizing young động 5%–60% heap theo pause-time goal.
❌ Nhầm 2: Nghĩ mọi object luôn cấp phát vào Eden. ✅ Object nhỏ (đa số) vào TLAB trong Eden; object quá lớn có thể bỏ qua TLAB (Eden chung), thậm chí vào thẳng Old.
❌ Nhầm 3: Pool mọi object nhỏ vì tưởng cấp phát chậm. ✅ Bump-the-pointer khiến cấp phát object nhỏ cực rẻ; pool object nhỏ còn làm chúng sống dai, promote sang Old, ép GC major làm việc nhiều hơn. Pool chỉ đáng với object đắt tạo (thread, connection, buffer lớn).
7. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JVMS §2.5.3 Heap — spec định nghĩa heap là vùng chia sẻ chứa mọi instance object và array.
- HotSpot GC Tuning — Generations — mô hình young/old, minor vs major GC,
NewRatio/SurvivorRatio. - G1 Garbage Collector Guide —
G1NewSizePercent=5,G1MaxNewSizePercent=60, vì sao G1 không dùng NewRatio.
Ghi chú: Với Java 21 (LTS), collector mặc định là G1 — nên các con số NewRatio/SurvivorRatio là mô hình khái niệm cho Serial/Parallel; muốn thấy hành vi thật của app, đọc GC log (bài 06b) thay vì suy từ flag.
8. Liên hệ các bài khác
- Bài 05b — Metaspace, stack và native memory — phần còn lại của bản đồ bộ nhớ: các vùng ngoài heap và chữ ký OOM từng vùng. Bài này lo vùng heap; 05b lo phần ngoài heap.
- Bài 06 — Garbage Collection — cơ chế reachability và thuật toán GC dọn chính các vùng young/old mô tả ở đây.
- Bài 09 — Object header và Compressed OOP — vì sao object mang header 12–16 byte (chi tiết cái hook "Integer 16 byte"), và compressed OOP tiết kiệm heap thế nào.
9. Tóm tắt
- Heap là vùng chia sẻ lớn nhất của JVM; HotSpot chia thành young (Eden + 2 Survivor) và old.
- Nền tảng là generational hypothesis: đa số object chết trẻ → quét young thường xuyên là dọn được phần lớn rác với chi phí thấp.
- Object mới vào Eden; sống qua GC minor thì copy luân phiên S0/S1 (tăng GC age); vượt
MaxTenuringThreshold(mặc định 15) thì promote sang Old. Cần hai Survivor để copying GC có from/to-space. - Tỉ lệ vùng:
NewRatio=2,SurvivorRatio=8cho Serial/Parallel; G1 (mặc định Java 9+) sizing young động 5%–60% heap, không dùng NewRatio. - Bump-the-pointer: cấp phát chỉ là dời con trỏ
top— O(1), vài lệnh máy, không free-list. Đánh đổi lấy GC minor khi Eden đầy. - TLAB cho mỗi thread một lát Eden riêng: bump cục bộ không khoá (fast path); chỉ khi cạn mới xin lát mới (slow path). Object quá lớn bỏ qua TLAB hoặc bỏ qua young.
10. Tự kiểm tra
Q1Heap chia young/old để làm gì? Một object di chuyển giữa các vùng theo trình tự nào?▸
Chia để tận dụng generational hypothesis: đa số object chết trẻ, nên tách riêng nhóm vừa sinh và quét nó thật thường xuyên sẽ dọn được nhiều rác với chi phí thấp, thay vì lục cả heap mỗi lần.
Trình tự: new Foo() cấp phát trong Eden → Eden đầy thì GC minor copy object còn sống sang một Survivor, xoá sạch Eden → các đợt sau copy luân phiên giữa S0/S1, tăng GC age mỗi lần → vượt MaxTenuringThreshold (mặc định 15) thì promote sang Old. Kết quả: young nhỏ, quét nhanh/thường xuyên; old lớn, quét chậm/hiếm.
Q2Vì sao young cần HAI Survivor (S0 và S1) thay vì một?▸
Vì young dùng copying GC: để nén object còn sống thành khối liền mạch, cần một vùng "from" để đọc và một vùng "to" trống để ghi, tách biệt nhau. Trong một đợt, Eden cộng Survivor đang dùng là "from", Survivor còn lại là "to"; sau đợt hai Survivor đổi vai.
Một Survivor duy nhất không có vùng trống tách biệt để nén sang — sẽ phải ghi đè lên chính vùng đang đọc. Cơ chế luân phiên cũng khử phân mảnh: sau mỗi đợt, object sống nằm liền mạch, sẵn cho bump-the-pointer.
Q3Bump-the-pointer là gì, vì sao nó khiến cấp phát object là O(1) và nhanh hơn malloc?▸
malloc?Eden là vùng nhớ liền mạch, JVM giữ con trỏ top chỉ vào ranh giới đã-dùng/còn-trống. Cấp phát gồm: lấy địa chỉ tại top, cộng top lên đúng kích thước object, trả về địa chỉ — vài lệnh máy, O(1), cỡ nanosecond.
Khác malloc phải duyệt free-list tìm ô trống vừa kích thước và chống phân mảnh — chi phí biến thiên, cao hơn. Cái giá của bump-pointer: Eden đầy thì phải GC minor để dọn và nén lại, đổi cấp phát siêu rẻ lấy GC định kỳ.
Q4Nếu hàng trăm thread cùng bump một con trỏ top chung, chuyện gì hỏng? TLAB giải quyết thế nào?▸
top chung, chuyện gì hỏng? TLAB giải quyết thế nào?top là tài nguyên chung: hai thread cùng đọc, cùng cộng size, cùng ghi lại → ghi đè nhau (race), hai object đè chồng vùng nhớ. Bọc mỗi lần bump bằng CAS thì an toàn nhưng cấp phát hàng triệu lần/giây khiến CAS trên con trỏ chung thành điểm nghẽn nóng.
TLAB cấp cho mỗi thread một lát Eden riêng: bump con trỏ cục bộ — không thread nào khác đụng, khỏi đồng bộ hoá (fast path). Chỉ khi TLAB cạn, thread mới xin lát mới bằng một atomic bump trên Eden (hiếm hơn cấp phát object hàng nghìn lần). Nhờ đó đường nóng vừa nhanh vừa không tranh khoá.
Q5App chạy Java 21 mặc định, bạn đặt -XX:NewRatio=2 mà tỉ lệ young/old không đổi. Vì sao?▸
-XX:NewRatio=2 mà tỉ lệ young/old không đổi. Vì sao?Vì collector mặc định từ Java 9 là G1, và G1 không dùng NewRatio. G1 sizing vùng young động theo pause-time: từ G1NewSizePercent=5 (tối thiểu 5% heap) tới G1MaxNewSizePercent=60 (tối đa 60%), tự điều chỉnh mỗi chu kỳ.
NewRatio=2 và SurvivorRatio=8 chỉ có hiệu lực với Serial và Parallel. Trên G1, muốn cố định kích thước young dùng -XX:NewSize/-XX:MaxNewSize, và đọc GC log để thấy tỉ lệ vùng thật thay vì suy từ flag.
Bài tiếp theo: Metaspace, stack và native memory — bản đồ bộ nhớ ngoài heap
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