虚拟机字节码执行引擎
1.前言
之前对虚拟机的加载机制进行了描述:这里,本章主要对虚拟机的运行机制进行记录说明。
虚拟机区别于物理机就在于运行方面,物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层次上的,虚拟机的执行引擎是自己实现的,可以自行定制指令集。所以JVM可以进行跨平台。
2.栈帧的结构
在最早的文章中介绍了JVM的内存布局:这里。文章中提到了栈和程序计数器是线程私有的。栈帧是虚拟机进行方法调用和方法执行的数据结构,是栈的元素。每个方法都对应一个栈帧,方法的执行过程就是在栈中的栈帧入栈到出栈的过程。
上图可以看出一个线程一个栈,一个栈中包含若干栈帧,栈帧对应一个具体的方法,主要包含四块内容:局部变量表、操作栈、动态链接、返回地址。最上面的栈才是当前要执行的有效栈,称为当前栈帧,关联的方法就是当前方法。
2.1 局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在编译时,就在方法的Code属性中的max_locals数据项定义了该方法需要分配的局部变量表的最大容量。
这个表以变量槽为单位——slot。虚拟机规范中没有规定一个slot的具体大小,但是说每个slot都应该存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。这8种数据类型都可以使用32位的物理内存存放,但不意味着slot就是32位。
对于64位的数据类型long、double,虚拟机会以高位对齐的方式为其分配两个连续的slot空间。这种方式与"long和double的非原子性协定"中把long和double数据类型读写分割成两次32位读写的做法类似(这也就是为什么long和double的赋值操作不是原子性的原因)。不过slot是分配在栈上的,所以在一个线程内,不会有线程安全问题。
虚拟机通过索引来定位使用局部变量表,范围从0~N(slot的数量),n就表示定位局部变量表的第n个slot的值,64位的会使用两个定位n和n+1,且不允许单独访问一个,校验环节会抛出异常。第0个slot是保留的,表示方法所属的实例的引用,代表this。从1~N就是真正的变量了,包括参数,后面就是按顺序的方法体内的变量。
为了节省空间,局部变量表中的slot是可以重用的,比如if条件句中定义的变量,出了这个结构就不再使用,这个时候就可以被复用。不过这种设计会带来额外的副作用,会影响垃圾回收的行为。即使定义在代码块中的变量不再被后续使用,垃圾回收也不会回收这部分内容,哪怕不再使用,因为:局部变量表中的slot还存在与其相关的引用。所以当被其他变量复用的时候,垃圾回收才会判断需要进行回收。所以如果遇到一个方法,后面代码有一段很耗时的操作,前面又占用了大量内存,后续又没有使用,那么将其手动设置成null就有意义了,这样才会被垃圾回收掉。这个就是《Practical Java》中提到的“不使用的对象应手动赋值为null"。当然没必要强行赋值成null,因为代码被编译过程可能会发生优化,赋值不赋值为null可能最终结果与代码本身书写的无关。
另外,局部变量和之前说的类变量不同,类变量要经过准备阶段赋予零值,再进行初始化阶段赋予真正的值。局部变量没有初始值,不赋值是不能使用的,不要想当然不赋值就是零值。
2.2 操作数栈
这是一个后入先出的LIFO栈。通局部变量表一样,操作数栈的最大深度在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在任何时候,操作数栈的深度都不会超过设置的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,执行过程中,各种字节码指令往操作数栈中写入和提取内容,出栈和入栈操作。举个例子,做加法:iadd命令,会取出栈中最顶上的两个元素,执行相加,然后将结果放入栈中。
操作数栈元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类的校验阶段也需要再次验证。
此外,概念模型中,两个栈帧作为虚拟机的元素是完全独立的,但是实现上会进行优化,栈帧会出现重叠。这样就可以共用一部分数据,无需进行额外的参数传递。
2.3 动态连接
每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用的目的是为了支持方法调用过程中的动态连接。之前类加载那文中介绍过,Class文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用有一部分会在类加载阶段翻译成直接引用,这种称为静态解析。另一部分在运行期间才会转化成直接引用,这个就是动态连接。后续会对这一块做详细的说明。
2.4 方法返回地址
执行一个方法有两种退出方式:
1.遇到返回字节码指令,可能会有返回值给上层方法进行调用,或者没有返回值。这种退出方法的方式称为正常完成出口。
2.遇到异常,没有被处理,就会导致方法退出,这种方式被称为异常完成出口,不会给它的上层调用者产生任何返回值。
无论哪种退出,退出之后都要返回方法被调用的位置,程序才能继续执行,返回时可能需要在栈帧保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,正常退出时,PC计数器的值可以作为返回地址,栈帧可能会保存这个计数器值。异常退出时,返回地址是要通过异常处理器表来确定的,栈中一般不会保存这部分信息。
方法退出等同于栈帧出栈,所以退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一个指令等。
3 方法调用
这里就详细说下动态连接的原因和过程了。方法调用不等于方法执行,这个阶段的目的就是确定调用的方法是哪个。前面说过,编译阶段不包含连接步骤,都是符号引用,类加载过程会转变部分为直接引用,但是还是有一些是无法确定,只有在运行时才能确定的。
3.1 解析
类加载的解析阶段会将部分符号引用替换成直接引用,成功的前提在于:在运行之前就能确定调用的版本,并且这个版本在运行期间是不可改变的。符合的方法有静态方法和私有方法两种,前者与类型直接关联,后者只能在内部访问。
JVM提供了5条方法调用字节码指令:
1.invokestatic:调用静态方法
2.invokespecial:调用构造器方法<init>、私有方法、父类方法
3.invokevirtual:调用所有虚方法
4.invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
5.invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
invokestatic和invokespecial的方法,可以在解析过程中确定,符合条件的有静态方法,私有方法、实例构造器、父类方法,这些方法称为非虚方法,其他的都是虚方法(除了final)。而invokedynamic又更加特殊,其他指令的逻辑是固化在虚拟机内部的,这个指令的逻辑由用户设置的引导方法决定。final方法虽然是由invokevirtual指令来调用,但是由于其无法被覆盖,没有其他版本,所以结果唯一,是一种非虚方法。
将符号引用转化成直接引用按照阶段分,可以划分成静态分派和动态分派,按数量又有单分派和多分派。
3.2 分派
Java的三个基本特征:继承、封装、多态。这里介绍的方法调用过程将描述一下多态的特征,如重载和重写时如何实现的,关注的是虚拟机是如何找到正确的执行目标。
3.2.1 静态分派
这里有个重载的理解题。Man和Woman都继承了Human,Test类有3个重载方法say(Human)、say(Man)、say(Woman)。如果创建两个对象Human man = new Man(),和Human woman = new Woman()。然后执行Test.say(man)和Test.say(woman),会触发哪个方法呢?
答案是会触发Test.say(Human)这个重载方法,但是为什么呢?这里有几个概念,Human是变量man的静态类型,Man是变量man的实际类型。编译阶段编译器是不清楚实际类型的变化的,其只关注静态类型,使用哪个方法重载完全取决于传入参数的数量和类型。虚拟机重载的时候判断的是参数的静态类型而不是实际类型,所以就使用了say(Human)这个方法。
静态分派发生在编译阶段,确定静态分派的动作实际上不是由虚拟机执行的。另外,编译器虽然能够确定方法的重载版本,但是很多情况符合条件的不是唯一的,往往需要确定一个更加符合的版本。模糊的原因是因为字面量是不需要定义的,也就不知道其静态类型是什么,比如'a',它可以是一个char或者Character或者Object或者int等等,这种时候就要有一些规则来判断具体分配到哪个方法上。
以'a'为例,大致过程如下:
1.优先匹配char类型
2.其次匹配int类型(发生自动类型转换)
3.其次匹配long类型(再次发生自动类型转换)
4.其次匹配Character类型(自动装箱)
5.其次匹配Serializable(这个是由于Character实现了这个接口,所以找不到Character会优先找接口)
6.其次匹配Object(找父类)
7.最后匹配char...可变长参数
3.2.2 动态分派
动态分派和重写密切相关。还是Man和Woman都实现了Human的抽象方法say。还是Human man = new Man(),Human woman = new Woman();这个时候调用man.say()和woman,say(),其会执行具体的man和woman的相关方法。
这里显然不可能通过静态类型来决定了,使用的是invokevirtual指令,具体步骤如下:
1.找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C
2.如果在C中找到指定方法,进行权限校验,通过返回方法的直接引用,不通过抛出异常IllegalAccessError异常
3.没找到,从下到上继承体系进行步骤2的查找
4.始终没找到,抛出AbstractMethodError异常
invokevirtual的第一步就是确定实际类型,所以才解析到了不同的方法直接引用上,这就是重写的本质。称为动态分派。
3.2.3 单分派和多分派
方法的接收者和方法的参数统称为方法的宗量,基于多少种宗量可以分为单分派和多分派两种。
单分派基于一个宗量进行选择,多分派基于多于1个宗量进行选择。静态分派首先要选择静态类型,再判断方法参数,所以静态分派是多分派。动态分派编译时期就知道了目标方法的方法签名,所以不需要关心参数是什么,所以属于单分派类型。目前就是静态多分配、动态单分派语言。JDK7实现了invokedynamic指令,这个十分复杂,用于满足动态性的需求,后续会进行描述。
3.2.4 虚拟机的动态分派实现
动态分派十分频繁,在运行过程中搜索合适的目标方法,所以实现要考虑性能,不会真正进行频繁的搜索。最常见的优化就是为类在方法区创建一个虚方法表vtable,同样invokeinterface也会有一个接口方法表itable。使用虚方法索引来代替元数据查找提升性能。
虚方法表中存放着各个方法的实际入口地址。如果这个方法没有被子类重写,那么子类的虚方法表和父类的方法是同一个,都是父类的入口。如果重写了,自然是替换成子类的入口地址。
为了实现方便,具有相同签名的方法在父类、子类的虚方法都应该是一样的索引序号,查找的时候就可以直接从不同的虚方法表的同一索引地址搜索。方法表在类加载的连接过程完成初始化,准备了类的变量初始值,虚拟机会把该类的方法表也初始化完毕。
除了方法表,还有内联缓存,基于类继承关系分析技术的守护内联这两种非稳定的激进化手段获得更性能。
3.3 动态类型语言支持
Java是静态类型语言,知道JDK7才提供了动态类型语言的指令invokedynamic进行改进,这也是JDK8可以实现Lambda表达式的技术准备。静态类型语言在编译时就确定了类型,可以提供严谨的类型检查,利于稳定性和规模化的代码。动态类型语言在运行时确定类型,可以提高灵活性,代码会简洁清晰(比如python、PHP,但是代码量上去后维护就很痛苦了,参数具体是什么无法确定)。
Java早期在编译阶段就确定了符号引用,这就相当于确定了具体的接收者,哪怕是前面说到的invokevirtual和invokeinterface符号引用也是确定了的,只是要查找具体的类型,虽然是运行中确定实际类型,但是还是不够动态。Java要实现动态类型语言就要采取其他方式(比如编译时留个占位符类型,运行阶段动态生成字节码实现具体类型到占位符类型的适配),这样会导致实现复杂,可能带来额外的开销。这就是invokedynamic指令和java.lang.invoke包出现的背景。
invoke包是JSR-292的一个重要组成部分,这个包的主要目的是在之前单纯靠符号引用来确定调用的目标方法这种方式之外,提供一种新的动态确定目标方法的机制,称为MethodHandle。可以简单的看成函数指针。Java不像C和C++那样,可以将一个函数当成参数传递给另一个函数,普遍的做法是设置一个类包含这个函数,然后将这个类传给另一个函数。不过有了MethodHandle之后,可以拥有函数指针类似的工具了。
上面的getPrintlnMH()的步骤就模拟了一个invokevirtual指令执行的过程,根据参数返回类型方法名和实例对象找到对应的方法的直接引用,这里的表现形式就是MethonHandler了。这里就会疑惑了,这和反射有什么区别,不是一样获取方法,通过方法invoke吗?
实际上区别并不是很明显:
1.本质上都是模拟方法调用,不过反射是代码层次的方法调用,MethonHandle是字节码层次的方法调用。
2.反射的Method对象包含的信息比MethodHandle更多,方法签名、描述符、方法属性表中各种属性等,后者只有执行该方法的相关信息。反射是重量级的,MethodHandle是轻量级的。
3.MethodHandle理论上可以像虚拟机一样进行优化(目前不行),反射调用方法做不到(像黑盒)。
关键之处在于反射是为Java语言设计的,MethodHandle服务于所有虚拟机上的语言。
3.4 invokedynamic指令
invokedynamic指令与MethodHandle机制的作用是一样的,为了解决其他4条invoke指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中。不过一个是字节码层,一个是Java代码层。
每一个invokedynamic指令的位置都被称为“动态调用点”,这条指令的第一个参数不是方法符合引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量,可以获得3个信息:引导方法、方法类型和名称。引导方法有固定的参数,返回java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法的调用。
invokedynamic指令面向的使用者不是Java语言,是其他虚拟机上的动态语言,所以javac是没办法生成invokedynamic指令的字节码。