ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

32 面试官问动态代理,其实在看你懂不懂“运行期凭空造类“

32 面试官问动态代理,其实在看你懂不懂“运行期凭空造类“

面试现场,代理这道题特别容易问出深浅。

面试官:"Spring 的 AOP 你用过吧,底层靠什么实现的?"

候选人A:"动态代理。有接口的时候用 JDK 动态代理,没接口的时候用 CGLIB。"

面试官:"那 JDK 动态代理生成的类长什么样,你见过吗?"

候选人A:"没见过……反正就是生成一个代理类。"

面试官:"它为什么要基于接口?直接代理类不行吗?"

候选人A卡住了。

候选人B接上:"这个得从 Java 的单继承说起。JDK 动态代理生成的代理类,自己已经extends java.lang.reflect.Proxy了,一个类只能有一个父类,剩下的'我是谁'只能靠implements接口来声明。所以它天生只能代理接口。想直接代理类,就得换 CGLIB——它走的是'生成子类'这条路。"

面试官:"接着说说反射到底慢在哪。"


一、Class 对象:类的"户口本"

要理解反射,先要理解Class这个类。

每个类被 JVM 加载之后,会在方法区(JDK 8 之后叫元空间)里生成一份类的元信息,并且为这个类创建一个Class类型的对象,用它来表示"这个类本身"。类的一切——方法、字段、构造器、注解、父类、接口——都挂在这个Class对象上。

拿到Class对象,有三条路:

Class<?> c1 = String.class; // 类字面量,编译期就确定,不触发静态初始化 Class<?> c2 = "hello".getClass(); // 从对象反推 Class<?> c3 = Class.forName("java.lang.String"); // 运行期按全限定名加载,会触发静态初始化

这里有个面试常挖的坑:String.class和Class.forName(...)都对,区别在初始化时机。前者只加载、链接,不跑静态代码块;后者会触发静态初始化(static {}会执行)。用哪个,取决于你要不要触发类的初始化。

另外提一句:基本类型也有 Class 对象,int.class、int[].class都是合法的;void.class也有。


二、反射的"三板斧"

日常反射就三件事:找方法、调方法、造对象。

Class<?> clazz = Class.forName("com.example.User"); // 1. 获取构造器 → 造对象 Constructor<?> ctor = clazz.getDeclaredConstructor(String.class); ctor.setAccessible(true); // 关闭访问检查(否则 private 构造器调不了) Object user = ctor.newInstance("张三"); // 2. 获取方法 → 调用 Method setName = clazz.getMethod("setName", String.class); // 只找 public(含继承) setName.invoke(user, "李四"); // 3. 获取字段 → 读写 Field age = clazz.getDeclaredField("age"); age.setAccessible(true); age.setInt(user, 18);

两个容易混的方法,面试官十有八九会追问:

-getMethods()/getFields():拿所有 public 的,包含从父类继承来的。

-getDeclaredMethods()/getDeclaredFields():只拿本类自己声明的(包括 private、protected),不包括继承的。

所以想拿父类的私有方法,得先getSuperclass()一层层往上找。想碰私有成员,必须setAccessible(true),否则抛IllegalAccessException。

反射为什么慢

这是必答题。原因有几层,拆开说:

1.访问检查:Method.invoke每次都要做访问权限校验,setAccessible(true)能关掉这一层。

2.参数装箱与数组封装:invoke(Object obj, Object... args)的变长参数会把每次调用打包成一个Object[],基本类型还要装箱,凭空多出一堆临时对象。

3.方法查找:getMethod是按名字和参数类型去遍历匹配,本身就不便宜,所以要缓存Method对象。

4.难以内联:直接调用编译成invokevirtual,JIT 能把它内联进调用方;反射调用是间接的,JIT 在内联上更保守。

需要说明的是:JDK 对反射做了优化。当同一个Method被调用到一定次数(inflation 阈值,默认 15)后,会从"本地实现"切换成"动态生成字节码实现"(sun.reflect.GeneratedMethodAccessor),后续调用会快不少。所以"反射慢很多"的说法要加个前提——没预热、频繁调用的场景才明显。具体倍数跟 JVM 版本、预热程度都有关,没有统一数字,面试时别报一个拍脑袋的百分比。


三、JDK 动态代理:只能代理接口

JDK 的动态代理,核心就一个方法:

