天天看点

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

一、Java代码如何运行

1. Java的执行方式都离不开JRE,也就是Java运行时环境。实际上JRE仅包含运行Java程序的必需组件,包括JVM以及Java核心类库等。JDK同样包含了JRE,并且还附带了一系列开发、诊断工具。然而运行C++代码无需额外的运行时,往往把这些代码直接编译成CPU所能理解的代码格式,也就是机器码。比如下面的中间列,就是用C语言写的Helloworld程序的编译结果。可以看到C程序编译而成的机器码就是一个个字节,它们是给机器读的。为了让开发人员也能够理解,可以用反汇编器将其转换成汇编代码,如下最右列所示:

; 最左列是偏移;中间列是给机器读的机器码;最右列是给人读的汇编代码
0x00:  55                    push   rbp
0x01:  48 89 e5              mov    rbp,rsp
0x04:  48 83 ec 10           sub    rsp,0x10
0x08:  48 8d 3d 3b 00 00 00  lea    rdi,[rip+0x3b] 
                                    ; 加载"Hello, World!\n"
0x0f:  c7 45 fc 00 00 00 00  mov    DWORD PTR [rbp-0x4],0x0
0x16:  b0 00                 mov    al,0x0
0x18:  e8 0d 00 00 00        call   0x12
                                    ; 调用printf方法
0x1d:  31 c9                 xor    ecx,ecx
0x1f:  89 45 f8              mov    DWORD PTR [rbp-0x8],eax
0x22:  89 c8                 mov    eax,ecx
0x24:  48 83 c4 10           add    rsp,0x10
0x28:  5d                    pop    rbp
0x29:  c3                    ret
           

既然C++的运行方式如此成熟,那为什么Java要在虚拟机中运行呢,JVM又是怎样运行Java代码,它的运行效率又如何?Java作为一门高级程序语言语法非常复杂,抽象程度也很高,因此直接在硬件上运行这种复杂程序并不现实。所以在运行Java程序之前,需要对其进行一番转换。当前的主流思路是设计一个面向Java语言特性的虚拟机,并通过编译器将Java程序转换成该虚拟机所能识别的指令序列,也称Java字节码。之所以这么取名,是因为Java字节码指令的操作码(opcode)被固定为一个字节。

例如下面例子的中间列,正是用Java写的Helloworld程序编译而成的字节码。可以看到它与C版本的编译结果一样,都是由一个个字节组成的。并且,同样可以将其反汇编为人类可读的代码格式。不同的是Java版本的编译结果相对精简一些。这是因为JVM相对于物理机而言抽象程度更高:

# 最左列是偏移;中间列是给虚拟机读的机器码;最右列是给人读的代码
0x00:  b2 00 02         getstatic java.lang.System.out
0x03:  12 03            ldc "Hello, World!"
0x05:  b6 00 04         invokevirtual java.io.PrintStream.println
0x08:  b1               return
           

JVM可由硬件实现,但更常见的是在各现有平台(如 Windows_x64、Linux_aarch64)上提供软件实现。这么做的意义在于,一旦程序被转换成Java字节码,便可以在不同平台上的虚拟机实现里运行。虚拟机的另外一个好处是它带来了一个托管环境(Managed Runtime)。这个托管环境能够代替程序员处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与GC。除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使程序员免于书写这些无关业务逻辑的代码。

2. 下面以JDK中的HotSpot虚拟机为例,从虚拟机以及底层硬件两个角度,讲JVM具体是怎么运行Java字节码的。从虚拟机视角来看,执行Java代码首先需要将它编译而成的class文件加载到JVM中。加载后的Java类会被存放于方法区(Method Area)中。实际运行时JVM会执行方法区内的代码,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

这和操作系统的段式内存管理中的代码段类似。而且JVM同样也在内存中划分出堆和栈来存储运行时数据。不同的是,JVM会将栈细分为面向Java方法的Java方法栈、面向本地方法(用C++写的native方法)的本地方法栈、存放各个线程执行位置的PC寄存器。

在运行过程中,每当调用进入一个Java方法,JVM会在当前线程的Java方法栈中生成一个栈帧,用来存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且JVM不要求栈帧在内存空间里连续分布。当退出当前执行的方法时,无论是正常还是异常返回,JVM均会弹出当前线程的当前栈帧并且舍弃。

从硬件视角来看,Java字节码无法直接执行,因此JVM需要将字节码翻译成机器码。在HotSpot里上述翻译过程有两种形式:(1)解释执行,即逐条将字节码翻译成机器码并执行;(2)即时编译(Just-In-Time compilation,JIT),即把一个方法中包含的所有字节码编译成机器码后再执行,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot默认采用混合模式,综合了解释执行和即时编译两者的优点,会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。

3. HotSpot采用了多种技术来提升启动性能以及峰值性能,上面提到的即时编译便是其中最重要的技术之一。即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。对于占据大部分的不常用代码,无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面对于仅占据小部分的热点代码,则可以将其编译成机器码以达到理想的运行速度。

理论上讲,即时编译后的Java程序执行效率,是可能超过C++程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。例如虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管它有很多个目标方法,但在实际运行过程中它可能只调用其中的一个。这个信息便可以被即时编译器所利用,来规避虚方法每次调用时寻找到目标方法的映射的开销,从而达到比静态编译的C++程序更高的性能。

为了满足不同用户场景的需要,HotSpot内置了多个即时编译器:C1、C2和Graal。Graal是Java 10正式引入的实验性即时编译器。之所以引入多个即时编译器,是为了在编译时间和生成代码的执行效率之间进行取舍。C1又叫Client编译器,面向的是对启动性能有要求的客户端GUI程序,采用的优化手段相对简单,因此编译时间较短。C2又叫Server编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。

从Java 7开始HotSpot默认采用分层编译的方式:热点方法首先会被C1编译,而后热点方法中的热点会进一步被C2编译。在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。

二、Java基本类型

4. Java引进了八个基本类型来支持数值计算,这么做的原因主要是使用基本类型,能够在执行效率以及内存使用两方面提升软件性能。接下来了解下基本类型在JVM中的实现。例如下面这段代码:

public class Foo {
  public static void main(String[] args) {
    boolean eaten = 2; // 直接编译javac会报错
    if (eaten) System.out.println("吃了");
    if (true == eaten) System.out.println("真吃了");
  }
}
           

直接编译这段代码编译器是会报错的。所以迂回一下,采用一个Java字节码的汇编工具,直接对字节码进行更改。那么当一个boolean变量的值是2时,它究竟是true还是false?实际运行时第一个if会回答“吃了”,而第二个if虚拟机不会回答“真吃了”。

在Java语言规范中,boolean类型的值只有用“true”和“false”来表示。但这两个符号是不能被JVM直接使用的。在JVM规范中,boolean类型则被映射成int类型,即“true”被映射为整数1,而“false”被映射为整数0。因此对于存储boolean数组的字节码,JVM需保证实际存入的值是整数1或者0。JVM规范同时也要求Java编译器遵守这个编码规则,并且用整数相关的字节码来实现逻辑运算,以及基于boolean类型的条件跳转。这样在编译成的class文件中,除了字段和传入参数外,基本看不出boolean类型的痕迹了:

# Foo.main编译后的字节码
 0: iconst_2       // 我们用AsmTools更改了这一指令
 1: istore_1
 2: iload_1
 3: ifeq 14        // 第一个if语句,即操作数栈上数值为0时跳转
 6: getstatic java.lang.System.out
 9: ldc "吃了"
11: invokevirtual java.io.PrintStream.println
14: iload_1
15: iconst_1
16: if_icmpne 27   // 第二个if语句,即操作数栈上两个数值不相同时跳转
19: getstatic java.lang.System.out
22: ldc "真吃了"
24: invokevirtual java.io.PrintStream.println
27: return
           

在上面的代码例子中,第一个if语句会被编译成条件跳转字节码ifeq,意思是如果局部变量“eaten”的值为0,那么跳过打印“吃了”的语句。而第二个if语句会被编译成条件跳转字节码if_icmpne,也就是说如果局部变量的值和整数1不相等,那么跳过打印“真吃了”的语句。可以看到Java编译器的确遵守了相同的编码规则。当然这个约束很容易绕开,除了汇编工具AsmTools外,还有许多可以修改字节码的Java库,比如ASM等。

对于JVM来说,它看到的boolean类型早已被映射为整数类型。因此将原本声明为boolean类型的局部变量,赋值为除0、1之外的整数值,在JVM看来是“合法”的。在上面例子中,经过编译器编译之后JVM看到的第一个if是在问:不会一碗饭都没吃吧。第二个if语句则变成:吃过一碗饭了吗。因为true的值是1不等于2,较真的JVM就会将第二个if语句判定为假了。

5. 除了上面提到的boolean类型外,Java的基本类型还包括整数类型byte、short、char、int和long,以及浮点类型float和double,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

Java的基本类型都有对应的值域和默认值。可以看到这八种类型的值域依次扩大,而且前面的值域被后面的值域所包含。因此从前面的基本类型转换至后面的基本类型,无需强制转换。另外一点值得注意的是,尽管它们的默认值看起来不一样,但在内存中都是0。在这些基本类型中,boolean和char是唯二的无符号类型。在不考虑违反规范的情况下,boolean类型的取值范围是0或1。char类型的取值范围则是[0, 65535]。通常可以认定char类型的值为非负数。这种特性十分有用,比如说作为数组索引等。

在前面例子中,能够将整数2存储到一个声明为boolean类型的局部变量中。其实声明为byte、char及short的局部变量也能够存储超出它们取值范围的数值,然而这些超出取值范围的数值同样会带来一些麻烦。比如声明为char类型的局部变量实际上有可能为负数。当然在正常使用Java编译器的情况下,生成的字节码会遵守JVM规范对编译器的约束,因此无须担心局部变量会超出它们的取值范围。

Java的浮点类型采用IEEE 754浮点数格式。以float为例,浮点类型通常有两个0,即+0.0F和-0.0F。前者在Java里是0,后者是符号位为1、其他位均为0的浮点数,在内存中等同于十六进制整数0x80000000(即-0.0F可通过Float.intBitsToFloat(0x80000000)求得)。尽管它们的内存数值不同,但是在Java中+0.0F == -0.0F会返回真。

在有了+0.0F和-0.0F这两个定义后,便可以定义浮点数中的正无穷及负无穷。正无穷就是任意正浮点数(不包括+0.0F)除以+0.0F得到的值,而负无穷是任意正浮点数除以-0.0F得到的值。在Java中,正无穷和负无穷是有确切的值,在内存中分别等同于十六进制整数0x7F800000和0xFF800000。

既然整数0x7F800000等同于正无穷,那么0x7F800001又对应什么浮点数呢?是NaN(Not-a-Number)。不仅如此,[0x7F800001, 0x7FFFFFFF]和[0xFF800001, 0xFFFFFFFF]范围内对应的都是NaN。当然一般计算得出的NaN比如+0.0F/+0.0F,在内存中应为0x7FC00000,这个数值称之为标准的NaN,而其他的称之为不标准的NaN。

NaN有一个有趣的特性:除了“!=”始终返回true之外,所有其他比较结果都会返回false。例如“NaN<1.0F”返回 false,“NaN>=1.0F”同样返回false。对于任意浮点数f不管它是0还是NaN,“f!=NaN”始终会返回true,而“f==NaN”始终会返回false。因此在程序里做浮点数比较的时候,需要考虑上述特性。

6. JVM每调用一个Java方法,便会创建一个栈帧。为方便理解这里只讨论供解释器用的解释栈帧(interpreted frame)。这种栈帧有两个主要组成部分,分别是局部变量区、以及字节码的操作数栈。这里局部变量是广义的,除了普遍意义下的局部变量之外,还包含实例方法的“this指针”以及方法所接收的参数。在JVM规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了long、double值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元。

也就是说,boolean、byte、char、short这四种类型,在栈上占用的空间和int是一样的,和引用类型也是一样的。因此在32位的HotSpot中,这些类型在栈上将占用4个字节;而在64位的HotSpot中它们将占8个字节。当然,这种情况仅存在于局部变量,而并不会出现在存储于堆中的字段或者数组元素上。对于byte、char、short这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节、两字节,也就是跟这些类型的值域相吻合。

