从方法调用到字节码:APS 分发机制逐层拆解

这一篇回答那个从第一篇埋到现在的问题:AcceleratedProxy.invokeSuper(proxy, method, args) 这一行,最后到底变成了什么?为什么它能快到接近直接调用?

答案的关键词是:一条 INVOKESPECIAL 指令

先看慢在哪:反射与 MethodHandle 的边界

在 JDK 动态代理里,拦截器要调原方法,只能 method.invoke(target, args)。这一行的问题在于它每次调用都要付出反射的成本

  • 访问权限检查;
  • 参数装箱成 Object[]
  • 最关键的是,Method.invoke 是 JVM 的内联屏障——JIT 编译器无法把这次调用内联掉,因为目标方法是在运行时才知道的。

MethodHandleMethod.invoke 轻,但本质上仍是「运行时分派」,同样难以被内联到只剩几条机器指令。

APS 的思路很直接:既然方法在生成字节码的那一刻就是已知的,那就别在运行时查找,直接生成一条 INVOKESPECIAL 调用父类方法

生成的代理类长什么样

APS 为每个目标类生成一个子类(或接口实现),结构上就三样东西:

  1. 每个不同的拦截器一个字段_interceptor$0_interceptor$1……
  2. 每个可代理方法一个覆盖方法:负责把参数装箱、调用拦截器。
  3. 一个 dispatch(Method, Object[]) 方法:根据 Method 分派到对应的父类方法。

调用链路是这样的:

proxy.hello("World")
   │  覆盖方法 hello(String) 被调用
   ▼
_box args_ → interceptor.intercept(proxy, method, args)
   │  拦截器里调用了 invokeSuper(proxy, method, args)
   ▼
((DispatchTarget) proxy).dispatch(method, args)
   │  按 method.hashCode() 分支
   ▼
super.hello("World")   ← 一条 INVOKESPECIAL,直接调父类

概念上,那个 dispatch 方法生成的字节码等价于这样一段伪代码:

Object dispatch(Method m, Object[] args) {
    switch (m.hashCode()) {
        case 2102374382:                       // add(int,int) 与 add(int,int,int) 同名
            if (参数是2个) return super.add((Integer)args[0], (Integer)args[1]);
            else            return super.add((Integer)args[0], (Integer)args[1], (Integer)args[2]);
        case 2105388484:                       // name()
            return super.name();
        default:
            throw new NoSuchMethodError();
    }
}

注意最后那几行 return super.add(...)——它们在字节码里就是 INVOKESPECIAL 直接调用父类方法,没有反射、没有 MethodHandle。这就是「为什么快」的全部秘密。

一个藏得很深的坑:重载方法的 hashCode 相同

上面伪代码里 add(int,int)add(int,int,int) 落在同一个 case 上,不是巧合。Method.hashCode() 的定义是:

getName().hashCode() ^ getDeclaringClass().getName().hashCode()

不包含参数类型!所以同名的重载方法,hashCode() 完全相同。

跑一下就能验证(源码在 examples/.../ch09/):

name -> hashCode=2105388484
add  -> hashCode=2102374382
add  -> hashCode=2102374382     ← 两个 add 一样!

所以 APS 不能只用 Method.hashCode() 分派重载方法。它的做法分两层:

  1. 先用 Method.hashCode() 分支——这一步是零分配的(hashCode() 是内存操作,不碰 Class[] 数组)。
  2. 只有落在「同名重载」分支上时,才调用 methodDispatchHash,它把参数类型折叠进 hash:
int methodDispatchHash(Method method) {
    int hash = method.hashCode();
    for (Class<?> pt : method.getParameterTypes()) {   // 只有这里才碰参数类型
        hash = 31 * hash + pt.getName().hashCode();
    }
    return hash;
}

注意它用的是 pt.getName().hashCode() 而不是 pt.hashCode()——Class.hashCode() 依赖对象身份、跨 JVM 不确定,而 Class.getName() 是确定字符串。这个细节保证了分派 hash 在任意 JVM 上结果一致。

结论:绝大多数方法(没有同名重载的)走的是零分配的 hashCode() 分支;只有重载方法才多一步参数类型折叠。这就是 README 里那句「allocation-free dispatch」的含义。

怎么看生成出来的类

源码在 examples/.../ch09/,运行它可以看到代理类的基本信息:

proxy class: ...Calc$$AcceleratedProxy$$0/0x0000000037244800
isHidden:    true
dispatch -> public int ...Calc.add(int,int)
3
dispatch -> public int ...Calc.add(int,int,int)
6
dispatch -> public java.lang.String ...Calc.name()
calc

两点观察:

  • 类名带 $$AcceleratedProxy$$ 后缀,是 APS 生成的代理类;
  • isHidden: true 说明它是隐藏类Lookup.defineHiddenClass),这一点下一篇会展开。

想亲眼看到 INVOKESPECIAL,可以加 -Xlog:class+load 或借助 jdk.internal.org.objectweb.asm.util.ASMifier 反汇编生成类,但原理已经说清楚了——拦截器到父类之间,就是一条直接调用。

小结

这一篇把「为什么快」拆到了字节码层:

  1. 反射/MethodHandle 是运行时分派,JIT 内联不了;
  2. APS 把分派提前到字节码生成期dispatch 里是 switch + 直接 INVOKESPECIAL
  3. Method.hashCode() 做零分配分支,methodDispatchHash 折叠参数类型解重载,Class.getName() 保证跨 JVM 确定。

下一篇补上另一半拼图:为什么这条 INVOKESPECIAL 能被 JIT 内联,以及 WeakCache + 隐藏类怎么保证不泄漏内存

本文示例代码见 aps-blog/examples,框架源码与完整文档见 APS 仓库

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