Java Internals & Concurrency/Vòng đời một class trong JVM: load, link, initialize
49/75
Bài 49 / 75~14 phútJVM InternalsMiễn phí lượt xem

Vòng đời một class trong JVM: load, link, initialize

JVM xử lý một class qua ba phase: loading, linking, initialization. Trace vòng đời này để định vị ClassNotFoundException khác NoClassDefFoundError chỗ nào.

TL;DR: Sau khi ClassLoader tìm được file .class, JVM còn xử lý qua ba phase tuần tự: Loading (đọc và parse bytecode, tạo Class object), Linking (verify bytecode → prepare cấp phát static field với giá trị default → resolve symbolic reference), và Initialization (chạy <clinit> — gán static field và chạy static block). Phân biệt được ba phase này cho phép định vị hai lỗi hay nhầm: ClassNotFoundExceptionchecked exception, ném khi code chủ động tìm class theo tên (Class.forName) mà không thấy; NoClassDefFoundErrorError, ném khi JVM cố link/init một class đã compile-time tham chiếu nhưng vắng lúc runtime — hoặc khi static init đã throw một lần khiến class chết vĩnh viễn.

Bài trước: ClassLoader xếp ba tầng Bootstrap → Platform → Application, con luôn hỏi parent trước (parent delegation). Loader chỉ lo tìm file .class. Bài này bắt đầu từ khoảnh khắc file đã tìm thấy: JVM làm gì tiếp theo để biến bytes thành một class dùng được?

Một bug production kinh điển: log đứng ở

Caused by: java.lang.NoClassDefFoundError: com/google/common/collect/ImmutableList
    at com.myapp.Service.<init>(Service.java:12)

Bạn mở pom.xml — Guava có đủ. Build xanh, test local xanh. Nhưng deploy lên môi trường khác thì NoClassDefFoundError. Vì sao không phải ClassNotFoundException? Hai lỗi này khác nhau ở phase nào trong vòng đời class phát sinh ra chúng — và đó chính là manh mối để debug.

Bài này trace ba phase Loading → Linking → Initialization, rồi dùng chúng để định vị chính xác ClassNotFoundException so với NoClassDefFoundError.

JVM biến một file .class thành class dùng được qua ba phase tuần tự (JLS §12, JVMS §5):

flowchart LR
    A["1. Loading<br/>doc .class, parse bytecode<br/>tao Class object"] --> B["2. Linking"]
    B --> B1["2a. Verification<br/>bytecode hop le?"]
    B1 --> B2["2b. Preparation<br/>cap static field = default"]
    B2 --> B3["2c. Resolution<br/>symbolic -> direct reference"]
    B3 --> C["3. Initialization<br/>chay clinit: gan static + static block"]
    C --> D["Class san sang dung"]

    style B1 fill:#FCD34D
    style D fill:#6EE7B7

Ba phase này giống một dây chuyền nhà máy biến linh kiện thô thành máy chạy được:

Nhà máyJVM
Nhập linh kiện vào khoLoading — đọc .class, tạo Class object
Lắp ráp, kiểm tra an toàn, đấu nốiLinking — verify bytecode, prepare static field, resolve reference
Bật nguồn, nạp cấu hình ban đầuInitialization — chạy static initializer: gán static, chạy static block

2. Loading — đọc .class và tạo Class object

JVM tìm file .class qua ClassLoader (bài 01), parse bytecode, tạo một Class<?> object trong bộ nhớ. Loading là lazy: JVM không nạp sẵn cả JAR mà chờ tới khi một class thực sự cần. Thời điểm nạp chính xác do JVM quyết định (JVMS §5.3), chậm nhất là ngay trước khi class bị dùng chủ động (active use) — và chính các tình huống active use đó cũng kích hoạt phase Initialization (§4), theo danh sách chuẩn JLS §12.4.1:

  • new MyClass() — tạo instance đầu tiên.
  • MyClass.staticMethod() — gọi static method.
  • MyClass.STATIC_FIELD — đọc hoặc ghi static field non-constant.
  • Class.forName("MyClass") — nạp và init tường minh.
  • Init subclass → nạp và init superclass trước.

Nhờ lazy, app có 1000 class trong JAR nhưng một lần chạy chỉ chạm 50 thì JVM chỉ xử lý 50 — phần còn lại không tốn gì.

3. Linking — verify, prepare, resolve

Linking gồm ba bước con. Bước Preparation là chỗ hay bị hiểu sai nhất, nên ta dừng lại ở đó.

