Java Internals & Concurrency/JIT compiler — interpreter, C1, C2 và tiered compilation
53/75
Bài 53 / 75~14 phútJVM InternalsMiễn phí lượt xem

JIT compiler — interpreter, C1, C2 và tiered compilation

JVM chạy bytecode qua interpreter → C1 → C2: counter kích hoạt compile, tiered escalate theo profile, và OSR thay khung ngay giữa vòng lặp đang chạy.

TL;DR: JVM không chạy bytecode một cách cố định. Nó bắt đầu ở interpreter (đọc từng bytecode, chậm nhất), đếm số lần mỗi method chạy bằng invocation counterback-edge counter, rồi khi method "nóng" thì biên dịch sang native code: C1 (compile nhanh, tối ưu vừa, khoảng 200 lần gọi) trước, C2 (compile chậm, tối ưu sâu, khoảng 5000 lần gọi) sau. Đó là tiered compilation — nhiều tier chạy song song, method leo tier theo profile runtime. Với vòng lặp chạy tỷ vòng trong một lần gọi, JVM dùng OSR (On-Stack Replacement) để thay khung interpreter bằng native ngay giữa vòng lặp. Đây là lý do Java "khởi động chậm nhưng chạy nhanh".

Một benchmark kinh điển:

long sum = 0;
for (int i = 0; i < 1_000_000_000; i++) {
    sum += i;
}

Lần đầu chạy trên JVM vừa khởi động (cold): khoảng 3 giây. Chạy lại đúng vòng lặp đó trong cùng JVM (warm): khoảng 0.3 giây. Code y hệt, JVM y hệt — nhanh gấp 10 lần. Thủ phạm là JIT compiler: JVM profile code lúc chạy, phát hiện method nóng, biên dịch bytecode thành native machine code, rồi dùng bản native cho các lần sau. Càng nóng, JVM càng đầu tư compile sâu hơn.

Bài này trace đường đi của một method qua các tier — interpreter, C1, C2 — cùng cơ chế counter kích hoạt compile và OSR cho vòng lặp. Các tối ưu bên trong C2 (inlining, devirtualization) và cách chẩn đoán deoptimization để dành cho hai bài kế tiếp.

1. Analogy — ba chế độ của người đầu bếp

Bạn mở quán ăn. Người đầu bếp có ba chế độ làm việc, và theo dõi tần suất từng món — món lên hot thì leo chế độ cao hơn:

  • Interpreter — đọc công thức từng dòng trong sách rồi làm theo. Đúng nhưng chậm. Phù hợp món hiếm khách gọi, không đáng công ghi nhớ.
  • C1 (compile nhanh) — sau vài lần, đầu bếp ghi quy trình rút gọn ra tờ ghi chú riêng, không cần mở sách. Nhanh hơn khoảng 3 tới 5 lần, nhưng chưa tối ưu kỹ.
  • C2 (tối ưu sâu) — với món bán chạy, đầu bếp cắt sẵn nguyên liệu, biến thành phản xạ cơ bắp, bỏ mọi bước thừa. Nhanh hơn khoảng 10 tới 20 lần; chuẩn bị lâu nhưng gần tối ưu.
Đời thườngJVM
Sách công thứcBytecode
Đọc sách từng dòngInterpreter
Tờ ghi chú rút gọnNative code do C1 sinh
Phản xạ cơ bắpNative code do C2 sinh
Đếm số lần làm mónInvocation counter
Escalate chế độ theo tần suấtTiered compilation
💡 Cách nhớ

Java khởi động chậm vì interpreter, chạy nhanh vì C2. Tiered nghĩa là nhiều tier chạy song song, method leo tier theo profile. Càng nóng, JVM càng đầu tư compile sâu hơn.

2. Vì sao JIT thay vì AOT?

AOT (Ahead-of-Time) biên dịch bytecode sang native ngay lúc build, giống C compile thẳng ra .exe: khởi động nhanh, không warm-up, nhưng thiếu profile runtime nên tối ưu kém hơn ở trạng thái ổn định. JIT (Just-in-Time) biên dịch lúc chạy, có profile thật: tối ưu quyết liệt dựa trên giả định "trường hợp này xảy ra 99%", đổi lại warm-up chậm.

