kiến trúc JVM
Tung Bui Thanh (TungDaDev)11 mins read

Dân Java ai cũng thuộc câu "Write Once, Run Anywhere" (WORA). Nhưng mấy ai thật sự biết bên dưới hệ điều hành có gì để câu đó thành sự thật?
Tài liệu nhập môn hay coi JVM như cái "hộp đen", có nhiệm vụ chạy file .jar là xong.
Nhưng đã làm Software Engineer lâu dài thì hiểu JVM bên trong chạy thế nào là nền móng để:
- Bắt bệnh rò rỉ bộ nhớ (Memory Leak, Metaspace OOM).
- Đỡ tốn chu kỳ CPU bằng cách viết code sao cho JIT dễ làm Escape Analysis và Loop Unrolling.
- Hiểu vì sao biến cục bộ (nằm trên stack riêng của từng thread) thì an toàn, còn object trên Heap dùng chung mới là chỗ sinh ra race condition.
Bài này đi từ lúc file .java được biên dịch ra bytecode, qua khâu nạp class (ClassLoader), phân bổ vào các vùng nhớ Runtime Data Areas, cho tới khi HotSpot JIT biến nó thành lệnh máy thật (Native Machine Instructions).
# "write once, run anywhere" dựa vào đâu: Bytecode & file .class
Java chạy được trên mọi phần cứng là nhờ tách bạch hai bộ đặc tả độc lập:
- Java Language Specification (JLS): quy định cú pháp và ngữ nghĩa của ngôn ngữ Java.
- Java Virtual Machine Specification (JVMS): quy định định dạng nhị phân của file
.classvà tập lệnh của máy ảo (Bytecode). Ngôn ngữ nào (Kotlin, Scala, Groovy, Clojure) chỉ cần biên dịch ra đúng định dạng bytecode theo JVMS là chạy ngon trên mọi JVM.
# mổ file nhị phân 0xcafebabe
File .class hợp lệ nào cũng mở đầu bằng 4 byte "thần chú" (Magic Number) cố định: 0xCAFEBABE. JVM nhìn 4 byte này là biết ngay file có phải class Java chuẩn không, khỏi mất công phân tích tiếp:
Cấu trúc một file .class theo JVMS:
ClassFile {
u4 magic; // Luôn là 0xCAFEBABE
u2 minor_version; // Phiên bản JDK phụ
u2 major_version; // Phiên bản JDK chính (Java 21 = 65, Java 25 = 69)
u2 constant_pool_count;
cp_info constant_pool[]; // Bảng hằng số (Chứa chuỗi, tên hàm, class references)
u2 access_flags; // public, final, abstract...
u2 this_class;
u2 super_class;
// ... interfaces, fields, methods, attributes
}# nạp class (ClassLoader subsystem) & parent delegation model
ClassLoader lo việc tìm file .class trên đĩa hoặc qua mạng, nạp vào bộ nhớ và tạo ra object java.lang.Class.
Việc nạp class đi theo đúng Parent Delegation Model (hỏi cha trước):
# sao parent delegation model lại quan trọng cho bảo mật?
Không có cơ chế này thì kẻ xấu có thể tự viết một class độc hại tên java.lang.String, nhét vào ứng dụng và thay luôn hành vi của String trong cả hệ thống.
Nhờ Parent Delegation Model, khi ứng dụng xin nạp java.lang.String, yêu cầu được đẩy thẳng lên Bootstrap ClassLoader. Bootstrap ClassLoader thì lúc nào cũng nạp class String chính chủ từ module lõi của JDK, nên trò giả mạo class (Class Spoofing) không có cửa. JVM còn chốt thêm một lớp nữa: ClassLoader nào không phải Bootstrap mà định nghĩa class trong package java.* là bị ném SecurityException ("Prohibited package name").
Cũng cần biết delegation chỉ là quy ước của ClassLoader.loadClass, không phải luật cứng. Tomcat (mỗi webapp nạp class của mình trước) hay OSGi cố tình đảo thứ tự này, và đó là nguồn gốc của mấy lỗi kinh điển kiểu ClassCastException: X cannot be cast to X khi cùng một class bị hai ClassLoader khác nhau nạp.
# vòng đời của một class
- Loading: đọc dữ liệu nhị phân của file
.classvào bộ nhớ native. - Linking:
- Verification: kiểm tra bytecode có vi phạm an toàn bộ nhớ, tràn stack hay ép kiểu sai không.
- Preparation: cấp phát bộ nhớ cho các biến
staticvà gán giá trị mặc định (0, null, false). - Resolution: đổi các tham chiếu tượng trưng (Symbolic References) trong Constant Pool thành địa chỉ bộ nhớ thật (Direct References).
- Initialization: chạy khối
static { ... }và gán giá trị khởi tạo thật cho các biến static.
# các vùng nhớ lúc chạy (JVM Runtime Data Areas)
Khi JVM khởi động, hệ điều hành cấp cho nó một vùng nhớ tiến trình. JVM chia vùng này ra thành mấy khu riêng:
Một chi tiết hay bị nhầm: từ Java 7–8, String Pool (chuỗi intern) và giá trị các biến static đã chuyển sang nằm trên Heap, không còn ở PermGen/Metaspace nữa. Metaspace không bị giới hạn mặc định (chỉ giới hạn bởi RAM máy), nên app tạo class động liên tục (proxy, script engine, redeploy nhiều lần) mà rò ClassLoader thì Metaspace cứ phình mãi. Đặt -XX:MaxMetaspaceSize để lỗi lộ ra sớm dưới dạng OutOfMemoryError: Metaspace thay vì ăn hết RAM.
# bên trong một stack frame
Mỗi khi thread gọi một method, một Stack Frame mới được push vào JVM Stack của thread đó. Method chạy xong (return hoặc throw exception) thì frame bị pop ra.
- Local Variable Array (LVA): đánh chỉ số từ 0. Với method thường, ô số 0 luôn giữ
this. Kiểu nguyên thuỷ 32-bit (int, float) chiếm 1 slot, kiểu 64-bit (long, double) chiếm 2 slot liền nhau. - Operand Stack: JVM là máy ảo chạy trên ngăn xếp (Stack-based Architecture), không phải máy ảo dùng thanh ghi (như Lua VM hay Dalvik). Phép tính nào cũng là đẩy toán hạng vào Operand Stack, chạy lệnh, rồi lấy kết quả ra.
# execution engine: từ interpreter tới tiered JIT compilation
Vậy mấy lệnh bytecode trong Operand Stack biến thành tín hiệu chạy trên CPU kiểu gì?
HotSpot dùng kiểu biên dịch phân tầng (Tiered Compilation):
Interpreter: lúc app mới khởi động, Interpreter đọc và thông dịch từng lệnh bytecode. Nhờ vậy app lên ngay, không phải chờ biên dịch.
C1 Compiler (Client JIT): đoạn code nào được gọi nhiều (chạm ngưỡng invocation counter) thì C1 biên dịch sang mã máy native, tối ưu vừa phải để chạy nhanh hơn. Ở tầng này mã do C1 sinh ra còn tự gắn thêm bộ đếm để thu thập profile (nhánh nào hay chạy, kiểu object nào hay gặp ở mỗi lời gọi) cho C2 dùng.
C2 Compiler (Server JIT): với những đoạn code cực "nóng" (Hotspots, thường chỉ là một phần nhỏ code nhưng chiếm phần lớn thời gian CPU), C2 dựa vào profile ở trên để tối ưu sâu hơn. (GraalVM thay C2 bằng Graal compiler viết bằng Java, nhưng vai trò tương tự.)
- Method Inlining: nhét thẳng thân hàm con vào hàm cha, khỏi tốn chi phí gọi hàm.
- Escape Analysis: xem object có "thoát" ra ngoài method không. Nếu không, HotSpot bỏ luôn việc cấp phát trên Heap, tách object thành các biến nguyên thuỷ nằm thẳng trên CPU Register hoặc Stack (Scalar Replacement)!
- Loop Unrolling & SIMD: duỗi vòng lặp ra và chuyển phép tính trên mảng sang lệnh vector song song của phần cứng (SSE/AVX trên x86, NEON trên ARM).
Những tối ưu của C2 dựa trên giả định rút ra từ profile (vd "chỗ này chỉ gặp một implementation của interface"). Khi giả định sai, chẳng hạn một class mới được nạp, JVM deoptimize: vứt bản native đó, quay về interpreter rồi biên dịch lại sau. Đây là lý do benchmark Java phải có pha warm-up (JMH làm sẵn chuyện này).
# thực hành: viết code & soi Bytecode bằng javap
Viết thử một đoạn tính toán đơn giản rồi xem bytecode bên dưới:
// MathService.java
public class MathService {
public int calculateDiscount(int price, int rate) {
int discount = price * rate / 100;
return discount;
}
}Biên dịch rồi dịch ngược bằng javap có sẵn trong JDK:
javac MathService.java
javap -c -v MathService.class# đọc bytecode lấy ra được
public int calculateDiscount(int, int);
descriptor: (II)I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=4, args_size=3
0: iload_1 // Đẩy giá trị biến cục bộ số 1 (price) vào Operand Stack
1: iload_2 // Đẩy giá trị biến cục bộ số 2 (rate) vào Operand Stack
2: imul // Lấy 2 số trên đỉnh stack ra nhân với nhau, đẩy kết quả trở lại stack
3: bipush 100 // Đẩy hằng số 100 vào Operand Stack
5: idiv // Chia kết quả phép nhân cho 100, đẩy thương số vào stack
6: istore_3 // Lấy kết quả từ đỉnh stack lưu vào biến cục bộ số 3 (discount)
7: iload_3 // Nạp lại biến số 3 vào stack để chuẩn bị trả về
8: ireturn // Trả về số nguyên trên đỉnh stack cho caller frameĐọc từng dòng là thấy rõ JVM là một Stack Machine: thao tác nào cũng là chuyển dữ liệu qua lại giữa Local Variable Array (iload, istore) và Operand Stack (imul, idiv).
# dựng môi trường làm việc cho đàng hoàng
Để làm Java nghiêm túc trong môi trường microservices:
Quản lý nhiều bản JDK bằng SDKMAN!:
# Cài đặt SDKMAN curl -s "https://get.sdkman.io" | bash # Cài đặt JDK 21 LTS và JDK 25 LTS (Temurin) # Số phiên bản đổi theo thời gian, xem danh sách bằng: sdk list java sdk install java 21.0.6-tem sdk install java 25.0.1-tem # Chuyển đổi linh hoạt giữa các dự án sdk use java 21.0.6-temChọn Distribution cho production:
- Eclipse Temurin (Adoptium): bản OpenJDK phổ biến nhất, được cộng đồng mã nguồn mở và nhiều công ty lớn đứng sau.
- Amazon Corretto: tối ưu kỹ cho hạ tầng AWS Cloud, có hỗ trợ bảo mật dài hạn miễn phí.
- GraalVM: chọn khi cần biên dịch Ahead-Of-Time (AOT Native Image) cho serverless function hay microservice cần khởi động trong vài chục mili-giây.
# lời kết
JVM không phải một cái trình giả lập nặng nề, mà là một món phần mềm được mài giũa liên tục suốt gần ba chục năm.
Muốn đi tiếp thì có thể thử: chạy app với -Xlog:class+load để xem class nào được ClassLoader nào nạp, bật -XX:+PrintCompilation để thấy method nào lên C1, C2, và dùng javap -c soi bytecode của vài đoạn code quen thuộc (lambda, switch trên String, try-with-resources).
Tài liệu tham khảo:
- Tim Lindholm, Frank Yellin: The Java Virtual Machine Specification (Java SE Edition)
- Scott Oaks: Java Performance - In-Depth Advice for Tuning and Optimization (O'Reilly)
- Aleksey Shipilëv: Java Memory Model Pragmatics
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.