ClassLoader · loadClass · findLoadedClass · parent · findClass · 启动 / 平台 / 应用 · 类的身份 · ClassCastException · ServiceLoader · META-INF/services · TCCL · 子优先 · Metaspace
类加载与双亲委派:先问上级,核心类就只有一份;SPI 要反着找,就得借线程手里的加载器
JVM 里一个类的身份是类名 + 定义它的加载器。双亲委派规定每个加载器先问上级、上级找不到才自己来,所以 java.lang.String 这类核心类在整个 JVM 里只有一份,应用换不掉。代价是上层的代码看不见下层的类。可 JDBC 这类 SPI 正好要反过来:接口和 DriverManager 在 JDK 里,实现在你的 jar 里。于是 ServiceLoader 改用线程上下文类加载器(TCCL)去下层找。在交易靶场里实测:同一个 Order.class 被两个加载器各加载一次,就报 Order cannot be cast to Order;框架用自己的加载器找支付渠道的实现,找到 0 个,换成 TCCL 找到 1 个。
①为什么需要:第一次 new Order,JVM 手里还没有 Order
在全景第 1 站,下单线程执行到 new Order(9527, 2)。JVM 要先有 Order 这个类,才能知道一个订单占几个字节、构造方法在哪。可这时它手里只有一个名字 labs.trade.Order。从名字到能用的类,要回答三件事:去哪找字节(classpath 上几十个 jar),找到后怎么变成类(校验、放进元空间),找到两份时用哪份。负责这件事的就是类加载器(ClassLoader)。
app
Order 由应用类加载器定义,在第一次 new 时才加载 实测
null
String.class.getClassLoader():启动类加载器是 C++ 写的,Java 里拿不到它 实测
platform
JDK 17 上 DriverManager 由平台类加载器定义,不是启动类加载器 实测
0 → 1
框架用自己的加载器找支付渠道实现:0 个;换成 TCCL:1 个 实测
java -cp target/classes:lib/* … 启动。用户 1024 下单,线程第一次执行 new Order;同一个进程里,订单要写库,DriverManager.getConnection 得找到 -cp 上 MySQL 驱动 jar 里的 com.mysql.cj.jdbc.Driver。这两件事都靠类加载器,后一件恰好要“打破”前一件的规矩。②没有它会怎样:每个加载器各找各的
设想没有“先问上级”的规矩:每个加载器拿到名字,就去自己能看到的地方找,找到就定义。听起来更简单,问题出在“同一个名字可能被定义好几次”。
各找各的
- classpath 上放一个自己写的
java.lang.String,应用加载器就会用它,JDK 的那份被顶掉 - 两个加载器都能看到
Order.class,就各定义一个 Order - 每个加载器把
Object、String这些基础类也各加载一遍
后果
- 安全和稳定都没了底:核心类可以被任何一个 jar 换掉,
String的不可变也就没了保证 - 两个 Order 名字一样,JVM 却当成两个类。⑤ 里实测:
(Order) fromA抛class labs…Order cannot be cast to class labs…Order - 同样的元数据在元空间里存好几份;一个类的对象传给另一个加载器的代码,类型就对不上
缺的是一条规矩,让同一个名字的类在需要共享的范围内只被定义一次,并且核心类永远由最上层定义。双亲委派就是这条规矩。不懂这一点的典型误用:以为“类名一样就是同一个类”。开了 Spring Boot devtools 热重启,或者把同一个 jar 同时放进 Tomcat 的 lib/ 和应用的 WEB-INF/lib,看到 X cannot be cast to X 就一头雾水。
③相邻方案:请求往哪个方向走
“谁来定义这个类”有几种做法。JDK 默认是父优先,其他几种都是在它上面为某个需要改的。
| 做法 | 请求怎么走 | 谁在用 | 为了什么 |
|---|---|---|---|
| 父优先(双亲委派) | 先问上级,上级都没有才自己找 | JDK 的 ClassLoader.loadClass 默认实现;自己继承 ClassLoader 只重写 findClass 的也是 | 核心类唯一、不可替换;同一个类不重复加载 |
| 按模块直达 | 先看包属于哪个模块,直接交给定义这个模块的加载器;不在模块里才按父优先 | JDK 9+ 的三个内置加载器(BuiltinClassLoader) | 模块化以后,包和加载器的对应关系启动时就定了,不用一层层问 |
| 子优先 | JDK 的类仍先交给上层;其余的先找自己,找不到才问上级 | Tomcat 的 webapp 加载器(默认 delegate=false) | 每个应用用自己 WEB-INF/lib 里的版本,互不干扰 |
| 反向(借 TCCL) | 上层的代码拿着下层的加载器去加载 | ServiceLoader、JDBC、JNDI、Spring 的 SpringFactoriesLoader | 上层定义接口,下层提供实现(SPI) |
| 网状 | 按声明的导入导出,直接找提供这个包的那个模块 | OSGi | 同一进程里一个库的多个版本并存 |
选择规则:普通应用不用选,用默认的父优先;需要“同一个 JVM 里多个应用各用各的版本”时用子优先或网状;需要“框架定义接口、业务提供实现”时用 SPI,它离不开 TCCL。本页学父优先,以及 SPI 和 Tomcat 这两种“打破”。
④原理:一个类的一生、三层加载器、身份,以及 SPI 为什么要反着走
.class文件就是一串字节,开头是CA FE BA BE。它可以来自目录、jar、网络,甚至运行时现生成(动态代理)。- 类的元数据放在元空间,在堆外;堆里另有一个
Class对象代表它。见 JVM 内存结构。 - 每个对象头里有一个类指针,指向它的类的元数据,见 new 一个对象的全过程。
instanceof和强转比的就是这个指针。
④-1 一个类的一生:加载器只管第一步
⑤ 的 WhoLoads 里,Order 的 static 块在“准备 new Order”之后才打印,-Xlog:class+load 里那一行也排在它前面一点:加载和初始化都推迟到第一次真正用到。初始化失败时(static 块抛异常),这个类会被标成错误状态,之后每次用都报 NoClassDefFoundError: Could not initialize class,见 ⑥ 坑 4。
④-2 三层加载器,和 loadClass 的十几行
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) { // 同名并发加载只放一个
Class<?> c = findLoadedClass(name); // 1. 我加载过吗
if (c == null) {
try {
if (parent != null)
c = parent.loadClass(name, false); // 2. 先问上级
else
c = findBootstrapClassOrNull(name); // 顶上是启动类加载器
} catch (ClassNotFoundException e) { }
if (c == null)
c = findClass(name); // 3. 都没有,才自己找
}
if (resolve) resolveClass(c);
return c;
}
}
自定义加载器只需要重写 findClass:去哪找字节、然后 defineClass。要“打破”委派,就得重写 loadClass 本身,Tomcat 就是这么做的。JDK 9 以后三个内置加载器另有一套 BuiltinClassLoader.loadClassOrNull:先查这个包属于哪个模块,属于模块就直接交给那个模块的加载器,不在模块里(classpath 上的类)才走上面的父优先。效果一样,少问几层。
④-3 委派走一遍先猜由谁定义,再一步一步看
先选一个场景。
java.lang.*、java.util.*java.sql 等模块:DriverManager、Driver 接口target/classes 和依赖 jarWEB-INF/classes、WEB-INF/lib步骤照 JDK 17 的 ClassLoader.loadClass、BuiltinClassLoader.loadClassOrNull 和 Tomcat 文档里 webapp 加载器的查找顺序编排,模拟逻辑在 node 里跑过;每个加载器“地盘里有什么”是这个场景的设定。
④-4 类的身份:名字 + 定义它的加载器
这条规则是本页一半坑的根源。双亲委派保证的是:只要请求都先往上走,同一个名字在这条链上只会被一个加载器定义。一旦有两个加载器都绕开上级、自己定义了同名类,就是两个类。
④-5 为什么非得先问上级
父优先(选中)
java.lang.String一定落到启动类加载器,全 JVM 只有一份;应用里同名的类永远轮不到- 一个类被谁加载,只取决于“链上最高的、能看到它的那一层”,结果是确定的
- 上层定义过的不再重复定义,元空间里只一份
- 类型检查依然便宜:比一下类指针就行
被放弃的:各找各的,或处处子优先
- 任何 jar 都能顶替核心类,JDK 的安全检查(比如
String不可变、SecurityManager时代的权限检查)就形同虚设 - 结果取决于“谁先被问到”,同一个名字在不同代码里可能是不同的类,处处有 ⑤ 那种 ClassCastException
- 要靠更复杂的规则补救:Tomcat 子优先时也得先把 JDK 的类交给上层,Servlet API 也不许应用自己带
决定胜负的约束:核心类必须全 JVM 唯一、不可替换,而 JVM 判断“是不是同一个类”只看名字和定义它的加载器。在这两条下面,让请求一律先往上走,是最简单的能保证唯一的办法。它的代价也由此而来:请求只往上走,上层的代码就看不见下层的类。
④-6 SPI 为什么要打破:接口在上面,实现在下面
破的是方向
双亲委派只允许“下层问上层”。SPI 让上层的代码主动用下层的加载器去加载,这才叫打破。⑤ 的 SpiDemo:框架用自己的加载器找到 0 个实现,TCCL 换成 app 找到 1 个。
没破的是 loadClass
TCCL 指向的 app 加载器自己仍然父优先:先问平台、启动,没有才在 -cp 上找。所以实现类里引用的 java.sql.Driver 拿到的还是上层那一份,类型对得上。
新的约束
TCCL 指向的加载器必须能把接口委派回同一份。⑤ 里把 TCCL 换成一个不委派的加载器,它自己又定义了一份接口,结果是 MockPay not a subtype。DriverManager 还会用调用方的加载器再核对一遍驱动(isDriverAllowed),就是在防这个。
Tomcat 是另一种打破:webapp 加载器重写了 loadClass,JDK 的类先交给上层,其余的先找 WEB-INF,找不到才问上级。目的是让每个应用用自己的依赖版本,热部署时整个加载器扔掉重建。这两种打破在实验台里都能走一遍。
Order.class.getClassLoader(); // 谁定义了它;启动类加载器返回 null
Class.forName("labs.trade.Order"); // 用调用方的加载器,加载并初始化
Class.forName("labs.trade.Order", false, loader); // 指定加载器,不初始化
loader.loadClass("labs.trade.Order"); // 走委派,只加载不初始化
Thread.currentThread().getContextClassLoader(); // TCCL,框架加载“用户的类”时用它
ServiceLoader.load(PaymentChannel.class); // 读 META-INF/services/接口全名,按 TCCL 加载实现
# src/main/resources/META-INF/services/labs.jvm.jvm_classloading.spi.PaymentChannel
labs.jvm.jvm_classloading.channel.MockPay
⑤亲眼看到:交易靶场里的三个小实验
代码在 study-labs 的 jvm/jvm-classloading,--release 8 编译,JDK 17 运行,不需要任何依赖。三个实验分别看:谁加载了谁;两个 Order;支付渠道的 SPI。
cd study-labs/java && JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64 mvn -q -pl jvm/jvm-classloading compile
cd jvm/jvm-classloading # 实验按相对路径 target/classes 找类,要在这里运行
J=/usr/lib/jvm/java-17-openjdk-arm64/bin/java; M=labs.jvm.jvm_classloading
$J -Xlog:class+load -cp target/classes $M.WhoLoads | grep -E "Order|DriverManager|─"
$J -cp target/classes $M.TwoOrders
$J -cp target/classes $M.SpiDemo
1. 谁加载了谁
加载器链(从下往上): jdk.internal.loader.ClassLoaders$AppClassLoader@30946e09 jdk.internal.loader.ClassLoaders$PlatformClassLoader@5f712fd4 null = 启动类加载器(C++ 实现,Java 里拿不到对象) String → null DriverManager → …PlatformClassLoader@5f712fd4 [class,load] java.sql.DriverManager source: jrt:/java.sql 准备 new Order …… [class,load] labs.jvm.jvm_classloading.Order source: file:/…/target/classes/ # 第一次 new 时才加载 [Order 的 static 块执行了:类初始化,只会有一次] loader=…AppClassLoader@30946e09 Order → …AppClassLoader@30946e09 当前线程的上下文类加载器(TCCL):…AppClassLoader@30946e09
2. 两个 Order
URL classes = new File("target/classes").toURI().toURL();
// parent = null:只问启动类加载器,不问 app,相当于“各找各的”
URLClassLoader a = new URLClassLoader(new URL[]{classes}, null);
URLClassLoader b = new URLClassLoader(new URL[]{classes}, null);
Class<?> ca = a.loadClass("labs.jvm.jvm_classloading.Order");
Class<?> cb = b.loadClass("labs.jvm.jvm_classloading.Order");
Object fromA = ca.getConstructor(long.class, int.class).newInstance(1024L, 2);
Order o = (Order) fromA; // 这里的 Order 是 app 定义的那个
ca.getName().equals(cb.getName()) = true ca == cb = false ca == Order.class = false [Order 的 static 块执行了:类初始化,只会有一次] loader=java.net.URLClassLoader@251a69d7 # “只一次”是对每个类而言:这是另一个类 fromA instanceof Order = false ClassCastException: class labs.jvm.jvm_classloading.Order cannot be cast to class labs.jvm.jvm_classloading.Order (labs.jvm.jvm_classloading.Order is in unnamed module of loader java.net.URLClassLoader @251a69d7; labs.jvm.jvm_classloading.Order is in unnamed module of loader 'app')
JDK 17 上这条异常信息会把两边的加载器都写出来。看到 X cannot be cast to X,先看括号里的两个 loader。
3. 支付渠道 SPI:框架在上,实现在下
// 两层:framework 只定义 spi 包(接口 PaymentChannel、框架代码 ChannelRegistry),看不见服务文件
// app = URLClassLoader(target/classes),parent = framework,能看到实现 channel.MockPay
FrameworkLoader framework = new FrameworkLoader();
Named app = new Named("app", framework);
Named isolated = new Named("isolated", null); // 不委派:自己再定义一份接口
// ChannelRegistry 里:
ServiceLoader.load(PaymentChannel.class, ChannelRegistry.class.getClassLoader()); // ① 用自己的
ServiceLoader.load(PaymentChannel.class); // ②③④ 用 TCCL
// 换 TCCL 的写法:用完一定还回去
ClassLoader old = t.getContextClassLoader();
t.setContextClassLoader(cl);
try { … } finally { t.setContextClassLoader(old); }
ChannelRegistry 由谁定义:framework MockPay 由谁定义:app ① 框架用自己的加载器找实现:0 个 [] ② TCCL = framework → 0 个 [] ③ TCCL = app → 1 个 [MockPay(loader=app)] ④ TCCL = isolated → ServiceConfigurationError: labs.jvm.jvm_classloading.spi.PaymentChannel: labs.jvm.jvm_classloading.channel.MockPay not a subtype
到这里已经能交差了:能说出一个类由谁加载、为什么会 X cannot be cast to X、SPI 找不到实现时先查 TCCL。已知还没处理的细节:加载器泄漏把元空间撑爆(坑 2),线程池里 TCCL 不对(坑 3),初始化失败后的 NoClassDefFoundError(坑 4),依赖冲突(坑 5)。
⑥细节:坑、规范、更深一层
注意事项
- 现象
- 类名完全一样,强转却失败;或者
instanceof莫名其妙地是 false。常见于 Spring Boot devtools 热重启后,以及同一个 jar 同时在 Tomcatlib/和WEB-INF/lib里。 - 根源
- 有两个加载器各自定义了这个类。devtools 把你的代码放进一个会重建的
RestartClassLoader,依赖 jar 留在基础加载器里;如果某个库把对象缓存在 static 字段里,重启后拿出来的是旧加载器的类。 - 做法
- 看异常括号里的两个 loader,确定是哪两个;把这个类只留在一处(删掉重复的 jar),或者让缓存跟着重启一起清掉。
- 现象
- 每重新部署一次,元空间涨一截,几次之后
OutOfMemoryError: Metaspace。 - 根源
- 一个类只有在定义它的加载器、这个加载器定义的所有类、以及这些类的所有实例都没人引用时才能卸载。任何一个对象被长寿的地方攥着(容器线程的
ThreadLocal、JDBC 驱动注册表、static 缓存、没停的线程),整个加载器连同它的全部类都卸不掉。见 ThreadLocal 与内存泄漏。 - 做法
- 停应用时注销驱动、停线程、清 ThreadLocal;用
jcmd <pid> VM.classloader_stats看加载器有多少个、各占多少元空间,用堆转储找谁引用着旧加载器。下面这张图是LoaderLeak的实测。
- 现象
- 同一段代码在 main 线程里好好的,放进
parallelStream()、CompletableFuture.supplyAsync(默认用公共池)里,就ClassNotFoundException或找不到实现。Spring Boot 可执行 jar 里最常见。 - 根源
- TCCL 是跟着线程走的。JDK 17 的
ForkJoinPool公共池,工作线程的 TCCL 被设成系统类加载器(ForkJoinWorkerThread源码、ForkJoinPool文档)。可执行 jar 里你的类和依赖由 Spring Boot 自己的加载器加载,系统类加载器看不到。 - 做法
- 在用到的地方显式传加载器:
ServiceLoader.load(X.class, X.class.getClassLoader());或者用自己的线程池,线程工厂里设好 TCCL;临时换 TCCL 一定写在 try/finally 里还回去。
- 现象
- 第一次报
ExceptionInInitializerError,之后每次都报NoClassDefFoundError: Could not initialize class X,类明明就在 jar 里。 - 根源
- static 块或静态字段赋值抛了异常,这个类被标成初始化失败(JLS 12.4.2),JVM 不会再试第二次。和
ClassNotFoundException不同:后者是按名字(Class.forName、loadClass)找不到字节。 - 做法
- 往日志前面翻,找第一次的
ExceptionInInitializerError和它的 cause;static 块里不要做读配置、连网络这种会失败的事,改成懒加载。
- 现象
- 编译通过,运行时
NoSuchMethodError;换一台机器或换个启动方式,问题时有时无。 - 根源
- classpath 上有同一个类的两个版本(比如 guava 两个版本)。同一个加载器只定义一次,按 classpath 的顺序拿第一份,后面那份永远不会被用到。顺序变了,用到的版本就变了。
- 做法
mvn dependency:tree -Dincludes=…找出是谁引进来的,排除掉一个;运行时用-Xlog:class+load(JDK 8 是-verbose:class)看这个类实际来自哪个 jar。
- 现象
- 在项目里放一个同名的
java.util.XXX想打补丁,运行时还是 JDK 的;用自定义加载器defineClass它,报SecurityException: Prohibited package name: java.util。 - 根源
- 请求一定先到上层;就算绕开委派自己定义,
ClassLoader.preDefineClass也拒绝非启动、非平台加载器定义java.*包里的类。 - 做法
- 别改 JDK 类。真要换框架里的类,用框架提供的扩展点;要改字节码用 Java agent(
Instrumentation)。
使用规范
别用 Class.forName(name) 一个参数的版本,它用的是框架自己的加载器。Spring 的 ClassUtils.getDefaultClassLoader() 就是 TCCL → 自己的加载器 → 系统加载器这个顺序。
坑 3,以及框架放在上层时找不到业务类。
⑤ 的 SpiDemo.run 就是这个写法。线程池里的线程会被复用,忘了还,下一个任务拿到的是别人的加载器。
莫名其妙的 ClassCastException,以及旧加载器被线程攥住(坑 2)。
除非像 Tomcat 那样确实要改顺序,否则不要重写 loadClass;要支持并发加载,在 static 块里调 registerAsParallelCapable()。
核心类被重复定义;多线程同时加载时全局一把锁。
static 缓存、注册表、线程、ThreadLocal 里放的对象,要是来自会被丢掉的加载器(插件、热部署、脚本引擎),要有对应的清理或用弱引用。
坑 2 的元空间泄漏。
读配置、连网络、加载本地库放到第一次使用时,失败可以重试。
坑 4:一次失败,整个进程里这个类再也用不了。
-XX:MaxMetaspaceSize 默认不限,泄漏时会一直吃到容器被杀;设个上限,配合 jvm.memory.used{area=nonheap,id=Metaspace} 告警。
泄漏变成没有日志的容器 OOMKilled(全景第 10 站)。
更深一层
JDK 9 以后,委派按模块走,平台加载器甚至会往下交
三个内置加载器都是 BuiltinClassLoader。启动时建好“包 → 模块 → 加载器”的表,loadClassOrNull 先查表,查到就直接交给那个加载器(④-3 实验台里 String 那一步)。JDK 17 的 ClassLoader 文档写明:为了支持升级平台模块,平台加载器在某些情况下“may have to delegate to other class loaders, the application class loader for example”。classpath 上的类不在命名模块里,仍按父优先。
全景里那行 “source: shared objects file”
JDK 自带的类大多从 CDS 归档里直接映射进来,跳过读文件和解析;JDK 12 起默认带一份归档。⑤ 里 DriverManager 显示 source: jrt:/java.sql,说明它不在默认归档里,是现读的。应用自己的类也可以做进归档(AppCDS),启动更快。
类为什么这么难卸载
启动、平台、应用这三个加载器活到进程结束,它们定义的类永远不卸载(JLS 12.7)。只有自定义加载器整个变成垃圾,它的类才会在某次 GC 里一起卸载。⑤ 的 LoaderLeak 里,“用完就丢”的版本前 1500 轮也一直在涨,直到元空间接近上限触发 GC,才一次卸了 6192 个类。加 -Xlog:class+unload 能看到每一个。
Spring 的“SPI”:spring.factories 和 AutoConfiguration.imports
Spring Boot 的自动配置也是“框架定义扩展点、jar 里写清单”,SpringFactoriesLoader 和 ImportCandidates 都用传入的加载器(通常是 TCCL)去读 META-INF 下的文件,原理和 ServiceLoader 相同。见 Spring Boot 启动流程。
版本差异
| JDK | 变化 | 对你意味着什么 |
|---|---|---|
| 8 | 三层是启动 / 扩展(Ext,管 jre/lib/ext)/ 应用;核心类在 rt.jar;DriverManager 由启动类加载器加载,在静态初始化时就用 ServiceLoader 扫一遍驱动 | 老资料里的“扩展类加载器”指的是这一代 |
| 9 | 模块化(JEP 261):去掉 rt.jar 和扩展机制(JEP 220),Ext 换成平台类加载器;java.sql 等模块改由平台加载器定义;内置加载器按模块直达 | 别再把 jar 放进 lib/ext;DriverManager.class.getClassLoader() 不再是 null(⑤ 实测是 platform) |
| 17 | DriverManager 在第一次 getConnection / getDrivers 时才扫描驱动(ensureDriversInitialized,本机源码;具体从哪一版改的没有核对);ClassCastException 信息带上两边的加载器;公共池线程的 TCCL 是系统加载器 | 驱动加载时机比 8 晚;排查坑 1 直接看异常信息;坑 3 |
| 21 | 类加载这一块和 17 一致(DriverManager 源码同样是 ensureDriversInitialized);虚拟线程同样有 TCCL,默认继承创建它的线程 | 升级 17 → 21 这里不用改 |
下一圈
- Tomcat
WebappClassLoaderBase.loadClass源码:子优先的完整顺序,以及停应用时的泄漏检测 - Spring Boot 可执行 jar 的
LaunchedURLClassLoader(3.2 起叫LaunchedClassLoader):jar 套 jar 是怎么读出来的 - Java agent 与
Instrumentation:在类加载时改字节码(Arthas、SkyWalking 的原理) - 隐藏类(JDK 15,JEP 371):lambda 生成的类改成隐藏类以后,为什么可以单独卸载
- JIT、逃逸分析(全景第 2 站,下一个知识点):类加载完以后,代码是怎么变快的
- JVM 故障排查(第 9 站):用
VM.classloader_stats、堆转储把坑 2 查到具体引用链
↺回到⑤:改造你的案例
1. 让两个 Order 变成一个
按 ④-4,把 TwoOrders 里两个加载器的 parent 从 null 改成 TwoOrders.class.getClassLoader()。先猜 ca == cb 和强转的结果,再跑;说出是哪一步委派让它们变成了同一个类。
2. 在线程池里找支付渠道
按坑 3,在 SpiDemo 里先把 main 线程的 TCCL 设成 framework,再建一个线程池去调 viaTccl():找得到几个?然后用线程工厂修好它。
3. 只放掉一半
按坑 2,LoaderLeak -Dkeep=true 时只删掉 HOLD.add(o),代理对象照留。先猜还会不会 OOM,再跑,用 ④-1 的卸载条件解释。
自测
1. 用一句话说出这一页的精华。
类的身份是“名字 + 定义它的加载器”;双亲委派让请求先往上走,保证核心类唯一、同名类不重复定义,代价是上层看不见下层;SPI 的接口在上层、实现在下层,所以要借线程上下文类加载器反过来找。
2. 没有双亲委派、各找各的,疼在哪?
核心类能被任何 jar 顶替;同一个名字被几个加载器各定义一次,就是几个不同的类,互相传对象就 ClassCastException(⑤ 实测 Order cannot be cast to Order);同样的元数据存好几份。
3. 热部署几次就 Metaspace OOM,根源是哪条原理?
④-1 的卸载条件:类只有在定义它的加载器整个不可达时才能卸载。旧应用里只要有一个对象被容器线程、static 字段或注册表攥着,它的类 → 加载器 → 加载器定义的所有类都留在元空间里。
4. 预测:在 JDK 17 上,把 Class.forName("com.mysql.cj.jdbc.Driver") 写在一个由平台类加载器定义的类里执行(假设能做到),会怎样?
抛 ClassNotFoundException。一个参数的 forName 用调用方自己的加载器,平台加载器只会往上问启动类加载器,看不见 -cp 上的 jar(④-2、④-6)。这正是 DriverManager 要借 TCCL 的原因。
5. 预测:Tomcat 里应用 A 和应用 B 都在 WEB-INF/lib 带了同一版本的 jackson,内存里有几份 ObjectMapper 类?如果把它挪到 Tomcat 的 lib/ 并从两个应用里删掉呢?
前者两份:webapp 加载器子优先,各自定义一份,两个应用之间不能直接传 jackson 对象。后者一份:两个 webapp 加载器在自己那里找不到,交给上级 common 加载器,它只定义一次(④-2 的 findLoadedClass)。代价是两个应用必须用同一个版本。
6. 放回全景:第 1 站的前后各发生了什么?类加载坏了,整条链路会怎样?
前面是下单线程执行到 new Order;后面是第 2 站 JIT 把热点代码编译进代码缓存、第 3 站在 Eden 的 TLAB 里分配 48 字节,对象头里的类指针就指向第 1 站放进元空间的元数据。类加载坏了有三种样子:加载器泄漏让元空间一直涨,最后 OutOfMemoryError: Metaspace,或者没设上限时进程超出容器内存被杀(第 10 站),下单接口整体不可用;同一个类被两个加载器加载,订单对象一过框架边界就 ClassCastException,请求在第 1 站之后就失败;SPI 找不到 MySQL 驱动,订单写不了库,堆和 GC 都正常,问题却在类加载。