为什么 Byte Buddy 无法拦截 Scala @specialized 生成的覆盖方法?
结论
建议先调整 Byte Buddy 的方法解析和过滤配置:使用 MethodGraph.Compiler.Default.forJVMHierarchy() 区分返回类型不同的 value(),并允许目标 synthetic 方法参与拦截。反射找到方法后,它仍有可能被排除在 Byte Buddy 最终匹配的候选集合之外。
题目没有提供 Scala、Byte Buddy 版本和方法标志,仅凭输出还不能确定唯一原因。修改后,需要检查保存的字节码和实际加载的类。
为什么更换拦截实现没有效果
这两个方法的名称和参数列表相同,返回类型不同:
value()Ljava/lang/Object;
value()I
它们在 JVM 中是不同的方法。Byte Buddy 默认的方法图按 Java 语言规则解析,返回类型不参与方法签名区分;forJVMHierarchy() 则会区分返回类型。排查 Scala 生成的这种结构时,需要注意这个差异。方法图实现说明
Byte Buddy 默认还会忽略 synthetic 方法。若目标 Object value() 带有该标志,即使 anyOf(value) 指向正确的反射对象,它仍可能被排除。默认过滤配置
这两个环节都发生在执行 FieldAccessor、SuperMethodCall 或委托实现之前,因此反复更换 .intercept(...) 的参数未必能解决问题。
修改 Byte Buddy 配置
在原代码中保留已经找到的 Main、value 和 x,将构建部分改为:
import net.bytebuddy.ByteBuddy
import net.bytebuddy.description.method.MethodDescription
import net.bytebuddy.dynamic.scaffold.MethodGraph
import net.bytebuddy.implementation.FieldAccessor
import net.bytebuddy.matcher.ElementMatchers
val made = new ByteBuddy()
.with(MethodGraph.Compiler.Default.forJVMHierarchy())
.ignore(ElementMatchers.none[MethodDescription]())
.redefine(Main)
.method(ElementMatchers.anyOf(value))
.intercept(FieldAccessor.of(x))
.make()
made.saveIn(dir)
这里的 x 应是 Main.getField("x") 返回的父类字段,而不是 x$mcI$sp。方法匹配仍限定为选中的反射方法,避免同时修改返回 int 的 value()。
以上配置适用于提供这些 API 的 Byte Buddy 版本。题目未给出依赖版本,不能保证所有历史版本的行为完全一致。
不要直接将方法图换成 ForDeclaredMethods.INSTANCE,然后假定它会保留所有 bridge 方法:所引用的实现明确过滤了 isBridge()。相关源码
确认预期结果和加载来源
按题目列出的实现,value_$eq(int) 修改的是 x$mcI$sp,不会同步修改父类的 Object x。如果替换成功,执行原来的 setter 后,最后两项输出应当都是 null。这是根据给出的字段和方法推导出的预期,并非实际运行结果。
也可以给父类字段写入容易辨认的值:
field.set(main, "base-field")
println(get.invoke(main))
println(field.get(main))
若 getter 已改为读取该字段,两次读取应得到相同的 base-field。
还需要检查加载来源。saveIn(dir) 只写入文件,不会更新已经加载的 Class。新建的 URLClassLoader 默认仍会先委托父加载器;如果父加载器能找到旧类,就可能继续使用旧实现。
可以在保存前记录原来的加载器,并在加载后比较:
val originalLoader = Main.getClassLoader
// 保存并重新加载后
println(Made.getClassLoader)
println(Made.getProtectionDomain.getCodeSource)
println(Made.getClassLoader eq originalLoader)
如果确认父加载器提供了旧类,复现时可以把待修改类移出父加载器的 classpath,再由测试用的加载器从输出目录读取;Scala 运行库等依赖仍须保持可见。
用 javap 检查实际字节码
在常见 Unix shell 中,未加引号的 $mcI、$sp 会被当作变量展开。这可以解释为什么 javap -p Main$mcI$sp 最后检查到了 Main。应将完整类名放进单引号:
javap -classpath /path/to/classes -p -c -s -v 'Main$mcI$sp'
将 /path/to/classes 替换为 dir 对应的目录;有包名时使用完整类名。
检查时重点看:
descriptor: ()Ljava/lang/Object;,确认选中返回Object的方法。ACC_BRIDGE、ACC_SYNTHETIC,确认编译器实际设置的标志。- 该方法中的字段读取是否指向
Main.x:Ljava/lang/Object;。
也可以通过反射直接打印标志:
Main.getDeclaredMethods
.filter(_.getName == "value")
.foreach { m =>
println(s"$m bridge=${m.isBridge} synthetic=${m.isSynthetic}")
}
反编译器可能隐藏编译器生成的方法,排查时应以 descriptor、访问标志和指令为依据。题目示意代码中的 value$mcI$s[_$eq 等拼写存在不一致,不能用它们代替实际 class 文件中的方法名称。
备注:内容仅供参考。
