资讯详情

资讯详情

一文搞懂Java动态代理:JDK动态代理与CGlib详解

做过几年Java后端的人基本都会在项目里碰上动态代理这个词。哪怕你没主动用过也一定被Spring、MyBatis这些框架间接安排过——Controller里的方法调用、Service层的事务管理、Mapper接口的注入背后几乎都有动态代理的影子。这篇文章我就用一线开发的实际视角把Java动态代理这件事从头到尾拆一遍从JDK动态代理到底怎么工作的到CGlib的实现机制再到两者怎么选、面试官最爱问哪些坑尽量把原理讲透、把代码给全让无论是刚学Java基础的朋友还是准备跳槽刷八股文的同学都能从中拿走点实在的东西。1. 动态代理的本质为什么框架都爱用它1.1 从静态代理到动态代理的演进逻辑理解动态代理之前先回忆一下静态代理。假设你有一个UserService接口里面有个saveUser方法现在想在方法执行前后打印日志静态代理的做法是再写一个UserServiceProxy类实现同一个接口持有原始对象然后在每个方法里手动加上日志逻辑。public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target target; } Override public void saveUser(User user) { System.out.println(开始保存用户...); target.saveUser(user); System.out.println(保存用户结束...); } }这样做有啥问题首先是代码爆炸每给一个类加日志、加事务、加权限校验就得写一个代理类。其次是维护痛苦如果接口新增一个方法代理类和目标类要同步改。更可怕的是如果我要给十个Service都加日志就得写十个代理类里面全是重复的样板代码。动态代理的思路是不再为每个目标类单独写代理类而是在程序运行过程中根据需要动态地生成一个代理对象这个代理对象能够拦截所有方法调用在调用前后统一做增强处理。这样一套通用机制就能复用到任意接口、任意类上。Spring AOP、MyBatis的Mapper动态实现、Retrofit的接口转HTTP请求这些框架的底层基石就是这个东西。1.2 动态代理的两个技术流派Java世界里动态代理的实现方案主要有两种JDK动态代理基于接口实现利用java.lang.reflect.Proxy类在运行时创建代理类配合InvocationHandler完成方法拦截。它是JDK自带的不需要额外依赖。CGlib动态代理基于继承实现运行时通过ASM字节码框架生成目标类的子类配合MethodInterceptor完成方法拦截。因为效率高、不要求目标类实现接口被Spring等框架广泛使用。很多人以为CGlib是Spring的东西其实它是独立的开源库cglibCode Generation LibrarySpring只是把它集成进来当做一个代理方案。后来Spring 5.3版本之后又把代理库切换成了更现代的Byte Buddy但Spring AOP默认代理方式里CGlib仍然占据重要位置。1.3 动态代理能解决什么实际问题从业务角度来说动态代理最常见的应用场景就是无侵入增强。什么意思就是说你不需要改动原有业务代码就能给方法加上额外能力。典型场景包括日志记录上线排查问题时希望知道每个方法的入参、出参、耗时。事务管理Spring中Transactional注解标记的方法由代理对象负责开启、提交、回滚事务。权限校验方法调用前检查当前用户是否有权限执行。懒加载Hibernate等ORM框架对实体关联对象的延迟加载。远程调用本地接口方法调用自动转为RPC/HTTP调用像Feign、Dubbo的Reference就是这种思路。这些场景共通的模式都是在不改源码的前提下在方法调用链路上插入逻辑。理解了这个思想后面看代码就会容易很多。2. JDK动态代理从接口到拦截器2.1 三个核心角色JDK动态代理涉及的API就三个Proxy、InvocationHandler、Proxy.newProxyInstance。InvocationHandler是一个接口里面只有一个方法invoke。所有被代理对象的方法调用都会先进入这个invoke方法。Proxy是所有动态代理类的父类提供静态方法newProxyInstance(...)用来生成代理对象。代理对象本身是JVM在运行时按照你给的接口定义自动生成的一个类实例它实现了你指定的接口内部关联了一个InvocationHandler。这三者的协作关系是这样的外部调用代理对象的方法时代理类内部会把方法调用转交给InvocationHandler.invoke()你在invoke里拿到方法名、参数决定调用之前做什么、是否调用真正的方法、之后做什么。2.2 手写一个完整示例先定义接口和目标实现类。注意JDK动态代理要求目标必须有接口。public interface UserService { void saveUser(String name); String getUserName(Long id); } public class UserServiceImpl implements UserService { Override public void saveUser(String name) { System.out.println(保存用户 name); } Override public String getUserName(Long id) { return 用户- id; } }再写一个通用的InvocationHandler实现用来打印方法调用耗时。为了让这个拦截器可复用我用泛型设计。import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; public class TimingInvocationHandler implements InvocationHandler { private final Object target; public TimingInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start System.currentTimeMillis(); Object result method.invoke(target, args); long cost System.currentTimeMillis() - start; System.out.println(方法 method.getName() 耗时 cost ms); return result; } }最后用Proxy.newProxyInstance生成代理对象。import java.lang.reflect.Proxy; public class ProxyDemo { public static void main(String[] args) { UserService target new UserServiceImpl(); TimingInvocationHandler handler new TimingInvocationHandler(target); UserService proxy (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), // 类加载器 target.getClass().getInterfaces(), // 目标类实现的接口列表 handler // 方法调用的处理器 ); proxy.saveUser(张三); System.out.println(proxy.getUserName(100L)); } }运行之后输出保存用户张三 方法 saveUser 耗时 55 ms 用户-100 方法 getUserName 耗时 0 ms注意一下输出顺序先执行了目标方法输出保存用户张三再打印了耗时信息。这说明invoke方法里顺序是先调用真正方法、再打印耗时。如果你想把耗时日志放前面就把打印挪到method.invoke之前。2.3 逐一讲透三个参数为什么这么传Proxy.newProxyInstance的三个参数很多新手容易死记硬背我来解释一下每个参数到底传什么、有什么用。第一个参数是类加载器ClassLoader。动态代理会在运行时生成一个新的类这个类要被JVM加载。用什么加载器常用的选择是目标类自己的类加载器。因为代理类要实现的接口UserService类加载器和目标类是同一个上下文用target.getClass().getClassLoader()一定不会错。极端情况下如果类加载器传错会报ClassCastException或者ClassNotFoundException。第二个参数是接口数组Class?[]。代理对象实现了哪些接口就传哪些接口。通常直接取target.getClass().getInterfaces()它能拿到目标类直接实现的接口列表。注意这里有个细节getInterfaces()只能拿到直接实现的接口如果目标类实现了接口AA又继承了接口B那getInterfaces()只会返回AB要额外处理才能拿到。早期版本里Spring还会用工具类去遍历父接口就是为了解决这个问题。第三个参数是InvocationHandler处理器。这个参数是代理逻辑的核心里面放的就是方法增强的代码。一个InvocationHandler实例可以被多个代理对象复用只要你愿意。2.4 invoke方法里的proxy参数和三个陷阱invoke(Object proxy, Method method, Object[] args)里面第一个参数proxy是生成的代理对象本身。它有什么用最常见的用途是在invoke里做方法级别的分流Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (saveUser.equals(method.getName())) { // 特殊处理 saveUser 方法 } return method.invoke(target, args); }但是这里有一个大坑在invoke里直接调用proxy上的方法会再次触发invoke导致无限递归。比如你写proxy.toString()就会再次进入invoke再调用proxy.toString()……直到栈溢出。正确的做法想要调用目标方法用的是method.invoke(target, args)这个target是你自己持有的原始对象不会触发代理逻辑。想获取代理对象的信息直接操作参数里的proxy别再调用它上面的业务方法。第二个陷阱是method对象上如果调用invoke时的接收者传成了proxy也会无限递归。见过很多新手把method.invoke(proxy, args)写成这样然后疯狂报StackOverflowError。记住method.invoke的第一个参数必须是原始目标对象。第三个陷阱invoke方法签名里args可能为null。如果目标方法的是无参方法Proxy传进来的args就是null而不是空数组。所以处理入参时要做空判断if (args ! null) { System.out.println(入参个数 args.length); }很多框架里都有类似判断比如Spring AOP的MethodInvocation在获取参数时会先判断长度再遍历就是防这个坑。2.5 JDK代理类的字节码验证它到底长什么样想看看JDK生成的代理类长什么样在运行时加一个JVM参数即可导出字节码文件-Dsun.misc.ProxyGenerator.saveGeneratedFilestrueJDK8及之前用这个参数JDK9之后变成了-Djdk.proxy.ProxyGenerator.saveGeneratedFilestrue导出后反编译你会发现代理类大致长这样public final class $Proxy0 extends Proxy implements UserService { private static Method m1; private static Method m2; private static Method m3; private static Method m4; public $Proxy0(InvocationHandler h) { super(h); } public final void saveUser(String var1) throws { try { super.h.invoke(this, m3, new Object[]{var1}); } catch (RuntimeException | Error var3) { throw var3; } catch (Throwable var3) { throw new UndeclaredThrowableException(var3); } } }几个重点代理类继承了Proxy而Proxy里有个InvocationHandler h字段。它把所有接口方法改写为对super.h.invoke(this, 方法对象, 参数数组)的调用。因为Java单继承机制代理类已经继承了Proxy所以再不能继承其他类——这就是JDK动态代理只能针对接口的根本原因。这个道理想明白了后面CGlib的设计动机也就顺理成章。3. CGlib动态代理突破接口的限制3.1 为什么需要CGlibJDK动态代理好用但有个硬伤目标类必须实现接口。如果代码里没有接口只有普通的实现类JDK代理就无能为力了。现实项目里确实有很多工具类、第三方库类是直接写的没有抽接口。比如一个ExcelExporter类你只想给它加个导出操作日志总不能为了用动态代理先给它再造个接口吧。CGlib的思路完全不同它直接生成目标类的子类来作为代理。子类重写父类的方法在重写方法里完成方法前置/后置增强。因为用的是继承所以不要求目标类实现任何接口。3.2 CGlib的完整示例CGlib不在JDK里需要额外引入依赖。如果是Maven项目dependency groupIdcglib/groupId artifactIdcglib/artifactId version3.3.0/version /dependency注意Spring 5.3之后虽然把AOP底层的字节码生成换成了Byte Buddy但org.springframework:spring-core里依然保留了org.springframework.cglib包的封装所以很多项目不显式依赖cglib也能用。不过独立用的时候还是建议自己引。下面写一个普通的目标类不需要接口。public class UserDao { public void insert(String name) { System.out.println(插入用户 name); } public String query(Long id) { return 用户- id; } }然后定义一个MethodInterceptorCGlib里对应的角色叫net.sf.cglib.proxy.MethodInterceptor。import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; public class LogInterceptor implements MethodInterceptor { Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println(开始执行 method.getName()); Object result proxy.invokeSuper(obj, args); System.out.println(结束执行 method.getName()); return result; } }通过Enhancer创建代理对象。import net.sf.cglib.proxy.Enhancer; public class CglibDemo { public static void main(String[] args) { Enhancer enhancer new Enhancer(); enhancer.setSuperclass(UserDao.class); enhancer.setCallback(new LogInterceptor()); UserDao proxy (UserDao) enhancer.create(); proxy.insert(李四); System.out.println(proxy.query(1L)); } }运行结果开始执行insert 插入用户李四 结束执行insert 开始执行query 结束执行query 用户-1可以看到proxy被强制转换成UserDao类型后可以直接使用这在调用方看来和操作原始对象几乎没有区别。intercept方法里的proxy.invokeSuper(obj, args)是关键一步它的作用是调用父类也就是目标类里的原始方法。注意这里用的是MethodProxy的invokeSuper而不是用Method的invoke两者性能差异在后续章节会展开讲。3.3 CGlib的使用限制final和privateCGlib用继承实现所以有些Java语法天然限制了它final类不能被代理final类不能有子类CGlib直接没法生成代理类。final方法不能被代理虽然子类可以继承到final方法但无法重写所以拦截不会生效。调用时走的还是父类原始逻辑。private方法不能被代理private方法对子类不可见无法重写。构造方法不会被拦截CGlib生成子类时构造链会调用父类构造方法但这个调用过程不走intercept也就是说构造阶段你做不了增强。在实际开发里遇到代理了但方法没被增强的现象先检查目标类和方法是不是被final修饰这是排查的第一优先级。3.4 MethodProxy.invokeSuper和method.invoke的区别这块值得单独讲一下因为面试里真的会问实际项目里也有人踩性能坑。MethodProxy.invokeSuper(obj, args)走的是FastClass机制。CGlib为每个目标类额外生成一个FastClass类通过索引直接定位方法直接调用全程没有反射开销。而Method.invoke(obj, args)是纯反射调用每调用一次都要进行方法访问权限检查、参数包装等性能相对低。写一个简单的压测对比// 循环 100 万次调用 long start System.nanoTime(); for (int i 0; i 1000000; i) { proxy.invokeSuper(obj, args); } long cost1 System.nanoTime() - start; start System.nanoTime(); for (int i 0; i 1000000; i) { method.invoke(obj, args); } long cost2 System.nanoTime() - start;我之前在本地JDK8下测过invokeSuper比method.invoke大概快一到两个数量级。在低并发、低频调用的场景下差异不致命但高频调用的场景比如每秒几千次的方法切面这个差异就会影响整体RT。所以框架层面尽量用invokeSuper不要图省事直接反射。3.5 生成CGlib代理类的缓存问题使用Enhancer创建代理时每次create()都会重新生成一个新的代理类。如果在一个循环里反复调用enhancer.create()会生成大量代理类导致元空间Metaspace占用不断增长最终可能触发OutOfMemoryError: Metaspace。我之前维护过一个老项目里面有个工具类在每次请求时都用Enhancer创建代理对象。上线后跑了一个月元空间就从默认的256MB涨到接近600MB最后频繁Full GC。后来把代理对象改成单例缓存问题瞬间解决。生产级别的用法应该把代理对象缓存起来或者用框架已经封装好的工具。Spring里用ProxyFactory创建代理时它内部就做了类级别的缓存同一个目标类型只生成一次代理类。4. JDK动态代理 vs CGlib完整对比与选型指南4.1 一张表讲清核心区别对比维度JDK动态代理CGlib动态代理实现机制基于接口反射基于继承字节码生成目标类要求必须实现接口普通类即可不能是final类方法拦截原理代理类实现接口方法转发给InvocationHandler生成子类重写非final方法依赖JDK内置无需额外依赖需要引入cglib或Byte Buddy性能创建代理实例较快反射调用有开销创建代理类较慢但调用阶段有FastClass加速嵌套增强适合接口编程场景适合无接口的遗留代码典型使用框架MyBatis Mapper、Retrofit接口代理Spring AOP默认、Hibernate延迟加载4.2 Spring AOP到底默认用哪个这个知识点是面试高频。Spring AOP的代理选择逻辑是这样的如果目标对象实现了接口则优先用JDK动态代理如果目标对象没有实现接口则用CGlib动态代理。但是Spring Boot 2.x之后默认配置改成了spring.aop.proxy-target-classtrue也就是强制使用CGlib代理。为什么要这么改主要是JDK动态代理有一个很别扭的限制代理对象只能转成接口类型不能转成目标实现类类型。比如Controller里注入UserService接口没毛病但如果有地方注入UserServiceImpl具体类JDK代理直接注入就会失败因为代理对象和UserServiceImpl没有继承关系。CGlib生成的代理对象是目标类的子类可以强转成目标类兼容性更好。如果你需要在Spring中改回JDK动态代理可以在配置文件里设置spring.aop.proxy-target-classfalse不过我不建议随便改因为Spring Boot团队默认用CGlib是综合权衡过的改了反而容易踩类型转换的坑。4.3 碎片知识JDK动态代理能否代理finalCGlib能否代理接口JDK动态代理不能代理final类因为它的方式是实现接口final类并不影响实现接口所以准确说如果final类实现了接口JDK动态代理依然可以代理因为它代理的是接口层面的方法和类本身是否final无关。CGlib可以代理接口吗严格说不能直接代理接口因为接口不能作为父类生成了类。但如果接口里方法默认实现default方法CGlib处理起来也比较棘手一般不这么用。JDK动态代理生成的代理类默认也是final的因为代理类继承了ProxyJava的final修饰不会导致它不能用这和一个普通final类不能被继承并不矛盾——代理类是被生成出来直接用没有人去继承它。4.4 选型建议什么时候用哪种判断标准其实很简单目标对象有接口、代码风格是面向接口编程优先JDK动态代理简单、无依赖、性能足够。目标对象没有接口、或者需要代理成具体类型必须用CGlib。方法调用量极大且全部要走代理链路CGlib调用阶段性能更好。需要频繁创建新代理实例JDK动态代理创建成本更低CGlib生成子类比较重。如果用的是Spring不需要手动选框架已经帮你做好了决策。但如果你在造轮子、写自己的AOP工具理解这些底层差异能帮你绕开很多坑。5. 动态代理的高频应用场景拆解5.1 Spring AOP里面的动态代理Spring AOP用动态代理实现声明式事务、权限控制和日志切面。它的核心流程是解析Aspect注解、切面表达式为符合条件的Bean创建代理对象在方法调用时按顺序执行增强逻辑。结合前面的知识你再看Spring AOP的JdkDynamicAopProxy和CglibAopProxy两个类就很好理解了。前者内部持有AopProxy配置生成代理对象时传入一个InvocationHandler后者用Enhancer创建代理并设置MethodInterceptor回调。两者本质上是同一个设计思想在不同技术方案上的实现。这里有个经典问题Spring事务为什么有时候不生效很多情况下就是因为动态代理的特性同类内部方法调用走的是this调用不会经过代理对象切面自然拦截不到。比如Service public class OrderService { Transactional public void createOrder() { updateStock(); // 内部调用不走代理updateStock上的事务注解不生效 } Transactional public void updateStock() { // 修改库存 } }解决办法一般是把方法拆分到另一个Bean或者从Spring上下文里取出代理对象后再调用比如通过AopContext.currentProxy()。5.2 MyBatis Mapper的动态代理先抛出个反直觉的事实MyBatis的Mapper接口没有实现类。那为什么userMapper.selectById(1L)能直接执行SQL就是因为MyBatis用了MapperProxy它实现了InvocationHandler接口。当调用sqlSession.getMapper(UserMapper.class)时MyBatis用JDK动态代理为接口生成一个代理实例所有方法调用都派发给MapperProxy.invoke()。在invoke里根据方法名从MapperMethod中解析SQL语句执行并返回结果。理解了动态代理你会对MyBatis的Magic少很多困惑它并不是真的把SQL绑定到了Java方法上而是通过代理在运行时把方法调用翻译成了对SqlSession的操作。5.3 RPC框架中的动态代理Dubbo、Feign这类RPC框架也大量使用了动态代理。以Feign为例FeignClient注解的接口在容器启动时会被FeignClientFactoryBean处理最终通过Feign的ReflectiveFeign为接口生成JDK动态代理。当你调用orderFeignClient.getOrder(id)时代理对象把方法名、参数解析为HTTP请求模板然后发起远程调用再把响应体反序列化为返回值。5.4 手动实现一个迷你AOP框架讲了这么多来动手做一个非常精简的AOP工具。核心思路就是动态代理注解扫描。先自定义一个注解import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface LogExecution { }再提供一个代理工厂根据目标对象类型自动决定用JDK还是CGlibimport net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class MiniAopFactory { public static Object wrap(Object target) { Class? clazz target.getClass(); if (clazz.getInterfaces().length 0) { return Proxy.newProxyInstance(clazz.getClassLoader(), clazz.getInterfaces(), (proxy, method, args) - { if (method.isAnnotationPresent(LogExecution.class)) { System.out.println([AOP] 拦截 method.getName() 开始); } Object result method.invoke(target, args); if (method.isAnnotationPresent(LogExecution.class)) { System.out.println([AOP] 拦截 method.getName() 结束); } return result; }); } Enhancer enhancer new Enhancer(); enhancer.setSuperclass(clazz); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) - { if (method.isAnnotationPresent(LogExecution.class)) { System.out.println([AOP](CGlib) 拦截 method.getName() 开始); } Object result proxy.invokeSuper(obj, args); if (method.isAnnotationPresent(LogExecution.class)) { System.out.println([AOP](CGlib) 拦截 method.getName() 结束); } return result; }); return enhancer.create(); } }这个工具严格来说还有很多边界没处理比如异常后的事务回滚但已经足够演示动态代理的代理调度、方法增强、逻辑插入的核心机制了。在实际项目里我们完全可以通过这种方式给老代码加日志而不去改动老代码的任何一行。6. 常见问题与面试避坑指南6.1 动态代理的典型报错和排查方法报错1ClassCastException: com.sun.proxy.$Proxy0 cannot be cast to xxx原因分析JDK生成的代理类实现了接口但它和你的目标类并没有继承关系。如果把代理对象强转成目标类类型一定会抛ClassCastException。解决只能转成接口类型。// 错误写法 UserServiceImpl service (UserServiceImpl) proxy; // 正确写法 UserService service (UserService) proxy;报错2StackOverflowError几乎都是因为invoke里调用了proxy上的方法导致方法递归进入invoke。排查时搜索代码里是否存在proxy.xxx()或method.invoke(proxy, ...)的写法。注意这里有个隐藏情况如果你在invoke里打印proxy.getClass()会触发getClass()。虽然getClass是Object的方法但代理类不会把getClass转发到invoke里所以这个可以放心。但像toString、hashCode、equals这几个Object方法默认会被代理拦截打印它们时要小心。报错3IllegalArgumentException: Cannot subclass final class class xxxCGlib生成代理类时发现目标类是final的无法创建子类。解决去掉final修饰或者改用接口JDK代理。报错4NullPointerException at ... MethodProxy.invokeSuper通常是构造代理时传入的target为null或者Objcet类型不对。排查时确认Enhancer.setSuperclass的类型和你传入invokeSuper的obj类型一致。6.2 面试八股这些考点必须背牢动态代理是Java基础到进阶的分水岭面试题基本绕不开。整理几个最常问的JDK动态代理和CGlib的区别是什么从机制、接口要求、性能、依赖、限制五个维度回答参考前面的对比表。Spring AOP默认用的是JDK还是CGlib基础回答Spring默认对方只实现了接口的类用JDK动态代理深入回答Spring Boot 2.x默认开启proxy-target-class强制使用CGlib并说明原因。动态代理和静态代理的区别静态代理手动编写代理类编译期已确定动态代理运行期生成代理类解耦性更强、复用性更高。JDK动态代理为什么必须基于接口因为生成的代理类已经继承了Proxy类Java单继承不允许再继承其他业务类只能通过实现接口来暴露代理能力。CGlib能代理final方法吗不能。代理对象创建一次能复用吗能。同一个InvocationHandler可以绑定到多个代理对象同一个代理类实例可以多次调用方法。6.3 资深开发者的几个实操心得第一个心得能用JDK动态代理就尽量用JDK。虽然CGlib性能好但JDK代理调试更方便、依赖更少。尤其在一些基础框架、中间件里目标类基本都定义成接口JDK代理足够用了。第二个心得动态代理不是银弹。如果一个类的方法非常多代理逻辑很重每次方法调用都经过拦截器性能开销不可忽视。遇到这种情况可以配合Spring的Scope单例缓存代理对象不要每次请求都创建代理。第三个心得理解动态代理对排障非常有用。线上看到某个方法莫名其妙多打印了日志、事务没生效、Bean类型转换失败第一时间要想到是不是代理对象搞的鬼。多对比几个底层类Proxy、Enhancer、MethodInterceptor之间的关系很多魔法都能迎刃而解。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →