反射机制原理与实践:从 Class 对象到方法调用的开销

举报
yd_227606267 发表于 2026/10/26 11:15:07 2026/10/26
【摘要】 讲清反射的底层模型、调用开销来源与 setAccessible 的真实作用

反射让程序在运行期检查并操作类,是 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 自己的加载器,跨加载器传递对象时要特别小心。

常见坑 / 最佳实践

  1. 在循环里反复 getDeclaredMethod。查找涉及字符串比较与遍历,应在初始化阶段缓存到 static final 或 ConcurrentHashMap。
  2. 忽略 setAccessible 的模块限制。JDK 17 下访问 JDK 内部类会失败,升级时需重点排查。
  3. 反射异常处理不当。getDeclaredMethod 抛 NoSuchMethodException,invoke 抛 InvocationTargetException,真正的业务异常在 getCause() 里,日志要打印 cause。
  4. 对基本类型用包装类签名。getMethod("m", Integer.class) 找不到 m(int),必须用 int.class。
  5. 反射创建对象绕过构造器校验。newInstance 不执行编译期检查,参数错误在运行期才暴露,框架层要做好类型转换。
  6. 在高频路径上无脑用反射。先测量:如果调用次数在百万级以上且参数简单,考虑 MethodHandle 或生成代码。
  7. 破坏单例。setAccessible(true) 可以访问私有构造器,枚举单例是唯一能抵御反射的实现方式。

小结

  • Class 对象是 JVM 暴露给 Java 层的元数据入口,forName 会触发初始化,loadClass 不会。
  • setAccessible(true) 跳过访问检查,JDK 9 之后受模块 opens 约束。
  • 反射开销主要来自参数装箱、权限检查与调用间接性,预热后约为直接调用的数倍而非数十倍。
  • MethodHandle 可被 JIT 内联,适合高频场景;VarHandle 是 Unsafe 的官方替代。
  • 缓存反射元数据、正确处理 InvocationTargetException 的 cause,是最有效的两条实践。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。