0%

JVM 字节码执行引擎

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

在 JVM 规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的 Java 虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写人到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图所示。

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,虚拟机规范中并没有明确指明一个 Slot 应占用的内存空间大小,只是很有导向性地说到每个 Slot 都应该能存放个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据,这 8 种数据类型,都可以使用 32 位或更小的物理内存来存放。

第 7 种 reference 类型表示对一个对象实例的引用,虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在 Java 堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现 Java 语言规范中定义的语法约束。

Java 虚拟机规范中没有明确规定 reference 类型的长度,它的长度与实际使用 32 还是 64 位虚拟机有关,如果是 64 位虚拟机,还与是否开启某些对象指针压缩的优化有关,这里暂且只取 32 位虚拟机的 reference 长度。

第8种即 returnAddress 类型目前已经很少见了,它是为字节码指令 jsr、jsrw 和 ret 服务的,指向了一条字节码指令的地址,很古老的 Java 虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。

对于 64 位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间,Java 语言中明确的(reference 类型则可能是 32 位也可能是 64 位)64 位的数据类型只有 long 和 double 两种。值得一提的是,这里把 long 和 double 数据类型分割存储的做法与“ long 和 double 的非原子性协定”中把一次 long 和 double 数据类型读写分割为两次 32 位读写的做法有些类似。不过,由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的 Slot 是否为原子操作,都不会引起数据安全问题。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的 Slot 数量。如果访问的是 32 位数据类型的变量,索引 n 就代表了使用第 n 个 Slot 如果是 64 位数据类型的变量,则说明会同时使用 n 和 n+1 两个Slot。对于两个相邻的共同存放一个 64 位数据的两个 Slot,不允许采用任何方式单独访问其中的某一个,Java 虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非 statIc 的方法),那局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 this 来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot 为了尽可能节省栈帧空间,局部变量表中的 Slot 是可以重用的,方法体中定义的变量其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的 Slot 就可以交给其他变量使用。不过,这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,在某些情况下,Slot 的复用会直接影响到系统的垃圾收集行为。

Slot复用影响系统垃圾收集行为示例

//-verbose:gc 查看GC过程

//在作用域内不回收相应 Slot
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();

//不在作用域内,但未复用,GC仍然保持着引用,依旧不会被回收
{
    byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();

//正确回收
{
    byte[] placeholder = new byte[64 * 1024 * 1024];
}
int i = 0;
System.gc();

placeholder 能否被回收的根本原因是:局部变量表中的 Slot 是否还存有关于 placeholder 数组对象的引用。

第一次修改中,代码虽然已经离开了 placeholder 的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder 原本所占用的 Slot 还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为 null 值(用来代替那句 int a=0,把变量对应的局部变量表 Slot 清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到 JIT 的编译条件)下的“奇技”来使用。

不应当对赋 null 值的操作有过多的依赖,更没有必要把它当做个普遍的编码规则来推广。从编码角度讲,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法。更关键的是,从执行角度讲,使用赋 null 值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的。在虚拟机使用解释器执行时,通常与概念模型还比较接近,但经过 JIT 编译器后,才是虚拟机执行代码的主要方式,赋 null 值的操作在经过 JIT 编译优化后就会被消除掉,这时候将变量设置为 null 就是没有意义的。字节码被编译为本地代码后,对 GC Roots 的枚举也与解释执行时期有巨大差别,以前面例子来看,在经过JIT编译后,第二段代码清单System.gc()执行时就可以正确地回收掉内存,无须写成第三段代码清单的样子。

关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在“准备阶段”。对于类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为 Java 中任何情况下都存在诸如整型变量默认为 0,布尔型变量默认为 false 等这样的默认值。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中会有各种字节码指令往操作数栈中写人和提取内容,也就是出栈/人栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

另外,在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。

第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口( Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退岀方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。

附加信息

虚拟机规范允许其体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过, Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析( Resolution)。

在 Java 语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

与之相对应的是,在 Java 虚拟机里面提供了 5 条方法调用字节码指令,分别如下

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行

该方法,在此之前的 4 条调用指令,分派逻辑是固化在 Java 虚拟机内部的,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去 final 方法,后文会提到)。

Java 中的非虚方法除了使用 invokestatic、 invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法。

分派

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况。

面向对象基本特征:继承、封装、多态

静态分派

public class StaticDispatch {
    static abstract class Human{}

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    public void say(Human guy) {
        System.out.println("hello guy");
    }
    public void say(Man man) {
        System.out.println("hello man");
    }
    public void say(Woman woman) {
        System.out.println("hello woman");
    }

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

        new StaticDispatch().say(man);
        new StaticDispatch().say(woman);
        new StaticDispatch().say((Man)man);
        new StaticDispatch().say((Woman)woman);
        
        //Console
        //hello guy
        //hello guy
        //hello man
        //hello woman
    }
}

我们把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。因此虚拟机(准确地说是编译器)使用哪个重载版本,就完全取决于传入参数的数量和数据类型。但虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是比较“稀罕”的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

public class StaticDispatch {
    public static void say(Object arg) {
        System.out.println("hello Object");
    }

    public static void say(int arg) {
        System.out.println("hello int");
    }

    public static void say(long arg) {
        System.out.println("hello long");
    }

    public static void say(Character arg) {
        System.out.println("hello Character");
    }

    public static void say(char arg) {
        System.out.println("hello char");
    }

    public static void say(char... arg) {
        System.out.println("hello char...");
    }

    public static void say(Serializable arg) {
        System.out.println("hello Serializable");
    }

    public static void main(String[] args) {
        say('a');
    }
}
//'a' 由于是 char 类型,会输出 hello char
//如果注释掉 char 方法,会输出 hello int,此时发生了自动类型转换 char -> int 
//继续注释掉 int 方法,会输出 hello long,按照 char->int->long->float->double 顺序转型进行匹配
	//注意,不会转化成 byte 和 short,因为窄化转型不安全
//继续注释掉 long 方法,会输出 hello Character,发生了一次自动装箱
//继续注释掉 Character 方法,输出会变为 Serializable,Serializable 是 Character 类实现的接口
	//这时,如果还存在着 Character 实现的其他接口,则会因为指代不明拒绝编译
//继续注释掉 Serializable 方法,会输出 hello Object,转换为父类
	//如果有多个父类,则一级一级往上找
//继续注释掉 Object 方法,输出 char...,变长参数的优先级最低

解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如,前面说过静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的

动态分派

动态分配与重载有着密切的关系。

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

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

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

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

显然虚拟机不能再根据静态类型来决定,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用sayHello()方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java 虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用 javap 命令输出这段代码的字节码,尝试从中寻找答案,输出结果如下。

//建立内存空间,将局部变量放入局部变量表
0: new           #2                  // class com/example/demo/DynamicDispatch$Man
3: dup
4: invokespecial #3                  // Method com/example/demo/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new           #4                  // class com/example/demo/DynamicDispatch$Woman
11: dup
12: invokespecial #5                  // Method com/example/demo/DynamicDispatch$Woman."<init>":()V
15: astore_2
//将对象引用压入栈顶
16: aload_1
17: invokevirtual #6                  // Method com/example/demo/DynamicDispatch$Human.say:()V
20: aload_2
21: invokevirtual #6                  // Method com/example/demo/DynamicDispatch$Human.say:()V
24: return

把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的say()方法的所有者,称为接收者(Receiver);17 和 21 句是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第 6 项的常量,注释显示了这个常量是 Human.say 符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从 invokevirtual 指令的多态查找过程开始说起,invokevirtual 指令的运行时解析过程大致分为以下几个步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C。
  2. 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对 C 的各个父类进行第 2 步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是 Java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

单分派和多分派的定义读起来拗口,从字面上看也比较抽象,不过对照着实例看就不难理解了。下述代码清单中列举了一个 Father 和 Son 一起来做出“一个决定”的例子。

public class Dispatch {
    static class QQ{
    }

    static class TIM{
    }

    public static class Father{
        public void choice(QQ arg) {
            System.out.println("Father chooses QQ");
        }

        public void choice(TIM arg) {
            System.out.println("Father chooses TIM");
        }
    }

    public static class Son extends Father{
        public void choice(QQ arg) {
            System.out.println("Son chooses QQ");
        }

        public void choice(TIM arg) {
            System.out.println("Son chooses TIM");
        }
    }

    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.choice(new TIM());
        son.choice(new QQ());
        //Console
        //Father chooses TIM
        //Son chooses QQ
    }
}

下面是编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是 Father 还是 Son,二是方法参数是 QQ 还是 360。这次选择结果的最终产物是产生了两条 invokevirtual 指令,两条指令的参数分别为常量池中指向Father.choice(360)Father.choice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型。

再看看运行阶段虛拟机的选择,也就是动态分派的过程。在执行son.choice(QQ)所对应的 invokevirtual 指令时,由于编译期已经决定目标方法的签名必须为hardchoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”的具体实现,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是 Father 还是 Son。因为只有一个宗量作为选择依据,所以 Java 语言的动态分派属于单分派类型。

根据上述论证的结果,直至 Java 1.8,Java 语言是一门静态多分派、动态单分派的语言。

虚拟机动态分配的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Virtual Methed Table,也称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表 — Inteface Method Table,简称 itable),使用虚方法表索引来代替元数据查找以提高性能。上面的代码漬单所对应的虚方法表结构示例,如下图所示。

虚方法表中在放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。上图中,Son 重写了来自 Father 全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。

为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

上文中笔者说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline cache)和基于“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获得更高的性能。

动态类型语言支持

随着 JDK7 的发布,字节码指令集终于迎来了第一位新成员 — invokedynamic 指令。这条新增加的指令是 JDK7 实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,也是为 JDK8 可以顺利实现 Lambda 表达式做技术准备。

动态类型语言

什么是动态类型语言?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括:Groovy、JavaScript、Jython、Lua、PHP、Python 等。相对的,在编译期就进行类型检查过程的语言(如 C++ 和 Java 等)就是最常用的静态类型语言。

动态类型语言的重要特征:变量无类型而变量值才有类型。

静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检査,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型峿言中需用大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也就意味着开发效率的提升。

lang.invoke包

仅站在 Java 语言的角度来看, MethodHandle 的使用方法和效果与 Reflection 有众多相似之处,不过,它们还是有以下这些区别:

  • 从本质上讲,Refection 和 MethodHandle 制都是在模拟方法调用,但 Refection是在模拟Java代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。在 MethodHandles.lookup 中的 3 个方法——findStatic()findVirtual()findSpecial()正是为了对应于 invokestatic、invokevirtual&invokeinterface 和 invokespecial 这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection APl 时是不需要关心的。
  • Reflection 中的java.lang.reflect.Method对象远比 MethodHandle 机制中的java.lang.invoke.MethodHandle对象所包含的信息多。前者是方法在 Java 一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的 Java 端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲,Reflection 是重量级,而 MethodHandle 是轻量级
  • 由于 MethodHandle 是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在 MethodHandle 上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。

MethodHandle 与 Reflection 除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提“仅站在Java语言的角度来看”:Reflection API 的设计目标是只为 Java 语言服务的,而 MethodHandle 则设计成可服务于所有 Java 虚拟机之上的语言,其中也包括 Java 语言。

public class Solution {
    static class ClassA{
        public void println(String s) {
            System.out.println(s);
        }
    }

    public static void main(String[] args) throws Throwable {
        getPrintlnMH(new ClassA()).invokeExact("sads");
    }

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

invokedynamic指令

JDK1.7 为了更好地支持动态类型语言引入了字节码指令 invokedynamic,但我们一直没有再见到它,甚至把上述 MethodHandle 的示例代码反编译后也不会看见 invokedynamic 的身影,它的应用之处在哪里呢?

在某种程度上,invokedynamic 指令与 MethodHandle 机制的作用是一样的,都是为了解决原有4条invoke*指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层 Java 代码和 API 来实现,另一用字节码和 Class 中其他属性、常量来完成。因此,如果理解了前面的 MethodHandie 例子,那么理解 invokedynamic 指令也并不困难。

每一处含有 invokedynamic 指令的位置都称做“动态调用点”(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的 CONSTANT_Methodref_info 常量,而是变为 JDK1.7 新加入的 CONSTANT_InvokeDynamic_info 常量,从这个新常量中可以得到 3 项信息:引导方法(Bootstrap Method,此方法存放在新增的 BootstrapMethods 属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表貞正要执行的目标方法调用。根据 CONSTANT_InvokeDynamic_info 常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个 CallSite 对象最终调用要执行的目标方法。

掌握方法分派规则

invokedynamic 指令与前面4条invoke*指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。比如,正常代码可以通过 super 关键字很方便地调用到父类中的方法,但如果要访间祖类的方法呢?

在 JDK1.7 之前,使用纯粹的 Java 语言很难处理这个问题(直接生成字节码就很简单,如使用 ASM 等字节码工具),原因是在子类的方法中无法获取一个实际类型是祖类的对象引用,而 invokevirtual 指令的分派逻辑就是按照方法接收者的实际类型进行分派,这个逻辑是固化在虚拟机中的,程序员无法改变。

在 JDK1.7 中,可以使用下面代码清单中的程序来解决这个问题。

public class Test {
    class GrandFather {
        void thinking (){
            System.out.println("grandfather");
        }
    }
    class Father extends GrandFather {
        void thinking (){
            System.out.println("father");
        }
    }
    class Son  extends Father{
        void thinking (){
            System.out.println("son");
            /**
             * 如何 实现 调用 grandfather 的方法
             */
            try {
                MethodType mt = MethodType.methodType(void.class);
                MethodHandle mh = MethodHandles.lookup().findSpecial(GrandFather.class,
                        "thinking", mt, getClass()) ;
                mh.invoke(this) ;
            } catch (Throwable e) {
                e.printStackTrace();
            }
        }
    }
     
    public static void main(String [] args){
        (new Test().new Son()).thinking();
        //Console
        //grandfather
    }
}

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

许多 Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,下面探讨一下在解释执行时,虚拟机执行引擎是如何工作的。

解释执行

编译过程如下图,下面的分支是传统编译原理中程序代码到目标柷器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。

Java语言中, javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。

基于栈的指令集与基于寄存器的指令集

Java 编译器输岀的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 x86 的二地址指令集,说得通俗一些,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如,现在 32 位 80x86 体系的处理器中提供了 8 个 32 位的寄存器,而 ARM 体系的 CPU(在当前的手机、PDA 中相当流行的一种处理器则提供了 16 个 32 位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。

基于栈的解释器执行过程

public int calc(){
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
}
public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      //这段代码需要深度为 2 的操作数栈和 4 个 Slot 的局部变量空间
      stack=2, locals=4, args_size=1
         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1
        12: iload_2
        13: iadd
        14: iload_3
        15: imul
        16: ireturn
      LineNumberTable:
        line 10: 0
        line 11: 3
        line 12: 7
        line 13: 11
}

执行过程

上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程不一定完仝符合概念模型的描述……更准确地说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,例如,在 HotSpot 虚拟机中,有很多以“fast”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多°。