"反射比直接调用慢几十倍"这句话只对了一半。慢的不是"反射"这个概念,而是 Method.invoke 这条路径上三件具体的事:每次调用都要过访问检查、参数要装进 Object[]、调用点难以被 JIT 内联。把这三件事拆开看,就知道哪些能消除、哪些不能,也就能判断什么时候该换成 MethodHandle,什么时候缓存一个 Method 就够了。
1. Method.invoke 的三段开销
| 开销 | 每次调用发生什么 | 能否消除 |
|---|---|---|
| 访问检查 | 校验调用方是否有权访问该成员(Reflection.verifyMemberAccess) |
setAccessible(true) 后可跳过 |
| 参数打包 | 实参装箱并写入 Object[],返回值再从 Object 转回 |
不能,除非换调用方式 |
| 无法内联 | 目标在运行期才确定,JIT 难以把调用者与被调者缝合成一段代码 | 句柄为常量时 MethodHandle 可以 |
Method m = Target.class.getMethod("add", int.class, int.class);
Object r = m.invoke(target, 1, 2); // 1 和 2 被装箱成 Integer,塞进 Object[2]
第一段是权限:反射入口先确定调用者类,再比较成员修饰符与模块导出状态;setAccessible(true) 成功后这条检查被标记为已越过,后续走快路径。
第二段是签名错配:反射 API 的形参是 Object,而方法可能是 (int, int) -> int,两个 int 要装箱成 Integer,返回值再拆箱;哪怕本身就是 Object 入参,invoke 仍要分配 Object[] 承载变长实参。较新的 JDK 对少量参数的常见形态做了专门访问器减轻开销,但只要走 invoke(Object, Object...) 这个签名,装箱与数组分配在语义上就还在。
第三段最要命:内联需要稳定的调用目标,而 m 是普通对象字段,指向谁由运行期决定,编译器只能保守处理。这正是"缓存 Method 也没快多少"的原因——缓存解决查找,不解决内联。
2. setAccessible(true) 到底消除了什么
| 状态 | 每次调用 |
|---|---|
| 未设置(私有成员) | 抛 IllegalAccessException,根本到不了执行阶段 |
setAccessible(true) |
校验一次,之后标记为可越过,走快路径 |
Method m = Target.class.getDeclaredMethod("secret");
m.setAccessible(true); // 私有成员:这一次免检,之后快路径
setAccessible(true) 消除的是访问检查,不是装箱,也不是内联。把它当万能开关是常见误读;收益随调用次数摊薄,调一次的方法几乎没有差别。
3. 模块系统:JDK 17 上 setAccessible 会失败
JDK 9 引入模块后,setAccessible 多了一道前置条件:目标包必须对调用方开放(opens)。JDK 16 起默认拒绝非法跨模块访问,JDK 17(JEP 403)彻底移除 --illegal-access 的逃生口——这个参数被忽略,行为固定为拒绝。
Field f = String.class.getDeclaredField("value");
f.setAccessible(true); // JDK 17:InaccessibleObjectException: Unable to make field
// private final byte[] java.lang.String.value accessible
报错形态是 java.lang.reflect.InaccessibleObjectException: Unable to make field ... accessible: module java.base does not "opens java.lang" to unnamed module。解法只有显式开放:
java --add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
-jar app.jar
这也是 JDK 17 升级最常见的坑之一:旧版 Spring、Jackson、Hibernate 会去摸 java.base 的私有字段,升到 17 后要一串 --add-opens 才能启动。
4. MethodHandle:把调用目标变成常量
MethodHandle 是 JVM 层面的函数指针,配合 invokeExact/invoke 的签名多态(signature polymorphic)语义,调用点可以被 JIT 当作直接调用内联——前提是句柄本身是常量(static final)。
private static final MethodHandle ADD;
static {
MethodHandles.Lookup lk = MethodHandles.lookup();
ADD = lk.findVirtual(Target.class, "add", MethodType.methodType(int.class, int.class, int.class));
}
int r = (int) ADD.invokeExact(target, 1, 2); // 无装箱、无 Object[]、可内联
| 维度 | Method.invoke |
MethodHandle.invokeExact |
|---|---|---|
| 参数传递 | 装箱 + Object[] |
直接按方法类型传 |
| 访问检查 | 每次(未 setAccessible 时) | 查找时一次,Lookup 已定权限 |
| JIT 内联 | 基本不能 | 句柄为常量时可内联到调用点 |
| 调用点类型 | 编译期固定为 Object |
签名多态,由调用点决定 |
| 生成成本 | getMethod 查找 + 访问器生成 |
查找一次,之后复用 |
| 典型用途 | 框架的通用调用 | 高频热路径、字节码生成 |
代价是类型安全后移:invokeExact 的实参类型必须与方法类型逐字匹配,写错不报编译错误,运行时抛 WrongMethodTypeException。它更适合生成代码或封装在框架内部,不宜业务代码手写。
5. 为什么 Lambda 与字符串拼接都走 invokedynamic
JDK 8 起 lambda 不编译成匿名内部类,而是编译成一条 invokedynamic,由 LambdaMetafactory 在首次执行时生成实现类;JDK 9 起字符串拼接也从 StringBuilder 链改成 invokedynamic + StringConcatFactory。
Runnable r = () -> System.out.println("hi"); // javap -c 形态:
String s = "a" + x + "b"; // InvokeDynamic #0:run:()Ljava/lang/Runnable;
// InvokeDynamic #1:makeConcatWithConstants:(I)Ljava/lang/String;
两者共用一个机制:把"怎么做"推迟到运行期由 bootstrap 方法决定,于是可以做两件编译期做不到的事——一是生成比匿名类更轻的实现,二是让 JIT 在内联之后把整个拼接或 lambda 体优化掉。StringConcatFactory 生成的策略会按参数类型与常量位置挑选,最终效果通常优于手写 StringBuilder,因为常量与 toString 调用都能被内联展开。
启示很直接:invokedynamic 不是"慢"的代名词。反射慢是因为调用点形态(Object 入参、运行期解析目标)挡死了优化;invokedynamic 恰恰是为了让 JIT 拿回优化能力。
6. 基准测试:JMH 与预热
测反射性能最常见的错误是 System.currentTimeMillis() 包循环——JIT 会在循环中段编译掉代码,前半段解释执行、后半段编译执行,平均值毫无意义。
@State(Scope.Thread)
public class ReflectBench {
Method m; Target t;
@Setup public void setup() throws Exception {
m = Target.class.getMethod("add", int.class, int.class);
t = new Target();
}
@Benchmark public Object reflect() throws Exception { return m.invoke(t, 1, 2); }
@Benchmark public int direct() { return t.add(1, 2); }
}
正确的做法至少包含四件事:
| 要素 | 作用 | 写错的后果 |
|---|---|---|
@Warmup |
让 JIT 完成编译与去优化循环 | 测到解释执行,反射显得"慢几十倍" |
@Fork |
每轮新起 JVM,隔离 profile 污染 | 上一轮的 JIT 状态影响下一轮 |
Blackhole.consume |
防止死代码消除 | 返回值没用,整段被优化掉 |
| 固定入参形态 | 避免分支预测与常量折叠偏差 | 结果随数据变化,不可比 |
另外要固定 -XX:... 与 JDK 版本。同一个 benchmark 在 JDK 8 与 JDK 17 上结论可能不同(访问器实现变过),把老文章的数字搬到 17 上是不成立的。
7. 缓存 Method 的意义,以及框架为什么敢用反射
getMethod/getDeclaredMethod 的查找要遍历方法表并复制数组,比 invoke 本身还贵;Method 不可变,天然可缓存。所以第一层优化永远是缓存 Method,用 ConcurrentHashMap + computeIfAbsent 包住查找。
| 框架 | 手法 | 反射残留 |
|---|---|---|
| Spring DI | 启动期扫描 + 反射注入,运行期缓存 Method/Field |
单例创建时的 setter 调用 |
| Spring AOP | CGLIB/ASM 在启动期生成子类字节码 | 运行期几乎无反射 |
| MyBatis | Mapper 接口用 JDK 动态代理,MapperMethod 缓存方法签名 |
结果集映射时调 setter |
| Jackson | 序列化器在首次使用时生成/缓存,后续走 PropertyAccessor |
首次调用的构建成本 |
| BeanUtils | 每次调用都查 PropertyDescriptor(除非用缓存版) |
全程反射 |
规律是:把反射成本从"每次请求"搬到"启动或首次使用"。Spring 与 MyBatis 敢重度依赖反射,不是因为反射快,而是因为它们把查找、权限校验、字节码生成都做在启动期,运行期只剩一次已缓存的 invoke,摊到每个请求上可以忽略。反过来,如果在 for 循环里反复 getMethod 再 invoke,那才是真的慢。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
InaccessibleObjectException |
模块未 opens | --add-opens 模块/包=ALL-UNNAMED |
IllegalAccessException |
私有成员且未 setAccessible |
先 setAccessible(true),并确认模块已开放 |
WrongMethodTypeException |
invokeExact 实参类型与方法类型不符 |
用 invoke 或逐字对齐 MethodType |
NoSuchMethodException |
参数类型写的是包装类/接口 | 用 getMethods() 打印全部签名核对 |
InvocationTargetException |
被调方法自己抛了异常 | 取 getCause(),栈顶才是真现场 |
| benchmark 显示反射只慢 2 倍 | 没预热或返回值被消除 | 上 JMH,加 Blackhole |
InvocationTargetException 这一条值得单独记:反射把被调方的异常包了一层,直接打栈会看到 Method.invoke 的帧,真正出错的位置在 getCause() 里。
9. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 每请求数百次的属性读写 | 缓存 Method 或改用 MethodHandle |
摊薄查找与校验 |
| 极高频热路径 | 启动期生成字节码(ASM/ByteBuddy) | 运行期零反射 |
| 需要跨 JDK 版本稳定 | 避开 --add-opens 依赖 |
未来版本可能收紧 |
| 只想少写样板 | 用 record / 构造器注入 | 编译期解决,无运行期成本 |
10. 自测
Method.invoke的三段开销里,哪一段是setAccessible(true)也消不掉的?- 为什么缓存了
Method对象,反射调用仍然不会被内联? - JDK 17 上
--illegal-access=permit为什么不再起作用? invokeExact与invoke的差别是什么,写错会抛哪个异常?- Lambda 与字符串拼接为什么都编译成
invokedynamic?这跟"反射慢"矛盾吗? - Spring 重度使用反射却依然够快,成本被搬到了哪个阶段?