从方法调用到字节码:APS 分发机制逐层拆解
这一篇回答那个从第一篇埋到现在的问题:AcceleratedProxy.invokeSuper(proxy, method, args) 这一行,最后到底变成了什么?为什么它能快到接近直接调用?
答案的关键词是:一条 INVOKESPECIAL 指令。
先看慢在哪:反射与 MethodHandle 的边界
在 JDK 动态代理里,拦截器要调原方法,只能 method.invoke(target, args)。这一行的问题在于它每次调用都要付出反射的成本:
- 访问权限检查;
- 参数装箱成
Object[]; - 最关键的是,
Method.invoke是 JVM 的内联屏障------JIT 编译器无法把这次调用内联掉,因为目标方法是在运行时才知道的。
MethodHandle 比 Method.invoke 轻,但本质上仍是「运行时分派」,同样难以被内联到只剩几条机器指令。
APS 的思路很直接:既然方法在生成字节码的那一刻就是已知的,那就别在运行时查找,直接生成一条 INVOKESPECIAL 调用父类方法。
生成的代理类长什么样
APS 为每个目标类生成一个子类(或接口实现),结构上就三样东西:
- 每个不同的拦截器一个字段 :
_interceptor$0、_interceptor$1...... - 每个可代理方法一个覆盖方法:负责把参数装箱、调用拦截器。
- 一个
dispatch(Method, Object[])方法 :根据Method分派到对应的父类方法。
调用链路是这样的:
text
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 方法生成的字节码等价于这样一段伪代码:
text
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() 的定义是:
text
getName().hashCode() ^ getDeclaringClass().getName().hashCode()
它不包含参数类型 !所以同名的重载方法,hashCode() 完全相同。
跑一下就能验证(源码在 examples/.../ch09/):
text
name -> hashCode=2105388484
add -> hashCode=2102374382
add -> hashCode=2102374382 ← 两个 add 一样!
所以 APS 不能只用 Method.hashCode() 分派重载方法。它的做法分两层:
- 先用
Method.hashCode()分支 ------这一步是零分配 的(hashCode()是内存操作,不碰Class[]数组)。 - 只有落在「同名重载」分支上时,才调用
methodDispatchHash,它把参数类型折叠进 hash:
java
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/,运行它可以看到代理类的基本信息:
text
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 反汇编生成类,但原理已经说清楚了------拦截器到父类之间,就是一条直接调用。
小结
这一篇把「为什么快」拆到了字节码层:
- 反射/
MethodHandle是运行时分派,JIT 内联不了; - APS 把分派提前到字节码生成期 ,
dispatch里是switch + 直接 INVOKESPECIAL; - 用
Method.hashCode()做零分配分支,methodDispatchHash折叠参数类型解重载,Class.getName()保证跨 JVM 确定。
下一篇补上另一半拼图:为什么这条 INVOKESPECIAL 能被 JIT 内联,以及 WeakCache + 隐藏类怎么保证不泄漏内存。
本文示例代码见 aps-blog/examples,框架源码与完整文档见 APS 仓库。