JAVA · Vol.III · DAY 32 · JVM 原理与调优
类加载机制:双亲委派模型与三种破坏场景
开场:类加载的三个阶段
你在 IDE 里写 public class App { public static void main(String[] args) {...} },编译得到 App.class 字节码文件。从"磁盘上的字节"到"JVM 里可调用的 Class 对象",中间要经历三个阶段:加载 → 链接(验证 / 准备 / 解析)→ 初始化。类加载器(ClassLoader)负责的正是第一阶段"加载":把 .class 的字节读进内存,完成格式检查,然后在方法区生成一个 java.lang.Class 对象,作为这个类在 JVM 里的唯一"身份证"。
注意"方法区生成 Class 对象"这句话——类元数据(字段、方法、常量池)是放在方法区 / Metaspace里的,所以类加载器和 GC 的关系比我们想的深:类加载器本身是堆里的对象,它加载的类元数据在方法区,两边一收一放就牵扯出"类卸载""Metaspace 泄漏"一整串问题(方法区的位置和布局见「JVM 运行时数据区」)。这就是为什么"类加载"和"GC"两卷的内容必须在脑子里串起来。
第三阶段"初始化"的触发时机是高频考点,一句话概括:只有"主动引用"才触发初始化——new 一个对象、调用静态方法、读写静态字段(非 constant_value)、反射调用、初始化子类时先初始化父类、main 所在类。而通过常量池把编译期内联的常量 Const.VALUE 直接内联进代码、通过数组定义 new Sub[10] 只初始化父类,这些都不算。面试被追问细节再展开,本篇的重点在"加载"这一环。
类加载三阶段:加载(读字节码生成 Class 对象)→ 链接(验证 / 准备 / 解析)→ 初始化(执行 <clinit>)。类元数据在方法区,Class 对象本身在堆——类加载与 GC 因此深度耦合。
类加载器家族:从 Bootstrap 到自定义
JVM 里负责加载的"人"有四位,自底向上排成一条链:
| 加载器 | 对应 Java 类 | 加载内容 | 父加载器 |
|---|---|---|---|
| Bootstrap ClassLoader | 无(C++ 实现,Java 视角里不存在对象) | JDK8:$JAVA_HOME/jre/lib/rt.jar 等核心类;JDK9+:java.base 等核心模块(java.*) | 无 |
| Platform / Extension ClassLoader | JDK8:sun.misc.Launcher$ExtClassLoader;JDK9+:jdk.internal.loader.ClassLoaders$PlatformClassLoader | JDK8:jre/lib/ext 扩展目录;JDK9+:jdk 等平台模块 | Bootstrap |
| Application ClassLoader | jdk.internal.loader.ClassLoaders$AppClassLoader(JDK8 是 sun.misc.Launcher$AppClassLoader,继承 java.net.URLClassLoader) | classpath 上所有目录和 jar(你写的业务代码) | Platform(JDK8 是 Extension) |
| 自定义 ClassLoader | 你继承 ClassLoader 写的子类 | 任意来源的字节码(网络、数据库、内存、加密文件) | 构造时传入的 parent |
最经典的认知坑在第一位:Bootstrap 是 C++ 写的,Java 代码里拿不到它的对象。所以打印核心类的 getClassLoader() 会得到 null——但 null 不代表"没人加载",恰恰是 Bootstrap 干的:
public class ClassLoaderDemo { public static void main(String[] args) { System.out.println("String → " + String.class.getClassLoader()); // null ← Bootstrap(C++) System.out.println("Integer → " + Integer.class.getClassLoader()); // null ← 同上 System.out.println("Thread → " + Thread.class.getClassLoader()); // null ← 同上 System.out.println("Demo → " + ClassLoaderDemo.class.getClassLoader()); // jdk.internal.loader.ClassLoaders$AppClassLoader@73df3e46 ← 你的 classpath 类 } }
"AppClassLoader" 和 "System ClassLoader" 是一回事吗?
规范里有个 ClassLoader.getSystemClassLoader(),它返回的就是 AppClassLoader——但在 Tomcat 这类容器里,启动器会替换掉这个角色,返回容器自己插的 System/Common 加载器。"System ClassLoader" 是个规范概念、每次运行时动态确定的角色;"AppClassLoader" 是具体类名。日常说"应用类加载器"时两者通常指同一个对象,面试里区分开更严谨。
四层加载器:Bootstrap(C++,核心类)→ Platform/Extension(平台/扩展)→ App(classpath)→ 自定义。核心类 getClassLoader() 为 null,因为 Bootstrap 在 Java 视角里不存在。
双亲委派流程:loadClass() 的三步判定
把"委派"拆开看,其实就是 ClassLoader 里一个 20 行不到的方法。简化后的源码(JDK 8,去掉了锁和检查):
// java.lang.ClassLoader(JDK 8 简化) protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { Class<?> c = findLoadedClass(name); // ① 查过没?加载过直接返回 → "类只加载一次"的根源 if (c == null) { ClassLoader parent = getParent(); if (parent != null) { try { c = parent.loadClass(name, resolve); } // ② 先问父亲,递归向上直到 Bootstrap catch (ClassNotFoundException e) {} } if (c == null) { c = findClass(name); // ③ 父亲们都找不到,才轮到自己找 } } return c; } // findClass 的默认实现直接抛 ClassNotFoundException —— // 所以自定义加载器必须重写 findClass(或 override 整个 loadClass) public class MyLoader extends ClassLoader { private final String dir; MyLoader(String dir) { this.dir = dir; } @Override protected Class findClass(String name) throws ClassNotFoundException { byte[] bytes = readClassFileFromMyDir(name); // 从指定目录读字节码 return defineClass(name, bytes, 0, bytes.length); // 字节码 → Class 对象 } }
三个关键动作各管一件事:① findLoadedClass 保证同一个加载器不会对同一个类加载两次(类唯一性的第一道闸);② parent.loadClass() 把请求向上抛,直到 Bootstrap 用 C++ 直接加载或抛异常;③ findClass() 是"自己找"的钩子,默认不干活,重写它才让自定义加载器有存在意义——而把字节码变成 Class 对象的那一步叫 defineClass,它是受保护的,只有 ClassLoader 自己能定义类。
叫"双亲委派"不是因为只有一层父亲,而是每个加载器只认识它直接的父亲——App 只把请求交给 Platform,Platform 交给 Bootstrap,递归上去而已。自定义加载器下面还可以再挂自定义,链有多长都行。
AppClassLoader 的 Java 继承链是 AppClassLoader → URLClassLoader → SecureClassLoader → ClassLoader,和 Bootstrap 没有半点继承关系(Bootstrap 是 C++)。"委派链"是构造时传入 parent 参数决定的——new ClassLoader(null) 时 parent 默认是 AppClassLoader(ClassLoader.getSystemClassLoader()),但完全可以传任意加载器。两条链在当前 JVM 里恰好重合,但本质是两回事。
loadClass 三步:findLoadedClass(查缓存)→ parent.loadClass(向上委派)→ findClass(自己找)+ defineClass(字节码变 Class)。"类只加载一次"由第①步保证,"核心类不可篡改"由第②步保证。
为什么要双亲委派:两大收益
委派模式不是随便设计的,它解决两个真实问题:
收益一:安全——核心 API 不可篡改
假设你(或某个恶意 jar)在自己的 classpath 里放了一个 java/lang/String.class。没有双亲委派的话,AppClassLoader 会直接把它加载进来,从此 String 的行为由你定义——整个 JVM 的世界观被改写。有了双亲委派:业务代码引用 java.lang.String 时,请求从 App 一路委派到 Bootstrap,Bootstrap 从核心类库加载了真正的 String 并返回;你的那份字节码永远轮不到被加载。核心 API 的安全性,靠的就是这条"向上不可逆"的链。
收益二:唯一性——同一个类只加载一次
同一个全限定名 com.foo.Util,无论从哪个 classpath 目录、哪个 jar 里找,最终都由同一个加载器加载一次,全 JVM 共享同一个 Class 对象。这保证了 == 比较、instanceof 判断、强制类型转换都有确定的语义。反例最能说明问题:
让两个不同的自定义加载器各自 defineClass 一份 com.foo.Util,会得到两个不同的 Class 对象——即便字节码一模一样。此时 loaderA.load("com.foo.Util") == loaderB.load("com.foo.Util") 为 false,互相强转直接 ClassCastException。这就是"jar 冲突 / 类重复 / 模块化隔离失败"一类事故的根因:在 JVM 里,类的身份 = 全限定名 + 定义它的 ClassLoader,两者缺一不可。
双亲委派给 JVM 两样东西:核心 API 的防篡改(安全)+ 类的唯一身份(同一加载器同一类只有一份 Class)。类身份 = 全限定名 + ClassLoader,这是理解后面三种破坏场景的钥匙。
破坏场景一:SPI 与线程上下文类加载器
先看一个真实矛盾:JDBC 的 DriverManager 在 java.sql 包里,属于 Bootstrap 层(JDK8 的 rt.jar);而驱动实现 com.mysql.cj.jdbc.Driver 在 mysql-connector jar 里,属于 classpath(App 层)。DriverManager 初始化时要扫描并加载所有驱动——可 Bootstrap 的委派方向是向上的,它根本"够不到"下层的驱动类。怎么办?
JDK 的答案是线程上下文类加载器(Thread Context ClassLoader,TCCL):每个 Thread 上挂一个 ClassLoader 引用,默认是 AppClassLoader,可用 Thread.setContextClassLoader() 修改。核心层的代码拿 TCCL,就相当于借了子加载器的手向下找——这是双亲委派第一次被"温柔地"打破。
// java.sql.DriverManager(JDK 8 简化) private static final DriverManager INSTANCE = new DriverManager(); static { loadInitialDrivers(); } private static void loadInitialDrivers() { ClassLoader cl = Thread.currentThread().getContextClassLoader(); // 借 TCCL(默认 App) ServiceLoader<Driver> loaded = ServiceLoader.load(Driver.class, cl); for (Driver a : loaded) { // 逐个实例化并注册 a; // 触发 <clinit>,驱动自己在 DriverManager.registerDriver() 登记 } } // 服务提供者侧:mysql-connector.jar/META-INF/services/java.sql.Driver 里写一行 // com.mysql.cj.jdbc.Driver // 你的代码里一行就完成"自动装配": ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class);
把这套模式抽出来就是 Java 的 SPI(Service Provider Interface)机制:接口在核心/上层 jar(如 java.sql.Driver、org.slf4j.Logger),实现在业务 jar;实现 jar 在 META-INF/services/ 下放一个与接口全限定名同名的文件,内容是实现类名;调用方用 ServiceLoader.load() 扫描加载。SLF4J/Logback、Dubbo 的扩展点、JDK 的 FileSystem、JAXB 全是这套路。本质都是同一招:用 TCCL 把"向下查找"的能力借给上层。
追问:"ServiceLoader 为什么能绕过双亲委派?"
它并没有"绕过"规则本身,而是利用了规则留下的接口:ServiceLoader 的 load(service) 内部调 Thread.currentThread().getContextClassLoader()。TCCL 指向 App,等于"借 App 的手"加载下层类——委派链没被改,只是请求的发起者从"自己委派上去"换成了"找当前线程借一个下层的加载器"。记住这个区分,面试就不会说错。
SPI 打破的是委派的"方向":核心层借 TCCL(默认 App)向下加载驱动/实现类。JDBC、SLF4J、Dubbo 扩展点全部基于这个模式。
破坏场景二:Tomcat 的 WebAppClassLoader
第二个打破场景来自真实需求:一个 Tomcat 里要部署几十个 webapp,每个都可能依赖不同版本的同一个 jar(A 应用要 fastjson 1.2.70,B 应用要 1.2.83)。如果按双亲委派"一律向上",所有 webapp 的类都会落到共享的 App/Common 层加载,版本冲突直接爆炸。所以 Tomcat 自己造了一棵加载器树:
Bootstrap # JVM 核心(C++) └─ System / Common # catalina 目录,容器公共库(所有 webapp 共享) ├─ Catalina # Tomcat 自身(server) ├─ Shared # 可选的公共共享区 └─ WebAppClassLoader ×N # 每个 webapp 一个!加载 WEB-INF/classes + WEB-INF/lib └─ JASPIC # JDK 7+ 为访问 jdk.naming 等平台模块插的一层 // WebAppClassLoader 的关键:loadClass 顺序被"反着"写(简化) protected Class<?> loadClass(String name, boolean resolve) { Class<?> c = findLoadedClass(name); if (c == null) { try { c = findClass(name); // ① 先查自己 WEB-INF/classes + WEB-INF/lib(顺序反了!) } catch (ClassNotFoundException e) {} if (c == null) { c = super.loadClass(name, resolve); // ② 找不到才走标准双亲委派向上 } } return c; }
注意顺序的关键:它先查自己的 WEB-INF,找不到才委派给父。对 java.* / javax.* 这些核心名,Tomcat 会强制继续委派向上(否则又会篡改核心类),但对业务类,它优先自给自足——于是每个 webapp 得到了自己的类空间,同名的第三方 jar 各用各的,互不干扰。
A 应用的 com.foo.Entity 由 A 的 WebAppClassLoader 加载,B 应用由 B 的加载器加载同名类——这是两个不同的 Class。若把 A 的对象塞进 request/共享容器里给 B 用,B 一 cast 就抛 CCE。所以跨 webapp 要么传字节流/序列化(各自重建对象),要么把共享对象下沉到 Common 层,让两边用同一个加载器。
Tomcat 破坏的是"方向":每个 webapp 一个 WebAppClassLoader,先查自己、再委派父,换来"同为 classpath、彼此隔离"。代价是跨应用同名类是两个 Class,直接传对象会 CCE。
破坏场景三:OSGi 与热部署
第三个打破场景针对的是另一条约束——"类只加载一次"。热部署(hot swap)要求:不重启 JVM,把某个类的旧版本卸载掉、加载新版本。可 JVM 的 findLoadedClass 会永远返回那份旧 Class,怎么办?答案是:让旧类的加载器整个变成垃圾——每次热部署 new 一个全新的自定义 ClassLoader 去加载新版本类,旧加载器连同它加载的所有旧类就一起失去引用,等着 GC 回收。
这里牵出"类卸载"的三条件(细节见「对象存活判定」的方法区回收节):① 该类的所有实例已回收;② 加载它的 ClassLoader 已回收;③ 该类的 Class 对象不再被引用(无反射、无静态注册表引用)。三者同时满足,旧类元数据才会从 Metaspace 释放。所以热部署的"开关"其实在类加载器上——换 ClassLoader = 换一类身份 = 老身份被 GC。
OSGi 和 Tomcat 的隔离有什么不同层次?
Tomcat 是"每个应用一层隔离",加载方向还是相对单一的"先自己后父";OSGi(如 Eclipse Equinox、Apache Felix)把每个 bundle 的类彻底模块化,bundle 可以通过 Import-Package / Export-Package 声明"我依赖谁、我暴露谁",加载器之间是网状、可动态增删的关系,粒度比 webapp 更细,能做到运行中启停某个 bundle。Jigsaw(JDK 9 模块系统)则是把这种模块化收编进了语言和平台,parent 委派和模块解析结合。
热部署很多次后 Metaspace 一直涨、最后 OutOfMemoryError: Metaspace,九成是旧类没卸载成:某个静态变量 / ThreadLocal / 全局注册表(JMX MBean、DriverManager.registerDriver、反射缓存 Method 对象)还拽着旧 Class 或旧 ClassLoader。排查和修复思路见「内存泄漏排查」的 Metaspace 节,核心就一句:让旧加载器真正不可达。
热部署打破"只加载一次":每次换一个新 ClassLoader 加载新版本,旧加载器连同旧类一起变垃圾被回收。类卸载三条件(实例/加载器/Class 引用)缺一不可。
总结:一张地图 + 面试模板
知识地图:类加载三阶段(加载 → 链接[验证/准备/解析] → 初始化)→ 四层加载器(Bootstrap → Platform → App → 自定义)→ loadClass 三步(findLoadedClass → parent.loadClass → findClass/defineClass)→ 两大收益(安全 + 唯一性)→ 三种破坏场景(SPI/TCCL 破坏方向、Tomcat 破坏隔离、热部署破坏"只加载一次")。
- 核心机制:类身份 = 全限定名 + 定义它的 ClassLoader;委派关系由构造时 parent 决定,与 Java 继承链是两回事。
- 两大收益:核心 API 防篡改(安全)、同一类唯一 Class 对象(唯一性)。
- 三种破坏:SPI 借 TCCL 向下找实现;Tomcat 每 webapp 独立加载器、先己后父;热部署换加载器让旧类整体回收。
面试回答模板
"类加载分加载、链接、初始化三阶段。JVM 用四层加载器加双亲委派:接到加载请求先查缓存,再逐级委派给父加载器,都找不到才自己 findClass。好处是两个——核心 API 不被篡改、同一个类全局唯一。生产里被打破三次:SPI 用线程上下文加载器向下加载实现、Tomcat 每个 webapp 一个加载器先查自己实现隔离、热部署靠换加载器让旧类整体卸载。"
把三阶段说全,再把 loadClass 三步打开讲:① findLoadedClass 是"类唯一性"的第一道闸;② parent.loadClass 向上委派,保证了 java.lang.String 只能由 Bootstrap 加载、永远轮不到 classpath 里的假 String;③ findClass+defineClass 是自定义加载器的意义所在。然后强调"委派链不是继承链"——委派关系由构造时 parent 决定,Bootstrap 是 C++ 写的、Java 里 getClassLoader 返回 null。最后落到三个破坏场景,每个用一句讲清它破坏了哪条规则、代价是什么:SPI/TCCL(向下借力,JDBC/SLF4J/Dubbo 都在用)、Tomcat(先己后父换隔离,代价是跨应用传对象 CCE)、热部署(换加载器触发类卸载,代价是 Metaspace 管理要小心)。
高频追问表
| 追问 | 答案要点 |
|---|---|
| 双亲委派是强制的吗? | 不是,是 JVM 规范建议的实现方式;想打破就自己重写 loadClass(Tomcat 就是这么干的) |
| 为什么 String 的 getClassLoader() 是 null? | String 由 Bootstrap 加载,而 Bootstrap 是 C++ 实现,Java 里不存在对应对象,只能返回 null |
| 两个加载器加载同名类会怎样? | 得到两个不同的 Class 对象,互相不可见,强转抛 ClassCastException |
| DriverManager 怎么加载到驱动? | 用 ServiceLoader + 线程上下文类加载器(TCCL)向下借力,核心层才够得到 classpath 里的驱动实现 |
| 热部署为什么必须换 ClassLoader? | 类卸载条件之一是"加载它的 ClassLoader 已回收",换新加载器才能让旧类整体失去引用被 GC |
| 怎么查某个类被谁加载? | xxx.getClass().getClassLoader() 看加载器;生产里 jcmd <pid> VM.classloader_stats 看各加载器的类统计 |
类加载是"名词 + 动词":名词是那条四层委派链,动词是 loadClass 三步。三种破坏场景共同说明一个道理——双亲委派是默认解,不是唯一解,理解它才能判断什么时候该变、变了要付什么代价。
Comments · 评论