← 返回博客
2026-09-01 08:00:01

类加载机制与双亲委派学习笔记:为什么你的 `ClassNotFoundException` 总在诡异的地方冒出来

类加载机制与双亲委派学习笔记:为什么你的 ClassNotFoundException 总在诡异的地方冒出来

先看一个真实场景。某天你接手一个老项目,启动时日志里报 NoClassDefFoundError: org/apache/commons/lang3/StringUtils,但 mvn dependency:tree 里明明有 commons-lang3。你查了半天,发现是另一个依赖把 commons-lang3 的旧版本给顶掉了。这种问题,不懂类加载机制,你只能靠猜。

先分清两个异常:ClassNotFoundException 和 NoClassDefFoundError

开头这个报错是 NoClassDefFoundError,不是 ClassNotFoundException。这两个经常被混在一起,其实完全不是一回事。

  1. 编译期有,运行期没了。开头场景就是这种:编译时 commons-lang3 在,运行时被 Maven 依赖仲裁换成了旧版本,旧版本里没有 StringUtils 这个类,于是报 Error。这是类加载机制里最常见的坑。
  2. 类初始化失败,之后再引用就报 Error。这是个经典面试坑,用一小段代码说清楚(本机 JDK 26 实测)。先造一个 static 块里必然炸的类:
public class InitFail {
    static {
        // 故意让初始化失败
        if (true) {
            throw new RuntimeException("static 块里炸了");
        }
    }
}

然后触发它两次:

public class InitFailDemo {
    public static void main(String[] args) throws Exception {
        try {
            Class.forName("InitFail");
        } catch (Throwable t) {
            System.out.println("第一次引用:" + t);
        }
        try {
            Class.forName("InitFail");
        } catch (Throwable t) {
            System.out.println("第二次引用:" + t);
        }
    }
}

输出:

第一次引用:java.lang.ExceptionInInitializerError
第二次引用:java.lang.NoClassDefFoundError: Could not initialize class InitFail

第一次触发 InitFail 的初始化,static 块抛异常,JVM 抛 ExceptionInInitializerError同时 JVM 记下这个类初始化失败了,第二次再引用,直接抛 NoClassDefFoundError: Could not initialize class InitFail,不会重试。所以"NoClassDefFoundError"不一定是类丢了,也可能是类炸了。

记一条判断线:类根本不存在 → ClassNotFoundException;类存在但加载或初始化出问题 → NoClassDefFoundError

手把手实操:自己写一个 ClassLoader,看看类到底怎么被加载的

先别管理论,动手跑一遍。本文实验全部跑在本机 JDK 26(OpenJDK 26.0.1),JDK 8 也适用,个别 API 差异会在文中标出。新建一个 Java 项目,写一个最简单的类:

// 文件:/tmp/classloader-demo/Hello.java
public class Hello {
    static {
        System.out.println("Hello 被加载了,加载器是:" + Hello.class.getClassLoader());
    }
}

编译:javac Hello.java,得到 Hello.class,把它放到一个独立目录,比如 /tmp/classloader-demo/classes。放独立目录是有讲究的:下面这个 demo 要保证 classpath 上找不到 Hello,父加载器问不到它,自定义加载器才能顶上。

然后写一个自定义 ClassLoader,直接读字节码:

import java.io.*;

public class MyClassLoader extends ClassLoader {
    private String path;

    public MyClassLoader(String path) {
        this.path = path;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String fileName = path + File.separator + name.replace('.', '/') + ".class";
        try (FileInputStream fis = new FileInputStream(fileName)) {
            byte[] bytes = new byte[fis.available()];
            fis.read(bytes);
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }

    public static void main(String[] args) throws Exception {
        MyClassLoader loader = new MyClassLoader("/tmp/classloader-demo/classes");
        Class<?> clazz = loader.loadClass("Hello");
        System.out.println("loadClass 已返回,static 块还没执行");
        Object o = clazz.getDeclaredConstructor().newInstance();
        System.out.println("Hello 的加载器:" + clazz.getClassLoader());
        System.out.println("System 的加载器:" + System.class.getClassLoader());
    }
}

跑一下,输出:

loadClass 已返回,static 块还没执行
Hello 被加载了,加载器是:MyClassLoader@8bcc55f
Hello 的加载器:MyClassLoader@8bcc55f
System 的加载器:null

关键点:

看源码及解析:双亲委派到底在防什么

打开 java.lang.ClassLoader 的源码,看 loadClass 方法(这段代码从 JDK 7 起结构就稳定了,本机 JDK 26 原样核对一致):

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;
    }
}

读这段代码时,先认清 ClassLoader 的定位:loadClass 写在 ClassLoader 基类里,它是所有加载器共用的模板,自己不会被 new 出来当加载器用。真正干活的是它的子类实例——JDK 启动时会用几个内置子类创建好固定几个加载器,并给它们设好 parent 串成一条链;你上一节自己写的 MyClassLoader extends ClassLoader,也属于这一类。所以源码里 parent 字段的类型是 ClassLoader——它指向的是链上的另一个加载器实例(运行时真正的那级"父"),跟 Java 继承里的父类没关系。下面回答问题提到的 AppClassLoaderExtClassLoader/PlatformClassLoader 就是那几个内置子类,Bootstrap 则是链路最顶端那个不算 Java 类的特殊加载器。

这段代码回答了三个问题:

为什么先查 findLoadedClass 同一个类在同一个加载器里只能被加载一次。JVM 用"加载器 + 类全名"作为唯一标识。两个不同的加载器可以加载同一个 Hello 类,它们互不干扰,但类型不兼容——这就是后面要说的"类隔离"。

为什么先问父加载器? 这是双亲委派的核心。parent 不是继承关系,是组合关系——每个加载器都持有一个父加载器的引用,先把机会让给父。那"父"具体是谁?JVM 内置的加载器从上到下排成三级:最顶层 Bootstrap(核心库)→ PlatformClassLoader(JDK 9 起;JDK 8 叫 ExtClassLoader,管扩展类库)→ AppClassLoader(应用类加载器,负责 classpath 上你写的类)。所谓"父",就是紧挨着它上面的那一级:AppClassLoader 的 parent 是 PlatformClassLoaderPlatformClassLoader 的 parent 是 Bootstrap。上面 demo 里的 MyClassLoader 没指定父,默认就挂在 AppClassLoader 下面。这条链的设计动机是保证核心类库的安全:你写的 java.lang.String 永远不会被加载,因为父加载器(Bootstrap)已经把它加载过了,子加载器根本没机会碰。

整个委派链长这样:

类加载器层级与双亲委派

每次 loadClass 都是同一套流程:先查自己有没有加载过 → 没有就问父 → 父一路向上问到 Bootstrap → Bootstrap 也找不到,才一层层回退 → 最后才轮到你自己的 findClass。所以"双亲委派"里的"父"不是优先级最高的执行者,而是被最先询问的那个。这也解释了为什么 classpath 上的类永远优先于你自定义加载器的私有目录:父能找到,就没你的事。

findBootstrapClassOrNull 是什么? Bootstrap 加载器是 C++ 写的,在 Java 里没有对应对象,所以用 null 表示。它负责加载 rt.jar(JDK 8)或 java.base 模块(JDK 9 起)。你写 new String() 时,String 类就是 Bootstrap 加载的。

再看一个细节:synchronized (getClassLoadingLock(name))。这个锁是 JDK 7 加的,防止并发加载同一个类。getClassLoadingLock 默认返回 this,但如果你重写了它,可以用别的对象做锁。这个设计解决了多线程同时 loadClass 时的重复加载问题。

验证方法:怎么确认类到底是谁加载的

跑一下上面的代码,你会看到 Hello 的加载器是 MyClassLoader,而 demo 最后一行 System 的加载器是 null。为什么是 null?因为 Bootstrap 在 Java 里没有对应对象,所以返回 null 表示。这个现象能帮你理解"父加载器优先"的实际效果:你的类被谁加载,取决于父链上谁先找到。

下面两个实验,比读十遍理论更能说明问题。

实验一:把 Hello.class 也放进 classpath,父加载器先赢。 上面的运行里,Hello.class 只放在独立目录,classpath 上找不到,父加载器(AppClassLoader)问不到它,才轮到 MyClassLoaderfindClass。现在把 Hello.class 所在目录也加进 classpath 再跑:

java -cp ".:/tmp/classloader-demo/classes" MyClassLoader

输出变成:

loadClass 已返回,static 块还没执行
Hello 被加载了,加载器是:jdk.internal.loader.ClassLoaders$AppClassLoader@18ff02e4
Hello 的加载器:jdk.internal.loader.ClassLoaders$AppClassLoader@18ff02e4

MyClassLoader.loadClass("Hello") 还是先问父,父(AppClassLoader)在 classpath 上找到了,直接加载,你的 findClass 根本没被调用。双亲委派就是"父能找到就不轮到你"。注意 JDK 9+ 的系统加载器类名是 jdk.internal.loader.ClassLoaders$AppClassLoader,JDK 8 里是 sun.misc.Launcher$AppClassLoader,名字随版本变,认准 AppClassLoader 这个后缀就行。

实验二:同一个 Hello,两个加载器各加载一份,类型互不兼容。 这是"加载器 + 类全名 = 类唯一标识"的直接证据。用两个 MyClassLoader 实例各自加载 Hello

public class IsolationDemo {
    public static void main(String[] args) throws Exception {
        MyClassLoader a = new MyClassLoader("/tmp/classloader-demo/classes");
        MyClassLoader b = new MyClassLoader("/tmp/classloader-demo/classes");
        Class<?> ca = a.loadClass("Hello");
        Class<?> cb = b.loadClass("Hello");
        System.out.println("ca == cb ? " + (ca == cb));
        Object oa = ca.getDeclaredConstructor().newInstance();
        Object ob = cb.getDeclaredConstructor().newInstance();
        System.out.println("ca.isInstance(oa) ? " + ca.isInstance(oa));
        System.out.println("cb.isInstance(ob) ? " + cb.isInstance(ob));
        System.out.println("cb.isInstance(oa) ? " + cb.isInstance(oa));
        try {
            cb.cast(oa);
            System.out.println("cast 成功?");
        } catch (ClassCastException e) {
            System.out.println("ClassCastException: " + e.getMessage());
        }
    }
}

输出(JDK 26 实测):

ca == cb ? false
Hello 被加载了,加载器是:MyClassLoader@58644d46
Hello 被加载了,加载器是:MyClassLoader@14dad5dc
ca.isInstance(oa) ? true
cb.isInstance(ob) ? true
cb.isInstance(oa) ? false
ClassCastException: Cannot cast Hello to Hello

四个信息点:

打破双亲委派:Tomcat 为什么要这么干

面试官最爱问的来了。双亲委派这么好,为什么还要打破它?

场景:Tomcat 要部署多个 Web 应用,每个应用可能依赖不同版本的 Spring。如果都用 AppClassLoader 加载,两个应用里的 Spring 版本冲突,只能有一个生效。Tomcat 的做法是:给每个 Web 应用一个独立的 WebAppClassLoader,重写它的 loadClass 把顺序反过来——先自己找,找不到再问 parent(它挂在谁下面,下面细说)。

注意:打破的只有 WebAppClassLoader 自己这一层,它上面的整条链没被动过。 WebAppClassLoader 的直接父是 Common 类加载器——负责加载 $CATALINA_HOME/lib(Tomcat 自身和 servlet-api.jar 都在这),Common 的父才是 JVM 的 AppClassLoader,再往上就是 Platform/Ext、Bootstrap。所以实际链是 WebAppClassLoader → Common → AppClassLoader → Platform/Ext → Bootstrap:只有最底下 WebAppClassLoader 一个把查找顺序反了过来,一旦它决定往上传,从 Common 到 Bootstrap 走的全是 JDK 默认的父优先,一层都没改。

连"先自己"都不是无条件的。 WebAppClassLoader 对两类类依然先交父:一是 java.* 这类 JDK 核心类——绝不让 Web 应用自己加载,否则每个应用都能塞一个自己的 java.lang.String;二是 Servlet API 这类容器和 Web 应用必须共享同一份的类,先问 Common 拿,保证 Tomcat 容器 new 出来塞给你的 HttpServletRequest 和你 import 的是同一个加载器加载的同一个类,instanceof、强转才不会炸。只有 WEB-INF/classesWEB-INF/lib 里你自己的类和第三方库,才走"先本地、找不到再问父"。准确说法是:Tomcat 只对 Web 应用自己的类反转查找顺序。

