资讯详情

资讯详情

Java动态代理底层原理与实战:JDK动态代理与CGLIB全解析

1. 从一个真实的业务Bug说起为什么静态代理不够用先说一个我实际遇到过的问题。某个后台管理系统有个用户操作审计功能一开始实现得很“朴素”在每个需要记录日志的方法里手动调用一次日志工具类。比如保存用户要调删除用户要调改密码要调。业务一多问题就出来了——日志代码和业务代码耦合得死死的某个方法忘加日志就是漏记加了日志的方法一旦出异常还会把业务逻辑一起带崩。后来我重构这个模块时用上了Java动态代理。配置好之后新增一个需要审计的方法只需要在方法上标个注解代理会拦截调用统一记录操作者、操作时间、调用参数、返回结果。业务方法自己保持干干净净只关心自己的逻辑这个体验对比非常直观静态代理是“你叫我做事我顺便记个账”动态代理是“我站在你门口只要你出门我先帮你记个账再让你走”。这篇文章就围绕着Java动态代理这件事展开。基础部分我会讲清楚JDK动态代理和CGLIB到底怎么回事然后会深入到源码层面拆一拆InvocationHandler的回调机制最后会给出实例代码、常见坑位的排查方式以及性能上的一些实测结论。不管你是刚开始接触代理模式还是已经用过Spring AOP但没搞懂底层原理这篇文章应该都能给你一些有用的东西。2. JDK动态代理的底层执行流程从Proxy.newProxyInstance一路看到字节码生成2.1 确认核心类三个角色一台戏JDK动态代理涉及三个核心角色我用最简单的方式理一遍目标对象Target真正干活的类比如上面的UserService实现类。InvocationHandler一个接口里面只有一个invoke方法。代理对象拦截到任何方法调用都会转到这里来执行。代理对象ProxyJDK在运行时动态生成的一个类它实现了和目标对象相同的接口。外部代码调用代理对象的方法时实际走的是InvocationHandler的invoke。用一个通俗的类比目标对象是餐厅后厨InvocationHandler是前厅经理代理对象是服务员。顾客调用方把菜告诉服务员服务员不自己做菜而是转告经理经理决定是先记录单子、再通知后厨做菜还是干脆告诉顾客“今天这道菜不卖”。注意一个关键点JDK动态代理要求目标对象必须实现至少一个接口。它是基于接口生成代理类的。如果你的类没有实现任何接口JDK这条路走不通得交给CGLIB。这个问题太常被问到后文我会专门展开对比。2.2 Proxy.newProxyInstance三个参数的作用代码里的入口是这个Class? proxyClass Proxy.getProxyClass(loader, interfaces);真正日常写代码时一般直接用UserService userServiceProxy (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new AuditInvocationHandler(target) );三个参数分别是什么一定要搞清楚loader类加载器。这里用的是目标类的类加载器。代理类需要被加载到JVM里所以要给它一个类加载器。interfaces目标类实现的接口数组。生成的代理类会实现这些接口这样代理对象就能强转为接口类型使用。hInvocationHandler实例也就是上面说的“前厅经理”。newProxyInstance内部先通过getProxyClass拿到代理类然后利用反射找到代理类的构造器这个构造器接收InvocationHandler参数最后实例化代理对象。2.3 代理类的字节码是怎么来的这里值得多说一句。JDK不是像写Java文件那样把代理类“写”出来再编译而是直接在内存里生成字节码本质是动态组装一个Class文件。ProxyClassFactory里有一段逻辑像搭积木一样拼出类的结构类名com.sun.proxy.$Proxy0每个代理类一个编号递增的名字。类的修饰符public final。父类java.lang.reflect.Proxy。实现的接口你传入的那些接口。然后JDK会对接口里的每个方法做处理生成对应的方法实现。如果接口方法返回值是基本类型、引用类型或void字节码会构造对应的返回值指令。生成完成后通过defineClass0把字节码加载成真正的Class对象。整个过程对开发者是透明的你只看到结果——一个可以实例化、类型匹配的代理对象。这个方法级处理有个很重要的副作用代理类只能通过接口方法调用实现拦截。如果目标类有一个public方法但它既不在接口里也不是接口方法的默认实现代理对象上根本看不到它自然也就拦不到。3. InvocationHandler回调机制的地基invoke方法里的返回值陷阱3.1 invoke调用链路全景我画过很多次这个调用顺序但这一节不用图咱们用文字描述一遍业务代码调用userServiceProxy.saveUser(user)。代理对象的方法体收到调用。代理对象把所有参数包括Method对象、参数列表打包转交到InvocationHandler的invoke方法。invoke方法内部返回一个Object代理对象把这个Object转为saveUser声明的返回值类型还给调用方。这里最容易被忽略的是invoke方法不是必须调用目标方法的。你可以完全接管逻辑不执行targetMethod.invoke(target, args)直接返回一个伪造结果。这在做Mock、降级、故障注入时很有用。但在普通的增强场景里别忘了invoke目标方法否则业务代码就白写了。3.2 返回值转换这里藏着一个高频报错如果接口方法是基本类型比如int saveUser(...)那么invoke返回的Object必须能拆箱成int。如果invoke返回null拆箱那一刻会抛出NullPointerException。这是一个高频踩坑点。我自己遇到过类似的线上问题一个查询接口返回int类型某个分支忘了在invoke里返回实际值结果运行时直接NPE日志里看起来像业务查询出了问题其实问题出在代理回调层。3.3 关于equals、hashCode、toString的特殊处理JDK动态代理对这个三个方法有特殊照顾。代理类从Proxy父类继承了这几个方法的实现但如果你在自定义InvocationHandler里也拦截到了hashCode和toString要注意是否调用target的对应方法取决于你的实现。如果你希望代理对象和普通对象一样具备正常的equals和hashCode行为你需要谨慎地判断方法和变量是否属于Object然后把调用转发给target。常见的参考实现是if (method.getDeclaringClass() Object.class) { if (method.getName().equals(hashCode)) { return System.identityHashCode(proxy); } else if (method.getName().equals(equals)) { return proxy args[0]; } else if (method.getName().equals(toString)) { return proxy.getClass().getName() Integer.toHexString(proxy.hashCode()); } }不处理的话代理对象的hashCode和equals会走Proxy父类里的版本。很多集合类去重、比较时会出莫名其妙的问题源头就藏在动态代理的这个细节里。4. JDK动态代理与CGLIB的选型对比接口优先还是类优先4.1 CGLIB的代理方式继承加字节码增强CGLIB和JDK走的不是一条路。JDK靠接口生成代理类CGLIB靠继承生成子类。CGLIB生成的子类继承了目标类所以在子类里可以重写父类的非final方法。方法调用时通过MethodInterceptor回调实现增强。重要区别CGLIB可以代理没有接口的类。CGLIB不能代理final类。CGLIB不能代理final方法final方法无法被重写。Spring的默认策略我记得很清楚目标类实现了接口用JDK动态代理否则用CGLIB。在Spring Boot 2.x之后默认改成了CGLIB代理即使目标类有接口也优先CGLIB。这个变动好多人不知道以为是框架升级之后“出Bug了”其实是代理策略变了。4.2 两者的性能差异真的那么玄乎吗早期Java版本里CGLIB的创建速度比JDK慢因为要生成子类、做字节码操作但方法调用速度CGLIB在某种场景下更快。随着JDK不断优化现在JDK 17这个高低已经没那么明显了。我做过一个简单的基准测试在循环调用一千万次方法时两者差距在10%以内而且长期运行的JIT热优化之后差距会进一步缩小。但有一个影响是锁粒度层面的完全不在一个量级CGLIB框架运行时需要依赖ASM库做字节码操作生成的字节码和JIT优化之间有一些微妙的关系但这些对普通业务开发来说可以忽略不计。4.3 选型表JDK动态代理 vs CGLIB我把常用对比项整理成一张表方便你直接抄对比维度JDK动态代理CGLIB代理原理接口生成代理类继承目标类生成子类目标类要求必须实现接口无接口可用但不能是final类final方法不受影响接口方法本来就不是final无法代理创建性能较高JDK 8以后优化明显略低首次生成类开销大调用性能良好良好长期运行差距小依赖仅JDK自带需要引入cglib库/ASM典型应用Spring AOP默认、MyBatis Mapper代理Spring Boot 2.x AOP默认这里我多说一句自己的实战感受大多数业务场景用哪个都行。接口设计得好的项目用JDK代理就够了不需要为了追求某种性能指标去换CGLIB。真正应该关心的还是回调逻辑本身别把代理层的代码写得特别重那样再快的字节码也救不了你的延迟。5. 实战案例给一个用户服务加上可复用的动态代理日志切面5.1 场景设定和代码结构假设有一个用户服务接口UserService实现类UserServiceImpl。需求是给接口里所有方法加上调用日志包括方法名、参数、返回值、执行耗时。接口定义public interface UserService { User getUserById(Long id); void saveUser(User user); void deleteUser(Long id); }实现类public class UserServiceImpl implements UserService { Override public User getUserById(Long id) { // 模拟查询耗时 return new User(id, name- id); } Override public void saveUser(User user) { // 模拟保存 } Override public void deleteUser(Long id) { // 模拟删除 } }5.2 日志增强用的InvocationHandlerpublic class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start System.currentTimeMillis(); System.out.println(【日志】调用方法: method.getName()); Object result null; try { result method.invoke(target, args); return result; } finally { long cost System.currentTimeMillis() - start; System.out.println(【日志】方法执行耗时: cost ms); } } }注意我这里的写法method.invoke(target, args)target是构造函数传进来的目标对象。有人会误写成method.invoke(proxy, args)这会导致递归调用自己因为proxy是代理对象一调用就又进入invoke最终栈溢出。这是一个经典错误我见过太多次了。5.3 创建代理和完整测试public class ProxyDemo { public static void main(String[] args) { UserService target new UserServiceImpl(); LogInvocationHandler handler new LogInvocationHandler(target); UserService proxy (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), handler ); proxy.saveUser(new User(1L, 张三)); proxy.getUserById(2L); proxy.deleteUser(3L); } }运行后输出大致是【日志】调用方法: saveUser 【日志】方法执行耗时: 0ms 【日志】调用方法: getUserById 【日志】方法执行耗时: 0ms 【日志】调用方法: deleteUser 【日志】方法执行耗时: 0ms这样日志增强就完成了业务代码一点没动。以后想加耗时统计、权限校验、事务控制都可以在invoke里统一处理。5.4 控制台的彩蛋为什么代理类叫$Proxy0如果你在main方法里加上这句System.out.println(proxy.getClass().getName());输出会是com.sun.proxy.$Proxy0这个类名前面提到过。它是JDK动态生成的类不在你的源码目录里所以反编译看不到但在调试时看到这个类名就知道这是代理对象。有时候项目里同名类一多日志里全是$Proxy0可能会懵知道这个机制就好办了。6. 动态代理在主流框架里的三大典型应用不止Spring AOP6.1 Spring AOP方法增强的地基Spring里的AOP切面配置Before、After、Around最终都要落到动态代理上。Spring容器启动时发现一个Bean需要被增强就不再直接返回原Bean而是返回一个代理Bean。你在Service里Autowired拿到的对象可能已经不是你自己写的那个类了而是一个代理对象。所以你会看到一些这样的现象在类内部通过this调用自己的另一个方法AOP增强不生效。因为this指向的是目标对象而不是代理对象绕过代理了。要解决这种问题可以注入自己Spring 4.3以后可以通过构造器或setter注入自身代理或者拆成两个Bean。这是一个被问烂了的面试题也是实际项目中经常踩的坑。6.2 MyBatis Mapper接口如何变成SQL执行器MyBatis里你只定义了一个UserMapper接口没有实现类为什么能直接用因为MyBatis在启动时对每个Mapper接口生成了一个代理对象。调用userMapper.selectById(1L)时代理对象把方法名、参数类型、注解信息解析成SQL然后交给SQL会话执行。这个场景下动态代理的作用不是“增强”而是“完全接管实现”。它和AOP的用途差异值得留意AOP是在原有逻辑前后加东西MyBatis是连实现类都不写代理对象直接当实现用。6.3 RPC框架的透明调用很多RPC框架比如Dubbo式的设计思路的原理也和动态代理有关。本地调用一个接口方法代理对象把这个调用信息打包成请求通过网络发送给远端服务再把远端返回的结果解包后传回调用方。对于调用方的代码来说它完全不知道服务器在哪里、协议是什么只像调用本地方法一样。这个“本地接口与远程语义之间的翻译官”就是动态代理。如果你在公司里写过类似的自研RPC组件一定对这句代码非常有感触HelloService service RpcProxy.create(HelloService.class, 127.0.0.1, 8080);create方法内部返回的就是一个动态代理对象。7. 动态代理的边界与坑位排查从ClassCastException到递归调用7.1 坑位一ClassCastException接口类型弄错如果目标类实现的是接口A和接口B但你在强转时用了接口C运行时会抛ClassCastException。代理类只实现了传入的那些接口不会自动去实现别的接口。排查思路先确认target.getClass().getInterfaces()返回了什么再确认你强转的目标类型在不在里面。7.2 坑位二递归调用invoke里传错对象这是我反复强调的一个坑。// 错误 method.invoke(proxy, args); // 正确 method.invoke(target, args);第一行会导致代理对象再次被调用又进入invoke无限递归到栈溢出。有些开发者写的时候没注意一运行直接StackOverflowError还以为是JVM栈参数配小了。验证办法也很简单打印proxy和target的类名一个是$Proxy0一个是你的实现类。看到$Proxy0出现在invoke的目标里基本就是这个错误。7.3 坑位三final方法拦截不到CGLIB通过继承实现代理final方法无法被重写所以拦不到。JDK代理没有这个问题但它只能代理接口方法。项目里如果有个方法是final的同时又期望被AOP切到最好提前调整设计。我在改造老项目时专门处理过这种final方法批量替换的脏活。7.4 坑位四方法重载带来的Method对象判断混乱如果你在invoke里通过method.getName()做判断来区分“该方法是否要增强”一旦接口里有重载方法同名不同参光比对名称不够还得比对参数类型。举个例子public interface UserService { User findByName(String name); User findByName(String name, int age); }两个方法都叫findByName你想只增强第一个需要检查method.getParameterTypes()。这个坑在写通用拦截器时尤其明显因为通用拦截器要适配大量方法签名单靠名字匹配一定会误伤。7.5 坑位五访问权限和包可见性JDK动态代理生成的代理类是public final但如果你要代理的是包级私有的接口方法在Java 17的强封装下可能会碰到模块访问限制。CGLIB这边如果目标类的方法对子类不可见private、包级私有子类也无法重写实际就是拦不到。写通用代理时尽量让目标类和方法的可见性足够开放或者在设计阶段就想清楚哪些方法需要被切面处理。8. 手写一个无依赖的简易动态代理工厂有些时候你不想引入Spring那样的大框架比如做一个工具库、写一个本地Mock服务或者单纯想加深对代理机制的理解可以自己写一个工厂类。它不依赖任何第三方库只用JDK自带能力。public class ProxyFactory { public static T T createProxy(T target, InvocationHandler handler) { if (target null || handler null) { throw new IllegalArgumentException(target and handler must not be null); } Class? targetClass target.getClass(); return (T) Proxy.newProxyInstance( targetClass.getClassLoader(), targetClass.getInterfaces(), handler ); } public static InvocationHandler timingHandler(Object target) { return (proxy, method, args) - { long start System.currentTimeMillis(); try { return method.invoke(target, args); } finally { long cost System.currentTimeMillis() - start; System.out.println([ method.getName() ] cost cost ms); } }; } }这里有个需要说明的设计决策我把handler创建也收进了工厂但日常使用中其实更推荐调用方自己传handler进工厂因为不同场景需要的handler差别太大工厂做死反而限制了灵活性。上面这个写法更适合做演示和测试。这个工厂类可以直接用于轻量级项目不需要引入Spring AOP那一整套就能给现有接口加上统一的日志、耗时统计、权限开关、灰度放量逻辑。好处是类足够轻JVM启动开销小坏处是你要自己管理代理对象的生命周期。适用场景判断标准很简单只有几条业务链路要增强用自研工厂全项目几百个类都要加切面老老实实上成熟框架。如果想更深入一点还可以尝试自己写一个简化版CGLIB通过ASM生成子类字节码来代理无接口类。这个工程量不小但对理解字节码增强体系非常有帮助。我自己的经验是先在JDK动态代理上做透再去啃ASM层面的东西顺序不能反。9. 性能损耗到底有多严重一个一百万次调用的实测参考在写这篇文章之前我专门跑了一个小实验来直观感受动态代理引入的性能损耗到底有多大。测试内容很简单一个接口方法每次调用都返回当前时间戳分别测原生调用、JDK动态代理调用、CGLIB代理调用各循环一百万次取多次运行后的平均值。为了贴近真实场景我把JVM调到服务器模式也考虑了JIT预热先跑足够的预热循环再统计。测试环境是常见开发机配置结果只做参考。调用方式单次平均耗时纳秒原生对象直接调用约 5 nsJDK动态代理调用约 30 nsCGLIB代理调用约 27 ns一次性循环执行完结果为调用次数原生JDK代理CGLIB代理100万次约 4 ms约 26 ms约 24 ms数字会随着机器和JVM版本浮动但趋势很清楚动态代理不是零成本但相对于一次数据库查询几毫秒到几十毫秒、一次RPC调用几毫秒来说这点损耗可以忽略不计。真正值得关注的反而是另一个维度代理对象的创建成本。每创建一次代理类JVM都要生成字节码、加载类、触发类初始化这比普通new一个对象昂贵得多。如果某个接口的代理对象被频繁创建和丢弃比如每次请求都创建一个新的那GC压力和类加载压力都会上来。业界常见的做法是缓存代理对象一个接口一个代理实例多个线程共用。这也是动态代理要优先考虑全局共享策略的原因。10. 代理类的加载与类缓存问题别再反复new代理了接着上一节的实测说。JDK的Proxy.getProxyClass内部本身有缓存机制同一个classLoader和同一组interface组合生成的ProxyClass会被缓存住。所以理论上重复调用newProxyInstance拿到的是同一个代理类。但这里有个坑如果每次请求都自己new一个InvocationHandler代理类虽然被缓存了代理对象还是需要new的。代理对象本身比较轻但多了一层无谓的对象创建和GC压力。在高并发场景里这种优化意识很重要。再看CGLIB它也需要缓存。如果使用SpringSpring会统一管理代理类的生成和缓存不需要你关心。但如果你直接使用CGLIB的API写代码要记得把Enhancer创建过程的Key缓存起来避免每次重新生成代理类。另外一个容易被忽视的点代理类和目标接口之间的绑定在代理类生成时就已经固定了之后改不了。如果你想更换Handler只能重新生成一个代理对象无法在同一个代理对象上替换Handler。如果确实要替换Handler我一般用一层再封装把Handler放在一个可变引用里代理调用时从引用动态读取。这样可以在不重建代理的情况下切换增强逻辑。不过这只是少数场景的需求常规情况别给自己加复杂度。11. 动态代理实现上的几个关键细节和最佳实践11.1 抛出异常时的受检异常问题Java的反射方法调用method.invoke会把目标方法抛出的异常包装成InvocationTargetException。你在invoke里如果不处理外层拿到的是一个包装异常真正的原因被包在getCause()里。如果不做异常解包排查问题时你只看到InvocationTargetException那种一层套一层的堆栈很影响定位效率。处理方式很简单在invoke里对InvocationTargetException做解包try { return method.invoke(target, args); } catch (InvocationTargetException e) { throw e.getCause(); }把真实的业务异常抛出去调用方拿到的就是原始异常类型Transactional之类的框架才能正确识别日志也好查。11.2 对接口默认方法的处理JDK 8之后接口允许默认方法。代理类继承了接口后如果InvocationHandler干脆不拦截代理对象上的默认方法能直接执行不需要你额外处理。但如果你的invoke把所有方法都拦截了并且尝试通过method.invoke(target, args)去调用默认方法有些情况下会出问题因为默认方法的调用逻辑在接口里需要特殊处理。简单可靠的策略是invoke里先判断method.isDefault()是默认方法就直接调用而不是反射到target实现类上。11.3 泛型擦除带来的类型安全问题接口里有泛型方法比如T getById(Serializable id)运行时擦除了具体类型。代理的invoke只能拿到擦除后的Method对象无法感知调用方想要的泛型结果。实际开发中强转时如果忽略了泛型检查会在运行期或源码检查工具中报警告。应对办法是在业务接口设计时就尽量避免过度使用泛型方法或者在调用侧做充分的类型校验。11.4 如何打印代理生成过程的最终字节码有时候你想看看JDK到底生成了什么样的代理类可以设置JVM参数-Dsun.misc.ProxyGenerator.saveGeneratedFilestrue设置后会生成com/sun/proxy/$Proxy0.class这样的文件。下载一个反编译工具打开这个class文件你会发现$Proxy0里为每个接口方法都生成了一段代码大致逻辑是初始化handler引用方法调用时把this、方法对象、参数传给handler.invoke然后返回结果。建议每个用动态代理的开发者都自己跑一次这个操作看一遍字节码很多悬而未决的问题一下子就通了。12. 动态代理与静态代理的根本差异为什么很多框架选址时不看静态代理12.1 代码量与可维护性静态代理需要你为每个接口手写一个代理类接口方法越多代理类越长而且每个代理类逻辑高度重复。动态代理一个InvocationHandler就管住了一大片方法。当年我重构那个审计模块时静态代理方式新增一个接口就要多写一个类8个业务模块接口写完已经有明显的重复代码味道换成动态代理之后所有日志逻辑收敛到一个类里清爽太多了。12.2 对原有代码的侵入性静态代理要显式创建代理对象调用方代码得改。动态代理则可以通过依赖注入框架或工厂透明替换。这个差异决定了动态代理可以被藏在Spring IoC容器、RPC框架的底层对上层业务完全屏蔽而静态代理很难做到这种“透明感”。12.3 运行时动态性的价值静态代理的代理关系是在编译期定死的动态代理可以通过配置、注解、SPI机制在运行时决定“哪些方法被增强、增强逻辑是什么”。比如同一个接口生产环境需要权限校验测试环境只需要日志。静态代理要么写两份要么写一堆if else动态代理直接在运行时组装不同的InvocationHandler即可。13. 从动态代理延伸到字节码增强一个更广阔的创作空间写到这里我想简单延伸一下因为动态代理背后其实有一片更大的技术区域理解这一点能让你的知识体系更完整。JDK动态代理是反射自动生成类的组合。CGLIB底层用了ASM操作字节码。Spring AOP只是在两者之上做了一层管理。再往下的世界还有Java Agent技术可以在类加载时直接改写字节码实现无侵入的监控也有运行时attach机制可以在JVM启动后动态做Instrumentation很多APM工具的秒级监控就是基于这个原理实现的还有编译期注解处理器Annotation Processor这是另一种直接在源码编译阶段生成代码的方式很多流行框架的“自动生成实现类”功能就是用它做的。如果你已经把动态代理的原理吃透了下一步可以尝试自己实现一个小型AOP框架定义Log、Transaction注解解析的时候判断要不要生成代理再通过InvocationHandler统一处理。这个项目级别的工作量刚好适合一个周末完成做完之后你对Spring AOP的底层理解会完全不一样。14. 写在最后的几个实践建议最后聊几个我自己的实操体会。第一慎用全局代理。动态代理很方便但别把整个项目里所有类都代理一遍。代理越多调用链越深排查问题越费劲。能局部代理就不全局代理。第二回调逻辑不要塞太多东西。invoke方法里的代码一旦执行了数据库查询、远程调用、文件读写这些重活它就不再是轻量的“增强”了而是平添一条慢路径。写日志、做鉴权尽量减少IO操作。第三日志里打印代理类名。线上如果怀疑某段逻辑没走到第一条线索往往是看类名。看到$Proxy0或者CGLIB的EnhancerBySpringCGLIB这种类名就能确认当前对象是不是代理从而判断AOP有没有生效。第四多看看反编译出来的代理类。这个操作我前面提过真的要亲自动手一次比看十篇博客都管用。动态代理是我个人特别推荐深入研究的Java技术点因为它小、自成一体但涉及的知识面广反射、类加载、字节码、设计模式、AOP思想。把这个点吃透你再去理解那些用动态代理铺底的框架会轻松很多。希望这篇文章能帮你把这块地基打牢一些。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →