ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java代理模式进阶:从静态代理到动态代理与字节码增强

Java代理模式进阶:从静态代理到动态代理与字节码增强 1. 内容整体设计与思路拆解先抛个场景你在面试现场对面面试官不紧不慢地问了一句讲一下 Java 代理模式接着补了一句从静态代理到动态代理再到字节码增强都说说。这个问题问出来基本就能把一个人的基础功底摸清了。为什么因为代理模式看似简单但每一个进阶层次背后都牵扯到 Java 语言的核心机制从接口设计到类加载器再到字节码操作一层一层往下挖越挖越深。作为后端开发者代理模式不是会用就行的知识点。它是 Spring AOP 的底层基石是 MyBatis Mapper 接口能直接注入的实现原理是各种框架做方法增强、权限拦截、日志记录的通用手段。不理解代理模式你写出来的代码只是能跑但一旦遇到为什么这个 Bean 调用内部方法时事务失效、为什么 MyBatis 只需要写接口就能操作数据库这种问题就会卡壳。这篇文章我从最基础的静态代理讲起一步一步推到 JDK 动态代理、CGLIB 字节码增强最后手写一个字节码增强的例子。每层都会解释设计思路、底层原理、应用场景再附上我在实际开发中踩过的坑和总结的面试答题框架。适合正在准备面试的 Java 工程师也适合那些框架用得很熟练但想搞清楚底层原理的开发者。1.1 为什么从静态代理开始讲很多教程喜欢一上来就甩一个静态代理的代码示例然后说这就是静态代理读者看完一脸懵——这不就是包装了一个类嘛有什么用我换个角度理解代理模式解决的核心问题是在不修改目标对象代码的前提下对目标方法进行扩展。用一个生活化的例子。你现在要租房子直接找房东要自己看房、谈价格、签合同、处理各种琐事。现在你找一个房产中介中介帮你筛选房源、约看房、谈价格、办手续你只需要最后签个字。这个过程中房东的房子没有被改动但中介在房东和租客之间加了一层服务这层服务就是代理。对应到代码里房东是目标对象中介是代理对象租客是客户端。客户端不与目标对象直接打交道而是通过代理对象间接调用目标方法。这样做的直接好处是可以在调用目标方法的前后增加额外的处理逻辑比如日志、权限校验、事务控制而不需要改动目标类本身的代码。静态代理的代码实现很直观。需要一个接口、一个目标类、一个代理类。接口定义了业务方法目标类实现接口代理类也实现同一个接口内部持有目标对象引用然后重写方法在调用目标方法前后加逻辑。// 业务接口 public interface HouseRentService { void rentHouse(); } // 目标类房东 public class LandlordService implements HouseRentService { Override public void rentHouse() { System.out.println(房东出租房子); } } // 代理类中介 public class HouseProxy implements HouseRentService { private final HouseRentService target; public HouseProxy(HouseRentService target) { this.target target; } Override public void rentHouse() { System.out.println(中介带客户看房); target.rentHouse(); System.out.println(中介协助办理合同); } } // 客户端 public class Client { public static void main(String[] args) { HouseRentService service new HouseProxy(new LandlordService()); service.rentHouse(); } }这段代码的逻辑很清楚客户端调用的是代理类的方法代理类在执行前后织入新的逻辑然后再调用真正的目标方法。静态代理在设计模式层面上没有什么问题但它有一个致命缺陷——代理类和目标类是一对一的。假如你有十个不同的业务接口每个都想加日志功能你就得写十个代理类。每个代理类里都是相似的逻辑只是目标方法不同代码重复率极高。这就是静态代理最大的痛点它解决了一个问题同时制造了一个规模化难题。正因为这个痛点的存在动态代理才被提上了台面。1.2 代理模式的背后诉求静态代理暴露出来的问题实际上指向了一个更深层的需求能不能在运行时动态生成代理类而不是在编译期写死这样无论有多少个接口只要传入目标对象就能自动生成对应代理一套逻辑复用不用从头写一遍。这个需求在划分上就演出了两条技术路线。第一条是 JDK 动态代理它只针对接口进行代理利用 JDK 提供的 Proxy 类在运行时创建代理对象。第二条是 CGLIB它直接对目标类进行字节码扩展通过继承的方式创建子类来增强方法。两条路线都绕不开一个底层机制——字节码生成。JDK 动态代理在运行时生成代理类的字节码CGLIB 更是直接操作字节码来生成子类。理解了这个演进逻辑再去看各种框架的源码你就能看明白很多设计决策。比如 Spring 为什么不直接用 JDK 代理为什么又要配一个 cglib 的依赖为什么有些场景强制 target class 还被警告这些问题的答案全都藏在这条进阶路线里。2. 核心细节解析与实操要点2.1 JDK 动态代理工作机制拆解JDK 动态代理的核心入口是java.lang.reflect.Proxy这个类配合InvocationHandler接口工作。它的思路是不再为每个业务接口单独写一个代理类而是提供一个统一的处理器在这个处理器里集中处理方法的增强逻辑然后由 Proxy 在运行时动态生成一个实现了指定接口的代理类这个代理类的所有方法调用都会转发给处理器。代码实现长这样。import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class JdkProxyDemo { public static void main(String[] args) { // 目标对象 HouseRentService target new LandlordService(); // 创建代理对象 HouseRentService proxy (HouseRentService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new InvocationHandler() { Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(代理前记录日志); Object result method.invoke(target, args); System.out.println(代理后执行后续操作); return result; } } ); proxy.rentHouse(); } }这段代码的核心在Proxy.newProxyInstance方法它接收三个参数类加载器、接口数组、InvocationHandler。类加载器用于加载运行时生成的代理类接口数组指定代理类要实现哪些接口InvocationHandler 则是增强逻辑的载体。调用proxy.rentHouse()时实际会进入 InvocationHandler 的invoke方法在这里你决定什么时候调用目标方法、调用前后做什么。我在实际写业务的时候会把 InvocationHandler 单独抽出来作为一个类而不是直接用匿名内部类。因为匿名内部类无法复用多个地方需要同样的增强逻辑时又得复制一遍。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(); Object result method.invoke(target, args); long cost System.currentTimeMillis() - start; System.out.println(方法 [ method.getName() ] 耗时 cost ms); return result; } }2.2 为什么 JDK 动态代理只能代理接口这是面试高频考点也是很多人一直没有真正想明白的问题。答案埋在 JDK 生成代理类的源码逻辑里Java 是单继承语言JDK 生成的代理类已经继承了 Proxy 类所以无法再继承目标类只能通过实现接口的方式来完成代理。来看一个关键证据。在Proxy.newProxyInstance的底层最终会调用ProxyClassFactory的apply方法生成代理类字节码。这个方法里有一行很重要的代码sun.misc.ProxyGenerator.generateProxyClass(className, interfaces, accessFlags)注意参数是interfaces不是目标类。生成出来的代理类的类定义大致是这样的public final class $Proxy0 extends Proxy implements HouseRentService { ... }extends Proxy这一条直接宣告了它无法再继承任何其他类。所以它只能通过接口来建立与业务类型的联系。你告诉 Proxy 你需要实现哪些接口它就生成一个实现这些接口的类。这也是为什么目标对象必须实现了至少一个接口JDK 动态代理才能工作。如果目标类没有实现任何接口JDK 动态代理直接报错Exception in thread main java.lang.ClassCastException: com.sun.proxy.$Proxy0 cannot be cast to ...或者无法找到接口时抛 IllegalArgumentException。这一点在 Spring 里体现得很明显当你的 Bean 没有实现接口时Spring 会自动切换为 CGLIB 代理因为 JDK 动态代理根本没法下手。2.3 手写一个 JDK 动态代理的调试案例光看 API 很难真正理解代理对象的内部结构。我自己做调试时喜欢把运行时生成的代理类 dump 出来直接看字节码。方法很简单在启动参数里加上-Dsun.misc.ProxyGenerator.saveGeneratedFilestrue加上这个参数后JDK 生成的$Proxy0.class会保存到本地磁盘你可以用任何反编译工具打开看。截取关键部分代理类的结构大概是public final class $Proxy0 extends Proxy implements HouseRentService { 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 rentHouse() { try { super.h.invoke(this, m3, null); } catch (RuntimeException | Error e) { throw e; } catch (Throwable e) { throw new UndeclaredThrowableException(e); } } static { try { m3 Class.forName(HouseRentService).getMethod(rentHouse, new Class[0]); // 其他方法初始化 } catch (NoSuchMethodException e) { throw new NoSuchMethodError(e.getMessage()); } } }这个结构说明了一件事每个接口方法在代理类里都有一个对应的 Method 对象方法调用被统一包装成对 handler.invoke 的转发。static代码块里通过反射获取所有需要代理的方法存到静态变量里性能上比每次调用都反射查找方法要好得多。在写这个调试案例时我踩过一个坑dump 出来的代理类在 JDK 8 和 JDK 17 下的包名不一样。JDK 8 里类名是com.sun.proxy.$Proxy0JDK 11 之后变成了jdk.proxy1.$Proxy0。如果你在程序里硬编码类名做判断升级 JDK 之后就会出问题。实际开发中千万不要依赖代理类的具体类名。3. 实操过程与核心环节实现3.1 CGLIB 字节码增强的实现与参数说明CGLIB 不要求目标类实现接口它的思路是直接为目标类生成一个子类然后重写需要增强的方法。因为是继承关系所以目标类不能被 final 修饰被增强的方法也不能是 final 方法。CGLIB 的核心 API 是Enhancer和MethodInterceptor。Enhancer 负责设置父类和回调逻辑MethodInterceptor 负责拦截方法调用。示例代码如下import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; public class CglibDemo { public static void main(String[] args) { // 目标类没有实现任何接口 LandlordService target new LandlordService(); Enhancer enhancer new Enhancer(); // 设置父类为目标类 enhancer.setSuperclass(LandlordService.class); // 设置回调 enhancer.setCallback(new MethodInterceptor() { Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println(代理前权限校验); Object result proxy.invokeSuper(obj, args); System.out.println(代理后操作日志); return result; } }); // 生成代理对象 LandlordService proxy (LandlordService) enhancer.create(); proxy.rentHouse(); } }这里有一个特别重要的细节MethodInterceptor.intercept方法的最后一个参数MethodProxy。执行目标方法时有两条路用method.invoke(obj, args)或者用proxy.invokeSuper(obj, args)。这两条路的性能差距非常大。method.invoke(obj, args)走的是传统的反射调用性能受到反射机制的限制在频繁调用场景下会有明显的开销。而proxy.invokeSuper(obj, args)走的是 FastClass 机制它会为代理类和目标类分别生成一个索引类直接通过索引定位方法避免了反射调用速度快很多。在写 CGLIB 拦截器时默认应该使用 invokeSuper 而不是 invoke。3.2 Spring AOP 中的代理选型逻辑Spring AOP 是代理模式最典型的实际应用场景。每次创建 Bean 时如果检测到 Bean 需要被增强就会创建代理对象。但这里有一个选择逻辑到底用 JDK 动态代理还是 CGLIBSpring Boot 2.x 之前Spring AOP 默认使用 JDK 动态代理因为它是 JDK 自带的能力不需要额外引入依赖。但 JDK 代理要求目标实现接口这就导致了一个常见问题如果你的 Service 没有实现接口Spring AOP 会悄悄退回 CGLIB。Spring Boot 2.x 开始官方把spring.aop.proxy-target-class默认值改成了 true也就是说默认优先使用 CGLIB。为什么官方要改默认策略原因很实际。JDK 代理只能代理接口中声明的方法如果你的目标类中有接口之外的自定义方法这些方法是不会被代理的。这在很多业务场景下会导致方法没被拦截的诡异问题。而 CGLIB 是子类继承机制目标类所有的非 final 方法都能被代理实际上 CGLIB 更准确来说是能力匹配不上的问题。要在 Spring Boot 中强制指定代理策略配置项是spring.aop.proxy-target-classtrue如果设为 false则回归 JDK 动态代理但目标类必须实现接口否则启动就会报错。我在实际项目里见过由于切换代理策略导致 Service 注入失败的情况本质就是目标类没有接口而配置又强制 JDK 代理。排查这类问题有个快速方法启动时打印一下 Bean 的类名看到EnhancerBySpringCGLIB说明走的是 CGLIB看到jdk.proxy或者com.sun.proxy说明走的是 JDK 代理。3.3 字节码增强的底层原理JDK 动态代理和 CGLIB 虽然面向的编程接口不同但它们的最底层都是字节码生成。字节码是 JVM 能直接识别的指令集Java 源码编译后生成.class文件这个文件里的内容就是字节码。CGLIB 在生成子类的时候本质上是直接构造出了一段符合 JVM 规范的字节码然后交给类加载器加载。它底层的字节码操作库是 ASMASM 是 Java 生态里最底层的字节码操作框架也几乎是性能最高的。Spring 的很多内部组件都直接用 ASM 操作字节码比如 Spring 的LocalVariableTableParameterNameDiscoverer从 class 文件里解析方法参数名时就是直接用 ASM。我自己试过用 ASM 手动生成一个类的字节码说实话非常痛苦因为要直接操控指令级别的流程可读性极差。日常开发中没必要这么原始但理解一下这个过程能帮你看穿代理的本质。ASM 生成一个简单的类需要创建 ClassWriter定义版本号、修饰符、类名等然后逐个添加字段和方法。import org.objectweb.asm.ClassWriter; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; public class AsmGenerateDemo { public static byte[] generateClass(String className) { ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_MAXS); cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, className, null, java/lang/Object, null); // 生成默认构造函数 MethodVisitor mv cw.visitMethod(Opcodes.ACC_PUBLIC, init, ()V, null, null); mv.visitCode(); mv.visitVarInsn(Opcodes.ALOAD, 0); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, java/lang/Object, init, ()V, false); mv.visitInsn(Opcodes.RETURN); mv.visitMaxs(0, 0); mv.visitEnd(); // 生成 sayHello 方法 mv cw.visitMethod(Opcodes.ACC_PUBLIC, sayHello, ()V, null, null); mv.visitCode(); mv.visitFieldInsn(Opcodes.GETSTATIC, java/lang/System, out, Ljava/io/PrintStream;); mv.visitLdcInsn(Hello from ASM); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, java/io/PrintStream, println, (Ljava/lang/String;)V, false); mv.visitInsn(Opcodes.RETURN); mv.visitMaxs(0, 0); mv.visitEnd(); cw.visitEnd(); return cw.toByteArray(); } }字节码写多了你会发现Java 编译之后其实就是这么一套简单的指令序列加一个方法、访问一个字段、调用一个方法都有对应的指令。代理框架做的事情本质上就是把这套指令序列自动化、模板化。理解了这一层你再看 CGLIB 的源码很多之前觉得玄乎的东西都会变得清晰起来。3.4 用 Javassist 做一个能看懂的字节码增强ASM 的性能好但代码可读性对普通开发者不友好。如果想快速验证运行时生成类并加载的整个链路Javassist 是个更合适的选择。它让我们可以用 Java 代码的语法来拼凑类的源码然后由 Javassist 编译成字节码。下面是一个完整的示例运行时生成一个接口的实现类加载并调用。import javassist.ClassPool; import javassist.CtClass; import javassist.CtMethod; import javassist.CtNewMethod; public class JavassistDemo { public static void main(String[] args) throws Exception { ClassPool pool ClassPool.getDefault(); // 创建新类实现 HouseRentService 接口 CtClass cc pool.makeClass(com.example.DynamicRentService); cc.addInterface(pool.get(HouseRentService)); // 生成方法 CtMethod method CtNewMethod.make( public void rentHouse() { System.out.println(\动态生成的服务实现\); }, cc ); cc.addMethod(method); // 生成字节码 byte[] bytes cc.toBytecode(); // 加载类并实例化需自定义类加载器处理 DynamicClassLoader loader new DynamicClassLoader(); Class? clazz loader.defineClass(com.example.DynamicRentService, bytes); HouseRentService service (HouseRentService) clazz.getDeclaredConstructor().newInstance(); service.rentHouse(); } static class DynamicClassLoader extends ClassLoader { Class? defineClass(String name, byte[] bytes) { return defineClass(name, bytes, 0, bytes.length); } } }这里有个容易踩的坑如果你用ClassPool.getDefault()创建类再强行调用cc.toClass()会抛ClassNotFoundException或者报attempted duplicate class definition原因在于这个类的字节码生成后父加载器复用了当前线程的上下文类加载器去加载同类名。稳妥做法是自己定义一个ClassLoader调用defineClass方法来完成类的加载。这一个坑我印象很深看了不少博客都没讲清楚自己在 debug 里绕了好一阵才搞明白。4. 应用场景与高频问题排查实录4.1 框架中的应用MyBatis 的 Mapper 代理MyBatis 是另一个把代理模式用到极致的框架。我们看到的现象是定义一个 Mapper 接口不需要写实现类直接Autowired注入就能用。背后的机制就是 JDK 动态代理。MyBatis 的MapperProxyFactory为每个 Mapper 接口创建一个代理对象代理对象的方法调用被统一拦截到MapperProxy中。MapperProxy拿到方法之后从方法上解析出 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 类的方法直接调用不经过 SQL 处理 if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 解析 SQL 并执行 MapperMethod mapperMethod cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); } }注意invoke方法里有一个重要的判断Object.class.equals(method.getDeclaringClass())。如果不加这个判断toString()、hashCode()、equals()这些方法也会被当成 SQL 方法来处理。这算是动态代理一个经典的细节问题不只是 MyBatis自己在写 InvocationHandler 时也必须处理这一类 Object 方法。4.2 日常开发中动态代理的几个实际用法除了框架底层动态代理在业务代码里也有不少实用场景。我自己用得比较多的是做一个统一的接口调用监控。公司内部有多个微服务之间通过 Feign 或者 HTTP 调用每次调用都要记录耗时、传参、异常信息。如果每个方法里去写日志代码冗余又难以维护。用一个动态代理就能把所有远程调用的日志逻辑统一处理public class RemoteCallProxy implements InvocationHandler { private final Object target; RemoteCallProxy(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String methodName method.getDeclaringClass().getSimpleName() . method.getName(); long start System.currentTimeMillis(); try { Object result method.invoke(target, args); long cost System.currentTimeMillis() - start; log(methodName, success, cost, args); return result; } catch (Throwable e) { long cost System.currentTimeMillis() - start; log(methodName, error, cost, args, e); throw e; } } }这样写完之后只需要在创建服务实例的地方统一走代理工厂所有远程调用的日志问题就解决了。用法上注意一点这适合统一的横切逻辑如果某个方法的日志格式有特殊要求还是要单独写不要试图在一个代理里适配所有个性化需求否则代理方法会变得越来越胖违背了代理模式解耦的初衷。4.3 高频问题排查技巧与避坑清单动态代理相关的报错和诡异问题我整理了一份实战排查清单都是真实项目中踩过的。问题一目标没有接口但配置了 JDK 代理报错信息通常是BeanNotOfRequiredTypeException: Bean named userService is expected to be of type xxx.UserService but was actually of type jdk.proxy1.$Proxy117原因是 Spring 开启 AOP 后默认用 JDK 动态代理但目标类没有实现接口。解决方式把spring.aop.proxy-target-class设为 true或者把代理对象注入时用接口类型接住。注意即使设了 true配置类的EnableAspectJAutoProxy(proxyTargetClass true)也需要保持一致。问题二同类内部方法调用AOP 不生效这是非常经典的坑。同一个类的 A 方法调用 B 方法B 方法标注了事务注解但事务并没有生效。原因是 Spring AOP 的原理是代理对象调用方法才会触发增强而内部调用是this.method()走的是目标对象本身没有经过代理引用。排查方法在 B 方法里打印this.getClass()会发现是目标类而不是代理类。解决办法有几种把内部调用改成注入自身代理拆到另一个 Bean 中或者用AopContext.currentProxy()但需要配置exposeProxytrue。我个人的建议是优先拆分 Bean这种结构最干净也最符合单一职责原则。问题三final 类或 final 方法无法使用 CGLIB 代理CGLIB 基于继承生成子类目标类带final或目标方法带final时子类无法覆盖代理增强不会生效。注意这里不是报错而是静默失效。事务注解标在 final 方法上程序正常跑但事务不生效排查起来很隐蔽。我遇到过同事排查了一个下午最后发现是方法被标成了 final事务注解在 IDE 里看起来生效了但实际上没有任何影响。这在设计上其实有积极意义它天然提醒你不可继承的类不可被代理增强写框架时如果有类似需求要预留扩展点。问题四代理模式下类型判断与强转的坑有时候代码里写了if (obj instanceof UserServiceImpl)在非代理模式下没问题但在代理模式下会返回 false因为实际对象是代理生成的子类不是目标类本身。同理用obj.getClass()拿到的类型、用targetClass做反射判断都会和目标类不一致。解决思路要判断类型时优先使用接口类型去判断如果硬要用instanceof判断具体实现类改成判断ClassUtils.isAssignableValue配合接口不要依赖具体实现类。这条在集成测试里也经常会遇到因为测试中如果注入的是 mock 对象再套上代理类的层次关系会变得很复杂。我的习惯是对于被 Spring 管理的 Bean永远用接口引用不做具体类强转。4.4 面试回答的参考框架这类问题面试官喜欢按阶梯问答题照着同样的阶梯走就能覆盖到位第一层答静态代理定义代理角色、目标角色、客户端三要素说明静态代理的编译期绑定特点。简述一个代码场景点明它的问题——每加一个业务类就要建一个代理类维护成本高。第二层答 JDK 动态代理说清楚Proxy.newProxyInstance三个参数的作用InvocationHandler 的调用流程。主动强调只能代理接口的原因——生成的代理类已经继承了 Proxy 类Java 单继承无法再继承目标类。第三层答 CGLIB说明它的继承式代理机制MethodInterceptor中invokeSuper与invoke的性能差异以及 final 类、final 方法的限制。第四层答底层字节码增强说明无论是 JDK 代理还是 CGLIB底层都是运行时生成字节码、再用类加载器加载进 JVM。能提一句 ASM 和 Javassist 的区别会显得真的有深入钻研过。记住答题时不要光背概念主动说出为什么比背结论更有说服力。特别是 Proxy 生成类继承结构、CGLIB FastClass 机制这些点回答出来就和其他背八股文的候选人拉开了差距。
返回列表