10、字节码执行引擎

所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。本节将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。

一、运行时栈帧结构(重点)

栈帧(Stack Frame) 是用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈(Virtual Machine Stack)的栈元素

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

在编译程序代码的时候,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定了,因此一个栈帧需要分配多少内存不会受到运行期变量数据的影响。

活动线程中只有栈顶的栈帧是有效的,称为当前栈帧,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

在活动线程中,只有位于栈顶的栈帧才是有效的,成为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

Java 虚拟机每调用一个 Java 方法,便会创建一个栈帧。

这种栈帧有两个主要的组成部分,分别是局部变量区,以及字节码的操作数栈。这里的局部变量是广义的,除了普遍意义下的局部变量之外,它还包含实例方法的“this指针”以及方法所接收的参数。

1、局部变量表

1.1.作用

局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。程序编译完成时,就在方法的的Code属性的max_locals数据项中定义了该方法的所需分配的局部变量表的最大容量。

  • 在 Java 虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了 long、double 值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元。

  • 也就是说,boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。

当然,这种情况仅存在于局部变量,而并不会出现在存储于堆中的字段或者数组元素上。对于 byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。

1.2.存储

  1. 局部变量表的容量以变量槽(slot)为最小单位,一个slot的内存占用并不确定,但每个slot应该能存放下boolean、byte、char、short、int、float、reference或者returnAddress类型的数据,这些可以使用32位或者更小的物理内存来存放,但允许slot的长度随着处理器、操作系统或者虚拟机的不同而发生变化。

  2. 虚拟机对reference类型的要求:一是从此引用中直接或者间接地查找到对象在Java堆中的数据存放的起始索引地址;二是此引用中直接或者间接地查找到对象所属数据类型在方法区中的存储的类型信息。

  3. 对于64位数据类型(long、double),虚拟机以高位对齐的方式为其分配两个连续的slot空间。

1.3.局部变量表的使用

  • 虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型,就代表会同时使用n和n+1这两个Slot。

  • 为了节省栈帧空间,局部变量Slot可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。这样的设计会带来一些额外的副作用,比如:局部变量表作为 GC Roots的一部分,如果局部变量表中对象的引用一直存在或者没有被替换,该对象就不会被GC 回收。

局部变量不会有“准备阶段”,即不会赋予它初始值,一个局部变量如果定义了但是没有赋初始值是不能使用的。

2、操作数栈

操作数栈也常被称作操作栈,他是后入先出的栈。操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。
32位的数据类型所占栈容量为1,64位数据类型的栈容量为2。在方法执行的时候,操作数栈的深度不会超过在max_stacks中设定的最大值。

Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是说,我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算。

  • 当一个方法刚刚开始执行时,方法的操作数栈为空,在方法执行过程中,字节码指令向操作数栈写入和提取内容,也就是出栈、入栈操作。

  • 在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。

3、动态链接

每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,这个引用为了支持调用过程中动态链接(Dynamic linking)。

字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的,有一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为 静态解析,另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接。

4、方法返回地址

当一个方法被执行后,有两种方式退出这个方法:

  • 第一种是执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  • 另外一种是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。 注意:这种退出方式不会给上层调用者产生任何返回值。

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

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

5、附加信息

虚拟机规范允许虚拟机实现向栈帧中添加一些自定义的附加信息,例如与调试相关的信息等。

二、方法调用
方法调用阶段唯一的任务就是确定被调用方法的版本(调用的是哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用)这个特性为java带来了更强大的动态扩展能力。

解析

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

符合”编译器可知,运行期不变“的方法,主要是静态方法和私有方法。静态方法与类型直接相关,私有方法在外部不可能被访问,因此这两种方法不可能通过继承或别的方法重写其他版本,因此适合在类加载阶段进行解析。

在Java虚拟机中提供了5条方法调用字节码指令:

invokestatic : 调用静态方法
invokespecial: 调用实例构造器方法、私有方法、父类方法
invokevirtual: 调用所有的虚方法
invokeinterface: 调用接口方法,会在运行时在确定一个实现此接口的对象
invokedynamic: 先在运行时动态解析出点限定符所引用的方法, 然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的, 而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合条件的有静态方法、私有方法、实力构造器、父类方法4类。它们在类加载的时候就会把符号引用解析为该方法的直接引用。 这些方法称为非虚方法。final修饰的方法也是非虚方法。

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

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

虚拟机如何调用方法的内容已经讲解完毕,现在我们来探讨虚拟机是如何执行方法中的字节码指令。

1、解释执行

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

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

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

例子:如下分别使用两种指令集计算“1+1”的结果:

基于栈的指令集:

1
iconst_1 iconst_1 iadd istore_0

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

基于寄存器:

1
mov eax,1 add eax,1

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

两套指令集的优缺点:

基于栈的指令集主要的优点就是可移植性,不会受硬件的不同而受影响。而基于寄存器的指令集,程序直接依赖这些硬件寄存器,不同的硬件设备,则不可避免受到约束。而使用栈架构的指令集,用户程序不会直接使用这些寄存器,而是由虚拟机来完成与寄存器的交互,从而避免直接与硬件交互。但基于栈指令集的主要缺点是执行速度相对来说会稍慢一些。而相对的基于寄存器指令集的执行速度会相对较优。

