2533 字
约 8 分钟
19
S04-X05 · 反射为什么慢:从 Method.invoke 到 MethodHandle

"反射比直接调用慢几十倍"这句话只对了一半。慢的不是"反射"这个概念,而是 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. 自测

  1. Method.invoke 的三段开销里,哪一段是 setAccessible(true) 也消不掉的?
  2. 为什么缓存了 Method 对象,反射调用仍然不会被内联?
  3. JDK 17 上 --illegal-access=permit 为什么不再起作用?
  4. invokeExact 与 invoke 的差别是什么,写错会抛哪个异常?
  5. Lambda 与字符串拼接为什么都编译成 invokedynamic?这跟"反射慢"矛盾吗?
  6. Spring 重度使用反射却依然够快,成本被搬到了哪个阶段?
S04-X05 · 反射为什么慢:从 Method.invoke 到 MethodHandle
https://www.clxhxhhr.top/posts/5663/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。