Compressed OOP và 32GB cliff — cái bẫy khi chọn heap size
Cơ chế nén reference 8 byte xuống 4, phép toán shift-by-3 chốt giới hạn 32GB, và anti-pattern 32GB cliff khiến heap to hơn lại chứa ít object hơn.
TL;DR: Compressed OOP biểu diễn mỗi reference (và klass pointer) bằng 32 bit thay vì 64 bit, cắt một nửa kích thước con trỏ và tiết kiệm khoảng 20-40% heap cho object nhiều reference. Nó lợi dụng việc mọi object align 8 byte (3 bit thấp của địa chỉ luôn bằng 0) để bỏ 3 bit đó khi lưu, khôi phục khi dùng. Chính độ rộng con trỏ 32 bit cộng alignment 8 byte chốt trần compressed OOP ở 32 GB. Đặt heap chạm 32 GB, JVM tự tắt nó — mọi reference phình lên 8 byte, object to ra 30-50%. Nghịch lý 32GB cliff: heap 31 GB compressed thường chứa nhiều object hơn heap 33 GB uncompressed.
Một team thấy app hay Full GC nên nâng heap từ 30 GB lên 40 GB cho "thoáng". Kết quả ngược: GC còn dày hơn, và app OOM sớm hơn trước. Không rò rỉ mới, không đổi code — chỉ đổi một cờ -Xmx. Thủ phạm là một ngưỡng ẩn ngay dưới chân họ: khi heap vượt 32 GB, JVM âm thầm tắt compressed OOP và mọi object phình to.
Bài 09 đã cho thấy klass pointer chỉ 4 byte "khi compressed class pointer bật". Bài này mổ cơ chế nén đó, phép toán chốt con số 32 GB, và vì sao chọn heap size quanh ngưỡng này là một trong những cái bẫy tuning JVM tốn kém nhất.
1. Compressed OOP — nén con trỏ để cứu heap
OOP (Ordinary Object Pointer) là con trỏ JVM dùng để tham chiếu một object trên heap. Trên JVM 64-bit, một con trỏ đầy đủ là 8 byte. Object nhiều reference field trả giá nặng: một HashMap.Node có ba reference (key, value, next) tốn 24 byte chỉ riêng con trỏ.
Compressed OOP biểu diễn mỗi reference bằng 32 bit (4 byte). Bật bằng -XX:+UseCompressedOops, và mặc định bật trên JVM 64-bit khi heap dưới 32 GB (từ JDK 6u23). Lợi ích:
- Mỗi reference field: 8 byte xuống 4 byte.
- Klass pointer — con trỏ trong object header trỏ tới metadata của class (bài 09) — cũng 8 xuống 4 byte, nhưng do cờ riêng
UseCompressedClassPointers(xem §6), thường cùng bật với compressed OOP. - Mỗi slot trong array of reference: 8 xuống 4 byte.
Với object điển hình (nhiều reference), tổng tiết kiệm thường 20-40% heap (một HashMap.Node tiết kiệm 33%, một String 25% — xem §5) — đủ để một app 40 GB uncompressed gói gọn quanh 28-30 GB compressed.
2. Cơ chế — vì sao 32 bit lại trỏ được vùng nhớ 64-bit?
Hình dung một khu phố mà mọi nhà chỉ được đánh số bội 8: 0, 8, 16, 24… Số nào cũng tận cùng bằng ba chữ số nhị phân 000, nên khi ghi sổ ta bỏ luôn ba số đó — lưu "1, 2, 3" thay cho "8, 16, 24" cho gọn, cần địa chỉ thật thì nhân lại 8. Compressed OOP làm y hệt với con trỏ object.
| Khu phố đánh số bội 8 | Compressed OOP |
|---|---|
| Nhà chỉ ở địa chỉ bội 8 | Object align 8 byte → 3 bit thấp luôn 000 |
Bỏ ba số 000 cuối khi ghi sổ | Dịch phải 3 bit khi nén |
| Nhân 8 để ra địa chỉ thật | Dịch trái 3 bit khi giải nén |
| Sổ chỉ có 4 chữ số → số nhà tối đa | Con trỏ 32 bit → một trần cố định (tự dẫn ra ở §3) |
Mẹo nằm ở alignment. Bài 09 đã chỉ ra mọi object HotSpot align 8 byte, nên địa chỉ thực của bất kỳ object nào luôn chia hết cho 8 — tức 3 bit thấp luôn bằng 0. Ba bit đó không mang thông tin, nên JVM bỏ chúng khi lưu và khôi phục khi dùng:
compressed = (actual_address - heap_base) >> 3
actual = heap_base + (compressed << 3)
Dịch phải 3 bit khi nén, dịch trái 3 bit khi giải nén. heap_base là địa chỉ gốc của heap; thường bằng 0 ở zero-based mode — khi JVM đặt được heap bắt đầu ngay tại địa chỉ 0, phép trừ heap_base biến mất và giải nén chỉ còn một phép dịch trái, nhanh hơn.
Giả sử heap_base = 0 và một reference compressed lưu giá trị 1000 (thập phân). Địa chỉ thực của object là bao nhiêu byte? Tự tính bằng công thức trên trước khi đọc đáp án.
Áp công thức: actual = 0 + (1000 << 3) = 1000 × 8 = 8000. Object nằm ở byte 8000. Mỗi bước tăng 1 của giá trị compressed tương ứng nhảy 8 byte trong heap thực — đó là lý do một con trỏ 32 bit "với tới" được vùng nhớ rộng gấp 8 lần chính nó.
3. Vì sao trần đúng 32 GB?
Giờ tự suy ra giới hạn:
Một con trỏ compressed rộng 32 bit, biểu diễn được 2^32 giá trị khác nhau. Mỗi giá trị cách nhau 8 byte trong heap thực. Vậy vùng nhớ tối đa mà nó địa chỉ hoá được là bao nhiêu byte? Ra GB bằng bao nhiêu? Viết ra trước khi xem.
Phép tính: 2^32 giá trị nhân 8 byte mỗi bước = 2^32 × 2^3 = 2^35 byte. Mà 2^35 = 34,359,738,368 byte = 32 GB. Đây là trần toán học — không thể tăng nếu không thêm bit hoặc đổi alignment.
Có một cách nới trần: tăng alignment. -XX:ObjectAlignmentInBytes=16 khiến mỗi bước compressed nhảy 16 byte, phủ 2^32 × 16 = 2^36 = 64 GB. Nhưng đổi lại mỗi object tốn padding nhiều hơn — hiếm khi lời, nên gần như không ai dùng.
4. Khi nào compressed OOP tắt?
JVM kiểm tra heap size lúc khởi động. Nếu -Xmx đủ nhỏ để 32-bit con trỏ phủ hết heap, nó bật compressed OOP; nếu heap lớn hơn trần, nó tắt vì con trỏ 32 bit không còn địa chỉ hoá đủ.
Ngưỡng thực tế thấp hơn 32 GB một chút. JVM cần một vùng địa chỉ nhỏ cho cấu trúc nội bộ, nên compressed OOP thường tắt sớm hơn mốc 32 GB chính xác — ngưỡng an toàn thực tế khoảng 30-31 GB. Kiểm tra với heap cụ thể:
java -Xmx31g -XX:+PrintFlagsFinal -version | grep UseCompressedOops
# UseCompressedOops = true -> con tri
java -Xmx33g -XX:+PrintFlagsFinal -version | grep UseCompressedOops
# UseCompressedOops = false -> da tat
5. 32GB cliff — cái bẫy đắt nhất khi chọn heap size
Đây là hệ quả phản trực giác: heap to hơn có thể chứa ít object hơn. Khi compressed OOP tắt, mọi reference và klass pointer nhảy từ 4 lên 8 byte, kéo mọi object nhiều reference phình 30-50%.
Trước khi xem ví dụ chốt, tự dựng con số:
Một HashMap.Node có: header (mark word + klass pointer), một int hash, và ba reference key, value, next. Quy tắc: mark word luôn 8 byte; klass pointer và mỗi reference bằng kích cỡ con trỏ ở chế độ đó (4 byte khi nén, 8 khi không); tổng phải làm tròn lên bội 8 (align, bài 09). Tự tính kích thước node ở cả hai chế độ — tự đặt padding đúng chỗ — trước khi đọc đáp án.
Đáp án:
Compressed OOP (heap 31 GB):
mark 8 + klass 4 + hash 4 + next 4 + key 4 + value 4 = 28 -> pad 4 -> 32 byte
Uncompressed OOP (heap 33 GB):
mark 8 + klass 8 + hash 4 + pad 4 + next 8 + key 8 + value 8 = 48 byte
Node phình từ 32 lên 48 byte — tăng 50% (chính là mặt kia của "tiết kiệm 33%" nêu ở §1: cùng một chênh lệch 16 byte, chỉ khác lấy 48 hay 32 làm mẫu số). Quy ra sức chứa (dùng GiB nhất quán với -Xmx…g và trần 2^35):
- Heap 31 GiB compressed: nếu chỉ đựng node,
31 GiB / 32 B≈ 1.04 tỷ node. - Heap 33 GiB uncompressed:
33 GiB / 48 B≈ 738 triệu node — ít hơn dù heap to hơn 2 GiB.
Cùng một node, chỉ khác chế độ con trỏ — thanh 32 byte dài đúng hai phần ba thanh 48 byte; heap to hơn lại đựng ít hơn.
Cùng cơ chế xảy ra với String (Java 9+ compact): 24 byte compressed lên 32 byte uncompressed — tăng 33%, chưa kể byte[] đi kèm. App cache 100 triệu String: 2.4 GB so với 3.2 GB chỉ riêng phần object.
flowchart TD
A["Chon -Xmx cho app"] --> B{"Heap duoi ~32 GB?"}
B -->|"Co"| C["Compressed OOP BAT<br/>ref 4 byte, object nho"]
B -->|"Khong"| D["Compressed OOP TAT<br/>ref 8 byte, object phinh 30-50%"]
D --> E{"Co that su can rat nhieu heap?"}
E -->|"Chua toi 64 GB"| F["32GB cliff: worst of both worlds<br/>giam ve 30-31 GB"]
E -->|"Rat lon"| G["Nhay thang 64 GB+<br/>hoac dung ZGC"]Nếu app cần dưới 31 GB, giữ heap ở 30-31 GB để hưởng compressed OOP. Nếu thật sự cần hơn, nhảy thẳng lên 64 GB trở lên để throughput bù lại pointer overhead. Vùng 33-50 GB là tệ nhất: compressed OOP đã tắt làm object phình, nhưng heap chưa đủ lớn để đáng. ZGC dùng colored pointer — nhét metadata vào các bit cao của con trỏ 64-bit thay vì nén xuống 32 bit — nên không phụ thuộc trần 2^35 và không dính 32GB cliff; lựa chọn tốt khi cần heap rất lớn (xem bài 12).
6. Compressed Class Pointer — một cờ riêng
Klass pointer trong header có cờ riêng biệt với compressed OOP: -XX:+UseCompressedClassPointers. Khi bật (mặc định khi heap nhỏ), klass pointer chỉ 4 byte, và JVM dồn mọi Klass struct vào một vùng riêng — Compressed Class Space (mặc định 1 GB, chỉnh bằng -XX:CompressedClassSpaceSize).
Từ JDK 15 hai cờ được tách độc lập (trước đó UseCompressedClassPointers buộc phải đi kèm UseCompressedOops). Ví dụ điển hình: ZGC dùng colored pointer cho reference (không nén OOP) nhưng vẫn nén klass pointer để mỗi object nhỏ đi 4 byte. Tuy vậy trên collector mặc định (G1), khi heap vượt 32 GB thì compressed OOP tắt thường kéo class pointer tắt theo, nên cả reference lẫn klass pointer đều phình.
Cơ chế & con số:
- JVM Anatomy Quark #23: Compressed References — Shipilëv giải thích shift-by-3 và trần
2^35= 32 GiB tận gốc; đọc để tự xác thực mọi con số ở bài này. - JVM Anatomy Quark #24: Object Alignment — vì sao tăng
ObjectAlignmentInBytesnới trần lên 64 GiB nhưng tốn padding.
Lưu ý version (Rule 2.7): mọi con số layout ở bài này giả định header 12 byte mặc định (mark 8 + klass 4). Từ JDK 24, tùy chọn Compact Object Headers (JEP 450, experimental — bật bằng -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders) nén header còn 8 byte; JDK 25 đưa nó thành product (JEP 519, vẫn opt-in). Khi bật, HashMap.Node và mọi kích thước tính ở §5 co lại tương ứng.
7. Liên hệ các bài khác
- Bài 09 — Object header & field layout: nền của bài này — header 12 byte, klass pointer 4 byte, quy tắc align 8 byte chính là điều kiện để compressed OOP hoạt động.
- Bài 05 — Memory: heap & metaspace: heap là vùng compressed OOP địa chỉ hoá; đặt
-Xmxquanh 32 GB tác động trực tiếp tới footprint. - Bài 12 — GC hiện đại: ZGC dùng colored pointer, không bị 32GB cliff — cách khác để chạy heap rất lớn mà không trả giá pointer.
8. Tóm tắt
- Compressed OOP nén reference và klass pointer 8 byte xuống 4 byte, tiết kiệm khoảng 20-40% heap; mặc định bật khi heap dưới 32 GB.
- Cơ chế: object align 8 byte nên 3 bit thấp của địa chỉ luôn 0 — bỏ 3 bit đó, dịch phải khi nén, dịch trái khi giải nén.
- Trần 32 GB =
2^32giá trị nhân 8 byte =2^35byte. Tăng alignment lên 16 nới lên 64 GB nhưng tốn padding, hiếm dùng. - 32GB cliff: vượt ngưỡng, compressed OOP tắt, object phình 30-50%; heap 31 GB compressed chứa nhiều object hơn 33 GB uncompressed.
- Chọn heap size: cần dưới 31 GB giữ 30-31 GB; cần hơn nhảy thẳng 64 GB, tránh vùng 33-50 GB.
- Compressed class pointer là cờ riêng, dùng Compressed Class Space (mặc định 1 GB), thường cùng trạng thái với compressed OOP.
9. Tự kiểm tra
Q1Compressed OOP hoạt động bằng cơ chế gì, và vì sao giới hạn đúng ở 32 GB?▸
Mọi object HotSpot align 8 byte, nên địa chỉ thực luôn chia hết 8 — 3 bit thấp luôn bằng 0 và không cần lưu. JVM bỏ 3 bit đó khi nén (dịch phải 3) và khôi phục khi dùng (dịch trái 3), gói một reference vào 32 bit.
Giới hạn 32 GB là toán học: 32 bit biểu diễn 2^32 giá trị, mỗi giá trị cách nhau 8 byte, phủ 2^32 × 8 = 2^35 byte = 32 GB. Muốn nới phải tăng alignment (ví dụ 16 byte cho 64 GB) nhưng object tốn thêm padding.
Q2"32GB cliff" là gì, và vì sao heap 31 GB đôi khi chứa được nhiều object hơn heap 33 GB?▸
Khi heap vượt ngưỡng khoảng 32 GB, JVM tắt compressed OOP: mọi reference và klass pointer nhảy từ 4 lên 8 byte, object nhiều reference phình 30-50%. Một HashMap.Node tăng từ 32 lên 48 byte (+50%).
Vì object phình nhanh hơn phần heap tăng thêm: heap 31 GiB / 32 byte mỗi node ≈ 1.04 tỷ node, còn heap 33 GiB / 48 byte ≈ 738 triệu node. Thêm 2 GiB heap nhưng mất compressed OOP là lỗ ròng.
Q3Một app đang chạy heap 30 GB, hay Full GC. Nâng lên 40 GB có phải cách đúng không? Vì sao?▸
Không — 40 GB rơi đúng vào vùng chết. Compressed OOP tắt làm mọi object phình 30-50%, nên áp lực GC có thể còn tệ hơn, và live set lớn hơn khiến Full GC lâu hơn. Đây chính là kịch bản ở đầu bài.
Hai hướng đúng: (1) nếu working set vừa dưới 31 GB, giữ heap 30-31 GB và giảm allocation rate / tinh chỉnh collector thay vì nâng heap; (2) nếu thật sự cần hơn, nhảy thẳng lên 64 GB trở lên (hoặc dùng ZGC) để lợi ích heap lớn bù pointer overhead.
Q4Compressed class pointer khác gì compressed OOP? Hai cờ này có luôn cùng trạng thái không?▸
Compressed OOP nén các reference field giữa các object trên heap. Compressed class pointer (-XX:+UseCompressedClassPointers) nén riêng klass pointer trong header, và dồn các Klass vào Compressed Class Space (mặc định 1 GB).
Từ JDK 15 hai cờ độc lập (trước đó class-pointer compression buộc đi kèm compressed OOP): ZGC là ví dụ nén klass pointer mà không nén OOP. Nhưng trên collector mặc định, cả hai thường cùng bật/tắt theo heap size, nên khi vượt 32GB cliff cả reference lẫn klass pointer đều phình lên 8 byte.
Q5Vượt 32GB cliff phạt nặng nhất kiểu object nào, và kiểu nào gần như không hề hấn? Vì sao?▸
Nặng nhất: object nhỏ nhưng dày reference — HashMap.Node, node của linked list / cây, object nhiều field tham chiếu. Mỗi field là một con trỏ, tắt nén thì mọi con trỏ nhân đôi 4 lên 8 byte, nên tỉ lệ phình lớn (Node +50%).
Gần như không hề hấn: object lớn, chủ yếu dữ liệu nguyên thủy — mảng int[]/double[] lớn, buffer byte[]. Chỉ header phình thêm 4 byte (klass pointer 4 lên 8), còn payload nguyên thủy không đổi, nên phần trăm phình nhỏ dần khi object càng lớn. Hệ quả: workload cache nhiều object nhỏ dày-pointer chịu 32GB cliff nặng nhất; workload xử lý mảng số lớn gần như miễn nhiễm.
Bài tiếp theo: Escape Analysis và Scalar Replacement — khi JIT xoá object trước khi bạn tạo nó
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