深入理解Java虚拟机——虚拟机字节码执行引擎

8.1 概述

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

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

8.2 运行时栈帧结构

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

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

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

栈帧的概念结构

8.2.1 局部变量表

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

局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确一个Slot应占用的内存空间大小,只是很有导向性的说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8中数据类型,都可以使用32位或更小的物理内存来存储,但这种描述与明确指出“每个Slot占用32位长度的存储空间”是有一些差别的,它允许Slot的长度可以随着处理器、操作系统或者虚拟机的不同而发生变化。只要保证即时在64位虚拟机中使用了64位的物理内存中去实现一个Slot,虚拟机仍然要使用对齐和补白的手段让Slot在外观上看起来和32位虚拟机中的一致。

既然前面提到了Java虚拟机的数据类型,在此再次简单介绍一下它们,一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference和returnAddress8中类型。前面6中不需要多加解释,而第七种reference类型表示对一个对象实例的引用,虚拟机既没有说明它的长度,也没有明确指出这种引用应该有怎样的结构,但一般来说,虚拟机实现至少应当能通过这个引用做到两点,一是从引用中直接或者间接的查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接的查找到对象所属的数据类型在方法区中存储的类型信息,否则无法实现Java语言规范中定义的语法约束。第八种即returnAddress类型目前已经很少见了,它是位字节码指令jsr、jsr_w和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复用会直接影响到系统的垃圾收集行为)。

8.2.2 操作数栈

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

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

举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型整数,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

操作数栈中元素的数据类型必须是与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点,在以上面的iadd指令为例,这个指令用于整形数加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个flaot使用iadd命令相加的情况。

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

8.3.2 动态连接

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

8.3.3 方法返回地址

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

另一种退出方式,在方法执行过程中遇到异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,主要在本方法中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。

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

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

8.2.5 附加信息

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

8.3 方法调用

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

8.3.1 解析

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

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

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

  1. invokestatic:调用静态方法;
  2. invokespecial:调用实例构造器< init>方法、私有方法和父类方法;
  3. invokevirtual:调用所有的虚方法;
  4. invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
  5. invokedynamic:现在运行时动态解析出调用点限制符所引用的方法,然后在执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是有用户所设定的引导方法决定的。

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

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

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

8.3.2 分派

众所周知,Java是一门面向对象的程序语言,因为Java面向对象的3个基本特征:继承、封装、多态。本节讲解的分派调用过程将会揭示多态性特征的一些基本体现,如“重写”、“重载”在Java虚拟机中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

1. 静态分派

在开始讲解静态分派前,笔者准备了一段经常出现在面试题中的代码程序方法静态分派演示
运行结果:
hello,guy!
hello,guy!
但是为什么会选择执行参数为Human的重载呢?在解决这个问题之前,我们先按如下代码定义两个重要的概念。

Human man = new Man();

我们把上面的代码中的“Human”称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅是使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型实在编译器可知的;而实际类型变化的结果实在运行期才可以确定的,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如下面的代码:
// 实际类型变化
Human man = new Man();
man = new Woman();
// 静态类型变化
sr.sayHello((Man) man);
sr.sayHello((Woman) man);

解释了这两个概念,再回到代码中。main()里面两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中可以的定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确的说是编译器)在重载时是通过参数的静态类型不同而不是实际类型作为判断依据的。并且静态类型在编译期是可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

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

重载方法匹配优先级
上面的代码运行后会输出:
hello char

这很好理解,'a’是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果注释掉sayHello(char arg)方法,那输出会变为:
hello int

这是发生了一次自动类型转换,'a’出了可以代表一个字符串,还可以代表数字97(字符’a’的Unicode数值为十进制数字97),因此参数类型int等重载也是最合适的。我们继续注释掉sayHello(int arg)方法,那输出会变为:
hello long

这是发生两次自动类型转换,'a’转换为整数97之后,进一步转换为长整数97L,匹配了参数类型为long的重载。代码中没有其他的类型入float、double等的重载,不过实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序继续转型记性匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的类型是不安全的,继续注释掉sayHello(long arg)方法,那输出会变为:
hello Character

这时发生了一次自动装箱,'a’被包装为它的封装类型java.lang.Character,所以匹配到了参数为Character的重载,继续注释掉sayHello(Character arg)方法,那输出会变为:
hello Serializable

这个输出可能会让人感觉摸不到头脑,一个字符或数字与序列化有什么关系?出现 hello Serializable,是因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char转换成int,但是Character绝对不会转换为Integer的,它只能安全的转换为它实现的接口或父类。Character还实现了另一个接口java.lang.Comparable< Character>,如果同时出现两个参数分别为Serializable和Comparable< Character>的重载方法,那它们的优先级是一样的。编译器无法确定要自动转换为哪个类型,会提示类型模糊,拒绝编译。程序必须在调用时显示的指定字面量的静态类型,如:sayHello(Comparable< Character> ‘a’),才能编译通过。下面继续注释掉sayHello(Serializable arg)方法,输出会变为:
hello Object

这时char装箱后转型为父类了,如果有多个父类,那么在继承关系中从下往上开始搜索,越接近上层优先级越低。即时方法调用传入参数值为null时,这个规则仍然适用。我们把sayHello(Object arg)也注释掉,输出将变为:
hello char…

7个重载方法已经被注释掉的只剩一个了,可见变长参数的重载优先级是最低的,这时候字符’a’被当做一个数组元素。

代码清单中演示了编译期间选择静态分派目标的过程,这个过程也是Java语言实现方法重载的本质。

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

2. 动态分派

了解了动态分派,接下来看下动态分派过程,它和多态性的另一个重要体现——重写(Override)有着密切的关联。

方法动态分派演示
运行结果:
hello,gentlman!
hello,lady!
hello,lady!

这个运行结果不会出乎任何人的意料,现在的问题还是和前面一样,虚拟机是如何知道要调用哪个方法的?

显然这里不可能在根据静态类型来决定,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法是执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?

原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下一个步骤:

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

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

3. 单分派和多分派

方法接收者和方法参数统称为方法的宗量,这个定义最早应该源于《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

单分派和多分派的定义读起来比较拗口,从字面上看也比较抽象,不过对照着实例看就不难理解了。
单分派和多分派演示
运行结果:
father choose 360
son choose qq

在main方法中调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序中已经显示很清楚了。

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

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

根据以上论证的结果,可以总结一句话:今天Java语言是一门静态多分派、动态单分派的语言。强调“今天的Java语言”是因为这个结论未必是恒久不变的,C##在3.0之前的版本和Java一样是动态单分派语言,但在C## 4.0中引入了dynamic类型后,就可以很方便的实现动态多分派。

按照Java语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过动态语言(如JavaScript)执行引擎的方式来满足动态的需求。但Java虚拟机层面上则不是如此,在JDK 1.7中实现的JSR-292里面就已经开始提供对动态语言的支持了,JDK 1.7中新增的invokedynamic指令也成为了最复杂的一条方法调用的字节码指令。

4. 虚拟机动态分派的实现

前面介绍的分派过程,作为虚拟机概念模型的解析基本上已经足够了,它已经解决了虚拟机在分派过程中“会做什么”这个问题。但是虚拟机“具体是如何做的”,可能各种虚拟机的实现都会有些差别。

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引优化来代替元数据查找以提升性能。方法表机构
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同的地址入口一致,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替代为指向子类实现版本的入口地址。如图,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但Son和Father都没有重写来自Object的方法,所以他们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

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

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

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

8.3.3 动态类型语言支持

Java虚拟机的字节码指令集的数量从SUN公司的第一款Java虚拟机问世至JDK 1.6。一致没有发生任何变化,直到JDK 1.7的来临,字节码指令集终于迎来了第一位新成员——invokedynamic指令。这条新增加的指令时 JDK7实现“动态类型语言”(Dynamically Typed Language)而进行的改进之一,也是为JDK 8可以顺利实现Lambda表达式做技术准备。

1. 动态类型语言

在介绍Java虚拟机的动态类型语言支持之前,需要弄明白什么是动态类型语言,它与Java语言、Java虚拟机有什么关系、了解JDK 1.7提供动态类型语言支持的技术背景,对理解这个语言特性是很有必要的。

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

觉得上面的定义过于概念化?不妨举两个例子以最浅显的方式来说明什么是“在编译期/运行期进行”和什么是“类型检查”,首先,来看下一个最简单的Java代码。

public static void main(String [] args){
	int[][][] array = new int [1][0][-1];
}

这段代码能够正常编译,但运行时会报NegativeArraySizeException异常。在Java虚拟机规范中明确规定了NegativeArraySizeException是一种运行时异常,通俗的说,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如NoClassDefFoundError便属于连接时异常,即时会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java的连接过程不一定在编译阶段,而在类加载阶段)也照样会抛出异常。

由此看来,一门语言的哪一种检查行为要是在运行期间进行,哪一种检查要在编译期进行并没有必然的因果逻辑关系,关键是语言规范中认为规定的。再举个例子来解释“类型检查”:

obj.printl(“hello world”);

虽然对于每个人都能看懂这条代码要做什么,但对于计算机来说,这行代码“没头没尾”是无法执行的,它需要一个具体的上下文才有讨论的意义。

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

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

这种差别的产生的原因是Java语言在编译期间已将println(String)方法完整的符号引用生成出来,作为方法调用指令的参数存储到Class文件中,这个符号引用包含此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译差这个方法的直接引用。而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。

了解了静态类型语言和动态类型语言的区别后,下一个问题就是动态、静态类型语言谁更好,或者谁更先进?这种比较不会有确切答案,因为它们都有自己的优点,选择哪种语言是需要经过权衡的。静态类型语言在编译期确定类型,最显著的好处是编译期可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码规模达到更大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,有动态类型语言来实现可能更加清晰和简洁,清晰和简洁通常也就意味着开发效率的提升。

2. JDK 1.7与动态连接

回到本节的主题,来看看Java语言、虚拟机与动态类型语言之间有什么关系。Java虚拟机毫无疑问是Java语言的运行平台,但它的使命并不仅限于此,早在1997年出版的《Java虚拟机规范》中就规划这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好的支持其他语言运行在Java虚拟机上”。而目前已经有许多动态类型语言运行与虚拟机之上了,Clojure、Groovy、Jython和JRuby等,能够在同一个虚拟机上可以达到静态类型语言的严谨性与动态类型语言的灵活性,这时一件很美妙的事情。

但遗憾的是,Java虚拟机层面对动态类型语言的支持一致都有所欠缺,主要表现在方法调用方面:JDK 1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型,这样,在Java虚拟机上实现动态类型语言就不得不使用其他的方式(图编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适。因此在Java虚拟机层面上提供动态类型的直接支持就成为了Java平台的发展趋势之一,这就是JDK 1.7(JSR-292)中invokedynamic指令以及java.lang.invoke包出现的技术背景。

3. java.lang.invoke包

JDK 1.7实现了JSR-292,新加入的java.lang.invoke包就是JSR-292的一个重要组成部分,这个包的主要目的就是在之前单纯依靠符号引用来确定目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle,这种表达凡是也许不太好懂?那不妨把MethodHandle与C/C++中的Function Pointer,或者C#里面的Delegate类比一下。举个例子,如果我们要实现一个带谓词的排序函数,在C/C++中常用的做法是把谓词定义为函数,用函数指针把谓词传递到排序方法,如下:

void sort(int list[],const int size,int (*compare)(int,int))

在Java语言中做不到这一点,即没有办法单独的把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现了这个接口的对象作为参数,例如Conllections.sort()就是这样定义的:

void sort(List list,Compartor c);

不过拥有MethodHandle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。一下代码演示了MethodHandle的基本用途,无论是obj是何种类型(临时定义ClassA抑或是实现PrintStream接口的实现类System.out),都可以正确的调用到println()方法。
MethodHandle演示
模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一种具体方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。以此为基础。有了MethodHandle就可以写出类似下面这样的函数声明:

void sort (List list, MethodHandle compare)

从上面的例子可以看出,使用MethodHandle并没有什么困难,不过看完它的用法之后,会产生疑问,相同的事情,用反射不是早就可以实现吗?

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

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

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

4. invokedynamic指令

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

每一处含有invokedynamic指令的位置都称为“动态调用点”(Dynamic Call Site),这条指令的第一个参数不再代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个常量可得到3个信息:

  1. 引导方法(BootStrap Method):此方法存放在新增的BootStrapMethods属性表中。引导方法是固有参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用,根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。
  2. 方法类型(Method Type):
  3. 名称
5. 掌控方法分派规则

invokedynamic指令与前面4条“invoke*”指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定的。

使用MethodHandle来解决相关问题
运行结果:
i am GrandFather

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

从这节开始,来探讨虚拟机是如何执行方法中的字节码指令的。许多Java虚拟机的执行引擎在执行Java代码的时候都会解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在本章中,来探讨一下解释执行时,虚拟机执行引擎是如何工作的。

8.4.1 解释执行

Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK 1.0时代,这种定义还算是比较准确的,但当主流的虚拟机中包含即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情了。在后来,Java也发展出了可以直接生成本地代码的编译器【如GCJ(GNU Compiler for the Java)】,而在C/C++语言也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念,只有确定了谈论对象是某个具体的Javav实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。

不论是解释还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后获得执行能力。大部分的程序代码到物理机的目标代码或者虚拟机能执行的指令集之前,都需要经过图中的各个步骤,图中就是传统编译原理中程序代码到目标机器语言代码的生成过程,而中间的那条分支,自然就是解释执行的过程。

编译过程
如今,基于物理机、Java虚拟机或者非Java的其他高级语言虚拟机(HLLVM)的语言,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST),对于一门具体语言的实现来说,词法分析、语法分析已至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择一部分步骤(如生成抽象语法树之前的步骤)实现一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装到一个封闭的黑匣子之中,如大多数的JavaScript执行器。

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

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

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

举个简单的例子,分别使用者两种指令集计算“1+1”的结果,基于栈的指令集回事这样子:

iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。

如果基于寄存器,那程序可能就是这个样子:

mov eax, 1
add eax, 1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。

了解了基于栈的指令集和基于寄存器的指令集的区别后,那么这两套指令集谁更好一些呢?应该这么说,既然这两套指令集会同时并存和发展,那肯定是各有优势,如果有一套指令全面优于另一套的话,就不会存在选择的问题了。

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

栈架构指令集的主要缺点是执行速度相对来说会慢一些,所有主流物理机指令集都是寄存器架构也从侧面印证了这一点。

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

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

初步的理论知识已经讲解过了,本节通过一段Java代码,看看在虚拟机中实际是如何执行的。

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

从Java语言的角度来看,这段代码没有任何解释的必要,可以直接使用javap命令kankan它的字节码指令:

public int calc();
	Code:
		Stack = 2, Locals = 4, Arg_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:	 return

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量表,下面是整个执行过程中代码、操作数栈和局部变量表的变化情况:

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

不过,从程序的执行中可以看出栈结构指令集的一般运行过程,这个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合前面分析的特点。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值