Classloader isolation: X cannot be cast to X (Tomcat, Boot)
Vì sao ClassCastException báo com.foo.Bar cannot be cast to com.foo.Bar? Diagnose lỗi classloader isolation trong Tomcat và Spring Boot DevTools.
TL;DR: Vì JVM định danh class bằng cặp (loader, tên đầy đủ), một class nạp qua hai loader độc lập là hai kiểu khác nhau — ép kiểu giữa chúng ném ClassCastException: com.foo.Bar cannot be cast to com.foo.Bar (từ Java 9, thông báo còn kèm tên hai loader, chính là manh mối chẩn đoán). Ba môi trường phải nắm: Tomcat (mỗi WAR một WebappClassLoader đảo parent delegation — cast-fail khi cùng JAR nằm ở hai nơi), Spring Boot executable jar (LaunchedClassLoader đọc nested JAR trong BOOT-INF/lib — chỉ một loader nên hiếm tự isolate), và DevTools hot reload (RestartClassLoader mới mỗi reload khiến object cũ và code mới khác loader). Fix chung: đảm bảo class chỉ nạp qua một loader — dời JAR trùng, hoặc restart thay vì hot reload khi đụng object đã cache.
Bài 01 chốt: JVM định danh class bằng cặp (loader, tên đầy đủ), nên hai loader không quan hệ parent-child cùng nạp một class sẽ tạo hai Class<?> object khác nhau. Bài này là hệ quả thực chiến của đúng một câu đó.
Bạn deploy WAR lên Tomcat và log ném ra dòng khó tin:
java.lang.ClassCastException: class com.foo.Bar cannot be cast to class com.foo.Bar
(com.foo.Bar is in unnamed module of loader org.apache.catalina.loader.ParallelWebappClassLoader @2f7c7260;
com.foo.Bar is in unnamed module of loader java.net.URLClassLoader @1b6d3586)
Cùng tên package, cùng tên class, cùng file JAR — sao lại không cast được sang chính nó? Đây không phải bug compiler hay lỗi type. Đây là classloader isolation: hai loader đã nạp com.foo.Bar thành hai kiểu độc lập. Từ Java 9, thông báo còn kèm phần trong ngoặc chỉ rõ hai loader khác nhau — chính là manh mối chẩn đoán đầu tiên (trước Java 9 chỉ có dòng X cannot be cast to X trơ trụi, khó hiểu hơn hẳn). Container và framework Java hiện đại đầy rẫy tình huống này.
Bài này giải thích vì sao thông báo đó xuất hiện, rồi đi qua ba môi trường thực tế — Tomcat, Spring Boot fat jar, DevTools hot reload — và cách diagnose khi gặp.
1. Vì sao "X cannot be cast to X"?
Nhớ lại từ bài 01: JVM định danh class bằng cặp (loader, tên đầy đủ). Ví von: Class object là hộ chiếu, loader là nước cấp. Hai nước cấp hộ chiếu cho hai người trùng họ tên vẫn là hai giấy tờ độc lập — cửa khẩu (phép ép kiểu) đòi đúng nước cấp, nên đưa hộ chiếu nước A vào quầy chỉ nhận nước B thì bị chặn dù tên trùng khít.
| Đời thường | Classloader |
|---|---|
| Quốc gia cấp hộ chiếu | ClassLoader nạp class |
| Cuốn hộ chiếu cụ thể | Class object trong JVM |
| Họ tên trên hộ chiếu | Tên đầy đủ của class (com.foo.Bar) |
| Cửa khẩu đòi đúng nước cấp | Phép ép kiểu resolve qua một loader cụ thể |
| Hai hộ chiếu trùng tên, khác nước | X cannot be cast to X |
Trước khi xem cơ chế, tự dựng lời giải:
Hai loader cùng nạp com.foo.Bar — nhưng có phải lúc nào cũng cast fail không? Cụ thể: nếu loader con delegate lên loader cha (parent-first như thường lệ) thì kết quả khác đi thế nào? Điều kiện nào phải đúng để phép ép kiểu thực sự ném ClassCastException? Viết dự đoán trước khi đọc.
// App loader da co com.foo.Bar tren classpath (main() chay o day).
// pluginLoader la CHILD-FIRST (nhu WebappClassLoader o muc 2): tu nap
// ban com.foo.Bar RIENG tu `urls`, khong delegate len app loader.
ClassLoader pluginLoader = new PluginClassLoader(urls, appLoader);
Object obj = pluginLoader.loadClass("com.foo.Bar") // instance kieu (pluginLoader, com.foo.Bar)
.getConstructor().newInstance();
// Dong nay do APP LOADER nap, nen kieu dich (com.foo.Bar) resolve qua app loader:
com.foo.Bar bar = (com.foo.Bar) obj;
// -> ClassCastException: class com.foo.Bar cannot be cast to class com.foo.Bar
// (obj thuoc pluginLoader; kieu dich thuoc app loader)
Cơ chế: obj là instance của class (pluginLoader, "com.foo.Bar"). Dòng cast (com.foo.Bar) obj nằm trong code do app loader nạp (nơi main() chạy), nên bytecode checkcast resolve kiểu đích qua app loader — class (appLoader, "com.foo.Bar"). Hai Class object khác nhau → cast fail, và từ Java 9 thông báo kèm tên hai loader (phần trong ngoặc ở đầu bài). Hai điều kiện cốt yếu: (1) pluginLoader phải child-first — tự nạp Bar, không delegate; (2) obj và biểu thức cast thuộc hai loader khác nhau. (Lưu ý: Class.cast() reflection ném thông báo khác — "Cannot cast ..." không kèm tên loader; phần tên loader chỉ đến từ biểu thức cast (Bar) obj như trên.)
Vì hai vế trùng tên đầy đủ, ta thấy com.foo.Bar cannot be cast to com.foo.Bar — dấu hiệu chẩn đoán gọn: cùng tên mà cast fail = hai loader.

2. Tomcat — mỗi WAR một loader
Tomcat host nhiều WAR trên cùng một JVM. Nếu tất cả dùng chung một loader, hai app cùng phụ thuộc Guava nhưng khác version sẽ đè nhau. Giải pháp: mỗi WAR một WebappClassLoader riêng.
tomcat/
lib/ <- Common ClassLoader (chung, cap cao)
servlet-api.jar
catalina.jar
webapps/
app1/WEB-INF/lib/ <- WebappClassLoader cua app1
guava-30.jar
app2/WEB-INF/lib/ <- WebappClassLoader cua app2
guava-33.jar
Mỗi WAR nạp bản Guava của mình qua loader riêng, không xung đột — isolation cố ý và hữu ích.
Tomcat đảo parent delegation
Bài 01 nói delegation chuẩn là "hỏi parent trước". WebappClassLoader đảo quy ước này cho class app: nó tự tìm trong WEB-INF/classes và WEB-INF/lib trước, chỉ khi không thấy mới hỏi parent.
Vì sao Tomcat lại đảo thứ tự — cho WebappClassLoader tự tìm trước thay vì hỏi parent trước như delegation chuẩn? Lợi ích cụ thể cho người deploy WAR là gì? Tự trả lời trước khi đọc tiếp.
Lý do: cho phép app override library dùng chung ở tomcat/lib (do Common loader nạp). Nếu app cần một version Hibernate mới hơn bản Tomcat để sẵn ở tomcat/lib, delegation chuẩn sẽ luôn trả bản cũ đó. Đảo thứ tự cho phép bản trong WEB-INF/lib thắng.
Nhưng có ngoại lệ cứng: các package của chính servlet contract (jakarta.servlet.* hoặc javax.servlet.* với Tomcat cũ) luôn nạp từ Common loader (tomcat/lib), không cho app override — nếu không container và app sẽ giữ hai bản HttpServletRequest khác nhau và mọi cuộc gọi qua ranh giới container ném ClassCastException. Và như bài 01, java.* vẫn luôn delegate lên Bootstrap.
Bug thường gặp: cùng một JAR nằm cả trong tomcat/lib lẫn WEB-INF/lib. Class trong JAR đó bị nạp hai lần (một qua Common loader, một qua webapp loader) → X cannot be cast to X khi hai phía trao đổi object.
3. Spring Boot executable jar — LaunchedClassLoader
Spring Boot đóng gói app thành một JAR chạy được bằng java -jar, chứa cả dependency dưới dạng nested JAR:
myapp.jar
META-INF/MANIFEST.MF (Main-Class: org.springframework.boot.loader.launch.JarLauncher)
org/springframework/boot/loader/... <- code loader cua Spring Boot
BOOT-INF/
classes/ <- class app cua ban
lib/
spring-core-6.x.jar <- dependency, la JAR long trong JAR
jackson-databind-2.x.jar
Vấn đề: format JAR chuẩn không hỗ trợ JAR-trong-JAR — java.net.URLClassLoader gặp BOOT-INF/lib/spring-core.jar chỉ thấy một entry nhị phân, không đọc được class bên trong. Nên khi chạy java -jar myapp.jar, Main-Class là JarLauncher; nó tạo một LaunchedClassLoader biết đọc nested JAR (tìm trong BOOT-INF/classes rồi tới các JAR ở BOOT-INF/lib), và class app của bạn nạp qua loader này.
Trước Spring Boot 3.2, loader này tên LaunchedURLClassLoader (extends java.net.URLClassLoader), package org.springframework.boot.loader. Từ 3.2, executable jar loader được viết lại: class đổi tên thành LaunchedClassLoader (extends JarUrlClassLoader), package org.springframework.boot.loader.launch, đọc nested JAR qua giao thức URL nested:. Vì khóa này nhắm Spring Boot 3.x, dùng tên LaunchedClassLoader; tài liệu cũ ghi LaunchedURLClassLoader là cho bản trước 3.2.
Điểm chẩn đoán: executable jar chỉ dùng một LaunchedClassLoader cho toàn app, nên hiếm khi tự gây X cannot be cast to X (một loader → mỗi class một Class object, không có hai bản để fail). Thấy lỗi này trong Spring Boot app, thủ phạm thường là DevTools (mục kế) hoặc class nạp thêm qua loader khác (Java agent, classpath thêm tay) — biết "fat jar = một loader" giúp loại trừ nhanh.
4. Spring DevTools — hot reload và cast fail
Spring DevTools reload code khi bạn save file mà không cần restart tay, nhờ tách hai loader: loader base (cha) chứa dependency — hàng trăm MB, hiếm đổi, không reload; RestartClassLoader (con) chứa user code — vài MB, đổi liên tục. Khi save, DevTools drop RestartClassLoader cũ và tạo cái mới nạp lại user code, base giữ nguyên — gần như tức thì.
Một object được tạo TRƯỚC lần reload và còn được giữ trong một cache tĩnh (nằm ở loader base, không bị reload). Sau reload, code mới (thuộc RestartClassLoader mới) đọc object đó ra và ép về kiểu của nó. Kết quả là gì, và vì sao? Viết ra dự đoán.
Object tạo trước reload thuộc RestartClassLoader cũ; code sau reload chạy trong RestartClassLoader mới — cùng tên, khác loader, khác kiểu, nên ép object cũ về kiểu mới ném ClassCastException. Không phải lỗi code của bạn — chỉ là hai loader. Vì thế cache tĩnh sống xuyên reload (base loader, hoặc ngoài như Redis) là cái bẫy; cách chắc chắn là restart đầy đủ thay vì hot reload.
5. Diagnose và fix
Khi thấy X cannot be cast to X, quy trình chẩn đoán:
- Xác nhận đây là isolation, không phải type thường: nếu hai vế của thông báo cast trùng tên đầy đủ, gần như chắc chắn là hai loader.
- Đọc thông báo, rồi in loader: từ Java 9 phần trong ngoặc của message đã nêu tên hai loader — bằng chứng đầu tiên; in thêm
obj.getClass().getClassLoader()so vớiTargetType.class.getClassLoader()để khẳng định. - Tìm nguồn nạp trùng: JAR nào bị nạp qua hai loader? Với Tomcat, kiểm JAR có mặt cả ở
tomcat/liblẫnWEB-INF/lib. Với DevTools, kiểm object có đến từ cache sống xuyên reload không.
Fix theo tình huống:
- Tomcat: dời JAR trùng về một loader — hoặc để ở
tomcat/lib(Common loader, mọi app chung một bản), hoặc chỉ để ởWEB-INF/lib(mỗi app một bản), đừng để cả hai. - Spring Boot: fat jar chỉ một loader nên isolation hiếm — nếu gặp, kiểm DevTools (object cached xuyên reload) hoặc class trùng nạp qua loader ngoài (agent,
-cpthêm tay). - DevTools: restart đầy đủ khi đụng object cached; hoặc đảm bảo cache không giữ object xuyên reload.
- Chung: khi nạp qua reflection, dùng loader của chính instance —
obj.getClass().getClassLoader().loadClass(name)— thay vì một loader ad-hoc.
❌ Nhầm phổ biến: thấy X cannot be cast to X rồi đi sửa code cast, thêm generic, hay đổi type.
✅ Vấn đề không nằm ở type mà ở loader. Sửa cấu hình nạp class (một loader duy nhất cho class đó), không sửa biểu thức cast.
6. 📚 Deep Dive Oracle
Reference chính thức:
- Apache Tomcat 10 Class Loader HOW-TO — mô tả hierarchy loader của Tomcat và vì sao WebappClassLoader đảo delegation.
- Spring Boot — The Executable Jar Format: Launching — JarLauncher, cấu trúc nested JAR trong BOOT-INF/lib, package
org.springframework.boot.loader.launch(bản 3.x). - Spring Boot Reference — Developer Tools — phần "Restart vs Reload" giải thích hai-loader và giới hạn của hot reload.
Ghi chú: Tomcat HOW-TO nói rõ package nào không cho app override (servlet API) — đọc để hiểu vì sao trùng JAR gây cast fail. Phần Restart của DevTools doc xác nhận thiết kế hai loader và cảnh báo về object sống xuyên reload — đúng cái bẫy ở §4.
7. Liên hệ các bài khác
- Bài 01 — ClassLoader hierarchy và parent delegation — nguồn gốc của "hai loader, hai class"; đọc lại §4 của bài đó nếu cần nền.
- Bài 01b — Vòng đời class: load, link, initialize —
NoClassDefFoundErrorkhi deploy container đôi khi là init fail chứ không phải isolation; hai bài bổ trợ nhau khi debug. - Bài 02 — Class file và javap — sau khi hết chuyện loader, ta mở chính file
.classmà loader nạp.
8. Tóm tắt
X cannot be cast to X= hai loader nạp cùng class → haiClass<?>khác nhau. Xác nhận: ingetClassLoader()hai phía; khác nhau = isolation, không phải bug type.- Tomcat đảo delegation: mỗi WAR dùng
WebappClassLoader, tự tìmWEB-INF/libtrước (trừ servlet API vàjava.*). Bug kinh điển: cùng JAR ở cảtomcat/liblẫnWEB-INF/lib→ nạp hai lần → cast fail. - Spring Boot fat jar:
LaunchedClassLoader(3.2+) một loader → hiếm isolation. Spring DevTools:RestartClassLoadertách khỏi loader base → object sống xuyên reload thuộc loader cũ → cast fail với code mới. - Fix chung: đảm bảo class đi qua một loader duy nhất — sửa cấu hình nạp, không sửa biểu thức cast.
9. Tự kiểm tra
- Q1Vì sao hai ClassLoader nạp cùng một JAR có thể gây
ClassCastException"com.foo.Bar cannot be cast to com.foo.Bar"? - Q2Một Spring Boot app chạy
java -jar(không bật DevTools) némX cannot be cast to X. Vì sao ít khi nên nghi chính executable jar, và bạn khoanh vùng thủ phạm ở đâu? - Q3Một WAR ném
X cannot be cast to X; cùng một JAR nằm ở cảtomcat/liblẫnWEB-INF/lib. Loader nào nạp bản nào, vì sao trao đổi object giữa hai phía fail, và fix ra sao? - Q4Spring DevTools tách
RestartClassLoaderkhỏi loader base để làm gì, và tác dụng phụ khi có object sống xuyên reload là gì? - Q5Gặp
com.foo.Bar cannot be cast to com.foo.Bartrong production. Trình bày các bước xác nhận nguyên nhân và hướng fix.
Bài tiếp theo: Class file và javap — đọc instruction JVM từ binary
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
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