这样每个应用都能加载自己版本的 Spring,互不干扰。代价是:两个应用各自在 WEB-INF/lib 里加载的同名类互不认识——A 应用里的 Spring 对象没法直接丢给 B 应用,对 B 的类加载器来说那是"另一个 Spring"。这就是"类隔离"。正常委派和打破的差别,看下面这张图:

双亲委派与打破对比

同一个"向上问父"的结构,对 Web 应用自己的类,先后顺序反过来:正常是父优先(父能找到就没你事),Tomcat 是本地优先(先自己找,找不到才问父);而 JDK 核心类和 Servlet API 仍走父优先,和正常委派一致。

打破双亲委派的方式有两种:

重写 loadClass(全盘破坏)。像 Tomcat 那样,直接改 loadClass 的逻辑,不调用 super.loadClass。整个加载器的查找顺序都变了,这是全盘级别的破坏。

线程上下文类加载器(局部破坏)。JDBC 的 DriverManager 就是这么干的。DriverManager 是 Bootstrap 加载的,但它要加载各数据库厂商的驱动(在 classpath 里)。Bootstrap 找不到,怎么办?DriverManagerThread.currentThread().getContextClassLoader() 来加载驱动。这个上下文加载器默认是 AppClassLoader,可以访问 classpath。

面试追问点:SPI(Service Provider Interface)为什么需要打破双亲委派? 因为核心库(Bootstrap 加载)需要调用外部实现(AppClassLoader 加载),父加载器看不到子加载器的类,只能通过线程上下文加载器"反向"加载。JDBC、JNDI、SLF4J 都是这个套路。注意这种破坏是局部的:类加载的整体流程还是双亲委派,只是在"核心库要调用外部实现"这个特定口子上,用上下文加载器把方向倒了一下。面试回答要分清:Tomcat 是重写 loadClass 的全盘破坏,SPI 是线程上下文类加载器的局部破坏

再挖一层:JDK 9 的模块化对类加载有什么影响? 从 JDK 9 起,Bootstrap 不再只加载 rt.jar,而是按模块加载。ExtClassLoader 变成了 PlatformClassLoader。双亲委派的逻辑没变,但"父加载器能加载什么"变了。如果你在 JDK 8 上写 sun.misc.Unsafe 的代码,到 JDK 9 可能就报 Package sun.misc is declared in module jdk.unsupported, which does not export it。这不是类加载器的问题,是模块封装的问题——sun.miscjdk.unsupported 模块里,没有对外导出。

类加载的五步与初始化时机

面试让你说"类加载过程",说的是五步:

  1. 加载:把 .class 字节码读进来(可以来自文件、网络、内存),生成 Class 对象。这一步才真正用到加载器。
  2. 验证:校验字节码格式、符号引用是否合法,防止非法字节码混进来。
  3. 准备:给 static 变量分配内存并赋默认值。注意,static int x = 10 在这一步结束时 x 还是 0,赋值动作在第五步才发生。这是高频考点。
  4. 解析:把常量池里的符号引用(类名、方法名)替换成直接引用(内存地址、偏移)。有的 JVM 是懒解析,用到才解析。
  5. 初始化:执行 static 块和 static 变量赋值。这一步是懒的,只有"首次主动使用"才触发。

初始化什么时候触发?创建实例(new)、调用静态方法、访问静态字段(非常量)、反射(Class.forNamenewInstance)、初始化子类前先初始化父类、JVM 启动时初始化 main 类。注意:访问 static final 编译期常量触发初始化,因为编译期就内联了。

这里点破一个对照,面试常考:Class.forName("X") 默认会触发初始化——老 JDBC 代码写 Class.forName("com.mysql.jdbc.Driver"),就是借驱动类的 static 块执行,让它向 DriverManager 注册自己;而 ClassLoader.loadClass("X") 只加载、不初始化,上面 demo 里 MyClassLoader.loadClass("Hello") 返回后 static 块没跑,原因正是它。JDBC 4 之后驱动改走 SPI(ServiceLoader),不再需要手写这行,但面试问起「为什么老代码要 Class.forName」仍常考。想用 forName 又不触发初始化,用重载 Class.forName(name, false, loader)

