Java Internals & Concurrency/Inlining và devirtualization — hai tối ưu chính của C2
54/75
Bài 54 / 75~13 phútJVM InternalsMiễn phí lượt xem

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ếpJVM
Chép công thức bếp phụ vào phiếuInline: dán thân callee vào caller
Gộp, rút gọn bước thừa sau khi chépTối ưu xuyên biên (constant fold, hoisting)
Luôn cùng một bếp → nhớ, đưa thẳngMonomorphic → gọi trực tiếp
Mỗi lần một bếp khác → tra sổ mỗi lầnMegamorphic → 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
MaxInlineSize35 bytecodeMethod thường dưới ngưỡng này là ứng viên inline
FreqInlineSize325 bytecodeMethod nóng được inline tới ngưỡng lớn hơn này
MaxInlineLevel15Độ sâu lồng nhau tối đa của chuỗi inline
InlineSmallCodekhoảng 2000 byteChỉ 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.

💡 final, static, private dễ inline hơn

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.

💡 Thử đ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ẳng Circle.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ới Circle — 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ỏ (TypeProfileWidth mặ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.

💡 Tự tìm lời giải

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:

  1. 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
  1. 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.
  2. 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-catch bao quanh cả vòng lặp nóng có thể cản một số tối ưu; đưa try ra 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

📚 Deep Dive — nguồn tham khảo

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

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, TypeProfileWidth 2) 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

Tự kiểm tra
Q1
Vì 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.

Q2
Vì 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.

Q3
Callsite 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().

Q4
Vì 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.

Q5
Mộ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.

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:

  1. 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.
  2. Tách collection: giữ dogs, cats, birds riê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

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

Deoptimization, đọc JIT log và giảm warm-up JVM HotSpot