深蓝黑背景封面:右侧大标题「动态代理,两条路线」,副标题「JDK 靠接口,CGLIB 靠继承」;左侧原创示意图——中央一个目标对象方框,向左上引出一条标着「实现同一接口」的箭头到 JDK 代理框,向左下引出一条标着「生成子类 bytecode」的箭头到 CGLIB 代理框,两个代理框都标注统一入口拦截器;左上标注「面试经典 · Java 后端」,右上标注「Day 04 · 11-01」 图 1:本文封面,同一个目标对象,两条代理路线。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面是「描述动态代理的几种实现方式,分别说出优缺点」。标准答案是两条主流路线:JDK 自带的接口代理和 CGLIB 的字节码增强。答出「各怎么实现」只是及格线,答出「限制与选型」才算吃透。

JDK 动态代理:运行时生成一个接口实现类。

JDK 的 java.lang.reflect.Proxy 在运行时生成一个代理类——它是 final 的、继承 Proxy、实现你指定的全部接口。每次方法调用都被编码成「Method 对象加参数数组」,统一派发到 InvocationHandler.invoke,由你在 invoke 里织入增强逻辑,再反射调用真实对象。

这条路线的硬限制写在 API 契约里:代理类只实现接口,所以目标必须实现接口;不在接口上的方法(实现类自己的方法)代理不到;代理实例可以转成任意一个被代理接口,但永远转不回实现类。

CGLIB:运行时生成一个目标类的子类。

CGLIB 不走接口,直接用 ASM 操作字节码,生成目标类的子类,重写父类方法,把调用拦到 MethodInterceptor.intercept 里,增强后再调父类逻辑。因为靠的是继承,它的限制也来自继承:final 类不能继承、final 方法不能重写,这两类都代理不了;构造函数会走两遍(代理实例是子类实例),也是使用时要留意的点。

性能上的常见说法是「JDK 生成快、调用多一跳反射;CGLIB 生成重、调用接近普通虚方法分派」。方向是对的,但别把这句话当成恒成立——JDK 各版本对反射调用做了大量内联优化,实际差距远小于直觉,真正决定选型的还是「有没有接口、有没有 final」。

还有一个时效性事实必须知道:CGLIB 项目的官方仓库自述已明确标注该项目不再维护,在 JDK 17 及更新的版本上工作不佳甚至无法工作,并建议迁移到 ByteBuddy 等活跃方案。所以「CGLIB 派生类」作为原理知识照旧考,但新项目选型时这条路线本身就要打个问号。

JDK 路线实测:跑一个真代理给你看。

下面这段 Java 代码用 JDK 动态代理给一个订单服务织入埋点,并验证「转不回实现类」的限制。用 JDK 17 编译运行(javac ProxyDemo.java && java ProxyDemo):

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class ProxyDemo {
    interface OrderService {
        String create(String item);
    }

    static class OrderServiceImpl implements OrderService {
        public String create(String item) {
            return "订单已创建: " + item;
        }
        // 不在接口上的方法:JDK 代理够不着它
        public String internalAudit() {
            return "内部审计";
        }
    }

    public static void main(String[] args) {
        OrderService target = new OrderServiceImpl();

        InvocationHandler handler = new InvocationHandler() {
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                long start = System.nanoTime();
                System.out.println("[代理] 拦截 " + method.getName() + ",织入埋点");
                Object result = method.invoke(target, args); // 反射调用真实对象
                System.out.println("[代理] 耗时 " + (System.nanoTime() - start) / 1000 + " 微秒");
                return result;
            }
        };

        OrderService proxy = (OrderService) Proxy.newProxyInstance(
                OrderService.class.getClassLoader(),
                new Class<?>[] { OrderService.class },
                handler);

        System.out.println(proxy.create("GPU 实例 x8"));
        System.out.println("代理类名: " + proxy.getClass().getName());
        System.out.println("Proxy.isProxyClass: " + Proxy.isProxyClass(proxy.getClass()));

        // JDK 代理只能转成接口,转不回实现类
        try {
            OrderServiceImpl wrong = (OrderServiceImpl) proxy;
        } catch (ClassCastException e) {
            System.out.println("强转实现类失败: " + e.getClass().getSimpleName());
        }
    }
}

实测输出(耗时一行每次运行略有不同,其余稳定):

[代理] 拦截 create,织入埋点
[代理] 耗时 3867 微秒
订单已创建: GPU 实例 x8
代理类名: $Proxy0
Proxy.isProxyClass: true
强转实现类失败: ClassCastException

三处细节值得指着输出讲:方法调用被拦进了统一的 invoke,增强逻辑织入成功;运行时生成的代理类名以 $Proxy 开头(JDK 文档明确为代理类保留了 $Proxy 前缀的命名空间);强转实现类直接抛 ClassCastException——JDK 代理与目标类没有任何继承关系,它们只是「恰好实现同一个接口」的两个类。

双栏对照图:左栏 JDK 动态代理——调用链为 调用方 → Proxy 类(运行时生成的接口实现)→ InvocationHandler.invoke → 反射调用目标;限制:目标必须实现接口,未在接口上的方法代理不到。右栏 CGLIB——调用链为 调用方 → bytecode 生成的目标子类(重写父类方法)→ MethodInterceptor.intercept → 调用父类逻辑;限制:final 类与 final 方法无法被重写,代理失效。中间共用一条「增强逻辑」:统一埋点、重试、事务都在这里织入。底部对比带:生成时机都在运行时;JDK 生成快、调用多一跳反射;CGLIB 生成重、调用走普通虚方法分派;选型看「有没有接口、有没有 final」 图 2:两条路线的调用链、限制与选型坐标。

可能的追问。

「Spring AOP 默认用哪个?」有接口走 JDK 代理,没接口走字节码子类路线,可以用配置强制后者——这也解释了为什么有人给一个没接口的类加事务能生效,给 final 方法加却悄悄失效。「为什么 RPC 客户端 stub 多用 JDK 代理?」因为 RPC 天然面向接口契约:调用方只持有接口,代理在 invoke 里把方法调用编码成网络请求,这是接口代理最贴切的场景。「除了这两条还有别的吗?」有:编译期织入(如 AspectJ 的编译期/类加载期织入)不生成代理对象,直接改字节码,行为没有「代理边界」的坑,但构建链路更重;ByteBuddy 则是 CGLIB 的现代继任者。

代理这层抽象在 AI 工程里一样高频:给模型调用统一加超时、重试、埋点,给数据访问层加缓存与审计,都是「在不改业务代码的前提下织入横切逻辑」——这正是动态代理存在的理由。回到面试现场,建议的答题顺序:先讲两条路线的生成物(接口实现类 vs 子类),再讲各自的硬限制(接口依赖 vs final 失效),补一句性能对比的正确打开方式,最后用 CGLIB 停维护这个时效事实收尾。你项目里有没有被「final 方法上增强不生效」坑过?最后怎么发现的?欢迎聊聊。