3.3 注解与反射的性能账 本节摘要:注解是贴在代码上的元数据标签,反射是运行时读取这些标签并操作类的通道。二者合体撑起了整个 Java 生态框架的"魔法",但每次反射调用的安全检查、方法查找与装箱开销会在热点路径上积少成多。本节从一次压测掉坑讲起,覆盖注解的保留策略、反射缓存、setAccessible 的边界与方法句柄的替代路线。 压测掉下来的三倍差距 订单查询接口重构后压测,QPS 从 4200 掉到 1400,火焰图上一条粗柱子格外扎眼: 。重构做了什么?把原来手写的字段拷贝换成了"通用对象映射器"——用反射扫描 DTO 的 注解,逐字段 / / 。
本节摘要:注解是贴在代码上的元数据标签,反射是运行时读取这些标签并操作类的通道。二者合体撑起了整个 Java 生态框架的"魔法",但每次反射调用的安全检查、方法查找与装箱开销会在热点路径上积少成多。本节从一次压测掉坑讲起,覆盖注解的保留策略、反射缓存、setAccessible 的边界与方法句柄的替代路线。
订单查询接口重构后压测,QPS 从 4200 掉到 1400,火焰图上一条粗柱子格外扎眼:Method.invoke。重构做了什么?把原来手写的字段拷贝换成了"通用对象映射器"——用反射扫描 DTO 的 @Column 注解,逐字段 getField/setAccessible/invoke。
问题不在用了反射,而在每次请求都重复做全套工作:反射查方法、权限检查、注解解析,这些结果对同一个类是完全不变的,却在每秒几千次的请求里一次次重算。框架(Spring、MyBatis)早就给出了正确姿势:启动或首次使用时解析一次,缓存在 Map<Class, 元数据> 里,请求路径上只查缓存。
注解本身的成本极低——它只是 class 文件里的属性表条目。真正影响行为的是保留策略:
@Retention(RetentionPolicy.SOURCE) // 编译后丢弃 如 Override 只帮编译器 @Retention(RetentionPolicy.CLASS) // 进字节码 但运行时反射读不到 默认值 @Retention(RetentionPolicy.RUNTIME) // 运行时可反射读取 框架注解全是这类
写自定义注解忘了设 RUNTIME,运行时 getAnnotation 永远返回 null,且没有任何报错——"注解不生效"排查清单的第一条。目标限定(@Target)则是编译期的护栏,把"注到类上"和"注到字段上"的语义区分开。
一个完整的自定义注解 + 处理器骨架:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface CostLog { String biz(); } // 处理器:解析一次 缓存复用 public class CostLogAspect { private static final Map<Method, CostLog> CACHE = new ConcurrentHashMap<>(); public Object around(Method m, Supplier<Object> call) { CostLog meta = CACHE.computeIfAbsent(m, k -> k.getAnnotation(CostLog.class)); if (meta == null) return call.get(); // 无注解 直通 long t0 = System.nanoTime(); try { return call.get(); } finally { Metrics.timer(meta.biz()).record(System.nanoTime() - t0, NANOSECONDS); } } }
这个骨架浓缩了元编程的三段式:标签(注解)— 读取(反射)— 行动(切面)。Spring 的 @Transactional、JUnit 的 @Test、MyBatis 的 @Select 全是这三段的不同实例。
一次 method.invoke 的开销大致分四块:
getMethod 按名字遍历方法表,还要处理可见性,比直接调用贵几十倍setAccessible(true) 后部分豁免,JDK 9+ 模块化后又收紧)Object[] 装箱、基本类型包装对应的三档优化阶梯:
| 档位 | 手法 | 收益 | 适用 |
|---|---|---|---|
| 一档 | 解析一次 + Map 缓存 Method/注解 | 数量级 | 一切框架的标配 |
| 二档 | setAccessible(true) + MethodHandle |
再 2-3 倍 | 高频反射调用 |
| 三档 | LambdaMetafactory 生成直接调用器 |
逼近直接调用 | 序列化/映射库 |
第三档的原理值得知道:用 LambdaMetafactory 在运行时把 MethodHandle 绑定成一个函数式接口实例(类似 Function<User, String>),JIT 眼里它就是普通接口调用,可内联。主流 JSON 库、Spring 的对象绑定都迁移到了这条路线,这也是它们"反射很快"的真相。

setAccessible(true) 是反射越权访问 private 的开关,历史上被框架大量用于读写私有字段。JDK 9 模块化之后,跨模块越权需要显式 --add-opens 打开包,否则运行时抛 InaccessibleObjectException——大量老框架在 JDK 17 上启动失败就是这个原因。趋势很明确:越权反射的空间在收窄,新代码应通过构造器/工厂方法而非私有字段注入,序列化库也在转向构造器绑定。
反射还有一个容易忽视的暗面:它绕过了编译期检查,NoSuchMethodException 只在运行时出现。字段改名后测试全绿、生产炸掉的事故,在"反射按字符串找方法"的代码里是常态。缓解办法是把反射访问集中到少数工厂类,并配一条"改名必须全局搜字符串"的团队规则。
⚠️ 常见坑:自定义注解默认保留策略是 CLASS,运行时读不到且无告警。写框架注解,
@Retention(RUNTIME)是肌肉记忆。
💡 关键直觉:反射的性能问题九成是"重复解析"问题。把"不变的元数据"与"每请求的变化"分开,缓存前者,反射的开销就只剩固定小头。
--add-opens,新代码不依赖私有字段越权下一节是本章最后一个暗面:Stream 写法优雅,陷阱也优雅。