JAVA 类加载机制与双亲委派模型

13 · 类加载机制与双亲委派模型

对应学习计划:第一月第3周「JVM 虚拟机(面试高频压轴)」

面试定位:类加载是 JVM 三连问之一(内存、GC、类加载)。双亲委派「为什么这样设计」「怎么打破」是进阶面试最爱追的。


一、本文能帮你回答的面试题

  1. 类加载的生命周期有哪几个阶段?什么时候触发「加载」?
  2. 什么是双亲委派模型?加载过程是怎么样的?
  3. 为什么要双亲委派?好处是什么?
  4. 怎么打破双亲委派?有哪些经典案例?
  5. Class.forName 和 loadClass 的区别?(与反射篇呼应)
  6. 什么是类加载器?有哪几类?各自加载什么?
  7. 什么情况会抛 NoClassDefFoundError?和 ClassNotFoundException 区别?

二、概念介绍

类加载器(ClassLoader)的作用 :把 .class 文件(字节码)加载进 JVM,生成 Class 对象。类加载 = 类从字节码到「能 new 的对象」的过程。

三层基本加载器(JDK8):

复制代码
Bootstrap ClassLoader(启动类加载器,C++ 实现,无父)
 └── 加载 JDK 核心:rt.jar / java.lang.* / java.util.*
     (parent = null)
Extension ClassLoader(扩展类加载器)
 └── 加载 JDK/ext 目录下的扩展类(jre/lib/ext)
     (parent = Bootstrap)
AppClassLoader(应用/系统类加载器)
 └── 加载 classpath(你写的业务代码)
     (parent = Extension)

所有加载器都有 parent 引用,形成「树状 + 委派」结构。自定义扩展是通过继承 ClassLoader 再插入这个层级。


三、原理深入

3.1 类加载的生命周期(7 个阶段)

复制代码
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
      【链接阶段(Linking)】
阶段 干什么 例子
加载 读字节码到内存,生成 Class 对象 从 classpath/jar/网络读 .class
验证 检查字节码合法性(防恶意代码) 格式、符号引用校验
准备 静态变量分配内存并给默认零值(注意不是赋值) static int a = 1 此时 a=0
解析 符号引用 → 直接引用(常量池解析) 方法调用可解析了
初始化 执行<clinit>:静态变量赋初值 + 静态代码块 a = 1 真正发生在这
使用 new 对象调用方法 业务代码
卸载 类被 GC(条件苛刻:无实例+无引用+加载器可回收) 常用于动态类库

初始化时机(主动使用触发,面试爱问):

  • new、getstatic/putstatic、invokestatic(new 一个对象、读写静态变量、调静态方法)
  • 反射 Class.forName(默认初始化)、Class.getDeclaredField 等
  • 初始化子类会先初始化父类
  • JVM 启动时初始化的主类(main)

被动使用不初始化 :调用父类静态变量(只初始化父类)、数组声明、CONSTANT_MethodHandle 等。

3.2 双亲委派模型(核心)

复制代码
自定义想加载 com.demo.X
  ↓ 委派给 AppClassLoader
  ↓ 它先问 Extension:你能加载吗?
  ↓ 再问 Bootstrap:你能加载吗?
  ↓ Bootstrap 没有 → 回到 Extension → 没有再回到 App → 自己加载
加载过的类:JVM 有缓存(class → Class 对象),先查缓存
java 复制代码
protected Class<?> loadClass(String name) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);        // ① 已加载?直接返回
        if (c == null) {
            if (parent != null)
                c = parent.loadClass(name);        // ② 委派给父加载器
            else
                c = findBootstrapClassOrNull(name);
            if (c == null)
                c = findClass(name);               // ③ 父都没有才自己 load
        }
        return c;
    }
}

为什么这么设计(三个理由,背):

  1. 避免重复加载:同一个类只加载一次。
  2. 安全性 :核心类(java.lang.String)永远由 Bootstrap 加载,防止你写个恶意的 String 替换 JDK 的(类名重复也不冲突,因为加载器不同=不同类)。
  3. 一致性:核心库被信任,优先可信来源。

3.3 打破双亲委派(进阶题,必会 3 个案例)

案例 怎么破的 为什么
JDBC(SPI 经典) DriverManager(Bootstrap 加载)要加载 MySQL 等驱动(业务 classpath)→ 父加载器找不到 → 用线程上下文类加载器(AppClassLoader)绕道加载 核心库需要调用「与厂商相关的实现」
Tomcat(Web 隔离) 每个 WebApp 一个 WebAppClassLoader,先自己找,找不到才委派父(web 优先) 多个应用部署一起,各自 lib 可能同名不同版本,需隔离
OSGi / 热部署 ClassLoader 每个 bundle 独立加载器 模块化、动态卸载

怎么自己写打破 :重写 loadClass(不调 super.loadClass,改 findClass)或重写 findClass。

3.4 两个经典异常区别(爱考)

异常 时间 含义
ClassNotFoundException 在 loadClass/findClass 主动抛 想加载但找不到字节码
NoClassDefFoundError 运行到代码处 JVM 抛 「原来加载过但现在坏了/初始化失败」或版本冲突(编译有、运行没有)

实战案例 :idea 里删了一个依赖,编译期没问题(本地缓存),运行期抛 NoClassDefFoundError------依赖缺失的常见诊断。

3.5 类初始化失败:ExceptionInInitializerError

静态块抛异常 → 类初始化失败 → 之后所有用到该类的操作都抛 NoClassDefFoundError。常见于静态代码块依赖全局配置没就绪。


四、实战例子

实战 1:打印类加载器链

