前面介绍了C/C++代码编译执行的过程,以及C++编译器如何支持面向对象的特征。本节简单介绍Java代码执行过程,JVM在执行Java代码时所做的工作,以及JVM是如何设计的。Java代码执行的过程简单可以分为以下几步:
1)Java代码被编译成字节码。
2)(可选)字节码被AOT编译器编译成可执行文件(该功能在JDK 17中被废弃)。
3)通过JVM执行字节码或者可执行文件。
JVM作为一个程序,主要包含以下功能:
1)JVM加载并解析字节码,或者JVM加载可执行文件,并解析可执行文件。为了能执行编译后的字节码,JVM中实现了一套多态处理的机制,类似于C++编译器中的虚指针、虚函数表。在介绍C++编译器时,提到了编译器把虚函数表放在数据段中,代码位于代码段。这些内容在JVM中是如何实现的?简单地说,可以认为虚函数表等信息是Java类的描述信息,而这些信息只需要保存一份即可,所以JVM设计了所谓的Klass,用于保存Java类中的描述信息。
2)JVM提供了解释执行的方案,其执行方法是把每一条字节码指令翻译成一段目标机器指令,然后执行。
3)JVM还提供了编译执行的方案,其执行方法是把一个Java函数对应的字节码或者字节码片段翻译成一段目标机器指令,在翻译的过程中还进行了编译优化,从而达到高效执行的目的。为了平衡编译时长和执行时长,JVM提供了两种编译器C1和C2。C1编译时间短但编译后的代码质量略低,C2编译时间长但编译质量高。
4)JVM提供了内存管理的功能,包括对象的快速/高效分配、垃圾回收等。
5)JVM维护和管理线程栈,最主要的原因是存在多种复杂的调用,比如Java可以调用C/C++,Java可以调用Java,C/C++本地代码也可以调用Java。
JVM整体架构图如图1-12所示。
图1-12 JVM整体架构图
下面从一个具体的实例出发,看一下Java代码是如何执行的。代码如下:
public class Example { int add(int i, int j){ return i + j; } public static void main(String [] args) { Example obj = new Example(); int result = obj.add(1,3); } }
Java代码编译成字节码由javac这个工具完成,编译后生成class文件。可以通过javap这个工具反编译字节码文件。反编译后整个文件很长,这里仅仅截取add函数和main函数相关代码片段对应的字节码。如下所示:
public class Example { // compiled from: Example.java // access flags 0x0 add(II)I L0 LINENUMBER 4 L0 ILOAD 1 ILOAD 2 IADD IRETURN L1 LOCALVARIABLE this LExample; L0 L1 0 LOCALVARIABLE i I L0 L1 1 LOCALVARIABLE j I L0 L1 2 MAXSTACK = 2 MAXLOCALS = 3 // access flags 0x9 public static main([Ljava/lang/String;)V throws java/lang/Exception L0 LINENUMBER 9 L0 NEW Example DUP INVOKESPECIAL Example.<init> ()V ASTORE 1 L1 LINENUMBER 11 L1 ALOAD 1 ICONST_1 ICONST_3 INVOKEVIRTUAL Example.add (II)I ISTORE 2 L2 LINENUMBER 13 L2 RETURN L3 LOCALVARIABLE args [Ljava/lang/String; L0 L3 0 LOCALVARIABLE obj LExample; L1 L3 1 LOCALVARIABLE result I L2 L3 2 MAXSTACK = 3 MAXLOCALS = 3 }
在这个字码片段中,add函数对应一共有4个字节码指令(ILOAD l、ILOAD 2、IADD、IRETURN),JVM在执行add函数时执行的就是这4个指令。
文件加载分为两种情况。第一种情况是JVM直接加载字节码文件,按照文件格式进行解析、链接和初始化。关于字节码的加载过程已经有很多文章和书籍介绍过,这里不赘述。
类加载完成后,Java类的描述信息已经存储在JVM的内存空间中。JVM为了存储类描述信息,设计了Klass结构,而Java的实例化对象在JVM内部使用oop结构存储。从C++的角度来理解Klass和oop,可以把JVM中的Klass对象视为C++中的Class对象(Class是类,为了描述类信息,需要对象来存储,所以称为Klass对象),oop对象是C++中Class实例化的对象。
提示
在JDK 8之前,类的描述信息存放在Java堆中,称为永久代。从JDK 8开始,类的描述信息存放在JVM的本地堆中,这一空间称为元数据空间。详情参见JEP 122提案。这一提案的出发点是为了促进JRockit(JRockit是另一款Java虚拟机的实现,后被Oracle公司收购)和JVM的融合。JRockit没有永久代,所以JRockit客户不需要配置永久代,并且习惯于不配置永久代。所以把永久代从Java堆中移到了本地堆(即元数据空间)中,元数据空间的大小受限于物理内存的大小(当内存不足时可以直接从物理内存申请),而不是Java堆的大小,所以在一定程度上减少了元数据空间不足导致的内存溢出。
回顾C++编译器对多态的支持,使用虚函数表来记录不同类实现的虚函数。这个思路在JVM中同样适用。也就是说,JVM需要在维护的Klass结构中维护虚函数表。JVM中描述Java类对象的Klass(更准确的类型是InstanceKlass)结构如图1-13所示。
图1-13 JVM中Klass结构示意图
在图1-13中,Klass中有一个vtable,等价于C++编译器中的vtbl。另外,Klass中的itable的作用类似于vtable,主要原因是Java语言只支持单继承和多接口,itable对应的就是接口的实现。Klass中还有一个oop map,这个变量与垃圾回收紧密相关,该信息用于支持精确垃圾回收,更多信息可参考第2章。除此以外,Klass还有几个重要的成员,图1-13中都已经展开介绍。
JVM文件加载的第二种情况是加载通过jaotc(JDK 9开始支持该功能)编译产生的可执行文件,加载过程实际类似于字节码,只不过文件格式不同。另外的不同点还有可执行文件中包含了一些额外的信息,这些信息用于垃圾回收、动态链接等。由于该特性已经从JDK 17中移除,因此本书不再进一步讨论。
另外,Java语言有一个特别的设计,即所有的引用类型都继承于Object类(此类是Java类库定义的基础类),Object类有5个方法是虚函数,所以任意的引用类型也都会包含这5个虚函数,并且对于引用类型定义的函数(默认函数都是虚函数,除非显式地使用final、static等修饰符限定)会添加在自己的虚函数中。Object默认的虚函数如图1-14所示。
图1-14 Object默认定义的虚函数
提示
读者可以通过诸如HSDB等工具查看Java代码中定义的虚函数。关于HSDB工具的使用可以参考其他文献。
另外,Object类中还有wait/notify等方法,它们使用final等修饰符限定后,不属于虚方法,直接编译成类似于C++语言的静态方法。JVM规范中还设计了不同的字节码用于执行不同类型的函数调用,例如使用字节码invokevirtual执行虚函数,使用字节码invokestatic执行静态函数。
在JVM规范中对于字节码的解释执行有详细的说明。从规范中可以看到,解释器主要有3个主要组件:PC、Operand Stack和Local Var,含义分别为PC是下一条执行字节码的地址、Operand Stack是解释器栈帧、Local Var是局部变量表,存放局部变量。下面以main函数调用add函数为例演示一下解释器的执行过程。
在main函数中通过invokevirtual调用add函数,在调用之前执行了3个字节码,分别是:
ALOAD 1 //将局部变量表中第1个槽位的对象放在栈中 ICONST_1 //将常量1放在操作数栈中 ICONST_3 //将常量3放在操作数栈中
在执行invokevirtual前需要将参数放入操作数栈中,参数的顺序是对象、参数1、参数2……,参数的顺序和方法描述的保持一致。此时PC、操作数栈和局部变量的状态如图1-15所示。
图1-15 main函数调用add函数前的状态
执行invokevirtual字节码进入函数add中,根据JVM规范,需要做以下动作:
1)创建新的栈帧(包含操作数栈和局部变量)。
2)将对象和参数传递到目标函数的局部变量表。
3)PC指向调用方法的首条指令。实际上这涉及函数查找过程,解释器需要从常量表中找到函数签名,然后找到执行方法的对象,从对象找到Klass信息,然后再找到虚方法,此时才能找到方法执行的起始地址。
4)执行对象的虚函数。
当进入add函数中时,PC、操作数栈和局部变量的状态如图1-16所示。
图1-16 进入add函数的状态
此处的操作数栈和局部方法表是add函数的,与main函数无关。需要注意的是,在Java源代码的编译过程中,已经知道add函数所需要的局部变量表的大小和操作数栈的大小,在上述字节码反编译代码中也可以看到这些信息,如MAXSTACK=2、MAXLOCALS=3,其中反编译代码中还有局部变量表存储的对象及对象所在的槽位(slot)。
当执行iload 1和iload 2时,PC、操作数栈和局部变量状态如图1-17所示。
图1-17 执行两个iload后的状态
当执行iadd时,根据JVM规范会将操作数栈中的两个对象弹出,然后执行add操作,并将执行的结果放入操作数栈顶。此时PC、操作数栈和局部变量的状态如图1-18所示。
图1-18 执行iadd后的状态
执行字节码ireturn时需要返回到调用者(caller)中,JVM规范中规定返回值从被调用者(callee)的栈帧出栈,然后入栈到caller的操作数栈中,callee栈帧中的其他值都被丢弃。解释器会切换至caller的栈帧,并将执行权交给caller。执行ireturn后caller的PC、操作数栈和局部变量的状态如图1-19所示。
图1-19 执行ireturn后的状态
caller(此例中为main函数)接下来执行istore 2指令,将操作数栈中的值出栈并存放在局部变量表中的第2个槽位中。PC、操作数栈和局部变量的状态如图1-20所示。
图1-20 istore执行后状态
解释器的实现也非常简单,执行过程中针对每一条字节码执行一段相应的逻辑。一个典型的解释器实现流程图如图1-21所示。
图1-21 解释器执行流程图
下面给出一个解释器实现的伪代码,使用vPC模拟程序执行下一条执行的指令,使用操作数栈模拟程序执行指令的操作数和执行结果,使用局部变量模拟store/load操作的内存空间。伪代码如下:
interpreter() { int *vPC; while(1) { switch(*vPC++) { case ICONST: int c= *vPC++; //将结果C放入操作数栈 break; case ILOAD: // 加载局部变量数据到操作数栈中 break; case ISTORE: // 将操作数栈的数据存入局部变量表 break; ... }
在伪代码中,针对每一个字节码都有一段相应的代码,通常把代码封装在一个函数中,将所有的函数组成一个分发表(dispatch table)。在执行每个字节码时,通过查询分发表执行相应的函数,就可以实现一个优雅的解释器。
对于解释执行,针对上述的Switch方式有不少的优化实践:
1)Direct Call Threading:将每条字节码用函数的方式实现,通过函数指针的方式调用每条字节码。
2)Direct Threading:在一个循环中实现每条字节码,并用Label和Goto分隔开。将每个指令从Label标记的地址开始实现。在加载阶段,将程序的字节码转换Label地址,存储到Direct Threading Table(DTT)。用vPC指向DTT的一项,表示下一条要执行的字节码。这种方式的主要问题是Goto会有分支预测失败的代价。
3)Subroutine Threading:衍生自Direct Threading,在加载解析字节码的时候生成Context Threading Table(CTT),根据CTT执行程序,可以认为是一个极简的JIT。对于非虚拟跳转有效果,但该方法无法提升虚拟跳转的性能。
4)Context Threading:衍生自Subroutine Threading,并针对虚拟跳转进行改进,相对Subroutine Threading有5%的性能提升。
更多关于解释器优化的细节可以参考相关论文。
在JVM中解释方式的实现主要是通过模板解释器完成的。在模板解释器中,每一个字节码对应一段可以执行的机器代码(本质上仍然是函数代码,但是模板解释器已经将函数使用机器码实现)。目前JVM中提供了202个字节码,在X86架构下字节码对应的机器代码如表1-1所示。
表1-1 字节码正常执行对应的解释模板表
注意
模板解释表中实际存放的是对应代码的地址(编译后位于代码区),这里为了便于理解,把代码直接放在表中。
例如,指令iload_1对应的代码如表1-1所示。这个代码的功能就是把栈中的对象加载到寄存器rax中(其中vtos和itos是栈顶执行的状态,即该指令执行完成后,栈顶存放的是一个整数。指令中iaddress(n)最终会转换成X86的地址寻址指令)。
所以,可以简单地认为JVM在执行字节码时,每一个字节码都被替换成一段目标机器的代码。
解释执行是针对每一个字节码执行一段函数,由此带来的问题是执行效率低下。提高执行效率的手段就是将解释执行转换为编译执行。由于将字节码进行编译需要花费资源和时间,一种有效的方法是仅仅针对热点代码进行编译。JVM在执行过程中如果发现一个Java的函数或者函数中的某一块代码片段(代码片段通常是控制流中的一个块,或循环代码片段)频繁地被执行,就把这个函数或者代码片段编译成一个新的函数。这样带来的好处有两个:
1)节约每一个字节码调用时的成本。
2)整个函数或者代码块可以使用编译优化的技术对代码进行进一步的优化,从而提高执行效率。
目前JVM提供的优化方案主要有两种:客户端优化(也称为C1优化)和服务器优化(也称为C2优化)。C1优化和C2优化的执行原理相同,只不过采用的优化方法不同,进而编译优化所用的时间不同,优化后代码的执行效率也不同。
另外,目前JVM的优化器都是采用C++编写的,这就带来一个问题,如果想优化Java代码,必须熟悉C++。在JDK 9中启动了一个新的项目Graal,该项目是使用Java代码编写一个优化器并替代C2优化器(Graal目前还是实验性质的项目,但有不少公司评测认为其性能优于C2优化器。但出于项目活跃度及商业考虑,该项目在JDK 17中被移除)。
JVM中编译优化的过程如图1-22所示。
图1-22 JVM中编译优化过程
这个过程一般经历三个阶段并做不同的优化,分别为:
1)高级中间语言的生成及其优化。高级中间语言一般是进行语言相关、机器无关的描述,针对特定的语言进行的优化。
2)低级中间语言的生成及其优化。低级中间语言一般是进行语言无关、机器无关的描述,这是通用的中间语言描述,常见的编译优化技术基本上都针对低级中间语言进行,例如常量折叠、死代码消除、循环不变量外提等。由于编译优化需要消耗时间和CPU等资源,因此在JVM中提供了Tiered Compilation技术,即当发现代码变成热点后首先进行简单的代码优化,这样的优化产生了初级优化的机器代码并替代原来的解释执行;如果热点代码继续被反复执行,会启动高级的编译优化,并用高级编译优化后的代码替换初级优化的机器代码。使用该技术可以在编译效率和执行效率间取得一个很好的平衡,从而提高应用整体执行的效率。关于编译优化的相关技术不在本书的介绍范围内,更多信息可以参考其他书籍或者文献。
3)目标机器代码的生成。一般是进行和目标机器相关的优化,最为典型的优化就是寄存器的分配。
当编译优化完成后,JVM将在本地堆(更为准确的地方是指JVM的CodeCache)中存储编译优化后的代码,同时把描述Java方法的Method对象(参考图1-13)和编译优化代码进行关联。当执行Java的方法时,如果发现有编译优化后的代码,则直接执行编译优化后的代码。
但是编译执行的过程非常复杂,在整个编译过程中需要考虑以下几个方面:
(1)编译的内容
虚拟机应该针对热点代码进行编译以取得最好的收益。如何定义热点代码就是关键。最简单的方式是以函数为粒度,如果发现函数被调用的次数足够多,则可以将整个函数作为待编译的内容进行编译。但实际上还有一种情况,函数本身被调用的次数很少,函数内部存在一个很大的循环,并且在循环中做复杂的运算。对于该情况最好的处理方式是编译循环相关的代码片段,但这样的处理方式会带来额外的实现难度。例如如下代码:
class Test { static int sum(int c) { int res = 0; for(int i = 0;i < c; i++) { res += i; } return res; } }
对于代码片段中的for循环执行1万次的数学运算,循环内部如果按照解释模式执行,则需要多次访问变量i,执行乘法和加法。假如函数sum本身不是热点,即函数sum本身不会由调用者触发执行编译优化,则对于函数sum中的循环优化片段,即语句res += i进行编译优化,并且可以执行优化后的代码。现代的高级虚拟机通常都支持代码片段的编译替换和执行。
(2)编译触发的时机
编译优化只有发现热点代码才能触发。如何定义代码是否是热点?一个简单的思路是代码执行的次数到达一定阈值就认为代码是热点,但实现中需要考虑更多的内容,特别是在多线程执行的情况中。如果一个线程执行一个循环,则可以通过对循环计数确定代码达到阈值从而触发编译,在这种情况下只需要一个线程局部的计数器就可以达到目的;实际中还有其他的情况,例如多个线程都会执行同一段代码,虽然每个线程执行代码的次数不多,但是多个线程加起来执行代码的次数就非常可观了,对于这样的情况,比较理想的设计是使用一个全局的计数器来记录热点代码执行的次数,而这样的设计需要考虑全局计数器的并发访问问题。需要指出的是,编译执行需要额外的计数器来记录热点代码,而维护额外的计数器不仅需要额外的空间来存储计数器,还会影响程序执行的效率。所以只有在可能出现热点代码的地方才会维护计数器,一般是在循环的回边(回边指的是循环体中跳转到循环起始位置继续执行的路径)中维护计数器。
(3)编译执行的方式
在确定好待编译的内容以后,需要考虑编译是同步执行还是异步执行。同步执行意味着应用程序需要等待编译结果完成后才能执行编译后的机器代码,异步执行意味着应用程序可以以解释的方式或者初级优化的代码继续执行,待编译完成后执行新的编译代码。
(4)编译代码的替换执行
要执行新的编译代码涉及原有栈帧到新的编译代码栈帧的切换。最简单的方式是当要执行新的编译代码时重新为新的代码构建栈帧,并将编译代码中所使用的变量作为参数传递,当编译代码执行结束后再返回原来的栈帧继续执行。当然,返回后需要更新原来栈帧的变量,这种方式也称为栈顶替换技术(On-Stack-Replacement,OSR)。继续使用上述sum函数进行演示,假设sum在执行到一定阈值后启动编译优化,并且在编译优化完成后执行编译优化后的代码。由于解释器是按照字节码顺序执行的,sum对应的字节码如下所示:
0 ICONST_0 1 ISTORE 1 // res = 0 2 ICONST_0 3 ISTORE 2 // i =0 4 ILOAD 2 // load i 5 ILOAD 0 // load c 6 IF_ICMPGE 13 // 大于阈值退出循环 7 ILOAD 1 // load res 8 ILOAD 2 // load i 9 IADD // res + i 10 ISTORE 1 // store res 11 IINC 2 1 // i++ 12 GOTO 4 // 回边,执行循环 13 ILOAD 1 // load res,然后返回 14 IRETURN
假设循环执行50次后认定代码片段为热点并对代码片段进行编译,当编译完成后执行。为了方便演示,使用字母A、L、B描述执行代码。其中L对应的是热点代码片段,编译执行时需要将L依赖或者使用的变量作为参数传递给编译后的代码,同时将L对应的代码片段进行编译。假设编译后形成函数sum_osr,函数的入参为L代码片段中使用的变量。替换执行时可以简单地构造一个函数调用,跳转到编译后的代码执行。代码执行完成后返回原来的栈帧继续执行。为了能够让原来的栈帧继续执行,通常需要知道原来栈帧执行的下一条指令的地址。整个过程的示意图如图1-23所示。
图1-23 OSR执行示意图
图中还有一个尚未解决的问题,从L处调用sum_osr时需要传递参数,那么此时参数可能有哪些?由于函数sum已经执行了部分代码,因此变量res和i已经不再是初值,并且res和i都将在编译代码中被使用,同时变量c也将在编译代码中被使用。这里假设执行50次后开始执行编译代码,所以i=50,此时res=1225(res=1+2+…+49=1225),另外,在解释执行时还会使用操作数栈,这些内容都将作为参数传递给编译优化的代码。
此外,虚拟机在执行编译优化时可能会进行一些激进的优化动作,例如根据已经执行的类的信息优化函数的调用关系。这就会带来额外的问题,如果类型信息发生变化,优化代码就会变成无效的,此时需要从编译优化后的代码切换到原来的解释执行方式(称为退优化)。退优化的过程中也涉及何时允许触发退优化,以及代码的替换执行等问题。编译优化是虚拟机中非常关键的模块,限于篇幅,本书不对编译优化展开介绍,读者可以参考其他的书籍或者文献。
注意
上述演示的是常规OSR技术。其中提到,由于JIT编译优化需要耗费资源和时间,在一些场景中需要更为轻量级的JIT。一种激进的实现是不做任何编译的JIT,也称为Level-0 JIT(简称L0 JIT)。在L0 JIT的实现中,通常的做法是重用解释器的栈帧,即L0 JIT尽可能重用解释器的数据(如有必要,仅仅保护两种执行模式不同的栈变量和寄存器)。例如,流行的JavaScript虚拟机V8 中实现了一款SparkPlug的轻量级JIT,重用了解释器Ignition的栈帧,无须额外的栈切换成本。经测试发现,相比原来的执行方式,引入SparkPlug后性能有5%~15%的提升。
最后需要指出的是,编译的执行过程和垃圾回收也有交互,即当执行垃圾回收时需要暂停编译代码的执行,这需要在编译优化的代码中考虑支持垃圾回收。关于这一内容将在第2章讨论。