查看原文
其他

报告,书里有个BUG!

why技术 why技术 2022-09-10

你好呀,我是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();
   }
  }
 }

来看运行结果:

这个方案也是周大大书上写的方案:

结合着这个看,基本上就能看懂了:

不得不说,反射真的是太“流氓”了。

好了,本文就这些内容了。

那你看完了,我问你一个问题:

你觉得你知道了这个点,有什么卵用吗?

是的,没有。

那么恭喜你,又在我这里学到了一个没有任何卵用的知识点。

如果一定要说有用的地方,那么就是看书的时候别只看,得动手。

比如本文的例子,如果不动手,你自己大概率是不会踩到这个“彩蛋”的。



推荐👍 :吴某凡表示这题他熟。

推荐👍 :这题答案不在源码里...

推荐👍 :神了!异常信息突然就没了?

推荐👍 :就这样,我走完了程序员的前五年...

推荐👍 :面试官:Java如何绑定线程到指定CPU上执行?

我是 why,一个主要写代码,经常写文章,偶尔拍视频的程序猿。

欢迎关注我呀。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存