为什么同一个类会被加载两次:双亲委派划出的身份边界

类加载器把字节码读进 JVM,双亲委派规定它先问父加载器、再自己找,这个顺序保证了核心类不被替换、同一个类不重复定义。但"同一个类"的判定靠的是加载器加全限定名,两个加载器各读一份同名 class,就会得到两个互不相认的 Class 对象,强转直接抛 ClassCastException。很多人把双亲委派当成强制规范,或以为类只会加载一次,也说不清 Tomcat 和 JDBC 为什么要故意绕开它。读完你能理清加载、链接、初始化的触发时机,判断该重写 findClass 还是 loadClass,并在类冲突现场快速定位是哪个加载器干的。


title: 为什么同一个类会被加载两次:双亲委派划出的身份边界 date: 2026-11-06T23:00:00+08:00 type: kb tags: [科普] status: published summary: 类加载器把字节码读进 JVM,双亲委派规定它先问父加载器、再自己找,这个顺序保证了核心类不被替换、同一个类不重复定义。但"同一个类"的判定靠的是加载器加全限定名,两个加载器各读一份同名 class,就会得到两个互不相认的 Class 对象,强转直接抛 ClassCastException。很多人把双亲委派当成强制规范,或以为类只会加载一次,也说不清 Tomcat 和 JDBC 为什么要故意绕开它。读完你能理清加载、链接、初始化的触发时机,判断该重写 findClass 还是 loadClass,并在类冲突现场快速定位是哪个加载器干的。 ---

是什么

类加载器(ClassLoader)负责把 .class 文件的字节流读进来,转成方法区里的运行时结构,再在堆上生成一个 java.lang.Class 对象作为访问入口。双亲委派(parent delegation)是它默认的查找顺序:收到加载请求先看自己加载过没有,没有就交给 parent,parent 那一路都找不到,才轮到自己调用 findClass 去读字节流。

一句话直觉:像在公司找一份归档文件,先翻自己抽屉,再逐级上报到总部档案室,总部也没有,才回到本层自己翻。这个顺序顺带保证了 java.lang.Object 在整条链上只有一份,否则连"什么是同一个类型"都无从谈起。

典型场景是两类线上问题:明明包名类名都一样,instanceof 却是 false、强转抛 ClassCastException;或者依赖里同一个类出现两个版本,运行时报 NoSuchMethodError。它们多半不是"类写错了",而是这个类被不同的加载器各定义了一份。

核心原理

先看一个类从字节流到可用要过哪几关。加载之后是链接,链接又分验证、准备、解析三步:验证检查字节码格式与语义;准备给类变量分配内存并设零值,被 ConstantValue 标记的 static final 常量在这一步就拿到初值;解析把常量池里的符号引用换成直接引用,这一步可以推迟到初始化之后。最后是初始化,执行编译器生成的 <clinit>(),静态赋值和静态块都在这里,JVM 保证同一个类的 <clinit> 在多线程下只执行一次,其余线程阻塞等待。

初始化不是加载完就自动发生的。new、读写静态字段、调用静态方法、反射、初始化子类前先初始化父类、含 main 方法的启动类,这些主动引用才会触发;而定义数组、通过子类引用父类的静态字段、读取编译期常量,都不会触发。Class.forName(name) 默认初始化,ClassLoader.loadClass 默认只加载不初始化,这个差别是很多老代码 Class.forName("com.mysql.jdbc.Driver") 能完成驱动注册的原因。

双亲委派的逻辑全在 loadClass 里,核心不过十几行:

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); // parent 为 null 即 Bootstrap
            } catch (ClassNotFoundException ignored) { }
            if (c == null) c = findClass(name);      // 父辈都找不到,自己找
        }
        if (resolve) resolveClass(c);
        return c;
    }
}

三个加载器各管一段:Bootstrap 由 JVM 自身实现,负责 java.lang.* 等核心类,在 Java 里用 null 表示,String.class.getClassLoader() 拿到的就是 null;Platform 在 Java 9 之后取代了 Extension,加载 java.sql、java.xml 这类平台模块;App 加载 classpath 或模块路径上的应用代码。Java 9 起 App 不再是 URLClassLoader 的子类,模块化还引入了应用加载器直接向 Bootstrap 查找模块的路径,但"先向上、再自找"的基本形状没变。

