Skip to content

类加载机制 ​

S6-008建议学高级约 60 分钟JDK 21Java 高级与架构 · JVM

1. 本节定位 ​

「双亲委派」四个字谁都会背,但能亲手打破它、并亲眼看到后果的人不多。

★ 本节用自写的类加载器做了 5 组实验,其中两组是「反常识」的:

text
自定义加载器读同一个 .class 文件,却「不生效」——因为父加载器先命中 ✓✓(委派生效)
强制跳过父加载器后:同名同字节码,instanceof 一个 true 一个 false ✗✗(类型不兼容)
切断引用 + System.gc() → 加载器被回收 true ✓✓(类随之卸载)

2. 学习目标 ​

  • 说清类的生命周期,以及「准备」与「初始化」的区别 ★★★;
  • 会用三类加载器与委派链解释一个类从哪来 ★★★;
  • 能亲手写出「遵守委派」与「打破委派」的加载器,并解释后果 ★★★;
  • 理解「类的唯一标识 = 类名 + 加载器」,能解释热部署与类冲突 ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

★ 线上三类「看起来毫无道理」的故障,根都在类加载 ✗✗:

现象真正原因
ClassNotFoundException / NoClassDefFoundError ✗✗前者「加载时找不到」,后者「加载过但初始化失败」
改了代码不生效 ✗✗加载器没换,旧类还在用(Tomcat 热部署的坑)
同一个类强转失败 ✗✗✗类名相同、加载器不同 —— 本节将亲手复现

5. 核心概念 ​

5.1 类的生命周期 ★★★ ​

text
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
      (准备:静态字段赋零值)(初始化:静态块执行、赋真值)

★★ 关键分界:「加载」不等于「初始化」 ✓✓✓ —— 初始化只发生一次,且推迟到首次真正使用时(new、访问静态字段、反射调用等)。

★ 主动引用的六种触发场景(其余如读编译期常量不触发)✓:

会触发初始化 ✓不会触发 ✗
new、访问静态字段/方法、反射 Class.forName(默认)读 static final 编译期常量(被内联)
初始化子类时父类先初始化ClassLoader.loadClass(只加载)
作为 main 类的入口类字面量 Xxx.class(只加载)

5.2 三类加载器与委派链(实测)★★★ ​

text
String.class            → null(Bootstrap,C++ 实现,看不见)
javax.sql.DataSource    → jdk.internal.loader.ClassLoaders$PlatformClassLoader@7852e922
ClassLoadingLesson      → jdk.internal.loader.ClassLoaders$AppClassLoader@105be200
App 的父加载器      = PlatformClassLoader
Platform 的父加载器 = null(Bootstrap)
加载器加载什么可见性
Bootstrap ★★★java.* 核心库(<JAVA_HOME>/lib)最顶层,Java 代码里看不到
Platform ★★部分标准模块(如 java.sql 的一部分)位于中间层
App ★★★classpath(你的代码与三方 jar)最底层,最常用

★ 双亲委派的流程 ✓✓✓:收到加载请求 → 先交给父加载器 → 父加载器逐层上报到 Bootstrap → 父辈都加载不了,自己才加载。

★★★ 三个作用(实测背书):

  1. 避免重复加载——本节实测:自定义加载器也去读了文件,但返回的是与 App 相同的 Class(只定义一次)✓✓;
  2. 防止核心类被顶替——你写一个 java.lang.String 也加载不了(会被委派给 Bootstrap)✓✓;
  3. 保证类型一致——同一个类在不同加载器里是不同的类型(下一小节实测)✗✗。

5.3 打破委派与「类唯一标识」(实测)★★★ ​

把 MirrorClassLoader 改成对目标类「自己先加载」后:

text
是同一个 Class 吗? false ✗✗(自己先加载,父辈被跳过)
app 版实例 instanceof ProbeSubject → true ✓
自定义版实例 instanceof ProbeSubject → false ✗✗
强转直接抛 ClassCastException:class cn.kb.learn.deep.jvm.ProbeSubject cannot be cast to
class cn.kb.learn.deep.jvm.ProbeSubject (…is in unnamed module of loader
cn.kb.learn.deep.jvm.MirrorClassLoader @12edcd21; …is in unnamed module of loader 'app')

★★★ 类的唯一标识 = 「全限定类名 + 定义它的类加载器」 ✓✓✓ —— 同名同字节码,只要加载器不同,就是两个互不兼容的类型 ✗✗。

★ 现实里谁在打破委派 ✓:

场景做法与目的
Tomcat 热部署 ★★每个 Web 应用一个独立加载器:重发时换新加载器 = 换新类
SPI(JDBC 驱动) ★★Bootstrap 加载接口、线程上下文加载器加载实现
OSGi / 插件化 ★按模块隔离,同名类可以共存

5.4 类的卸载(实测)★★ ​

text
切断全部强引用 + System.gc() 后,加载器被回收? true

★★ 条件:类的加载器不可达 → 类(连同静态字段)一起卸载 ✓✓ —— 热部署内存泄漏的经典原因:旧加载器被静态字段 / 未结束的线程 / ThreadLocal 意外引用住 ✗✗(呼应 《虚拟线程》 的 ThreadLocal 话题)。

6. 递进示例 ​

6.1 Class.forName 与 loadClass 的差别 ​

java
Class<?> a = ClassLoader.getSystemClassLoader().loadClass("cn.kb.learn.deep.jvm.InitProbe");
// 只加载,不初始化 —— 静态块不执行 ✓
Class<?> b = Class.forName("cn.kb.learn.deep.jvm.InitProbe");
// 默认 initialize=true —— 静态块在此执行 ✓✓

★ 这就是 JDBC 老代码写 Class.forName("com.mysql.cj.jdbc.Driver") 的原因 ✓✓: 驱动类靠静态块把自身注册进 DriverManager —— 必须触发初始化。

6.2 最小自定义加载器 ​

java
public class MirrorClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Path file = dir.resolve(name.replace('.', '/') + ".class");
        byte[] bytes = Files.readAllBytes(file);      // 字节码从哪来,你说了算
        return defineClass(name, bytes, 0, bytes.length);
    }
}

★ 加载器只做两件事:找到字节码 → defineClass 定义成类 ✓✓(网络、加密 jar、内存字节码都能当来源)。

6.3 查看「某个类是谁加载的」 ​

java
System.out.println(SomeClass.class.getClassLoader());

★ 排障第一动作 ✓✓:NoClassDefFoundError / 类型冲突时,先确认两边的加载器是不是同一个。

7. 完整可运行代码与实测 ​

代码:labs/java-deep/src/main/java/cn/kb/learn/deep/jvm/ClassLoadingLesson.java(配 InitProbe、ProbeSubject、MirrorClassLoader)

实测输出(关键部分):

text
===== 1. 三类加载器与委派链 =====
  String.class            → null(Bootstrap,C++ 实现,看不见)
  javax.sql.DataSource    → jdk.internal.loader.ClassLoaders$PlatformClassLoader@7852e922
  ClassLoadingLesson      → jdk.internal.loader.ClassLoaders$AppClassLoader@105be200

===== 2. 加载 ≠ 初始化:静态块什么时候执行 =====
  第 1 步:读编译期常量 InitProbe.TAG(会被内联,不触发初始化)
         tag = probe(上面没有静态块输出 ✓)
  第 2 步:ClassLoader.loadClass(只加载、不初始化)
         已装入 cn.kb.learn.deep.jvm.InitProbe(仍没有静态块输出 ✓)
  第 3 步:Class.forName(默认 initialize=true)
        [静态块执行] InitProbe 正在初始化 → 静态字段赋真值
         两个 Class 是同一个吗? true(同一个 Class,只是初始化晚了)
  第 4 步:再次访问静态字段(不重复打印静态块)
         InitProbe.counter = 42

===== 3. 双亲委派:先问父辈 =====
  是同一个 Class 吗? true ← 父加载器先命中,只定义了一次 ✓✓