java 复制代码
public class ClassLoaderDemo {
    public static void main(String[] args) {
        ClassLoader app = ClassLoaderDemo.class.getClassLoader();
        System.out.println("业务类加载器: " + app);                  // AppClassLoader
        System.out.println("父: " + app.getParent());               // Extension
        System.out.println("祖父: " + app.getParent().getParent()); // null(Bootstrap)
        // String 由 Bootstrap 加载(getClassLoader() 返回 null)
        System.out.println("String 加载器: " + String.class.getClassLoader());
    }
}

实战 2:证明「同名类 ≠ 同一类」(加载器隔离)

java 复制代码
// 用两个不同的自定义 ClassLoader 分别 load 同一个 .class 文件
// 得到两个不同的 Class 对象,互相 instanceof 为 false
public class LoaderIsolationDemo {
    public static void main(String[] args) throws Exception {
        // 读字节码(省略文件读取细节)
        byte[] bytes = Files.readAllBytes(Path.of("target/classes/demo/Target.class"));

        ClassLoader l1 = new ByteClassLoader("loader1", bytes, ClassLoaderDemo.class.getClassLoader());
        ClassLoader l2 = new ByteClassLoader("loader2", bytes, ClassLoaderDemo.class.getClassLoader());

        Class<?> c1 = l1.loadClass("demo.Target");
        Class<?> c2 = l2.loadClass("demo.Target");
        System.out.println("c1 == c2 ? " + (c1 == c2));           // false!
        Object o1 = c1.getDeclaredConstructor().newInstance();
        // o1 instanceof demo.Target → 这里用 AppClassLoader 的 Target 判断 → false
    }

    static class ByteClassLoader extends ClassLoader {
        private final byte[] bytes;
        ByteClassLoader(String name, byte[] bytes, ClassLoader parent) {
            super(name, parent);
            this.bytes = bytes;
        }
        @Override protected Class<?> findClass(String name) {
            return defineClass(name, bytes, 0, bytes.length);
        }
    }
}

实战 3:测试「加载目录不在 classpath 的类」

java 复制代码
// 自定义 ClassLoader 把类文件从磁盘流读进来,动态加载(热部署原理)
public class HotDeployLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = "/tmp/classes/" + name.replace('.', '/') + ".class";
        try {
            byte[] bytes = Files.readAllBytes(Path.of(path));
            return defineClass(name, bytes, 0, bytes.length);   // 核心方法
        } catch (Exception e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

五、面试话术(背下来)

Q:类加载过程?

「加载、验证、准备、解析、初始化。加载是读字节码生成 Class;验证是查安全;准备工作是给静态变量分配内存并给零值;解析是把符号引用变直接引用;初始化是执行静态块和静态变量赋值。只有主动使用才会触发初始化。」

Q:双亲委派?

「类加载请求先委派给父加载器,父加载不了才自己加载。好处三点:避免重复加载;防止核心库(java.lang.String)被偷梁换柱,安全;保证核心类一致。一个类只要加载器不同就不是同一个类。」

Q:怎么打破双亲委派?

「重写 loadClass 不走父委派,改用 findClass 自己找。生产案例:JDBC 用线程上下文类加载器反转委派让核心库能找到厂商驱动;Tomcat 每个 WebApp 独立加载器做版本隔离;OSGi 做模块热部署。这三个案例是面试官要听的标准答案。」

Q:Class.forName 和 loadClass?

「Class.forName 默认会执行初始化(触发静态块执行),JDBC 注册驱动就靠它;loadClass 只加载不初始化,即不触发静态块,更适合只拿 Class 对象或延迟初始化场景。」


六、踩坑总结(易错点)

# 坑 说明
1 混淆「准备」和「初始化」 准备阶段静态变量只是零值;初始化才赋值
2 getParent() 是 null 就以为没有父 Bootstrap 用 C++ 实现,Java 层看不到,所以是 null,不代表无父
3 认为同名类会覆盖 不同加载器加载的同名类是不同类,互不干扰(依赖冲突问题由此而来)
4 Class.forName 不带 initialize=false 参数 触发初始化副作用,某些工具类加载时别这样
5 花栗鼠式依赖冲突 NoClassDefFoundError/ClassCastException(不同加载器同接口)要查 jar 版本和加载器来源
6 静态块抛异常 变成 ExceptionInInitializerError,之后该类所有使用都 NoClassDefFoundError

七、课后作业

  1. 手写一个「翻转委派」的 ClassLoader(先自己 findClass 再找父),加载两个不同目录的同名类验证隔离。
  2. 复现 ExceptionInInitializerError → 观察之后抛 NoClassDefFoundError 的行为。
  3. 阅读 DriverManager 源码,找到 ServiceLoader 和线程上下文类加载器的用法(JDBC SPI)。
  4. 画一张「双亲委派 + 打破(Tomcat/JDBC)」的原理图,讲给面试官听。
相关推荐
小蒜学长1 小时前
基于 Spring Boot 的英语单词背诵系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端
lambo mercy1 小时前
持久化01——线程级持久化
java·开发语言·jvm
CoderYanger2 小时前
Java EE 进阶:5. Spring IoC&DI(第一弹):Spring IoC
java·开发语言·spring boot·spring·面试·职场和发展·java-ee
真上帝的左手2 小时前
2. 编程语言-JavaScript
开发语言·javascript
zhanghaha13142 小时前
C# 系列教程_1 基础语法篇(面向新手系统教程)
开发语言·c#
通问AI2 小时前
【实战】企业口径一致性检查:用 Python 对齐官网/招聘平台/投标文件三个来源的成立日期与注册地
开发语言·python
zyseo82 小时前
谷歌SEO 内容更新焕新流程:让老页面重新起量
java·前端·javascript
老王IT2 小时前
Go开发环境搭建(window操作系统)
开发语言·后端·golang
学逆向的2 小时前
C++ 匿名命名空间
开发语言·c++·网络安全