Object proxy = Proxy.newProxyInstance( loader, // 类加载器 interfaces, // 要实现的接口数组 handler // 调用处理器 );

用起来是这样:

interface UserService { void save(String name); } class UserServiceImpl implements UserService { public void save(String name) { System.out.println("保存:" + name); } } UserService target = new UserServiceImpl(); UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), new Class[]{ UserService.class }, new InvocationHandler() { public Object invoke(Object p, Method method, Object[] args) throws Throwable { System.out.println("开始..."); // 前置增强 Object result = method.invoke(target, args); // 调用真实对象 System.out.println("结束..."); // 后置增强 return result; } }); proxy.save("张三");

编译器在运行期动态生成一个类,通常叫$Proxy0(编号会递增)。它的结构大致是:

// $Proxy0 的等价示意 public final class $Proxy0 extends Proxy implements UserService { private static Method m3; // 对应 save 方法 public $Proxy0(InvocationHandler h) { super(h); } public final void save(String name) { try { super.h.invoke(this, m3, new Object[]{ name }); // 转交给 InvocationHandler } catch (Throwable e) { throw new UndeclaredThrowableException(e); } } }

想亲眼看一眼这个生成的类,可以让 JVM 把它存到磁盘上:JDK 8 用-Dsun.misc.ProxyGenerator.saveGeneratedFiles=true,JDK 9 之后属性名变成-Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true。开启后运行程序,会在工作目录的com/sun/proxy/下冒出$Proxy0.class之类的文件,用javap一反编译,源码结构看得清清楚楚。面试时能说出这个属性,杀伤力不小。

为什么必须基于接口?看第一行——$Proxy0 extends Proxy。Java 是单继承,这个名额被Proxy占了。那代理类怎么知道自己是"UserService 的代理"?只能靠implements UserService。换句话说,代理类跟目标类之间没有继承关系,只能靠"实现同一个接口"来骗过调用方。这就是 JDK 动态代理天生绑定接口的原因。

副作用也很直接:没有接口的类,JDK 代理爱莫能助。而且代理类只能调用接口里声明的方法,目标类里接口之外的方法,代理不认。


四、CGLIB:靠"认爹"也就是继承

没有接口怎么办?CGLIB(Code Generation Library)走的是另一条路——运行期生成目标类的子类,覆写目标方法,在覆写里插入增强逻辑。底层用 ASM 字节码框架直接拼字节码。

Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(UserServiceImpl.class); // 设成父类,生成的类是它的子类 enhancer.setCallback(new MethodInterceptor() { public Object intercept(Object obj, Method m, Object[] args, MethodProxy mp) throws Throwable { System.out.println("前置"); Object r = mp.invokeSuper(obj, args); // 调用父类(真实)方法 System.out.println("后置"); return r; } }); UserServiceImpl proxy = (UserServiceImpl) enhancer.create();

它生成的类大致是这样:

// CGLIB 生成类的等价示意 public class UserServiceImpl$$EnhancerByCGLIB extends UserServiceImpl { public void save(String name) { // 拦截器链 → 最终调用 super.save(name) } }

约束来自"继承"这两个字:

-不能代理final类——final 类没法被继承。

-不能代理final方法——final 方法没法被覆写。

-private方法拦不住——私有方法不参与多态,子类"覆写"不了。

-构造器会被调两次(目标类的构造器 + 子类的构造器各一次,子类初始化时会再调父类构造),这是它的一个已知代价。


五、对比与 Spring 的选择

维度JDK 动态代理CGLIB
原理生成类,实现接口生成类,继承目标类
前提必须有接口类不能是 final
拦截范围接口中声明的方法可覆写的非 final 方法
依赖JDK 自带第三方字节码库(Spring 已内嵌)
典型代表Proxy+InvocationHandlerEnhancer+MethodInterceptor

Spring AOP 的选择:

- 老版本的策略是"有接口用 JDK,无接口用 CGLIB"。

- Spring Boot 2.x 开始,默认spring.aop.proxy-target-class=true,也就是默认都用 CGLIB。原因是:接口代理时,注入方必须按接口类型注入;如果某个 Bean 实际类型里有些方法没在接口里声明,用 JDK 代理就会"丢方法",容易踩坑。统一用 CGLIB,按具体类型注入更省心。

- 顺带说一句,Spring 用的 CGLIB 是重新打包进spring-core的(改过包名),所以你在项目里不一定能看到独立的 cglib 依赖。


🎯 面试官真正想听的答案

问动态代理,他想确认三件事:你懂不懂Class、懂不懂"运行期生成类"、以及能不能把两种代理讲清楚"为什么有这个限制"。

第一层给结论:"动态代理的本质,是在运行期动态生成一个类,用它对目标对象做一层包装。JDK 动态代理生成的是'实现接口'的类,所以必须有接口;CGLIB 生成的是'继承目标类'的子类,所以目标类不能是 final、目标方法不能是 final。"

第二层讲机制:Proxy.newProxyInstance生成的$Proxy0为什么只能implements接口——因为它已经extends Proxy,单继承的名额被占了;CGLIB 靠 ASM 拼字节码生成子类,覆写方法插入增强。

第三层讲反射的代价与优化:慢在访问检查、装箱、方法查找、难以内联四点;setAccessible(true)能省访问检查,缓存Method能省查找,而且 JDK 有 inflation 机制会生成GeneratedMethodAccessor加速。能点出"性能依赖预热、不给绝对值",比背一个倍数更靠谱。


下篇预告

反射是"运行期操作类型",还有一类新特性是"把行为当参数传"——Lambda 和 Stream。下一篇聊:Stream 的惰性求值到底懒在哪、中间操作和终止操作的分界、map和flatMap怎么选、并行流为什么一不留神就出错(共享可变状态 + 共用的 ForkJoinPool)、以及Optional到底该怎么用才对。面试问"什么时候用并行流",答案绝对不是"数据多就用"。


唠点键盘之外的 · 第 32 篇

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进