Verification — kiểm bytecode hợp lệ: stack frame nhất quán, type an toàn, không nhảy lung tung. Đây là lá chắn chống class file độc hại (ví dụ ai đó viết tay bytecode để đọc trộm bộ nhớ).

Preparation — cấp phát bộ nhớ cho static field và set giá trị default theo type (0 cho số, null cho reference, false cho boolean). Chưa chạy bất kỳ assignment nào trong code.

Resolution — chuyển symbolic reference (chuỗi text như "java/lang/String" trong constant pool — bảng hằng số nhúng trong .class, chứa mọi tên class/method/field dạng chuỗi) thành direct reference (con trỏ tới Class object thật). Có thể lazy: JVM trì hoãn resolve tới khi reference được dùng.

Trước khi xem một ví dụ, hãy tự trả lời:

💡 Thử đoán

Class có public static int count = 100;. Ngay sau khi phase Linking (Preparation) xong nhưng TRƯỚC khi Initialization chạy, đọc count ra giá trị nào? Viết ra con số bạn tin chắc trước khi đọc tiếp.

public static int count = 100;
// Sau Preparation: count == ?
// Sau Initialization: count == ?

Sau Preparation, count0 — giá trị default của int, vì Preparation chỉ cấp bộ nhớ và set default, chưa chạy assignment. Chỉ sau Initialization, <clinit> mới chạy count = 100. Hai thời điểm này tách biệt là nền để hiểu lỗi circular static reference ở phần tự kiểm tra.

4. Initialization — chạy static initializer (clinit)

Phase cuối chạy <clinit> lần đầu class bị active use (§2) — method do JVM tự sinh, chứa:

  • Assignment cho static field (static int count = 100;).
  • Nội dung mọi static { ... } block.

<clinit> chạy một lần trên mỗi loader, và thread-safe (JVM giữ lock init). Thứ tự thực thi là thứ tự các câu lệnh xuất hiện trong source:

class Config {
    static final int VERSION = 1;                    // 1
    static String name = loadName();                 // 2
    static { System.out.println("Config init"); }    // 3
    static List<String> tags = new ArrayList<>();    // 4
}

Khi static init ném exception

Nếu <clinit> ném exception, class bị JVM đánh dấu erroneous vĩnh viễn. Điều xảy ra tiếp theo là nguồn của nhiều giờ debug lãng phí — hãy tự đoán trước:

💡 Thử đoán

Đoạn dưới truy cập Bad.x hai lần trong hai khối try giống hệt nhau. Lần thứ nhất in ra loại Throwable nào? Lần thứ hai in ra loại nào? Chúng có giống nhau không? Viết ra dự đoán trước khi đọc lời giải bên dưới.

class Bad {
    static int x = 1 / 0;   // static initializer chia cho 0
}

try { int v = Bad.x; } catch (Throwable t) { System.out.println(t); }
// Lan 1: ?

try { int v = Bad.x; } catch (Throwable t) { System.out.println(t); }
// Lan 2: ?

Kết quả hai lần khác nhau:

Lan 1: java.lang.ExceptionInInitializerError
         Caused by: java.lang.ArithmeticException: / by zero
Lan 2: java.lang.NoClassDefFoundError: Could not initialize class Bad

Lần đầu, JVM chạy <clinit>, gặp 1 / 0 ném ArithmeticException, wrap lại thành ExceptionInInitializerErrorcó cause gốc. Class bị mark erroneous. Lần thứ hai, JVM từ chối init lại một class đã chết, ném thẳng NoClassDefFoundError: Could not initialize class Badkhông kèm cause.

⚠️ Static init throw → class chết vĩnh viễn

Trong production, log lần thứ N (sau khi app đã chạy một lúc) thường chỉ có NoClassDefFoundError trống trơn, không nói vì sao. Cause gốc chỉ xuất hiện ở lần init đầu tiên — tức log ngay khi app vừa start. Muốn tìm bug thật: xóa log, restart app, đọc dòng ExceptionInInitializerError đầu tiên với cause của nó, đừng tin thông báo NoClassDefFoundError ở lần sau.

5. ClassNotFoundException khác NoClassDefFoundError chỗ nào?

Hai lỗi anh em, hay bị nhầm. Khác biệt cốt lõi: phát sinh ở phase nào, do ai chủ động tìm.

ClassNotFoundException

Ném khi code chủ động tìm class theo tên (chuỗi string) mà không thấy. Là checked exception — phải catch hoặc declare throws.

