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

Hai đường nạp cạnh nhau: child-first tự nạp bản Bar riêng nên obj và kiểu đích thuộc hai Class object khác nhau, cast ném ClassCastException; parent-first thì cả hai vế cùng một Class object nên cast chạy bình thường

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

  • X cannot be cast to X = hai loader nạp cùng class → hai Class<?> khác nhau. Xác nhận: in getClassLoader() hai phía; khác nhau = isolation, không phải bug type.
  • Tomcat đảo delegation: mỗi WAR dùng WebappClassLoader, tự tìm WEB-INF/lib trước (trừ servlet API và java.*). Bug kinh điển: cùng JAR ở cả tomcat/lib lẫn WEB-INF/lib → nạp hai lần → cast fail.
  • Spring Boot fat jar: LaunchedClassLoader (3.2+) một loader → hiếm isolation. Spring DevTools: RestartClassLoader tá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

Tự kiểm tra
0/5 câu đã trả lời
  1. 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"?
  2. 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?
  3. 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?
  4. 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ì?
  5. 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.

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