报告,书里有个BUG!原创
你好呀,我是why。
是这样的,我在看《深入理解 JVM 虚拟机》(第三版)的时候发现一个有意思的 BUG。
给大家汇报一下。
这段话位于第三版的 326 页,属于书中的第八章虚拟机字节码执行引擎这一部分的内容。
整个第八章主要分析了虚拟机在执行代码是,如何找到正确的方法、如何执行方法内的字节码,以及执行代码时涉及的内存结构。
而其中的 8.4 小节是这样的:
其实还有个 8.4.5 小节,由于排版问题,我不好拍下来。
而出 Bug 的地方,就是对应书中的 8.4.5 小节,标题是:
实战:掌控方法分派规则
接下来,我们就看看到底是那里出 Bug 了。
另外,需要提前说明的是,我没有做背景知识的铺垫,默认你是了解关于 Java 虚拟机层面对于动态类型语言的支持的。
其实说白了就是那几个指令:
-
invokestatic -
invokespecial -
invokevirtual -
invokeinterface -
invokedynameic
同时也了解 MethodHandle 类中下面几个方法和上诉几个指令的关系的:
-
findStatic -
findSpecial -
findVirtual
不知道也没关系,就看一乐呵。面试不考,放心。
啥 BUG
先直接给大家上个代码,也是书上的示例代码,你思考一下,能不能实现这个需求:
绝大部分人的第一反应就是 super 关键字。
但是可惜的是 super 调用的是父类的 thinking 方法,而当前类 son 的父类是 Father 类。
再接着想,可能有的同学能想到操作字节码,比如用 ASM、Javassist 等字节码操作工具,去搞一些骚操作。
这个思路是可以的,但是属于作弊行为。
题目是要求在字节码之上的 Java 层面解决。
有的同学还能想到反射。
诶,想到反射的同学很不错,可以给自己鼓个掌。
先公布答案,为了你方便运行,我直接把整个代码放这里,你粘过去就能跑(暖男石锤):
public class MethodHandleTest {
class GrandFather{
void thinking(){
System.out.println("i am grandfather");
}
}
class Father extends GrandFather{
void thinking(){
System.out.println("i am father");
}
}
class Son extends Father {
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
MethodHandle mh = lookup().findSpecial(GrandFather.class,
"thinking", mt, getClass());
mh.invoke(this);
} catch (Throwable e) {
}
}
}
public static void main(String[] args) {
(new MethodHandleTest().new Son()).thinking();
}
}
上面这个答案就是来自书中的答案。
但是当你粘出来运行的时候,有趣的事情发生了:
什么情况,为什么书上的运行结果是这样的?
诶,这就是 BUG 的体现了。
为啥是这样的?
同样的程序,在第三版里面是这样描述的:
很明显了,在 JDK 7 Update 9 之前的运行结果是这样的,说明后续更的时候修复了什么问题。
如果你的运行结果还是 i am grandfather
,那么兄弟,你的 JDK 版本该升级一下了。
那么到底修复了什么问题呢?
我在知乎上找到了关于这个问题的R大的回答:
https://www.zhihu.com/question/40427344
首先这个神一样的男人,直接就说书上的结论是错误的。
因为 MethodHandle 用于模拟 invokespecial 时,必须遵守跟 Java 字节码里的 invokespecial 指令相同的限制,只能调用到传给 findSpecial() 方法的最后一个参数(“specialCaller”)的直接父类的版本。
啥意思,直接就是看着头大。
不慌,根据我们深厚的语文功底,大家都知道,重点在后半句:
只能调用到传给 findSpecial() 方法的最后一个参数(“specialCaller”)的直接父类的版本。
那么最后一个参数是什么?
它的直接父类又是什么?
来,我给你 Debug 一下:
通过截图我们知道最后一个参数其实就是当前类,即 son。
它的直接父类又是什么?
在周大大书里的例子里,类之间的基础关系是这样的:
Son->Father->GrandFather
所以 son 的直接父类,就是 father 类:
从这里可以清楚的看到,这里的 method 其实是 father 类的 thinking 方法。
同时,R大还说了:
findSpecial()还特别限制如果Lookup发现传入的最后一个参数(“specialCaller”)跟当前类不一致的话默认会马上抛异常
来,试验一把嘛。
当我们把最后一个参数传 Father.class,再次运行发现抛出了异常。
最后,R大也指出,曾经有这样的 bug 存在,所以也有可能是存在示例代码中的结果的:
可能是因为findSpecial()得到的MethodHandle的具体语义在JSR 292的设计过程中有被调整过。有一段时间findSpecial()得到的MethodHandle确实可以超越invokespecial的限制去调用到任意版本的虚方法,但这种行为很快就被认为是bug而修正了。
所以,周大大在第三版中也更新了这部分的内容:
我也去看了 JDK 8 关于 findSpecial 方法的规范说明 :
https://docs.oracle.com/javase/8/docs/api/java/lang/invoke/MethodHandles.Lookup.html#findSpecial-java.lang.Class-java.lang.String-java.lang.invoke.MethodType-java.lang.Class
其中有这样的一句话:
The function MethodHandles.lookup is caller sensitive so that there can be a secure foundation for lookups. Nearly all other methods in the JSR 292 API rely on lookup objects to check access requests.
简单翻译一下就是这样的。
MethodHandles.lookup这个函数对调用者是敏感的,这样就可以有一个安全的查找基础。JSR 292 API 中的几乎所有其他方法都依赖查找对象来检查访问请求。
调用者敏感,我是这样理解的:不同调用者,访问权限不同,其结果也不同。
比如在书中的例中,在 Son 类中调用 MethodHandles.lookup,Son 是调用者,因为调用者是敏感,所以只能访问到 Father 类的 thinking。
另外,文档中提到的 JSR 292 也和 R 大的回答呼应上了。
我对比了一下 JDK 7 和 8 之间描述的差异:
发现 JDK 8 的描述多了整整一个 Caller sensitive methods
小节。
翻译过来就是“这是一个调用者敏感的方法”。
这一小节里面的这一句话,就是我刚刚说的那句。
能突破吗?
知道问题被修复了,那么问题又来了。
这个需求还能实现吗?
现在这个需求按照前面的思路走不通的原因,是因为这个地方的校验绕不过去:
java.lang.invoke.MethodHandles.Lookup#checkSpecialCaller
那我们绕过这个限制就好了。
这个方法看起来也不复杂,而且有这样的一个判断,如果成立则直接返回,不做校验:
allowedModes,这个值如果我们可以设置为 “TRUSTED”,那么就能直接返回,从而避开下面的这些校验。
怎么绕开呢?
直接上代码:
class Son extends Father {
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
lookupImpl.setAccessible(true);
MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
mh.invoke(this);
} catch (Throwable e) {
e.printStackTrace();
}
}
}
来看运行结果:
这个方案也是周大大书上写的方案:
结合着这个看,基本上就能看懂了: