Java chạy thế nào: từ .java tới JIT

4 câu hỏi~20 phútTừ khóa: javac, bytecode, JVM, JDK, JRE, interpreter, JIT, C1, C2, tiered compilation, warm-up, AOT, GraalVM native image, CDS

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

Thành phầnGồm gìAi cần
JVMClass loader, interpreter, JIT, GC, runtimeThứ thật sự chạy bytecode
JREJVM + 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)
JDKJRE + công cụ: javac, jar, jcmd, jstack, jfr, jlinkDev, 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

InterpreterJIT (C1/C2)AOT: GraalVM native image
Khi nào dịchKhông dịchLúc runtime, method nóngLúc build
StartupNhanhCần warm-upRất nhanh (ms)
Peak throughputThấpCao 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ì

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:

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


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).