Java chạy thế nào: từ .java tới JIT
Bắt đầu chương JVM nhé. order-service của mình mỗi lần deploy xong thì vài phút đầu API chậm hẳn, sau đó mới nhanh lại. Vì sao vậy?
Câu hỏi rất hay để mở chương JVM. Hiện tượng "chậm lúc đầu, nhanh dần" gọi là warm-up, và nó đến từ cách JVM chạy code: thông dịch trước, biên dịch sau. Cả chương này ta dùng một project: order-service (Spring Boot 3, Java 21) chạy production, lúc thì chậm, lúc thì OOM. Bài này bắt đầu từ gốc: code Java chạy ra sao.
1. Bài toán: deploy xong, p99 tăng vọt 3 phút
Deploy 10:00
10:00–10:03 GET /orders/{id} p99 = 850 ms
10:05 trở đi GET /orders/{id} p99 = 45 ms
(cùng code, cùng dữ liệu, cùng traffic)
Không có bug, không có query chậm. Chỉ là JVM chưa "nóng".
2. Vấn đề: CPU không hiểu Java
CPU chỉ chạy mã máy (x86, ARM). File OrderController.java phải đi qua nhiều bước mới thành mã máy, và bước cuối cùng xảy ra lúc runtime, trong chính lúc order-service đang phục vụ request.
3. Giải pháp của Java: bytecode + JVM
- javac dịch
.java→.classchứa bytecode: tập lệnh của một máy ảo, không phụ thuộc CPU/OS. - JVM load bytecode, kiểm tra, rồi thực thi. Mỗi OS có JVM riêng → "write once, run anywhere".
- JVM thực thi bằng 2 cách: interpreter (đọc từng lệnh bytecode, chạy ngay, chậm) và JIT compiler (dịch method "nóng" sang mã máy, rất nhanh).
| Thành phần | Gồm gì | Ai cần |
|---|---|---|
| JVM | Class loader, interpreter, JIT, GC, runtime | Thứ thật sự chạy bytecode |
| JRE | JVM + thư viện chuẩn (java.lang, java.util...) | Máy chỉ chạy app (khái niệm cũ, từ Java 11 không còn bản JRE riêng chính thức) |
| JDK | JRE + công cụ: javac, jar, jcmd, jstack, jfr, jlink | Dev, CI build |
Ngày nay image production thường dùng một runtime nhỏ (ví dụ eclipse-temurin:21-jre hoặc runtime tự tạo bằng jlink, xem Docker bài 16.2). Lưu ý: image JRE không có jcmd/jstack, bài 2.6 sẽ cần đến.
4. Từng bước
Bước 1: biên dịch và xem bytecode
// PriceCalculator.java
public class PriceCalculator {
public long total(long price, int qty) {
return price * qty;
}
}
$ javac PriceCalculator.java
$ javap -c PriceCalculator
public long total(long, int);
Code:
0: lload_1 // đẩy price lên operand stack
1: iload_3 // đẩy qty
2: i2l // int → long
3: lmul // nhân
4: lreturn
Bytecode là lệnh cho một máy stack, chưa phải mã máy.
Bước 2: xem JIT làm việc
$ java -XX:+PrintCompilation -jar order-service.jar | grep PriceCalculator
412 1 3 PriceCalculator::total (5 bytes) ← C1, tier 3
980 2 4 PriceCalculator::total (5 bytes) ← C2, tier 4
981 1 3 PriceCalculator::total made not entrant ← bỏ bản C1
Bước 3: đo warm-up bằng load test
$ for i in $(seq 1 2000); do curl -s -o /dev/null -w "%{time_total}\n" localhost:8080/orders/42; done \
| awk 'NR%200==0'
0.412
0.081
0.019
0.008 ← đã được JIT
5. Cách hoạt động: tiered compilation
OrderController.java
│ javac (lúc build)
▼
OrderController.class (bytecode) ──► order-service.jar
│ java -jar (lúc chạy)
▼
┌──────────────────── JVM ─────────────────────────┐
│ Class loader → Verify → Interpreter (tier 0) │
│ │ method gọi nhiều lần │
│ ▼ (đếm invocation, │
│ C1 compiler (tier 1-3) loop) │
│ nhanh, tối ưu nhẹ, thu profile│
│ │ vẫn "nóng" │
│ ▼ │
│ C2 compiler (tier 4) │
│ inline, escape analysis, │
│ loop unrolling → mã máy tốt │
│ │ giả định sai? │
│ └─► deoptimize → về │
│ interpreter │
│ Code cache: lưu mã máy đã compile │
└──────────────────────────────────────────────────┘
Vì sao request đầu chậm? Cộng dồn: class loading lần đầu (Jackson, Hibernate, proxy Spring), chạy bằng interpreter, CPU bận compile C1/C2, connection pool / cache chưa đầy.
C2 tối ưu dựa trên profile (ví dụ "chỗ này luôn là ArrayList"). Nếu sau đó giả định sai, JVM deoptimize: bỏ mã máy, quay về interpreter, compile lại.
6. So sánh cách chạy
| Interpreter | JIT (C1/C2) | AOT: GraalVM native image | |
|---|---|---|---|
| Khi nào dịch | Không dịch | Lúc runtime, method nóng | Lúc build |
| Startup | Nhanh | Cần warm-up | Rất nhanh (ms) |
| Peak throughput | Thấp | Cao nhất (tối ưu theo profile thật) | Tốt, thường thấp hơn JIT một chút |
| RAM | - | Lớn hơn (JIT, metadata) | Nhỏ |
| Hạn chế | - | - | Reflection/proxy động phải khai báo; build lâu; "closed world" |
7. Khi nào dùng gì
- Service chạy lâu (order-service trên K8s): JIT bình thường. Giảm đau warm-up bằng readiness probe + warm-up request, CDS/AppCDS để load class nhanh hơn (Spring Boot 3.3+ hỗ trợ), hoặc CRaC.
- Serverless, CLI, scale-to-zero: GraalVM native image (
./mvnw -Pnative native:compile) đáng cân nhắc. - Không nên tắt JIT hay ép
-Xcomp: chậm hơn.
Order, reference... mỗi thứ sống ở vùng nhớ nào của JVM?Vậy readiness probe liên quan gì tới warm-up? Mình tưởng probe chỉ kiểm tra app sống hay chết.
Bạn hiểu gần đúng, chỉ cần tách hai loại probe. Liveness hỏi "còn sống không", readiness hỏi "đã sẵn sàng nhận traffic chưa" (chi tiết ở Kubernetes bài 17.2).
Mẹo: cho order-service tự gọi các endpoint chính vài trăm lần trước khi báo ready.
@Component
@RequiredArgsConstructor
class WarmUp implements ApplicationListener<ApplicationReadyEvent> {
private final OrderService orderService;
private final ApplicationEventPublisher publisher;
@Override
public void onApplicationEvent(ApplicationReadyEvent e) {
AvailabilityChangeEvent.publish(publisher, this, ReadinessState.REFUSING_TRAFFIC);
for (int i = 0; i < 500; i++) {
orderService.previewPrice(SampleData.order()); // không ghi DB
}
AvailabilityChangeEvent.publish(publisher, this, ReadinessState.ACCEPTING_TRAFFIC);
}
}
Pod start ──► warm-up (JIT C1/C2 chạy) ──► ready ──► Service gửi traffic
(user chưa thấy gì) (đã nóng)
Chú ý: warm-up chỉ gọi code đọc/tính toán, đừng tạo order giả.
Vậy chuyển hết sang GraalVM native image cho khỏi warm-up luôn được không?
Được, nhưng không miễn phí. Với order-service chạy 24/7 thì lợi ích không lớn:
- Native image phân tích "closed world" lúc build: reflection, dynamic proxy, resource phải biết trước. Spring Boot 3 có AOT processing lo phần lớn, nhưng thư viện lạ có thể vỡ lúc runtime.
- Build lâu (vài phút) và tốn RAM build.
- Không có JIT profile-guided nên peak throughput thường thấp hơn (trừ khi dùng PGO của Oracle GraalVM).
- Tool chẩn đoán khác: không có heap dump kiểu HotSpot đầy đủ như bài 2.5–2.6.
Rule: startup/RAM quan trọng (function, scale-to-zero) → native. Throughput lâu dài, cần tool chẩn đoán → JVM + JIT.
Phỏng vấn hỏi JVM chạy code Java thế nào thì trả lời sao?
Q1. JDK, JRE, JVM khác nhau thế nào?
🇻🇳 Tiếng Việt:
JVM là máy ảo chạy bytecode: gồm class loader, interpreter, JIT, GC. JRE là JVM cộng thư viện chuẩn, đủ để chạy app. JDK là JRE cộng công cụ phát triển như javac, jar, jcmd, jstack. Build cần JDK; production chỉ cần runtime, nhưng nếu muốn chẩn đoán thì nên có jcmd.
🇬🇧 English:
The JVM is the virtual machine that executes bytecode: class loader, interpreter, JIT and GC. The JRE is the JVM plus the standard library, enough to run an application. The JDK is the JRE plus development tools like javac, jar, jcmd and jstack. You need the JDK to build; production only needs a runtime, although having jcmd helps for diagnostics.
Q2. JIT là gì, vì sao Java app cần warm-up?
🇻🇳 Tiếng Việt:
Ban đầu JVM thông dịch bytecode. Method nào được gọi nhiều thì JIT compile sang mã máy: C1 compile nhanh và thu profile, C2 tối ưu mạnh dựa trên profile đó, gọi là tiered compilation. Vì vậy vài phút đầu sau deploy app chậm: class mới load, code còn thông dịch, CPU bận compile. Em xử lý bằng warm-up trước khi readiness báo ready, hoặc dùng CDS.
🇬🇧 English:
The JVM starts by interpreting bytecode. Hot methods are JIT-compiled to machine code: C1 compiles quickly and collects profiling data, then C2 applies aggressive optimizations based on that profile; this is tiered compilation. That is why the first minutes after a deploy are slow: classes are loading, code is interpreted and the CPU is busy compiling. I mitigate it with a warm-up phase before the readiness probe passes, or with CDS.
Q3. JIT và AOT (GraalVM native image) khác nhau thế nào?
🇻🇳 Tiếng Việt:
JIT compile lúc runtime dựa trên profile thật nên peak performance cao nhưng cần warm-up và tốn RAM. AOT compile toàn bộ lúc build thành binary native: startup tính bằng mili giây, RAM nhỏ, nhưng reflection phải khai báo trước, build lâu, peak throughput thường thấp hơn. Em chọn native cho serverless, JIT cho service chạy lâu.
🇬🇧 English:
JIT compiles at runtime using real profiling data, so peak performance is high but it needs warm-up and more memory. AOT compiles everything at build time into a native binary: startup in milliseconds and a small footprint, but reflection must be declared upfront, builds are slow, and peak throughput is usually lower. I choose native for serverless and JIT for long-running services.
Cách nhớ ngắn gọn
- .java → javac → bytecode → JVM (interpreter → C1 → C2) → mã máy.
- JDK ⊃ JRE ⊃ JVM.
- Warm-up = class loading + interpreter + đang compile.
- Deoptimize khi giả định của C2 sai.
- Native image: startup nhanh, closed world; JIT: peak cao, cần nóng máy.
Tự kiểm tra
Bytecode chạy được trên CPU không?
Không. JVM thông dịch hoặc JIT nó thành mã máy của CPU hiện tại.
C1 và C2 khác nhau gì?
C1 compile nhanh, tối ưu nhẹ, thu profile. C2 compile chậm hơn, tối ưu mạnh (inline, escape analysis) dựa trên profile.
Deoptimization là gì?
Giả định C2 dùng để tối ưu bị sai, JVM bỏ mã máy, quay về interpreter rồi compile lại.
Image chỉ có JRE thì thiếu gì khi debug production?
Công cụ JDK như jcmd, jstack, jmap.
Hai cách giảm warm-up cho order-service trên K8s?
Warm-up request trước khi readiness pass; CDS/AppCDS (hoặc CRaC, native image nếu phù hợp).