3、基于栈的解释器执行过程

通过如下代码解释基于栈的解释器执行过程:

1
2
3
4
5
6
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}

使用javap命令后得如下反编译代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
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

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,根据这些信息总共可绘制7张图来描述程序执行过程中的代码、 操作数栈和局部变量表的变化情况:

四、字节码介绍

Java虚拟机指令由一个字节长度的、代表某种特定操作含义的数字(操作码)以及跟随其后的零至多个代表此操作所需的参数构成。

1、字节码与数据类型

Java虚拟机的指令由一个字节长度,代表着某种特定操作含义的数字(称为操作码)以及跟其随后的零至多个代表此操作所需参数(称为操作数)而构成。Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数指令都不包含操作数。因为字节码指令只有一个字节,所以指令集的操作码总数不可能超过256条。

在Java虚拟机中,大多数的指令都包含了其对操作所对应的数据类型信息。对于大部分与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务。i代表int l代表long,s代表short,b代表byte,c代表char,f代表float,a代表reference.

2、加载和存储指令

加载存储指令用于将数据在帧栈中的局部变量表和操作数栈进行来回传输。

①将一个局部变量加载到操作栈:

1
iload,iload<n>,lload,lload<n>,fload,fload<n>,dload,dload<n>,aload,aload<n>

②将一个数值从操作数栈存储到局部变量表中:

1
istore,istore<n>,lstore,lstore<n>,fstore,fstore<n>,dstore,dstore<n>,adore,adore<n>

③将一个常量加载到操作数栈中:

1
bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_m1,iconst_<i>,fconst_<i>,dconst_<i>.

④扩充局部变量的访问索引的指令。wide.

3、运算指令

运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。注意:由于没有直接支持byte,short,char和boolean类型的算术运算指令,对这种类型直接转换为int进行运算。特别需要注意的是:数据运算可能会导致溢出对象,Java虚拟机在整数没有定义数据异常,所以整数异常不报异常,注意在编程中范围。

  • 加法指令:iadd,ladd,fadd,dadd 减法指令:isub,lsub,fsub,dsub
  • 乘法指令:imul,lmul,fmul,dmul
  • 除法指令:idiv,ldiv,fdiv,ddiv
  • 求于指令:irem,lrem,frem,drem
  • 取反指令:ineg,lneg,fneg,dneg
  • 位移指令:iishl,lishl,fishl,dishl 按位或指令:ior,lor
  • 按位与指令:land,land 按位异或指令:ixor,lxor
  • 局部变量自增指令:inc 比较指令:dcmpg,dcmpl,fcmpg,fcmpl,lcmp

注意:

  1. Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常,当一个操作产生溢出时,将会使用有符号的无穷大来表示。操作结果没有明确的数学定义的话,将会时候NaN值来表示。

  2. 在处理整型数据时,只有除法指令(idiv和ldiv)以及求余指令(irem和lrem)出现除数为零时会导致虚拟机抛出异常

4、类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换,一般用于实现用户代码中的显式类型转换操作。

一、

  1. int类型到long、float或者double类型
  2. long类型到float、double类型
  3. float类型到double类型

二、

  1. 宽化类型转换指令包括:i2l, i2f, i2d, l2f, l2d, f2d
  2. 窄化类型转换:指令包括有:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级,转换过程很可能会导致数值丢失精度。

5、对象创建与访问指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。

  1. new :创建类实例的指令。
  2. newarray、anewarray、multianewarray:创建数组的指令。
  3. getstatic、putstatic、getfield、putfield:访问类字段(类变量)和实例字段(实例变量)的指令。
  4. baload、caload、saload、iaload、laload、faload、daload、aaload:把一个数组元素加载到操作数栈的指令。
  5. bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore:把一个操作数栈的值存储到数组元素中的指令。
  6. arraylength:取数组长度的指令。
  7. instanceof、checkcast:检查类实例类型的指令。

6、控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定指令而不是控制转移指令的下一条指令继续执行程序。控制转移指令包括有:

  1. ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne:条件分支。
  2. tableswitch、lookupswitch:复合条件分支。
  3. goto、goto_w、jsr、jsr_w、ret:无条件分支。

7、操作数栈管理指令

java虚拟机提供了一些用于直接操作操作数栈的指令

  1. pop、pop2:将操作数栈的栈顶一个或两个元素出栈。
  2. dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2:复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶。
  3. swap:将栈最顶端两个数值互换。

8、控制转移指令

  1. ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne:条件分支。
  2. tableswitch、lookupswitch:复合条件分支。
  3. goto、goto_w、jsr、jsr_w、ret:无条件分支。

9、方法调用和返回指令

方法调用:

  1. invokestatic:调用静态方法。
  2. invokespecial:调用实例构造器方法、私有方法和父类方法。
  3. invokevirtual:调用所有的虚方法。非虚方法以外的都是虚方法,非虚方法包括使用invokestatic、invokespecial调用的方法和被final修饰的方法。
  4. invokeinterface:调用接口方法,运行时再确定一个实现此接口的对象。
  5. invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。
    返回值指令:
    ireturn(返回值是boolean、byte、char、short、int)、lreturn、freturn、dreturn、areturn:方法返回指令。

