Java 27: Compact Object Headers
Tung Bui Thanh (TungDaDev)13 mins read

Header của mỗi object trong HotSpot JVM co lại từ 12 bytes xuống đúng 8 bytes (64 bits). Nghe thì nhỏ, nhưng heap toàn object nhỏ thì cộng lại không nhỏ chút nào: theo JEP 450, dữ liệu sống trên heap thường giảm 10% đến 20%, mà mỗi CPU Cache Line 64-byte cũng nhét được nhiều dữ liệu hơn.
# cái giá ngầm của Object Header trên HotSpot JVM 64-bit
Trên HotSpot JVM 64-bit kiểu cũ, object (instance) nào được cấp phát trên Heap cũng phải mang theo một phần đầu gọi là Object Header. Header này gồm hai phần:
- Mark Word (64 bits / 8 bytes): chứa thông tin trạng thái lúc runtime của object:
- Identity Hash Code (31 bits), chỉ được tính và ghi vào khi có ai đó gọi
hashCode()mặc định hoặcSystem.identityHashCode(). - Tuổi của object trong GC (GC Age, 4 bits, giá trị từ 0 đến 15).
- Trạng thái lock (Lock Bits, 2 bits: Unlocked, Lightweight Locked, Inflated Monitor, Marked for GC).
- Cờ Epoch và metadata của Biased Locking (tính năng này bị deprecate từ Java 15 qua JEP 374 và gỡ hẳn ở Java 18).
- Khoảng 25 bit còn lại không dùng tới. Chỗ trống này chính là lý do Lilliput nhét được class pointer vào Mark Word.
- Identity Hash Code (31 bits), chỉ được tính và ghi vào khi có ai đó gọi
- Klass Word (32 hoặc 64 bits): con trỏ tới cấu trúc
Klasstrong Metaspace, để JVM biết object thuộc kiểu gì và bảng phương thức ảo (vtable) nằm đâu. Bật cờ-XX:+UseCompressedClassPointers(mặc định) thì con trỏ này chiếm 32 bits (4 bytes), tắt đi thì chiếm trọn 64 bits (8 bytes). Cờ này bị deprecate ở Java 25 và thành obsolete ở Java 27: JVM luôn nén class pointer, truyền cờ vào chỉ nhận lại một dòng cảnh báo.
# bộ nhớ bị "đội" lên bao nhiêu
Lấy ví dụ một object bọc số nguyên rất quen là java.lang.Integer:
| Thành phần | Kích thước thực tế | Ghi chú |
|---|---|---|
| Mark Word | 8 bytes | Metadata lúc runtime |
| Klass Word | 4 bytes | Compressed Class Pointer |
Giá trị value (int) | 4 bytes | Phần dữ liệu có ích duy nhất |
| Tổng trước padding | 16 bytes | Vừa khớp bội số 8 bytes |
Một số nguyên 4 bytes mà tốn tới 16 bytes trên Heap. Dữ liệu có ích (Payload Efficiency) chỉ chiếm 25%, 75% còn lại là tiền "quản lý phí" cho JVM.
Riêng Integer thì Compact Headers cũng không cứu được: 8 bytes header + 4 bytes value = 12 bytes, làm tròn lên bội số 8 vẫn là 16 bytes, chỉ có điều 4 bytes thừa giờ là padding. Object nào có tổng header + field rơi qua mốc bội số 8 nhờ 4 bytes tiết kiệm được thì mới thật sự nhỏ đi (ví dụ dưới đây: 32 xuống 24 bytes).
Với mấy cấu trúc như đồ thị, cây nhị phân, danh sách liên kết (LinkedList, HashMap.Node), có cả chục triệu node nhỏ cỡ 24 đến 32 bytes thì 12 bytes header mỗi node đã chiếm một phần đáng kể của Heap. Tỷ lệ cụ thể tuỳ app, cách chắc nhất là đo bằng heap histogram (jcmd <pid> GC.class_histogram) hoặc JOL ở cuối bài.
# Compact Object Headers (Project Lilliput) sắp bit thế nào
Project Lilliput gom hết thông tin của Mark Word lẫn Klass Word vào một word 64-bit duy nhất. Tính năng đi qua ba bước: experimental ở Java 24 (JEP 450), thành tính năng chính thức nhưng vẫn phải tự bật ở Java 25 (JEP 519), và bật mặc định từ Java 27 (JEP 534). Layout 64 bit theo JEP 450, từ bit cao xuống bit thấp:
Cộng lại: 22 + 31 + 4 + 4 + 1 + 2 = 64. Hash code vẫn giữ đủ 31 bit như cũ, tuổi GC và lock tag cũng vậy. Thứ bị "ép cân" duy nhất là class pointer, từ 32 bit xuống 22 bit.
# 1. class pointer nén còn 22 bit
Header mới vẫn lưu một compressed class pointer như trước, chỉ là ngắn hơn. Compressed class pointer bản chất là một offset tính từ đầu vùng Compressed Class Space trong Metaspace, chứ không phải địa chỉ đầy đủ. Lilliput đổi cách mã hoá (encoding) của offset này để nó gói gọn trong 22 bit:
- 22 bit đủ đánh số cho
2^22 ≈ 4,194,304class. JEP 450 nói thẳng đây là giới hạn mới: tối đa khoảng 4 triệu class. Nhớ là con số này tính cả class sinh lúc runtime (lambda, proxy, class do framework generate), nhưng vẫn dư sức với gần như mọi ứng dụng. - Giải mã vẫn chỉ là vài phép dịch bit và cộng với địa chỉ gốc, nên khi gọi phương thức ảo hay kiểm tra
instanceof, chi phí lấy raKlassgần như không đổi so với trước. Khác chút là JVM phải tách 22 bit này ra khỏi mark word thay vì đọc nguyên một word riêng.
# 2. lock và GC đụng vào header thì sao
Bài toán khó thật sự không nằm ở chuyện đủ bit, mà ở chỗ trước đây có mấy cơ chế ghi đè cả mark word:
- Stack locking kiểu cũ: lock nhẹ thì chép mark word ra stack của thread rồi thay header bằng con trỏ tới chỗ đó (displaced header). Với Compact Headers, ghi đè như vậy là mất luôn class pointer, mà class pointer thì được đọc liên tục. Vì vậy Compact Headers đòi lightweight locking (có từ Java 21, mặc định từ Java 23): lock chỉ lật 2 bit tag từ
01sang00, phần còn lại của header giữ nguyên. Nếu JVM được cấu hình dùng stack locking kiểu cũ thì Compact Headers bị tắt. - Monitor (lock nặng, Inflated Monitor): thông tin chi tiết nằm ở cấu trúc
ObjectMonitorngoài Heap. Cách cũ là nhét con trỏ tới monitor vào header, nên muốn lấy class pointer phải đi vòng qua monitor. Các bản JDK mới dùng Object Monitor Table, một bảng băm ánh xạ object sang monitor nằm ngoài header (cờUseObjectMonitorTable, bật sẵn ở Java 27), nên header chỉ đổi 2 bit tag. - GC dời object (forwarding): khi copy object sang chỗ mới, GC thường ghi địa chỉ mới đè lên header. Với Compact Headers, ZGC dùng bảng forwarding riêng nên không đụng header. G1, Parallel, Serial ghi forwarding pointer dạng nén vào header và cất lại header gốc. Lúc dời thất bại (evacuation failure), object "tự trỏ về chính nó" được đánh dấu bằng bit self-forwarded thay vì ghi đè header.
# ảnh hưởng tới CPU Cache Line và phần cứng
Lợi ích của Compact Object Headers không chỉ là đỡ tốn RAM. Cái lời lớn hơn nằm ở CPU Cache Line, chỗ này mới thấy hiệu năng tăng rõ.
CPU bây giờ (x86-64 lẫn ARM64) nạp dữ liệu từ RAM lên cache L1/L2/L3 theo từng khối cố định 64 bytes (Cache Line):
- Dữ liệu dày hơn (Data Density): lấy một object có 2 field
long(16 bytes payload):- Layout cũ: 12 bytes header, thêm 4 bytes trống để field
longcăn đúng bội số 8, rồi 16 bytes payload = 32 bytes. Mỗi Cache Line 64-byte chứa vừa đúng 2 object. - Với Compact Headers: 8 bytes header + 16 bytes payload = 24 bytes. Mỗi Cache Line chứa được khoảng 2,7 object, tức cùng lượng cache chứa được nhiều hơn 1/3 số object. Thực tế object không căn thẳng hàng với cache line nên con số này là trung bình.
- Layout cũ: 12 bytes header, thêm 4 bytes trống để field
- Ít CPU Cache Miss hơn: khi duyệt qua nhiều object nhỏ nằm sát nhau trên Heap (ví dụ các phần tử mà một
ArrayListtrỏ tới, nếu chúng được cấp phát liền nhau), mỗi Cache Line chứa được nhiều object hơn thì CPU ít phải chạy ra DRAM lấy dữ liệu, đỡ bị kẹt CPU Stall Cycles. - GC nhẹ gánh hơn: Heap nhỏ hơn thì G1, ZGC hay Shenandoah có ít byte hơn phải quét và copy (evacuation), và heap đầy chậm hơn nên số lần GC giảm. Số liệu trong JEP 519/534: với một cấu hình SPECjbb2015, heap dùng ít hơn 22% và CPU ít hơn 8%; số lần GC giảm 15% với cả G1 lẫn Parallel; một benchmark JSON parser chạy song song nhanh hơn 10%. Đó là benchmark, app của bạn có thể khác. JEP 450 đặt mục tiêu chi phí phụ (throughput, latency) không quá 5% trong trường hợp xấu.
# cờ cấu hình HotSpot JVM và vài lưu ý khi triển khai
Từ Java 27 thì gần như không phải làm gì, nhưng nên biết các cờ theo từng phiên bản.
# cờ bật tính năng
# Java 27 trở đi: bật sẵn, không cần cờ nào.
# Muốn quay về layout cũ (ví dụ để so sánh, hoặc khi gặp vấn đề):
-XX:-UseCompactObjectHeaders
# Java 25, 26: đã là tính năng chính thức nhưng phải tự bật
-XX:+UseCompactObjectHeaders
# Java 24: còn experimental
-XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders
# Kiểm tra JVM đang chạy có bật không
java -XX:+PrintFlagsFinal -version | grep UseCompactObjectHeadersNhiều hướng dẫn cũ khuyên thêm -XX:+UseCompressedOops -XX:+UseCompressedClassPointers cho chắc. Không cần: Compact Headers không phụ thuộc UseCompressedOops (cờ này nén các reference trong field, vốn bật mặc định khi heap dưới khoảng 32 GB), còn UseCompressedClassPointers ở Java 27 đã obsolete, JVM in ra Ignoring option UseCompressedClassPointers; support was removed in 27.0. JEP 534 cũng nói layout header cũ sẽ bị deprecate trong một bản sau, nên đừng xây gì phụ thuộc vào nó.
# chạy chung với các Garbage Collector
- G1, Parallel, Serial: chạy được, forwarding pointer được nén vào header như đã nói ở trên. Theo JEP 450, cách làm này kéo theo giới hạn heap khoảng 8 TB; heap lớn hơn mà không dùng ZGC thì Compact Headers tự tắt.
- ZGC (Generational): dùng bảng forwarding riêng nên không đụng header. Colored Pointers và Load Barrier của ZGC nằm ở reference chứ không ở header, nên hai thứ chạy chung bình thường. Muốn hiểu thêm ZGC thì xem bài về Generational ZGC.
- JEP 450 chỉ nhắm x64 và AArch64; trên nền tảng 32-bit hoặc khi bật JVMCI thì tính năng không được kích hoạt.
# đo thử bằng Java Object Layout (JOL)
Bạn có thể dùng thư viện org.openjdk.jol:jol-core để xem tận mắt kích thước header của class trong ứng dụng:
import org.openjdk.jol.info.ClassLayout;
public class HeaderBenchmark {
static class UserSession {
private long userId; // 8 bytes
private int status; // 4 bytes
private boolean active; // 1 byte
}
public static void main(String[] args) {
System.out.println(ClassLayout.parseClass(UserSession.class).toPrintable());
}
}Nhìn kết quả in ra là thấy khác liền: OFFSET của field đầu tiên bắt đầu ngay ở byte thứ 8, chứ không phải byte 12 hay 16 như layout cũ. Chạy thử với jol-core 0.17 trên openjdk version "27" (JOL in thêm vài dòng cảnh báo về sun.misc.Unsafe, bỏ qua được):
Mac dinh (Compact Headers bat):
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 8 long UserSession.userId N/A
16 4 int UserSession.status N/A
20 1 boolean UserSession.active N/A
21 3 (object alignment gap)
Instance size: 24 bytes
-XX:-UseCompactObjectHeaders:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 (object header: class) N/A
12 4 int UserSession.status N/A
16 8 long UserSession.userId N/A
24 1 boolean UserSession.active N/A
25 7 (object alignment gap)
Instance size: 32 bytesCùng một class mà từ 32 xuống 24 bytes, giảm 25%. Để ý layout cũ JVM đã khéo nhét field int vào 4 bytes trống sau header, nhưng vẫn không thoát được mốc 32.
# bảng so sánh tổng kết
| Tiêu chí kỹ thuật | Java 8 – 26 (Mặc định) | Java 27 (Compact Object Headers) |
|---|---|---|
| Kích thước Object Header | 12 bytes (với Compressed Class Pointers) hoặc 16 bytes | Đúng 8 bytes (64 bits) |
| Kích thước tối thiểu của một Object | 16 bytes (do padding bội số 8) | 8 bytes (new Object() chỉ còn mỗi header) |
| Cách xác định Klass | Compressed class pointer 32 bit (hoặc con trỏ 64 bit) | Compressed class pointer 22 bit, tối đa ~4 triệu class |
| Hiệu suất CPU Cache L1/L2 | Cache miss nhiều khi duyệt nhiều object nhỏ | Nhiều object hơn trên mỗi Cache Line, tuỳ kích thước object |
| Mức tiết kiệm Heap | Mốc so sánh (Baseline) | Theo JEP 450: dữ liệu sống thường giảm 10% – 20% |
| Phải sửa code ứng dụng? | Không | 0 dòng code, ứng dụng không cần biết gì |
Nói ngắn gọn, Compact Object Headers là một thay đổi ở tầng nền của HotSpot JVM: app không phải sửa gì mà thường gọn RAM hơn, đỡ việc cho GC, nhất là khi chạy trên cloud với cả đống microservice. Lên Java 27 rồi thì việc nên làm là so heap usage và GC log trước/sau (có thể tắt bằng -XX:-UseCompactObjectHeaders để so trên cùng phiên bản), và kiểm tra các thư viện hay agent tự đọc layout object (dùng Unsafe để tính offset, tự ước lượng kích thước object) xem đã hỗ trợ layout mới chưa.
Đọc thêm: JEP 450, JEP 519, JEP 534.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.