ClassNotFoundExceptionNoClassDefFoundError 到底有什么区别?为什么同一个类在两个框架里"互相不认识",明明全限定名一模一样?为什么 Tomcat 能在一个 JVM 里隔离多个 web 应用、热部署还不串味?这些问题的答案都藏在 Java 的类加载机制和双亲委派模型里。

场景:两个"相同"的类却不相等

1
2
3
4
5
ClassLoader cl1 = new MyClassLoader();
ClassLoader cl2 = new MyClassLoader();
Class<?> a = cl1.loadClass("com.demo.Foo");
Class<?> b = cl2.loadClass("com.demo.Foo");
System.out.println(a == b); // false!

明明是同一个 .class 文件、同一个全限定名,结果两个 Class 对象不相等,把一方的实例强转成另一方的类型会抛 ClassCastException。这揭示了一个核心事实:在 JVM 里,一个类的唯一标识不是它的全限定名,而是"全限定名 + 加载它的 ClassLoader"。这是理解一切类加载隔离问题的钥匙。

机制一:类加载的生命周期

一个类从 .class 字节流到可用,要经历:

1
2
加载 Loading → 验证 Verification → 准备 Preparation → 解析 Resolution → 初始化 Initialization
\_____________ 连接 Linking ______________/
  • 加载:通过类的全限定名获取字节流,在方法区生成 Class 对象。字节流来源不限于文件,也可以来自网络、动态生成(如代理)、加密包解密等。
  • 验证:确保字节码合法、安全,不会破坏 JVM。
  • 准备:为静态变量分配内存并设零值(注意此时 static int x = 5 只会先置 0,赋值 5 是初始化阶段做的;但 static final int x = 5 这种编译期常量会在准备阶段直接赋值)。
  • 解析:把常量池里的符号引用替换为直接引用。
  • 初始化:执行 <clinit> 方法——静态变量赋值和静态代码块按源码顺序合并而成。

初始化是惰性的。JVM 规定了几种主动引用才触发初始化:new 实例、读写非常量静态字段、调用静态方法、反射、初始化子类时父类先初始化、main 类启动等。常见误区:通过子类访问父类的静态字段,只会初始化父类不会初始化子类;引用一个类的编译期常量,根本不会触发该类初始化(常量在编译期已被内联到调用方)。

机制二:三层类加载器与双亲委派

JVM 内置三个层级的加载器:

1
2
3
4
5
Bootstrap ClassLoader(C++ 实现,加载 JDK 核心类,如 java.lang.*)

Platform/Extension ClassLoader(加载扩展/平台模块)

Application ClassLoader(加载 classpath 上你写的代码)

双亲委派(Parents Delegation)的逻辑是:当一个类加载器收到加载请求,它不先自己加载,而是先委托给父加载器,父加载器再往上委托,直到 Bootstrap。只有当父加载器表示"我加载不了",子加载器才尝试自己加载。源码体现在 ClassLoader.loadClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name); // 先查是否已加载
if (c == null) {
try {
if (parent != null)
c = parent.loadClass(name, false); // 委托父加载器
else
c = findBootstrapClassOrNull(name);
} catch (ClassNotFoundException e) { /* 父加载不了,落到下面 */ }

if (c == null) {
c = findClass(name); // 父加载不了,自己找
}
}
if (resolve) resolveClass(c);
return c;
}
}

为什么要这样设计? 两个目的:

  1. 安全:你写一个 java.lang.String 放到 classpath,永远不会被加载——请求层层上委托到 Bootstrap,它优先加载了 JDK 自带的 String,你的山寨版没机会执行。这防止了核心类被篡改。
  2. 一致性:保证核心类在整个 JVM 里只有一份。java.lang.Object 无论被谁请求,最终都由 Bootstrap 加载,所有人拿到的是同一个 Class。

机制三:打破双亲委派

双亲委派不是铁律,很多框架场景必须打破它。要自定义加载逻辑,正确做法是重写 findClass(保留委派),要彻底打破则重写 loadClass

1
2
3
4
5
6
7
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytes = loadClassBytes(name); // 自己读取字节流
return defineClass(name, bytes, 0, bytes.length);
}
}

典型的打破场景:

  • SPI 与线程上下文类加载器(TCCL):JDBC 这类 SPI,接口(如 java.sql.Driver)由 Bootstrap 加载,但具体实现(MySQL 驱动)在 classpath 上、得由 Application ClassLoader 加载。父加载器按双亲委派"看不到"子加载器加载的实现类。解决办法是线程上下文类加载器:Bootstrap 加载的代码通过 Thread.currentThread().getContextClassLoader() 反向拿到 Application 加载器去加载实现,相当于"父请子"。
  • 容器隔离:Tomcat 给每个 web 应用一个 WebappClassLoader,应用自己的类优先由它加载(而非一律上委托),从而实现多应用类隔离、同名不同版本的 jar 共存、以及热部署(丢弃旧加载器、用新加载器重载)。
  • OSGi / 模块化:用网状的类加载器实现细粒度模块隔离与版本管理。

工程权衡与踩坑

  • 类隔离 = 内存与复杂度成本。每个加载器维护自己的类空间,同一个类被多个加载器加载会在方法区存多份元数据。容器频繁热部署若旧加载器无法被回收(被某个静态引用、线程或 ThreadLocal 拖住),就会类加载器泄漏,表现为 Metaspace 持续增长直至 OutOfMemoryError: Metaspace
  • ClassNotFoundException vs NoClassDefFoundError:前者是显式 loadClass/Class.forName 时找不到类(受检异常);后者是类之前加载/初始化失败过,或链接时引用的类不在了(Error)。后者尤其坑:常见于静态初始化块抛异常,第一次报真实异常,之后再用这个类就只剩干瘪的 NoClassDefFoundError
  • 类初始化死锁:两个类的 <clinit> 互相依赖、由不同线程触发,可能死锁,因为初始化锁是按类加锁的。

小结

类的身份是"全限定名 + 类加载器"二元组,这是隔离的根基。双亲委派靠层层上委托保证核心类的安全与唯一;而 SPI、Web 容器、模块化又通过线程上下文类加载器或自定义加载器有节制地打破它。掌握加载的五个阶段、惰性初始化的触发条件,以及两类"找不到类"异常的区别,能让你在排查框架冲突、热部署泄漏时有的放矢。