受检 / 非受检 · 栈轨迹 · try-with-resources · suppressed · Class · Method.invoke · inflation · setAccessible · @Retention · RuntimeVisibleAnnotations · Proxy · InvocationHandler · $Proxy0 · CGLIB
异常、反射、注解与动态代理:Spring 下面那一层
Spring 的 @Transactional 只是把四样 JDK 自带的东西拼在一起:动态代理生成一个替身类,把每次调用都交给一个 InvocationHandler;handler 用反射读方法上的注解,再用反射调真正的对象;真正的对象抛出异常,handler 按异常类型决定提交还是回滚。这一页不碰 Spring,用 JDK 自己手搭一遍:替身类里到底写了什么,反射为什么前 15 次慢,注解对象为什么也是个代理,异常为什么贵在“填栈”。
全景:一个订单对象的一生 · 第 2、3、7、10 站地图:J01 · 异常、反射、注解与动态代理(下一圈)上一圈(Spring 层):Spring AOP 代理原理、Spring AOP 与事务实验:JDK 17.0.20 实测
①为什么需要:第 3 站那个替身,自己能不能造一个
在全景第 3 站,这一步要解决的是:控制器手里的 orderService 其实是 Spring 生成的替身 OrderService$$SpringCGLIB$$0,它在 place() 前后开事务、提交或回滚,业务代码一行没改。上一圈的 Spring 页讲了 Spring 怎么挑替身、怎么排拦截器。这一圈往下走一层:不用 Spring,只用 JDK,能不能自己造出这样一个替身?造的时候要用到的反射、注解、异常,各自在底下是怎么工作的?第 2、10 站 Jackson 读 record 和 @JsonIgnore、第 7 站库存不足抛异常回滚,用的也是这同一套。
②没有它会怎样:每个方法里手写事务和审计
public BigDecimal place(long uid, long sku, int qty) {
audit.log("下单", uid);
Connection c = ds.getConnection();
c.setAutoCommit(false);
try {
… 真正的业务 6 行 …
c.commit();
} catch (RuntimeException e) {
c.rollback();
throw e;
} finally {
c.close();
}
}
// cancel()、pay()、refund() … 再写 20 遍
疼在哪
- 业务 6 行,样板 12 行,20 个方法就是 240 行一模一样的代码。
- 规则改了(比如“受检异常也要回滚”),要改 20 处,漏一处就是数据不一致。
- 写静态代理类(
OrderServiceTxProxy implements OrderService)能把样板挪出去,但每个接口都要写一个代理类,接口加个方法,代理类也要跟着改。 - 缺的那一块:运行时自动生成代理类(动态代理),加上在运行时读到“这个方法要什么处理”(注解 + 反射)。
不懂精华时的典型误用:在 handler 里 catch 了 InvocationTargetException 直接往外抛,调用方收到的是一个包装异常,catch (OutOfStockException e) 永远接不住(⑥ 坑 4)。
③相邻方案:替身有几种造法
| 造法 | 什么时候生成 | 能代理什么 | 谁在用 |
|---|---|---|---|
| 静态代理 | 你手写,编译期 | 任何东西 | 少量、固定的包装 |
| JDK 动态代理 | 运行时,Proxy.newProxyInstance | 只能是接口 | Spring(有接口时可选)、MyBatis 的 Mapper、Feign、注解对象本身 |
| CGLIB / ByteBuddy 子类 | 运行时生成目标类的子类 | 类(非 final,方法非 final / private) | Spring Boot 默认($$SpringCGLIB$$)、Hibernate 懒加载、Mockito |
| AspectJ 织入 | 编译期或类加载期改字节码 | 任何方法,包括自调用、private | 需要连自调用都拦住时 |
本页用 JDK 动态代理手搭一遍,因为它是 JDK 自带、最小、能直接看到生成的类。CGLIB 的原理一样(生成子类,覆盖方法,转给拦截器),在上一圈的 Spring AOP 代理原理里已经画过。
④原理:四样东西各自怎么工作
- Class 对象:每个加载进 JVM 的类,都有一个
java.lang.Class对象描述它:有哪些字段、方法、注解。反射就是通过它“看”和“调”。 - 调用栈:每调一层方法压一个栈帧,异常的栈轨迹就是把这些栈帧抄一遍。
- invokeinterface 和方法表:见面向对象那页④-2;invokedynamic:见 Lambda 那页④-1。
④-1异常:一个对象,加上创建那一刻抄下来的栈
两类异常编译器管不管
- 受检异常(继承 Exception,不是 RuntimeException):编译器强制调用方 catch 或在签名里 throws。适合“调用方能处理的、预期内的失败”:风控超时、文件不存在。
- 非受检异常(RuntimeException、Error):不强制。适合“程序错误”或“没法就地处理的业务失败”:库存不足、参数非法。
- 受检异常和 Lambda 合不来:
Function.apply签名里没有 throws,Lambda 里没法直接抛受检异常。这是今天大多数框架偏向非受检异常的一个原因。
贵在哪不是 throw,是 new
- 异常的构造器会调
fillInStackTrace(),把当前整条调用栈抄一遍。栈越深越贵。 - 实测 调用深度 10 / 100 / 1000 时,new 一个带栈的异常 1.3 / 5.8 / 43 µs,不填栈的 0.07 / 0.19 / 1.3 µs。
- 所以“用异常做流程控制”慢,慢在每次都抄栈;真要这么用,就用不填栈的异常(构造参数
writableStackTrace = false)。 - JIT 还会对同一处反复抛出的 NPE 等异常换成一个预先分配的、没有栈的对象:实测 第 115741 次起栈轨迹变空(⑥ 坑 3)。
- 1try-with-resources:
try (Conn a = …; Conn b = …)结束时按相反顺序 close(先 b 后 a)。业务异常和 close 异常同时发生时,业务异常是主异常,close 的异常挂在它的getSuppressed()里,不会把主异常盖掉。实测 主异常是“库存不足”,被压住的依次是 B、A 的关闭失败。 - 2finally 里 return:会把 try 里抛出的异常整个吞掉,方法“正常”返回。实测 库存不足异常没了,返回了 finally 里的字符串。
④-2反射:拿着 Class 对象去找方法,再“间接地”调它
- 1“反射慢”主要慢在三处:第一次(查找 Method、生成访问器类,Jackson 第一次反序列化 PlaceOrderCmd 用了 67 ms 就有这部分,见全景第 2 站);参数装箱(每次 new 一个 Object[]、把 long 装成 Long);访问检查。预热以后差距不大:实测 2000 万次调用,直接调 465 ms,Method.invoke 603 ms(两边都含字符串拼接,只看比例)。所以框架都会把 Method 对象缓存起来,只查一次。
- 2setAccessible(true) 能绕过 private。JDK 16 起,JDK 自己的内部类默认强封装:实测 对
HashMap.table调它抛 InaccessibleObjectException,必须用--add-opens java.base/java.util=ALL-UNNAMED显式打开(全景第 4 站就是这么做的)。你自己项目里的类不受影响。
④-3注解:存在 class 文件的属性里,运行时读出来的是一个代理对象
- 1注解本身不做任何事,它只是贴在方法上的一张标签。谁去读它、读了做什么,全看框架。
- 2
@Retention决定标签留到哪:SOURCE编译完就扔(@Override);CLASS写进 class 文件的RuntimeInvisibleAnnotations属性,但运行时不加载(默认值);RUNTIME写进RuntimeVisibleAnnotations,反射读得到。实测@BuildOnly(CLASS)用反射读到的是 null。 - 3反射读到的注解对象是什么?注解类型是一个接口,JDK 用动态代理给它造了一个实现:实测
getAnnotation(Audit.class).getClass()是$Proxy1,Proxy.isProxyClass为 true;调value()时,handler(AnnotationInvocationHandler)从一个 Map 里取出解析好的值。四样东西在这里又绕回了一起。
④-4动态代理:生成的 $Proxy 类里到底写了什么
jdk.proxyN(实测 @Retention 的代理是 jdk.proxy1.$Proxy0);接口不是 public 的,代理类必须和它在同一个包(实测 labs.….$Proxy2)。JDK 动态代理继承 Proxy,实现接口
- JDK 自带,不依赖第三方
- 代理的是“接口”这份契约,和实现类无关
- 没有接口就不行:实测 传类进去抛
OrderServiceImpl is not an interface - 每次调用都走 h.invoke + 反射,参数要装箱
CGLIB / ByteBuddy生成目标类的子类
- 没有接口也能代理,Spring Boot 默认就用它(
proxyTargetClass = true) - 可以生成直接调用父类方法的代码,不必反射
- final 类、final 方法、private 方法覆盖不了,所以拦不住
- 子类构造时要调父类构造器(Spring 用 Objenesis 绕开)
this.find() 用的是真正的对象,不经过替身:实测 下单时 place 里调的 find 没打印审计。这就是 Spring“自调用事务失效”的根,它不是 Spring 的 bug,是“替身”这种做法本身的边界;要连自调用都拦,只能用 AspectJ 改目标类自己的字节码。④-5跟着一次调用走:四样东西在哪一步上场
前三个场景和 ⑤ 的 JDK 17 实测输出一致;第四个是按生成类的异常表推出来的(handler 里抛一个 place 没声明的受检异常),没有单独跑。模拟逻辑在 node 里跑过。
最少语法
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface Audit { String value(); }
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(), new Class<?>[]{ OrderService.class },
(p, method, args) -> {
Audit a = method.getAnnotation(Audit.class);
try { return method.invoke(target, args); }
catch (InvocationTargetException e) { throw e.getCause(); } // 拆开再抛
});
try (Conn c = ds.open()) { … } // 自动 close,异常挂在 suppressed
class OutOfStockException extends RuntimeException { … }
⑤亲眼看到:不用 Spring,给订单服务套一个替身
四个文件:Model(OrderService 接口、@Audit、@BuildOnly、两种异常、OrderServiceImpl)、TxAuditHandler(手写的事务 + 审计拦截器)、Demo(5 段)。加上 -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true,生成的代理类会存成 class 文件,可以 javap。完整代码:study-labs/java/javase/exception-reflection-proxy →
class TxAuditHandler implements InvocationHandler {
private final Object target;
public Object invoke(Object proxy, Method method,
Object[] args) throws Throwable {
Audit audit = method.getAnnotation(Audit.class);
String name = audit == null
? method.getName() : audit.value();
System.out.println("[代理] 开事务,审计:" + name);
try {
Object r = method.invoke(target, args);
System.out.println("[代理] 提交");
return r;
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
boolean rollback = cause instanceof RuntimeException
|| cause instanceof Error;
System.out.println("[代理] 捕获 " + … + (rollback
? "回滚" : "受检异常,照样提交"));
throw cause;
}
}
}
// Demo 里
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class<?>[]{ OrderService.class },
new TxAuditHandler(impl));
proxy.place(1024, 88, 2); // 正常
proxy.place(1024, 88, 100); // 库存不足
proxy.place(4040, 88, 1); // 风控超时(受检)
== 1. 异常 == 主异常:库存不足:sku 88 要 100 件 被压住的:连接 B 关闭失败 被压住的:连接 A 关闭失败 finally 里的 return 吞掉了异常 == 2. 异常的成本 == 深度 10:带栈 1287 ns,不填栈 70 ns 深度 100:带栈 5793 ns,不填栈 190 ns 深度 1000:带栈 43399 ns,不填栈 1317 ns 同一处 NPE 连抛:第 115741 次起栈轨迹变空 == 3. 反射 == 第 1 次 invoke:调用方是 NativeMethodAccessorImpl.invoke0 第 17 次 invoke:调用方是 GeneratedMethodAccessor1.invoke setAccessible(HashMap.table) → InaccessibleObjectException 直接调用 2000 万次:464.7 ms;Method.invoke:603.3 ms == 4. 注解 == @Audit(RUNTIME):@…Audit("查订单") @BuildOnly(CLASS):null 读到的注解对象的类:…$Proxy1,是 Proxy 吗?true == 5. 动态代理 == 代理类:…$Proxy2,父类 java.lang.reflect.Proxy,final=true 正常下单 2 件: [代理] 开事务,审计:下单 [代理] 提交 → 598.00(place 里 this.find() 没有打印审计) 买 100 件: [代理] 捕获 OutOfStockException → 回滚 用户 4040 风控超时: [代理] 捕获 RiskCheckTimeoutException → 照样提交 拿类去做 JDK 代理 → OrderServiceImpl is not an interface
包名前缀略去了,注解值原样输出时是 \u 转义。第 2、3 段的耗时每次运行会变,只看量级。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 风控超时抛受检异常,事务照样提交,前面已经扣的库存不回来。⑤ 用户 4040
- 根源
- 拦截器按异常类型决定回滚:默认只认 RuntimeException 和 Error(Spring 的
@Transactional也是这条规则,实测的手写 handler 照抄了它)。 - 做法
- 业务失败用非受检异常;或者
@Transactional(rollbackFor = Exception.class)。
- 现象
- place 里调 this.find(),find 上的审计、事务、缓存注解全部不生效。⑤ 实测
- 根源
- this 是真正的对象,不是替身;替身只是“包在外面”。
- 做法
- 把要被拦截的方法挪到另一个 Bean;或者注入自己的代理(
@Lazy注入 self);实在不行用 AspectJ。
- 现象
- 服务跑了一阵以后,日志里的 NPE 只剩类名,
at …一行都没有,定位不了。⑤ 实测第 115741 次起 - 根源
- HotSpot 的
OmitStackTraceInFastThrow(默认开):同一处反复抛 NPE、ArithmeticException、ArrayIndexOutOfBounds 等,JIT 编译后直接抛一个预先分配好的、没有栈的异常,省掉抄栈。 - 做法
- 往日志前面翻,找最早那几次(还有栈);排障时加
-XX:-OmitStackTraceInFastThrow。
- 现象
- 调用方
catch (OutOfStockException e)接不住,日志里是 UndeclaredThrowableException 套 InvocationTargetException 套 OutOfStockException。 - 根源
- Method.invoke 把目标方法抛的任何异常都包成 InvocationTargetException(受检);handler 原样抛出去,代理类的异常表又发现它没声明,再包一层 UndeclaredThrowableException(④-4)。
- 做法
- catch InvocationTargetException,
throw e.getCause()(⑤ 的写法)。
- 现象
- 库存扣失败了,接口却返回成功;外层的事务拦截器看不到异常,照样提交。⑤ 实测 finally return
- 根源
- 异常是“沿调用栈往上冒”的信号,在半路被接住不再抛,上层所有依赖它的逻辑(回滚、重试、告警)都失效。
- 做法
- 处理不了就别 catch,要 catch 就包一层再抛(
throw new OrderException("…", e),保留 cause);finally 里只做清理,不 return。
- 现象
- 自定义注解在运行时 getAnnotation 返回 null;或者注解写在实现类方法上,用接口的 Method 去读,读不到。
- 根源
- 没写
@Retention(RUNTIME)时默认是 CLASS(实测 null);注解属于它所在的那个方法,接口方法和实现类方法是两个 Method 对象,方法上的注解也不会被继承。 - 做法
- 自定义注解一律写清 Retention 和 Target;读的时候想清楚读谁(Spring 用
AnnotationUtils.findAnnotation会往接口、父类上找)。
使用规范
OutOfStockException(skuId, qty) 比 RuntimeException("error") 好:类型可以单独 catch、可以映射成 HTTP 状态码,消息里有能排查的数据。
“出错了”却不知道是哪一单、哪个商品。
throw new OrderException("下单失败 " + orderId, e),不要只传 e.getMessage()。
丢掉原始栈,查不到根因。
连接、流、锁的包装都实现 AutoCloseable,用 try (…)。
异常路径上漏关,以及 close 的异常盖掉真正的异常。
放在 static final 字段或 ConcurrentHashMap 里,只查一次。
每次 getMethod 的查找开销,和每个 Method 对象各自重新 inflation。
“用户不存在”返回 Optional,别 throw 再 catch;真要用异常传递,用不填栈的。
每次 new 都抄一遍栈(实测深度 100 时 5.8 µs)。
@Retention(RUNTIME)、@Target,需要时 @Inherited(只对类上的注解、只沿父类继承有效)。
坑 6。
深处
框架为什么都缓存反射对象
每次 getMethod 都要按名字和参数类型在类的方法列表里查找,还会复制出一个新的 Method 对象;新对象的调用计数从 0 开始,又要走一遍前 15 次 native。所以 Jackson 给每个类建一份“怎么序列化”的描述并缓存,Spring 把每个 Bean 方法的拦截器链缓存在 methodCache 里,MyBatis 的 Reflector 缓存每个类的 getter / setter。全景第 2 站第一单 67 ms、之后 0.9 ms,差的就是这些“第一次”。
JDK 18 起的反射:基于 MethodHandle
JEP 416 把 Method.invoke、Constructor.newInstance、Field.get 的实现换成了 MethodHandle(JDK 21 源码里的 DirectMethodHandleAccessor),去掉了 native + 生成字节码的两段式和 inflation 阈值。好处是只维护一套机制、JIT 能更好地优化;老参数 -Dsun.reflect.noInflation 在 18 之后基本不起作用了。靶场用 JDK 21,所以全景里的 Jackson、Spring 走的是新路。(JDK 18 这一条按 JEP 416 写,没在本机跑 18。)
受检异常之争
Java 是少数有受检异常的主流语言。初衷是让编译器逼你处理“可预期的失败”。实践中的问题:一层层 throws 往上传,接口签名被底层实现细节污染(换个存储,异常类型就变);和 Lambda、Stream 不兼容;大家为了编译通过写 catch (Exception e) {},比不处理还糟。所以 Spring 把 JDBC 的 SQLException 统一翻译成非受检的 DataAccessException,Kotlin 直接取消了受检异常。业务代码的主流做法:对外的业务失败用非受检异常加全局异常处理器,受检异常只留在边界上(IO、远程调用)就地处理或转换。
版本差异
| JDK 1.3 | java.lang.reflect.Proxy 动态代理 |
|---|---|
| JDK 5 | 注解 |
| JDK 7 | try-with-resources、suppressed、multi-catch |
| JDK 9 | 模块系统;try-with-resources 可以直接用外面的 effectively final 变量 |
| JDK 14 | NPE 消息精确到“哪个变量是 null”(JEP 358,15 起默认开) |
| JDK 16 | 强封装 JDK 内部(JEP 396),代理类放进 jdk.proxyN 动态模块 |
| JDK 18 | 反射改用 MethodHandle 实现(JEP 416) |
JDK 1.3、5、7、9、14、18 几行按文档写;JDK 16 的两项在本机 17 上看到了结果(InaccessibleObjectException、jdk.proxy1)。
JDK 动态代理和 CGLIB 有什么区别?
JDK 代理生成 extends Proxy implements 接口 的类,只能代理接口,每次调用经 InvocationHandler 和反射;CGLIB 生成目标类的子类,能代理类,覆盖不了 final / private 方法。两者都拦不住自调用(④-4)。
反射为什么慢?怎么优化?
慢在第一次查找和生成访问器、每次调用的参数装箱和访问检查,以及 JDK 17 及以前前 15 次走 native;优化是缓存 Method、用 MethodHandle、或者干脆生成字节码(④-2、⑥-3)。
受检异常和非受检异常有什么区别?业务里怎么选?
编译器是否强制处理。业务失败一般用非受检加全局处理;受检异常留在 IO 和远程调用边界。别忘了事务默认只对非受检异常回滚(④-1、坑 1)。
注解是怎么生效的?
注解本身只是元数据,RUNTIME 保留的写进 class 文件的 RuntimeVisibleAnnotations 属性;框架用反射读出来(读到的是 JDK 动态代理生成的对象),再决定做什么(④-3)。
try-with-resources 里业务异常和 close 异常同时发生,抛哪个?
抛业务异常,close 的异常作为 suppressed 挂在它上面,可以用 getSuppressed 取出(④-1)。
- 反射、注解与 SPI(J01 补给待做页):
ServiceLoader、类加载器和双亲委派,Spring Boot 的自动配置怎么找到 jar 里的类 - MethodHandle 和 VarHandle:比反射更快的“方法指针”,以及 JDK 18 的反射新实现
- ByteBuddy 手写一个子类代理,对照上一圈 Spring 页里的 CGLIB
- 注解处理器(APT):Lombok、MapStruct 怎么在编译期改代码
- 全局异常处理:Spring 的
@RestControllerAdvice怎么把异常映射成 HTTP 响应(全景第 7 站的 409)
↺回到⑤:改造你的替身
1. 让受检异常也回滚
给 @Audit 加一个属性 Class<? extends Throwable>[] rollbackFor() default {},在 handler 里读它,命中就回滚。用户 4040 那次应该打印“回滚”。
用到:坑 1、④-3
2. 缓存注解和 Method
在 handler 里加一个 ConcurrentHashMap<Method, String>,审计名只读一次;对比加缓存前后 100 万次代理调用的耗时。
用到:⑥-3 缓存
3. 复现坑 4
把 handler 的 catch 去掉、改成 throws Throwable 直接让 InvocationTargetException 冒出去,看调用方拿到什么;再改回来。
用到:坑 4、④-4 异常表
自测
1. 用一句话说出这四样怎么拼成 @Transactional。
动态代理生成替身类把调用交给 handler;handler 用反射读注解、用反射调目标;目标抛异常时按异常类型决定回滚还是提交。
2. 不用这些,手写事务最疼在哪?
每个方法都重复十几行样板,规则一改要改几十处;静态代理又要每个接口写一个代理类(②)。
3. 自调用事务失效,根源是哪条原理?
替身只是包在外面的另一个对象,方法内部的 this 是真正的对象,调用不经过替身(④-4)。
4. 预测:把 OrderService 接口删掉,让 Demo 直接代理 OrderServiceImpl,会怎样?Spring Boot 里为什么不会出这个问题?
JDK 代理会抛 IllegalArgumentException “not an interface”(实测)。Spring Boot 默认 proxyTargetClass = true,用 CGLIB 生成子类,不需要接口;但如果 OrderServiceImpl 是 final 类,CGLIB 也会失败(④-4)。
5. 预测:在 handler 里 catch InvocationTargetException 后 throw new IOException(cause),调用方拿到什么?
IOException 是受检异常,place 的签名只声明了 RiskCheckTimeoutException,代理类的异常表不放行它,会包成 UndeclaredThrowableException 抛给调用方(④-4 异常表,坑 4)。
6. 放回全景:第 3 站前后各发生了什么?这一页讲的东西坏了,整条链路会怎样?
前面是第 2 站 Jackson 用反射和注解把 JSON 变成 PlaceOrderCmd,后面是第 4 站查商品、第 7 站扣库存。如果 OutOfStockException 改成受检异常,第 7 站抛出后第 3 站的替身照样提交,多商品订单会只扣了一部分库存;如果 place 里自调用了另一个带 @Transactional(REQUIRES_NEW) 的方法,那个“新事务”根本不会开;如果某个注解漏写了 RUNTIME,第 10 站 Jackson 读不到 @JsonIgnore,internalNote 就被发给了前端;如果全局异常处理器把异常吞了只返回 200,用户会看到下单成功,库存却没扣。