主题
类加载机制
一句话:JVM 在第一次主动使用类时才加载它,并通过「先问父加载器」的双亲委派保护核心类、建立命名空间;代价是隔离和热部署必须自己打破这条链。
适用 JDK 17 / JDK 21。JDK 9 起扩展类加载器被平台类加载器取代,模块系统改变了可见性规则。
为什么需要
如果每个类都由业务代码随意定义,任何人都能造一个 java.lang.String 替换核心类,JVM 的安全模型会直接失效。如果所有类都由同一个加载器加载,同一个库的两个版本又会互相覆盖。
类加载机制要同时解决三件事:按需加载、核心类保护、同名类的隔离。双亲委派是这三件事的最小公约数。
核心机制
加载、链接、初始化
| 阶段 | 做什么 | 关键点 |
|---|---|---|
| 加载 | 找到 .class 字节流,生成 Class 对象 | 来源可以是文件、jar、网络、动态生成 |
| 验证 | 检查字节码格式、语义和符号引用 | 防止非法字节码破坏 JVM |
| 准备 | 给静态字段分配内存并赋零值 | static int x = 1 此时还是 0 |
| 解析 | 把符号引用转成直接引用 | 可以延迟到首次使用 |
| 初始化 | 执行 <clinit>() | 静态赋值和静态块在这里执行 |
初始化不是加载的同义词。以下行为会触发初始化:new、访问非编译期常量的静态字段、调用静态方法、反射、初始化子类、作为启动类、MethodHandle 解析到静态成员。static final int A = 1 这类编译期常量在编译时已内联,不会触发初始化。
双亲委派
ClassLoader.loadClass 的顺序是:
- 查
findLoadedClass,已加载就返回。 - 调
parent.loadClass,父加载器继续向上。 - 父加载器都找不到,才调自己的
findClass。
JDK 9+ 的三个核心加载器:
- Bootstrap ClassLoader:加载
java.base等核心模块,Java 代码里通常表现为null。 - Platform ClassLoader:加载 Java SE 平台模块中除 Bootstrap 外的类,替代 JDK 8 的 Extension ClassLoader。
- Application ClassLoader:加载 classpath 和模块路径上的应用类。
双亲委派保证 java.lang.String 永远由 Bootstrap 加载,业务类不能伪造核心类;同时让不同加载器加载的同名类成为两个不同的类型。判断两个类是否相同,要同时看全限定名和定义类加载器。
什么时候会打破双亲委派
- SPI:JDBC 驱动由应用类加载器加载,但
DriverManager在核心模块里,核心代码看不到应用类。于是用线程上下文类加载器(TCCL)反向加载实现。 - Web 容器:Tomcat 的 WebappClassLoader 对应用类先自己找、再问父加载器,保证两个应用可以各自带一个版本的 Spring。
- 热部署/字节码增强:新加载器加载修改后的类,旧加载器整体丢弃;Agent 通过
Instrumentation重新定义已加载类。 - 模块系统:JDK 9 的
module-info.java用requires、exports、opens控制可见性,双亲委派仍在,但「父加载器能看见」不再等于「模块允许访问」。
最小上手
下面的类不改写 findClass,只在 loadClass 上打印日志,用来观察父加载器优先:
java
public class LoaderDemo extends ClassLoader {
LoaderDemo() { super(LoaderDemo.class.getClassLoader()); }
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
System.out.println("load " + name + " via " + getClass().getSimpleName());
return super.loadClass(name, resolve);
}
public static void main(String[] args) throws Exception {
Class<?> c = new LoaderDemo().loadClass("java.lang.String");
System.out.println("loader=" + c.getClassLoader()); // null = Bootstrap
System.out.println("same=" + (c == String.class)); // true
}
}bash
javac LoaderDemo.java
java -Xlog:class+load=info LoaderDemo | grep -E 'String|LoaderDemo'预期能看到 java.lang.String 的加载器是 bootstrap,并且 same=true。即使把 findClass 改成先加载 java.lang.String,JVM 也会以 java.lang.Prohibited package name 拒绝,核心包不是靠约定保护的。
ClassNotFoundException 与 NoClassDefFoundError
| 异常 | 触发点 | 典型原因 |
|---|---|---|
ClassNotFoundException | 受检异常,Class.forName、loadClass、ServiceLoader 按名字找不到类 | 类不在 classpath、名字拼错、依赖没打进来、TCCL 指错 |
NoClassDefFoundError | 链接错误,JVM 在解析/初始化一个编译期存在的类时失败 | 运行时缺依赖、静态初始化抛异常、类被另一个加载器加载后不可见 |
关键区别:前者是「按名字没找到」,后者是「编译时见过,运行时用不了」。NoClassDefFoundError 的 Caused by 经常是 ExceptionInInitializerError,再往下才是静态块里的真实异常;只看第一行会误判成缺 jar。
生产动作
- 看类从哪来:JDK 9+ 用
java -Xlog:class+load=info -Xlog:class+unload=info -jar app.jar,日志会给出类名、来源和加载器。 - 看加载器统计:
jcmd <pid> VM.classloader_stats观察每个加载器加载了多少类;数字只增不减通常意味着类加载器泄漏。 - 查依赖:
jdeps --print-module-deps app.jar看模块依赖;mvn dependency:tree排查同一个库的多版本。 - 元空间兜底:
-XX:MaxMetaspaceSize=512m防止动态类无限增长;出现OutOfMemoryError: Metaspace时先找谁在生成类,而不是只调大上限。 - TCCL 排查:线程池复用时 TCCL 可能还指向上一个应用的加载器,框架代码取 TCCL 前先确认当前线程的上下文;Web 应用卸载时清理线程池和 TCCL。
边界
- 双亲委派不是规范强制:它只是
ClassLoader的默认实现,Tomcat、OSGi、Agent 都会改写顺序;不要用它解释所有加载行为。 - 父加载器看不见子加载器:父加载器加载的类不能直接引用子加载器里的实现,SPI 和 TCCL 就是为此存在。
- 模块不是类加载器:JDK 9 模块主要约束可读性和导出包,类仍然由加载器定义;
--add-opens解决的是反射访问,不是类找不到。 - 热部署不是免费:旧类只有在其加载器不可达时才能卸载,线程、ThreadLocal、JDBC 驱动和静态缓存都可能把旧加载器钉住。
- 自定义加载器不能定义
java.*:JVM 会拒绝核心包,安全性不依赖双亲委派这一条实现。
讲清楚
类加载分加载、链接、初始化三大步,静态字段在准备阶段只赋零值。双亲委派的核心是「先问父加载器」,它保证核心类不被伪造,也让不同加载器下的同名类成为不同命名空间。JDK 9 以后核心加载器是 Bootstrap、Platform、Application,扩展加载器已经不存在。
破坏双亲委派的场景都有明确动机:SPI 需要父加载器反向拿到子加载器的实现,Tomcat 需要应用间隔离,热部署需要丢掉旧加载器。排障时先分清
ClassNotFoundException和NoClassDefFoundError:前者是按名字没找到,后者是编译时存在、运行时链接或初始化失败。看-Xlog:class+load和VM.classloader_stats,比猜 classpath 更快。