跳到正文

类加载机制 ​

一句话: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 的顺序是:

  1. 查 findLoadedClass,已加载就返回。
  2. 调 parent.loadClass,父加载器继续向上。
  3. 父加载器都找不到,才调自己的 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 更快。

把一门技术讲透到既懂也会用