ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java动态代理原理与实战:JDK代理为何只能接口?与CGLIB怎么选?

Java动态代理原理与实战:JDK代理为何只能接口?与CGLIB怎么选? 很多人面试的时候都会说代理我懂。静态代理就是提前把代理类写死动态代理就是运行时用 Proxy 生成。这句话背出来很容易——但你要是追问一句“JDK 动态代理为什么只能代理接口”或者“InvocationHandler 里边调个 toString 会不会出事”很多人就接不上了。这篇二我们就干一件事把静态代理和动态代理从原理到实战彻底捋一遍顺便把那些面试官自己都未必讲得清的坑一个个踩给你们看。适合谁看正在啃 Java 面试题的人、写框架封装时需要给接口加一层拦截的人、以及想搞清楚 Spring AOP 和 MyBatis 背后到底在干嘛的人。1. JDK 动态代理为什么非要接口才能工作1.1 先从一段标准写法说起不管哪篇博客讲 JDK 动态代理基本都会给你这么一段public interface UserService { User findById(Long id); } public class UserServiceImpl implements UserService { Override public User findById(Long id) { // 模拟查询数据库 return new User(id, 张三); } } public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(before: method.getName()); Object result method.invoke(target, args); System.out.println(after: method.getName()); return result; } } UserService userService (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class?[]{UserService.class}, new LogHandler(new UserServiceImpl()) );代码很短但大部分人只看懂了用法没看懂机制。面试官只要追问一句“newProxyInstance 的第二个参数我把 UserServiceImpl.class 传进去行不行”马上就会有人卡住。答案是不行会直接抛IllegalArgumentException: UserServiceImpl is not an interface。1.2 单继承占位代理类必须继承 Proxy要理解为什么不传接口就用不了得先搞清楚 JDK 动态代理生成的代理类长什么样。JVM 在运行时真正创建的类名字类似com.sun.proxy.$Proxy0它的类签名是final class $Proxy0 extends Proxy implements UserService关键就在extends Proxy这一句。java.lang.reflect.Proxy是 JDK 给的公共父类代理对象内部那个 InvocationHandler 就是存在 Proxy 的h字段里的。Proxy.newProxyInstance之所以能统一处理所有代理对象靠的就是大家都有同一个父类都继承了这个字段。但 Java 是单继承一个类只能有一个父类。$Proxy0的继承位已经被 Proxy 占了它想“伪装”成 UserService就只剩一条路实现 UserService 接口。接口在 Java 里被称为“契约”它不是父类不占继承名额所以代理类可以同时 implements 很多个接口。这就是为什么 JDK 动态代理只能代理接口——不是 JDK 设计者闲得没事非要限制你而是语言层面的继承模型决定了它只能这么做。打个比方你想伪装成一个“教师”有两种方式。一是直接继承某个真实教师的血缘这是 Java 的 extends二是考一张教师资格证这是 implements。JDK 动态代理选的是第二条路因为第一条路在运行时根本走不通——父类位置早就被 Proxy 占了。1.3 反编译一下 $Proxy0转发逻辑一目了然理解了类签名再看代理类的内部结构就非常简单了。我用等价代码还原一下 $Proxy0 的样子final class $Proxy0 extends Proxy implements UserService { private static Method m3; // findById方法对应的 Method 对象 private static Method m0; // hashCode private static Method m1; // equals private static Method m2; // toString public $Proxy0(InvocationHandler h) { super(h); } Override public User findById(Long id) { try { return (User) h.invoke(this, m3, new Object[]{id}); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } Override public final int hashCode() { return (int) h.invoke(this, m0, null); } Override public final boolean equals(Object obj) { return (boolean) h.invoke(this, m1, new Object[]{obj}); } Override public final String toString() { return (String) h.invoke(this, m2, null); } }看到没有代理类自己没有任何业务逻辑。每个方法做的事情就一件把这次方法调用包装成Method Object[] args的数据包丢给 handler 的 invoke 方法再把返回值转成目标类型返回。所以“动态代理”这个“动态”本质上是说类的字节码是运行时根据你传入的接口列表现拼出来的。拼出来的类没有灵魂灵魂全在 InvocationHandler 里。谁实现 handler谁就决定这次方法调用最终被转发到真实对象、还是被拦截替换、还是做一些日志埋点再放行。顺带说一个容易被忽略的细节代理类不仅代理你传入的业务方法hashCode、equals、toString这三个 Object 方法也会被代理。如果你传入的接口数组是空的生成的代理类依然存在只是它只有这三个方法而已。这一点很多人的认知是空白的后面第四章我会展开讲它带来的坑。1.4 没有接口会发生什么回到开头那个问题把UserServiceImpl.class传给 newProxyInstance 会发生什么Proxy.newProxyInstance( UserServiceImpl.class.getClassLoader(), new Class?[]{UserServiceImpl.class}, new LogHandler(new UserServiceImpl()) );结果就是一个异常IllegalArgumentException: UserServiceImpl is not an interface。JDK 源码在拿到 interfaces 数组后会对每个 Class 都做一次isInterface()校验只要有一个不是接口直接拒收。原因是逻辑闭环的代理类只能通过实现接口来继承方法契约。你给它一个类类里虽然有方法实现但代理类不可能通过 extends 去继承它继承位已被 Proxy 占用也不可能通过 implements 去实现它实现类不是接口implements 一个类在 Java 语法层面就不合法。所以这条路从一开始就是死的。这里再补充一个多接口代理的边界情况如果两个接口里有一个同名的getId()方法但返回类型不同生成的代理类就会面临 JVM 方法签名冲突的问题。日常业务里出现这种设计的概率极低但如果你在做框架层代码给外部使用者提供多接口代理能力时要注意校验这一点否则某些组合会等到运行时才炸出来。2. 手写一遍动态代理的生成过程原理才算真懂2.1 代理类到底长什么样先把它等价地写出来上一节我展示了$Proxy0的等价代码但那是“看完反编译结果再说原理”。如果你想真正理解 JDK 的 ProxyGenerator 干了什么最好的方式是自己把“代理类生成器”这个角色扮演一遍。其实生成一个代理类不需要什么高深技术核心思路就三步根据接口列表拼出一段 Java 源码把源码编译成 class 文件加载 class反射拿到构造器把 InvocationHandler 传进去创建实例。我写一个简易版的生成器重点在拼源码这一步你看完就知道 JDK 内部做的事不过如此public class ManualProxyGenerator { public static Object newProxyInstance(ClassLoader loader, Class?[] interfaces, InvocationHandler h) throws Exception { // 1. 拼接代理类源码extends ManualProxy implements 接口列表 StringBuilder src new StringBuilder(); src.append(public class $ManualProxy extends ManualProxy implements ); for (int i 0; i interfaces.length; i) { if (i 0) { src.append(, ); } src.append(interfaces[i].getName()); } src.append( {\n); src.append( public $ManualProxy(InvocationHandler h) { super(h); }\n); // 2. 对每个接口的每个方法生成一个转发方法 for (Class? intf : interfaces) { for (Method m : intf.getMethods()) { src.append( public ) .append(m.getReturnType().getName()) .append( ).append(m.getName()).append((); Class?[] params m.getParameterTypes(); for (int j 0; j params.length; j) { if (j 0) { src.append(, ); } src.append(params[j].getName()).append( p).append(j); } src.append() {\n); src.append( return h.invoke(this, getMethod(\) .append(m.getName()).append(\), new Object[]{); // 简化处理参数数组的拼接 src.append(});\n); src.append( }\n); } } src.append(}\n); // 3. 写入临时文件 - JavaCompiler 编译 - 加载 class // 4. 反射 new $ManualProxy(h) return newInstance(loader, interfaces, h); } }这段代码不能直接跑真正要跑还得处理方法参数数组的完整拼接、泛型返回类型的擦除、临时文件的写入和 classpath 设置但结构已经说明白了。代理的本质就是一段由程序替你写好的、只负责转发的代码。2.2 用内存编译把代码变成 class写代理类代码只是第一步第二步是把字符串变成 JVM 能加载的 Class 对象。Java 自带的javax.tools.JavaCompiler可以完成这件事。基本步骤是把拼好的源码写入一个临时目录下的.java文件创建 JavaCompiler 实例设置编译参数classpath 必须包含当前项目的全部依赖路径否则编译期无法解析接口引用调用task.call()编译出.class文件用自定义的 URLClassLoader 加载这个 class反射获取构造器传入 InvocationHandler创建代理实例。这里有三个容易踩的坑。第一个坑是源码文件名。Java 规定 public 类的类名必须和文件名一致所以临时文件要命名为$ManualProxy.java否则编译直接失败。第二个坑是 classpath。用内存编译时编译器默认的 classpath 和 IDE 里的运行 classpath 不是一回事接口类往往不在默认路径里不手动设置-classpath就会报“找不到符号”。第三个坑是加载器。加载生成的 class 时ClassLoader 必须对接口类可见最稳妥的做法是直接使用接口类的 ClassLoader 作为父加载器。2.3 JDK 的 ProxyGenerator 做了什么知道了这两节的内容再看 JDK 的 ProxyGenerator就只剩下性能层面的差距了。JDK 没有走“拼源码 JavaCompiler 编译”这条路因为字符串拼接太慢每次还要启动编译器JVM 扛不住。它是在运行时直接操作字节码用内部的方式把字段、方法、构造器一个一个写进 Class 文件结构里效率和直接从磁盘加载 class 相当。另外有个很关键的优化代理类会被缓存。同一个 ClassLoader、同一组接口组合第一次生成$Proxy0之后后面再调用 Proxy.newProxyInstanceJVM 会直接复用这个代理类不会重新生成。所以你不用担心每次创建代理对象都要经历一次字节码生成的开销真正的开销只在第一次。从 Java 8 之后反射调用本身也做了大幅优化Method.invoke不再是早年那么重的操作。在很多基准测试里JDK 动态代理和 CGLIB 的性能差距已经很小了。真正影响选择的核心因素从来不是性能而是“目标对象有没有接口”这个结构性问题。3. 实战里动态代理最值钱的三个用法3.1 RPC 框架的透明远程调用动态代理用得最透彻的领域我第一个想到的是 RPC 框架。你本地调一个接口方法像调用本地方法一样自然OrderService orderService rpcClient.create(OrderService.class); Order order orderService.findOrderById(1001L);但真正执行的时候findOrderById这个调用根本没有本地实现它要被序列化成一个请求包通过网络发到另一台机器在那边的服务实现类上执行再把结果序列化传回来。中间这整套“拦截调用、编码、传输、解码、执行、返回”的操作就发生在代理对象的 InvocationHandler 里public class RpcInvocationHandler implements InvocationHandler { private final Channel channel; public RpcInvocationHandler(Channel channel) { this.channel channel; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.getDeclaringClass() Object.class) { // Object 方法本地处理不发送网络请求 return handleObjectMethod(proxy, method, args); } RpcRequest request new RpcRequest( method.getDeclaringClass().getName(), method.getName(), args); RpcResponse response channel.send(request); return response.getResult(); } }为什么这个场景必须用动态代理因为调用方拿到的OrderService只是一个接口框架开发者不可能在编译期知道你要调用哪些方法。静态代理需要手写代理类每接一个新的 RPC 服务就要写一堆样板代码完全不可行。动态代理把你需要关注的逻辑收敛到一个 invoke 方法里后续加服务、加方法一行调用代码都不用改。3.2 MyBatis Mapper 接口没有实现类却能执行 SQL除了 RPC另一个你天天在用但未必意识到的动态代理案例是 MyBatis 的 Mapper 接口。写过 MyBatis 的人都知道UserMapper通常只有接口定义没有写实现类public interface UserMapper { Select(SELECT * FROM user WHERE id #{id}) User selectById(Integer id); }你在 service 里注入 UserMapper直接调selectById它就能正确地执行 SQL 并把结果映射成 User 对象。那这个执行逻辑是从哪来的实现类在哪答案是没有实现类。Spring 容器里放的那个 UserMapper bean是一个 JDK 动态代理对象。MyBatis 的MapperProxy实现了 InvocationHandler在 invoke 方法里做的事情本质上就是“根据方法上的 Select 注解、方法名、参数列表拼出一条 SQL然后交给 SqlSession 去数据库执行”public class MapperProxyT implements InvocationHandler { private final SqlSession sqlSession; private final ClassT mapperInterface; Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 判断是否 Object 方法 if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 解析方法注解生成 SQL 并执行省略真正解析逻辑 MapperMethod mapperMethod cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); } }这个案例最有价值的地方在于它告诉你“面向接口编程”在框架层面意味着什么。你的业务代码依赖的是一个干干净净的接口而接口背后站着的可能是一个代理对象。只要它行为正确你根本不需要关心它到底是真实实现类还是代理类。这是动态代理在 Java 生态里最成功的一次普及。3.3 Spring 事务和 AOP 的增强逻辑Spring 的声明式事务是动态代理最经典的“增强”场景。你在方法上加一个Transactional注解Spring 会在容器初始化阶段判断这个 bean 需不需要代理。如果需要它就把原始的 bean 替换成一个代理对象。你在 service 里注入的依赖是代理对象调用方法时先经过代理代理负责开启事务、执行方法、提交或回滚最后才返回结果。这个过程的关键点是Spring 无法提前把每个 bean 的代理逻辑写死在代码里。bean 的类型是千变万化的方法列表也是不可预知的只有在运行时拿到真实类之后才能动态地决定“这个类的哪些方法需要被增强”。这种事静态代理做不了因为静态代理要求在编译期就知道完整的方法集合。AOP 切面也是同一个套路日志切面、权限切面、性能监控切面全部可以在 InvocationHandler 里统一实现。你不需要侵入业务代码只需要在代理层把“方法调用前做什么”和“方法调用后做什么”编排好。4. InvocationHandler 里那些面试官都未必讲过的翻车细节4.1 三个 Object 方法也会进 invoke搞不好就是死循环我在 1.3 节提过代理类会拦截hashCode、equals、toString。这意味着你在写 InvocationHandler 时这三个方法也会不请自来地进入你的 invoke 方法。最常见的翻车场景是这样的你在 invoke 里写日志想把代理对象本身打印出来Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(调用方法 method.getName() 代理对象是 proxy); // ... 其他逻辑 }代理对象是 proxy这行代码Java 编译器会把它编译成proxy.toString()。而 toString 是被代理的于是它又回到 invoke 方法。如果 invoke 里再次拼接 proxy就形成了无限递归最后直接StackOverflowError。这不是理论上的风险我实际见过有人排查日志系统时反复重启服务最后发现是代理对象被无意中打印导致的。标准做法是在 invoke 入口处对 Object 的三个方法做特殊处理。要么直接用 JDK 默认语义Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.getDeclaringClass() Object.class) { if (toString.equals(method.getName())) { return handlerName System.identityHashCode(proxy); } if (hashCode.equals(method.getName())) { return System.identityHashCode(proxy); } if (equals.equals(method.getName())) { return proxy args[0]; } } // 其余正常走业务增强逻辑 }总之只要代理对象可能被输出到日志、放进集合、参与比较这三个方法就一定会被触发。提前处理别等线上炸了再补。4.2 目标方法内部调用不走代理自调用失效问题动态代理只能拦截“从外面进来的调用”拦截不了目标对象内部this.xxx()的调用。举个例子public class PayService { Transactional public void pay() { // 扣款逻辑 this.updateBalance(); // this 调用不是代理调用 } Transactional public void updateBalance() { // 更新余额 } }如果你在外面调用payServiceProxy.pay()代理会拦截 pay 并开启事务。但在 pay 方法内部调用this.updateBalance()时这个 this 是目标原始对象不是代理对象所以 updateBalance 的 Transactional 根本不会生效。这在动态代理模式下是必然的因为代理对象和目标对象是两个不同的实例代理只包在外面一层。目标对象内部的this永远指向自己。规避方式有三种把updateBalance拆到另一个 bean 里通过注入调用在内部用AopContext.currentProxy()获取当前代理对象注入自身代理。我说这个坑不只是为了解释动态代理而是因为它是 Spring 事务失效最常见的隐性原因之一。很多人查了半天事务为什么不回滚最后发现是“自调用旁路了代理”。4.3 返回 void 却收到 null装箱与强转的连环坑InvocationHandler 的 invoke 方法统一返回 Object但业务方法是有具体返回类型的。JDK 生成的代理方法拿到 invoke 的返回值后会自动做类型转换和拆箱。容易出问题的点在基本类型上。看这段public interface Counter { int count(); } public class CounterHandler implements InvocationHandler { private final Object target; Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 如果某个分支返回了 null return null; } } Counter counter (Counter) Proxy.newProxyInstance(...); int result counter.count(); // NPE 空指针异常counter.count()的返回类型是 int代理方法从 invoke 拿到 null 后去做拆箱直接空指针。这个问题在普通方法里可能被返回值的强转掩盖但基本类型包装类的自动拆箱会让 NPE 变得非常隐蔽。另外如果接口方法声明返回voidinvoke 返回 null 是完全合法的代理方法不会对返回值做任何事。所以你在写通用拦截器时要分清楚“返回 null 是正常语义”和“返回 null 导致拆箱异常”的边界。稳妥的做法是在 invoke 中判断方法的返回类型如果是基本类型就给出一个默认值兜底。4.4 instanceOf 判断里的“身份”真相最后一个细节代理对象和真实实现类之间没有任何继承关系。UserService proxy (UserService) Proxy.newProxyInstance(...); System.out.println(proxy instanceof UserService); // true System.out.println(proxy instanceof UserServiceImpl); // falseproxy instanceof UserService为 true是因为代理类实现了 UserService 接口。而proxy instanceof UserServiceImpl为 false是因为字节码层面$Proxy0和 UserServiceImpl 是两个完全独立的类唯一的共同点仅仅是“都实现了 UserService”。这个直觉很多人一开始是反的他觉得 UserServiceImpl 是 UserService 的实现类代理对象都实现了接口了为什么不算是 UserServiceImpl答案很简单——代理类在生成的时候只拿到了接口列表它根本没见过 UserServiceImpl 存在。它和 UserServiceImpl 是陌生人。这个特性还会带来一个小坑如果你的切点表达式匹配的是实现类的方法而 Spring 里实际放的是 JDK 动态代理那么代理对象上的方法调用可能不会被切点匹配或者注解信息获取不到。老版本 Spring 里Transactional加在实现类上但接口上没加时有时候就会因为这个类型匹配问题失效。5. 静态代理、JDK 动态代理、CGLIB 到底怎么选5.1 三种代理方式的核心差异把三种代理放在一张表里看选型的逻辑就非常清楚了维度静态代理JDK 动态代理CGLIB生成时机编译期手写运行期生成代理类运行期生成子类是否需要接口不需要必须不需要代理范围写多少代理多少接口公开方法非 final 类和方法是否依赖字节码库否否是Spring 内嵌破坏性无代理类与实现类无血缘产生目标类子类典型代价类爆炸、样板代码多目标必须实现接口final 类/方法无法代理静态代理为什么在现在的开发里越来越少见因为它的核心问题不是“能不能用”而是“维护成本”。每有一个被代理的服务你就得手写一个代理类方法一变代理类也得跟着改。当你要为几百个 service 统一加鉴权逻辑时静态代理的方案基本等于给脚手架写了一屋子模板。CGLIB 走的是另一条路它不搞接口直接在运行期生成目标类的子类通过继承重写方法来实现拦截。所以 CGLIB 不需要接口但代价是目标类不能是 final 的目标方法也不能是 final 的——final 方法在子类里没法重写。5.2 框架里是怎么替你做选择的Spring 的 AopProxyFactory 在创建代理时默认逻辑是这样的如果目标 bean 有实现接口优先用 JDK 动态代理如果没有接口才退到 CGLIB。这是老版本 Spring 的行为。Spring Boot 2.0 之后默认策略被改成了proxyTargetClass true也就是默认优先用 CGLIB哪怕有接口也直接用子类代理。这个调整的背后原因是实际项目里经常出现“接口方法不全、增强逻辑加在实现类方法上”的情况JDK 动态代理只能拦接口方法拦不到实现类里独有的方法导致 Transactional 在某些场景下莫名其妙失效。CGLIB 不管你有没有接口直接对目标类做子类代理能拦截的方法范围更大。但有一点要注意MyBatis 的 Mapper 这种“只有接口没有实现类”的场景CGLIB 是无能为力的必须用 JDK 动态代理。因为 CGLIB 的遗传机制要求有一个真实的父类可以继承而接口只能被实现不能被继承。这就是为什么 MyBatis 和 Spring 整合时Mapper 的代理始终走 JDK 动态代理。5.3 我踩过的选型坑和现在的判断标准说一个我真实遇到过的案例。一个老项目用的 Spring Boot 1.5service 层有接口也有实现类事务注解加的没毛病。但某天发现有个方法Transactional不生效数据改一半没回滚。排查到数据库连接层面发现确实没有开启事务。最后定位到原因Spring Boot 1.5 默认走 JDK 动态代理而那个业务方法只在实现类里定义接口里根本没有对应方法。JDK 代理只认接口方法这个“接口外方法”完全没被代理接管事务自然就没了。后来把方法补到接口里或者全局配置spring.aop.proxy-target-classtrue切到 CGLIB问题才解决。踩过这个坑之后我现在的判断标准很简单被代理对象只有接口、没有实现类比如 RPC 服务、MyBatis Mapper无脑用 JDK 动态代理拦截逻辑需要覆盖实现类独有方法优先用 CGLIB目标类是 final 的或者方法签名极其复杂考虑用 AspectJ 编译期织入别跟动态代理死磕。还有一个现实参考现在 Spring Boot 2.x、3.x 默认就是 CGLIB 优先所以新项目里你基本不用主动配置。真正需要手动选择的场景往往是你自己写小框架、做中间件封装的时候。动态代理这个东西概念层面不难难的是把它放进真实工程时你能预判“代理链会不会旁路、Object 方法会不会捣乱、目标类型到底支不支持被代理”。这些坑背面试题背不出来只有亲手在代码里踩过一遍才会变成真正属于你的经验。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进