Chặng mới của sê-ri: bên trong JVM. Bắt đầu bằng thứ chạy trước cả dòng mã đầu tiên của bạn — cơ chế tìm và nạp lớp.
Phần lớn thời gian nó vô hình. Nó chỉ hiện ra lúc có chuyện, và khi ấy thông báo lỗi thường vô nghĩa với người chưa biết cơ chế bên dưới.
Ba tầng
java.lang.String -> bootstrap (null)
javax.crypto.Cipher -> bootstrap (null)
java.sql.Connection -> platform (PlatformClassLoader)
CL -> app (AppClassLoader)
Bootstrap nạp lõi Java. Nó viết bằng C++, không phải đối tượng Java, nên getClassLoader() trả về null — chú ý null ở đây nghĩa là "bootstrap", không phải "không có".
Platform nạp các module tiêu chuẩn ngoài lõi: JDBC, XML, logging. Trước Java 9 nó tên là "extension class loader".
Application nạp mã của bạn và các thư viện trên classpath. Đây là loader mà 99% mã đụng tới.
Chú ý javax.crypto.Cipher nằm ở bootstrap chứ không phải platform — ranh giới giữa các tầng là kết quả của lịch sử module hoá, không phải một quy tắc đơn giản đoán được.
Uỷ quyền lên trên trước
AppClassLoader -> cha:
PlatformClassLoader -> cha:
null (bootstrap)
Khi được yêu cầu nạp một lớp, loader không tự tìm trước. Nó hỏi cha, cha hỏi ông, tới bootstrap. Chỉ khi cả cây trả lời "không có" thì nó mới tự đi tìm.
Tôi kiểm bằng một loader tự viết, cố tình đưa cho nó một thư mục và bảo nạp java.lang.String:
loadClass("java.lang.String") -> loader = bootstrap
(loader tự viết KHÔNG hề được hỏi tới — cha trả lời trước)
Đây là cơ chế bảo mật nền tảng: không ai thay được java.lang.String bằng bản của mình. Nếu loader tự tìm trước, một tệp String.class độc hại đặt đúng chỗ trên classpath là chiếm được cả JVM.
Danh tính một lớp không phải là cái tên
Đây là điều bất ngờ lớn nhất với người mới, và là nguồn của một thông báo lỗi nổi tiếng.
Tôi nạp cùng một tệp .class bằng hai loader khác nhau:
cùng tên? true
c1 == c2 ? false
o instanceof c2 ? false <- HAI LỚP KHÁC NHAU
Và khi ép kiểu:
java.lang.ClassCastException: class Rieng cannot be cast to class Rieng
(Rieng is in unnamed module of loader java.net.URLClassLoader @5ca881b5;
Rieng is in unnamed module of loader 'app')
"Không thể ép Rieng thành Rieng" nghe như JVM hỏng. Nhưng nó đúng: danh tính một lớp là cặp (tên đầy đủ, loader đã nạp nó). Hai loader khác nhau tạo ra hai lớp khác nhau, dù byte hoàn toàn giống hệt.
Phần trong ngoặc của thông báo — có từ Java 9 — chính là chỗ chỉ ra nguyên nhân. Trước đó thông báo chỉ có "class Rieng cannot be cast to class Rieng" và người ta ngồi nhìn nó rất lâu.
Gặp lỗi này trong thực tế thường có nghĩa: cùng một thư viện xuất hiện hai lần ở hai chỗ khác nhau — trong WEB-INF/lib và trong thư mục lib của máy chủ ứng dụng chẳng hạn.
Nhưng đó cũng là một tính năng
Chính đặc tính ấy cho phép nạp nóng. Tôi biên dịch hai phiên bản của cùng một lớp Plugin, rồi nạp lần lượt bằng hai loader:
[loader tự viết đọc Plugin.class từ phien1]
lần 1 -> phiên bản 1 (loader 1554874502)
[loader tự viết đọc Plugin.class từ phien2]
lần 2 -> phiên bản 2 (ĐÃ SỬA, nạp nóng) (loader 989110044)
Cùng tên lớp, hai bản mã khác nhau, sống song song trong một JVM.
Đây là nền tảng của mọi hệ thống plugin, của việc triển khai lại một webapp mà không khởi động lại máy chủ, và của cách Tomcat cô lập các ứng dụng với nhau. Muốn bỏ phiên bản cũ thì bỏ tham chiếu tới loader — cả loader lẫn mọi lớp nó nạp sẽ được thu hồi cùng nhau.
Và đó cũng là lý do rò rỉ classloader tồn tại: một ThreadLocal còn sót, một luồng chưa dừng, một listener chưa gỡ — bất cứ thứ gì giữ tham chiếu tới một lớp cũng giữ luôn loader và toàn bộ ứng dụng cũ. Đúng bài học ở bài 78.
Ba tình huống, hai loại lỗi
Đây là phần thực dụng nhất của bài, vì hai lỗi này rất hay bị lẫn.
Một: lớp không tồn tại ở đâu cả
Class.forName("com.khong.He.Ton.Tai");
java.lang.ClassNotFoundException: com.khong.He.Ton.Tai
ClassNotFoundException là ngoại lệ được kiểm tra, sinh ra khi bạn hỏi tên lớp bằng chuỗi lúc chạy — Class.forName, loadClass, hay các khung làm việc nạp lớp theo cấu hình. Nghĩa của nó rất hẹp: tôi đã tìm khắp cây uỷ quyền và không thấy.
Hai: lớp có lúc biên dịch, thiếu lúc chạy
Tôi biên dịch một ứng dụng dùng ThuVien, rồi xoá ThuVien.class đi và chạy:
ứng dụng bắt đầu chạy bình thường
sắp gọi ThuVien...
Exception in thread "main" java.lang.NoClassDefFoundError: ThuVien
at App.main(App.java:5)
Caused by: java.lang.ClassNotFoundException: ThuVien
NoClassDefFoundError là Error, không phải Exception — vì với JVM đây là lỗi cấu hình môi trường chứ không phải tình huống mã của bạn nên xử lý.
Chú ý cấu trúc: NoClassDefFoundError bọc ngoài ClassNotFoundException. Trình biên dịch đã thấy lớp đó, mã máy tham chiếu tới nó, nhưng lúc chạy nó biến mất. Đây là ca kinh điển của "thiếu jar", "phiên bản phụ thuộc lệch nhau", hoặc jar bị loại khi đóng gói.
Chú ý thêm: ứng dụng chạy bình thường tới tận dòng gọi lớp đó. Lớp chỉ được nạp khi lần đầu cần tới, nên lỗi cấu hình có thể ngủ yên hàng giờ rồi mới nổ ra ở một nhánh mã hiếm khi chạy.
Ba: lớp có, nhưng khởi tạo tĩnh đã hỏng
Đây là ca khó chịu nhất, và là lý do chính tôi viết bài này.
class Hong {
static { throw new RuntimeException("cấu hình thiếu biến DB_URL"); }
}
lần chạm ĐẦU TIÊN:
java.lang.ExceptionInInitializerError: null
lần chạm THỨ HAI (cùng JVM):
java.lang.NoClassDefFoundError: Could not initialize class Hong
Lần đầu bạn nhận ExceptionInInitializerError, và getCause() của nó chứa nguyên nhân thật. Từ lần thứ hai trở đi, JVM đánh dấu lớp là "hỏng vĩnh viễn" và trả về NoClassDefFoundError — không còn nguyên nhân gốc nữa.
ExceptionInInitializerError. Với hệ thống có nhiều luồng, dòng đó có thể cách xa hàng trăm dòng log.
Tôi đã thấy nhiều người mất cả buổi vì thông báo này, kể cả tôi. Chữ "Could not initialize" nghe như không tìm thấy, trong khi nó nói rằng lớp đã được tìm thấy và đã cố khởi tạo rồi thất bại.
Cách phòng: đừng đặt việc có thể hỏng vào khối static. Đọc biến môi trường, mở kết nối, nạp tệp cấu hình — hãy làm trong một phương thức khởi tạo tường minh, nơi bạn kiểm soát được thời điểm và xử lý được lỗi.
Class.forName khác loadClass
loadClass("CoTinh") : (không in gì -> khối static chưa chạy)
Class.forName("CoTinh") : >>> khối static của CoTinh CHẠY
loadClass chỉ nạp. Class.forName nạp và khởi tạo — chạy khối static và gán các trường tĩnh.
Khác biệt này quan trọng đúng ở tình huống ba. Nếu bạn muốn kiểm tra sự tồn tại của một lớp mà không kích hoạt tác dụng phụ, dùng bản ba tham số: Class.forName(ten, false, loader).
Đây cũng là lý do mã JDBC cũ viết Class.forName("com.mysql.jdbc.Driver") — mục đích không phải lấy đối tượng Class mà là chạy khối static để driver tự đăng ký. Từ JDBC 4.0 có ServiceLoader nên dòng đó không còn cần nữa.
Và trong cùng một loader, nạp hai lần trả về cùng một đối tượng:
cùng một đối tượng Class (có bộ nhớ đệm)
Danh sách kiểm khi gặp lỗi nạp lớp
ClassNotFoundException → lớp không có trên classpath. Kiểm phụ thuộc, kiểm cách đóng gói, kiểm tên gói có gõ đúng không.
NoClassDefFoundError kèm Caused by: ClassNotFoundException → cùng nguyên nhân trên, chỉ khác là phát hiện lúc chạy chứ không phải lúc gọi tường minh.
NoClassDefFoundError: Could not initialize class X → không phải thiếu lớp. Tìm ngược lên trong log tới ExceptionInInitializerError.
ClassCastException: class X cannot be cast to class X → cùng lớp nạp bởi hai loader. Tìm bản trùng của thư viện.
NoSuchMethodError hay NoSuchFieldError → lớp có nhưng sai phiên bản. Biên dịch với bản này, chạy với bản khác. Đây là dấu hiệu kinh điển của xung đột phiên bản phụ thuộc.
Công cụ hữu ích: -verbose:class in ra mọi lớp được nạp kèm nguồn gốc — chậm và ồn, nhưng nó trả lời dứt điểm câu hỏi "lớp này thực ra đến từ jar nào".
Thử ba mươi giây
Chạy ứng dụng của bạn với -verbose:class rồi lọc theo tên một lớp bạn nghi ngờ:
java -verbose:class -cp ... MyApp 2>&1 | grep TenLopNghiNgo
Nó in ra đường dẫn jar thật mà lớp được nạp từ đó. Ba mươi giây này chấm dứt mọi tranh cãi kiểu "chắc nó lấy nhầm phiên bản cũ" — bạn nhìn thấy câu trả lời thay vì đoán.
Ngày mai: bộ nhớ của JVM — heap, stack, metaspace, và những vùng ngoài heap mà -Xmx không quản.