本节摘要:框架的"魔法"本质是运行时生成字节码造出新类:JDK 动态代理与 CGLIB 是高层封装,ASM 与 Byte Buddy 是底层塑形工具。本节亲手生成一个代理类并拆开看它的内部,再用一张对照表讲清各层级工具的能力边界与代价,把 AOP、Mock、字节码增强统一到同一套原理上。
前两节讲的装载流程都假设字节流来自磁盘上的 class 文件。这一节打开另一扇门:字节流可以在运行时现场制造——在内存里拼出一份符合 class 文件格式的数据,defineClass 进 JVM,一个此前不存在的类就诞生了。Spring 的事务代理、接口无实现注入、Mockito 的假对象、Arthas 的线上热修复,底层都是这一招。学完本节你应当能回答两个问题:框架到底生成了什么样的类?以及为什么"魔法用多了 Metaspace 会涨"。
JDK 内置的动态代理面向接口工作:给它一个接口和一个调用处理器,它现场生成一个实现该接口的类,所有方法调用都转发给处理器。先看用法,再拆产物:
import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.List; public class ProxyPeek { public static void main(String[] args) { InvocationHandler logHandler = new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] methodArgs) throws Throwable { long start = System.nanoTime(); Object result = method.invoke(new ArrayList<>(), methodArgs); // 调真实对象 System.out.println(method.getName() + " took " + (System.nanoTime() - start) + " ns"); return result; } }; // 在内存中生成 List 的代理实现类 @SuppressWarnings("unchecked") List<String> proxy = (List<String>) Proxy.newProxyInstance( ProxyPeek.class.getClassLoader(), new Class<?>[] { List.class }, logHandler); proxy.add("hello"); // 每次调用都会先经过 logHandler System.out.println(proxy.size()); System.out.println("proxy class: " + proxy.getClass().getName()); } }
$ java -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true ProxyPeek add took 48000 ns size took 12000 ns 1 proxy class: jdk.proxy2.$Proxy0
$Proxy0 就是现场生成的类。打开保存下来的字节码文件,能看到它的全部秘密:继承 java.lang.reflect.Proxy 并实现 List 接口;每个方法体都是同一套模板——把方法对象与参数打包成交互参数,转交给创建时传入的 InvocationHandler。代理类本身不含任何业务逻辑,它只是一个"把调用转交给处理器"的转发壳。这也解释了两件事:为什么 JDK 代理要求目标有接口(它只能靠实现接口来"冒充"目标类型);以及为什么代理只拦截公开方法(转发壳只覆盖接口声明)。
用 javap 验证它的血统:
$ javap jdk/proxy2/$Proxy0.class | head -n 8 public final class jdk.proxy2.$Proxy0 extends java.lang.reflect.Proxy implements java.util.List ... public final boolean add(java.lang.String); public final int size();
目标类没有实现接口时,JDK 代理失效,社区惯用 CGLIB:它生成目标类的子类,重写方法插入拦截逻辑——这就是 Spring 在"无接口 Bean"上默认采用的路径(Spring 内置了 CGLIB 的内嵌版)。子类方案的天生限制也在这里:final 方法与 final 类无法被重写或继承,拦截自然失效。
再往下一层是字节码工程库,它们不提供"代理"这种成品,只提供塑形能力:
| 工具 | 抽象层级 | 典型用途 | 代价与门槛 |
|---|---|---|---|
| JDK 动态代理 | 成品代理 | 接口级 AOP、远程调用桩 | 只支持接口;转发走反射,单次调用有固定开销 |
| CGLIB | 成品子类化代理 | 无接口类的 AOP | final 不可拦;生成慢;历史上有 MethodInterceptor 的反射开销(新版用索引分派改善) |
| ASM | 指令级 | 框架内核、语言实现、字节码分析 | 直接读写指令,API 贴近 class 文件格式,版本升级需跟着字节码版本走 |
| Byte Buddy | 流式构建器 | 探针、Mock 框架、监控 agent | API 友好接近手写 Java;运行时生成性能优于裸 ASM 的常用路径 |
ASM 的抽象层级决定了它什么都能做、什么都难做:加一行日志要理解帧栈映射。Byte Buddy 在其上封了一层流式 API,"拦截某方法、委托给某对象"一句话写完,因此新一点的探针产品(包括很多 APM 的 java agent)都迁到了它上面。
第二章已经算过 Metaspace 的账,这里把它与代理直接挂钩:每个生成的类都要占类元数据、常量池,并绑定创建它的加载器。三类常见事故都与这笔账有关:
其一,循环生成。把 Proxy.newProxyInstance 放进循环、每次用全新的处理器实例(或传入不同的类加载器),每次都会生成新类,Metaspace 直线上涨——第二章 2.2 的溢出演练就是 CGLIB 版的现场。缓存代理类(Spring 对同一 Bean 类型只生成一次)是框架帮你做掉的正确姿势。
其二,代理类膨胀。多层 AOP 叠加(事务、日志、鉴权、重试)会让一层调用穿过多个处理器链,热点方法上反射转发的固定开销可观测——对纳秒级敏感的路径,应考虑编译期织入(AspectJ 静态织入)或减少代理层数。
其三,graalvm-native 环境翻车。第一章提过,原生镜像要求运行期不生成类,所有代理必须构建期登记。所以把传统 Spring 应用原生化时,代理相关的配置登记常常是迁移工作量的大头——根源就是本节的运行时造类与 AOT 模型冲突。
⚠️ 常见坑:调试代理时用 getClass().getName() 判断类型之外,还想直接强转成目标实现类。代理只实现接口(或继承目标类),它与"原始实现类"是兄弟关系而非同一个类——试图把代理强转回实现类会抛 ClassCastException。跨层传参请始终面向接口。
💡 关键直觉:动态代理与字节码生成的全部价值,在于把"横切逻辑"从业务代码里抽出来塞进生成的类。理解了转发壳的结构,AOP 不再是注解魔术,而是一个你能徒手写出来的模板。
第三章完工:类怎么进柜、谁维持秩序、柜外还能现造标本,三条线索合拢。第四章进入执行引擎——柜里的方法字节码如何被逐条解释、又如何被 JIT 编译器晋升成机器码,这台机器的动力系统开始点火。