Java Internals & Concurrency/Classloader isolation: X cannot be cast to X (Tomcat, Boot)
50/75
Bài 50 / 75~14 phútJVM InternalsMiễn phí lượt xem

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ườngClassloader
Quốc gia cấp hộ chiếuClassLoader nạp class
Cuốn hộ chiếu cụ thểClass object trong JVM
Họ tên trên hộ chiếuTên đầy đủ của class (com.foo.Bar)
Cửa khẩu đòi đúng nước cấpPhép ép kiểu resolve qua một loader cụ thể
Hai hộ chiếu trùng tên, khác nướcX cannot be cast to X

Trước khi xem cơ chế, tự dựng lời giải:

💡 Thử đoán

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; nếu nó delegate lên app loader (parent-first như thường lệ) thì cả hai cùng (appLoader, Bar) và cast thành công (đây là lý do delegation parent-first tránh được lỗi này); (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.

flowchart TD
    L1["Loader L1"] --> C1["Class (L1, com.foo.Bar)"]
    L2["Loader L2"] --> C2["Class (L2, com.foo.Bar)"]
    C1 --> O["obj: instance cua (L1, com.foo.Bar)"]
    O -->|"ep ve kieu resolve qua L2"| X["ClassCastException:<br/>com.foo.Bar cannot be cast to com.foo.Bar"]
    C2 -.->|"kieu ben trai la (L2, ...)"| X

    style X fill:#FCA5A5

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/classesWEB-INF/lib trước, chỉ khi không thấy mới hỏi parent.

💡 Thử đoán

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-ClassJarLauncher; 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.

⚠️ Tên class đổi từ Spring Boot 3.2

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

💡 Thử đoán

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 ; 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:

  1. 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.
  2. Đọ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ới TargetType.class.getClassLoader() để khẳng định.
  3. 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/lib lẫn WEB-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, -cp thê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

📚 Deep Dive Oracle

Reference chính thức:

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

8. Tóm tắt

  • com.foo.Bar cannot be cast to com.foo.Bar = hai loader độc lập nạp cùng một class thành hai kiểu — không phải bug type.
  • Xác nhận bằng cách in getClassLoader() của cả hai phía; khác nhau là isolation.
  • Tomcat: mỗi WAR một WebappClassLoader; loader này đảo delegation (tự tìm WEB-INF/lib trước) để app override được library, trừ servlet API và java.*.
  • Bug Tomcat kinh điển: cùng JAR ở cả tomcat/lib lẫn WEB-INF/lib → nạp hai lần → cast fail.
  • Spring Boot executable jar: LaunchedClassLoader (trước 3.2 là LaunchedURLClassLoader) đọc class từ nested JAR trong BOOT-INF/lib.
  • Spring DevTools: RestartClassLoader tách khỏi loader base để reload nhanh; object sống xuyên reload thuộc loader cũ → cast fail với code mới.
  • Fix chung: đảm bảo class nạp 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

Tự kiểm tra
Q1
Vì 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"?

JVM định danh class bằng cặp (loader, tên đầy đủ), không chỉ tên. com.foo.Bar nạp qua L1 và qua L2 là hai Class<?> object khác nhau. Khi ép kiểu, kiểu đích resolve qua một loader (giả sử L2) còn object là instance qua L1 — hai Class khác nhau nên cast fail. Thông báo in tên đầy đủ cả hai vế; vì trùng tên nên ta thấy "X cannot be cast to X" (từ Java 9, phần trong ngoặc nêu tên hai loader).

Q2
Một Spring Boot app chạy java -jar (không bật DevTools) ném X 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?

Executable jar dùng đúng một LaunchedClassLoader cho toàn bộ class app và dependency trong BOOT-INF/lib. Class identity là (loader, tên); chỉ một loader thì mỗi class chỉ có một Class object — không có hai bản để cast fail. Nên bản thân fat jar hiếm khi tự gây isolation.

Khoanh vùng: (1) có bật DevTools không — RestartClassLoader tách khỏi loader base, object sống xuyên reload sẽ khác loader với code mới. (2) Có ai nạp thêm cùng class qua loader khác không — Java agent, -cp thêm tay, hay một plugin loader? In getClassLoader() hai phía để xác nhận. Nếu cả hai đều không, nhiều khả năng đây là ClassCastException thường (hai type khác nhau thật), không phải isolation.

Q3
Một WAR ném X cannot be cast to X; cùng một JAR nằm ở cả tomcat/lib lẫn WEB-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?

WebappClassLoader đảo delegation (tự tìm WEB-INF/lib trước), bản trong WEB-INF/lib được nạp qua webapp loader. Nhưng khi một class dùng chung ở tomcat/lib (nạp qua Common loader) cũng tham chiếu JAR đó, nó lại thấy bản của Common loader. Kết quả: hai Class object cho cùng tên, thuộc hai loader — object tạo ở phía này ép về kiểu ở phía kia thì fail.

Fix: dời JAR trùng về một loader — chỉ ở tomcat/lib (container + mọi app chung một bản) hoặc chỉ ở WEB-INF/lib (mỗi app một bản), đừng để cả hai. Lưu ý ngoại lệ: servlet API (jakarta.servlet.*) luôn nạp từ Common loader dù có bản trong WEB-INF/lib — nên đừng đóng gói servlet-api.jar vào WAR, kẻo giữ hai bản HttpServletRequest và mọi cuộc gọi qua ranh giới container cast fail.

Q4
Spring DevTools tách RestartClassLoader khỏi loader base để làm gì, và tác dụng phụ khi có object sống xuyên reload là gì?

Tách để reload nhanh: dependency (hàng trăm MB, hiếm đổi) ở loader base không reload; user code (vài MB) ở RestartClassLoader. Save file → DevTools drop RestartClassLoader cũ, tạo cái mới nạp lại user code — gần như tức thì, thay vì restart cả JVM.

Tác dụng phụ: object tạo trước reload thuộc RestartClassLoader cũ. Nếu nó sống xuyên reload (cache tĩnh ở loader base, hoặc lưu ngoài), code sau reload chạy trong loader mới ép nó về kiểu mới — cùng tên, khác loader → ClassCastException: X cannot be cast to X. Cách xử lý: restart đầy đủ khi đụng object cached, hoặc đừng để cache giữ object xuyên reload.

Q5
Gặp com.foo.Bar cannot be cast to com.foo.Bar trong production. Trình bày các bước xác nhận nguyên nhân và hướng fix.

Xác nhận: (1) Hai vế của thông báo trùng tên đầy đủ → nghi isolation ngay, không phải type thường. (2) Thông báo Java 9+ đã nêu tên hai loader trong ngoặc; in thêm obj.getClass().getClassLoader() so với TargetType.class.getClassLoader() để chắc. (3) Truy nguồn nạp trùng: JAR nào bị nạp qua hai loader.

Fix theo môi trường: Tomcat — dời JAR có mặt cả ở tomcat/lib lẫn WEB-INF/lib về một chỗ. DevTools — object từ cache sống xuyên reload thì restart đầy đủ hoặc bỏ cache. Reflection — nạp qua loader của chính instance thay vì loader ad-hoc. Đừng sửa biểu thức cast / thêm generic / đổi type — vấn đề ở cấu hình nạp class, không ở type.

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

Đặ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

Class file và javap — đọc instruction JVM từ binary