Deoptimization, đọc JIT log và giảm warm-up JVM HotSpot
Khi giả định của JIT sai, JVM deoptimize về interpreter. Đọc -XX:+PrintCompilation để thấy deopt, và các chiến lược giảm warm-up: CDS, AOT, native image.
TL;DR: JIT compile dựa trên giả định rút từ profile — "callsite này luôn là ArrayList", "nhánh if này chưa bao giờ vào". Giả định sai lúc runtime thì JVM phải deoptimize: huỷ native code, quay method về interpreter cho tới khi recompile. Deopt lẻ tẻ là bình thường; deopt liên tục trên method nóng (thrashing) khiến performance không bao giờ ổn định. Quan sát qua -XX:+PrintCompilation: cột made not entrant báo một bản bị vô hiệu hoá (deopt là một nguyên nhân, tier-up là nguyên nhân kia), còn JFR jdk.Deoptimization mới xác nhận deopt thật. Cuối cùng, vì mọi thứ bắt đầu ở interpreter nên Java có warm-up problem — giảm bằng CDS, AOT class loading (JEP 483), hoặc GraalVM Native Image.
Một service redeploy plugin classes định kỳ mà không restart JVM. Mỗi lần một class con mới được nạp, nó có thể override method mà C2 đã inline dựa trên giả định "chỉ có một implementation". JVM buộc phải huỷ native code đó và biên dịch lại. Kết quả: throughput cứ vài phút lại tụt xuống mức interpreter rồi bò lên, không bao giờ giữ được peak. Đây là deopt thrashing — và JIT log là công cụ để nhìn thấy nó.
Nhưng "chậm sau deploy" còn một thủ phạm nữa dễ nhầm với nó: warm-up của một JVM vừa khởi động lại, đang bò từ interpreter lên C2. Hai nguyên nhân đòi hai cách xử lý khác hẳn — bài này dạy dùng JIT log để phân biệt chúng và chọn đúng hướng.
1. Deoptimization — khi JIT undo
Hình dung C2 như một tài xế quen đường: sáng nào ngã tư cũng rẽ phải, nên anh ta bỏ luôn thói quen nhìn biển báo để đi nhanh hơn. Hôm đường đổi chiều, anh phải phanh gấp và quay lại đọc biển như người mới — chậm hẳn một đoạn. Deoptimization chính là cú phanh gấp đó của JVM: bỏ bản tối ưu vừa cược sai, quay về interpreter đọc từng bước.
| Tài xế quen đường | JIT / JVM |
|---|---|
| Bỏ nhìn biển vì "sáng nào cũng rẽ phải" | C2 bỏ nhánh/kiểu mà profile cho là không xảy ra |
| Đường bỗng đổi chiều | runtime gặp kiểu hoặc nhánh ngoài giả định |
| Phanh gấp, quay lại đọc biển | deopt: huỷ native code, chạy lại ở interpreter |
| Đi thêm vài lần thì quen đường mới | recompile khi method vẫn còn nóng |
Cụ thể hơn: nếu profile nói callsite luôn nhận ArrayList, C2 inline ArrayList kèm type guard; nếu profile nói một nhánh if chưa bao giờ chạy, C2 compile như thể nhánh đó không tồn tại và đặt một uncommon trap ở đó — một chốt JVM cài tại nhánh bị bỏ, luồng nào chạm tới thì nó kích hoạt deopt. Cược sai thì phải trả — deoptimization là cơ chế undo.
Các trigger chính:
- Type guard sai: callsite speculate
ArrayListnay gặpLinkedList— bản inline không còn đúng. - Uncommon trap dính: nhánh profile cho là "không bao giờ vào" nay được vào — chạm trap, deopt để xử lý nhánh chưa compile.
- Class con mới nạp: một subclass vừa load override method đã được inline — JVM phải invalidate native code cũ (chính là kịch bản ở hook).
- Assertion nội bộ JVM: giả định về object layout hay lock state không còn đúng.
1.1 Vì sao deopt đắt
- Khung native đang chạy phải được dựng lại thành khung interpreter — tái tạo local variable và operand stack từ register/stack native. Tốn vài micro-giây.
- Native code đã compile bị vứt bỏ.
- Method chạy lại ở interpreter — chậm hơn hàng chục lần; nếu vẫn nóng, được recompile sau vài nghìn invocation nữa.
Deopt lẻ tẻ (vài lần mỗi giờ) là hoàn toàn bình thường — đó là cái giá của tối ưu speculative. Nhưng thrashing — deopt liên tục vì kiểu hoặc nhánh đổi mỗi vài giây — khiến JIT recompile không ngừng, performance không bao giờ đạt steady state.
flowchart LR
A["C2 native code<br/>chay nhanh"] -->|"gia dinh sai"| B["Deoptimize<br/>ve interpreter"]
B -->|"van nong, recompile"| A
B -.->|"lap lai lien tuc"| C["Deopt thrashing<br/>khong bao gio on dinh"]
style A fill:#6EE7B7
style B fill:#FCD34D
style C fill:#FCA5A52. Đọc JIT log
Cách trực tiếp nhất để thấy compile và deopt là -XX:+PrintCompilation:
java -XX:+PrintCompilation MyApp
123 1 b 4 java.lang.String::hashCode (49 bytes)
201 3 3 com.foo.Bar::process (12 bytes)
312 4 4 com.foo.Bar::process (12 bytes)
456 4 4 com.foo.Bar::process (12 bytes) made not entrant
457 5 4 com.foo.Bar::process (12 bytes)
Trước khi đọc lời giải thích, hãy tự luận.
Nhìn bốn dòng cuối: cùng method com.foo.Bar::process xuất hiện ở tier 3 rồi tier 4, sau đó có dòng ghi made not entrant, rồi lại xuất hiện. Theo bạn, chuyện gì đã xảy ra với method này theo thời gian? Viết ra diễn giải của bạn trước khi đọc tiếp.
Các cột: timestamp (ms từ khi JVM start), compile ID, các cờ, tier (3 = C1, 4 = C2), rồi method và kích thước bytecode. Ý nghĩa các cờ:
| Cờ | Nghĩa |
|---|---|
b | blocking compile |
n | native method |
s | synchronized |
! | có exception handler |
% | OSR (On-Stack Replacement) |
made not entrant | nmethod bị vô hiệu hoá, không nhận lời gọi mới — deopt là một nguyên nhân, nhưng tier-up (C2 thay C1) cũng in dòng này |
made zombie | (chỉ JDK ≤ 19) code cũ được dọn sau khi hết frame dùng — state zombie đã gỡ ở JDK 20 |
Đọc ví dụ: process compile C1 (tier 3) lúc 201ms, lên C2 (tier 4, compile ID 4) lúc 312ms; tới 456ms chính bản C2 ID 4 bị made not entrant vì một giả định sai, rồi recompile tier 4 (ID mới) lúc 457ms. Nếu chu kỳ "tier 4 → made not entrant → tier 4" lặp đều đặn, đó là dấu hiệu thrashing cần điều tra.
Một lưu ý quan trọng: made not entrant không phải lúc nào cũng là deopt. Khi bản C2 (tier 4) cài đè lên bản C1 (tier 3) của cùng method, bản C1 cũ cũng bị made not entrant — đó là chuyển tier bình thường, lành mạnh. Muốn biết chắc một deopt thật đã xảy ra (giả định sai, không phải tier-up), đối chiếu với JFR event jdk.Deoptimization (mục 2.1) — nó chỉ bắn khi thực sự deopt, kèm lý do.
2.1 Log chi tiết và JFR
-XX:+LogCompilation(cần-XX:+UnlockDiagnosticVMOptions) xuất một file XML mô tả từng quyết định compile, inline, deopt. Phân tích bằng JITWatch — UI cho xem method nào compile tier nào, deopt khi nào, vì sao.- JFR (Java Flight Recorder) có event
jdk.Compilation(mỗi lần compile) vàjdk.Deoptimization(mỗi deopt kèm lý do). JFR overhead dưới 1%, an toàn để bật always-on ở production — nên đây là cách chẩn đoán deopt production được ưu tiên, thay choPrintCompilationchỉ hợp cho dev.
3. Vì sao Java có warm-up, và giảm nó thế nào?
Vì mọi method bắt đầu ở interpreter và chỉ leo tier khi đủ nóng (bài 04), một app vừa khởi động chạy chậm trong vài chục giây tới vài phút đầu — đó là warm-up problem. Với server chạy 24/7 thì không sao (warm-up một lần, hưởng peak hàng giờ). Nhưng với serverless, CLI, hay pod scale-out liên tục, cold start là chi phí thật.
Một function serverless chậm 3 giây mỗi lần lạnh rồi mới nhanh, và nó KHÔNG redeploy giữa chừng. Đây là deopt hay warm-up — và bạn dựa vào dấu hiệu nào trong JIT log để kết luận? Nếu chỉ được đổi cách khởi động JVM (không sửa code), bạn thử hướng nào trước? Viết ra trước khi đọc tiếp.
Ba chiến lược giảm warm-up:
CDS (Class Data Sharing). Nền tảng là AppCDS (JEP 310, JDK 10): pre-load metadata của class vào một archive (.jsa) rồi map thẳng vào memory mỗi lần JVM start, bỏ qua phần lớn loading và verification (làm sẵn lúc dump) — phần linking còn lại (preparation/resolution) lúc chạy vẫn làm, và đó mới là thứ JEP 483 phía dưới cache thêm. Cách tạo archive tiện nhất là một bước qua Dynamic CDS (JEP 350, JDK 13):
java -XX:ArchiveClassesAtExit=app.jsa -jar myapp.jar # tao archive (dynamic CDS)
java -XX:SharedArchiveFile=app.jsa -jar myapp.jar # chay voi archive
Rút startup khoảng 10 tới 30% cho CLI và serverless. Spring Boot 3.3+ tích hợp tạo archive tự động.
AOT Class Loading & Linking — JEP 483 (Java 24). Thuộc Project Leyden: cache trạng thái đã load và link của class ra đĩa; lần start sau đọc cache là class sẵn sàng ngay. Lưu ý: JEP 483 không cache native code đã JIT — caching JIT code là một JEP Leyden khác, muộn hơn. Cache gắn với version JVM và classpath cụ thể, phải tạo lại khi đổi.
GraalVM Native Image. Compile toàn bộ app thành native binary lúc build, không cần JVM runtime — startup dưới 100ms, hợp serverless và CLI. Đánh đổi: peak thấp hơn HotSpot khoảng 10 tới 20% (không có profile runtime để tối ưu speculative), reflection và dynamic class loading phải khai báo cấu hình trước, build lâu vài phút.
HotSpot JIT đổi cold-start chậm lấy peak cao; Native Image đổi peak thấp hơn lấy startup tức thì. Dùng HotSpot cho server long-running, Native Image cho serverless/CLI nơi cold start là điểm chết. Project Leyden hướng tới gộp cả hai.
4. Pitfall
- Deopt thrashing ẩn sau throughput lởm chởm: nếu p99 latency dao động mà GC log sạch, kiểm JFR
jdk.Deoptimization— hot method deopt nhiều lần mỗi giây là dấu hiệu type/nhánh không ổn định (xem bài 04b về callsite ổn định kiểu). - Hot reload / plugin sau warm-up: nạp class con mới override method đã inline sẽ invalidate native code — tránh reload class trong hot path của app long-running.
- Đo performance khi chưa warm-up: benchmark cold cho số vô nghĩa; dùng JMH tự warm-up (nhắc lại từ bài 04).
5. Deep Dive
- JEP 310: Application Class-Data Sharing — AppCDS (JDK 10), nền tảng CDS cho app.
- JEP 350: Dynamic CDS Archives — JDK 13, thêm
-XX:ArchiveClassesAtExitđể tạo archive một bước. - JEP 483: Ahead-of-Time Class Loading & Linking — Java 24, cache trạng thái load+link (không phải native code JIT).
- JEP 295: Ahead-of-Time Compilation — AOT cũ (
jaotc), đã gỡ ở Java 17 (JEP 410), thay bằng GraalVM. - GraalVM Native Image — AOT toàn app.
- JITWatch — trực quan hoá
LogCompilation: tier, deopt, inline decision.
Ghi chú: PrintCompilation hợp cho dev đọc nhanh; production nên dùng JFR (jdk.Compilation, jdk.Deoptimization) vì overhead dưới 1% và có lý do deopt cụ thể. Cột made not entrant báo một nmethod bị vô hiệu hoá — deopt là một nguyên nhân, nhưng C2 thay C1 (tier-up) cũng in dòng này; xác nhận deopt thật bằng JFR jdk.Deoptimization. Còn made zombie chỉ có ở JDK ≤ 19 (state zombie đã gỡ ở JDK 20 khi bỏ sweeper), không phải sự kiện performance.
6. Liên hệ các bài khác
- Bài 04b — Inlining và devirtualization: type guard và speculative inline là nguồn gốc phần lớn deopt; callsite ổn định kiểu giảm deopt.
- Bài 04 — Interpreter, C1, C2, tiered: warm-up problem bắt nguồn từ việc mọi method khởi đầu ở interpreter.
- Bài 11 — Safepoint và Stop-The-World: deopt xảy ra tại safepoint — cùng cơ chế điểm dừng an toàn với GC.
7. Tóm tắt
- Deoptimization: JVM huỷ native code JIT và quay về interpreter khi giả định compile-time sai (type guard sai, uncommon trap, class con mới override method đã inline).
- Đắt vì phải dựng lại khung interpreter từ khung native, vứt native code, chạy chậm tới khi recompile. Lẻ tẻ thì bình thường; thrashing trên method nóng là lỗi performance.
- Đọc deopt qua JIT log:
-XX:+PrintCompilation(cộtmade not entrant= deopt),-XX:+LogCompilation+ JITWatch cho chi tiết, JFRjdk.Deoptimizationcho production (overhead dưới 1%). - Warm-up problem: mọi code bắt đầu ở interpreter nên app mới start chậm. Giảm bằng CDS (JEP 310), AOT class loading (JEP 483, Java 24), hoặc GraalVM Native Image (AOT toàn app, peak thấp hơn 10 tới 20%).
8. Tự kiểm tra
Q1Một callsite từng ổn định kiểu ArrayList nay bắt đầu nhận LinkedList. JVM làm gì với native code đã tối ưu, và ba tình huống nào khác cũng buộc nó làm vậy?▸
ArrayList nay bắt đầu nhận LinkedList. JVM làm gì với native code đã tối ưu, và ba tình huống nào khác cũng buộc nó làm vậy?Deoptimization là việc JVM huỷ native code mà JIT đã sinh và quay method về chạy ở interpreter, vì một giả định lúc compile không còn đúng. C2 tối ưu speculative dựa trên profile, nên khi thực tế lệch profile thì phải undo.
Ba trường hợp thường gặp: (1) type guard sai — callsite speculate luôn một kiểu (ví dụ ArrayList) nay gặp kiểu khác; (2) uncommon trap dính — một nhánh profile cho là không bao giờ vào nay được vào, chạm trap đặt sẵn; (3) class con mới nạp override method đã được inline, buộc invalidate native code cũ. Ngoài ra còn các assertion nội bộ JVM về layout/lock state.
Q2Vì sao deopt lẻ tẻ là bình thường nhưng deopt thrashing lại là lỗi performance?▸
Deopt là cái giá tất yếu của tối ưu speculative: để đạt peak, C2 phải đặt cược vào profile, và thỉnh thoảng cược sai. Một vài deopt mỗi giờ chỉ tốn vài micro-giây mỗi lần rồi method recompile và tiếp tục nhanh — không đáng lo.
Thrashing khác hẳn: nếu kiểu hoặc nhánh đổi mỗi vài giây, method liên tục deopt rồi recompile rồi lại deopt. Mỗi vòng gồm dựng lại khung interpreter, vứt native code, chạy chậm chờ recompile — cộng dồn khiến method không bao giờ giữ được steady state, throughput lởm chởm. Nguyên nhân thường là callsite không ổn định về kiểu; sửa bằng thiết kế callsite ổn định (bài 04b).
Q3Log hiện method X made not entrant ngay sau khi nó vừa lên tier 4. Có chắc đây là deopt không, và xác nhận bằng cách nào? (made zombie nếu gặp trên JVM cũ khác gì?)▸
made not entrant ngay sau khi nó vừa lên tier 4. Có chắc đây là deopt không, và xác nhận bằng cách nào? (made zombie nếu gặp trên JVM cũ khác gì?)made not entrant chỉ nghĩa là bản native code đó bị vô hiệu hoá — không nhận lời gọi mới. Nó có hai nguyên nhân: (1) deopt vì một giả định sai, hoặc (2) tier-up bình thường — khi bản C2 (tier 4) cài đè, bản C1 (tier 3) cũ cũng bị made not entrant. Vì tình huống ở đây là "ngay sau khi lên tier 4", rất có thể đó chỉ là bản C1 bị thay, hoàn toàn lành mạnh. Muốn biết chắc một deopt thật đã xảy ra, đối chiếu JFR event jdk.Deoptimization — nó chỉ bắn khi thực sự deopt, kèm lý do.
made zombie là chuyện của JVM cũ: state zombie (và sweeper sinh ra nó) đã bị gỡ ở JDK 20, nên trên JDK 20+ bạn không còn thấy dòng này. Ở JDK ≤ 19 nó chỉ là bước thu hồi bộ nhớ code cache sau khi không còn frame nào dùng bản cũ — không phản ánh vấn đề performance.
Q4Vì sao ở production nên chẩn đoán deopt bằng JFR thay vì -XX:+PrintCompilation?▸
-XX:+PrintCompilation?PrintCompilation in một dòng ra console cho mỗi sự kiện compile — hợp để dev đọc nhanh trong lúc phát triển, nhưng ở production nó ồn (hàng nghìn dòng), khó lọc, và không cho biết lý do deopt cụ thể.
JFR có event jdk.Deoptimization ghi từng deopt kèm reason (type check, uncommon trap, ...) và jdk.Compilation cho compile, với overhead dưới 1% nên bật always-on được. Dữ liệu vào file .jfr, phân tích trong JDK Mission Control — lọc theo method, đếm tần suất deopt, phát hiện thrashing. Đó là lý do JFR là công cụ chẩn đoán deopt production được ưu tiên.
Q5Một app serverless cold-start mất vài giây rồi mới đạt tốc độ đỉnh. Vì sao Java có độ trễ đầu này, và ba chiến lược giảm nó đánh đổi ra sao?▸
Warm-up problem: mọi method bắt đầu ở interpreter và chỉ leo C1/C2 khi đủ nóng, nên app vừa start chạy chậm vài chục giây tới vài phút — chi phí thật với serverless/CLI (server 24/7 thì warm-up một lần rồi thôi).
Đánh đổi: CDS (JEP 310) và AOT class loading (JEP 483) giữ nguyên HotSpot JIT nên vẫn đạt peak cao — chỉ cache sẵn class metadata / trạng thái load+link để rút startup (10 tới 30%), rẻ và ít rủi ro. GraalVM Native Image bỏ hẳn JIT, AOT toàn app cho startup dưới 100ms nhưng trả giá bằng peak thấp hơn HotSpot 10 tới 20% và phải khai báo reflection trước. Chọn theo app sống lâu (HotSpot) hay chết nhanh (Native Image).
Bài tiếp theo: Memory layout — heap, metaspace, stack, object header
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