《深入理解Java虚拟机》读书笔记(七)--虚拟机字节码执行引擎(下)

目录

一、Java动态类型语言支持

1.1 MethodHandle

1.2 MethodHandle和Reflection的区别

1.3 invokedynamic指令

二、基于栈的字节码解释执行引擎

2.1 基于栈和基于寄存器

2.2 基于栈的解释器执行过程

三、总结


一、Java动态类型语言支持

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,比如JavaScript、Python等,相对的,在编译期就进行类型检查的语言(如C++/Java等)就是最常用的静态类型语言

例如以下代码:

obj.println("hello world");

假设这行代码在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那么变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口)才是合法的,否则哪怕obj确实有一个合法的println(String)方法,但与PrintStream接口没有继承关系,代码也不能运行,因为类型检查不合法。

而同样的代码在JavaScript中的情况则不一样,无论obj具体是何种类型,只要这种类型的定义中确实包含有println(String)方法,那么方法调用就可以成功。

这种差别产生的原因是,Java语言在编译期就将println(String)方法完整的符号引用就生成出来,作为方法调用指令的参数存储到class文件中,例如如下代码:

invokevirtual #4//Method java/io/printStream.println:(Ljava/lang/String;)V

这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。而在JavaScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型。

静态类型语言在编译期确定类型,所以编译器可以提供严谨的类型检查,利于稳定性;而动态语言在运行期确定类型,更加灵活,实现功能的时候,相对于静态类型语言来说更加清晰和简洁,代码也不会显得那么”臃肿“。

由于invokevirtual、invokespecial、invokestatic、invokeinterface这几条方法调用指令的第一参数都是被调用方法的符号引用,而符号引用在编译期产生,而动态类型语言要在运行期才能确定方法接受者类型,所以在JDK1.7中新增了一个invokedynamic指令来提供支持。

1.1 MethodHandle

JDK1.7 在除了之前单纯依靠符号引用来确定调用的目标方法的方式外,提供了一种新的动态确定目标方法的机制,称为MethodHandle。其功能大体上就是在一个class中寻找方法签名匹配的方法,结果以MethodHandle表示,然后可以通过MethodHandle调用该方法。这样就使用代码模拟了虚拟机分派寻找方法的过程,对于程序员来说有了更高的自由度。

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

public class Main {
    static class ClassA {
        public void println(String arg) {
            System.out.println(arg);
        }
    }

    public static void main(String[] args) throws Throwable {
        Object obj = new ClassA();
        getMethodHandle(obj).invoke("hello MethodHandle");
    }

    private static MethodHandle getMethodHandle(Object receiver) throws Exception {
        //定义一个MethodType
        //第一个参数(void.class)是方法的返回类型,后面的参数(String.class)是方法的参数
        MethodType methodType = MethodType.methodType(void.class, String.class);
        //在receiver.class中寻找方法
        //并且通过bindTo将该方法的接收者(也就是this)传递给它
        return MethodHandles.lookup()
                .findVirtual(receiver.getClass(), "println", methodType)
                .bindTo(receiver);
    }
}

上述示例代码使用的是invokeVirtual,其模拟了invokevirtual指令的执行过程,其它还有findStatic、findSpecial等等。

1.2 MethodHandle和Reflection的区别

  • 从本质上,Reflection和Method机制都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用;MethodHandles.lookup中的3个方法:findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual&invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。

  • Reflection是重量级的,其中的Mehod对象包含了方法签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含了执行权限等的运行期信息。而MethodHandle仅包含于执行该方法相关的信息,比如方法名和参数等,相对来说是轻量级的。

  • 由于MethodHandle是对字节码的方法执行指令的模拟,所以理论上虚拟机在这方面做的各种优化(比如方法内联),在MethodHandle上也应当可以采用类似的思路去支持,而通过Reflection去调用方法则不行。

  • Reflection的设计目标是只为Java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言

1.3 invokedynamic指令

invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条方法调用指令分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体的用户代码之中,让用户有更高的自由度。两者的思路也是可以类比的,只是一个采用上层Java代码和API来实现,另一个用字节码和class中其它属性、常量来完成。

每一处含有invokedynamic指令的位置都称为“动态调用点“(Dynamic Call Site),这条指令的第一条指令不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变成了JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3个信息:引导方法方法类型名称。引导方法是固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。通过CONSTANT_InvokeDynamic_info可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。

invokedynamic与前面4条invoke*指令最大的差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。该指令所面向的使用者并非Java语言,而是其他Java虚拟机之上的动态语言,因此仅依靠Java语言的编译器javac无法生成带有invokedynamic指令的字节码。

在这小节的最后,书中给出了一个很有趣的题:如何在子类中调用祖父类的重写方法?

import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

public class Main {
    static class GrandFather {
        void thinking() {
            System.out.println("i am grandfather");
        }
    }

    static class Father extends GrandFather {
        void thinking() {
            System.out.println("i am father");
        }
    }

    static class Son extends Father {
        void thinking() {
           //只完善这个方法的代码,实现调用祖父类的thinking()方法,打印"i am grandfather"
        }
    }

    public static void main(String[] args) throws Throwable {
        Son son = new Son();
        son.thinking();
    }

}

注:当然,我们不允许这样填充: new GrandFather().thinking();

