虚拟机字节码执行引擎——《深入理解JVM》第8章

虚拟机是相对于物理机的概念,这两种机器都有代码执行能力,但区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上,而虚拟机的执行引擎这是自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

Java虚拟机有很多,但是有一个统一外观(Facade)。

虚拟机执行引擎执行代码有两种方式,解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)。但是一般都是Mixed mode,混合模式。

但是不管执行起来是什么方式,但从外观上看下来,所有的java虚拟机的执行引擎都是一致的:输入的是字节码文件,执行过程就是字节码的解析,输出的是执行结果。

运行时栈帧结构——Stack Frame

对于栈来说,你栈里得有东西,这个东西就是栈帧,就好比一个方法运行了,就是一帧入栈,运行结束了,这一帧就出栈没了。
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是运行时数据区中的虚拟机栈的栈元素

栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址等信息。
每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

代码在编译过程中,栈帧中需要多大的局部变量表(max_locals)、多深的操作数栈(max_stack )都已经完全确定了,并且写入到方法表的Code属性中,所以一个栈帧需要分配多少内存,不会收到程序运行期变量数据的影响,而仅仅取决于具体的java虚拟机实现。

一个线程可能会出现方法调用链很长,很多方法都会处于运行状态。但对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,成为当前栈帧,与这个栈帧相关联的方法被称为当前方法。执行引擎运行的所有字节码指令都只针对于当前栈帧进行操作。

局部变量表--Local Variable Table

局部变量表是一组变量值存储空间,用来存放方法参数和方法定义的局部变量。在java编译class文件时,就已经在max_local数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存大小,只是说一个Slot应该是能够存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放。
对于64位的虚拟机来说,本身是使用了64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来和32位虚拟机中的一致。

前6种类型本来就可以用32位的二进制数去存储,对于reference,表示对一个对应实例的引用,这个引用至少做到两点,一个从这个32位数中得到对象在java堆中的数据存放的起始地址索引,第二要从此引用中得到对象的数据类型在方法区中存储的类型信息,为的是满足java的语法约束。

对于long和double这两个64位的数据,虚拟机会为其分配两个连续的Slot空间,读写都是分割为两次32位读写的操作。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表的Slot最大值,如果访问的是32位数据类型的变量,索引n就代表第n个slot,如果是64位,代表了同时使用第n和n+1个slot,不允许单独访问其中的某一个。

如果是实例方法,局部变量表第0位索引的是用于传递方法所属对象实例的引用,在方法中通过关键字this来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用雨分配其余的Slot。

上面这个说完以后,就理解了类变量和局部变量的区别:
类变量有两次赋初始值的过程,第一次在准备阶段,赋予系统初始值;第二次是在初始化阶段,赋于程序员定义的初始值。
局部变量在局部变量表中通过slot被初始化,没有系统初始化这个阶段,所以程序员可以不给类变量赋值也没什么,但是局部变量定义了不赋值是不能使用的。

操作数栈——Operand Stack

操作数栈是一个后入先出栈。操作数栈的最大深度在编译的时候已经被写入到max_stacks数据项中。操作数栈的每一个元素可以是任意的java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

当一个方法开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,就会出栈/入栈操作。例如方法参数传递是通过操作数栈,运算也是通过操作数栈。

例如iadd字节码指令在运行的时候会将最接近栈顶的两个元素找出来相加在入栈,在这个过程中还会进行数据的校验。

java虚拟机的解释执行引擎称为“基于栈的执行引擎”,这个栈指的就是操作数栈。

动态连接——Dynamic Linking

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池指向方法的符号引用作为参数;

方法返回地址

无论是正常的return还是异常结束,在方法退出以后,都需要返回到方法被调用的位置,程序才能继续执行。

方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,但是Class文件存储的都是符号引用,而不是直接引用,这个特性给java带来了极大的动态扩展能力,但是也使得调用过程变得复杂,需要在类加载期间甚至运行期间才能确定目标方法的直接引用。
下面看方法调用的两种情况,解析和分派,两者在不同层次进行筛选、确定目标方法的过程。

解析——Resolution

所有方法调用中的目标方法在Class文件里都是一个常量池的符号引用,在类加载的解析阶段,会将其中一部分符号转为直接引用,这种解析能成立的前提是方法在真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,这类方法的调用称为解析。

上面那段话总结就是“编译期可知,运行期不可变”,符合这句话的方法主要包括静态方法和私有方法两大类,前者直接和类型相关,后者在外部不能访问。这两种方法决定他们不可以被继承或者重写,因此他们都适合在类加载阶段进行解析。

Java虚拟机提供的5条方法调用字节码指令

字节码指令 含义
invokestatic 调用静态方法
invokespecial 调用实例构造器方法,私有方法,父类方法
invokevirtual 调用所有的虚方法
invokeinterface 调用接口方法,会在运行时再确定一个实现此接口的对象
invokedynamic 先在运行时动态解析处调用点限定符所引用的方法,然后再执行改方法,前面四条时java虚拟机内部的,而invokedynamic指令的分派逻辑是用户所设定的引导方法来决定的。

只要能被invokestatic和invokespecial指令调用方法都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法4类,他们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法是非虚方法。
与之相反,是虚方法。

特殊的是final方法,虽然是使用invokevirtual指令来调用final方法,但java语言规范中明确说明了final方法是一种非虚方法。

解析一定是一个静态的过程,在编译期间完全确定,在类装载的解析阶段就会把符号引用全部转变成可确定的直接引用,不会延迟到运行期在去完成。

分派——Dispatch

Java是一门面向对象的程序语言——封装、继承和多态。
而分派调用将会揭示多态性特征的一些最基本的体现。

静态分派

package zzm.dispatch;

public class StaticDispatch {
    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    public void sayHello(Human human) {
        System.out.println("hello, guy");
    }

    public void sayHello(Woman human) {
        System.out.println("hello, woman");
    }

    public static void main(String[] args) {
        Human human = new Man();
        Human woman = new Woman();
        StaticDispatch staticDispatch = new StaticDispatch();
        staticDispatch.sayHello(human);
        staticDispatch.sayHello(woman);
    }
}

Human human = new Man();
这一句 Human是静态类型,也叫外观类型,Man是实际类型。
外观类型在编译期可知不会被改变,而实际类型是运行时可知的。
虚拟机决定使用哪个方法的时候是根据参数的外观类型而不是实际类型作为判定依据的。

所有依赖外观类型来定位方法执行版本的分派动作是静态分派,静态分派的典型是方法重载。

下面的这个例子会说明重载方法匹配的优先级:

package zzm.dispatch;

import java.io.Serializable;

public class Overload {
    public static void method(Object o) {
        System.out.println("hello,o");
    }

//    public static void method(int o) {
//        System.out.println("hello,int");
//    }

//    public static void method(long o) {
//        System.out.println("hello,long");
//    }

//    public static void method(Character o) {
//        System.out.println("hello,Character");
//    }

//    public static void method(char o) {
//        System.out.println("hello,char");
//    }

    public static void method(char... o) {
        System.out.println("hello,char...");
    }

    public static void method(Serializable o){
        System.out.println("hello,serializable");
    }

    public static void method(Comparable o){
        System.out.println("hello,Comparable");
    }

    public static void main(String[] args) {
        method('a');
    }
}

结论就是:
先是基本类型,char-int-long-float-double,因为byte是8位,short是16位,不会出现这种不安全的转型。
如果没找到,就要自动装箱,变成Character,然后是Serializable或者Comparable,这两个不能同时出现,因为优先级一致。
最后是可见变长参数,这是已经当作了一个数组参数。

动态分派

动态分派和多态的另外一个体现——重写有着密切的关系。
按照书上写的程序,然后javap -v 这个class文件

package zzm.dispatch;

public class DynamicDispatch {
    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("hello, man");
        }
    }


    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("hello, woman");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
    }
}

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
Error: unknown attribute
      org.aspectj.weaver.MethodDeclarationLineNumber: length = 0x8
       00 00 00 17 00 00 01 DB
    Code:
      stack=2, locals=3, args_size=1
         0: new           #17                 // class zzm/dispatch/DynamicDispatch$Man
         3: dup
         4: invokespecial #19                 // Method zzm/dispatch/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #20                 // class zzm/dispatch/DynamicDispatch$Woman
        11: dup
        12: invokespecial #22                 // Method zzm/dispatch/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #23                 // Method zzm/dispatch/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #23                 // Method zzm/dispatch/DynamicDispatch$Human.sayHello:()V
        24: return
      LineNumberTable:
        line 24: 0
        line 25: 8
        line 26: 16
        line 27: 20
        line 28: 24
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      25     0  args   [Ljava/lang/String;
            8      17     1   man   Lzzm/dispatch/DynamicDispatch$Human;
           16       9     2 woman   Lzzm/dispatch/DynamicDispatch$Human;
}

主要是第16行aload_1的时候去找astore_1存的zzm/dispatch/DynamicDispatch$Man."":()V 的Man实例,这个被称为接收者,然后第17行虽然是Human.sayHello方法,但是多态查找是先用找到的类型中去搜索名称相符的方法,进行访问权限校验,如果通过则返回这个方法的直接引用,如果不通过,返回java.lang.IllegalAccessError异常。
否则,从下往上按照继承关系进行搜索和验证。
由于invoke是先确定load接受者的实际类型,所以invokevirual指令把常量池中类方法符号解析到了不同的直接引用上,这个是java方法重写的本质。
这种根据运行期实际类型执行版本的分配过程是动态分派。

单分派和多分派

方法的接收者和方法的参数被称为方法的宗量。根据分派基于多少种宗量,可以将分派分为单分派和多分派。

package zzm.dispatch;

public class Dispatch {
    static class QQ {
    }

    static class _360 {
    }

    public static class Father {
        public void hardChoice(QQ qq) {
            System.out.println("f c qq");
        }

        public void hardChoice(_360 qq) {
            System.out.println("f c _360");
        }
    }

    public static class Son extends Father {
        @Override
        public void hardChoice(QQ qq) {
            System.out.println("s c qq");
        }

        @Override
        public void hardChoice(_360 qq) {
            System.out.println("s c _360");
        }
    }

    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }

}

编译阶段,进行静态分派,上面对于方法的重载,要考虑静态类型是Father还是Son,另外要考虑方法参数QQ还是360,宗量为2,多分派。

运行阶段,动态分派时,虚拟机已经决定方法参数了,就是看方法的接受者一个宗量,单分派。

因此java目前是一种“静态多分派,动态单分派”的语言。

虚拟机动态分派的实现

对于动态分派,虚拟机为类在方法区建立一个虚方法表,这样可以建立索引查找提高性能,包括接口方法表。
虚方法表存放着各个方法的实际入口地址,如果子类没有重写方法,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一样的,如果重写了,那就会被替换为子类实现版本的入口地址。
虚方法表一般会在类的变量初始化以后,虚拟机把虚方法表也初始化完毕。

动态类型语言支持

JDK7才增加了动态类型语言支持,JDK8才能实现Lambda表达式。
什么是动态类型语言?动态类型的语言关键特征是他的类型检查的主体过程是在运行期而不是编译期。变量无类型而变量值才有类型,就像python里 a=3;没问题,a开始也不知道是啥,赋值为3以后,就是知道是个数值型。
而C++和java在编译期就进行类型检查。

动态不需要事先定义很多变量,清晰简洁开发效率高,静态语言提供严谨的类型检查,利于稳定性以及大规模代码。

JDK7中加入了java.lang.invoke包,提供了一种新的动态确定目标方法的机制MethodHandle。

package zzm.methodhandle;

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

public class MethodHandleTest {
    static class ClassA {
        public void println(String s){
            System.out.println("asdadhjslouifb  "+s);
        }
    }

    public static void main(String[] args) throws Throwable {
        Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
        getPrintlnMH(obj).invokeExact("llllll");
    }

    private static MethodHandle getPrintlnMH(Object obj) throws NoSuchMethodException, IllegalAccessException {
        MethodType mt = MethodType.methodType(void.class, String.class);
        return MethodHandles.lookup().findVirtual(obj.getClass(), "println", mt).bindTo(obj);
    }
}

上面的,用反射也可以实现,自己写了一下像下面这样:

package zzm.methodhandle;

import java.lang.reflect.Method;

public class ReflectionTest {
    static class ClassA {
        public void println(String s) {
            System.out.println("asdadhjslouifb  " + s);
        }
    }

    public static void main(String[] args) throws Throwable {
        Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
        Class c = obj.getClass();
        Method println = c.getDeclaredMethod("println", String.class);
        println.invoke(obj, "sldkslkf");
    }
}

Reflect vs. Invoke

MethodHandle属于invoke系,直接用class去进行反射在调用Method属于reflect系。

  • 从本质上讲,两者都是在模拟方法调用,但reflect是java代码层面的方法调用,而MethodHandle是在模拟字节码层次的方法调用。MethodHandles.lookup()中的三个方法findStatic()、findVirtual()、findSpecial()方法直接对应上面的四条字节码指令。
  • Reflection的Method对象远比MethodHandle包含的对象信息多。Method是方法在java一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的java端表示方式,还包含执行权限等的运行期信息。而后者只是执行该方法相关的信息。前者是重量级,后者是轻量级。
  • MethodHandle在虚拟机层面做了一些内联优化,反射是不行的
  • 关键区别在于Reflection API的设计目标在于为java语言服务,而MethodHandle则设计成所有java虚拟机之上的语言,只是包括java语言。

invokedynamic指令

其实前面说过有了JDK7动态调用,才有JDK8的Lambda表达式,所以不用像书上用INDY,直接写个lambda表达式,再javap一下就看到了。

package zzm.methodhandle;

public class InDyTest {
    
    public static void main(String[] args) {
        Runnable x = () -> {
            System.out.println("Hello, World!");
        };
        new Thread(x).start();
    }
}

我是参照这篇博客走了一遍,感觉还是有点疑惑,以后再说吧

掌控方法分派规则

孙子调用爷爷的方法

package zzm.chapter8;

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

public class GrandFatherInvokeTest {
    class GrandFather {
        void thinking() {
            System.out.println("grandfather");
        }
    }

    class Father extends GrandFather {
        @Override
        void thinking() {
            System.out.println("father");
        }
    }

    class Son extends Father {
        @Override
        void thinking() {
            try {
                MethodType mt = MethodType.methodType(void.class);
                MethodHandle mh = MethodHandles.lookup().findVirtual(GrandFather.class, "thinking", mt).bindTo(new GrandFatherInvokeTest().new GrandFather());
                mh.invoke();
            } catch (Throwable e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        (new GrandFatherInvokeTest().new Son()).thinking();
    }

}

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

解释执行

没有即时编译器以前,java就是解释执行的语言。
有了以后,怎么执行不确定,混合执行。

不论是解释还是编译,不论是物理机还是虚拟机,程序代码到目标代码或者虚拟机能执行的指令集之前,首先都要遵循现代经典编译原理的思路,在执行前对程序源码进行词法分析以及语法分析处理,把源码转换成抽象语法树,有的这一部分独立于执行引擎,这类代表是C/C++。
而Java语言中,Javac编译期完成了程序代码的词法分析、语法分析到抽象语法树,再遍历抽象语法树生成线性的字节码指令流的过程,因为这一部分在jvm外的,而解释器在虚拟机的内部,所以是半独立的。
javascript全程封闭。

基于栈的指令集 vs. 基于寄存器的指令集

两者互有优势,前者可移植,不受硬件干扰,代码紧凑,实现简单。但是执行速度会慢。
因为栈访问意味着频繁的内存访问。

具体的执行过程可以按照书上理解,但实际上与这种概念上理解的差距,因为有很多内部优化的地方。

发表评论

电子邮件地址不会被公开。