主题 05类加载与反射
这一章是所有框架的地基。Spring 凭什么能扫描到你的 @Service?
MyBatis 凭什么把接口变成能执行的 SQL?Tomcat 凭什么让两个应用用不同版本的同一个 jar?
答案都在这里:加载、委派、反射。
先把最容易背错的一条说清:反射没有「很慢」到不能用——实测直接调用 0.23 纳秒、
反射 4.23 纳秒,差 18 倍,但绝对值只差 4 纳秒。真正卡住反射的不是性能,是模块系统的封装:
访问 JDK 自己的私有字段,会直接抛 InaccessibleObjectException。
05.1这道题在问什么
| 你听到的问题 | 它其实在问 | 说错的代价 |
|---|---|---|
| 「双亲委派是什么」 | 为什么「先问爹、爹没有才自己来」 | 背得出流程,说不出它防的是什么 |
| 「什么时候打破双亲委派」 | Tomcat / SPI / 热部署三个真实场景 | 只会说「Tomcat 打破了」,说不出怎么破的 |
| 「一个类会被加载几次」 | 类加载器的命名空间隔离 | 答「一次」→ 两个加载器就是两次,而且是两个类 |
| 「反射为什么慢」 | 慢多少、慢在哪、能不能绕过 | 答「很慢别用」→ 说不出 4 纳秒这个量级 |
| 「反射能改 final 吗」 | static final 和实例 final 待遇不同 | 答「能」→ 实测抛 IllegalAccessException |
| 「Spring 怎么创建对象的」 | 反射 + 缓存 Method/Constructor | 答「new 出来的」→ 那怎么支持运行时才确定的类 |
ClassDemo.java,
java ClassDemo loader|forname|custom|reflect|access 五组分别跑。
平台类加载器、模块系统的报错都直接从 JDK 17 里抄的原文,没有改写。
05.2三个阶段:加载、链接、初始化
「类加载」这个词被用得很泛,严格说它包含三个阶段,其中只有最后一个阶段会执行你的代码:
| 阶段 | 做了什么 | 会不会执行你的代码 |
|---|---|---|
| 加载 | 找字节码、读进内存、生成 Class 对象 | 不会 |
| 链接 | 验证(字节码合法)+ 准备(静态变量给零值)+ 解析(符号引用转直接引用) | 不会 |
| 初始化 | 执行 <clinit>:静态变量赋值 + 静态代码块 | 会 |
这个区别有一个非常实用的推论:ClassLoader.loadClass() 只做到「加载」,
Class.forName() 做到「初始化」。实测:
--- 先试 ClassLoader.loadClass ---
loadClass 拿到: ClassDemo$Target,上面有没有静态块输出? <-- 没有
--- 再试 Class.forName ---
[Target] 静态代码块执行了
forName 拿到: ClassDemo$Target
--- forName 但不初始化 ---
initialize=false: ClassDemo$Target,静态块没再执行
两个引用是同一个 Class 吗: true / true
三个方法拿到的是同一个 Class 对象,但只有中间那个触发了静态块。
这就是为什么 JDBC 时代要写 Class.forName("com.mysql.cj.jdbc.Driver")——
它要的不是「加载驱动类」,而是触发驱动类静态块里的注册逻辑:
static {
DriverManager.registerDriver(new Driver()); // 注册发生在静态块里
}
DriverManager 会用 ServiceLoader 自动发现
META-INF/services/java.sql.Driver 里声明的驱动。
但「为什么以前要写」这个问题的答案,仍然是这一节讲的三阶段。
还有一条容易被追问的:什么时候会触发初始化?只有五种,其余都不会:
| # | 场景 | 例子 |
|---|---|---|
| 1 | new、读写静态字段、调用静态方法 | 最常见 |
| 2 | 反射调用(Class.forName 默认初始化) | 框架干的事 |
| 3 | 初始化子类时,父类先初始化 | 接口不适用这条路 |
| 4 | 虚拟机启动时的主类 | main 所在的类 |
| 5 | MethodHandle 解析到静态字段/方法时 | JDK 7 之后新增 |
① 通过子类访问父类的静态字段——只初始化父类;
② 定义数组
Target[] arr = new Target[10]——不初始化 Target;③ 访问编译期常量
static final int X = 1——常量在编译期就被塞进调用方的常量池,
连类都不用加载。
05.3双亲委派:它到底在防什么
先说现象。JDK 17 的类加载器只剩三层,实测每个类的归属:
java.lang.String -> null(启动类加载器)
javax.crypto.Cipher -> null(启动类加载器)
ClassDemo -> jdk.internal.loader.ClassLoaders$AppClassLoader
java.sql.DriverManager -> ClassLoaders$PlatformClassLoader
当前线程上下文类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader
app 的父加载器: ClassLoaders$PlatformClassLoader
app 的父的父: null
null 不是「没有加载器」,是启动类加载器——它由 C++ 实现,
在 Java 里拿不到引用,所以返回 null。app → platform → null 这条链就是双亲委派的路由表。
流程一句话:收到加载请求,先甩给父加载器;父加载器也甩给它的父;顶到最上面还找不到, 才一层层往下自己找。它防的是两件事:
| 防什么 | 不这么做会怎样 | 实测证据 |
|---|---|---|
| 核心类被篡改 | 你自己写个 java.lang.String,就能替换掉 JDK 的 | 实测连编译都过不去:package exists in another module: java.base |
| 同一个类出现多份 | 不同 jar 里的同名类各加载一份,类型系统就崩了 | 见 05.4 的 ClassCastException |
第一条实测比想象的更彻底。写一个 package java.lang; 的 String 类去编译:
probe\java\lang\String.java:1: 错误: 程序包已存在于另一个模块中: java.base
package java.lang;
^
--- 运行 ---
错误: 找不到 java.lang.String 的主方法 <-- 加载的是 JDK 自己的 String
两层拦截:编译期javac 就报「包已存在于 java.base 模块」,运行期即使硬塞进去,
java.lang.String 这个请求在双亲委派的第一跳就被启动类加载器接走了,你的类根本没机会。
所以这道题的答案要分层:
① JDK 8 及以前——ClassLoader.preDefineClass 里一句
if (name.startsWith("java.")) throw new SecurityException("Prohibited package name: java.lang") 硬拦;
② JDK 9 及以后——模块系统在编译期就挡了,连 class 文件都产不出来。
双亲委派是「约定」,包名检查/模块系统才是「硬拦」——这个区别在面试里很加分。
什么时候会打破它
三种典型场景,从上到下越来越常见:
| # | 场景 | 为什么必须打破 | 怎么破的 |
|---|---|---|---|
| 1 | SPI(JDBC、SLF4J) | 接口在启动类加载器里(rt.jar),实现在应用类加载器里,父加载器看不见子加载器的东西 | 线程上下文类加载器(TCCL)兜底 |
| 2 | Tomcat 多应用隔离 | 两个应用要用不同版本的同一个 jar | 每个应用一个 WebappClassLoader,先自己找、找不到再委派 |
| 3 | 热部署 / OSGi | 不重启就替换类,旧类必须能被卸载 | 换一个新的 ClassLoader 重新加载 |
第 1 条的代码长这样(DriverManager 源码):
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// 内部用的是 Thread.currentThread().getContextClassLoader()
// 而不是 DriverManager.class.getClassLoader()
05.4同一个类,两个 Class 对象
「类加载器是类的命名空间」这句话,只有跑一次才信。写一个故意破坏双亲委派的加载器
(先自己 findClass,找不到才委派),用它把同一个 class 文件加载两次:
默认加载器 : class ClassDemo$Target @ 925858445
l1 加载 : class ClassDemo$Target @ 1480010240
l2 加载 : class ClassDemo$Target @ 81628611
c1 == c2 ? false
c2 == c3 ? false
c1 的加载器: jdk.internal.loader.ClassLoaders$AppClassLoader@4e25154f
c2 的加载器: ClassDemo$MyLoader@28a418fc
三个对象、三个哈希、互不相等。同一个 .class 文件,被加载了三次,就是三个不同的类。 再往下走一步,看它有多致命:
c2 造出来的对象,能不能塞进 c1 的变量?
ClassCastException: class ClassDemo$Target cannot be cast to
class ClassDemo$Target
(ClassDemo$Target is in unnamed module of loader ClassDemo$MyLoader @28a418fc;
ClassDemo$Target is in unnamed module of loader 'app')
报错信息里两个类名一模一样,但 JVM 认为它们是两个类型。因为 JVM 判断「是不是同一个类」的条件是:
类的全限定名 + 加载它的 ClassLoader 实例 (两者都相同才算同一个类)
① Tomcat 给每个 webapp 一个加载器 → 两个应用的同名类互不干扰;
② 热部署换一个新加载器 → 旧类的引用全部断开 → 可以被 GC 卸载;
③ 反序列化漏洞里攻击者构造恶意类,也是靠自定义加载器把它塞进来。
反过来,「为什么热部署会内存泄漏」的答案也在这:只要有一个线程/静态字段还引用着旧类的对象, 旧加载器就回收不掉,整个应用的类都跟着泄漏——这就是
PermGen/Metaspace OOM 的经典成因。
05.5反射到底慢多少
先说结论:慢,但没有传说中那么慢。预热 3 轮后测 1000 万次:
直接调用 : 0.23 ns/次
反射 invoke : 4.23 ns/次 (18.7 倍)
反射+setAccessible: 2.77 ns/次 (12.2 倍)
MethodHandle : 1.80 ns/次 (8.0 倍)
四个数字要分两层读:
- 倍数很吓人(18.7 倍),但绝对值只差 4 纳秒。 一次 Redis 往返是 0.5 毫秒 = 50 万纳秒,反射那 4 纳秒连零头都不算。
setAccessible(true)能提速 34%(4.23 → 2.77)。 因为它跳过了每次调用都要做的访问权限检查——这也是为什么框架创建 Method 对象后第一件事就是setAccessible(true)。
MethodHandle 最快(1.80ns),因为它在第一次调用后会被 JIT 当成普通的虚方法调用内联掉,
和直接调用同一个量级。这也是 JDK 7 引入它的目的:给「动态调用」一个能被 JIT 优化的入口。
| 方式 | 实测 | 慢在哪 | 什么时候用 |
|---|---|---|---|
| 直接调用 | 0.23 ns | — | 能写死就写死 |
Method.invoke | 4.23 ns | 参数装箱成 Object[]、权限检查 | 框架通用调用 |
+ setAccessible | 2.77 ns | 省掉权限检查 | 框架的标配 |
MethodHandle | 1.80 ns | 首次链接有开销,之后可内联 | 高频动态调用(Lambda、StringConcat) |
① 反射让 JIT 没法内联——在每秒百万次的热点路径上,4ns 会变成 4ms;
② 每次都
getDeclaredMethod 才是灾难(要遍历方法表 + 拷贝),
框架都会缓存 Method 对象;③ 参数装箱:
invoke(Object, Object...) 每次调用都要 new 一个 Object 数组,
高频下 GC 压力比反射本身更大。
05.6反射能碰到什么,碰不到什么
「反射能打破封装」这句话在 JDK 17 上只对了一半。四组实测:
--- 1. 自己的类(nestmate)---
不 setAccessible 直接读 Target.value: 42
不 setAccessible 直接改: 99
原因:ClassDemo 和 Target 在同一个 nest 里,私有成员互相可见
--- 2. JDK 自己的类 ---
setAccessible(String.value) -> java.lang.reflect.InaccessibleObjectException
--- 3. static final 常量 ---
改 CONST -> java.lang.IllegalAccessException:
Can not set static final int field ClassDemo$Target.CONST to (int)123
--- 4. 私有方法 ---
调私有方法: 私有方法被调用了
逐条解释,每一条都能当面试答案:
| # | 场景 | 结果 | 原因 |
|---|---|---|---|
| 1 | 访问自己写的类的私有字段 | 不加 setAccessible 也成功 | JDK 11 起的 nestmate:同一个外层类里的内部类,私有成员互相可见 |
| 2 | 访问 String.value | InaccessibleObjectException | 模块系统:java.base 没有 opens java.lang |
| 3 | 改 static final 常量 | IllegalAccessException | JVM 层面禁止写 static final(实例 final 可以) |
| 4 | 调私有方法 | 成功 | setAccessible(true) 就够了 |
第 2 条的完整报错(一个字没改):
Unable to make field private final byte[] java.lang.String.value accessible:
module java.base does not "opens java.lang" to unnamed module @63961c42
要绕过它,启动参数加一行就行——这本身就说明模块系统是「封装」不是「安全」:
java --add-opens java.base/java.lang=ALL-UNNAMED -cp . ClassDemo access
-> String.value = [B@816f27d (拿到了 byte[])
实例 final:能改(
setAccessible(true) 即可),但改完 JIT 可能还用旧值——
因为 final 字段可能已经被常量折叠进字节码。static final:改不了,直接
IllegalAccessException,实测原文见上。
网上说的「用 Unsafe 能改」是对的,但那已经不是反射了。
05.7框架为什么离不开反射
把这一章的知识落到一个具体问题上:Spring 启动时,凭什么根据一个字符串
"com.example.UserService" 就把对象造出来、把依赖注入好?
// 简化后的三步,和本章三节一一对应
Class<?> clazz = Class.forName("com.example.UserService"); // 05.2 加载+初始化
Object bean = clazz.getDeclaredConstructor().newInstance(); // 05.5 反射创建
for (Field f : clazz.getDeclaredFields()) { // 05.6 反射注入
if (f.isAnnotationPresent(Autowired.class)) {
f.setAccessible(true);
f.set(bean, getBean(f.getType()));
}
}
三个细节全都能对上本章的实测:
Class.forName而不是loadClass——因为要触发静态块, 很多类在静态块里注册自己;setAccessible(true)——这就是 05.5 里那 34% 的性能来源, 也是能注入私有字段的前提;- 缓存
Method/Constructor——因为getDeclaredMethod本身比invoke还贵。
05.8速记卡与自查
| # | 卡 | 内容(带数字) |
|---|---|---|
| 1 | 三阶段 | 加载 → 链接 → 初始化。只有初始化会跑静态块。
loadClass 不跑,forName 跑(实测:前者无输出,后者打出「静态代码块执行了」)。 |
| 2 | 双亲委派 | 先问父、父没有再自己找。防核心类被篡改(硬拦靠包名检查)+ 同名类多份。
JDK 17 三级:App → Platform → null。 |
| 3 | 同一个类的判据 | 全限定名 + ClassLoader 实例。实测两个加载器加载同一个 class 文件 →
c1 == c2 为 false,强转抛 ClassCastException(两个类名一模一样)。 |
① 反射只慢 4 纳秒(0.23 → 4.23 ns),真正的坑是「每次重新 getMethod」和「参数装箱」, 不是 invoke 本身;
② 反射改不了
static final(实测 IllegalAccessException),
也改不了 JDK 自己的私有字段(InaccessibleObjectException,除非 --add-opens)。
动手清单与自查表
| # | 动手项 | 可观察的判据 |
|---|---|---|
| 1 | 分别调 loadClass 和 forName 加载同一个类 |
前者没有静态块输出,后者有 |
| 2 | 把 MyLoader.loadClass 里的「先自己找」删掉,改成纯委派 |
c1 == c2 从 false 变 true——双亲委派一恢复,类就唯一了 |
| 3 | 跑 ClassDemo reflect,把 setAccessible 那行注掉 |
4.23ns → 2.77ns,权限检查占三成 |
| 4 | 访问 String.class.getDeclaredField("value") 并 setAccessible(true) |
抛 InaccessibleObjectException,报错里有 does not "opens java.lang" |
| 5 | 给上一步加 --add-opens java.base/java.lang=ALL-UNNAMED |
拿到 [B@...——封装是能开的,安全边界不是它 |
c1 == c2 换成
c1.getName().equals(c2.getName()),结果是 true。
两个类名字完全一样,却互相强转不了——这一行足以推翻「类名唯一确定一个类」的直觉,
也是 Tomcat 能做应用隔离、热部署必须换加载器的全部原因。
| 卡住了怎么办 | 去哪查 |
|---|---|
| 不知道某个类是谁加载的 | obj.getClass().getClassLoader(),null 就是启动类加载器 |
| 不知道类从哪个 jar 来的 | clazz.getProtectionDomain().getCodeSource().getLocation() |
| 不知道静态块有没有跑 | 断点打在 <clinit>,或直接看日志顺序 |
反射报 NoSuchMethodException | 用 getDeclaredMethods() 把方法列表打出来,别猜签名 |
反射报 InaccessibleObjectException | 先看报错里的 module X does not "opens Y",再补 --add-opens |