在JDK1.7之前,我们是没有办法的,因为invokevirtual指令动态分派使用的是接收者的实际类型,这个逻辑是固化在虚拟机之中的,但是在Son类的thinking方法中无法获取一个实际类型为GrandFather的对象引用(除非我们重新实例化一个)。但是在JDK1.7之后,可以借助MethodHandle:

static class Son extends Father {
        void thinking() {
            try {
                MethodType methodType = MethodType.methodType(void.class);
                Field IMPL_LOOKUP = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
                IMPL_LOOKUP.setAccessible(true);
                ((MethodHandles.Lookup) IMPL_LOOKUP.get(null)).findSpecial(GrandFather.class, "thinking", methodType, Father.class)
                        .invoke(this);
            } catch (Throwable e) {
                e.printStackTrace();
            }
        }
    }

我们使用MethodHandle模拟invokespecial指令,按照我们自己的意愿从GrandFaher.class中寻找thinking方法,完成了自己的分派逻辑。

注:书中给出的解决方案是:

MethodType methodType = MethodType.methodType(void.class);
MethodHandles.lookup().findSpecial(GrandFather.class, "thinking", methodType, this.getClass())
    .bindTo(this)
    .invoke();

但是经验证,在JDK1.7和1.8下该代码都不能达到预期的效果。博主是通过翻看Lookup的源码发现了使用IMPL_LOOKUP的方式,详情见评论区~

二、基于栈的字节码解释执行引擎

在Java语言中,javac编译器完成了程序代码经过词法分析->语法分析->抽象语法树->遍历语法树生成线性的字节码指令流的过程,而解释器在虚拟机的内部。

2.1 基于栈和基于寄存器

Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套指令集架构是基于寄存器的指令集,这些指令依赖寄存器进行工作。

如果要计算”1+1“的结果,基于栈的指令流会是这样的:

iconst_1 //int类型的1入栈
iconst_1 //int类型的1入栈
iadd //栈顶两个int类型出栈,相加,把结果入栈
istore_0 //将栈顶的值出栈放到局部变量表的第0位置的slot中

如果基于寄存器,可能会是这样子的:

mov eax,1 //把eax寄存器的值设为1
add eax,1 //把eax寄存器的值加1,结果保存在eax寄存器

基于栈的指令集是可移植的,而寄存器由硬件直接或间接提供,程序依赖这些硬件寄存器就要受到硬件的约束;但是基于栈的指令集完成相同功能所需的指令数量一般会比寄存器架构多,而且栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。由于指令数量内存访问的原因,就导致了栈架构指令集的执行速度相对来说会慢一些。所有主流物理机的指令集都是基于寄存器的。

2.2 基于栈的解释器执行过程

这里采用书中一样的示例代码,但是出于方便,就不画图了,而是在每个指令后面通过文字备注操作数栈和局部变量表的状态,Java代码如下:

public int calc() {
        int a = 100;
        int b = 200;
        int c = 300;
        return (a + b) * c;
    }

编译之后通过javap查看字节码指令(操作数栈和局部变量表的变化都在备注中,其中描述栈时,右边为栈顶方向):

public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        100  //将100入栈。栈:100;变量表:0=this
         2: istore_1           //将100出栈,存放到局部变量表第1个slot。栈:空;变量表:0=this,1=100
         3: sipush        200  //将200入栈。栈:200;变量表:0=this,1=100
         6: istore_2           //将200出栈,存放到局部变量表第2个slot。栈:空;变量表:0=this,1=100,2=200
         7: sipush        300  //将300入栈。栈:300;变量表:0=this,1=100,2=200
        10: istore_3           //将300出栈,存放到局部变量表第3个slot。栈:空;变量表:0=this,1=100,2=200,3=300
        11: iload_1            //将局部变量表中第1个slot整型值入栈。栈:100;变量表:0=this,1=100,2=200,3=300
        12: iload_2            //将局部变量表中第2个slot整型值入栈。栈:100,200;变量表:0=this,1=100,2=200,3=300
        13: iadd               //将栈顶两个元素出栈做整型加法,然后把结果入栈。栈:300;变量表:0=this,1=100,2=200,3=300
        14: iload_3            //将局部变量表中第3个slot整型值入栈。栈:300,300;变量表:0=this,1=100,2=200,3=300
        15: imul               //将栈顶两个元素出栈做整型乘法,然后把结果入栈。栈:90000;变量表:0=this,1=100,2=200,3=300
        16: ireturn            //结束方法执行,将栈顶整型值返回给方法调用者
      LineNumberTable:
        line 8: 0
        line 9: 3
        line 10: 7
        line 11: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      17     0  this   Lcom/demo/Main;
            3      14     1     a   I
            7      10     2     b   I
           11       6     3     c   I

从上可以看出,这段代码需要深度为2的操作数栈(参照出栈/入栈过程中栈的最大深度),需要4个slot的局部变量空间(this、a、b、c)

关于各个指令的含义,可以参考官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html

上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程可能和概念模型差距非常大。因为虚拟机中解析器和即时编译器都会对输入的字节码进行优化,比如在HotSpot虚拟机中,有很多以”fast_“开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行的性能,而即时编译器的优化手段更加繁多(后面章节会介绍)。

三、总结

虽然前面描述很多内容都基于Java虚拟机的概念模型,和实际情况会有一定的差距,但是这对我们理解虚拟机原理没有什么阻碍。

猜你喜欢

转载自blog.csdn.net/huangzhilin2015/article/details/114467776