拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java进阶必读:反射与动态代理从原理到MyBatis、Spring AOP实战

写Java学习日记以来DAY20是我最焦躁的一天。头天晚上练习MyBatis我明明白白只写了一个UserMapper接口连一行实现代码都没有运行时调用selectById(1)数据却老老实实返回了。一个没有实现类的接口凭什么能被调用这个问题卡了我两小时最终把我拽到了Java反射和动态代理的门口。这篇文章就是DAY20的完整学习记录从接口如何被实例化这个疑问出发把反射常用API、JDK动态代理、CGLIB的工作机制完整过了一遍再回头看MyBatis和Spring AOP为什么那么设计。如果你正在Java进阶阶段准备啃框架源码或者刷面试题这篇日记应该能帮上忙。1. DAY20的开题一个让我卡了两小时的疑问1.1 事情的起因一个凭空出现的代理对象当时我的代码结构和下面这段很像。定义了一个接口没有任何实现类public interface UserMapper { User selectById(Integer id); }然后直接用MyBatis的方式拿了个mapper出来这玩意儿居然能正常查询SqlSession sqlSession sqlSessionFactory.openSession(); UserMapper mapper sqlSession.getMapper(UserMapper.class); User user mapper.selectById(1); System.out.println(user.getName());我盯着UserMapper mapper sqlSession.getMapper(...)这行代码看了很久。UserMapper明明是个接口getMapper返回的到底是个什么对象接口不能new这是Java基础课第一节就讲过的规则但现实是这个接口的实例确实在工作。下意识用getClass()看了一眼System.out.println(mapper.getClass().getName());输出的是com.sun.proxy.$Proxy7。我当时不认识这个东西但这个名字里的Proxy让我隐约觉得这应该是一个代理对象。它实现了UserMapper接口所以能赋值给UserMapper mapper但它的实际逻辑大概率被转发到了某个统一的处理器里。这个问题让我把原本的学习计划全部推翻DAY20临时改成反射与动态代理专题。1.2 DAY20的学习目标与路线我把当天的目标拆成了四件事搞懂反射的三个核心操作获取Class对象、操作构造器和方法、访问私有成员。手写一个完整的JDK动态代理从InvocationHandler到Proxy.newProxyInstance。搞明白JDK动态代理和CGLIB的差别知道Spring AOP底层到底怎么选。回来研究MyBatis的MapperProxy顺便扫一眼Spring AOP的切面实现把原理对应到真实框架里。下面就是当天笔记的整理版。我会把代码、原理、踩过的坑都写在一起尽量还原当时的思考过程。2. 反射先把透视Class对象这件事搞明白2.1 获取Class对象的三种姿势与选择反射的第一步永远是拿到Class?对象。Class对象在JVM里是唯一的每个类在运行时只有一份对应的Class实例它承载了这个类的结构信息构造器、方法、字段、注解、接口、父类。获取方式有三种方式代码特点类名.classClassUser clazz User.class;编译期类型安全不触发静态初始化实例.getClass()Class? clazz user.getClass();拿到的是运行期实际类型适合多态场景Class.forNameClass? clazz Class.forName(com.demo.User);通过字符串类名加载会触发静态初始化块第三种最常用也最容易踩坑。Class.forName(com.demo.User)不只是加载类它还会执行User里的静态代码块。早年JDBC驱动注册就是用这个特性Class.forName(com.mysql.cj.jdbc.Driver);这行代码触发了Driver类的静态初始化从而让驱动自动注册到DriverManager。这是有意的设计但如果你只是想把类拿到手不想执行静态代码那就要用User.class或者ClassLoader.loadClass()。// 只加载不初始化 Class? clazz User.class.getClassLoader().loadClass(com.demo.User);踩坑记录我第一次写反射工具类时图省事把几个类名写错了一个字母Class.forName直接抛ClassNotFoundException。虽然异常信息很明确但在批量处理场景里一个类名错误可能中断整批任务所以我后来都在工具类里统一catch并且打日志而不是让异常一路上抛。2.2 用反射操作构造器、方法与字段拿到Class对象后我对照着一份普通User类做了三组练习。准备一个简单的实体类package com.demo; public class User { private String name; private Integer age; public User() { } private User(String name, Integer age) { this.name name; this.age age; } public void setName(String name) { this.name name; } public String getName() { return name; } Override public String toString() { return User{name name , age age }; } }第一组获取构造器并创建对象。User里的有参构造器是private的正常情况下外面不可能new但反射可以Class? clazz Class.forName(com.demo.User); // 获取私有构造器 Constructor? constructor clazz.getDeclaredConstructor(String.class, Integer.class); constructor.setAccessible(true); // 反射创建对象 Object user constructor.newInstance(小明, 20); System.out.println(user);getConstructor只能拿public的getDeclaredConstructor可以拿私有构造器。拿到之后还必须setAccessible(true)否则调用时会抛IllegalAccessException。setAccessible的本质是修改Java语言访问权限的检查开关让JVM跳过访问控制代价是破坏了封装性。这个能力在Spring里非常关键比如Autowired需要注入私有字段时Spring靠的就是这个机制。第二组获取方法并调用。Method setName clazz.getDeclaredMethod(setName, String.class); setName.invoke(user, 小红); Method getName clazz.getDeclaredMethod(getName); Object name getName.invoke(user); System.out.println(name);这里有个容易迷糊的点getDeclaredMethod的第二个参数是方法形参的类型Class?...不是实参值。因为Java方法可以重载setName(String)和setName(Integer)是不同方法必须通过参数类型精确定位。我一开始写成了getDeclaredMethod(setName, 小刚)以为传的是参数值编译直接报错。后来才反应过来方法定位靠的是签名而不是值。第三组访问私有字段。Field nameField clazz.getDeclaredField(name); nameField.setAccessible(true); nameField.set(user, 王五); System.out.println(user); // User{name王五, age20}这组练习做完我确实感觉到反射的暴力了。私有构造器、私有方法、私有字段全都能被穿透。不过在Java 9之后模块系统收紧了反射的权限。如果目标类在某个模块里没有对当前模块opens指定的包即使setAccessible(true)也会抛InaccessibleObjectException。我们平时写普通应用没有模块化影响不大但如果做框架给用户用就必须考虑这个兼容问题。2.3 invoke慢的真相与缓解办法网上总说反射慢我做了个粗糙测试频繁调用invoke和直接调用对比差距确实明显。慢的原因不只是反射绕了一圈更主要的是这几个方面invoke在调用前会做方法签名匹配、参数装箱、包装结果等处理。反射调用无法像直接调用那样被JIT内联优化因为它跨越了Method.invoke这个通用入口。权限检查也存在开销虽然setAccessible(true)能去掉一部分但不会完全消除。每一次invoke都会把受检异常包装成InvocationTargetException处理异常有额外成本。缓解的方向也明确把Class、Method、Field缓存起来不要每次都getDeclaredMethod这个查找过程本身很贵。高频场景下调用前统一setAccessible(true)。对极端性能敏感的场景可以考虑MethodHandle但它的API更底日常业务没必要上。我的个人结论是业务代码里如果没有频繁的反射调用性能完全不是问题真正要命的是用反射去写一系列getMethod(getXxx)这种散装代码那既影响着又不安全。Spring之所以好用是因为它把反射的复杂性收进了框架内部并且都做了元数据缓存。3. 动态代理JDK Proxy怎么做到拦截所有方法的3.1 静态代理先打个底在直接看JDK动态代理之前我先写了个静态代理做铺垫。静态代理的逻辑特别朴素写一个类实现同一个接口内部持有真实对象的引用然后在重写的方法里做增强。public class UserServiceStaticProxy implements IUserService { private final IUserService target; public UserServiceStaticProxy(IUserService target) { this.target target; } Override public User findById(Integer id) { long start System.currentTimeMillis(); User result target.findById(id); System.out.println(findById 耗时 (System.currentTimeMillis() - start) ms); return result; } Override public void updateName(Integer id, String name) { long start System.currentTimeMillis(); target.updateName(id, name); System.out.println(updateName 耗时 (System.currentTimeMillis() - start) ms); } }静态代理的问题很直白每增加一个方法代理类就得同步加一个方法方法体里干的还是同一件事计时、日志、事务代码严重重复。如果我有十个Service接口就得写十个代理类。这种感觉就像每个接口配了个专用管家管家之间还不能共用一套流程。3.2 JDK动态代理的完整实现JDK动态代理把方法拦截统一收口到一个处理器上无论接口有多少方法增强逻辑只写一份。先定义接口和真实实现public interface IUserService { User findById(Integer id); void updateName(Integer id, String name); } public class UserServiceImpl implements IUserService { Override public User findById(Integer id) { return new User(用户 id, id); } Override public void updateName(Integer id, String name) { System.out.println(更新用户 id 的姓名为 name); } }然后是核心的InvocationHandlerpublic class TimeInvocationHandler implements InvocationHandler { private final Object target; public TimeInvocationHandler(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创建代理对象IUserService target new UserServiceImpl(); IUserService proxy (IUserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new TimeInvocationHandler(target) ); proxy.findById(1); proxy.updateName(1, 小李);运行结果更新用户 1 的姓名为 小李 findById 执行耗时 1 ms updateName 执行耗时 1 ms到这里我理解了第一层所谓动态代理就是JVM在运行时动态生成一个实现了指定接口的类这个类内部所有接口方法的调用都会走到同一个InvocationHandler.invoke里。所以我真正的业务逻辑只需要写一遍想做什么增强都在invoke里控制。3.3 JDK动态代理的底层原理$Proxy0是从哪冒出来的第二部分让我更踏实Proxy.newProxyInstance在运行时用字节码技术动态生成了一个类类名形如com.sun.proxy.$Proxy0。这个类有几个关键特征继承了java.lang.reflect.Proxy。实现了我们传入的所有接口。所有接口方法被重写方法体里把调用转交给InvocationHandler.invoke。equals、hashCode、toString这三个Object方法也会被转发到InvocationHandler。为什么JDK动态代理只能代理接口答案就在这生成的代理类已经继承了Proxy类Java是单继承这个类没法再继承其他类了所以只能靠实现接口来扩展。如果目标类没有接口JDK动态代理就无从下手。我第一次没配好类加载器代码长这样Proxy.newProxyInstance( this.getClass().getClassLoader(), // 错误示范 target.getClass().getInterfaces(), new TimeInvocationHandler(target) );结果报了IllegalArgumentException提示接口和类加载器不在同一个包里或者类型不一致。原因是类加载器需要能加载到目标接口。正确做法是用目标对象自己的类加载器或者接口的类加载器保证JDK生成的$Proxy0能被正常装载并关联到接口。还有一个细节值得说InvocationHandler.invoke的第一个参数proxy指的是代理对象本身。在invoke里对proxy调用方法时要格外小心如果写成proxy.toString()递归又来了。正确的做法是用反射调用target对应的方法或者处理Object方法时直接判断method.getDeclaringClass() Object.class。4. 打破接口限制CGLIB动态代理的工作机制与选型4.1 CGLIB原理与代码示例JDK动态代理只能代理接口那么无接口的类怎么办比如一个类没有实现任何接口只是继承了BaseService这时JDK Proxy就无能为力。CGLIB解决的正是这个问题。CGLIB的核心思路是生成目标类的一个子类在子类中重写父类的非final方法并在重写方法里插入拦截逻辑。因为是继承方案所以目标类不能被final修饰final方法也无法重写。模拟真实使用场景public class OrderService { public void createOrder(String orderNo) { System.out.println(创建订单 orderNo); } public final void cancelOrder(String orderNo) { System.out.println(取消订单 orderNo); } }public class TimeMethodInterceptor implements MethodInterceptor { Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { long start System.currentTimeMillis(); // 注意这里调的是 proxy.invokeSuper不是 method.invoke Object result proxy.invokeSuper(obj, args); long cost System.currentTimeMillis() - start; System.out.println(method.getName() 执行耗时 cost ms); return result; } }创建CGLIB代理Enhancer enhancer new Enhancer(); enhancer.setSuperclass(OrderService.class); enhancer.setCallback(new TimeMethodInterceptor()); OrderService proxy (OrderService) enhancer.create(); proxy.createOrder(A001); proxy.cancelOrder(A002);运行后会发现createOrder被拦截了但cancelOrder没有被拦截。原因就是cancelOrder是final方法子类无法重写它。CGLIB只能老老实实调用父类原有的实现。这里有个我踩过的坑在intercept方法里回调目标方法时如果用的是method.invoke(obj, args)在某些版本里会产生递归问题因为obj本身就是代理子类method又是被重写的父类方法容易再次走进拦截器。标准写法是使用MethodProxy.invokeSuper(obj, args)它会直接调用父类目标方法绕过子类重写。4.2 JDK Proxy与CGLIB选型对比表把两种代理放在一起对比关键差异非常清楚对比项JDK动态代理CGLIB代理原理运行时生成代理类实现传入接口运行时生成目标类子类重写非final方法目标要求必须有接口目标类不能是final方法限制只能代理接口中声明的方法不能代理final方法额外依赖JDK内置cglib或spring-core内置版本创建速度较快较慢需要生成子类字节码调用性能现代JVM下有优化差距不大略优直接调用父类方法适用场景基于接口的标准Bean无接口的类或强制CGLIB的项目Spring Boot 2.x之后Spring AOP的默认行为变成了CGLIB优先spring.aop.proxy-target-classtrue哪怕目标Bean实现了接口默认也走CGLIB。这一点很多人容易忽略遇到注入时类型不匹配或者代理转换异常先检查是不是代理方式变了。4.3 CGLIB的坑final修饰符不可逾越CGLIB的坑主要集中在继承带来的限制上final类无法被代理。如果OrderService被final修饰Enhancer创建代理时会直接报错。final方法无法被增强。这通常不报错但是你的切面逻辑不会生效容易造成为什么我的AOP不生效这种诡异问题。private方法不会被增强。因为子类不可见也谈不上重写。构造方法会被执行两次吗代理子类创建时会调用父类构造器所以目标类不是无参构造时需要注意CGLIB的构造参数处理但大多数框架场景都依赖无参构造或Spring的Bean实例化机制日常不用太纠结。排查切面不生效时我建议按这个顺序检查类是不是final、方法是不是final/private、目标对象是不是被代理替换了、切点表达式匹配是否正确。5. 反射与动态代理的真实应用从MyBatis Mapper到Spring AOP5.1 MyBatis Mapper接口、代理与SqlSession的三角关系把原理学完我回到最初的UserMapper问题上。MyBatis的getMapper最终代码大概长这样简化版逻辑public class MapperProxyT implements InvocationHandler { private final ClassT mapperInterface; private final SqlSession sqlSession; public MapperProxy(ClassT mapperInterface, SqlSession sqlSession) { this.mapperInterface mapperInterface; this.sqlSession sqlSession; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 处理 Object 自带的方法 if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 拼接 statementId接口全限定名 方法名 String statementId mapperInterface.getName() . method.getName(); // 简化处理实际还有 Select 注解解析、参数处理等 return sqlSession.selectOne(statementId, args); } }而SqlSession.getMapper内部做的事情就是用一个MapperProxyFactory创建JDK动态代理public T T getMapper(ClassT type, SqlSession sqlSession) { MapperProxyT mapperProxy new MapperProxy(type, sqlSession); return (T) Proxy.newProxyInstance( type.getClassLoader(), new Class[]{type}, mapperProxy ); }这段代码和我前面手写的JDK动态代理几乎一一对应。UserMapper没有实现类但MyBatis在运行时给它生成了一个实现UserMapper接口的代理对象。所有方法调用统一落到MapperProxy.invoke方法名被拼成com.demo.mapper.UserMapper.selectById到Configuration里查找对应的MappedStatement再通过SqlSession执行SQL。这个设计最巧妙的地方在于每个Mapper接口看起来是独立的但所有Mapper的实现逻辑其实是同一个MapperProxy在做。无论你定义十个接口还是一百个接口MyBatis只需要一个通用处理器不需要给你每个接口都写实现。这就是动态代理通用管家的核心价值。5.2 Spring AOP一个日志注解背后的代理机制Spring AOP的底层和动态代理关系更直接。写一个最简单的日志切面Aspect Component public class LogAspect { Around(annotation(com.demo.annotation.Loggable)) public Object aroundLog(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); Object result pjp.proceed(); System.out.println(pjp.getSignature().getName() 耗时 (System.currentTimeMillis() - start) ms); return result; } }Service public class OrderService { Loggable public void createOrder(String orderNo) { System.out.println(创建订单 orderNo); } }Spring在启动时发现OrderService上需要应用切面就直接为目标Bean创建代理。如果目标类实现了接口并且允许JDK代理就生成JDK代理对象否则生成CGLIB代理对象。容器里保存的实际上是代理对象而不是你写的原始Bean对象。这也是为什么同类内部方法调用AOP不生效public void outer() { inner(); // 这里调用的是 this.inner()不是代理对象的方法 } Loggable public void inner() { }因为outer()里直接调用了this.inner()而这个this是原始目标对象或者说在代理对象内部指向目标对象本身没有经过代理拦截。要让切面生效必须从外部拿到代理对象并调用proxy.inner()或者把inner()拆分到另一个Bean里。这是面试里非常经典的问题。5.3 反射工具类日常少手写反射看完几个框架我最大的感受是反射API虽然基础但日常业务里真的不建议疯狂手写。因为容易出错的地方太多了类名拼写、方法签名、异常处理、权限问题每个都是坑。Spring自带了一个ReflectionUtils封装了常见操作Method method ReflectionUtils.findMethod(User.class, setName, String.class); ReflectionUtils.makeAccessible(method); ReflectionUtils.invokeMethod(method, user, 小红);Hutool也有ReflectUtil用起来更简洁ReflectUtil.invoke(user, setName, 小红);我对工具类的态度是写一两个简单方法可以自己来但涉及大量反射操作的通用逻辑直接用成熟工具类更稳妥。框架的开发者和维护者已经帮你踩过大部分坑了。6. DAY20面试复盘反射与代理的高频考点与手写练习6.1 口述题JDK动态代理为什么只能代理接口这是我整理的面试回答思路JDK动态代理通过Proxy.newProxyInstance在运行时生成一个代理类。这个代理类继承了java.lang.reflect.Proxy因为Java不支持多继承代理类不能再继承其他类。要让它具备业务方法的能力只能通过实现接口来完成。接口方法在代理类里被重写所有调用会转发到InvocationHandler.invoke。所以JDK动态代理要求目标必须有接口接口就是它添加方法的唯一通道。CGLIB的思路完全不同它直接生成目标类的子类所以不要求接口但要求类不能被final修饰方法不能被final修饰。回答这个问题的关键点在于代理类继承了Proxy单继承限制了它只能走接口这条路。6.2 手写题用JDK动态代理实现一个Loggable日志注解加上注解的定义Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Loggable { }业务接口和实现public interface IUserService { Loggable User findById(Integer id); void updateName(Integer id, String name); } public class UserServiceImpl implements IUserService { Override public User findById(Integer id) { return new User(用户 id, id); } Override public void updateName(Integer id, String name) { // 没有 Loggable不打印日志 } }代理处理器public class LoggableInvocationHandler implements InvocationHandler { private final Object target; public LoggableInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.isAnnotationPresent(Loggable.class)) { long start System.currentTimeMillis(); Object result method.invoke(target, args); long cost System.currentTimeMillis() - start; System.out.println(执行 method.getName() 耗时 cost ms); return result; } return method.invoke(target, args); } }测试IUserService target new UserServiceImpl(); IUserService proxy (IUserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LoggableInvocationHandler(target) ); proxy.findById(1); // 会打印日志 proxy.updateName(1, 小李); // 不会打印日志这个练习虽然简单但把InvocationHandler、注解检查、反射调用串起来了和Spring AOP的基础思想是一致的。6.3 八股延伸反射慢、代理失效的几个细节除了核心原理面试还经常追问这几个相关点反射和单例通过反射setAccessible(true)调用私有构造器可以创建第二个单例实例。所以枚举为什么能防反射因为Constructor.newInstance对枚举类做了限制遇到枚举直接抛异常。动态代理和静态代理的对比静态代理在编译期就确定代理关系写起来直观但代码重复动态代理在运行期生成代理类通用性和扩展性更强。代理模式与装饰器模式的区别代理模式重在控制访问装饰器模式重在增强功能。虽然代码结构相似但意图不同这个区分有时会被问到。Spring AOP和AspectJ的区别Spring AOP基于动态代理方法级别AspectJ是编译期和加载期织入支持字段、构造器等更多粒度的切面。Spring AOP对大多数业务场景已经够用。天真的代理所有类有人动不动想代理整个类想省事但没考虑被代理类是否有final、是否有private方法、构造器是否复杂、代理创建是否频繁。这些都会影响效果。最后分享一个让原理变直观的调试技巧学完动态代理我觉得光看文字还是差那么点意思所以特意找了让生成的代理类现原形的办法。JDK 8及以后运行前加一个系统参数就能把生成的代理类保存到磁盘上-Djdk.proxy.ProxyGenerator.saveGeneratedFilestrue看到那个错误示范的类名运行完去target目录找com/sun/proxy/$Proxy0.class用IDEA打开反编译能看到它继承了Proxy、实现了你的接口、把每个方法都重写并转发给handler.invoke。我第一眼看到生成代码的时候整个DAY20的学习内容瞬间串联起来了。如果用的是CGLIB想调试生成的子类可以加-Dcglib.debugLocation./cglib_classes做完这一步你对动态代理的理解就不一样了不再是背面试题而是真的看见它了。这个技巧强烈建议学有余力的读者试一下尤其是那些准备啃Spring源码的人看完代理类的真实模样再回头看Spring AOP就不会觉得它神秘了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门