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.

- 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).

Nhờ 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 = Eden + 2 Survivor (young) + Old. Generational hypothesis: đa số object chết trẻ → minor GC thường xuyên quét young, chi phí thấp.
- Cần hai Survivor: copying GC cần from/to-space để compact không có fragmentation.
MaxTenuringThreshold=15— vượt ngưỡng → promote Old. - G1 (default Java 9+): sizing young động 5–60% heap, không dùng
NewRatio(chỉ cho Serial/Parallel). G1 chọn region nhiều rác nhất — "garbage first". - Bump-the-pointer + TLAB (lát Eden per-thread): cấp phát O(1), không lock. Object quá lớn bỏ qua TLAB hoặc bỏ qua young, vào thẳng Old.
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?
- Q2Vì sao young cần HAI Survivor (S0 và S1) thay vì một?
- Q3Bump-the-pointer là gì, vì sao nó khiến cấp phát object là O(1) và nhanh hơn
malloc? - Q4Nếu hàng trăm thread cùng bump một con trỏ
topchung, chuyện gì hỏng? TLAB giải quyết thế nào? - Q5App chạy Java 21 mặc định, bạn đặt
-XX:NewRatio=2mà tỉ lệ young/old không đổi. Vì sao?
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