JVM truyền thống chọn JIT vì Java nhắm tới server chạy dài hạn — vài giây warm-up đổi lấy throughput cao hàng giờ là một đổi chác tốt. Với serverless và CLI (nơi startup quan trọng hơn peak), hệ sinh thái Java có GraalVM Native Image đi theo hướng AOT — chuyện đó thuộc bài 04c.

3. Ba tier: interpreter, C1, C2

HotSpot (OpenJDK mainstream) có năm mức tier nhưng thực dụng chỉ cần ba nhóm:

TierTrình biên dịchTốc độ compileChất lượng code
0InterpreterKhông compileChậm nhất
1 tới 3C1 (Client)Nhanh, khoảng 1ms mỗi methodTrung bình, khoảng 3 tới 5 lần interpreter
4C2 (Server)Chậm, khoảng 10 tới 100ms mỗi methodCao nhất, khoảng 10 tới 20 lần interpreter

Tier 1 tới 3 đều là biến thể C1 với mức profile khác nhau (tier 3 sinh thêm profiling data để C2 dùng sau). Thực dụng không cần phân biệt sâu.

3.1 Counter kích hoạt compile

Mỗi method có hai bộ đếm:

  • Invocation counter — đếm số lần method được gọi.
  • Back-edge counter — đếm số lần một vòng lặp trong method quay lại đầu vòng (mỗi lần lặp là một back-edge).

Khi counter vượt ngưỡng, JVM submit method cho compile thread. Ngưỡng mặc định (Java 21, chế độ tiered) do các cờ HotSpot điều khiển:

  • Interpreter lên C1: khoảng 200 lần gọi (Tier3InvocationThreshold).
  • C1 lên C2: khoảng 5000 lần gọi (Tier4InvocationThreshold).
⚠️ Bẫy con số 10.000

Con số 10.000 hay bị nhắc như "ngưỡng JIT" thực ra là CompileThreshold — mặc định của chế độ non-tiered CŨ (bật bằng -XX:-TieredCompilation), không còn là mặc định. Chế độ tiered mặc định từ Java 8 dùng 200 (C1) và 5000 (C2). Đây là kiểu "version myth" điển hình — luôn gắn số với đúng chế độ và version.

Với vòng lặp nóng, back-edge counter tăng rất nhanh: một vòng lặp chạy hàng trăm triệu lần có thể đẩy method lên C2 trong chưa tới một giây.

3.2 Compile chạy trên thread nền

JIT compile là bất đồng bộ: compile thread riêng biệt, không chặn app thread. Trong lúc compile thread sản xuất native code, app thread vẫn tiếp tục chạy bản interpreter. Khi native code xong, JVM tráo callsite từ interpreter sang native — lần chạy tiếp theo dùng bản native.

flowchart LR
    A["Method moi"] -->|"0 lan goi"| B["Interpreter (tier 0)"]
    B -->|"~200 lan goi"| C["C1 compile (tier 3)"]
    C -->|"~5000 lan goi"| D["C2 compile (tier 4)"]
    D -->|"on dinh"| D

    style B fill:#93C5FD
    style C fill:#FCD34D
    style D fill:#6EE7B7

Số compile thread mặc định phụ thuộc số core (tune bằng -XX:CICompilerCount=N). Vì compile không chặn app, warm-up là quá trình mượt: throughput tăng dần khi từng method nóng leo tier.

4. OSR — On-Stack Replacement

Xét một method chỉ được gọi một lần nhưng chứa vòng lặp chạy hàng tỷ vòng:

void compute() {
    for (int i = 0; i < 1_000_000_000; i++) {
        // hot loop
    }
}

Invocation counter chỉ đếm được 1 — không bao giờ chạm ngưỡng để compile theo cách thường. Nếu chỉ dựa vào invocation counter, cả vòng lặp tỷ vòng này sẽ chạy hoàn toàn ở interpreter.

Đây là lúc back-edge counter vào cuộc. Sau vài chục nghìn vòng lặp (ngưỡng back-edge tier 4 mặc định khoảng 40.000 ở Java 21), JVM nhận ra vòng lặp đang nóng. Vướng mắc: method vẫn đang chạy dở — khung của nó nằm trên stack, không thể "gọi lại" để dùng bản native như method thường.