try {
    Class<?> c = Class.forName("com.foo.NonExistent");
} catch (ClassNotFoundException e) {
    // JVM chu dong tim theo ten, khong thay
}

Stack trace có Class.forName hoặc loader.loadClass. Nguyên nhân thường gặp: JAR thiếu trong runtime classpath, typo tên class, hoặc nạp qua loader sai.

NoClassDefFoundError

Ném khi JVM cố link hoặc init một class A mà code đã compile-time tham chiếu, nhưng A vắng mặt lúc runtime. Là Error (không phải Exception) — không nên catch.

// Compile-time: Guava co trong classpath -> bien dich OK
ImmutableList<String> list = ImmutableList.of("a");
// Runtime: Guava vang -> new/link that bai -> NoClassDefFoundError

Nguyên nhân thường gặp:

  • Compile classpath có JAR, runtime classpath thiếu (Maven scope provided mà môi trường runtime không cung cấp là case kinh điển).
  • Class nạp OK nhưng static init throw → class chết → mọi truy cập sau ném NoClassDefFoundError: Could not initialize class... không kèm cause (§4).
  • Hai thư viện kéo hai version khác nhau; version được chọn thiếu method version kia cần → NoSuchMethodError (anh em cùng họ).

Phân biệt nhanh

Triệu chứng trong stack traceLoạiHành động debug
Class.forName / loadClassClassNotFoundExceptionTìm JAR cung cấp class đó, kiểm runtime classpath
<init> hoặc truy cập static field trực tiếpNoClassDefFoundErrorĐối chiếu compile classpath với runtime classpath
Could not initialize class XNoClassDefFoundError (init fail)Tìm log lần đầu — ExceptionInInitializerError có cause gốc
NoSuchMethodErrorVariant — version mismatchmvn dependency:tree tìm version conflict

6. Pitfall tổng hợp

Nhầm 1: Catch NoClassDefFoundError để "xử lý cho êm".

try { useGuava(); } catch (NoClassDefFoundError e) { /* nuot loi */ }

✅ Đây là Error, báo hiệu môi trường hỏng (thiếu JAR / class chết). Nuốt nó chỉ giấu bug — fix classpath thay vì catch.

Nhầm 2: Nhồi logic nặng, dễ fail vào static block.

class Config {
    static { Files.readAllBytes(Path.of("/etc/app/config")); }  // fail -> class chet
}

✅ Nếu static init fail, class chết vĩnh viễn và các lần sau chỉ ra NoClassDefFoundError trống. Dùng holder pattern để init lazy và cô lập lỗi:

class Config {
    private static class Holder { static final Config INSTANCE = load(); }
    static Config get() { return Holder.INSTANCE; }
}

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: JLS §12.4.1 liệt kê chính xác khi nào một class bị "actively used" để trigger init — đáng đọc khi debug "vì sao static block không chạy". JVMS §5.5 mô tả trạng thái erroneous: hiểu vì sao lần init thứ hai ném NoClassDefFoundError chứ không lặp lại ExceptionInInitializerError.

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

9. Tóm tắt

  • JVM xử lý class qua ba phase: Loading (đọc .class), Linking (verify + prepare + resolve), Initialization (chạy <clinit>).
  • Lazy loading: class chỉ nạp khi lần đầu thực sự cần.
  • Preparation set static field giá trị default; Initialization mới chạy assignment thật — hai thời điểm tách biệt.
  • <clinit> chạy một lần trên mỗi loader, thread-safe, theo thứ tự source.
  • Static init throw → class erroneous vĩnh viễn: lần 1 ném ExceptionInInitializerError (có cause), lần 2+ ném NoClassDefFoundError (không cause). Tìm log lần đầu để debug.
  • ClassNotFoundException: checked exception, code chủ động tìm theo tên mà không thấy.
  • NoClassDefFoundError: Error, link/init class compile-time có nhưng runtime vắng — hoặc class đã chết vì static init fail.

10. Tự kiểm tra

Tự kiểm tra
Q1
Khác biệt cốt lõi giữa ClassNotFoundExceptionNoClassDefFoundError là gì?

ClassNotFoundException — checked exception, ném khi code chủ động tìm class theo tên string mà không thấy: Class.forName("com.foo.Bar") hoặc loader.loadClass("com.foo.Bar"). Stack trace có forName/loadClass. Nguyên nhân: typo tên, JAR thiếu, hoặc nạp qua loader sai.