还要分清三个方法的职责:loadClass 管查找与委派,findClass 负责按自己的规则找字节流(默认实现直接抛 ClassNotFoundException),defineClass 才是把字节数组变成 Class 对象的 native 入口。自定义加载器若只想换来源,覆盖 findClass 即可;想换顺序,才去动 loadClass。

关键在于,类的身份是"加载器 + 全限定名"。同一个 class 文件被两个加载器分别 defineClass,JVM 里就有两个互不相干的 Class 对象:它们的 getName() 一模一样,isInstance 却互相返回 false。这也是 Tomcat 能给每个 Web 应用一份自己的类、JDBC 能让 JDK 核心加载器里的 DriverManager 用上 classpath 里的驱动实现的原因。

委派还带来单向的可见性:子加载器能看到父加载器已经定义的类,父加载器却看不到子加载器里的类。于是"父加载器加载的代码要用子加载器的实现"就成了结构性难题,SPI 才需要 TCCL 这样一条反向通道。

打破委派有三条常见路子。JDBC/JNDI 走的是线程上下文类加载器(TCCL):父加载器加载的接口需要回调子加载器的实现,于是 ServiceLoader 用 Thread.currentThread().getContextClassLoader() 反向去 classpath 找实现类。Tomcat 的 WebAppClassLoader 直接重写 loadClass,先本地找、再往上委派,但对 java.* 这类受保护包仍然先交给父加载器,否则连 Object 都会打架。OSGi 和各类插件框架则把层次结构改成网状,按包级别的导入导出关系委派。

常见考点与误区

  • 误区一:"双亲"就是两个父加载器,或者父子靠继承实现:其实是单亲,ClassLoader 里只有一个 parent 字段,靠组合而非 extends;Bootstrap 没有 parent,getParent() 返回 null。
  • 误区二:双亲委派是 JVM 强制规范:它只是 JDK 推荐的约定,loadClass 是 protected 且可重写。真正的硬保护在 defineClass:以 java. 开头的包名不允许被自定义加载器定义,会抛 SecurityException。所以能改的是查找顺序,改不了"自己造一个 java.lang.String"。
  • 误区三:"一个类只会被加载一次":准确说是同一个加载器内只定义一次。不同加载器各有一份,Class 对象不相等,静态变量也不共享。
  • 误区四:重写 findClass 就算打破委派:重写 findClass 只是换字节流来源,委派顺序照旧;要改顺序必须重写 loadClass。
  • 误区五:TCCL 就是"加载当前类的加载器":不是。它默认继承父线程,主线程里通常就是 App 加载器,和当前代码由谁加载无关。
  • 误区六:类加载完就永远留在元空间:类可以被卸载,条件是它的所有实例可回收、Class 对象无引用、加载它的 ClassLoader 也可回收。反复热部署却一直复用旧加载器,元空间就会一路涨。
  • 误区七:数组类和基本类型也有自己的加载器:数组类由 JVM 直接创建,组件类型是类时沿用那个类的加载器,组件是基本类型时通常归 Bootstrap;基本类型本身没有对应的 ClassLoader。

实践建议

  • 自定义加载器优先只重写 findClass,把加密、远程、隔离的读取逻辑放进去,委派交给父类;只有做容器隔离或插件热替换时才动 loadClass,并且记住把 java.* 先让给 Bootstrap。
  • 用 TCCL 的地方一律 try/finally 还原,线程池里的线程尤其要小心:一个请求设了 Web 应用加载器却没清掉,下一个请求会继承错的加载器,旧加载器也一直回收不了。
  • 排查类冲突先打印两边的加载器:obj.getClass().getClassLoader() 和 Target.class.getClassLoader() 一比,是不是同一份立刻清楚;再配合 -Xlog:class+load=info(JDK 9+)或 jmap -clstats <pid> 看每个加载器装了多少类。
  • 热部署与插件化场景,发布时换新的加载器而不是复用,同时清理 ThreadLocal、注册的 JDBC 驱动、定时器和线程池,否则旧加载器被引用,类永远卸不掉。
  • 构建期消掉重复类比运行期救火便宜:用 mvn dependency:tree 或 Gradle 的 dependencyInsight 找出同一坐标的多个版本,必要时用 shaded jar 重定位包名。

一句话总结:双亲委派管的是查找顺序,类加载器划的是身份边界,两者一起决定了你拿到的是哪一个类。

相关阅读

← 返回文章志