Java Internals & Concurrency/Heap generational — young, old và bump-the-pointer ở Eden
56/75
Bài 56 / 75~13 phútJVM InternalsMiễn phí lượt xem

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à khoJVM 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àngEden
Kệ trung chuyểnSurvivor S0 / S1
Chuyển hàng sang khu cũPromotion
Một đợt kiểm kê khu mớiGC minor
💡 Cách nhớ

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.

Khung Young generation chứa Eden rồi Survivor S0 và S1 nối tiếp nhau, mỗi đợt GC minor copy object còn sống và tăng GC age; vượt MaxTenuringThreshold thì object promote xuống khung Old generation

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

Vì sao cần HAI Survivor, không phải một?

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 SerialParallel: -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ỳ".

💡 Thử đoán trước khi đọc mục 4

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ỏ toptà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).

Đối chiếu hai bố cục: một con trỏ top dùng chung thì hai thread cùng ghi nên mỗi lần new phải CAS, còn mỗi thread một lát Eden riêng thì chỉ bump con trỏ cục bộ, không CAS không khoá, và chỉ khi lát cạn mới cần một atomic bump

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

📚 Deep Dive Oracle

Spec / reference chính thức:

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

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

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Heap chia young/old để làm gì? Một object di chuyển giữa các vùng theo trình tự nào?
  2. Q2
    Vì sao young cần HAI Survivor (S0 và S1) thay vì một?
  3. Q3
    Bump-the-pointer là gì, vì sao nó khiến cấp phát object là O(1) và nhanh hơn malloc?
  4. Q4
    Nế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?
  5. Q5
    App chạy Java 21 mặc định, bạn đặt -XX:NewRatio=2 mà 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

Đặ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

Metaspace, stack và native memory — đọc chữ ký OOM từng vùng