NoClassDefFoundError — Error, ném khi JVM cố link/init class A đã compile-time tham chiếu, nhưng A vắng runtime. Stack trace ở new Foo(), truy cập Foo.X, hoặc Could not initialize class. Nguyên nhân: compile classpath khớp nhưng runtime classpath khác (Maven scope provided điển hình), hoặc class đã chết vì static init fail.

Quy tắc thực dụng: Exception thì catch được; Error không nên catch — fix classpath thay vì nuốt lỗi.

Q2
Đoạn sau in gì ở lần 1 và lần 2? class C { static int x = 1/0; } try { int v=C.x; } catch(Throwable t){print(t);} try { int v=C.x; } catch(Throwable t){print(t);}

Lần 1: ExceptionInInitializerError với cause là ArithmeticException: / by zero. JVM chạy <clinit> của C, gặp x = 1/0 ném ArithmeticException, wrap thành ExceptionInInitializerError.

Lần 2: NoClassDefFoundError: Could not initialize class Ckhông có cause. Class C đã bị mark erroneous ở lần 1; JVM từ chối init lại và ném NoClassDefFoundError báo class đã chết.

Hệ quả debug: trong production, sau khi app đã chạy, log chỉ thấy NoClassDefFoundError trống. Muốn ra cause gốc phải tìm log lần init đầu tiên (ngay khi app start) — chỉ ở đó mới có ExceptionInInitializerError kèm nguyên nhân thật.

Q3
Static field assignment chạy ở phase nào — Loading, Linking, hay Initialization? Khác gì với giá trị default?

Hai bước tách biệt. Linking → Preparation: JVM cấp bộ nhớ cho static field và set giá trị default theo type — 0 cho số nguyên, 0.0 cho số thực, false cho boolean, null cho reference. Initialization: JVM chạy <clinit>, thực thi assignment thật (static int x = 100;) và static block, theo thứ tự source.

Vì thế trong khoảng giữa hai phase, một static field đã tồn tại nhưng còn mang giá trị default. Đây chính là cơ chế đằng sau lỗi circular static reference: nếu A và B tham chiếu static field của nhau trong <clinit>, một bên sẽ đọc bên kia khi bên kia còn ở giá trị default — kết quả phụ thuộc thứ tự nạp, một anti-pattern cần tránh.

Ngoại lệ: field static final gán bằng compile-time constant (literal) được inline thẳng vào bytecode caller, nên đọc nó KHÔNG trigger init class chứa nó.

Q4
Vì sao lazy loading giúp app khởi động nhanh và tiết kiệm bộ nhớ?

JVM chỉ nạp một class khi lần đầu thực sự cần: tạo instance, gọi static method, đọc static field non-constant, hoặc Class.forName. Một app đóng gói 1000 class nhưng một lần chạy chỉ chạm 50 class thì JVM chỉ tốn công parse + verify + link cho 50 class đó.

Lợi ích: khởi động nhanh hơn (không phải verify toàn bộ JAR trước khi chạy dòng đầu), và bộ nhớ Metaspace chỉ chứa metadata của class đã dùng. Nhánh code không bao giờ chạy thì class của nó không bao giờ được nạp.

Đánh đổi: chi phí nạp bị dời sang lần chạm đầu tiên (một độ trễ nhỏ, thấy rõ khi warm-up), và lỗi nạp (thiếu class) lộ ra lúc runtime chứ không phải lúc start — đó là lý do NoClassDefFoundError có thể xuất hiện muộn, sau khi app đã chạy êm một lúc.

Q5
Verification (bước 2a của Linking) bảo vệ JVM khỏi điều gì? Vì sao bước này cần thiết dù compiler đã kiểm type rồi?

Verification kiểm bytecode hợp lệ trước khi chạy: stack frame nhất quán (không underflow/overflow operand stack), type an toàn (không dùng một reference như số nguyên), branch không nhảy ra ngoài method hay vào giữa một instruction. Nó bảo vệ JVM khỏi bytecode độc hại hoặc hỏng.

Cần thiết vì JVM không tin file .class đến từ compiler chính thống. Một attacker có thể viết tay bytecode (hoặc sửa file .class) vi phạm mọi rule type của Java nhưng vẫn là bytecode "chạy được" nếu không kiểm — ví dụ ép một int thành con trỏ để đọc/ghi vùng nhớ bất kỳ. Compiler chỉ đảm bảo source hợp lệ; verification đảm bảo bytecode hợp lệ, bất kể nó được sinh ra thế nào.

Bài tiếp theo: Classloader isolation trong thực tế

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

Classloader isolation: X cannot be cast to X (Tomcat, Boot)