Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 05类加载与反射

这一章是所有框架的地基。Spring 凭什么能扫描到你的 @Service? MyBatis 凭什么把接口变成能执行的 SQL?Tomcat 凭什么让两个应用用不同版本的同一个 jar? 答案都在这里:加载、委派、反射。

先把最容易背错的一条说清:反射没有「很慢」到不能用——实测直接调用 0.23 纳秒、 反射 4.23 纳秒,差 18 倍,但绝对值只差 4 纳秒。真正卡住反射的不是性能,是模块系统的封装: 访问 JDK 自己的私有字段,会直接抛 InaccessibleObjectException。

学习目标
能讲清「双亲委派解决什么、又为什么被打破」
前置要求
读过第 03 章(运行时数据区);写过反射
本章产出
5 组实测 + 3 份真实异常 + 3 张速记卡
建议学时
2 小时(把 ClassCastException 那段跑一遍)

05.1这道题在问什么

你听到的问题它其实在问说错的代价
「双亲委派是什么」为什么「先问爹、爹没有才自己来」背得出流程,说不出它防的是什么
「什么时候打破双亲委派」Tomcat / SPI / 热部署三个真实场景只会说「Tomcat 打破了」,说不出怎么破的
「一个类会被加载几次」类加载器的命名空间隔离答「一次」→ 两个加载器就是两次,而且是两个类
「反射为什么慢」慢多少、慢在哪、能不能绕过答「很慢别用」→ 说不出 4 纳秒这个量级
「反射能改 final 吗」static final 和实例 final 待遇不同答「能」→ 实测抛 IllegalAccessException
「Spring 怎么创建对象的」反射 + 缓存 Method/Constructor答「new 出来的」→ 那怎么支持运行时才确定的类
实验环境:JDK 17.0.12,源码单文件 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());   // 注册发生在静态块里
}
JDK 9 之后这句可以删了:因为 JDBC 4.0 引入了 SPI 机制, DriverManager 会用 ServiceLoader 自动发现 META-INF/services/java.sql.Driver 里声明的驱动。 但「为什么以前要写」这个问题的答案,仍然是这一节讲的三阶段。

还有一条容易被追问的:什么时候会触发初始化?只有五种,其余都不会:

#场景例子
1new、读写静态字段、调用静态方法最常见
2反射调用(Class.forName 默认初始化)框架干的事
3初始化子类时,父类先初始化接口不适用这条路
4虚拟机启动时的主类main 所在的类
5MethodHandle 解析到静态字段/方法时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 文件都产不出来。
双亲委派是「约定」,包名检查/模块系统才是「硬拦」——这个区别在面试里很加分。

什么时候会打破它

三种典型场景,从上到下越来越常见:

#场景为什么必须打破怎么破的
1SPI(JDBC、SLF4J)接口在启动类加载器里(rt.jar),实现在应用类加载器里,父加载器看不见子加载器的东西线程上下文类加载器(TCCL)兜底
2Tomcat 多应用隔离两个应用要用不同版本的同一个 jar每个应用一个 WebappClassLoader,先自己找、找不到再委派
3热部署 / OSGi不重启就替换类,旧类必须能被卸载换一个新的 ClassLoader 重新加载

第 1 条的代码长这样(DriverManager 源码):

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// 内部用的是 Thread.currentThread().getContextClassLoader()
// 而不是 DriverManager.class.getClassLoader()
为什么 TCCL 能解决:因为子加载器能看见父加载器加载的类,反过来不行。 启动类加载器要加载应用类加载器里的 MySQL 驱动,唯一的办法是「问一个能看见两者的加载器」—— 线程上下文类加载器默认就是 AppClassLoader,它看得见 rt.jar 也看得见 classpath。 这是一次「父加载器向子加载器借眼睛」的操作,也是双亲委派唯一被官方承认的缺口。

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 倍)

四个数字要分两层读:

MethodHandle 最快(1.80ns),因为它在第一次调用后会被 JIT 当成普通的虚方法调用内联掉, 和直接调用同一个量级。这也是 JDK 7 引入它的目的:给「动态调用」一个能被 JIT 优化的入口。

方式实测慢在哪什么时候用
直接调用0.23 ns—能写死就写死
Method.invoke4.23 ns参数装箱成 Object[]、权限检查框架通用调用
+ setAccessible2.77 ns省掉权限检查框架的标配
MethodHandle1.80 ns首次链接有开销,之后可内联高频动态调用(Lambda、StringConcat)
真正该担心的不是这 4 纳秒,是这三件事:
① 反射让 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.valueInaccessibleObjectException模块系统:java.base 没有 opens java.lang
3改 static final 常量IllegalAccessExceptionJVM 层面禁止写 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 字段吗?」
实例 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()));
    }
}

三个细节全都能对上本章的实测:

把这一章压成一条因果链:因为要运行时决定加载哪个类,所以有类加载器; 因为不能让同名类打架,所以有双亲委派;因为 SPI 和隔离场景必须打架,所以委派会被打破; 打破之后同一个类能有多份,所以判断「是不是同一个类」要连加载器一起比; 而这一切能落地,靠的是反射——它让你在运行时看见并操作那些编译期根本不知道的类。

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@...——封装是能开的,安全边界不是它
反例实验(做了会记一辈子):把 05.4 的 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