ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

动态代理从入门到实战:JDK与CGLIB原理、应用与面试避坑

动态代理从入门到实战:JDK与CGLIB原理、应用与面试避坑 先从一个我在群里被问过无数次的问题开始学会反射之后下一个绕不开的点是什么我的答案一直是动态代理。而且不只是面试要考你日常用的Spring、MyBatis、Feign、Retrofit这些框架底层全都在玩同一个东西。搞清楚动态代理你基本就能看穿半套主流框架的设计套路。这篇我打算从0开始把动态代理讲到你能在纸上徒手写出一份可用的JDK动态代理和CGLIB代理为止。1. 为什么需要动态代理从一个日志需求说起很多人学动态代理一上来就背InvocationHandler、Proxy.newProxyInstance背完就忘因为根本没搞清楚这东西到底解决了什么问题。我们先不碰API先还原一个最真实的开发场景。1.1 静态实现给每个方法手动加日志假设你现在负责一个订单服务接口大概是这样的public interface OrderService { void createOrder(String orderId, double amount); String queryOrder(String orderId); }业务很简单但老板提了一个需求所有订单操作都要记录日志包括方法名、入参、耗时。你第一反应肯定是直接改实现类public class OrderServiceImpl implements OrderService { Override public void createOrder(String orderId, double amount) { long start System.currentTimeMillis(); System.out.println(调用 createOrder参数 orderId , amount); // 核心业务逻辑 System.out.println(创建订单成功耗时 (System.currentTimeMillis() - start) ms); } Override public String queryOrder(String orderId) { long start System.currentTimeMillis(); System.out.println(调用 queryOrder参数 orderId); // 核心业务逻辑 System.out.println(查询订单成功耗时 (System.currentTimeMillis() - start) ms); return order info; } }看起来很简单对吧但问题马上就来了订单服务有几十个方法每个方法都要写一遍这段日志逻辑。更难受的是如果明天需求变成所有方法都要加权限校验你又要改几十个方法。后天需求变成所有方法都要加事务管理你还得再改一遍。这就是横切逻辑侵入业务代码的第一个痛点——重复。1.2 静态代理把公共逻辑抽出来但依然僵硬既然直接改业务类不行有人就想到了代理模式。我们保留OrderServiceImpl不动创建一个代理类让它实现同样的接口在代理类里统一加日志逻辑public class OrderServiceStaticProxy implements OrderService { private final OrderService target; public OrderServiceStaticProxy(OrderService target) { this.target target; } Override public void createOrder(String orderId, double amount) { long start System.currentTimeMillis(); System.out.println(调用 createOrder参数 orderId , amount); target.createOrder(orderId, amount); System.out.println(耗时 (System.currentTimeMillis() - start) ms); } Override public String queryOrder(String orderId) { long start System.currentTimeMillis(); System.out.println(调用 queryOrder参数 orderId); String result target.queryOrder(orderId); System.out.println(耗时 (System.currentTimeMillis() - start) ms); return result; } }这样写的好处是业务类的代码干净了公共逻辑收拢到了代理类里。但代价也很明显——你每新增一个接口就要手写一个代理类每新增一个方法就要在代理类里补一个方法。接口一改代理类也得跟着改。如果你的项目里有几十个接口你就要维护几十个代理类这本质上还是重复劳动。1.3 痛点总结JDK到底帮我们解决了什么静态代理的核心矛盾在于代理逻辑是通用的但代理类却要针对每个接口单独编写。我们真正想要的是写一次公共逻辑然后让JVM在运行时帮我们自动生成代理类自动把每个方法的调用转发到公共逻辑上。这就是动态代理的意义。JDK动态代理通过反射机制在运行时动态创建代理类你只需要写一个InvocationHandler告诉JVM所有方法的调用都走这个处理器的invoke方法剩下的代理类生成、方法匹配全部由JVM完成。你不再需要为每个接口手写代理类这就是动态二字的含义。2. JDK动态代理实战三行代码跑通核心流程理解了这个背景我们再来看JDK动态代理的代码就完全不慌了。它总共只需要两个核心角色InvocationHandler调用处理器和Proxy代理类工厂。2.1 完整代码实现与逐行解读我们先写调用处理器import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; public 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() 参数 java.util.Arrays.toString(args)); // 反射调用目标对象的真实方法 Object result method.invoke(target, args); System.out.println(方法执行完成耗时 (System.currentTimeMillis() - start) ms); return result; } }三个参数要理解透彻proxy生成的代理对象本身。你可以在invoke里调用proxy的方法但99%的情况下不要这么做因为调用proxy的任意方法都会再次进入invoke形成无限递归。method当前被调用方法的反射对象通过它可以拿到方法名、参数类型、注解最重要的是通过method.invoke(target, args)调用目标对象的真实方法。args调用方法时传入的参数列表。然后通过Proxy创建代理对象import java.lang.reflect.Proxy; public class Main { public static void main(String[] args) { // 目标对象 OrderService target new OrderServiceImpl(); // 创建代理对象 OrderService proxy (OrderService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogInvocationHandler(target) ); // 调用代理方法 proxy.createOrder(A001, 199.0); String result proxy.queryOrder(A001); System.out.println(查询结果 result); } }运行后输出调用方法createOrder参数[A001, 199.0] 创建订单成功 方法执行完成耗时0ms 调用方法queryOrder参数[A001] 查询订单成功 方法执行完成耗时0ms 查询结果order info日志逻辑只写了一次但是所有接口方法都自动有了日志这就是动态代理最直观的威力。2.2 Proxy.newProxyInstance三个参数到底在干什么第一个参数是类加载器ClassLoader。代理类是在运行时动态生成的JVM需要用一个类加载器把它加载进来。这里有一个非常容易踩的坑有人会写成proxy.getClass().getClassLoader()或者随便用一个自定义类加载器结果在某些环境下抛ClassCastException。最稳妥的写法是用被代理类的类加载器也就是target.getClass().getClassLoader()因为目标类和代理类实现了同一个接口用目标类的类加载器能确保类型兼容。有些老项目里有自定义类加载器的场景比如Tomcat的WebAppClassLoader这时候更要保持和目标类一致的类加载器。第二个参数是接口数组Class[]。JDK动态代理要求被代理对象必须实现至少一个接口代理类本质上是实现了这些接口的类所以调用方可以安全地把代理对象强转为接口类型。第三个参数是InvocationHandler。它负责定义代理逻辑JVM生成的代理类里所有方法调用最终都会转发到handler.invoke()上。2.3 一个关键问题代理对象能不能强转为实现类很多人会问如果目标类除了接口方法还有自己独有的方法代理对象能调到吗答案是不能。JVM生成的代理类只会实现你在第二个参数里传入的接口它和目标实现类没有任何继承关系。所以下面这种写法一定会抛ClassCastExceptionOrderServiceImpl implProxy (OrderServiceImpl) Proxy.newProxyInstance(...); // 报错这条规则后面在讲CGLIB的时候会形成鲜明对比——CGLIB恰恰就是为了解决没有接口怎么办而诞生的它生成的代理类是目标类的子类所以能强转为目标类本身。3. 从JDK代理到CGLIB没有接口也能代理JDK动态代理很优雅但它有一个硬约束目标对象必须实现接口。实际开发中总有类不实现接口比如很多工具类、第三方SDK里的类还有Spring里的大多数Bean。这时候CGLIB就登场了。3.1 CGLIB的核心思路通过继承实现代理CGLIBCode Generation Library的原理非常直观它通过字节码技术动态生成目标类的子类子类重写父类的非final方法在重写方法里插入代理逻辑。需要引入依赖。如果用的是Spring Boot项目spring-core里已经内置了CGLIBrepackage过的直接能用。如果是纯Java项目在pom里加dependency groupIdcglib/groupId artifactIdcglib/artifactId version3.3.0/version /dependency然后定义一个不需要接口的业务类public class UserService { public void addUser(String username) { System.out.println(添加用户 username); } public final String getUserInfo(String userId) { return user- userId; } }注意getUserInfo方法我特意加了final后面会解释为什么。接着写CGLIB的方法拦截器import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; public class LogMethodInterceptor implements MethodInterceptor { Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { long start System.currentTimeMillis(); System.out.println(CGLIB调用方法 method.getName() 参数 java.util.Arrays.toString(args)); // 注意这里是 proxy.invokeSuper不是 method.invoke Object result proxy.invokeSuper(obj, args); System.out.println(CGLIB方法执行完成耗时 (System.currentTimeMillis() - start) ms); return result; } }创建代理对象import net.sf.cglib.proxy.Enhancer; public class CglibMain { public static void main(String[] args) { Enhancer enhancer new Enhancer(); enhancer.setSuperclass(UserService.class); enhancer.setCallback(new LogMethodInterceptor()); UserService proxy (UserService) enhancer.create(); proxy.addUser(张三); System.out.println(proxy.getUserInfo(1001)); } }运行输出CGLIB调用方法addUser参数[张三] 添加用户张三 CGLIB方法执行完成耗时0ms user-1001注意输出里没有getUserInfo的日志——因为它是final方法CGLIB无法重写所以代理逻辑直接失效。这是CGLIB一个非常重要的边界。3.2 JDK代理与CGLIB的选择对比我把两者的核心差异整理成一张表面试和实际选型都很有用对比维度JDK动态代理CGLIB动态代理代理方式基于接口运行时生成实现类基于继承生成目标类的子类目标限制必须实现至少一个接口不能被final修饰被代理的方法不能是final生成速度较快类结构简单较慢需要生成子类字节码调用速度反射调用较慢通过FastClass机制直接调用较快JDK版本是JDK原生能力无需额外依赖需要引入CGLIB或Spring内置适用场景面向接口编程的Spring默认无接口的类、Spring中配置为强制CGLIB时Spring里有一个非常经典的默认策略如果Bean实现了接口优先用JDK动态代理如果没有实现接口用CGLIB。但在Spring Boot 2.x以后默认强制使用CGLIB代理主要是为了避免JDK代理只能代理接口方法导致的AOP失效问题比如Transactional加在实现类上如果只代理接口事务就失效了。3.3 为什么CGLIB调用速度更快JDK动态代理通过method.invoke(target, args)反射调用这个调用过程涉及方法查找、访问权限检查、参数装箱拆箱等操作性能开销较大。而CGLIB引入了FastClass机制——它为目标类和代理类额外生成了索引类通过方法索引直接跳转调用省去了反射查找的过程所以调用速度明显更快。这也是很多高性能框架如Spring、MyBatis内部混合使用两种代理策略的原因接口丰富的用JDK代理类结构简单的用CGLIB。4. 动态代理的底层机制JVM到底生成了什么不少人在这一节就蒙了动态代理到底是怎么动态的代理类是谁生成的长什么样我们直接深入字节码层面看看JVM到底做了什么。4.1 保存并反编译代理类JDK动态代理在运行时会用ProxyGenerator生成一个名为$Proxy0的类。默认情况下这个类是存在JVM内存里的普通开发看不到。我们可以在启动参数里加上-Dsun.misc.ProxyGenerator.saveGeneratedFilestrue或者在代码里设置System.getProperties().put(sun.misc.ProxyGenerator.saveGeneratedFiles, true);这样代理类会以$Proxy0.class的形式输出到当前目录的com/sun/proxy/文件夹里取决于JDK版本。用IDEA打开或者用javap反编译能看到类似这样的结构public final class $Proxy0 extends Proxy implements OrderService { private static Method m1; private static Method m2; private static Method m3; private static Method m4; public $Proxy0(InvocationHandler h) { super(h); } public final String queryOrder(String var1) { try { return (String) super.h.invoke(this, m3, new Object[]{var1}); } catch (RuntimeException | Error var3) { throw var3; } catch (Throwable var4) { throw new UndeclaredThrowableException(var4); } } public final void createOrder(String var1, double var2) { try { super.h.invoke(this, m4, new Object[]{var1, var2}); } catch (RuntimeException | Error var4) { throw var4; } catch (Throwable var5) { throw new UndeclaredThrowableException(var5); } } }这段字节码信息量很大。代理类继承了java.lang.reflect.Proxy同时实现了OrderService接口。每个接口方法对应一个静态的Method对象m1、m2...这些Method对象是在代理类静态初始化时通过反射取到的。而方法调用时代理类把this、对应的Method、参数列表打包然后调用super.h.invoke(...)也就是我们传入的InvocationHandler.invoke方法。4.2 为什么JDK动态代理只能代理接口从上面的字节码就能看出来了Java是单继承的而代理类已经继承了Proxy类它就不可能再去继承任何其他类。所以JDK动态代理只能通过实现接口来实现代理。这一点困扰过很多初学者——你没法让一个和Proxy毫无关系的类直接被动态代理除非它有个接口。而CGLIB走的是另一条路不继承Proxy而是直接继承目标类。子类对父类方法进行重写调用invokeSuper时走的是父类真实方法。所以CGLIB没有单继承的束缚但它受限于finalfinal类无法继承final方法无法重写这两个位置就是CGLIB的盲区也是最常见的坑。4.3 Spring AOP源码里的代理创建流程理解了原理再看Spring AOP就很容易了。Spring的AbstractAutoProxyCreator在Bean初始化后会调用wrapIfNecessary判断这个Bean是否匹配切点表达式。如果匹配就通过ProxyFactory创建代理对象如果目标对象实现了接口且proxyTargetClassfalse默认用JdkDynamicAopProxy也就是JDK动态代理。否则用ObjenesisCglibAopProxy基于CGLIB通过Objenesis绕过构造器实例化代理类。所以你在Spring项目里如果一个被Transactional标记的类没有实现接口Spring会生成一个它的CGLIB子类来代理事务。如果这个方法恰好被final修饰或者类是final那CGLIB没法重写事务注解就静默失效了——排错的时候相当折磨人。5. 动态代理在主流框架里的真实应用动态代理不是面试造火箭它就在你身边的框架里天天跑。我从目前主流的几个框架里挑几个代表性场景讲一下。5.1 Spring AOP与声明式事务Spring AOP的整个核心就是动态代理。Transactional之所以能让一个普通方法获得事务能力是因为Spring容器启动时看到这个Bean上有事务注解就会为它生成代理对象。代理对象在调用目标方法之前先开启事务目标方法执行成功后提交事务抛出RuntimeException则回滚事务。声明式事务失效的场景很多都和代理机制有关同类内部调用this调用绕过代理对象、方法不是public、方法是final、类没有实现接口但强制JDK代理等。搞懂了动态代理这些坑就全都恍然大悟了。5.2 MyBatis的Mapper代理MyBatis的Mapper接口本身没有实现类但你可以直接注入并调用它的方法。这是因为SqlSession.getMapper()内部用了MapperProxy它实现了InvocationHandler在invoke方法里根据方法名和参数从MapperMethod中解析SQL语句然后执行并返回结果。每个Mapper接口的实例其实就是一个JDK动态代理对象。可以说我们平时用MyBatis写的那一行userMapper.selectById(1)真实执行链路是经过了MapperProxy处理器转发的。5.3 日志、权限、缓存等通用横切能力动态代理最典型的应用场景还包括统一日志给所有Service方法加上入参、出参、耗时的记录而不侵入业务代码。权限校验在代理的invoke方法里读取当前登录用户判断是否有权限调用目标方法无权限直接抛异常或返回默认值。缓存代理方法执行前先去查询缓存命中直接返回未命中再调用真实方法并把结果写入缓存。事务管理对方法进行事务开启、提交、回滚的统一管理。RPC框架像Feign、Retrofit、Dubbo的接口代理用户只需要声明接口框架在运行时生成代理对象把所有调用发送到远程服务。5.4 自己动手做一个简单AOP框架理解了动态代理的所有零件后我们可以花十分钟搓一个极简AOP框架。思路很简单定义一个Log注解标记需要日志的方法然后写一个代理工厂通过反射找到方法上的注解有注解才在invoke里输出日志。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Log { } public class LogProxyFactory { SuppressWarnings(unchecked) public static T T createProxy(T target) { return (T) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) - { if (method.isAnnotationPresent(Log.class)) { long start System.currentTimeMillis(); System.out.println([AOP] 方法 method.getName() 开始执行); Object result method.invoke(target, args); System.out.println([AOP] 方法 method.getName() 执行完毕耗时 (System.currentTimeMillis() - start) ms); return result; } return method.invoke(target, args); } ); } }用起来就像这样OrderService proxy LogProxyFactory.createProxy(new OrderServiceImpl()); proxy.createOrder(A002, 299.0);这个极简AOP虽然没有Spring那么完备但核心机制已经全部打通了通过注解标记切点通过动态代理织入增强逻辑。6. 高频面试考点与实战避坑动态代理是Java面试的高频考点。我整理了最常见的几个问题和实战中最容易踩的坑。6.1 面试题问烂了的那几个问题JDK动态代理和CGLIB动态代理有什么区别参考上面那张表格重点答JDK基于接口CGLIB基于继承CGLIB不能代理final方法和final类Spring默认策略。动态代理的实现原理是什么JDK动态代理通过ProxyGenerator在运行时生成$Proxy0类实现目标接口所有方法调用转发到InvocationHandler.invoke()再由invoke通过反射调用目标真实方法。CGLIB通过字节码技术生成目标类的子类子类重写方法通过MethodProxy.invokeSuper调用父类方法。JDK动态代理为什么只能代理接口因为代理类已经继承了Proxy类Java单继承机制不允许它再继承其它类只能借助接口。Spring AOP用的是JDK代理还是CGLIBSpring Boot默认强制CGLIB纯Spring默认是有接口用JDK无接口用CGLIB。从Spring Boot 2.x开始默认使用CGLIB因为JDK代理对实现类上的注解支持不好。动态代理的性能问题怎么看创建代理对象的过程比静态代理开销大需要生成字节码、加载类但方法调用层面CGLIB有FastClass索引优化JDK在较新版本也有优化。对绝大多数业务系统来说性能差距可以忽略。需要做极致性能的场景才考虑把代理对象缓存复用避免重复创建。6.2 实战避坑invoke里调用proxy的方法会导致StackOverflowError这是我见过最多人踩的第一个坑。在LogInvocationHandler里写Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(proxy.toString()); // 死循环 return method.invoke(target, args); }因为proxy是代理对象调用它的toString()方法会再次进入invoke方法又调用proxy.toString()无限递归最终StackOverflowError。解决办法在invoke里永远不要调用proxy的任何方法需要啥信息就从method和args里取。6.3 实战避坑method.invoke(target, args)中target传错对象invoke方法里反射调用目标方法时第一个参数必须是目标对象。如果传成proxy因为proxy不是目标类型的实例轻则ClassCastException重则又进入递归。我见过有人把proxy传给method.invoke的调试了半天才反应过来。6.4 实战避坑CGLIB代理final方法的静默失效在Spring里给一个final方法加Transactional项目启动不会报任何错误但方法执行完根本不会有事务。这就是CGLIB无法重写final方法导致的静默失效排查起来极其痛苦。建议团队规范里明确被Spring AOP管理的方法不要加final修饰。另外CGLIB代理因为要生成子类所以目标类不能被final修饰。Spring Boot 2.x默认使用CGLIB代理如果你的某个配置类被final修饰启动时会直接报错。6.5 实战避坑代理对象的类型判断JDK代理对象isInstance(target的类型)是false因为它和目标类没有继承关系。比如System.out.println(proxy instanceof OrderServiceImpl); // false System.out.println(proxy instanceof OrderService); // true这在做有些框架设计的时候很关键别想当然用instanceof判断代理对象。6.6 性能优化复用代理对象与Class缓存每次Proxy.newProxyInstance都会重新生成代理类并加载如果业务里频繁创建代理对象性能消耗会很可观。最典型的场景是MyBatis如果每次查询都new一个Mapper代理系统性能会直线下降。所以许多框架内部会缓存代理类甚至代理对象。自己在做工具类的时候也建议这样public class ProxyCache { private static final ConcurrentHashMapClass?, Object CACHE new ConcurrentHashMap(); SuppressWarnings(unchecked) public static T T getProxy(ClassT interfaceType, InvocationHandler handler) { return (T) CACHE.computeIfAbsent(interfaceType, type - Proxy.newProxyInstance(type.getClassLoader(), new Class?[]{type}, handler)); } }这样同一个接口只生成一次代理类后续直接复用代理对象。7. 学习建议动态代理之后该往哪个方向走动态代理学到这个程度已经不是学过而是掌握了。按照我的经验学完动态代理之后有几条路值得继续走它们的知识会互相印证越学越通透。7.1 强化AOP思想动态代理是AOP面向切面编程的底层实现手段之一但AOP不止于动态代理。Spring AOP中还有切点Pointcut、通知Advice、切面Aspect的概念这些是建立在动态代理之上的抽象。建议在理解了动态代理之后手动去Spring官网上跑一遍Before、After、Around的Demo再尝试用动态代理复现一个简单的事务管理。这一步做完你对Spring的认知会有一个质的飞跃。7.2 深挖字节码增强CGLIB只是字节码增强的代表之一还有更底层的ASM、Java assist、以及新一代的Byte Buddy。如果你对JVM和字节码技术感兴趣可以读一读ASM的文档尝试用ASM生成一个最简单类的字节码。这会让你对类是怎么被加载的字节码长什么样有非常直观的理解对后面学习JVM调优、Java Agent都很有帮助。7.3 阅读框架源码我的建议是先从MyBatis的MapperProxy入手因为它的类结构最简洁逻辑最清晰。然后看Spring的JdkDynamicAopProxy、CglibAopProxy、AbstractAutoProxyCreator。你会发现框架内部不是把动态代理当作一个面试考点在讲而是把它当作基础设施在反复使用。7.4 自己写一个小框架这个是我最推荐的收尾动作。你可以尝试写一个类似Spring AOP的轻量级框架支持注解定义切面、支持多个通知顺序、支持JDK代理和CGLIB代理自动切换。当你把它跑通的时候动态代理就不只是你简历上的一行字而是长在你脑子里的肌肉记忆了。
返回列表