===== 4. 打破委派:同一个类名,两个 Class 对象 =====
  是同一个 Class 吗? false ✗✗(自己先加载,父辈被跳过)
  加载器分别是:ClassLoaders$AppClassLoader / MirrorClassLoader
  app 版实例 instanceof ProbeSubject → true ✓
  自定义版实例 instanceof ProbeSubject → false ✗✗

===== 5. 类的卸载:加载器不可达 = 类可卸载 =====
  切断全部强引用 + System.gc() 后,加载器被回收? true

★ 第 5 组的注意点 ✓:GC 不保证立即回收(本节多轮触发才稳定观察到)。

8. Debug 观察点 ​

  1. 打印任意类的 getClassLoader(),把「谁加载的」问到底 ✓✓✓;
  2. java -verbose:class 观察类加载顺序与加载器 ✓✓;
  3. 把 MirrorClassLoader 的 selfFirstClass 换成 java.lang.String 试试 ✗✗(defineClass 会被安全机制拒绝)✓;
  4. -Xlog:class+load 在 JDK 21 下看加载日志与来源 jar ✓。

9. 常见错误 ​

#错误说明
1把 loadClass 当初始化用 ✗✗静态块不会执行(JDBC 驱动就靠 forName)✓✓
2两个同名类靠强转打通 ✗✗✗不同加载器 = 不同类型,必然 ClassCastException ✓
3NoClassDefFoundError 当「没引入依赖」 ✗它常是「初始化失败」——查静态块的异常 ✓
4热部署后内存持续上涨 ✗✗旧加载器被静态字段/线程引用住 ✓
5自定义加载器忘了 defineClass 的包名归属 ✗defineClass 的 name 必须与字节码一致 ✓

10. 动手实验 ​

  1. 跑 ClassLoadingLesson 五组实验 ✓✓✓;
  2. 把 ProbeSubject 的字节码改一个字节再加载,观察 LinkageError ✓✓;
  3. 用 -Xlog:class+load 观察自己程序的类从哪个 jar 来 ✓;
  4. 用 WeakReference 观察加载器卸载(本机实测成功)✓✓。

验收方式:能对一段「改了代码不生效」「同名类强转失败」的现场,说出加载器层面的原因与验证步骤。

扩展任务:写一个「类冲突检测」小工具:列出指定 jar 包被哪些加载器加载过(呼应线上 NoSuchMethodError 排查)✓✓

11. 练习题 ​

  1. 加载与初始化的边界在哪?各自由什么触发?
  2. 双亲委派的三个作用?
  3. 「类的唯一标识」是什么?为什么同名类会不兼容?
  4. 类被卸载的条件是什么?热部署泄漏的典型原因?
  5. ClassNotFoundException 与 NoClassDefFoundError 的区别?
参考答案(要点)
  1. 初始化 = 静态块 + 赋真值,发生在首次主动使用;加载只产生 Class 对象 ✓✓★
  2. 避免重复加载、防止核心类被顶替、保证类型一致 ✓✓★
  3. 全限定类名 + 定义它的加载器;不同加载器定义的同名类互不兼容 ✗✗★
  4. 加载器不可达;泄漏 = 旧加载器被静态字段 / 线程 / ThreadLocal 引用 ✗✓★
  5. 前者:加载阶段找不到字节码(拼错名字、缺 jar);后者:类已被加载过但初始化失败(静态块抛异常)✓★

12. 掌握标准 ​

  • [ ] 能画出生命周期并区分准备/初始化 ★★★;
  • [ ] 会解释三类加载器与委派链 ★★★;
  • [ ] 能写出两种自定义加载器并解释差异 ★★★;
  • [ ] 理解类唯一标识与热部署原理 ★★★;
  • [ ] 会用手法验证「这个类是谁加载的」★★★。

13. 扩展资源 ​

14. 下一步学习 ​

按顺序学 《垃圾回收算法与收集器》(垃圾回收算法与收集器)→ 《GC 日志分析与 JVM 调优》(GC 日志分析与 JVM 调优)。 本节思路同时支撑 《Arthas 与线上问题诊断》(Arthas 用 sc -d 查类的加载器)与 《字节码与 Java Agent》(Agent 如何改写类文件)。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引