Giải pháp là OSR (On-Stack Replacement): JIT compile riêng phần vòng lặp (điểm vào là back-edge offset trong bytecode), dựng một "OSR adapter" chuyển trạng thái khung interpreter (local variable, operand stack) sang khung native, rồi tại một safepoint ở back-edge tráo khung interpreter thành khung native ngay giữa vòng lặp — vòng lặp chạy tiếp bằng native từ đúng vòng đang dở, giữ nguyên state.

Trong JIT log, một compile OSR trông như:

1234   42 % 4   com.foo.Bar::compute @ 12 (123 bytes)

Ký hiệu % đánh dấu đây là OSR; @ 12 là bytecode offset của back-edge kích hoạt; 4 là tier (C2). (Cách đọc đầy đủ các cột JIT log nằm ở bài 04c.)

📌 OSR không tái dùng cho lần gọi sau

Native code sinh bởi OSR chỉ hợp lệ cho lần chạy vòng lặp hiện tại, và thường kém bản C2 thường vài phần trăm vì phải compile vội với ít profile hơn. Mẹo: nếu vòng lặp critical, chia nó thành các chunk gọi qua một method — invocation counter của method đó tăng, kích hoạt C2 compile bản thường (chất lượng tốt hơn OSR).

5. Sau khi vào C2 — những tối ưu tiếp theo

Khi method đã lên C2, phần lớn tăng tốc đến từ 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) và devirtualization (biến lời gọi ảo thành lời gọi trực tiếp qua inline cache và type profile) — trọng tâm của bài 04b. C2 còn có escape analysis (phân rã object không thoát method thành biến trên stack, bỏ hẳn cấp phát heap), đã được mổ đầy đủ ở bài 10 nên bài này không lặp lại.

6. Pitfall — đo sai vì chưa warm-up

Sai lầm phổ biến nhất khi làm việc với JIT là benchmark khi code còn ở interpreter:

long t1 = System.nanoTime();
hotMethod();                 // lan dau: interpreter, cham ~10x
long t2 = System.nanoTime();
System.out.println((t2 - t1) / 1000 + "us");  // do interpreter time, vo nghia

Kết quả này đo tốc độ interpreter, không phản ánh performance ở trạng thái ổn định. Cách đúng: dùng JMH (Java Microbenchmark Harness) — framework chuẩn của OpenJDK, tự warm-up (chạy nhiều vòng cho JIT leo tier) rồi mới đo, và tự chống dead-code elimination và constant folding.

Hệ quả production: app vừa khởi động có throughput thấp vài chục giây đầu vì mọi thứ còn ở interpreter hoặc C1 — nên pre-warm bằng synthetic traffic trước khi route real traffic vào instance mới. Chi tiết warm-up problem và các chiến lược giảm nó (CDS, AOT, native image) nằm ở bài 04c.

7. Deep Dive

📚 Deep Dive — nguồn tham khảo

Spec / reference chính thức:

Ghi chú: Các ngưỡng trong bài (200, 5000, back-edge 40.000) là mặc định OpenJDK 21 chế độ tiered trong compiler_globals.hpp; đều tune được và có thể khác giữa các version. Con số 10.000 là CompileThreshold non-tiered cũ.

8. Liên hệ các bài khác

9. Tóm tắt

  • JVM chạy bytecode qua các mức: interpreter (chậm nhất, không compile) leo lên C1 (compile nhanh, tối ưu vừa) rồi C2 (compile chậm, tối ưu sâu). Đây là tiered compilation — nhiều tier chạy song song, method nóng leo tier theo profile.
  • Ngưỡng mặc định Java 21: khoảng 200 lần gọi lên C1, 5000 lên C2. Con số 10.000CompileThreshold non-tiered cũ, không phải mặc định tiered. Invocation counter (số lần gọi) và back-edge counter (số vòng lặp) kích hoạt compile.
  • Compile chạy trên thread nền, không chặn app thread — warm-up là quá trình tăng throughput mượt dần.
  • OSR (On-Stack Replacement): compile riêng phần vòng lặp và tráo khung interpreter thành native ngay giữa vòng lặp, nhờ back-edge counter — không đợi method được gọi lại.
  • Đo performance phải ở trạng thái ổn định sau warm-up; dùng JMH thay vì tự đo bằng System.nanoTime().

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Java có "warm-up" mà C++ không? Điều này khiến benchmark dễ sai thế nào?