因此,当把一个int类型的值存储到这些类型的字段或数组时,相当于做了一次隐式的掩码操作。例如当把0xFFFFFFFF(-1)存储到一个声明为char类型的字段里时,由于该字段仅占两字节,所以高两位的字节便会被截取掉,最终存入“\uFFFF”。boolean字段和boolean数组则比较特殊,在HotSpot中boolean字段占用一字节,而boolean数组则直接用byte数组来实现。为了保证堆中的boolean值是合法的,HotSpot在存储时显式地进行掩码操作,也就是只取最后一位的值存入boolean字段或数组中。

JVM的算数运算几乎全部依赖于操作数栈。也就是需要将堆中的boolean、byte、char及short加载到操作数栈上,然后将栈上的值当成int类型来运算。对于boolean、char这两个无符号类型来说,加载伴随着零扩展。例如char的大小为两个字节,在加载时char的值会被复制到int类型的低二字节,而高二字节则会用0来填充。对于byte、short这两个类型来说,加载伴随着符号扩展。例如short的大小为两个字节,在加载时short的值同样会被复制到int类型的低二字节。如果该short值为非负数即最高位为0,那么该int类型的值的高二字节会用0来填充,否则用1来填充。

三、JVM如何加载Java类

7. JVM中的类加载是从class文件到内存中的类,按先后顺序经过加载、链接、初始化三大步骤。其中,链接过程中需要验证,而内存中的类没有经过初始化同样不能使用。Java语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。基本类型是由JVM预先定义好的,而引用类型Java将其细分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除,因此JVM实际上只有前三种。

在类、接口和数组类中,数组类是由JVM直接生成的,其他两种则有对应的字节流。字节流最常见的形式要属由Java编译器生成的class文件。除此之外也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序Java applet)字节流。这些不同形式的字节流都会被加载到JVM中,成为类或接口。为叙述方便下面都用“类”来统称它们。无论是直接生成的数组类还是加载的类,JVM都需要对其进行链接和初始化。

接下来详细介绍每个步骤具体都在干些什么。加载,是指查找字节流并且据此创建类的过程。前面提到对于数组类来说,它并没有对应的字节流,而是由JVM直接生成的。对于其他的类来说,JVM需要借助类加载器来完成查找字节流的过程。众多类加载器有共同的“祖师爷”叫启动类加载器(bootstrap class loader),它是由C++实现的没有对应的Java对象,因此在Java中只能用null来指代,也就是祖师爷不喜欢有人打扰,小喽啰们都联系不上它。

除了启动类加载器之外,其他的类加载器都是java.lang.ClassLoader的子类,因此有对应的Java对象。这些类加载器需要先由另一个类加载器比如启动类加载器,加载至JVM中才能执行类加载。就像接到单子自己不能直接干,得先给师傅过目,师傅不接手才能自己来一样,在JVM中这个规则叫双亲委派模型:每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类时,该类加载器才会尝试去加载。

在Java 9之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在JRE的lib目录下jar包中的类,以及由虚拟机参数-Xbootclasspath指定的类。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由Java核心类库提供。

扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要但又通用的类,比如存放在JRE的lib/ext目录下jar包中的类,以及由系统变量java.ext.dirs指定的类。应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类,这里的应用程序路径便是指虚拟机参数-cp/-classpath、系统变量java.class.path、环境变量CLASSPATH所指定的路径。默认情况下,应用程序中包含的类便是由应用类加载器加载的。

Java 9引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE中除了少数几个关键模块,比如java.base是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。除了由Java核心类库提供的类加载器外,还可以加入自定义的类加载器来实现特殊的加载方式。例如可以对class文件进行加密,加载时再利用自定义的类加载器对其解密。

除了加载功能之外,类加载器还提供了命名空间的作用。在JVM中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中往往借助这一特性,来运行同一个类的不同版本。

8. 链接,是指将创建成的类合并至JVM中,使之能够执行的过程。可分为验证、准备以及解析三个阶段。验证阶段的目的,在于确保被加载类能够满足JVM的约束条件。通常Java编译器生成的类文件必然满足JVM的约束条件,除非遇到字节码注入的场景。准备阶段的目的,则是为被加载类的静态字段分配内存。Java代码中对静态字段的具体初始化,则会在稍后的初始化阶段中进行。除了分配内存外,部分JVM还会在此阶段构造其他跟类层次相关的数据结构,比如用来实现虚方法的动态绑定的方法表。

在class文件被加载至JVM之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。例如对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型、返回值类型的符号引用,来指代所要调用的方法。

解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化)。符号引用就好比“Tony的房子”这种说法,不管它是否存在,都可以用这种说法来指代Tony的房子。实际引用则好比实际的通讯地址,如果想要与Tony通信,则需要启动盖房子的过程。JVM规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

9. 在Java代码中,如果要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。如果直接赋值的静态字段被final所修饰,并且它的类型是基本类型或字符串时,该字段会被Java编译器标记成常量值(ConstantValue),其初始化直接由JVM完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被Java编译器置于同一方法中,并把它命名为<clinit>。类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行<clinit>方法的过程。JVM会通过加锁来确保类的<clinit>方法仅被执行一次。init是instance实例构造器,对非静态变量解析初始化,而clinit是class类构造器,对静态变量、静态代码块进行初始化。

只有当初始化完成之后,类才正式成为可执行的状态。那么类的初始化何时会被触发呢?JVM规范枚举了下述多种触发情况:

(1)当JVM启动时,初始化用户指定的主类;

(2)当遇到用以新建目标类实例的new指令时,初始化new指令的目标类;

(3)当遇到调用静态方法的指令时,初始化该静态方法所在的类;

(4)当遇到访问静态字段的指令时,初始化该静态字段所在的类;

(5)子类的初始化会触发父类的初始化;

(6)如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;

(7)使用反射API对某个类进行反射调用时,初始化这个类;

(8)当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。

例如下面代码:

public class Singleton {
  private Singleton() {}
  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
  }
  public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
  }
}
           

这段代码是著名的单例延迟初始化例子,只有当调用Singleton.getInstance时,程序才会访问LazyHolder.INSTANCE,才会触发对LazyHolder的初始化(上面第4种情况),继而新建一个Singleton的实例。由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个Singleton实例。

四、JVM如何执行方法调用

10. 通常不提倡可变长参数方法的重载,是因为Java编译器可能无法决定应该调用哪个目标方法。例如下面的代码:

void invoke(Object obj, Object... args) { ... }
void invoke(String s, Object obj, Object... args) { ... }

invoke(null, 1);    // 调用第二个invoke方法
invoke(null, 1, 2); // 调用第二个invoke方法
invoke(null, new Object[]{1}); // 只有手动绕开可变长参数的语法糖,才能调用第一个invoke方法
           

该API定义了两个同名的重载方法。其中第一个接收一个Object,以及声明为Object…的变长参数;而第二个则接收一个String、Object,以及声明为Object…的变长参数。这里想调用第一个方法,传入的参数为(null, 1)。照理说这种情况下编译器会报错,提示这个方法调用有二义性。然而Java编译器直接将方法调用识别为调用第二个方法,为什么呢?带着这个问题来看一看JVM是怎么识别目标方法的。

在Java程序里,如果同一个类中出现多个名字相同且参数类型相同的方法,那么它无法通过编译。也就是说正常情况下,如果想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同,这些方法之间的关系称之为重载。

当然这个限制可以通过字节码工具绕开。也就是说在编译完成之后,可以再class文件中添加方法名和参数类型相同,而返回类型不同的方法。当这种包括多个方法名相同、参数类型相同,而返回类型不同的方法的类,出现在Java编译器的用户类路径上时,是怎么确定需要调用哪个方法的呢?当前版本的Java编译器会直接选取第一个方法名以及参数类型匹配的方法。并且,它会根据所选取方法的返回类型来决定可不可以通过编译,以及需不需要进行值转换等。

重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:

(1)在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;

(2)如果在第1个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;

(3)如果在第2个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。

如果Java编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。在上面代码例子中,当传入null时既可以匹配第一个方法中声明为Object的形式参数,也可以匹配第二个方法中声明为String的形式参数。由于String是Object的子类,因此Java编译器会认为第二个方法更为贴切。除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。也就是如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中这两个方法同样构成了重载。

那么如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,这两个方法之间又是什么关系呢?如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。因为静态方法是所有对象共享即只有一份,无所谓谁重写谁。而普通方法可以存在多个,所以存在重写。

Java是一门面向对象的编程语言,它的一个重要特性便是多态。而方法重写正是多态最重要的一种体现方式:它允许子类在继承父类部分功能的同时,拥有自己独特的行为。重写调用会根据调用者的动态类型,来选取实际的目标方法。

11. 接下来来看JVM是怎么识别方法的。JVM识别方法的关键在于类名、方法名、方法描述符(method descriptor)。至于方法描述符(也叫方法签名),它是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么JVM会在类的验证阶段报错。

可以看到JVM与Java语言不同,JVM是根据名字和描述符来判断的,它允许名字与参数类型相同,但返回类型不同的方法出现在同一个类中,而Java语言不允许。对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此JVM能够准确地识别目标方法。JVM中关于方法重写的判定同样基于方法描述符。也就是说如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,JVM才会判定为重写。

对于Java语言中重写而JVM中非重写的情况,编译器会通过生成桥接方法来实现Java中的重写语义。由于对重载方法的区分在编译阶段已经完成,可以认为JVM不存在重载这一概念。因此在某些文章中,重载也被称为静态绑定(static binding)或者编译时多态(compile-time polymorphism);而重写则被称为动态绑定(dynamic binding)。这个说法在JVM语境下不完全正确,是因为某个类中的重载方法可能被它的子类所重写,因此Java编译器会将所有对非静态非私有非final(final方法不会被继承所以使用静态绑定)实例方法的调用编译为需要动态绑定的类型。

也就是说,不可被子类继承的方法(静态、私有、final方法)都会被编译成静态绑定。有可能被子类继承重写、造成需运行时判断对象实例类型后才决定调用哪个方法的,都会被编译成动态绑定。确切地说,JVM中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需在运行过程中根据调用者的动态类型来识别目标方法的情况。

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

(1)invokestatic:用于调用静态方法。

(2)invokespecial:用于调用私有实例方法、构造器,以及使用super关键字调用父类的实例方法或构造器,和所实现接口的默认方法。

(3)invokevirtual:用于调用非私有实例方法。

(4)invokeinterface:用于调用接口方法。

(5)invokedynamic:用于调用动态方法。

这里先讨论前四种。下面是一段伪代码,展示了编译生成这四种调用指令的情况:

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;
  }
}
           

在这个方法中首先会调用“客户”接口的“isVIP”方法。该调用会被编译为invokeinterface指令。如果客户是VIP,那么会调用奸商类的一个名叫“价格歧视”的静态方法,该调用会被编译为invokestatic指令。如果客户不是VIP,那么会通过super关键字调用父类的“折后价格”方法,该调用会被编译为invokespecial指令。在静态方法“价格歧视”中,会调用Random类的构造器,该调用会被编译为invokespecial指令。然后会以这个新建的Random对象为调用者,调用Random类中的nextDouble方法,该调用会被编译为invokevirutal指令。

对于invokestatic以及invokespecial而言,JVM能够直接识别具体的目标方法,因为静态方法不能被继承,编译时就可以直接确定调用。而对于invokevirtual以及invokeinterface而言,在绝大部分情况下JVM需要在执行过程中,根据调用者的动态类型来确定具体的目标方法。唯一的例外在于,如果JVM能够确定目标方法有且仅有一个,比如说目标方法被标记为final[3][4],那么它可以不通过动态类型直接确定目标方法。

12. 在编译过程中,并不知道目标方法的具体内存地址。因此Java编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字、目标方法的方法名、方法描述符。符号引用存储在class文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。下面是一个例子,利用“javap -v”打印某个类的常量池:

// 在奸商.class的常量池中,#16为接口符号引用,指向接口方法"客户.isVIP()"。而#22为非接口符号引用,指向静态方法"奸商.价格歧视()"。
$ javap -v 奸商.class ...
Constant pool:
...
  #16 = InterfaceMethodref #27.#29        // 客户.isVIP:()Z
...
  #22 = Methodref          #1.#33         // 奸商.价格歧视:()D
...
           

前面提到过,在执行使用了符号引用的字节码前,JVM需要解析这些符号引用并替换为实际引用。对于非接口符号引用,假定该符号引用所指向的类为C,则JVM会按照如下步骤进行查找:

(1)在C中查找符合名字及描述符的方法。

(2)如果没有找到,在C的父类中继续搜索,直至Object类。

(3)如果没有找到,在C所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的(因为接口中不存在私有方法,静态方法不可以被实现类继承)。并且,如果目标方法在间接实现的接口中,则需满足C与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

从这个解析算法可看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。对于接口符号引用,假定该符号引用所指向的接口为I,则JVM会按照如下步骤进行查找:

(1)在I中查找符合名字及描述符的方法。

(2)如果没有找到,在Object类中的公有实例方法中搜索。

(3)如果没有找到,则在I的超接口中搜索。这一步搜索结果的要求与上面非接口符号引用步骤(3)的要求一致。

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

13. 设计模式大量使用了虚方法来实现多态。但是虚方法的性能效率并不高。但是通常来说,JVM中虚方法调用的性能开销并不大,有些时候甚至可以完全消除。首先来看下面的代码例子:

abstract class Passenger {
  abstract void passThroughImmigration();
  @Override
  public String toString() { ... }
}
class ForeignerPassenger extends Passenger {
   @Override
   void passThroughImmigration() { /* 进外国人通道 */ }
}
class ChinesePassenger extends Passenger {
  @Override
  void passThroughImmigration() { /* 进中国人通道 */ }
  void visitDutyFreeShops() { /* 逛免税店 */ }
}

Passenger passenger = ...
passenger.passThroughImmigration();
           

那么在实际运行过程中,JVM是如何高效地确定每个Passenger实例应该去哪条通道呢?前面提到过,Java里所有非私有实例方法调用都会被编译成invokevirtual指令,而接口方法调用都会被编译成invokeinterface指令,这两种指令均属于JVM中的虚方法调用。因为JAVA里没有C++的virtual关键字,所以干脆把所有方法和接口调用都设置成虚方法。

在绝大多数情况下,JVM需要根据调用者的动态类型,来确定虚方法调用的目标方法,这个过程称之为动态绑定。那么相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。在JVM中,静态绑定包括用于调用静态方法的invokestatic指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的invokespecial指令。如果虚方法调用指向一个标记为final的方法,那么JVM也可以静态绑定该虚方法调用的目标方法。

14. JVM中采取了一种用空间换时间的策略来实现动态绑定,即为每个类生成一张方法表用来快速定位目标方法。那么方法表具体是怎样实现的呢?前面提到类加载机制的链接时,提到了类加载的准备阶段除了为静态字段分配内存之外,还会构造与该类相关联的方法表,这个数据结构便是JVM实现动态绑定的关键。下面以invokevirtual所用的虚方法表(virtual method table,vtable)为例介绍方法表的用法。invokeinterface所使用的接口方法表(interface method table,itable)稍微复杂些,但原理类似。

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:

(1)子类方法表中包含父类方法表中的所有方法;

(2)子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

前面提到,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用,实际引用将指向具体的目标方法(直接地址)。对于动态绑定的方法调用,实际引用则是方法表的索引值(间接地址,实际上不仅是索引值)。在执行过程中,JVM将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法,这个过程便是动态绑定。

在上面代码例子中,Passenger类的方法表包括两个方法,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

之所以方法表调换了toString方法和passThroughImmigration方法的位置,是因为toString方法的索引值需要与Object类(父类)中同名方法的索引值一致。为保持简洁这里不考虑Object类中的其他方法。上面代码例子的最后,实例化了具体的游客,如下所示:

Passenger passenger = ...
passenger.passThroughImmigration();
           

这里JVM工作可以想象为导航员。JVM会先根据乘客的具体类型来获取动态类型,然后获取动态类型的方法表,然后用1作为索引来查找方法表所对应的目标方法passThroughImmigration。实际上使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化Java栈帧来说,这几个内存解引用操作的开销简直可以忽略不计。

15. 那么是否可以认为虚方法调用对性能没有太大影响呢?其实不是,上述优化效果看上去美好,但实际上仅存在于解释执行、或即时编译代码的最坏情况中。这是因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。

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

(1)单态(monomorphic)指的是仅有一种状态的情况。

(2)多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)是多态的其中一种。

(3)超多态(megamorphic)指的是更多种状态的情况。通常用一个具体数值来区分多态和超多态。在这个数值之下称之为多态。否则称之为超多态。

对于内联缓存来说也有对应的单态内联缓存、多态内联缓存、超多态内联缓存。单态内联缓存便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中则直接调用对应的目标方法。多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中则调用对应的目标方法。

一般来说,会将更加热门的动态类型放在前面。在实践中大部分的虚方法调用均是单态的,也就是只有一种动态类型,为了节省内存空间JVM只采用单态内联缓存。当内联缓存没有命中的情况下,JVM需要重新使用方法表进行动态绑定。对于内联缓存中的内容有两种选择:

(1)替换单态内联缓存中的纪录。这种做法就好比CPU中的数据缓存,对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。因此在最坏情况下,是用两种不同类型的调用者轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说只有写缓存的额外开销,而没有用缓存的性能提升。

(2)退化为超多态状态。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。

加载阶段构造虚方法的数组结构的方法表,解析阶段把编译时的虚方法的符号引用解析为虚方法表中对应的下标,动态调用时从栈上获取实际类型,查找对应类型虚方法表的下标获取对应的方法,因此要求子类从父类继承方法、重写方法的下标相同。内联缓存并不是方法内联,只是缓存了调用者的实际类型和对应的目标方法,当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法,节约了查找方法表的时间,其实就节约了一次数组结构的搜索,这种节约的意义并不是非常大,所以多态内联时就直接退化为了方法表查找的方式。

这里需要明确,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧等。对于极其简单的方法比如getter/setter,这部分固定开销占据的CPU时间甚至超过了方法本身。此外在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性。

五、JVM如何处理异常

16. 抛出异常可分为显式和隐式两种。显式抛异常的主体是应用程序,指的是在程序中使用“throw”关键字,手动将异常实例抛出。隐式抛异常的主体则是JVM,指的是JVM在执行过程中碰到无法继续执行的异常状态,自动抛出异常。捕获异常则涉及了try、catch和finally代码块。在Java 中,try代码块后面可以跟着多个catch代码块,来捕获不同类型的异常。JVM会从上至下匹配异常处理器。因此前面的catch块所捕获的异常类型不能覆盖后边的,否则编译器会报错。

try代码块触发异常的情况下,如果该异常没有被捕获,finally代码块会直接运行,并且在运行之后重新抛出该异常。如果该异常被catch代码块捕获,finally代码块则在catch块之后运行。在某些情况下如果catch块也触发了异常,那么finally块同样会运行,并会抛出catch触发的异常。如果连finally代码块也触发了异常,那么只好中断当前finally代码块的执行,并往外抛异常。

在Java语言规范中,所有异常都是Throwable类或者其子类的实例。Throwable有两大直接子类:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

(1)Error,涵盖程序不应捕获的异常。当程序触发Error时,它的执行状态已经无法恢复,需要中止线程甚至是JVM。

(2)Exception,涵盖程序可能需要捕获并且处理的异常。

Exception有一个特殊的子类RuntimeException,用来表示“程序虽然无法继续执行,但还能抢救一下”的情况,数组索引越界(ArrayIndexOutOfBoundsException)便是其中的一种。RuntimeException和Error属于Java里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。在Java语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用throws关键字标注。通常情况下程序中自定义的异常应为检查异常,以便最大化利用Java编译器的编译时检查。

异常实例的构造十分昂贵。这是由于在构造异常实例时,JVM便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的Java栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字、方法所在类名、文件名、代码中的第几行触发该异常等。当然在生成栈轨迹时,JVM会忽略掉异常构造器以及填充栈帧的Java方法(Throwable.fillInStackTrace),直接从新建异常位置开始算起。此外JVM还会忽略标记为不可见的Java方法栈帧。

既然异常实例的构造十分昂贵,是否可以缓存异常实例,在需要用到的时候直接抛出呢?从语法角度上来看是允许的。然而,该异常对应的栈轨迹并非throw语句的位置,而是新建异常的位置。因此这种做法可能会误导开发人员,使其定位到错误的位置。这也是为什么在实践中,往往选择抛出新建异常实例的原因。

17. 在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由from指针、to指针、target指针、所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用来定位字节码。其中,from指针和to指针标示了该异常处理器所监控的范围,例如try代码块所覆盖的范围。target指针则指向异常处理器的起始位置,例如catch代码块的起始位置,如下所示:

public static void main(String[] args) {
  try {
    mayThrowException();
  } catch (Exception e) {
    e.printStackTrace();
  }
}

// 对应的Java字节码
public static void main(java.lang.String[]);
  Code:
    0: invokestatic mayThrowException:()V
    3: goto 11
    6: astore_1
    7: aload_1
    8: invokevirtual java.lang.Exception.printStackTrace
   11: return
  Exception table:
    from  to target type
      0   3   6  Class java/lang/Exception  // 异常表条目
           

编译过后,该方法的异常表拥有一个条目。其from指针和to指针分别为0和3,代表它的监控范围从索引为0的字节码开始,到索引为3的字节码结束(不包括3)。该条目的target指针是6,代表这个异常处理器从索引为6的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型是Exception。当程序触发异常时,JVM会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,JVM会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,JVM会将控制流转移至该条目target指针指向的字节码。

如果遍历完所有异常表条目,JVM仍未匹配到异常处理器,那么它会弹出当前方法对应的Java栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,JVM需要遍历当前线程Java栈上所有方法的异常表。finally代码块的编译比较复杂。当前版本Java编译器的做法,是复制finally代码块的内容,分别放在try-catch代码块所有正常执行路径以及异常执行路径的出口中,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

针对异常执行路径,Java编译器会生成一个或多个异常表条目,监控整个try-catch代码块,并且捕获所有种类的异常(在javap中以any指代)。这些异常表条目的target指针将指向另一份复制的finally代码块。并且在这个finally代码块的最后,Java编译器会重新抛出所捕获的异常。可以用javap工具来查看下面这段包含了try-catch-finally 代码块的编译结果:

public class Foo {
  private int tryBlock;
  private int catchBlock;
  private int finallyBlock;
  private int methodExit;

  public void test() {
    try {
      tryBlock = 0;
    } catch (Exception e) {
      catchBlock = 1;
    } finally {
      finallyBlock = 2;
    }
    methodExit = 3;
  }
}


$ javap -c Foo
...
  public void test();
    Code:
       0: aload_0
       1: iconst_0
       2: putfield      #20                 // Field tryBlock:I
       5: goto          30
       8: astore_1
       9: aload_0
      10: iconst_1
      11: putfield      #22                 // Field catchBlock:I
      14: aload_0
      15: iconst_2
      16: putfield      #24                 // Field finallyBlock:I
      19: goto          35
      22: astore_2
      23: aload_0
      24: iconst_2
      25: putfield      #24                 // Field finallyBlock:I
      28: aload_2
      29: athrow
      30: aload_0
      31: iconst_2
      32: putfield      #24                 // Field finallyBlock:I
      35: aload_0
      36: iconst_3
      37: putfield      #26                 // Field methodExit:I
      40: return
    Exception table:
       from    to  target type
           0     5     8   Class java/lang/Exception
           0    14    22   any

  ...
           

可以看到,编译结果包含三份finally代码块。其中前两份分别位于try代码块和catch代码块的正常执行路径出口。最后一份则作为异常处理器,监控try代码块以及catch代码块。它将捕获try代码块触发的、未被catch代码块捕获的异常,以及catch代码块触发的异常。这里有一个小问题,如果catch代码块捕获了异常,并且触发了另一个异常,那么finally捕获并且重抛的异常是哪个呢?答案是原本的异常便会被忽略掉,这对于代码调试来说十分不利。

18. Java 7 引入了 Suppressed 异常来解决这个问题。这个新特性允许开发人员将一个异常附于另一个异常之上。因此,抛出的异常可以附带多个异常的信息。然而,Java层面的finally代码块缺少指向所捕获异常的引用,即获取不到所要释放资源的对象的引用,所以这个新特性使用起来非常繁琐。为此Java 7专门构造了一个名为try-with-resources的语法糖,在字节码层面自动使用Suppressed异常。当然该语法糖的主要目的并不是使用Suppressed异常,而是精简资源打开关闭的用法。