前面 demo 的输出就是第五步的实锤:

loadClass 已返回,static 块还没执行
Hello 被加载了,加载器是:MyClassLoader@8bcc55f
Hello 的加载器:MyClassLoader@8bcc55f
System 的加载器:null

loadClass 只做了前四步(加载、验证、准备、解析),Hello 的 static 块没跑;newInstance() 是"首次主动使用",才触发初始化,static 块才打印。加载 ≠ 初始化loadClass 不会初始化类。面试官常拿这个点挖"类到底什么时候被初始化"。

上面五步是"类加载过程",但如果把视角拉长到类的完整生命周期,初始化之后还有两步:使用卸载。"使用"没什么机制可讲——初始化完成后类就进入活跃期,你 new、调静态方法都发生在这个阶段,类的实例被引用着,类本身就不会被卸载。真正值得关注的是卸载,要分清资格时点两层。先看资格:只有由自定义类加载器加载的类才谈得上卸载——前提是加载它的那个 ClassLoader 实例不可达,同时它加载的所有类既没有存活的实例、也没有任何 Class 对象引用,三条都满足,这些类才具备卸载资格。再看时点:具备资格不等于马上回收——方法区(Metaspace)里的类元数据,要等一次会执行类卸载的 GC 才真正回收:传统分代收集器(Serial/Parallel)通常只在 Full GC 才做类卸载,而 JDK 9+ 默认的 G1 在并发标记后的 GC 停顿里就能完成,不必死等 Full GC。把这两层合起来能推出面试常考的那条:只要 ClassLoader 实例还被引用着,它加载过的类就永远不会被卸载——反观 Bootstrap、AppClassLoader 这类系统核心加载器,被 JVM 一路强引用,所以它们加载的类永生不卸。把资格条件套回前面的例子:IsolationDemo 里两个 MyClassLoader 各加载一份 Hello,只有当一个 loader 实例被丢弃、它那份 Hello 类既没有存活实例也没有 Class 引用时,才具备卸载资格,等下一次能做类卸载的 GC 才真正消失。Tomcat 的热部署本质上就是丢掉旧的 WebAppClassLoader、再 new 一个新的——旧 loader 连同它从 WEB-INF/lib 加载的整批类一起失去可达性、整体回收,新 loader 重新加载新版本。

面试速答

类加载分五步:加载(读字节码)、验证(检查格式)、准备(分配静态变量内存并给默认值)、解析(把符号引用转直接引用)、初始化(执行 static 块和赋值,懒触发)。双亲委派是 loadClass 先问父加载器,父找不到才自己找,保证核心类不被篡改。打破双亲委派靠重写 loadClass(全盘)或线程上下文类加载器(局部),典型场景是 Tomcat 的 WebAppClassLoader 和 JDBC 的 DriverManager。被追问"类隔离"时,说清"加载器 + 类全名 = 类唯一标识":两个加载器加载同名类互不兼容,报错就是 ClassCastException: Cannot cast Hello to Hello。被追问两个异常时:类不存在是 ClassNotFoundException,类存在但加载或初始化出问题是 NoClassDefFoundError。被追问"类卸载"时:能被卸载的主要是自定义类加载器加载的类——加载它的 ClassLoader 实例不可达、且它加载的所有类没有存活实例和 Class 对象引用时,才具备卸载资格;实际回收要等一次会执行类卸载的 GC(传统收集器多为 Full GC,G1 不必等)。只要 ClassLoader 还被引用,类就永不卸载,所以 Bootstrap、AppClassLoader 加载的类永生不卸,Tomcat 热部署靠的就是换一个新的 WebAppClassLoader。


核心收获:类加载机制的本质是"谁加载、什么时候加载、加载谁的"三个问题,双亲委派是安全策略,打破它是隔离需求。下一步:把上面的代码跑一遍,再自己写一个重写 loadClass 的加载器,对比两种方式的差异。

本文关键词:双亲委派、ClassLoader、打破双亲委派、类加载过程