10、异常处理指令

在程序中显式抛出异常的操作会由athrow指令实现,除了这种情况,还有别的异常会在其他Java虚拟机指令检测到异常状况时由虚拟机自动抛出。

athrow :显式抛出异常指令。

11、同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。

  • 同步一段指令集序列通常是由Java语言中的synchronized块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义.

  • monitorenter、monitorexit:支持synchronized语句块语义的指令。

五、指令

具体来说,Java 字节码中与调用相关的指令共有五种。

  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  3. invokevirtual:用于调用非私有实例方法。
  4. invokeinterface:用于调用接口方法。
  5. invokedynamic:用于调用动态方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface 客户 {
boolean isVIP();
}

class 商户 {
public double 折后价格 (double 原价, 客户 某客户) {
return 原价 * 0.8d;
}
}

class 奸商 extends 商户 {
@Override
public double 折后价格 (double 原价, 客户 某客户) {
if (某客户.isVIP()) { // invokeinterface
return 原价 * 价格歧视 (); // invokestatic
} else {
return super. 折后价格 (原价, 某客户); // invokespecial
}
}
public static double 价格歧视 () {
// 咱们的杀熟算法太粗暴了,应该将客户城市作为随机数生成器的种子。
return new Random() // invokespecial
.nextDouble() // invokevirtual
+ 0.8d;
}
}

1. 虚方法调用

Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而接口方法调用都会 被编译成 invokeinterface 指令。这两种指令,均属于Java 虚拟机中的虚方法调用。

在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。

在 Java 虚拟机中,静态绑定包括用于调用静态方法的 invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的 invokespecial 指令。如果虚方法调用指向一个标记为 final 的方法,那么 Java 虚拟机也可以静态绑定该虚方法调用的目标方法。

Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。

2. 方法表

类加载的准备阶段,它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。

这个数据结构,便是 Java 虚拟机实现动态绑定的关键所在。下面我将以 invokevirtual 所使用的虚方法表(virtual method table,vtable)为例介绍方法表的用法。invokeinterface 所使用的接口方法表(interface method table,itable)稍微复杂些,但是原理其实是类似的。

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。

这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:

其一,子类方法表中包含父类方法表中的所有方法;
其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。
我们知道,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。

在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销简直可以忽略不计

那么我们是否可以认为虚方法调用对性能没有太大影响呢?

其实是不能的,上述优化的效果看上去十分美好,但实际上仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。

3. 内联缓存

内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

即:

Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。

当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。

在针对多态的优化手段中,我们通常会提及以下三个术语。

  1. 单态(monomorphic)指的是仅有一种状态的情况。
  2. 多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)是多态的其中一种。
  3. 超多态(megamorphic)指的是更多种状态的情况。通常我们用一个具体数值来区分多态和超多态。在这个数值之下,我们称之为多态。否则,我们称之为超多态。

对于内联缓存来说,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存。单态内联缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。

  • 多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。

  • 一般来说,我们会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java 虚拟机只采用单态内联缓存。

  • 前面提到,当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定。对于内联缓存中的内容,我们有两种选择。一是替换单态内联缓存中的纪录。这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。

  • 因此,在最坏情况下,我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。

  • 另外一种选择则是劣化为超多态状态。这也是 Java 虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。

  • 虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

  • 对于极其简单的方法而言,比如说 getter/setter,这部分固定开销占据的 CPU 时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性.

六、调用指令的符号引用

在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。

符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。

对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 C 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
  3. 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。
  4. 从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。

对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 I 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 Object 类中的公有实例方法中搜索。
  3. 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

七、公有设计和私有实现

Java虚拟机规范描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。这些内容与硬件、操作系统及具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把他们看作是程序在各种Java平台实现之间互相安全的交互的手段。

理解公有设计与私有实现之间的分界线是非常有必要的,Java虚拟机实现必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的语义。拿着Java虚拟机规范一成不变的逐字实现其中要求的内容当然是一种可行的途径,但一个优秀的虚拟机实现,在满足虚拟机规范的约束下对具体实现做出修改和优化也是完全可行的,并且虚拟机规范中明确鼓励实现者这样做。只要优化后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整的保持,那实现者就可以选择任何方式去实现这些语义,虚拟机后台如何处理Class文件完全是实现者自己的事情,只要他在外部接口上看起来与规范描述的一致即可。

虚拟机实现者可以使用这种伸缩新来让Java虚拟机获得更高的性能、更低的内存消耗或者更好的可移植性,选择哪种特性取决于Java虚拟机实现的目标和关注点是什么。虚拟机实现的方式主要有以下两种:

  • 将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集。
  • 将输入的Java虚拟机代码在加载或执行时翻译成宿主CPU的本地指令集(即JIT代码生成技术)。

精确定义的虚拟机和目标文件格式不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机因被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的、新的、有趣的解决方案。