反射机制原理与实践:从 Class 对象到方法调用的开销
反射让程序在运行期检查并操作类,是 Spring、MyBatis、Jackson 这些框架的基础。但"反射慢"这个结论过于粗糙:慢在哪一步、JDK 18 之后有什么变化、什么时候该换 MethodHandle,才是工程中真正需要判断的。
本文从 Class 对象的来源讲到 Method.invoke 的调用路径,给出可测量的性能对比和取舍建议。
Class 对象从哪来
每个类被加载后,JVM 在方法区(JDK 8 起为元空间)生成一个 InstanceKlass,同时在堆上创建一个 java.lang.Class 对象作为访问入口。三者关系是:Class 对象持有指向 InstanceKlass 的指针,InstanceKlass 的 java_mirror 又指回 Class 对象。
获取 Class 的三种方式:
Class<?> c1 = String.class; // 类字面量,编译期确定,不触发初始化
Class<?> c2 = "abc".getClass(); // 运行期对象
Class<?> c3 = Class.forName("java.lang.String"); // 会触发类初始化
Class.forName 默认执行静态初始化块,ClassLoader.loadClass 只加载不初始化,这是 JDBC 驱动注册的经典区别。
反射的核心 API
Class<?> clazz = Class.forName("com.example.User");
Constructor<?> ctor = clazz.getDeclaredConstructor(String.class, int.class);
ctor.setAccessible(true);
Object user = ctor.newInstance("tom", 18);
Method setter = clazz.getDeclaredMethod("setAge", int.class);
setter.invoke(user, 20);
Field field = clazz.getDeclaredField("age");
field.setAccessible(true);
int age = field.getInt(user);
getDeclaredXxx 只返回本类声明的成员,getXxx 会沿父类向上查找并返回 public 成员。setAccessible(true) 关闭 Java 语言的访问检查,但不改变成员本身的可见性。
setAccessible 到底做了什么
方法调用时,JVM 需要做访问权限校验:调用方类是否与目标成员同包、是否有继承关系、成员是否 public。setAccessible(true) 在 AccessibleObject 上设置 override 标志,后续调用跳过这层校验。
在 JDK 9 引入模块系统之后,这一行为受到约束:如果目标类所在包没有对调用方 opens,setAccessible(true) 会抛出 InaccessibleObjectException。JDK 17 默认强封装 JDK 内部 API,很多依赖反射访问 sun.misc.* 的旧库会直接失败。
解决方案是在模块描述符中显式开放,或在启动参数中加:
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar
注意:
--add-opens是临时手段,长期应升级到使用公开 API 的库版本。
反射慢在哪
Method.invoke 的调用路径大致是:参数装箱成 Object[] → 权限检查 → 参数类型适配 → 生成或查找 MethodAccessor → 实际调用。
HotSpot 使用 MethodAccessor 做优化:前 15 次调用(-Dsun.reflect.inflationThreshold)使用 native 实现的 NativeMethodAccessorImpl,之后动态生成字节码版本的 GeneratedMethodAccessor,直接调用目标方法,避免 native 边界开销。这就是所谓"反射调用预热"。
即便如此,invoke 仍有固定成本:可变参数数组的创建、基本类型装箱、无法被调用方内联(生成版本能被 JIT 内联,但跨过 invoke 这层间接性仍有代价)。
一个可复现的对比(JDK 17,-Xmx1g,一亿次调用,结果因机器而异,只看数量级):
| 调用方式 | 耗时(ms) | 相对倍数 |
| --- | --- | --- |
| 直接调用 | 45 | 1x |
| MethodHandle(预热后) | 60 | 1.3x |
| Method.invoke(预热后) | 180 | 4x |
| Method.invoke(未预热) | 2600 | 58x |
结论是:预热后的反射大约是直接调用的几倍,而不是几十倍;未预热时才慢两个数量级。因此"缓存 Method 对象"和"让调用次数足够多"比"避免反射"更实际。
MethodHandle 与 VarHandle
JDK 7 引入 MethodHandle,它是类型化的、可直接执行的方法引用,由 JVM 做签名多态优化:
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle mh = lookup.findVirtual(String.class, "length",
MethodType.methodType(int.class));
int len = (int) mh.invokeExact("hello");
invokeExact 要求参数与返回类型严格匹配,JIT 能把它内联为直接调用,性能接近原生。代价是 API 使用复杂、错误在运行期才暴露。
JDK 9 的 VarHandle 用于替代 Unsafe 做字段的原子访问,AtomicInteger 等类内部已改用:
private static final VarHandle VALUE;
static {
try {
VALUE = MethodHandles.lookup().findVarHandle(AtomicCounter.class, "value", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
动态代理与反射的关系
JDK 动态代理基于反射:Proxy.newProxyInstance 在运行期生成一个实现指定接口的类,每个方法转发到 InvocationHandler.invoke,再由 handler 内部用 Method.invoke 调用目标。Spring AOP 的 JDK 代理模式就是这样工作的。
CGLIB 走的是另一条路:用 ASM 生成目标类的子类,重写方法并插入拦截逻辑,因此不需要接口,但无法代理 final 类和方法。
反射的典型用途
- 框架的依赖注入与配置绑定:按类型或名称查找字段并赋值。
- 序列化:遍历 getter 或字段读写值,Jackson 会缓存
Method与Field。 - 注解处理:读取
@Retention(RUNTIME)的注解并据此改变行为。 - 测试工具:JUnit 通过反射调用测试方法,Mockito 通过反射创建 mock。
反射与泛型的交界
反射拿到的 Method、Field 默认只暴露擦除后的原始类型,但通过 getGenericReturnType 与 getGenericParameterTypes 可以取回 Signature 属性中保存的泛型信息。
class Repo {
public List<User> find(String name) { return null; }
}
Method m = Repo.class.getMethod("find", String.class);
ParameterizedType rt = (ParameterizedType) m.getGenericReturnType();
System.out.println(rt.getActualTypeArguments()[0]); // class User
Jackson 的 TypeReference、Spring 的 ResolvableType、MyBatis 的 TypeHandler 都依赖这条路径。如果泛型信息出现在局部变量或对象实例上,反射就无能为力了,因为这些位置不写 Signature 属性。
一个实际影响是:new ArrayList<User>() 在运行期无法得知元素类型,反序列化时必须由调用方显式提供 Class 或 Type 参数,这也是 objectMapper.readValue(json, new TypeReference<List<User>>() {}) 这种写法的由来。
类加载与反射的边界
反射拿到的 Class 对象与类加载器绑定。同一个类文件被两个不同的 ClassLoader 加载,会产生两个互不相等的 Class 对象,instanceof 和强制转换都会失败,异常信息通常是 cannot be cast to com.example.User,而两边的类名完全一样。
这是容器与插件化系统中最常见的排查方向:Tomcat 的 WebappClassLoader 为每个应用独立加载类,热部署时旧 ClassLoader 未释放,就会出现"同名类转换失败"或 OutOfMemoryError: Metaspace。
判断两个 Class 是否相同,必须同时满足全限定名相同、由同一个 ClassLoader 加载。反射创建对象时,clazz.getDeclaredConstructor().newInstance() 使用的是该 Class 自己的加载器,跨加载器传递对象时要特别小心。
常见坑 / 最佳实践
- 在循环里反复
getDeclaredMethod。查找涉及字符串比较与遍历,应在初始化阶段缓存到static final或ConcurrentHashMap。 - 忽略
setAccessible的模块限制。JDK 17 下访问 JDK 内部类会失败,升级时需重点排查。 - 反射异常处理不当。
getDeclaredMethod抛NoSuchMethodException,invoke抛InvocationTargetException,真正的业务异常在getCause()里,日志要打印 cause。 - 对基本类型用包装类签名。
getMethod("m", Integer.class)找不到m(int),必须用int.class。 - 反射创建对象绕过构造器校验。
newInstance不执行编译期检查,参数错误在运行期才暴露,框架层要做好类型转换。 - 在高频路径上无脑用反射。先测量:如果调用次数在百万级以上且参数简单,考虑 MethodHandle 或生成代码。
- 破坏单例。
setAccessible(true)可以访问私有构造器,枚举单例是唯一能抵御反射的实现方式。
小结
- Class 对象是 JVM 暴露给 Java 层的元数据入口,
forName会触发初始化,loadClass不会。 setAccessible(true)跳过访问检查,JDK 9 之后受模块opens约束。- 反射开销主要来自参数装箱、权限检查与调用间接性,预热后约为直接调用的数倍而非数十倍。
- MethodHandle 可被 JIT 内联,适合高频场景;VarHandle 是
Unsafe的官方替代。 - 缓存反射元数据、正确处理
InvocationTargetException的 cause,是最有效的两条实践。
- 点赞
- 收藏
- 关注作者
评论(0)