在Java 7之前,对于打开的资源需要定义一个finally代码块,来确保该资源在正常或者异常执行状况下都能关闭。资源的关闭操作本身容易触发异常。因此如果同时打开多个资源,那么每一个资源都要对应一个独立的try-finally代码块,以保证每个资源都能够关闭。这样一来代码将会变得十分繁琐,如下所示:

FileInputStream in0 = null;
  FileInputStream in1 = null;
  FileInputStream in2 = null;
  ...
  try {
    in0 = new FileInputStream(new File("in0.txt"));
    ...
    try {
      in1 = new FileInputStream(new File("in1.txt"));
      ...
      try {
        in2 = new FileInputStream(new File("in2.txt"));
        ...
      } finally {
        if (in2 != null) in2.close();
      }
    } finally {
      if (in1 != null) in1.close();
    }
  } finally {
    if (in0 != null) in0.close();
  }
           

Java 7的try-with-resources语法糖,极大地简化了上述代码。程序可以在try关键字后声明并实例化实现了AutoCloseable接口的类,编译器将自动添加对应的close()操作。在声明多个AutoCloseable实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources还会使用Suppressed异常的功能,来避免原异常“被消失”,如下所示:

public class Foo implements AutoCloseable {
  private final String name;
  public Foo(String name) { this.name = name; }

  @Override
  public void close() {
    throw new RuntimeException(name);
  }

  public static void main(String[] args) {
    try (Foo foo0 = new Foo("Foo0"); // try-with-resources
         Foo foo1 = new Foo("Foo1");
         Foo foo2 = new Foo("Foo2")) {
      throw new RuntimeException("Initial");
    }
  }
}

// 运行结果:
Exception in thread "main" java.lang.RuntimeException: Initial
        at Foo.main(Foo.java:18)
        Suppressed: java.lang.RuntimeException: Foo2
                at Foo.close(Foo.java:13)
                at Foo.main(Foo.java:19)
        Suppressed: java.lang.RuntimeException: Foo1
                at Foo.close(Foo.java:13)
                at Foo.main(Foo.java:19)
        Suppressed: java.lang.RuntimeException: Foo0
                at Foo.close(Foo.java:13)
                at Foo.main(Foo.java:19)
           

除了try-with-resources语法糖之外,Java 7还支持在同一catch代码块中捕获多种异常。实际实现非常简单,生成多个异常表条目即可,如下所示:

// 在同一catch代码块中捕获多种异常
try {
  ...
} catch (SomeException | OtherException e) {
  ...
}
           

六、JVM如何实现反射

19. 反射是Java语言中一个相当重要的特性,它允许正在运行的Java程序观测、甚至修改程序的动态行为。例如可以通过Class对象枚举该类中的所有方法,还可以通过Method.setAccessible(位于java.lang.reflect包,该方法继承自AccessibleObject)绕过Java语言的访问权限,在私有方法所在类之外的地方调用该方法。反射在Java中的应用十分广泛,例如IDEA便运用了这一功能:每当敲入点号时,IDEA会根据点号前的内容,动态展示可以访问的字段或者方法,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

另一个日常应用则是Java调试器,它能够在调试过程中枚举某一对象所有字段的值。在Web开发中,经常能够接触到各种可配置的通用框架。为了保证框架的可扩展性,它们往往借助Java的反射机制,根据配置文件来加载不同的类。例如Spring框架的依赖反转(IoC)。

然而,反射有性能开销大的缺点。首先来看看方法的反射调用也就是Method.invoke,是怎么实现的,如下所示:

public final class Method extends Executable {
  ...
  public Object invoke(Object obj, Object... args) throws ... {
    ... // 权限检查
    MethodAccessor ma = methodAccessor;
    if (ma == null) {
      ma = acquireMethodAccessor();
    }
    return ma.invoke(obj, args);
  }
}
           

上面是Method.invoke的源码,会发现它实际上委派给MethodAccessor来处理。MethodAccessor是一个接口,它有两个已有的具体实现:一个是通过本地方法来实现反射调用,另一个则使用了委派模式。为方便记忆这里用“本地实现”和“委派实现”来指代这两者。

每个Method实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解,当进入了JVM内部之后,便拥有了Method实例所指向方法的具体地址。这时候反射调用无非就是将传入的参数准备好,然后调用进入目标方法,如下所示:

// v0版本
import java.lang.reflect.Method;

public class Test {
  public static void target(int i) {
    new Exception("#" + i).printStackTrace();
  }

  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    method.invoke(null, 0);
  }
}

# 不同版本的输出略有不同,这里使用了Java 10。
$ java Test
java.lang.Exception: #0
        at Test.target(Test.java:5)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl. .invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.i .invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base/java.lang.reflect.Method.invoke(Method.java:564)
        at Test.main(Test.java:131
           

从上面运行的栈轨迹(stack trace)可以看到(从底往上看),反射调用先是调用了Method.invoke,然后进入委派实现DelegatingMethodAccessorImpl,再然后进入本地实现NativeMethodAccessorImpl,最后到达目标方法。那为什么反射调用还要采取委派实现作为中间层?直接交给本地实现不行吗?其实,Java的反射调用机制还设立了另一种动态生成字节码的实现(后面称动态实现),直接使用invoke指令来调用目标方法。之所以采用委派实现,是为了能够在本地实现以及动态实现中切换,如下所示:

// 动态实现的伪代码,这里只列举了关键的调用逻辑,其实它还包括调用者检测、参数检测的字节码。
package jdk.internal.reflect;

public class GeneratedMethodAccessor1 extends ... {
  @Overrides    
  public Object invoke(Object obj, Object[] args) throws ... {
    Test.target((int) args[0]);
    return null;
  }
}
           

动态实现和本地实现相比,其运行效率要快上20倍。这是因为动态实现无需经过Java到C++(上面v0版本代码中stack trace中的native method)再到Java的切换,但由于生成字节码十分耗时,仅调用一次的情况反而是本地实现要快3到4倍。考虑到许多反射调用仅会执行一次,JVM设置了一个默认阈值15(可通过-Dsun.reflect.inflationThreshold=来调整),当某个反射调用的调用次数在15之下时,采用本地实现;当达到15时便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程称之为Inflation。

为了观察这个过程,可将上面的例子改为下面的v1版本。它会将反射调用循环20次:

// v1版本
import java.lang.reflect.Method;

public class Test {
  public static void target(int i) {
    new Exception("#" + i).printStackTrace();
  }

  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    for (int i = 0; i < 20; i++) {
      method.invoke(null, i);
    }
  }
}

# 使用-verbose:class打印加载的类
$ java -verbose:class Test
...
java.lang.Exception: #14
        at Test.target(Test.java:5)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base/java.lang.reflect.Method.invoke(Method.java:564)
        at Test.main(Test.java:12)
[0.158s][info][class,load] ...
...
[0.160s][info][class,load] jdk.internal.reflect.GeneratedMethodAccessor1 source: __JVM_DefineClass__
java.lang.Exception: #15
       at Test.target(Test.java:5)
       at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
       at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke(NativeMethodAccessorImpl.java:62)
       at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
       at java.base/java.lang.reflect.Method.invoke(Method.java:564)
       at Test.main(Test.java:12)
java.lang.Exception: #16
       at Test.target(Test.java:5)
       at jdk.internal.reflect.GeneratedMethodAccessor1 .invoke(Unknown Source)
       at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
       at java.base/java.lang.reflect.Method.invoke(Method.java:564)
       at Test.main(Test.java:12)
...
           

可以看到,在第15次(从0开始数)反射调用时,便触发了动态实现的生成。这时JVM额外加载了不少类。其中最重要的当属GeneratedMethodAccessor1(第30行)。并且从第16次反射调用开始,便切换至这个刚生成的动态实现(第40行)。反射调用的Inflation机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的。这样在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。

20. 下面便来拆解反射调用的性能开销。在上面的例子中,先后进行了Class.forName、Class.getMethod以及Method.invoke三个操作。其中Class.forName会调用本地方法,Class.getMethod则会遍历该类的公有方法。如果没有匹配到还将遍历父类的公有方法。可想而知这两个操作都非常费时。值得注意的是,以getMethod为代表的查找方法操作,会返回查找得到结果的一份拷贝。因此应当避免在热点代码中使用返回Method数组的getMethods或者getDeclaredMethods方法,以减少不必要的堆空间消耗。

在实践中,往往会在应用程序中缓存Class.forName和Class.getMethod的结果,因此下面就只关注反射调用本身的性能开销。为了比较直接调用和反射调用的性能差距,将前面的例子改为下面的v2版本。它会将反射调用循环二十亿次,此外它还将记录下每跑一亿次的时间:

// v2版本
mport java.lang.reflect.Method;

public class Test {
  public static void target(int i) {
    // 空方法
  }

  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);

    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }

      method.invoke(null, 128);
    }
  }
}
           

这里取最后五个记录的平均值,作为预热后的峰值性能。一亿次直接调用耗费的时间大约在120ms。这和不调用的时间是一致的。其原因在于这段代码属于热循环,同样会触发即时编译。并且即时编译会将对Test.target的调用内联进来,从而消除了调用的开销。下面以120ms作为基准,来比较反射调用的性能开销。由于目标方法Test.target接收一个int类型的参数,因此传入128作为反射调用的参数,测得的结果约为直接调用基准的2.7倍。先来看看在反射调用之前字节码都做了什么,如下所示:

59: aload_2                         // 加载Method对象
60: aconst_null                     // 反射调用的第一个参数null
61: iconst_1
62: anewarray Object                // 生成一个长度为1的Object数组
65: dup
66: iconst_0
67: sipush 128
70: invokestatic Integer.valueOf    // 将128自动装箱成Integer
73: aastore                         // 存入Object数组中
74: invokevirtual Method.invoke     // 反射调用
           

这里截取了循环中反射调用编译而成的字节码。可以看到这段字节码除了反射调用外,还额外做了两个操作:

(1)由于Method.invoke是一个变长参数方法,在字节码层面它的最后一个参数会是Object数组(可用javap查看)。Java编译器会在方法调用处生成一个长度为传入参数数量的Object数组,并将传入参数一一存储进该数组中。

(2)由于Object数组不能存储基本类型,Java编译器会对传入的基本类型参数进行自动装箱。

这两个操作除了带来性能开销外,还可能占用堆内存使得GC更加频繁(可以用虚拟机参数-XX:+PrintGC试试)。那么如何消除这部分开销呢?关于第二个自动装箱,Java缓存了[-128, 127]中所有整数所对应的Integer对象。当需要自动装箱的整数在这个范围内时,便返回缓存的Integer,否则需要新建一个Integer对象。因此可以将这个缓存的范围扩大至覆盖128(对应参数-Djava.lang.Integer.IntegerCache.high=128),便可以避免需要新建Integer对象的场景。

或者,可以在循环外缓存128自动装箱得到的Integer对象,并且直接传入反射调用中。这两种方法测得的结果差不多,约为直接调用基准的1.8倍。现在再回来看第一点因变长参数而自动生成的Object数组。既然每个反射调用对应的参数个数是固定的,那么可以选择在循环外新建一个Object数组,设置好参数并直接交给反射调用。改好的代码如下v3版本所示:

// v3版本
import java.lang.reflect.Method;

public class Test {
  public static void target(int i) {
    // 空方法
  }

  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);

    Object[] arg = new Object[1]; // 在循环外构造参数数组
    arg[0] = 128;

    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }

      method.invoke(null, arg);
    }
  }
}
           

测得的结果反而更糟糕了,为基准的2.9倍。这是为什么呢?如果在上一步自动装箱之后查看运行时的GC状况,会发现这段程序并不会触发GC。原因在于原本的反射调用被内联了,从而使得即时编译器中的逃逸分析将原本新建的Object数组判定为不逃逸的对象。如果一个对象不逃逸,那么即时编译器可以选择栈分配甚至是虚拟分配,也就是不占用堆空间。

如果在循环外新建数组,即时编译器无法确定这个数组会不会中途被更改,因此无法优化掉访问数组的操作,可谓是得不偿失。到目前为止最好记录是直接调用的1.8倍,那能否再进一步提升呢?上面曾提到可以关闭反射调用的Inflation机制从而取消委派实现,并且直接使用动态实现。此外,每次反射调用都会检查目标方法的权限,而这个检查同样可以在Java代码里关闭,在关闭了这两项机制之后也就得到了下面的v4版本,它测得的结果约为直接调用基准的1.3倍:

// v4版本
import java.lang.reflect.Method;

// 在运行指令中添加如下两个虚拟机参数:
// -Djava.lang.Integer.IntegerCache.high=128
// -Dsun.reflect.noInflation=true
public class Test {
  public static void target(int i) {
    // 空方法
  }

  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    method.setAccessible(true);  // 关闭权限检查

    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }

      method.invoke(null, 128);
    }
  }
}
           

21. 到上面这里,基本上把反射调用的水分都榨干了。接下来把反射调用的性能开销给提回去。首先在上面例子中,之所以反射调用能够变得这么快,主要是因为即时编译器中的方法内联。在关闭了Inflation的情况下,内联的瓶颈在于Method.invoke方法中对MethodAccessor.invoke方法的调用,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

这里先说个结论:在生产环境中往往拥有多个不同的反射调用,对应多个GeneratedMethodAccessor也就是动态实现。由于JVM的关于上述调用点的类型profile(对于invokevirtual或invokeinterface,JVM会记录下调用者的具体类型,称之为类型profile)无法同时记录这么多个类,因此可能造成所测试的反射调用没有被内联的情况,如下所示:

// v5版本
import java.lang.reflect.Method;

public class Test {
  public static void target(int i) {
    // 空方法
  }

  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    method.setAccessible(true);  // 关闭权限检查
    polluteProfile();

    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }

      method.invoke(null, 128);
    }
  }

  public static void polluteProfile() throws Exception {
    Method method1 = Test.class.getMethod("target1", int.class);
    Method method2 = Test.class.getMethod("target2", int.class);
    for (int i = 0; i < 2000; i++) {
      method1.invoke(null, 0);
      method2.invoke(null, 0);
    }
  }
  public static void target1(int i) { }
  public static void target2(int i) { }
}
           

在上面的v5版本中,在测试循环之前调用了polluteProfile()方法。该方法将反射调用另外两个方法,并且循环上2000遍。而测试循环则保持不变。测得的结果约为基准的6.7倍。也就是说只要误扰了Method.invoke方法的类型profile,性能开销便会从1.3倍上升至6.7倍。之所以这么慢除了没有内联之外,另外一个原因是逃逸分析不再起效。这时候便可以采用刚才v3版本中的解决方案,在循环外构造参数数组,并直接传递给反射调用。这样子测得的结果约为基准的5.2倍。

除此之外,还可以提高JVM关于每个调用能够记录的类型数目(对应参数-XX:TypeProfileWidth,默认值为2,这里设置为3)。最终测得的结果约为直接调用基准的2.8倍,尽管它和原本的1.3倍还有一定的差距,但比6.7倍好多了。

七、JVM如何实现invokedynamic

22. 先来回顾一下Java里的方法调用。在Java中方法调用会被编译为invokestatic、invokespecial、invokevirtual、invokeinterface四种指令。这些指令与包含目标方法类名、方法名、方法描述符的符号引用捆绑。在实际运行之前,JVM将根据这个符号引用链接到具体的目标方法。可以看到在这四种调用指令中,JVM明确要求方法调用需要提供目标方法的类名。

比起直接调用,用装饰者模式套一层马甲再调用和使用反射的方法都更复杂,执行效率也较低。为解决这个问题,Java 7引入了一条新的指令invokedynamic。该指令的调用机制抽象出调用点这一个概念,并允许应用程序将调用点链接至任意符合条件的方法上。也就是说其它四类invoke指令,都是JVM根据方法的符号引用链接到具体的目标方法,而invokedynamic是根据用户生成的callSite来链接到一个模板方法上,JVM自己并没有实现链接到具体的目标方法上,如下所示:

public static void startRace(java.lang.Object)
       0: aload_0                // 加载一个任意对象
       1: invokedynamic race     // 调用赛跑方法
           

作为invokedynamic的准备工作,Java 7引入了更底层、更灵活的方法抽象:方法句柄(MethodHandle)。方法句柄是一个强类型的,能够被直接执行的引用。该引用可以指向常规的静态方法或者实例方法,也可以指向构造器或者字段。当指向字段时,方法句柄实则指向包含字段访问字节码的虚构方法,语义上等价于目标字段的getter或setter方法。这里需要注意的是,它并不会直接指向目标字段所在类中的getter/setter,毕竟无法保证已有的getter/setter方法就是在访问目标字段。

方法句柄的类型(MethodType)是由所指向方法的参数类型以及返回类型组成的。它是用来确认方法句柄是否适配的唯一关键。当使用方法句柄时,其实并不关心方法句柄所指向方法的类名或者方法名。例如如果兔子的“赛跑”和“睡觉”方法的参数类型以及返回类型一致,那么对于兔子递过来的一个方法句柄,并不知道会是哪一个方法。方法句柄的创建是通过MethodHandles.Lookup类来完成的。它提供了多个API,既可以使用反射API中的Method来查找,也可以根据类、方法名、方法句柄类型来查找。

当使用后者这种查找方式时,用户需要区分具体的调用类型,比如说对于用invokestatic调用的静态方法,需要使用Lookup.findStatic方法;对于用invokevirtual调用的实例方法,以及用invokeinterface调用的接口方法,需要使用findVirtual方法;对于用invokespecial调用的实例方法,则需要使用findSpecial方法。调用方法句柄和原本对应的调用指令是一致的。也就是说,对于原本用invokevirtual调用的方法句柄,它也会采用动态绑定;而对于原本用invkespecial调用的方法句柄,它会采用静态绑定,如下所示:

class Foo {
  private static void bar(Object o) {
    ..
  }
  public static Lookup lookup() {
    return MethodHandles.lookup();
  }
}

// 获取方法句柄的不同方式
MethodHandles.Lookup l = Foo.lookup(); // 具备Foo类的访问权限
Method m = Foo.class.getDeclaredMethod("bar", Object.class);
MethodHandle mh0 = l.unreflect(m);

MethodType t = MethodType.methodType(void.class, Object.class);
MethodHandle mh1 = l.findStatic(Foo.class, "bar", t);
           

方法句柄同样也有权限问题。但它与反射API不同,其权限检查是在句柄的创建阶段完成的。在实际调用过程中,JVM并不会检查方法句柄的权限。如果该句柄被多次调用的话,那么与反射调用相比它将省下重复权限检查的开销。需要注意的是,方法句柄的访问权限不取决于方法句柄的创建位置,而是取决于Lookup对象的创建位置。

例如对于一个私有字段,如果Lookup对象是在私有字段所在类中获取的,那么这个Lookup对象便拥有对该私有字段的访问权限,即使是在所在类的外边,也能够通过该Lookup对象创建该私有字段的getter或者setter。由于方法句柄没有运行时权限检查,因此应用程序需要负责方法句柄的管理。一旦它发布了某些指向私有方法的方法句柄,那么这些私有方法便被暴露出去了。

23. 方法句柄的调用可分为两种:

(1)需要严格匹配参数类型的invokeExact。有多严格呢?假设一个方法句柄将接收一个Object类型的参数,如果直接传入String作为实际参数,那么方法句柄的调用会在运行时抛出方法类型不匹配的异常。正确的调用方式是将该String显式转化为Object类型。

在普通Java方法调用中,只有在选择重载方法时才会用到这种显式转化。这是因为经过显式转化后,参数的声明类型发生了改变,因此有可能匹配到不同的方法描述符,从而选取不同的目标方法。调用方法句柄也是利用同样的原理,并且涉及了一个签名多态性(signature polymorphism)的概念(这里暂且认为签名等同于方法描述符),如下所示:

public final native @PolymorphicSignature Object invokeExact(Object... args) throws Throwable;
           

方法句柄API有一个特殊的注解类@PolymorphicSignature。在碰到被它注解的方法调用时,Java编译器会根据所传入参数的声明类型来生成方法描述符,而不是采用目标方法所声明的描述符。在上面例子中,当传入的参数是String时,对应的方法描述符包含String类;而当转化为Object时,对应的方法描述符则包含Object类,如下所示:

public void test(MethodHandle mh, String s) throws Throwable {
    mh.invokeExact(s);
    mh.invokeExact((Object) s);
  }

  // 对应的Java字节码
  public void test(MethodHandle, String) throws java.lang.Throwable;
    Code:
       0: aload_1
       1: aload_2
       2: invokevirtual MethodHandle.invokeExact:(Ljava/lang/String;)V
       5: aload_1
       6: aload_2
       7: invokevirtual MethodHandle.invokeExact:(Ljava/lang/Object;)V
      10: return
           

invokeExact会确认该invokevirtual指令对应的方法描述符,和该方法句柄的类型是否严格匹配。在不匹配的情况下便会在运行时抛出异常。

(2)如果需要自动适配参数类型,那么可以选取方法句柄的第二种调用方式invoke。它同样是一个签名多态性的方法。invoke会调用MethodHandle.asType方法,生成一个适配器方法句柄对传入的参数进行适配,再调用原方法句柄。调用原方法句柄的返回值同样也会先进行适配,然后再返回给调用者。

方法句柄还支持增删改参数的操作,这些操作都是通过生成另一个方法句柄来实现的。这其中改操作就是刚介绍的MethodHandle.asType方法。删操作指的是将传入的部分参数就地抛弃,再调用另一个方法句柄,它对应的API是MethodHandles.dropArguments方法。增操作则会往传入的参数中插入额外的参数,再调用另一个方法句柄,它对应的API是MethodHandle.bindTo方法。Java 8中捕获类型的Lambda表达式便是用这种操作来实现的。

增操作还可以用来实现方法的柯里化。例如有一个指向f(x, y)的方法句柄,可以通过将x绑定为4,生成另一个方法句柄g(y) = f(4, y)。在执行过程中每当调用g(y)的方法句柄,它会在参数列表最前面插入一个4,再调用指向f(x, y)的方法句柄。

24. 下面我们来看看HotSpot虚拟机中方法句柄调用的具体实现。这里只讨论DirectMethodHandle。前面提到,调用方法句柄所使用的invokeExact或者invoke方法具备签名多态性的特性。它们会根据具体的传入参数来生成方法描述符。那么拥有这个描述符的方法实际存在吗?对invokeExact或者invoke的调用具体会进入哪个方法呢?如下代码所示:

import java.lang.invoke.*;

public class Foo {
  public static void bar(Object o) {
    new Exception().printStackTrace();
  }

  public static void main(String[] args) throws Throwable {
    MethodHandles.Lookup l = MethodHandles.lookup();
    MethodType t = MethodType.methodType(void.class, Object.class);
    MethodHandle mh = l.findStatic(Foo.class, "bar", t);
    mh.invokeExact(new Object());
  }
}
           

和查阅反射调用的方式一样,可以通过新建异常实例来查看栈轨迹。打印出来的占轨迹如下所示:

$ java Foo
java.lang.Exception
        at Foo.bar(Foo.java:5)
        at Foo.main(Foo.java:12)
           

也就是说,invokeExact的目标方法就是方法句柄指向的方法。前面提到过,invokeExact会对参数的类型进行校验,并在不匹配的情况下抛出异常。如果它直接调用了方法句柄所指向的方法,那么这部分参数类型校验的逻辑将无处安放。因此唯一的可能便是JVM隐藏了部分栈信息。当启用了-XX:+ShowHiddenFrames这个参数来打印被JVM隐藏了的栈信息时,会发现main方法和目标方法中间隔着两个貌似是生成的方法,如下所示:

$ java -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames Foo
java.lang.Exception
        at Foo.bar(Foo.java:5)
        at java.base/java.lang.invoke.DirectMethodHandle$Holder. invokeStatic(DirectMethodHandle$Holder:1000010)
        at java.base/java.lang.invoke.LambdaForm$MH000/766572210. invokeExact_MT000_LLL_V(LambdaForm$MH000:1000019)
        at Foo.main(Foo.java:12)
           

实际上,JVM会对invokeExact调用做特殊处理,调用至一个共享的、与方法句柄类型相关的特殊适配器中。这个适配器是一个LambdaForm,可以通过添加虚拟机参数将之导出成class文件(-Djava.lang.invoke.MethodHandle.DUMP_CLASS_FILES=true),如下所示:

final class java.lang.invoke.LambdaForm$MH000 {  static void invokeExact_MT000_LLLLV(jeava.lang.bject, jjava.lang.bject, jjava.lang.bject);
    Code:
        : aload_0
      1 : checkcast      #14                 //Mclass java/lang/invoke/ethodHandle
        : dup
      5 : astore_0
        : aload_32        : checkcast      #16                 //Mclass java/lang/invoke/ethodType
      10: invokestatic  I#22                 // Method java/lang/invoke/nvokers.checkExactType:(MLjava/lang/invoke/ethodHandle,;Ljava/lang/invoke/ethodType);V
      13: aload_0
      14: invokestatic   #26     I           // Method java/lang/invoke/nvokers.checkCustomized:(MLjava/lang/invoke/ethodHandle);V
      17: aload_0
      18: aload_1
      19: ainvakevirtudl #30             2   // Methodijava/lang/nvokev/ethodHandle.invokeBasic:(LLeava/lang/bject;;V
       23 return
           

可以看到在这个适配器中,它会调用Invokers.checkExactType方法来检查参数类型,然后调用Invokers.checkCustomized方法,后者会在方法句柄的执行次数超过一个阈值时进行优化(对应参数-Djava.lang.invoke.MethodHandle.CUSTOMIZE_THRESHOLD,默认值127)。最后它会调用方法句柄的invokeBasic方法。

JVM同样会对invokeBasic调用做特殊处理,这会将调用至方法句柄本身所持有的适配器中。这个适配器同样是一个LambdaForm,可以通过反射机制将其打印出来,如下所示:

// 该方法句柄持有的LambdaForm实例的toString()结果
DMH.invokeStatic_L_V=Lambda(a0:L,a1:L)=>{
  t2:L=DirectMethodHandle.internalMemberName(a0:L);
  t3:V=MethodHandle.linkToStatic(a1:L,t2:L);void}
           

这个适配器将获取方法句柄中的MemberName类型的字段,并且以它为参数调用linkToStatic方法。JVM也会对linkToStatic调用做特殊处理,它将根据传入的MemberName参数所存储的方法地址或方法表索引,直接跳转至目标方法。如下所示:

final class MemberName implements Member, Cloneable {
...
    //@Injected JVM_Method* vmtarget;
    //@Injected int         vmindex;
...
           

那么前面那个适配器中的优化又是怎么回事?实际上,方法句柄一开始持有的适配器是共享的。当它被多次调用之后,Invokers.checkCustomized方法会为该方法句柄生成一个特有的适配器。这个特有适配器会将方法句柄作为常量,直接获取其MemberName类型的字段,并继续后面的linkToStatic调用。如下所示:

final class java.lang.invoke.LambdaForm$DMH000 {
  static void invokeStatic000_LL_V(java.lang.Object, java.lang.Object);
    Code:
       0: ldc           #14                 // String CONSTANT_PLACEHOLDER_1 <<Foo.bar(Object)void/invokeStatic>>
       2: checkcast     #16                 // class java/lang/invoke/MethodHandle
       5: astore_0     // 上面的优化代码覆盖了传入的方法句柄
       6: aload_0      // 从这里开始跟初始版本一致
       7: invokestatic  #22                 // Method java/lang/invoke/DirectMethodHandle.internalMemberName:(Ljava/lang/Object;)Ljava/lang/Object;
      10: astore_2
      11: aload_1
      12: aload_2
      13: checkcast     #24                 // class java/lang/invoke/MemberName
      16: invokestatic  #28                 // Method java/lang/invoke/MethodHandle.linkToStatic:(Ljava/lang/Object;Ljava/lang/invoke/MemberName;)V
      19: return
           

可以看到,方法句柄的调用和反射调用一样都是间接调用。因此它也会面临无法内联的问题。不过与反射调用不同的是,方法句柄的内联瓶颈在于即时编译器能否将该方法句柄识别为常量。

25. 上面深入地探讨了invokedynamic所依赖的方法句柄。下面正式地介绍invokedynamic指令,讲讲它是如何生成调用点,并且允许应用程序自己决定链接至哪一个方法中的。invokedynamic是Java 7引入的一条新指令,用以支持动态语言的方法调用。具体来说它将调用点(CallSite)抽象成一个Java类,并且将原本由JVM控制的方法调用以及方法链接暴露给了应用程序。在运行过程中,每一条invokedynamic指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。

在第一次执行invokedynamic指令时,JVM会调用该指令所对应的启动方法(BootStrap Method),来生成前面提到的调用点,并且将之绑定至该invokedynamic指令中。在之后的运行过程中,JVM则会直接调用绑定的调用点所链接的方法句柄。

在字节码中,启动方法是用方法句柄来指定的。这个方法句柄指向一个返回类型为调用点的静态方法。该方法必须接收三个固定的参数,分别为一个Lookup类实例,一个用来指代目标方法名字的字符串,以及该调用点能够链接的方法句柄的类型。除了这三个必需参数之外,启动方法还可以接收若干个其他的参数,用来辅助生成调用点,或者定位所要链接的目标方法,如下所示:

import java.lang.invoke.*;

class Horse {
  public void race() {
    System.out.println("Horse.race()"); 
  }
}

class Deer {
  public void race() {
    System.out.println("Deer.race()");
  }
}

// javac Circuit.java
// java Circuit
public class Circuit {

  public static void startRace(Object obj) {
    // aload obj
    // invokedynamic race()
  }

  public static void main(String[] args) {
    startRace(new Horse());
    // startRace(new Deer());
  }
  
  public static CallSite bootstrap(MethodHandles.Lookup l, String name, MethodType callSiteType) throws Throwable {
    MethodHandle mh = l.findVirtual(Horse.class, name, MethodType.methodType(void.class));
    return new ConstantCallSite(mh.asType(callSiteType));
  }
}
           

其中便包含一个启动方法。它将接收前面提到的三个固定参数,并且返回一个链接至Horse.race方法的ConstantCallSite。这里的ConstantCallSite是一种不可以更改链接对象的调用点。除此之外,Java核心类库还提供多种可以更改链接对象的调用点,比如MutableCallSite和VolatileCallSite。这两者的区别就好比正常字段和volatile字段之间的区别。此外,应用程序还可以自定义调用点类,来满足特定的重链接需求。由于Java暂不支持直接生成invokedynamic指令,所以接下来会借助字节码工具ASM来实现这一目的,如下所示:

import java.io.IOException;
import java.lang.invoke.*;
import java.nio.file.*;

import org.objectweb.asm.*;

// javac -cp /path/to/asm-all-6.0_BETA.jar:. ASMHelper.java
// java -cp /path/to/asm-all-6.0_BETA.jar:. ASMHelper
// java Circuit
public class ASMHelper implements Opcodes {

  private static class MyMethodVisitor extends MethodVisitor {

    private static final String BOOTSTRAP_CLASS_NAME = Circuit.class.getName().replace('.', '/');
    private static final String BOOTSTRAP_METHOD_NAME = "bootstrap";
    private static final String BOOTSTRAP_METHOD_DESC = MethodType
        .methodType(CallSite.class, MethodHandles.Lookup.class, String.class, MethodType.class)
        .toMethodDescriptorString();

    private static final String TARGET_METHOD_NAME = "race";
    private static final String TARGET_METHOD_DESC = "(Ljava/lang/Object;)V";

    public final MethodVisitor mv;

    public MyMethodVisitor(int api, MethodVisitor mv) {
      super(api);
      this.mv = mv;
    }

    @Override
    public void visitCode() {
      mv.visitCode();
      mv.visitVarInsn(ALOAD, 0);
      Handle h = new Handle(H_INVOKESTATIC, BOOTSTRAP_CLASS_NAME, BOOTSTRAP_METHOD_NAME, BOOTSTRAP_METHOD_DESC, false);
      mv.visitInvokeDynamicInsn(TARGET_METHOD_NAME, TARGET_METHOD_DESC, h);
      mv.visitInsn(RETURN);
      mv.visitMaxs(1, 1);
      mv.visitEnd();
    }
  }

  public static void main(String[] args) throws IOException {
    ClassReader cr = new ClassReader("Circuit");
    ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
    ClassVisitor cv = new ClassVisitor(ASM6, cw) {
      @Override
      public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
          String[] exceptions) {
        MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
        if ("startRace".equals(name)) {
          return new MyMethodVisitor(ASM6, visitor);
        }
        return visitor;
      }
    };
    cr.accept(cv, ClassReader.SKIP_FRAMES);

    Files.write(Paths.get("Circuit.class"), cw.toByteArray());
  }
}
           

不用理解上面这段代码的具体含义,只须了解它会更改同一目录下Circuit类的startRace(Object)方法,使之包含invokedynamic指令,执行所谓的赛跑方法,如下所示:

public static void startRace(java.lang.Object);
         0: aload_0
         1: invokedynamic #80,  0 // race:(Ljava/lang/Object;)V
         6: return
           

会发现该指令所调用的赛跑方法的描述符,和Horse.race方法或者Deer.race方法的描述符并不一致。这是因为invokedynamic指令最终调用的是方法句柄,而方法句柄会将调用者当成第一个参数。因此刚提到的那两个方法恰恰符合这个描述符所对应的方法句柄类型。到目前为止,已经可以通过invokedynamic调用Horse.race方法了。为了支持调用任意类的race方法,这里实现了一个简单的单态内联缓存,如果调用者的类型命中缓存中的类型,便直接调用缓存中的方法句柄,否则便更新缓存:

// 需更改ASMHelper.MyMethodVisitor中的BOOTSTRAP_CLASS_NAME
import java.lang.invoke.*;

public class MonomorphicInlineCache {

  private final MethodHandles.Lookup lookup;
  private final String name;

  public MonomorphicInlineCache(MethodHandles.Lookup lookup, String name) {
    this.lookup = lookup;
    this.name = name;
  }

  private Class<?> cachedClass = null;
  private MethodHandle mh = null;

  public void invoke(Object receiver) throws Throwable {
    if (cachedClass != receiver.getClass()) {
      cachedClass = receiver.getClass();
      mh = lookup.findVirtual(cachedClass, name, MethodType.methodType(void.class));
    }
    mh.invoke(receiver);
  }

  public static CallSite bootstrap(MethodHandles.Lookup l, String name, MethodType callSiteType) throws Throwable {
    MonomorphicInlineCache ic = new MonomorphicInlineCache(l, name);
    MethodHandle mh = l.findVirtual(MonomorphicInlineCache.class, "invoke", MethodType.methodType(void.class, Object.class));
    return new ConstantCallSite(mh.bindTo(ic));
  }
}
           

可以看到,尽管invokedynamic指令调用的是所谓的race方法,但是实际上返回了一个链接至名为“invoke”的方法的调用点。由于调用点仅要求方法句柄的类型能够匹配,因此这个链接是合法的。不过这正是invokedynamic的目的,也就是将调用点与目标方法的链接交由应用程序来做,并且依赖于应用程序对目标方法进行验证。所以,如果应用程序将赛跑方法链接至兔子的睡觉方法,那也只能怪应用程序自己了。

26. 在Java 8中,Lambda表达式也是借助invokedynamic来实现的。具体来说,Java编译器利用invokedynamic指令来生成实现了函数式接口的适配器。这里的函数式接口指的是仅包括一个非default接口方法的接口,一般通过@FunctionalInterface注解。不过就算是没有使用该注解,Java编译器也会将符合条件的接口辨认为函数式接口。如下所示:

int x = ..
IntStream.of(1, 2, 3).map(i -> i * 2).map(i -> i * x);
           

上面这段代码会对IntStream中的元素进行两次映射。映射方法map所接收的参数是IntUnaryOperator(是一个函数式接口)。也就是说在运行过程中需要将i->i*2和i->i*x这两个Lambda表达式转化成IntUnaryOperator的实例。这个转化过程便是由invokedynamic来实现的。在编译过程中Java编译器会对Lambda表达式进行解语法糖(desugar),生成一个方法来保存Lambda表达式的内容。该方法的参数列表不仅包含原本Lambda表达式的参数,还包含它所捕获的变量(方法引用,如Horse::race则不会生成生成额外的方法)。

在上面例子中,第一个Lambda表达式没有捕获其他变量,而第二个Lambda表达式(i->i*x)则会捕获局部变量x。这两个Lambda表达式对应的方法如下所示,可以看到所捕获的变量同样也会作为参数传入生成的方法之中:

// i -> i * 2
  private static int lambda$0(int);
    Code:
       0: iload_0
       1: iconst_2
       2: imul
       3: ireturn