C++ biên dịch AOT nên chạy exe là chạy native tối ưu ngay — không warm-up. Java biên dịch JIT: JVM khởi động với interpreter, method nóng mới lần lượt lên C1 (khoảng 200 lần gọi) rồi C2 (khoảng 5000), quá trình mất từ vài giây tới vài phút.

Benchmark sai phổ biến là đo một method ngay lần gọi đầu bằng System.nanoTime() — lúc đó code còn ở interpreter (chậm khoảng 10 lần), con số vô nghĩa với trạng thái ổn định. Cách đúng: dùng JMH (tự warm-up rồi mới đo). Hệ quả production: instance vừa start có throughput thấp vài chục giây đầu, nên pre-warm trước khi route real traffic.

Q2
Interpreter, C1, C2 khác nhau ở điểm gì, và vì sao JVM chạy nhiều tier song song thay vì chọn một?

Interpreter thực thi từng bytecode, không sinh native — khởi động tức thì nhưng chậm nhất. C1 compile nhanh, native tối ưu vừa (gấp 3 tới 5 lần). C2 compile chậm nhưng native tối ưu sâu (gấp 10 tới 20 lần).

Chọn một tier là dở: chỉ interpreter thì mãi chậm; compile thẳng C2 cho mọi method thì phí thời gian compile đắt cho method chạy vài lần và startup rất chậm. Tiered dung hoà — mọi method bắt đầu rẻ ở interpreter, nóng thì lên C1 để nhanh sớm, thật nóng mới trả giá C2. Tier 3 còn sinh profiling data cho C2 dùng.

Q3
Ngưỡng 200 và 5000 đến từ đâu, và vì sao con số "10.000" hay bị nhắc lại không phải mặc định?

200 là Tier3InvocationThreshold (interpreter lên C1) và 5000 là Tier4InvocationThreshold (C1 lên C2) — mặc định OpenJDK 21 chế độ tiered, trong compiler_globals.hpp. Cả hai tune được và JVM có thể compile sớm hơn nếu back-edge counter tăng nhanh.

Con số 10.000 là CompileThreshold — mặc định của chế độ non-tiered cũ (-XX:-TieredCompilation), một bước nhảy thẳng interpreter lên C2. Từ Java 8, tiered là mặc định nên 10.000 không còn áp dụng. Nhắc nó như "ngưỡng JIT hiện tại" là version myth — luôn gắn số với đúng chế độ và version.

Q4
OSR khác biên dịch method thông thường thế nào, và khi nào nó được kích hoạt?

Biên dịch thường: JVM compile cả method, lần gọi sau mới dùng bản native. Vấn đề: method chỉ gọi một lần nhưng chứa vòng lặp tỷ vòng thì invocation counter chỉ là 1, không bao giờ chạm ngưỡng — cả vòng lặp chạy ở interpreter.

OSR dùng back-edge counter: sau vài chục nghìn vòng (ngưỡng tier 4 mặc định khoảng 40.000), JIT compile riêng phần vòng lặp với điểm vào là back-edge offset, dựng adapter chuyển state, rồi tại safepoint tráo khung interpreter thành native ngay giữa vòng lặp. Nó được kích hoạt bởi vòng lặp nóng trong method chưa kịp compile thường. Native OSR chỉ hợp lệ cho lần chạy hiện tại và thường kém bản C2 thường vài phần trăm.

Q5
Vì sao việc JIT compile trên thread nền lại quan trọng cho trải nghiệm chạy Java?

Compile sang native tốn thời gian — C2 có thể mất hàng chục tới trăm mili-giây mỗi method. Nếu app thread phải dừng chờ compile, mỗi method nóng được phát hiện sẽ là một khựng rõ rệt và startup giật cục.

HotSpot đẩy compile sang compile thread riêng (tune bằng -XX:CICompilerCount); trong lúc đó app thread vẫn chạy bản tier thấp hiện có, xong thì JVM tráo callsite. Nhờ vậy warm-up là quá trình tăng throughput mượt khi từng method leo tier, thay vì có mốc dừng dài để compile.

Bài tiếp theo: Inlining và devirtualization — hai tối ưu chính của C2

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

Inlining và devirtualization — hai tối ưu chính của C2