Inlining và devirtualization — hai tối ưu chính của C2
C2 dán thân method vào caller (inlining) và biến lời gọi ảo thành trực tiếp qua inline cache. Vì sao callsite monomorphic nhanh còn megamorphic chậm hơn nhiều lần.
TL;DR: Sau khi một method lên C2 (bài 04), phần lớn tăng tốc đến từ hai tối ưu. Inlining dán thân callee vào caller, bỏ overhead lời gọi và mở ra tối ưu xuyên biên (constant folding, loop hoisting). C2 inline theo budget bytecode: method dưới MaxInlineSize (mặc định 35 bytecode) là ứng viên; method nóng inline tới FreqInlineSize (mặc định 325); độ sâu lồng nhau giới hạn bởi MaxInlineLevel (mặc định 15). Devirtualization biến lời gọi ảo (invokevirtual/invokeinterface) thành lời gọi trực tiếp bằng cách quan sát type profile tại mỗi callsite: nếu callsite chỉ thấy một kiểu (monomorphic) thì gần như miễn phí. Thấy hai kiểu (bimorphic) vẫn nhanh. Thấy nhiều hơn (megamorphic) thì rơi về vtable lookup mỗi lần gọi, chậm hơn đáng kể (thường vài lần, tuỳ hot path).
Cùng một vòng lặp gọi method, chỉ khác kiểu object đi qua, có thể chênh nhau vài lần về tốc độ:
for (Order order : orders) order.calculate(); // orders toan OrderImpl
for (Animal animal : zoo) animal.sound(); // Dog, Cat, Bird, ... xen ke
Vòng đầu chạy nhanh vì order.calculate() luôn gọi cùng một kiểu cụ thể — C2 inline thẳng. Vòng sau chậm vì animal.sound() gặp nhiều kiểu xen kẽ, C2 không inline được và phải tra bảng method mỗi lần. Bài này giải thích vì sao: cơ chế inlining, và cách C2 dùng type profile để devirtualize.
1. Inlining — tối ưu nền
Một hình dung trước khi vào cơ chế: coi C2 như đầu bếp làm theo công thức. Inlining: thay vì mỗi lần cần nước sốt lại gọi bếp phụ (một lời gọi method, có độ trễ chờ), đầu bếp chép thẳng công thức vào phiếu mình — mọi bước trước mắt nên còn gộp, rút gọn được (tối ưu xuyên biên). Devirtualization (§2) giải câu "món này ai nấu?": luôn cùng một bếp (monomorphic) thì nhớ và đưa thẳng; mỗi lần một bếp khác (megamorphic) thì tra sổ mỗi lần — chậm.
| Nhà bếp | JVM |
|---|---|
| Chép công thức bếp phụ vào phiếu | Inline: dán thân callee vào caller |
| Gộp, rút gọn bước thừa sau khi chép | Tối ưu xuyên biên (constant fold, hoisting) |
| Luôn cùng một bếp → nhớ, đưa thẳng | Monomorphic → gọi trực tiếp |
| Mỗi lần một bếp khác → tra sổ mỗi lần | Megamorphic → tra vtable mỗi lần |
Inline nghĩa là dán thân method được gọi (callee) thẳng vào chỗ gọi (caller), xoá bỏ lời gọi:
int square(int x) { return x * x; }
int sumOfSquares(int n) {
int sum = 0;
for (int i = 0; i < n; i++) sum += square(i); // goi method
return sum;
}
Sau khi C2 inline square:
int sumOfSquares(int n) {
int sum = 0;
for (int i = 0; i < n; i++) sum += i * i; // than square dan thang vao
return sum;
}
Lợi ích không chỉ là bỏ chi phí push/pop frame (cỡ vài nano-giây mỗi lời gọi — trong vòng lặp một tỷ vòng cũng thành vài giây). Quan trọng hơn, inline mở biên tối ưu: sau khi thân callee nằm trong caller, C2 mới có thể constant-fold (square(2) thành 4), dead-code elimination, loop-invariant hoisting, register allocation xuyên qua ranh giới cũ. Đây là lý do inlining được gọi là "tối ưu nền" — hầu hết tối ưu khác chỉ phát huy sau khi đã inline.
1.1 Budget — inline tới đâu thì dừng?
C2 không inline vô hạn (nếu không, một caller sẽ phình to vô tận). Các cờ HotSpot (mặc định OpenJDK 21, x86) giới hạn:
| Cờ | Mặc định | Ý nghĩa |
|---|---|---|
MaxInlineSize | 35 bytecode | Method thường dưới ngưỡng này là ứng viên inline |
FreqInlineSize | 325 bytecode | Method nóng được inline tới ngưỡng lớn hơn này |
MaxInlineLevel | 15 | Độ sâu lồng nhau tối đa của chuỗi inline |
InlineSmallCode | khoảng 2000 byte | Chỉ inline method đã compile nếu native code của nó nhỏ hơn ngưỡng |
Cơ chế: method nhỏ (dưới 35 bytecode) gần như luôn được inline; method lớn hơn chỉ được inline khi callsite đủ nóng và vẫn dưới 325 bytecode. Method quá lớn (một "god method" 500 dòng) vượt mọi ngưỡng nên không được inline — caller gọi nó phải trả full overhead lời gọi. Đây là lý do sư phạm để tách method nhỏ, focused: JVM inline nhiều method nhỏ tốt hơn hẳn một method to.
Các method final, static, private có đúng một đích gọi xác định tại compile time, nên C2 inline thẳng không cần devirtualize. Method virtual (có thể bị override) phải qua bước devirtualization ở §2 trước.
2. Devirtualization và inline cache
Lời gọi invokevirtual (method có thể bị override) hoặc invokeinterface — hai trong các lệnh gọi đã gặp ở bài 03 — về lý thuyết cần tra vtable/itable (bảng con trỏ method của mỗi class / mỗi interface, nằm trong metadata lớp) lúc runtime để biết gọi implementation nào — một bước gián tiếp cản inline. Devirtualization là cách C2 loại bước đó bằng cách quan sát type profile: trong lúc method chạy ở tier 3 (C1 có profiling), JVM ghi lại những kiểu cụ thể mà mỗi callsite thực sự gặp.
Trước khi đọc tiếp, hãy tự dự đoán.
Một callsite shape.area() trong vòng lặp nóng, qua profile chỉ thấy đúng một kiểu cụ thể là Circle. Nếu bạn là C2, bạn sẽ sinh native code thế nào cho callsite này để lời gọi ảo chạy nhanh gần bằng một lời gọi trực tiếp? Viết ra ý tưởng của bạn trước khi đọc.
C2 xử lý theo số kiểu mà callsite đã thấy:
- Monomorphic (1 kiểu) — C2 speculate "luôn là
Circle", inline thẳngCircle.area(), và chèn một type guard: so sánh klass pointer của object (con trỏ trong header mỗi object trỏ tới metadata lớp — xem bài 09) vớiCircle— chỉ vài chỉ thị máy (load klass + so sánh + nhánh). Guard đúng thì chạy bản inline; guard sai thì deoptimize về interpreter để học kiểu mới. Chi phí gần như bằng lời gọi trực tiếp. - Bimorphic (2 kiểu) — C2 dựng inline cache hai nhánh:
if klass == A -> A.method(); else if klass == B -> B.method(). Vẫn nhanh, chỉ thêm một nhánh so sánh. - Megamorphic (nhiều hơn 2 kiểu) — type profile chỉ ghi được một số kiểu nhỏ (
TypeProfileWidthmặc định 2). Vượt quá, C2 bỏ cuộc: sinh code tra vtable/itable mỗi lần gọi. Không inline, không tối ưu xuyên biên.
flowchart LR
A["Callsite goi virtual"] --> B["1 kieu: monomorphic<br/>inline + type guard"]
A --> C["2 kieu: bimorphic<br/>inline cache 2 nhanh"]
A --> D["3+ kieu: megamorphic<br/>vtable lookup moi lan"]
style B fill:#6EE7B7
style C fill:#FCD34D
style D fill:#FCA5A5// native code C2 sinh cho callsite monomorphic (pseudo):
if (shape.getClass() != Circle.class) goto deopt;
// ... than Circle.area() da inline
Type guard chỉ là một phép so sánh klass pointer. Nếu sai thì deoptimize, quay lại interpreter, học kiểu mới — cơ chế deopt này là chủ đề của bài 04c.
2.1 Xem quyết định inline
Cờ -XX:+PrintInlining in ra vì sao mỗi callsite được inline hay không:
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyApp
@ 5 java.util.ArrayList::size (5 bytes) inline (hot)
@ 12 com.foo.Bar::validate (8 bytes) inline (hot)
@ 28 com.foo.Bar::log (95 bytes) too big
@ 45 com.foo.Bar::handleCase (250 bytes) callee is too large
Dòng too big cho thấy method vượt budget — tín hiệu để tách nhỏ hoặc, hiếm khi, tune -XX:MaxInlineSize.
3. Vì sao callsite ổn định về kiểu lại nhanh hơn?
Quay lại hai vòng lặp ở đầu bài: order.calculate() (orders toàn một kiểu — monomorphic) inline thẳng nên nhanh; animal.sound() (Dog, Cat, Bird xen kẽ — megamorphic) chậm hơn đáng kể — thường vài lần, tuỳ hot path — vì animal.sound() phải tra vtable mỗi lần gọi, đồng thời mất mọi tối ưu xuyên biên mà inline lẽ ra mở ra. Trong vòng lặp trăm triệu phần tử, đó là chênh lệch đo được bằng phần giây tới giây.
Giờ đến lượt bạn tự tìm cách sửa.
Bạn phải giữ nguyên hành vi (vẫn gọi sound() cho mọi con vật) nhưng muốn mỗi callsite chỉ thấy một kiểu để C2 devirtualize được. Hãy viết ra ít nhất một cách tổ chức lại vòng lặp trước khi đọc tiếp.
Ba hướng thường dùng, mỗi hướng làm callsite trở lại monomorphic:
- Group theo kiểu rồi lặp từng nhóm — trong vòng lặp con, callsite chỉ thấy một kiểu:
groupBy(zoo, Animal::getClass).forEach((cls, list) ->
list.forEach(Animal::sound)); // callsite trong day monomorphic
- Tách collection riêng cho mỗi kiểu (
dogs,cats,birds) — mỗi vòng lặp cố định một kiểu. - Sort theo kiểu trước khi lặp, nếu thứ tự không quan trọng — tạo các "vệt" cùng kiểu liên tiếp để inline cache trúng lâu hơn.
Không phải mọi polymorphic call đều cần sửa — chỉ những callsite nằm trong hot path và profiler (JFR) cho thấy vtable_call chiếm thời gian mới đáng. Ngoài hot path, code polymorphic sạch sẽ và dễ đọc quan trọng hơn.
4. Pitfall — method to và exception cản inline
- God method không inline được: method vượt
FreqInlineSize(325 bytecode) không bao giờ được inline, nên mọi tối ưu xuyên biên đều mất. Tách thành nhiều method nhỏ focused. - Catch trong hot loop: đặt
try-catchbao quanh cả vòng lặp nóng có thể cản một số tối ưu; đưatryra ngoài vòng lặp hoặc validate trước thay vì bắt exception. - Stream/lambda dài trong hot path: chuỗi stream dùng nhiều virtual call (lambda là interface), C2 khó inline toàn bộ chuỗi. Nếu profile cho thấy đây là bottleneck, viết lại bằng vòng lặp tường minh với kiểu cụ thể. Ngoài hot path thì stream vẫn tốt.
5. Deep Dive
- HotSpot Performance Techniques — inlining, devirtualization, và các tối ưu C2 khác.
- JEP 165: Compiler Control — điều khiển quyết định inline theo từng method.
- JITWatch — trực quan hoá
LogCompilation, xem cây inline và inline cache mỗi callsite.
Ghi chú: Các budget (MaxInlineSize 35, FreqInlineSize 325, MaxInlineLevel 15, InlineSmallCode khoảng 2000, TypeProfileWidth 2) là mặc định OpenJDK 21 x86 trong c2_globals.hpp; một số phụ thuộc kiến trúc CPU và đổi giữa các version — tra source đúng bản JDK thay vì nhớ vẹt.
6. Liên hệ các bài khác
- Bài 04 — Interpreter, C1, C2, tiered: method phải lên C2 thì inlining và devirtualization mới hoạt động đầy đủ.
- Bài 04c — Deoptimization và JIT log: khi type guard sai (kiểu mới xuất hiện), JIT deopt thế nào và cách đọc log.
- Bài 10 — Escape analysis: escape analysis chỉ hoạt động sau khi inline — virtual call cản inline cũng cản luôn escape analysis.
7. Tóm tắt
- Inlining dán thân callee vào caller: bỏ overhead lời gọi và mở biên cho constant fold, dead-code elimination, loop hoisting. Là tối ưu nền cho các tối ưu khác.
- Budget inline (OpenJDK 21): dưới 35 bytecode (
MaxInlineSize) là ứng viên; method nóng tới 325 (FreqInlineSize); độ sâu lồng nhau tối đa 15 (MaxInlineLevel). - Devirtualization dùng type profile mỗi callsite: monomorphic (1 kiểu) inline trực tiếp kèm type guard gần như miễn phí; bimorphic (2 kiểu) qua inline cache hai nhánh vẫn nhanh.
- Megamorphic (nhiều hơn 2 kiểu,
TypeProfileWidth2) rơi về vtable lookup mỗi lần gọi — chậm hơn đáng kể (thường vài lần, vì mất cả inline lẫn tối ưu xuyên biên). - Thiết kế hot path: giữ callsite ổn định về kiểu (group/tách/sort theo kiểu); method nhỏ focused để inline tốt.
8. Tự kiểm tra
Q1Vì sao inlining được gọi là "tối ưu nền", không chỉ là bỏ chi phí lời gọi?▸
Bỏ chi phí push/pop frame (cỡ vài nano-giây mỗi lời gọi) chỉ là lợi ích trực tiếp. Quan trọng hơn: sau khi thân callee nằm trong caller, C2 mới "nhìn thấy" toàn bộ code trong một context và áp được các tối ưu xuyên biên — constant folding (square(2) thành 4), dead-code elimination, loop-invariant hoisting, register allocation qua ranh giới cũ.
Hầu hết tối ưu mạnh của C2 chỉ phát huy sau khi đã inline. Đó là lý do "tối ưu nền": nó không đứng một mình mà mở đường cho những tối ưu khác. Hệ quả thiết kế: tách method nhỏ focused thì JVM inline tốt, còn god method vượt budget không inline được nên chặn cả chuỗi tối ưu.
Q2Vì sao C2 giới hạn inline theo kích thước bytecode thay vì inline vô hạn, và hệ quả thiết kế với "god method" là gì?▸
C2 giới hạn inline theo kích thước bytecode để caller không phình vô hạn (mặc định OpenJDK 21): method dưới MaxInlineSize (35 bytecode) gần như luôn là ứng viên; method nóng được inline tới FreqInlineSize (325 bytecode); chuỗi inline lồng nhau giới hạn bởi MaxInlineLevel (15); và method đã compile chỉ inline nếu native code của nó nhỏ hơn InlineSmallCode (khoảng 2000 byte).
Nghĩa là method nhỏ, focused được inline dễ; god method vượt 325 bytecode không bao giờ inline. Đây là lý do thực dụng để giữ method nhỏ. Các số này tune được và một số phụ thuộc kiến trúc CPU — nên tra c2_globals.hpp của đúng version thay vì nhớ vẹt.
Q3Callsite shape.area() trong hot loop chỉ thấy đúng một kiểu Circle. C2 sinh code thế nào, và vì sao gần như miễn phí?▸
shape.area() trong hot loop chỉ thấy đúng một kiểu Circle. C2 sinh code thế nào, và vì sao gần như miễn phí?Đây là callsite monomorphic. C2 speculate "luôn là Circle", inline thẳng thân Circle.area() vào caller, và chèn một type guard: so sánh klass pointer của shape với Circle — chỉ vài chỉ thị máy (load klass, so sánh, nhánh).
Guard đúng (trường hợp phổ biến) thì chạy thân đã inline, gần như không khác lời gọi trực tiếp. Guard sai (xuất hiện kiểu khác) thì deoptimize về interpreter để học kiểu mới. Vì guard chỉ một phép so sánh và trường hợp thường luôn trúng, chi phí devirtualize gần như bằng không — đồng thời việc inline lại mở ra tối ưu xuyên biên cho area().
Q4Vì sao callsite megamorphic chậm hơn monomorphic đáng kể?▸
Type profile mỗi callsite chỉ ghi được một số kiểu nhỏ (TypeProfileWidth mặc định 2). Callsite thấy nhiều hơn 2 kiểu trở thành megamorphic: C2 không thể speculate một đích cụ thể nên bỏ inline, sinh code tra vtable/itable mỗi lần gọi.
Mỗi lần gọi phải đọc klass pointer, tra bảng, nhảy gián tiếp tới đúng implementation — vừa tốn vài nano-giây, vừa mất mọi tối ưu xuyên biên mà inline lẽ ra mở ra. So với monomorphic (một guard rồi chạy code đã inline), tổng chi phí cao hơn đáng kể — thường vài lần, và mức chính xác phụ thuộc hot path chứ không phải một hệ số cố định. Trong vòng lặp trăm triệu phần tử, đó là chênh lệch đo được bằng phần giây tới giây.
Q5Một hot loop for (Animal a : zoo) a.sound(); với zoo trộn nhiều kiểu đang chậm. Nêu hai cách sửa và giải thích vì sao chúng giúp.▸
for (Animal a : zoo) a.sound(); với zoo trộn nhiều kiểu đang chậm. Nêu hai cách sửa và giải thích vì sao chúng giúp.Vấn đề: callsite a.sound() megamorphic vì thấy nhiều kiểu xen kẽ, phải tra vtable mỗi lần. Mọi cách sửa đều nhằm làm mỗi callsite chỉ thấy một kiểu:
- Group theo kiểu:
groupBy(zoo, Animal::getClass)rồi lặp từng nhóm — trong vòng lặp con, mọi phần tử cùng một class nên callsite monomorphic, C2 inline được. - Tách collection: giữ
dogs,cats,birdsriêng và lặp từng list — mỗi vòng lặp cố định một kiểu.
Cách thứ ba là sort theo kiểu trước khi lặp (nếu thứ tự không quan trọng) để tạo các vệt cùng kiểu liên tiếp. Lưu ý: chỉ sửa khi callsite nằm trong hot path và profiler xác nhận là bottleneck — ngoài hot path thì code polymorphic dễ đọc quan trọng hơn.
Bài tiếp theo: Deoptimization, JIT log và warm-up
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