  // i -> i * x
  private static int lambda$1(int, int);
    Code:
       0: iload_1
       1: iload_0
       2: imul
       3: ireturn
           

第一次执行invokedynamic指令时,它所对应的启动方法会通过ASM来生成一个适配器类。这个适配器类实现了对应的函数式接口,在上面例子中也就是IntUnaryOperator。启动方法的返回值是一个ConstantCallSite,其链接对象为一个返回适配器类实例的方法句柄。根据Lambda表达式是否捕获其他变量,启动方法生成的适配器类以及所链接的方法句柄都不同。

如果该Lambda表达式没有捕获其他变量,那么可以认为它是上下文无关的。因此启动方法将新建一个适配器类的实例,并且生成一个特殊的方法句柄,始终返回该实例。如果该Lambda表达式捕获了其他变量,那么每次执行该invokedynamic指令,都要更新这些捕获了的变量以防止它们发生了变化。另外为了保证Lambda表达式的线程安全,无法共享同一个适配器类的实例。因此在每次执行invokedynamic指令时,所调用的方法句柄都需要新建一个适配器类实例。

在这种情况下,启动方法生成的适配器类将包含一个额外的静态方法,来构造适配器类的实例。该方法将接收这些捕获的参数,并且将它们保存为适配器类实例的实例字段。可以通过参数-Djdk.internal.lambda.dumpProxyClasses=/DUMP/PATH导出这些具体的适配器类。这里导出了上面例子中两个Lambda表达式对应的适配器类,如下所示:

// i->i*2 对应的适配器类
final class LambdaTest$$Lambda$1 implements IntUnaryOperator {
 private LambdaTest$$Lambda$1();
  Code:
    0: aload_0
    1: invokespecial java/lang/Object."<init>":()V
    4: return

 public int applyAsInt(int);
  Code:
    0: iload_1
    1: invokestatic LambdaTest.lambda$0:(I)I
    4: ireturn
}

// i->i*x 对应的适配器类
final class LambdaTest$$Lambda$2 implements IntUnaryOperator {
 private final int arg$1;

 private LambdaTest$$Lambda$2(int);
  Code:
    0: aload_0
    1: invokespecial java/lang/Object."<init>":()V
    4: aload_0
    5: iload_1
    6: putfield arg$1:I
    9: return

 private static java.util.function.IntUnaryOperator get$Lambda(int);
  Code:
    0: new LambdaTest$$Lambda$2
    3: dup
    4: iload_0
    5: invokespecial "<init>":(I)V
    8: areturn

 public int applyAsInt(int);
  Code:
    0: aload_0
    1: getfield arg$1:I
    4: iload_1
    5: invokestatic LambdaTest.lambda$1:(II)I
    8: ireturn
}
           

可以看到,捕获了局部变量的Lambda表达式多出了一个get$Lambda的方法。启动方法会把所返回的调用点链接至指向该方法的方法句柄。也就是说每次执行invokedynamic指令时,都会调用至这个方法中,并构造一个新的适配器类实例。

27. 那么这个多出来的新建实例会对程序性能造成影响吗?再次找出之前测试反射调用性能开销的那段代码,将其改造成使用Lambda表达式的v6版本,如下所示:

// v6版本
import java.util.function.IntConsumer;

public class Test {
  public static void target(int i) { }

  public static void main(String[] args) throws Exception {
    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }

      ((IntConsumer) j -> Test.target(j)).accept(128);
      // ((IntConsumer) Test::target.accept(128);
    }
  }
}
           

测量结果显示,它与直接调用的性能并无太大的区别。也就是说即时编译器能够将转换Lambda表达式所使用的invokedynamic,以及对IntConsumer.accept方法的调用统统内联进来,最终优化为空操作。其实不难理解:Lambda表达式所使用的invokedynamic将绑定一个ConstantCallSite,其链接的目标方法无法改变,因此即时编译器会将该目标方法直接内联进来。对于这类没有捕获变量的Lambda表达式而言,目标方法只完成了一个动作,便是加载缓存的适配器类常量。

另一方面,对IntConsumer.accept方法的调用实则是对适配器类的accept方法的调用。如果查看accept方法对应的字节码,会发现它仅包含一个方法调用,调用至Java编译器在解Lambda语法糖时生成的方法。该方法的内容便是Lambda表达式的内容,也就是直接调用目标方法Test.target。将这几个方法调用内联进来之后,原本对accept方法的调用则会被优化为空操作。

下面将上面代码更改为带捕获变量的v7版本。理论上每次调用invokedynamic指令,JVM都会新建一个适配器类的实例。然而实际运行结果还是与直接调用的性能一致,如下所示:

// v7版本
import java.util.function.IntConsumer;

public class Test {
  public static void target(int i) { }

  public static void main(String[] args) throws Exception {
    int x = 2;

    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }

      ((IntConsumer) j -> Test.target(x + j)).accept(128);
    }
  }
}
           

显然,即时编译器的逃逸分析又将该新建实例给优化掉了。可以通过参数-XX:-DoEscapeAnalysis来关闭逃逸分析,这时候测得的值约为直接调用的2.5倍。尽管逃逸分析能够去除这些额外的新建实例开销,但是它也不是时时奏效。它需要同时满足两件事:

(1)invokedynamic指令所执行的方法句柄能够内联;

(2)接下来的对accept方法的调用也能内联。

只有这样,逃逸分析才能判定该适配器实例不逃逸。否则会在运行过程中不停地生成适配器类实例。所以,应当尽量使用非捕获的Lambda表达式,而捕获外部变量的lambda表达式,每次执行时都要生成一个新的适配器实例。

八、Java对象内存布局

28. 在Java程序中拥有多种新建对象的方式。除了最常见的new语句之外,还可以通过反射机制、Object.clone方法、反序列化、Unsafe.allocateInstance方法来新建对象。其中Object.clone方法、反序列化通过直接复制已有的数据,来初始化新建对象的实例字段。Unsafe.allocateInstance方法则没有初始化实例字段,而new语句、反射机制则通过调用构造器来初始化实例字段。

例如new语句,它编译成的字节码将包含用来请求内存的new指令,以及用来调用构造器的invokespecial指令,如下所示:

// Foo foo = new Foo(); 编译而成的字节码
  0 new Foo
  3 dup
  4 invokespecial Foo()
  7 astore_1
           

提到构造器就要提到Java对构造器的诸多约束。首先,如果一个类没有定义任何构造器的话, Java编译器会自动添加一个无参数的构造器,如下所示:

// Foo类构造器会调用其父类Object的构造器
public Foo();
  0 aload_0 [this]
  1 invokespecial java.lang.Object() [8]
  4 return
           

然后,子类的构造器需要调用父类的构造器。如果父类存在无参数构造器,则该调用可以是隐式的,也就是Java编译器会自动添加对父类构造器的调用。但是如果父类没有无参数构造器,那么子类的构造器需要显式地调用父类带参数的构造器。显式调用又可分为两种:

(1)直接使用“super”关键字调用父类构造器。super构造器作为第一条语句。

(2)使用“this”关键字调用同一个类中的其他构造器。其他构造器中包含super关键字。

无论是直接的显式调用,还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段(也可以通过调用其他生成参数的方法,或者字节码注入来绕开)。总之当调用一个构造器时,它将优先调用父类的构造器直至Object类。这些构造器的调用者皆为同一对象,也就是通过new指令新建而来的对象。

29. 所以通过new指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存。这些字段在内存中的具体分布是怎样的?下面就来看看对象的内存布局。

在JVM中,每个Java对象都有一个对象头(object header),由标记字段和类型指针所构成。其中标记字段用来存储JVM有关该对象的运行数据,如哈希码、GC信息、锁信息,而类型指针则指向该对象的类。在64位的JVM中,对象头的标记字段占64位,而类型指针又占了64位。也就是说每一个Java对象在内存中的额外开销就是128/8=16个字节。以Integer类为例,它仅有一个int类型的私有字段,占4个字节,因此每一个Integer对象的额外内存开销至少是16/4=4倍,这也是为什么Java要引入基本类型的原因之一。

为了尽量较少对象的内存使用量,64位JVM引入了压缩指针的概念(对应选项-XX:+UseCompressedOops,默认开启),将堆中原本64位的Java对象指针压缩成32位的。这样一来,对象头中的类型指针也会被压缩成32位,使得对象头的大小从16字节降至12字节。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。

那么压缩指针是什么原理呢?比如路上停着的全是房车,而且每辆房车恰好占据两个停车位。也就是说,停在0号、1号停车位上的叫0号车,停在2号、3号停车位上的叫1号车,依次类推。原本的内存寻址用的是车位号,比如有一个值为6的指针代表第6个车位,那么沿着这个指针可以找到3号车,现在规定指针里存的值是车号,比如3指代3号车。当需要查找3号车时,便可以将该指针的值乘以2,再沿着6号车位找到3号车。

这样一来,32位压缩指针最多可以标记2的32次方辆车,对应着2的33次方个车位。当然房车也有大小之分,大房车占据的车位可能是三个甚至是更多。不过这并不会影响这个寻址算法:只需跳过部分车号,便可以保持原本车号*2的寻址方法。上述模型有一个前提,就是每辆车都从偶数号车位停起,这个概念称之为内存对齐(对应虚拟机选项-XX:ObjectAlignmentInBytes,默认值为8,压缩指针要求JVM堆中对象的起始地址要对齐至8的倍数)。

在默认情况下,JVM中的32位压缩指针可以寻址到2的35次方个字节,也就是32GB的地址空间,超过32GB则会关闭压缩指针。在对压缩指针解引用时,需要将其左移3位再加上一个固定偏移量,便可以得到能够寻址32GB地址空间的伪64位指针了。此外,可以通过配置刚提到的内存对齐选项(-XX:ObjectAlignmentInBytes)来进一步提升寻址范围。但是这同时也可能增加对象间填充,导致压缩指针没有达到原本节省空间的效果。

例如,如果规定每辆车都需要从偶数车位号停起,那么对于占据两个车位的小房车刚刚好,而对于需要三个车位的大房车来说也仅是浪费一个车位。但是如果规定需要从4的倍数号车位停起,那么小房车则会浪费两个车位,而大房车至多可能浪费三个车位。当然,就算是关闭了压缩指针,JVM还是会进行内存对齐。此外,内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如JVM要求long、double字段、非压缩指针状态下的引用字段地址,为8的倍数。

字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段,也就是说该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行,这两种情况对程序的执行效率都是不利的。

30. 下面来介绍一下对象内存布局另一个有趣的特性:字段重排列。顾名思义,就是JVM重新分配字段的先后顺序,以达到内存对齐的目的。JVM中有三种排列方法(对应选项-XX:FieldsAllocationStyle,默认值为1),但都会遵循如下两个规则:

(1)如果一个字段占据C个字节,那么该字段的偏移量需要对齐至N*C。这里偏移量指的是字段地址与对象的起始地址差值。以long类为例,它仅有一个long类型的实例字段。在使用了压缩指针的64位虚拟机中,尽管对象头的大小为12个字节,该long类型字段的偏移量也只能是16,而中间空着的4个字节便会被浪费掉。

(2)子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。在具体实现中,JVM还会对齐子类字段的起始位置。对于使用了压缩指针的64位虚拟机,子类第一个字段需要对齐至4N;而对于关闭了压缩指针的64位虚拟机,子类第一个字段则需要对齐至8N。如下所示:

class A {
  long l;
  int i;
}

class B extends A {
  long l;
  int i;
}
           

下面分别打印了B类在启用和未启用压缩指针时,各个字段的偏移量:

# 启用压缩指针时,B类的字段分布
B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION
      0     4        (object header)
      4     4        (object header)
      8     4        (object header)
     12     4    int A.i                                       0
     16     8   long A.l                                       0
     24     8   long B.l                                       0
     32     4    int B.i                                       0
     36     4        (loss due to the next object alignment)
           

当启用压缩指针时,可以看到JVM将A类的int字段放置于long字段之前,以填充因long字段对齐造成的4字节缺口。由于对象整体大小需要对齐至8N,因此对象的最后会有4字节的空白填充。

# 关闭压缩指针时,B类的字段分布
B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION
      0     4        (object header)
      4     4        (object header)
      8     4        (object header)
     12     4        (object header)
     16     8   long A.l
     24     4    int A.i
     28     4        (alignment/padding gap)                  
     32     8   long B.l
     40     4    int B.i
     44     4        (loss due to the next object alignment)
           

当关闭压缩指针时,B类字段的起始位置需对齐至8N,这样B类字段的前后各有4字节的空白。

Java 8还引入了一个新的注释@Contended,用来解决对象字段之间的虚共享(false sharing)问题。这个注释也会影响到字段的排列。虚共享是怎么回事呢?假设两个线程分别访问同一对象中不同的volatile字段,逻辑上它们并没有共享内容,因此不需要同步。然而,如果这两个字段恰好在同一个缓存行中,那么对任一字段的写操作会导致该缓存行的失效和写回,也就造成了实质上的共享。JVM会让不同的@Contended字段处于独立的缓存行中,因此会看到大量的空间被浪费掉,padding部分空间让这样的字段各自独占缓存行。

九、垃圾回收

31. JVM的自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。不过既然是自动机制,肯定没法做到像手动回收那般精准高效 ,而且还会带来不少与垃圾回收实现相关的问题。在JVM中垃圾指的是死亡的对象所占据的堆空间。那么如何辨别一个对象是存是亡?

先讲一种古老的辨别方法:引用计数法(reference counting)。做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,便可以被回收了。具体实现是:如果有一个引用被赋值为某一对象,那么将该对象的引用计数器+1;如果一个指向某一对象的引用被赋值为其他值,那么将该对象的引用计数器-1。也就是说需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。

除了需要额外空间来存储计数器以及繁琐的更新操作,引用计数法还有一个重大漏洞,那便是无法处理循环引用对象。例如假设对象a与b相互引用,除此之外没有其他引用指向a或b。这种情况下a和b实际上已经死了,但由于它们的引用计数器皆不为0,在引用计数法认为这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

目前JVM的主流垃圾回收器采取的是可达性分析算法。这个算法实质在于将一系列GC Roots作为初始的存活对象合集(live set),然后从该合集出发探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程也称为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。GC Roots可暂时理解为由堆外指向堆内的引用,一般而言GC Roots包括(但不限于)如下几种:

(1)Java方法栈桢中的局部变量;

(2)已加载类的静态变量;

(3)JNI handles;

(4)已启动且未停止的Java线程。

可达性分析可以解决引用计数法所不能解决的循环引用问题。例如即便对象a和b相互引用,只要从GC Roots出发无法到达a或b,那么可达性分析便不会将它们加入存活对象合集之中。虽然可达性分析的算法本身很简单,但是在实践中还是有不少其他问题需要解决。比如在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为null)或者漏报(将引用设置为未被访问过的对象)。误报并没有什么伤害,JVM至多损失部分GC的机会。漏报则比较麻烦,因为可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致JVM崩溃。

32. 那么怎么解决这个漏报崩溃问题呢?在JVM里传统的GC算法采用的是一种简单粗暴的方式,就是Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。JVM中的Stop-the-world是通过安全点(safepoint)机制来实现的。当JVM收到Stop-the-world请求,便会等待所有的线程都到达安全点,才允许请求Stop-the-world的线程进行独占的工作。

当然,安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态,在这个执行状态下JVM的堆栈不会发生变化。这样垃圾回收器便能够“安全”地执行可达性分析。例如,当Java程序通过JNI执行native代码时,如果这段代码不访问Java对象、调用Java方法或者返回至原Java方法,那么JVM的堆栈不会发生改变,也就代表着这段C代码可以作为同一个安全点。只要不离开这个安全点,JVM便能够在垃圾回收的同时,继续运行这段native代码。

由于本地代码需要通过JNI的API来完成上述三个操作,因此JVM仅需在API的入口处进行安全点检测(safepoint poll),测试是否有其他线程请求停留在安全点里,就可以在必要时挂起当前线程。除了执行JNI本地代码外,Java线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码、线程阻塞。阻塞的线程由于处于JVM线程调度器的掌控之下,因此属于安全点。

其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,GC线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了GC的暂停时间。对于解释执行来说,字节码与字节码之间皆可作为安全点,JVM采取的做法是当有安全点请求时,执行一条字节码便进行一次安全点检测。执行即时编译器生成的机器码则比较复杂。由于这些代码直接运行在底层硬件之上,不受JVM掌控,因此在生成机器码时即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。HotSpot的做法是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。

那么为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测呢?原因主要有两个:

(1)安全点检测本身也有一定的开销。不过HotSpot已经将机器码中的安全点检测简化为一个内存访问操作。在有安全点请求的情况下,JVM会将安全点检测访问的内存所在页设置为不可读,并且定义一个segfault处理器,来截获因访问该不可读内存而触发segfault的线程,并将它们挂起。

(2)即时编译器生成的机器码打乱了原本栈桢上的对象分布状况。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举GC Roots。由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。

不过,不同的即时编译器插入安全点检测的位置也可能不同。以Graal为例,除上述位置外,它还会在计数循环的循环回边处插入安全点检测。其他的虚拟机也可能选取方法入口而非方法出口来插入安全点检测。不管如何,其目的都是在可接受的性能开销以及内存开销之内,避免机器码长时间不进入安全点的情况,间接地减少GC的暂停时间。除了GC之外,JVM其他一些对堆栈内容的一致性有要求的操作也会用到安全点这一机制。

33. 当标记完所有的存活对象时,便可以进行死亡对象的回收工作了。主流的基础回收方式可分为三种:

(1)清除(sweep),即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

清除这种回收方式的原理很简单,但是有两个缺点。一是会造成内存碎片。由于JVM的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。另一个则是分配效率较低。如果是一块连续的内存空间,那么可以通过指针加法(pointer bumping)来做分配。而对于空闲列表JVM则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

(2)压缩(compact),即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

(3)复制(copy),即把内存区域分为两等分,分别用两个指针from和to来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to指针指向的内存区域中,并且交换from指针和to指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

当然,现代的垃圾回收器往往会综合上述几种回收方式,综合它们优点的同时规避它们的缺点。

34. 大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长一段时间。这样的现象和理念造就了JVM的分代回收思想。简单来说就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时则将其移动到老年代。

JVM可以给不同代使用不同的回收算法。对于新生代,猜测大部分的Java对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能在新生代被回收掉。对于老年代,猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。这时JVM往往需要做一次全堆扫描(Full GC),耗时也将不计成本(当然现代的垃圾回收器都在并发收集道路上发展,来避免这种全堆扫描的情况)。

关于新生代的Minor GC,首先来看看JVM中的堆具体是怎么划分的。新生代又被划分为Eden区,以及两个大小相同的Survivor区。默认情况下JVM采取的是一种动态分配策略(对应虚拟机参数-XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。当然也可以通过参数-XX:SurvivorRatio来固定这个比例,如下图所示:

JVM基本原理笔记一、Java代码如何运行二、Java基本类型三、JVM如何加载Java类四、JVM如何执行方法调用五、JVM如何处理异常六、JVM如何实现反射七、JVM如何实现invokedynamic八、Java对象内存布局九、垃圾回收

但是需要注意,其中一个Survivor区会一直为空,因此比例越低浪费的堆空间将越高,因为SurvivorRatio代表eden区域和两个survivor区域的内存占比,比如-XX:SurvivorRatio=6说明每个survivor区域和eden区域的内存比例为1:6,也就是说每个survivor区域占用的内存是新生代内存大小的1/8(新生代中有两个survivor区域,8 = 1(survivor) + 1(survivor) + 6(eden))。 这个值越小比如2,那么说明每个survivor区域就会占用到新生代内存大小的 1/4(1+1+2),而又因为其中一个survivor一定是空的,所以浪费的空间就会加大。比如新生代大小为8G,当survivorRatio = 6时浪费的空间为1G,当survivorRatio = 2时浪费的空间为2G。

通常当调用new指令时,它会在Eden区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里划空间是需要进行同步(synchronized)的。否则将有可能出现两个对象共用一段内存的事故。就像停车一样,JVM的解决方法是为每个司机预先申请多个停车位,并且只允许该司机停在自己的停车位上。那当司机的停车位用完了怎么办呢?答案是再申请多个停车位便可以了。这项技术被称之为TLAB(Thread Local Allocation Buffer,对应虚拟机参数-XX:+UseTLAB,默认开启)。具体来说每个线程可以向JVM申请一段连续的内存,比如2048字节作为线程私有的TLAB。也就是线程内存预分配,类似于数据库ID预取。

这个操作需要加锁,线程需要维护两个指针(实际可能更多,但重要的就两个),一个指向TLAB中空余内存的起始位置,一个则指向TLAB末尾。接下来的new指令,便可以直接通过指针加法(bump up the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB已经没有足够的空间来满足本次新建操作,这时需要当前线程重新申请新的TLAB。

当Eden区的空间耗尽时,JVM便会触发一次Minor GC,来收集新生代的垃圾。存活下来的对象则会被送到Survivor区。前面提到新生代共有两个Survivor区,分别用from和to来指代。其中to指向的Survivior区是空的。当发生Minor GC时,Eden区和from指向的Survivor区中的存活对象会被复制到to指向的Survivor区中,然后交换from和to指针,以保证下一次Minor GC时to指向的Survivor区还是空的。

JVM会记录Survivor区中的对象一共被来回复制了几次。如果一个对象被复制的次数为15(对应虚拟机参数-XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外如果单个Survivor区已经被占用了50%(对应虚拟机参数-XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。总之,当发生Minor GC时应用了标记-复制算法,将Survivor区中的老存活对象晋升到老年代,然后将剩下的存活对象和Eden区的存活对象复制到另一个Survivor区中。理想情况下Eden区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记-复制算法的效果极好。

Minor GC的另外一个好处是不用对整个堆进行垃圾回收。但是却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说在标记存活对象时,需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots。

35. 如果如上所述,岂不是又做了一次全堆扫描?HotSpot给出的解决方案是叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在那么就认为这张卡是脏的。在进行Minor GC时,便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,JVM便会将所有脏卡的标识位清零。

这个表和具体的垃圾回收器是相关的,针对CMS垃圾回收器只会在老年代的堆里面记录卡表,老年代的堆中指向对新生代的引用,扫描时需要扫描老年代。老年代的空间比较大,同时带来的扫描卡表的消耗机会比较多,这种机制叫做单向卡表。为了解决该问题,G1垃圾回收器有一个双向卡表的机制,不但老年代的卡表会指向引用的新生代,新生代被老年代引用也会被记录在新生代的卡表中。

在Minor GC之前,并不能确保脏卡中包含指向新生代对象的引用,其原因和如何设置卡的标识位有关。首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么JVM需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。这个操作在解释执行器中比较容易实现,但是在即时编译器生成的机器码中,则需要插入额外的逻辑,这也就是所谓的写屏障(write barrier,不要和volatile字段的写屏障混淆)。

写屏障需要尽可能地保持简洁。这是因为并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀不可放过,一律当成可能指向新生代对象的引用。这样写屏障便可精简为下面的伪代码:

CARD_TABLE [this address >> 9] = DIRTY;
           

这里右移9位相当于除以512,JVM便是通过这种方式来从地址映射到卡表中的索引的。最终这段代码会被编译成一条移位指令和一条存储指令。虽然写屏障不可避免地带来一些开销,但是它能够加大Minor GC的吞吐率( 应用运行时间 / (应用运行时间 + 垃圾回收时间) ),总的来说还是值得的。不过在高并发环境下,写屏障又带来了虚共享(false sharing)问题。但上面对象内存布局中提到的虚共享问题,讲的是几个volatile字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。

在HotSpot中,卡表是通过byte数组来实现的。对于一个64字节的缓存行来说,如果用它来加载部分卡表,那么它将对应64张卡也就是32KB的内存。如果同时有两个Java线程在这32KB内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。为此,HotSpot引入了一个新的参数-XX:+UseCondCardMark来尽量减少写卡表的操作。其伪代码如下所示:

if (CARD_TABLE [this address >> 9] != DIRTY) 
  CARD_TABLE [this address >> 9] = DIRTY;
           

而由于G1的出现,CMS这种并发的标记-清除算法在Java 9中已被废弃。G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上它已经打乱了前面所说的堆结构,直接将堆分成多个区域。每个区域都可以充当Eden区、Survivor区或者老年代中的一个。它采用的是标记-压缩算法,而且和CMS一样都能够在应用程序运行过程中并发地进行垃圾回收。G1能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域,这也是G1名字的由来。