mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
156
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/01 | Java代码是怎么运行的?.md
Normal file
156
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/01 | Java代码是怎么运行的?.md
Normal file
@@ -0,0 +1,156 @@
|
||||
<audio id="audio" title="01 | Java代码是怎么运行的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/0f/01b3f837908f53801442791f3101440f.mp3"></audio>
|
||||
|
||||
我们学院的一位教授之前去美国开会,入境的时候海关官员就问他:既然你会计算机,那你说说你用的都是什么语言吧?
|
||||
|
||||
教授随口就答了个Java。海关一看是懂行的,也就放行了,边敲章还边说他们上学那会学的是C+。我还特意去查了下,真有叫C+的语言,但是这里海关官员应该指的是C++。
|
||||
|
||||
事后教授告诉我们,他当时差点就问海关,是否知道Java和C++在运行方式上的区别。但是又担心海关官员拿他的问题来考别人,也就没问出口。那么,下次你去美国,不幸地被海关官员问这个问题,你懂得如何回答吗?
|
||||
|
||||
作为一名Java程序员,你应该知道,Java代码有很多种不同的运行方式。比如说可以在开发工具中运行,可以双击执行jar文件运行,也可以在命令行中运行,甚至可以在网页中运行。当然,这些执行方式都离不开JRE,也就是Java运行时环境。
|
||||
|
||||
实际上,JRE仅包含运行Java程序的必需组件,包括Java虚拟机以及Java核心类库等。我们Java程序员经常接触到的JDK(Java开发工具包)同样包含了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要在虚拟机中运行呢,Java虚拟机具体又是怎样运行Java代码的呢,它的运行效率又如何呢?
|
||||
|
||||
今天我便从这几个问题入手,和你探讨一下,Java执行系统的主流实现以及设计决策。
|
||||
|
||||
## 为什么Java要在虚拟机里运行?
|
||||
|
||||
Java作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此,直接在硬件上运行这种复杂的程序并不现实。所以呢,在运行Java程序之前,我们需要对其进行一番转换。
|
||||
|
||||
这个转换具体是怎么操作的呢?当前的主流思路是这样子的,设计一个面向Java语言特性的虚拟机,并通过编译器将Java程序转换成该虚拟机所能识别的指令序列,也称Java字节码。这里顺便说一句,之所以这么取名,是因为Java字节码指令的操作码(opcode)被固定为一个字节。
|
||||
|
||||
举例来说,下图的中间列,正是用Java写的Helloworld程序编译而成的字节码。可以看到,它与C版本的编译结果一样,都是由一个个字节组成的。
|
||||
|
||||
并且,我们同样可以将其反汇编为人类可读的代码格式(如下图的最右列所示)。不同的是,Java版本的编译结果相对精简一些。这是因为Java虚拟机相对于物理机而言,抽象程度更高。
|
||||
|
||||
```
|
||||
# 最左列是偏移;中间列是给虚拟机读的机器码;最右列是给人读的代码
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
Java虚拟机可以由硬件实现[1],但更为常见的是在各个现有平台(如Windows_x64、Linux_aarch64)上提供软件实现。这么做的意义在于,一旦一个程序被转换成Java字节码,那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的“一次编写,到处运行”。
|
||||
|
||||
虚拟机的另外一个好处是它带来了一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收,这部分内容甚至催生了一波垃圾回收调优的业务。
|
||||
|
||||
除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。
|
||||
|
||||
## Java虚拟机具体是怎样运行Java字节码的?
|
||||
|
||||
下面我将以标准JDK中的HotSpot虚拟机为例,从虚拟机以及底层硬件两个角度,给你讲一讲Java虚拟机具体是怎么运行Java字节码的。
|
||||
|
||||
从虚拟机视角来看,执行Java代码首先需要将它编译而成的class文件加载到Java虚拟机中。加载后的Java类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。
|
||||
|
||||
如果你熟悉X86的话,你会发现这和段式内存管理中的代码段类似。而且,Java虚拟机同样也在内存中划分出堆和栈来存储运行时数据。
|
||||
|
||||
不同的是,Java虚拟机会将栈细分为面向Java方法的Java方法栈,面向本地方法(用C++写的native方法)的本地方法栈,以及存放各个线程执行位置的PC寄存器。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/77/ab5c3523af08e0bf2f689c1d6033ef77.png" alt="" />
|
||||
|
||||
在运行过程中,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且Java虚拟机不要求栈帧在内存空间里连续分布。
|
||||
|
||||
当退出当前执行的方法时,不管是正常返回还是异常返回,Java虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。
|
||||
|
||||
从硬件视角来看,Java字节码无法直接执行。因此,Java虚拟机需要将字节码翻译成机器码。
|
||||
|
||||
在HotSpot里面,上述翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/3b/5ee351091464de78eed75438b6f9183b.png" alt="" />
|
||||
|
||||
前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
|
||||
|
||||
## Java虚拟机的运行效率究竟是怎么样的?
|
||||
|
||||
HotSpot采用了多种技术来提升启动性能以及峰值性能,刚刚提到的即时编译便是其中最重要的技术之一。
|
||||
|
||||
即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。
|
||||
|
||||
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。
|
||||
|
||||
理论上讲,即时编译后的Java程序的执行效率,是可能超过C++程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。
|
||||
|
||||
举个例子,我们知道虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管它有很多个目标方法,但在实际运行过程中它可能只调用其中的一个。
|
||||
|
||||
这个信息便可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的C++程序更高的性能。
|
||||
|
||||
为了满足不同用户场景的需要,HotSpot内置了多个即时编译器:C1、C2和Graal。Graal是Java 10正式引入的实验性即时编译器,在专栏的第四部分我会详细介绍,这里暂不做讨论。
|
||||
|
||||
之所以引入多个即时编译器,是为了在编译时间和生成代码的执行效率之间进行取舍。C1又叫做Client编译器,面向的是对启动性能有要求的客户端GUI程序,采用的优化手段相对简单,因此编译时间较短。
|
||||
|
||||
C2又叫做Server编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。
|
||||
|
||||
从Java 7开始,HotSpot默认采用分层编译的方式:热点方法首先会被C1编译,而后热点方法中的热点会进一步被C2编译。
|
||||
|
||||
为了不干扰应用的正常运行,HotSpot的即时编译是放在额外的编译线程中进行的。HotSpot会根据CPU的数量设置编译线程的数目,并且按1:2的比例配置给C1及C2编译器。
|
||||
|
||||
在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我简单介绍了Java代码为何在虚拟机中运行,以及如何在虚拟机中运行。
|
||||
|
||||
之所以要在虚拟机中运行,是因为它提供了可移植性。一旦Java代码被编译为Java字节码,便可以在不同平台上的Java虚拟机实现上运行。此外,虚拟机还提供了一个代码托管的环境,代替我们处理部分冗长而且容易出错的事务,例如内存管理。
|
||||
|
||||
Java虚拟机将运行时内存区域划分为五个部分,分别为方法区、堆、PC寄存器、Java方法栈和本地方法栈。Java程序编译而成的class文件,需要先加载至方法区中,方能在Java虚拟机中运行。
|
||||
|
||||
为了提高运行效率,标准JDK中的HotSpot虚拟机采用的是一种混合执行的策略。
|
||||
|
||||
它会解释执行Java字节码,然后会将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。
|
||||
|
||||
HotSpot装载了多个不同的即时编译器,以便在编译时间和生成代码的执行效率之间做取舍。
|
||||
|
||||
下面我给你留一个小作业,通过观察两个条件判断语句的运行结果,来思考Java语言和Java虚拟机看待boolean类型的方式是否不同。
|
||||
|
||||
下载asmtools.jar [2] ,并在命令行中运行下述指令(不包含提示符$):
|
||||
|
||||
```
|
||||
$ echo '
|
||||
public class Foo {
|
||||
public static void main(String[] args) {
|
||||
boolean flag = true;
|
||||
if (flag) System.out.println("Hello, Java!");
|
||||
if (flag == true) System.out.println("Hello, JVM!");
|
||||
}
|
||||
}' > Foo.java
|
||||
$ javac Foo.java
|
||||
$ java Foo
|
||||
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm.1
|
||||
$ awk 'NR==1,/iconst_1/{sub(/iconst_1/, "iconst_2")} 1' Foo.jasm.1 > Foo.jasm
|
||||
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
|
||||
$ java Foo
|
||||
|
||||
|
||||
```
|
||||
|
||||
[1] : [https://en.wikipedia.org/wiki/Java_processor](https://en.wikipedia.org/wiki/Java_processor)<br />
|
||||
[2]: [https://wiki.openjdk.java.net/display/CodeTools/asmtools](https://wiki.openjdk.java.net/display/CodeTools/asmtools)
|
||||
|
||||
|
||||
161
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/02 | Java的基本类型.md
Normal file
161
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/02 | Java的基本类型.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<audio id="audio" title="02 | Java的基本类型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/08/b34a839c56ed9151f116d11f11f4c308.mp3"></audio>
|
||||
|
||||
如果你了解面向对象语言的发展史,那你可能听说过Smalltalk这门语言。它的影响力之大,以至于之后诞生的面向对象语言,或多或少都借鉴了它的设计和实现。
|
||||
|
||||
在Smalltalk中,所有的值都是对象。因此,许多人认为它是一门纯粹的面向对象语言。
|
||||
|
||||
Java则不同,它引进了八个基本类型,来支持数值计算。Java这么做的原因主要是工程上的考虑,因为使用基本类型能够在执行效率以及内存使用两方面提升软件性能。
|
||||
|
||||
今天,我们就来了解一下基本类型在Java虚拟机中的实现。
|
||||
|
||||
```
|
||||
public class Foo {
|
||||
public static void main(String[] args) {
|
||||
boolean 吃过饭没 = 2; // 直接编译的话javac会报错
|
||||
if (吃过饭没) System.out.println("吃了");
|
||||
if (true == 吃过饭没) System.out.println("真吃了");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上一篇结尾的小作业里,我构造了这么一段代码,它将一个boolean类型的局部变量赋值为2。为了方便记忆,我们给这个变量起个名字,就叫“吃过饭没”。
|
||||
|
||||
赋值语句后边我设置了两个看似一样的if语句。第一个if语句,也就是直接判断“吃过饭没”,在它成立的情况下,代码会打印“吃了”。
|
||||
|
||||
第二个if语句,也就是判断“吃过饭没”和true是否相等,在它成立的情况下,代码会打印“真吃了”。
|
||||
|
||||
当然,直接编译这段代码,编译器是会报错的。所以,我迂回了一下,采用一个Java字节码的汇编工具,直接对字节码进行更改。
|
||||
|
||||
那么问题就来了:当一个boolean变量的值是2时,它究竟是true还是false呢?
|
||||
|
||||
如果你跑过这段代码,你会发现,问虚拟机“吃过饭没”,它会回答“吃了”,而问虚拟机“真(==)吃过饭没”,虚拟机则不会回答“真吃了”。
|
||||
|
||||
那么虚拟机到底吃过没,下面我们来一起分析一下这背后的细节。
|
||||
|
||||
## Java虚拟机的boolean类型
|
||||
|
||||
首先,我们来看看Java语言规范以及Java虚拟机规范是怎么定义boolean类型的。
|
||||
|
||||
在Java语言规范中,boolean类型的值只有两种可能,它们分别用符号“true”和“false”来表示。显然,这两个符号是不能被虚拟机直接使用的。
|
||||
|
||||
在Java虚拟机规范中,boolean类型则被映射成int类型。具体来说,“true”被映射为整数1,而“false”被映射为整数0。这个编码规则约束了Java字节码的具体实现。
|
||||
|
||||
举个例子,对于存储boolean数组的字节码,Java虚拟机需保证实际存入的值是整数1或者0。
|
||||
|
||||
Java虚拟机规范同时也要求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,翻译成人话就是说,如果局部变量“吃过饭没”的值为0,那么跳过打印“吃了”的语句。
|
||||
|
||||
而第二个if语句则会被编译成条件跳转字节码if_icmpne,也就是说,如果局部变量的值和整数1不相等,那么跳过打印“真吃了”的语句。
|
||||
|
||||
可以看到,Java编译器的确遵守了相同的编码规则。当然,这个约束很容易绕开。除了我们小作业中用到的汇编工具AsmTools外,还有许多可以修改字节码的Java库,比如说ASM [[1] ](https://asm.ow2.io/)等。
|
||||
|
||||
对于Java虚拟机来说,它看到的boolean类型,早已被映射为整数类型。因此,将原本声明为boolean类型的局部变量,赋值为除了0、1之外的整数值,在Java虚拟机看来是“合法”的。
|
||||
|
||||
在我们的例子中,经过编译器编译之后,Java虚拟机看到的不是在问“吃过饭没”,而是在问“吃过几碗饭”。也就是说,第一个if语句变成:你不会一碗饭都没吃吧。第二个if语句则变成:你吃过一碗饭了吗。
|
||||
|
||||
如果我们约定俗成,每人每顿只吃一碗,那么第二个if语句还是有意义的。但如果我们打破常规,吃了两碗,那么较真的Java虚拟机就会将第二个if语句判定为假了。
|
||||
|
||||
## Java的基本类型
|
||||
|
||||
除了上面提到的boolean类型外,Java的基本类型还包括整数类型byte、short、char、int和long,以及浮点类型float和double。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/45/77dfb788a8ad5877e77fc28ed2d51745.png" alt="" />
|
||||
|
||||
Java的基本类型都有对应的值域和默认值。可以看到,byte、short、int、long、float以及double的值域依次扩大,而且前面的值域被后面的值域所包含。因此,从前面的基本类型转换至后面的基本类型,无需强制转换。另外一点值得注意的是,尽管他们的默认值看起来不一样,但在内存中都是0。
|
||||
|
||||
在这些基本类型中,boolean和char是唯二的无符号类型。在不考虑违反规范的情况下,boolean类型的取值范围是0或者1。char类型的取值范围则是[0, 65535]。通常我们可以认定char类型的值为非负数。这种特性十分有用,比如说作为数组索引等。
|
||||
|
||||
在前面的例子中,我们能够将整数2存储到一个声明为boolean类型的局部变量中。那么,声明为byte、char以及short的局部变量,是否也能够存储超出它们取值范围的数值呢?
|
||||
|
||||
答案是可以的。而且,这些超出取值范围的数值同样会带来一些麻烦。比如说,声明为char类型的局部变量实际上有可能为负数。当然,在正常使用Java编译器的情况下,生成的字节码会遵守Java虚拟机规范对编译器的约束,因此你无须过分担心局部变量会超出它们的取值范围。
|
||||
|
||||
Java的浮点类型采用IEEE 754浮点数格式。以float为例,浮点类型通常有两个0,+0.0F以及-0.0F。
|
||||
|
||||
前者在Java里是0,后者是符号位为1、其他位均为0的浮点数,在内存中等同于十六进制整数0x8000000(即-0.0F可通过Float.intBitsToFloat(0x8000000)求得)。尽管它们的内存数值不同,但是在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。
|
||||
|
||||
因此,我们在程序里做浮点数比较的时候,需要考虑上述特性。在本专栏的第二部分,我会介绍这个特性给向量化比较带来什么麻烦。
|
||||
|
||||
## Java基本类型的大小
|
||||
|
||||
在第一篇中我曾经提到,Java虚拟机每调用一个Java方法,便会创建一个栈帧。为了方便理解,这里我只讨论供解释器使用的解释栈帧(interpreted frame)。
|
||||
|
||||
这种栈帧有两个主要的组成部分,分别是局部变量区,以及字节码的操作数栈。这里的局部变量是广义的,除了普遍意义下的局部变量之外,它还包含实例方法的“this指针”以及方法所接收的参数。
|
||||
|
||||
在Java虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了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字段或数组中。
|
||||
|
||||
讲完了存储,现在我来讲讲加载。Java虚拟机的算数运算几乎全部依赖于操作数栈。也就是说,我们需要将堆中的boolean、byte、char以及short加载到操作数栈上,而后将栈上的值当成int类型来运算。
|
||||
|
||||
对于boolean、char这两个无符号类型来说,加载伴随着零扩展。举个例子,char的大小为两个字节。在加载时char的值会被复制到int类型的低二字节,而高二字节则会用0来填充。
|
||||
|
||||
对于byte、short这两个类型来说,加载伴随着符号扩展。举个例子,short的大小为两个字节。在加载时short的值同样会被复制到int类型的低二字节。如果该short值为非负数,即最高位为0,那么该int类型的值的高二字节会用0来填充,否则用1来填充。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了Java里的基本类型。
|
||||
|
||||
其中,boolean类型在Java虚拟机中被映射为整数类型:“true”被映射为1,而“false”被映射为0。Java代码中的逻辑运算以及条件跳转,都是用整数相关的字节码来实现的。
|
||||
|
||||
除boolean类型之外,Java还有另外7个基本类型。它们拥有不同的值域,但默认值在内存中均为0。这些基本类型之中,浮点类型比较特殊。基于它的运算或比较,需要考虑+0.0F、-0.0F以及NaN的情况。
|
||||
|
||||
除long和double外,其他基本类型与引用类型在解释执行的方法栈帧中占用的大小是一致的,但它们在堆中占用的大小确不同。在将boolean、byte、char以及short的值存入字段或者数组单元时,Java虚拟机会进行掩码操作。在读取时,Java虚拟机则会将其扩展为int类型。
|
||||
|
||||
今天的动手环节,你可以观测一下,将boolean类型的值存入字段中时,Java虚拟机所做的掩码操作。
|
||||
|
||||
你可以将下面代码中boolValue = true里的true换为2或者3,看看打印结果与你的猜测是否相符合。
|
||||
|
||||
熟悉Unsafe的同学,可以使用Unsafe.putBoolean和Unsafe.putByte方法,看看还会不会做掩码操作。
|
||||
|
||||
```
|
||||
public class Foo {
|
||||
static boolean boolValue;
|
||||
public static void main(String[] args) {
|
||||
boolValue = true; // 将这个true替换为2或者3,再看看打印结果
|
||||
if (boolValue) System.out.println("Hello, Java!");
|
||||
if (boolValue == true) System.out.println("Hello, JVM!");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
157
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/03 | Java虚拟机是如何加载Java类的?.md
Normal file
157
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/03 | Java虚拟机是如何加载Java类的?.md
Normal file
@@ -0,0 +1,157 @@
|
||||
<audio id="audio" title="03 | Java虚拟机是如何加载Java类的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/76/fa41d2233af9f067cba722170d36f976.mp3"></audio>
|
||||
|
||||
听我的意大利同事说,他们那边有个习俗,就是父亲要帮儿子盖栋房子。
|
||||
|
||||
这事要放在以前还挺简单,亲朋好友搭把手,盖个小砖房就可以住人了。现在呢,整个过程要耗费好久的时间。首先你要请建筑师出个方案,然后去市政部门报备、验证,通过后才可以开始盖房子。盖好房子还要装修,之后才能住人。
|
||||
|
||||
盖房子这个事,和Java虚拟机中的类加载还是挺像的。从class文件到内存中的类,按先后顺序需要经过加载、链接以及初始化三大步骤。其中,链接过程中同样需要验证;而内存中的类没有经过初始化,同样不能使用。那么,是否所有的Java类都需要经过这几步呢?
|
||||
|
||||
我们知道Java语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。在上一篇中,我已经详细介绍过了Java的基本类型,它们是由Java虚拟机预先定义好的。
|
||||
|
||||
至于另一大类引用类型,Java将其细分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除(我会在专栏的第二部分详细介绍),因此Java虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由Java虚拟机直接生成的,其他两种则有对应的字节流。
|
||||
|
||||
说到字节流,最常见的形式要属由Java编译器生成的class文件。除此之外,我们也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序Java applet)字节流。这些不同形式的字节流,都会被加载到Java虚拟机中,成为类或接口。为了叙述方便,下面我就用“类”来统称它们。
|
||||
|
||||
无论是直接生成的数组类,还是加载的类,Java虚拟机都需要对其进行链接和初始化。接下来,我会详细给你介绍一下每个步骤具体都在干些什么。
|
||||
|
||||
## 加载
|
||||
|
||||
加载,是指查找字节流,并且据此创建类的过程。前面提到,对于数组类来说,它并没有对应的字节流,而是由Java虚拟机直接生成的。对于其他的类来说,Java虚拟机则需要借助类加载器来完成查找字节流的过程。
|
||||
|
||||
以盖房子为例,村里的Tony要盖个房子,那么按照流程他得先找个建筑师,跟他说想要设计一个房型,比如说“一房、一厅、四卫”。你或许已经听出来了,这里的房型相当于类,而建筑师,就相当于类加载器。
|
||||
|
||||
村里有许多建筑师,他们等级森严,但有着共同的祖师爷,叫启动类加载器(bootstrap class loader)。启动类加载器是由C++实现的,没有对应的Java对象,因此在Java中只能用null来指代。换句话说,祖师爷不喜欢像Tony这样的小角色来打扰他,所以谁也没有祖师爷的联系方式。
|
||||
|
||||
除了启动类加载器之外,其他的类加载器都是java.lang.ClassLoader的子类,因此有对应的Java对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至Java虚拟机中,方能执行类加载。
|
||||
|
||||
村里的建筑师有一个潜规则,就是接到单子自己不能着手干,得先给师傅过过目。师傅不接手的情况下,才能自己来。在Java虚拟机中,这个潜规则有个特别的名字,叫双亲委派模型。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。
|
||||
|
||||
在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引入了模块系统,并且略微更改了上述的类加载器[1](https://docs.oracle.com/javase/9/migrate/toc.htm#JSMIG-GUID-A868D0B9-026F-4D46-B979-901834343F9E)。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE中除了少数几个关键模块,比如说java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
|
||||
|
||||
除了由Java核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对class文件进行加密,加载时再利用自定义的类加载器对其解密。
|
||||
|
||||
除了加载功能之外,类加载器还提供了命名空间的作用。这个很好理解,打个比方,咱们这个村不讲究版权,如果你剽窃了另一个建筑师的设计作品,那么只要你标上自己的名字,这两个房型就是不同的。
|
||||
|
||||
在Java虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
|
||||
|
||||
## 链接
|
||||
|
||||
链接,是指将创建成的类合并至Java虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。
|
||||
|
||||
验证阶段的目的,在于确保被加载类能够满足Java虚拟机的约束条件。这就好比Tony需要将设计好的房型提交给市政部门审核。只有当审核通过,才能继续下面的建造工作。
|
||||
|
||||
通常而言,Java编译器生成的类文件必然满足Java虚拟机的约束条件。因此,这部分我留到讲解字节码注入时再详细介绍。
|
||||
|
||||
准备阶段的目的,则是为被加载类的静态字段分配内存。Java代码中对静态字段的具体初始化,则会在稍后的初始化阶段中进行。过了这个阶段,咱们算是盖好了毛坯房。虽然结构已经完整,但是在没有装修之前是不能住人的。
|
||||
|
||||
除了分配内存外,部分Java虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。
|
||||
|
||||
在class文件被加载至Java虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。
|
||||
|
||||
举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。
|
||||
|
||||
解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)
|
||||
|
||||
如果将这段话放在盖房子的语境下,那么符号引用就好比“Tony的房子”这种说法,不管它存在不存在,我们都可以用这种说法来指代Tony的房子。实际引用则好比实际的通讯地址,如果我们想要与Tony通信,则需要启动盖房子的过程。
|
||||
|
||||
Java虚拟机规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。
|
||||
|
||||
## 初始化
|
||||
|
||||
在Java代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。
|
||||
|
||||
如果直接赋值的静态字段被final所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被Java编译器标记成常量值(ConstantValue),其初始化直接由Java虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被Java编译器置于同一方法中,并把它命名为< clinit >。
|
||||
|
||||
类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行< clinit >方法的过程。Java虚拟机会通过加锁来确保类的< clinit >方法仅被执行一次。
|
||||
|
||||
只有当初始化完成之后,类才正式成为可执行的状态。这放在我们盖房子的例子中就是,只有当房子装修过后,Tony才能真正地住进去。
|
||||
|
||||
那么,类的初始化何时会被触发呢?JVM规范枚举了下述多种触发情况:
|
||||
|
||||
1. 当虚拟机启动时,初始化用户指定的主类;
|
||||
1. 当遇到用以新建目标类实例的new指令时,初始化new指令的目标类;
|
||||
1. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
|
||||
1. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
|
||||
1. 子类的初始化会触发父类的初始化;
|
||||
1. 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
|
||||
1. 使用反射API对某个类进行反射调用时,初始化这个类;
|
||||
1. 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。
|
||||
|
||||
```
|
||||
public class Singleton {
|
||||
private Singleton() {}
|
||||
private static class LazyHolder {
|
||||
static final Singleton INSTANCE = new Singleton();
|
||||
}
|
||||
public static Singleton getInstance() {
|
||||
return LazyHolder.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我在文章中贴了一段代码,这段代码是在著名的单例延迟初始化例子中[2](https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom),只有当调用Singleton.getInstance时,程序才会访问LazyHolder.INSTANCE,才会触发对LazyHolder的初始化(对应第4种情况),继而新建一个Singleton的实例。
|
||||
|
||||
由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个Singleton实例。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了Java虚拟机将字节流转化为Java类的过程。这个过程可分为加载、链接以及初始化三大步骤。
|
||||
|
||||
加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在Java虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。
|
||||
|
||||
链接,是指将创建成的类合并至Java虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。
|
||||
|
||||
初始化,则是为标记为常量值的字段赋值,以及执行< clinit >方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。
|
||||
|
||||
今天的实践环节,你可以来验证一下本篇中的理论知识。
|
||||
|
||||
通过JVM参数-verbose:class来打印类加载的先后顺序,并且在LazyHolder的初始化方法中打印特定字样。在命令行中运行下述指令(不包含提示符$):
|
||||
|
||||
```
|
||||
|
||||
$ echo '
|
||||
public class Singleton {
|
||||
private Singleton() {}
|
||||
private static class LazyHolder {
|
||||
static final Singleton INSTANCE = new Singleton();
|
||||
static {
|
||||
System.out.println("LazyHolder.<clinit>");
|
||||
}
|
||||
}
|
||||
public static Object getInstance(boolean flag) {
|
||||
if (flag) return new LazyHolder[2];
|
||||
return LazyHolder.INSTANCE;
|
||||
}
|
||||
public static void main(String[] args) {
|
||||
getInstance(true);
|
||||
System.out.println("----");
|
||||
getInstance(false);
|
||||
}
|
||||
}' > Singleton.java
|
||||
$ javac Singleton.java
|
||||
$ java -verbose:class Singleton
|
||||
|
||||
```
|
||||
|
||||
问题1:新建数组(第11行)会导致LazyHolder的加载吗?会导致它的初始化吗?
|
||||
|
||||
在命令行中运行下述指令(不包含提示符$):
|
||||
|
||||
```
|
||||
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Singleton\$LazyHolder.class > Singleton\$LazyHolder.jasm.1
|
||||
$ awk 'NR==1,/stack 1/{sub(/stack 1/, "stack 0")} 1' Singleton\$LazyHolder.jasm.1 > Singleton\$LazyHolder.jasm
|
||||
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Singleton\$LazyHolder.jasm
|
||||
$ java -verbose:class Singleton
|
||||
|
||||
```
|
||||
|
||||
问题2:新建数组会导致LazyHolder的链接吗?
|
||||
|
||||
|
||||
236
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/04 | JVM是如何执行方法调用的?(上).md
Normal file
236
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/04 | JVM是如何执行方法调用的?(上).md
Normal file
@@ -0,0 +1,236 @@
|
||||
<audio id="audio" title="04 | JVM是如何执行方法调用的?(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/24/9799e437a07151965a7bba29e898b924.mp3"></audio>
|
||||
|
||||
前不久在写代码的时候,我不小心踩到一个可变长参数的坑。你或许已经猜到了,它正是可变长参数方法的重载造成的。(注:官方文档建议避免重载可变长参数方法,见[1]的最后一段。)
|
||||
|
||||
我把踩坑的过程放在了文稿里,你可以点击查看。
|
||||
|
||||
```
|
||||
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)。也就是说,声明为Object的形式参数所对应的实际参数为null,而变长参数则对应1。
|
||||
|
||||
通常来说,之所以不提倡可变长参数方法的重载,是因为Java编译器可能无法决定应该调用哪个目标方法。
|
||||
|
||||
在这种情况下,编译器会报错,并且提示这个方法调用有二义性。然而,Java编译器直接将我的方法调用识别为调用第二个方法,这究竟是为什么呢?
|
||||
|
||||
带着这个问题,我们来看一看Java虚拟机是怎么识别目标方法的。
|
||||
|
||||
## 重载与重写
|
||||
|
||||
在Java程序里,如果同一个类中出现多个名字相同,并且参数类型相同的方法,那么它无法通过编译。也就是说,在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。这些方法之间的关系,我们称之为重载。
|
||||
|
||||
```
|
||||
小知识:这个限制可以通过字节码工具绕开。也就是说,在编译完成之后,我们可以再向class文件中添加方法名和参数类型相同,而返回类型不同的方法。当这种包括多个方法名相同、参数类型相同,而返回类型不同的方法的类,出现在Java编译器的用户类路径上时,它是怎么确定需要调用哪个方法的呢?当前版本的Java编译器会直接选取第一个方法名以及参数类型匹配的方法。并且,它会根据所选取方法的返回类型来决定可不可以通过编译,以及需不需要进行值转换等。
|
||||
|
||||
```
|
||||
|
||||
重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:
|
||||
|
||||
1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
|
||||
1. 如果在第1个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
|
||||
1. 如果在第2个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
|
||||
|
||||
如果Java编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
|
||||
|
||||
在开头的例子中,当传入null时,它既可以匹配第一个方法中声明为Object的形式参数,也可以匹配第二个方法中声明为String的形式参数。由于String是Object的子类,因此Java编译器会认为第二个方法更为贴切。
|
||||
|
||||
除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。
|
||||
|
||||
那么,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?
|
||||
|
||||
如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。
|
||||
|
||||
众所周知,Java是一门面向对象的编程语言,它的一个重要特性便是多态。而方法重写,正是多态最重要的一种体现方式:它允许子类在继承父类部分功能的同时,拥有自己独特的行为。
|
||||
|
||||
打个比方,如果你经常漫游,那么你可能知道,拨打10086会根据你当前所在地,连接到当地的客服。重写调用也是如此:它会根据调用者的动态类型,来选取实际的目标方法。
|
||||
|
||||
## JVM的静态绑定和动态绑定
|
||||
|
||||
接下来,我们来看看Java虚拟机是怎么识别方法的。
|
||||
|
||||
Java虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。前面两个就不做过多的解释了。至于方法描述符,它是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么Java虚拟机会在类的验证阶段报错。
|
||||
|
||||
可以看到,Java虚拟机与Java语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此Java虚拟机能够准确地识别目标方法。
|
||||
|
||||
Java虚拟机中关于方法重写的判定同样基于方法描述符。也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java虚拟机才会判定为重写。
|
||||
|
||||
对于Java语言中重写而Java虚拟机中非重写的情况,编译器会通过生成桥接方法[2]来实现Java中的重写语义。
|
||||
|
||||
由于对重载方法的区分在编译阶段已经完成,我们可以认为Java虚拟机不存在重载这一概念。因此,在某些文章中,重载也被称为静态绑定(static binding),或者编译时多态(compile-time polymorphism);而重写则被称为动态绑定(dynamic binding)。
|
||||
|
||||
这个说法在Java虚拟机语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写,因此Java编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。
|
||||
|
||||
确切地说,Java虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
|
||||
|
||||
具体来说,Java字节码中与调用相关的指令共有五种。
|
||||
|
||||
1. invokestatic:用于调用静态方法。
|
||||
1. invokespecial:用于调用私有实例方法、构造器,以及使用super关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
|
||||
1. invokevirtual:用于调用非私有实例方法。
|
||||
1. invokeinterface:用于调用接口方法。
|
||||
1. invokedynamic:用于调用动态方法。
|
||||
|
||||
由于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;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在代码中,“商户”类定义了一个成员方法,叫做“折后价格”,它将接收一个double类型的参数,以及一个“客户”类型的参数。这里“客户”是一个接口,它定义了一个接口方法,叫“isVIP”。
|
||||
|
||||
我们还定义了另一个叫做“奸商”的类,它继承了“商户”类,并且重写了“折后价格”这个方法。如果客户是VIP,那么它会被给到一个更低的折扣。
|
||||
|
||||
在这个方法中,我们首先会调用“客户”接口的”isVIP“方法。该调用会被编译为invokeinterface指令。
|
||||
|
||||
如果客户是VIP,那么我们会调用奸商类的一个名叫“价格歧视”的静态方法。该调用会被编译为invokestatic指令。如果客户不是VIP,那么我们会通过super关键字调用父类的“折后价格”方法。该调用会被编译为invokespecial指令。
|
||||
|
||||
在静态方法“价格歧视”中,我们会调用Random类的构造器。该调用会被编译为invokespecial指令。然后我们会以这个新建的Random对象为调用者,调用Random类中的nextDouble方法。该调用会被编译为invokevirutal指令。
|
||||
|
||||
对于invokestatic以及invokespecial而言,Java虚拟机能够直接识别具体的目标方法。
|
||||
|
||||
而对于invokevirtual以及invokeinterface而言,在绝大部分情况下,虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法。
|
||||
|
||||
唯一的例外在于,如果虚拟机能够确定目标方法有且仅有一个,比如说目标方法被标记为final[3][4],那么它可以不通过动态类型,直接确定目标方法。
|
||||
|
||||
## 调用指令的符号引用
|
||||
|
||||
在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。
|
||||
|
||||
符号引用存储在class文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。我在文章中贴了一个例子,利用“javap -v”打印某个类的常量池,如果你感兴趣的话可以到文章中查看。
|
||||
|
||||
```
|
||||
// 在奸商.class的常量池中,#16为接口符号引用,指向接口方法"客户.isVIP()"。而#22为非接口符号引用,指向静态方法"奸商.价格歧视()"。
|
||||
$ javap -v 奸商.class ...
|
||||
Constant pool:
|
||||
...
|
||||
#16 = InterfaceMethodref #27.#29 // 客户.isVIP:()Z
|
||||
...
|
||||
#22 = Methodref #1.#33 // 奸商.价格歧视:()D
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
上一篇中我曾提到过,在执行使用了符号引用的字节码前,Java虚拟机需要解析这些符号引用,并替换为实际引用。
|
||||
|
||||
对于非接口符号引用,假定该符号引用所指向的类为C,则Java虚拟机会按照如下步骤进行查找。
|
||||
|
||||
1. 在C中查找符合名字及描述符的方法。
|
||||
1. 如果没有找到,在C的父类中继续搜索,直至Object类。
|
||||
1. 如果没有找到,在C所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足C与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。
|
||||
|
||||
从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。
|
||||
|
||||
对于接口符号引用,假定该符号引用所指向的接口为I,则Java虚拟机会按照如下步骤进行查找。
|
||||
|
||||
1. 在I中查找符合名字及描述符的方法。
|
||||
1. 如果没有找到,在Object类中的公有实例方法中搜索。
|
||||
1. 如果没有找到,则在I的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤3的要求一致。
|
||||
|
||||
经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。具体什么是方法表,我会在下一篇中做出解答。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了Java以及Java虚拟机是如何识别目标方法的。
|
||||
|
||||
在Java中,方法存在重载以及重写的概念,重载指的是方法名相同而参数类型不相同的方法之间的关系,重写指的是方法名相同并且参数类型也相同的方法之间的关系。
|
||||
|
||||
Java虚拟机识别方法的方式略有不同,除了方法名和参数类型之外,它还会考虑返回类型。
|
||||
|
||||
在Java虚拟机中,静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。由于Java编译器已经区分了重载的方法,因此可以认为Java虚拟机中不存在重载。
|
||||
|
||||
在class文件中,Java编译器会用符号引用指代目标方法。在执行调用指令前,它所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用为目标方法的指针。对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息。
|
||||
|
||||
在文中我曾提到,Java的重写与Java虚拟机中的重写并不一致,但是编译器会通过生成桥接方法来弥补。今天的实践环节,我们来看一下两个生成桥接方法的例子。你可以通过“javap -v”来查看class文件所包含的方法。
|
||||
|
||||
1. 重写方法的返回类型不一致:
|
||||
|
||||
```
|
||||
interface Customer {
|
||||
boolean isVIP();
|
||||
}
|
||||
|
||||
class Merchant {
|
||||
public Number actionPrice(double price, Customer customer) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
class NaiveMerchant extends Merchant {
|
||||
@Override
|
||||
public Double actionPrice(double price, Customer customer) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
1. 范型参数类型造成的方法参数类型不一致:
|
||||
|
||||
```
|
||||
interface Customer {
|
||||
boolean isVIP();
|
||||
}
|
||||
|
||||
class Merchant<T extends Customer> {
|
||||
public double actionPrice(double price, T customer) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
class VIPOnlyMerchant extends Merchant<VIP> {
|
||||
@Override
|
||||
public double actionPrice(double price, VIP customer) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
[1] [https://docs.oracle.com/javase/8/docs/technotes/guides/language/varargs.html](https://docs.oracle.com/javase/8/docs/technotes/guides/language/varargs.html)<br>
|
||||
[2]<br>
|
||||
[https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html](https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html)<br>
|
||||
[3]<br>
|
||||
[https://wiki.openjdk.java.net/display/HotSpot/VirtualCalls](https://wiki.openjdk.java.net/display/HotSpot/VirtualCalls)<br>
|
||||
[4]<br>
|
||||
[https://wiki.openjdk.java.net/display/HotSpot/InterfaceCalls](https://wiki.openjdk.java.net/display/HotSpot/InterfaceCalls)
|
||||
|
||||
|
||||
171
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/05 | JVM是如何执行方法调用的?(下).md
Normal file
171
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/05 | JVM是如何执行方法调用的?(下).md
Normal file
@@ -0,0 +1,171 @@
|
||||
<audio id="audio" title="05 | JVM是如何执行方法调用的?(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/1a/f0a1bb55d5e05fd94be44d518d56d91a.mp3"></audio>
|
||||
|
||||
我在读博士的时候,最怕的事情就是被问有没有新的Idea。有一次我被老板问急了,就随口说了一个。
|
||||
|
||||
这个Idea究竟是什么呢,我们知道,设计模式大量使用了虚方法来实现多态。但是虚方法的性能效率并不高,所以我就说,是否能够在此基础上写篇文章,评估每一种设计模式因为虚方法调用而造成的性能开销,并且在文章中强烈谴责一下?
|
||||
|
||||
当时呢,我老板教的是一门高级程序设计的课,其中有好几节课刚好在讲设计模式的各种好处。所以,我说完这个Idea,就看到老板的神色略有不悦了,脸上写满了“小郑啊,你这是舍本逐末啊”,于是,我就连忙挽尊,说我是开玩笑的。
|
||||
|
||||
在这里呢,我犯的错误其实有两个。第一,我不应该因为虚方法的性能效率,而放弃良好的设计。第二,通常来说,Java虚拟机中虚方法调用的性能开销并不大,有些时候甚至可以完全消除。第一个错误是原则上的,这里就不展开了。至于第二个错误,我们今天便来聊一聊Java虚拟机中虚方法调用的具体实现。
|
||||
|
||||
首先,我们来看一个模拟出国边检的小例子。
|
||||
|
||||
```
|
||||
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();
|
||||
|
||||
```
|
||||
|
||||
这里我定义了一个抽象类,叫做Passenger,这个类中有一个名为passThroughImmigration的抽象方法,以及重写自Object类的toString方法。
|
||||
|
||||
然后,我将Passenger粗暴地分为两种:ChinesePassenger和ForeignerPassenger。
|
||||
|
||||
两个类分别实现了passThroughImmigration这个方法,具体来说,就是中国人走中国人通道,外国人走外国人通道。由于咱们储蓄较多,所以我在ChinesePassenger这个类中,还特意添加了一个叫做visitDutyFreeShops的方法。
|
||||
|
||||
那么在实际运行过程中,Java虚拟机是如何高效地确定每个Passenger实例应该去哪条通道的呢?我们一起来看一下。
|
||||
|
||||
## 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虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/c3/f1ff9dcb297a458981bd1d189a5b04c3.png" alt="">
|
||||
|
||||
在我们的例子中,Passenger类的方法表包括两个方法:
|
||||
|
||||
- toString
|
||||
- passThroughImmigration,
|
||||
|
||||
它们分别对应0号和1号。之所以方法表调换了toString方法和passThroughImmigration方法的位置,是因为toString方法的索引值需要与Object类中同名方法的索引值一致。为了保持简洁,这里我就不考虑Object类中的其他方法。
|
||||
|
||||
ForeignerPassenger的方法表同样有两行。其中,0号方法指向继承而来的Passenger类的toString方法。1号方法则指向自己重写的passThroughImmigration方法。
|
||||
|
||||
ChinesePassenger的方法表则包括三个方法,除了继承而来的Passenger类的toString方法,自己重写的passThroughImmigration方法之外,还包括独有的visitDutyFreeShops方法。
|
||||
|
||||
```
|
||||
Passenger passenger = ...
|
||||
passenger.passThroughImmigration();
|
||||
|
||||
```
|
||||
|
||||
这里,Java虚拟机的工作可以想象为导航员。每当来了一个乘客需要出境,导航员会先问是中国人还是外国人(获取动态类型),然后翻出中国人/外国人对应的小册子(获取动态类型的方法表),小册子的第1页便写着应该到哪条通道办理出境手续(用1作为索引来查找方法表所对应的目标方法)。
|
||||
|
||||
实际上,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化Java栈帧来说,这几个内存解引用操作的开销简直可以忽略不计。
|
||||
|
||||
那么我们是否可以认为虚方法调用对性能没有太大影响呢?
|
||||
|
||||
其实是不能的,上述优化的效果看上去十分美好,但实际上仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。下面我便来介绍第一种内联缓存。
|
||||
|
||||
## 3.内联缓存
|
||||
|
||||
内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。
|
||||
|
||||
在我们的例子中,这相当于导航员记住了上一个出境乘客的国籍和对应的通道,例如中国人,走了左边通道出境。那么下一个乘客想要出境的时候,导航员会先问是不是中国人,是的话就走左边通道。如果不是的话,只好拿出外国人的小册子,翻到第1页,再告知查询结果:右边。
|
||||
|
||||
在针对多态的优化手段中,我们通常会提及以下三个术语。
|
||||
|
||||
1. 单态(monomorphic)指的是仅有一种状态的情况。
|
||||
1. 多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)是多态的其中一种。
|
||||
1. 超多态(megamorphic)指的是更多种状态的情况。通常我们用一个具体数值来区分多态和超多态。在这个数值之下,我们称之为多态。否则,我们称之为超多态。
|
||||
|
||||
对于内联缓存来说,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存。单态内联缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。
|
||||
|
||||
多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。
|
||||
|
||||
一般来说,我们会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java虚拟机只采用单态内联缓存。
|
||||
|
||||
前面提到,当内联缓存没有命中的情况下,Java虚拟机需要重新使用方法表进行动态绑定。对于内联缓存中的内容,我们有两种选择。一是替换单态内联缓存中的纪录。这种做法就好比CPU中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。
|
||||
|
||||
因此,在最坏情况下,我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。
|
||||
|
||||
另外一种选择则是劣化为超多态状态。这也是Java虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。
|
||||
|
||||
具体到我们的例子,如果来了一队乘客,其中外国人和中国人依次隔开,那么在重复使用的单态内联缓存中,导航员需要反复记住上个出境的乘客,而且记住的信息在处理下一乘客时又会被替换掉。因此,倒不如一直不记,以此来节省脑细胞。
|
||||
|
||||
虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。
|
||||
|
||||
对于极其简单的方法而言,比如说getter/setter,这部分固定开销占据的CPU时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性,我们会在专栏的第二部分详细介绍方法内联的内容。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了虚方法调用在Java虚拟机中的实现方式。
|
||||
|
||||
虚方法调用包括invokevirtual指令和invokeinterface指令。如果这两种指令所声明的目标方法被标记为final,那么Java虚拟机会采用静态绑定。
|
||||
|
||||
否则,Java虚拟机将采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。
|
||||
|
||||
Java虚拟机的动态绑定是通过方法表这一数据结构来实现的。方法表中每一个重写方法的索引值,与父类方法表中被重写的方法的索引值一致。
|
||||
|
||||
在解析虚方法调用时,Java虚拟机会纪录下所声明的目标方法的索引值,并且在运行过程中根据这个索引值查找具体的目标方法。
|
||||
|
||||
Java虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。
|
||||
|
||||
当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。
|
||||
|
||||
否则,Java虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。
|
||||
|
||||
在今天的实践环节,我们来观测一下单态内联缓存和超多态内联缓存的性能差距。为了消除方法内联的影响,请使用如下的命令。
|
||||
|
||||
```
|
||||
// Run with: java -XX:CompileCommand='dontinline,*.passThroughImmigration' Passenger
|
||||
public abstract class Passenger {
|
||||
abstract void passThroughImmigration();
|
||||
public static void main(String[] args) {
|
||||
Passenger a = new ChinesePassenger();
|
||||
Passenger b = new ForeignerPassenger();
|
||||
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;
|
||||
}
|
||||
Passenger c = (i < 1_000_000_000) ? a : b;
|
||||
c.passThroughImmigration();
|
||||
}
|
||||
}
|
||||
}
|
||||
class ChinesePassenger extends Passenger {
|
||||
@Override void passThroughImmigration() {}
|
||||
}
|
||||
class ForeignerPassenger extends Passenger {
|
||||
@Override void passThroughImmigration() {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
287
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/06 | JVM是如何处理异常的?.md
Normal file
287
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/06 | JVM是如何处理异常的?.md
Normal file
@@ -0,0 +1,287 @@
|
||||
<audio id="audio" title="06 | JVM是如何处理异常的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/d6/6d231cff0a16e93b6622a0a2cbc50bd6.mp3"></audio>
|
||||
|
||||
你好,我是郑雨迪。今天我们来讲讲Java虚拟机的异常处理。
|
||||
|
||||
众所周知,异常处理的两大组成要素是抛出异常和捕获异常。这两大要素共同实现程序控制流的非正常转移。
|
||||
|
||||
抛出异常可分为显式和隐式两种。显式抛异常的主体是应用程序,它指的是在程序中使用“throw”关键字,手动将异常实例抛出。
|
||||
|
||||
隐式抛异常的主体则是Java虚拟机,它指的是Java虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。举例来说,Java虚拟机在执行读取数组操作时,发现输入的索引值是负数,故而抛出数组索引越界异常(ArrayIndexOutOfBoundsException)。
|
||||
|
||||
捕获异常则涉及了如下三种代码块。
|
||||
|
||||
<li>
|
||||
try代码块:用来标记需要进行异常监控的代码。
|
||||
</li>
|
||||
<li>
|
||||
catch代码块:跟在try代码块之后,用来捕获在try代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch代码块还定义了针对该异常类型的异常处理器。在Java中,try代码块后面可以跟着多个catch代码块,来捕获不同类型的异常。Java虚拟机会从上至下匹配异常处理器。因此,前面的catch代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。
|
||||
</li>
|
||||
<li>
|
||||
finally代码块:跟在try代码块和catch代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。
|
||||
</li>
|
||||
|
||||
在程序正常执行的情况下,这段代码会在try代码块之后运行。否则,也就是try代码块触发异常的情况下,如果该异常没有被捕获,finally代码块会直接运行,并且在运行之后重新抛出该异常。
|
||||
|
||||
如果该异常被catch代码块捕获,finally代码块则在catch代码块之后运行。在某些不幸的情况下,catch代码块也触发了异常,那么finally代码块同样会运行,并会抛出catch代码块触发的异常。在某些极端不幸的情况下,finally代码块也触发了异常,那么只好中断当前finally代码块的执行,并往外抛异常。
|
||||
|
||||
上面这段听起来有点绕,但是等我讲完Java虚拟机的异常处理机制之后,你便会明白这其中的道理。
|
||||
|
||||
## 异常的基本概念
|
||||
|
||||
在Java语言规范中,所有异常都是Throwable类或者其子类的实例。Throwable有两大直接子类。第一个是Error,涵盖程序不应捕获的异常。当程序触发Error时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。第二子类则是Exception,涵盖程序可能需要捕获并且处理的异常。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/93/47c8429fc30aec201286b47f3c1a5993.png" alt="">
|
||||
|
||||
Exception有一个特殊的子类RuntimeException,用来表示“程序虽然无法继续执行,但是还能抢救一下”的情况。前边提到的数组索引越界便是其中的一种。
|
||||
|
||||
RuntimeException和Error属于Java里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。在Java语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用throws关键字标注。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用Java编译器的编译时检查。
|
||||
|
||||
异常实例的构造十分昂贵。这是由于在构造异常实例时,Java虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的Java栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。
|
||||
|
||||
当然,在生成栈轨迹时,Java虚拟机会忽略掉异常构造器以及填充栈帧的Java方法(Throwable.fillInStackTrace),直接从新建异常位置开始算起。此外,Java虚拟机还会忽略标记为不可见的Java方法栈帧。我们在介绍Lambda的时候会看到具体的例子。
|
||||
|
||||
既然异常实例的构造十分昂贵,我们是否可以缓存异常实例,在需要用到的时候直接抛出呢?从语法角度上来看,这是允许的。然而,该异常对应的栈轨迹并非throw语句的位置,而是新建异常的位置。
|
||||
|
||||
因此,这种做法可能会误导开发人员,使其定位到错误的位置。这也是为什么在实践中,我们往往选择抛出新建异常实例的原因。
|
||||
|
||||
## Java虚拟机是如何捕获异常的?
|
||||
|
||||
在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由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 // 异常表条目
|
||||
|
||||
|
||||
```
|
||||
|
||||
举个例子,在上图的main方法中,我定义了一段try-catch代码。其中,catch代码块所捕获的异常类型为Exception。
|
||||
|
||||
编译过后,该方法的异常表拥有一个条目。其from指针和to指针分别为0和3,代表它的监控范围从索引为0的字节码开始,到索引为3的字节码结束(不包括3)。该条目的target指针是6,代表这个异常处理器从索引为6的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是Exception。
|
||||
|
||||
当程序触发异常时,Java虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java虚拟机会将控制流转移至该条目target指针指向的字节码。
|
||||
|
||||
如果遍历完所有异常表条目,Java虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的Java栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java虚拟机需要遍历当前线程Java栈上所有方法的异常表。
|
||||
|
||||
finally代码块的编译比较复杂。当前版本Java编译器的做法,是复制finally代码块的内容,分别放在try-catch代码块所有正常执行路径以及异常执行路径的出口中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/06/17e2a3053b06b0a4383884f106e31c06.png" alt="">
|
||||
|
||||
针对异常执行路径,Java编译器会生成一个或多个异常表条目,监控整个try-catch代码块,并且捕获所有种类的异常(在javap中以any指代)。这些异常表条目的target指针将指向另一份复制的finally代码块。并且,在这个finally代码块的最后,Java编译器会重新抛出所捕获的异常。
|
||||
|
||||
如果你感兴趣的话,可以用javap工具来查看下面这段包含了try-catch-finally代码块的编译结果。为了更好地区分每个代码块,我定义了四个实例字段:tryBlock、catchBlock、finallyBlock、以及methodExit,并且仅在对应的代码块中访问这些字段。
|
||||
|
||||
```
|
||||
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捕获并且重抛的异常是哪个呢?答案是后者。也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。
|
||||
|
||||
## Java 7的Suppressed异常以及语法糖
|
||||
|
||||
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) {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了Java虚拟机的异常处理机制。
|
||||
|
||||
Java的异常分为Exception和Error两种,而Exception又分为RuntimeException和其他类型。RuntimeException和Error属于非检查异常。其他的Exception皆属于检查异常,在触发时需要显式捕获,或者在方法头用throws关键字声明。
|
||||
|
||||
Java字节码中,每个方法对应一个异常表。当程序触发异常时,Java虚拟机将查找异常表,并依此决定需要将控制流转移至哪个异常处理器之中。Java代码中的catch代码块和finally代码块都会生成异常表条目。
|
||||
|
||||
Java 7引入了Suppressed异常、try-with-resources,以及多异常捕获。后两者属于语法糖,能够极大地精简我们的代码。
|
||||
|
||||
那么今天的实践环节,你可以看看其他控制流语句与finally代码块之间的协作。
|
||||
|
||||
```
|
||||
|
||||
// 编译并用javap -c查看编译后的字节码
|
||||
public class Foo {
|
||||
private int tryBlock;
|
||||
private int catchBlock;
|
||||
private int finallyBlock;
|
||||
private int methodExit;
|
||||
|
||||
public void test() {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
try {
|
||||
tryBlock = 0;
|
||||
if (i < 50) {
|
||||
continue;
|
||||
} else if (i < 80) {
|
||||
break;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
catchBlock = 1;
|
||||
} finally {
|
||||
finallyBlock = 2;
|
||||
}
|
||||
}
|
||||
methodExit = 3;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
464
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/07 | JVM是如何实现反射的?.md
Normal file
464
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/07 | JVM是如何实现反射的?.md
Normal file
@@ -0,0 +1,464 @@
|
||||
<audio id="audio" title="07 | JVM是如何实现反射的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2b/96/2b25e19607a819df26f56f65e2050096.mp3"></audio>
|
||||
|
||||
今天我们来聊聊Java里的反射机制。
|
||||
|
||||
反射是Java语言中一个相当重要的特性,它允许正在运行的Java程序观测,甚至是修改程序的动态行为。
|
||||
|
||||
举例来说,我们可以通过Class对象枚举该类中的所有方法,我们还可以通过Method.setAccessible(位于java.lang.reflect包,该方法继承自AccessibleObject)绕过Java语言的访问权限,在私有方法所在类之外的地方调用该方法。
|
||||
|
||||
反射在Java中的应用十分广泛。开发人员日常接触到的Java集成开发环境(IDE)便运用了这一功能:每当我们敲入点号时,IDE便会根据点号前的内容,动态展示可以访问的字段或者方法。
|
||||
|
||||
另一个日常应用则是Java调试器,它能够在调试过程中枚举某一对象所有字段的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/75/ceeabb2dbdd80577feaecd0879e42675.png" alt="" />
|
||||
|
||||
(图中eclipse的自动提示使用了反射)
|
||||
|
||||
在Web开发中,我们经常能够接触到各种可配置的通用框架。为了保证框架的可扩展性,它们往往借助Java的反射机制,根据配置文件来加载不同的类。举例来说,Spring框架的依赖反转(IoC),便是依赖于反射机制。
|
||||
|
||||
然而,我相信不少开发人员都嫌弃反射机制比较慢。甚至是甲骨文关于反射的教学网页[1],也强调了反射性能开销大的缺点。
|
||||
|
||||
今天我们便来了解一下反射的实现机制,以及它性能糟糕的原因。如果你对反射API不是特别熟悉的话,你可以查阅我放在文稿末尾的附录。
|
||||
|
||||
## 反射调用的实现
|
||||
|
||||
首先,我们来看看方法的反射调用,也就是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实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解。当进入了Java虚拟机内部之后,我们便拥有了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)
|
||||
a t java.base/jdk.internal.reflect.NativeMethodAccessorImpl. .invoke(NativeMethodAccessorImpl.java:62)
|
||||
t java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.i .invoke(DelegatingMethodAccessorImpl.java:43)
|
||||
java.base/java.lang.reflect.Method.invoke(Method.java:564)
|
||||
t Test.main(Test.java:131
|
||||
|
||||
```
|
||||
|
||||
为了方便理解,我们可以打印一下反射调用到目标方法时的栈轨迹。在上面的v0版本代码中,我们获取了一个指向Test.target方法的Method对象,并且用它来进行反射调用。在Test.target中,我会打印出栈轨迹。
|
||||
|
||||
可以看到,反射调用先是调用了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倍 [2] 。这是因为动态实现无需经过Java到C++再到Java的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上3到4倍 [3]。
|
||||
|
||||
考虑到许多反射调用仅会执行一次,Java虚拟机设置了一个阈值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开始数)反射调用时,我们便触发了动态实现的生成。这时候,Java虚拟机额外加载了不少类。其中,最重要的当属GeneratedMethodAccessor1(第30行)。并且,从第16次反射调用开始,我们便切换至这个刚刚生成的动态实现(第40行)。
|
||||
|
||||
反射调用的Inflation机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。
|
||||
|
||||
## 反射调用的开销
|
||||
|
||||
下面,我们便来拆解反射调用的性能开销。
|
||||
|
||||
在刚才的例子中,我们先后进行了Class.forName,Class.getMethod以及Method.invoke三个操作。其中,Class.forName会调用本地方法,Class.getMethod则会遍历该类的公有方法。如果没有匹配到,它还将遍历父类的公有方法。可想而知,这两个操作都非常费时。
|
||||
|
||||
值得注意的是,以getMethod为代表的查找方法操作,会返回查找得到结果的一份拷贝。因此,我们应当避免在热点代码中使用返回Method数组的getMethods或者getDeclaredMethods方法,以减少不必要的堆空间消耗。
|
||||
|
||||
在实践中,我们往往会在应用程序中缓存Class.forName和Class.getMethod的结果。因此,下面我就只关注反射调用本身的性能开销。
|
||||
|
||||
为了比较直接调用和反射调用的性能差距,我将前面的例子改为下面的v2版本。它会将反射调用循环二十亿次。此外,它还将记录下每跑一亿次的时间。
|
||||
|
||||
我将取最后五个记录的平均值,作为预热后的峰值性能。(注:这种性能评估方式并不严谨,我会在专栏的第三部分介绍如何用JMH来测性能。)
|
||||
|
||||
在我这个老笔记本上,一亿次直接调用耗费的时间大约在120ms。这和不调用的时间是一致的。其原因在于这段代码属于热循环,同样会触发即时编译。并且,即时编译会将对Test.target的调用内联进来,从而消除了调用的开销。
|
||||
|
||||
```
|
||||
// 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接收一个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 // 反射调用
|
||||
|
||||
```
|
||||
|
||||
这里我截取了循环中反射调用编译而成的字节码。可以看到,这段字节码除了反射调用外,还额外做了两个操作。
|
||||
|
||||
第一,由于Method.invoke是一个变长参数方法,在字节码层面它的最后一个参数会是Object数组(感兴趣的同学私下可以用javap查看)。Java编译器会在方法调用处生成一个长度为传入参数数量的Object数组,并将传入参数一一存储进该数组中。
|
||||
|
||||
第二,由于Object数组不能存储基本类型,Java编译器会对传入的基本类型参数进行自动装箱。
|
||||
|
||||
这两个操作除了带来性能开销外,还可能占用堆内存,使得GC更加频繁。(如果你感兴趣的话,可以用虚拟机参数-XX:+PrintGC试试。)那么,如何消除这部分开销呢?
|
||||
|
||||
关于第二个自动装箱,Java缓存了[-128, 127]中所有整数所对应的Integer对象。当需要自动装箱的整数在这个范围之内时,便返回缓存的Integer,否则需要新建一个Integer对象。
|
||||
|
||||
因此,我们可以将这个缓存的范围扩大至覆盖128(对应参数<br />
|
||||
-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
到这里,我们基本上把反射调用的水分都榨干了。接下来,我来把反射调用的性能开销给提回去。
|
||||
|
||||
首先,在这个例子中,之所以反射调用能够变得这么快,主要是因为即时编译器中的方法内联。在关闭了Inflation的情况下,内联的瓶颈在于Method.invoke方法中对MethodAccessor.invoke方法的调用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/b5/93dec45b7af7951a2b6daeb01941b9b5.png" alt="" />
|
||||
|
||||
我会在后面的文章中介绍方法内联的具体实现,这里先说个结论:在生产环境中,我们往往拥有多个不同的反射调用,对应多个GeneratedMethodAccessor,也就是动态实现。
|
||||
|
||||
由于Java虚拟机的关于上述调用点的类型profile(注:对于invokevirtual或者invokeinterface,Java虚拟机会记录下调用者的具体类型,我们称之为类型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倍。
|
||||
|
||||
除此之外,我们还可以提高Java虚拟机关于每个调用能够记录的类型数目(对应虚拟机参数-XX:TypeProfileWidth,默认值为2,这里设置为3)。最终测得的结果约为基准的2.8倍,尽管它和原本的1.3倍还有一定的差距,但总算是比6.7倍好多了。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了Java里的反射机制。
|
||||
|
||||
在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。在调用超过15次之后,委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动生成的,它将直接使用invoke指令来调用目标方法。
|
||||
|
||||
方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的Object数组,基本类型的自动装箱、拆箱,还有最重要的方法内联。
|
||||
|
||||
今天的实践环节,你可以将最后一段代码中polluteProfile方法的两个Method对象,都改成获取名字为“target”的方法。请问这两个获得的Method对象是同一个吗(==)?他们equal吗(.equals(…))?对我们的运行结果有什么影响?
|
||||
|
||||
```
|
||||
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("target", int.class);
|
||||
Method method2 = Test.class.getMethod("target", 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) { }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 附录:反射API简介
|
||||
|
||||
通常来说,使用反射API的第一步便是获取Class对象。在Java中常见的有这么三种。
|
||||
|
||||
1. 使用静态方法Class.forName来获取。
|
||||
1. 调用对象的getClass()方法。
|
||||
1. 直接用类名+“.class”访问。对于基本类型来说,它们的包装类型(wrapper classes)拥有一个名为“TYPE”的final静态字段,指向该基本类型对应的Class对象。
|
||||
|
||||
例如,Integer.TYPE指向int.class。对于数组类型来说,可以使用类名+“[ ].class”来访问,如int[ ].class。
|
||||
|
||||
除此之外,Class类和java.lang.reflect包中还提供了许多返回Class对象的方法。例如,对于数组类的Class对象,调用Class.getComponentType()方法可以获得数组元素的类型。
|
||||
|
||||
一旦得到了Class对象,我们便可以正式地使用反射功能了。下面我列举了较为常用的几项。
|
||||
|
||||
<li>
|
||||
使用newInstance()来生成一个该类的实例。它要求该类中拥有一个无参数的构造器。
|
||||
</li>
|
||||
<li>
|
||||
使用isInstance(Object)来判断一个对象是否该类的实例,语法上等同于instanceof关键字(JIT优化时会有差别,我会在本专栏的第二部分详细介绍)。
|
||||
</li>
|
||||
<li>
|
||||
使用Array.newInstance(Class,int)来构造该类型的数组。
|
||||
</li>
|
||||
<li>
|
||||
使用getFields()/getConstructors()/getMethods()来访问该类的成员。除了这三个之外,Class类还提供了许多其他方法,详见[4]。需要注意的是,方法名中带Declared的不会返回父类的成员,但是会返回私有成员;而不带Declared的则相反。
|
||||
</li>
|
||||
|
||||
当获得了类成员之后,我们可以进一步做如下操作。
|
||||
|
||||
- 使用Constructor/Field/Method.setAccessible(true)来绕开Java语言的访问限制。
|
||||
- 使用Constructor.newInstance(Object[])来生成该类的实例。
|
||||
- 使用Field.get/set(Object)来访问字段的值。
|
||||
- 使用Method.invoke(Object, Object[])来调用方法。
|
||||
|
||||
有关反射API的其他用法,可以参考reflect包的javadoc [5] ,这里就不详细展开了。
|
||||
|
||||
[1] : [https://docs.oracle.com/javase/tutorial/reflect/](https://docs.oracle.com/javase/tutorial/reflect/)<br />
|
||||
[2]: [http://hg.openjdk.java.net/jdk10/jdk10/jdk/file/777356696811/src/java.base/share/classes/jdk/internal/reflect/ReflectionFactory.java#l80](http://hg.openjdk.java.net/jdk10/jdk10/jdk/file/777356696811/src/java.base/share/classes/jdk/internal/reflect/ReflectionFactory.java#l80)<br />
|
||||
[3]: [http://hg.openjdk.java.net/jdk10/jdk10/jdk/file/777356696811/src/java.base/share/classes/jdk/internal/reflect/ReflectionFactory.java#l78](http://hg.openjdk.java.net/jdk10/jdk10/jdk/file/777356696811/src/java.base/share/classes/jdk/internal/reflect/ReflectionFactory.java#l78)<br />
|
||||
[4]: [https://docs.oracle.com/javase/tutorial/reflect/class/classMembers.html](https://docs.oracle.com/javase/tutorial/reflect/class/classMembers.html)<br />
|
||||
[5]: [https://docs.oracle.com/javase/10/docs/api/java/lang/reflect/package-summary.html](https://docs.oracle.com/javase/10/docs/api/java/lang/reflect/package-summary.html)
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<audio id="audio" title="08 | JVM是怎么实现invokedynamic的?(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/6c/357dbd97d790c76e54938df91d61856c.mp3"></audio>
|
||||
|
||||
前不久,“虚拟机”赛马俱乐部来了个年轻人,标榜自己是动态语言,是先进分子。
|
||||
|
||||
这一天,先进分子牵着一头鹿进来,说要参加赛马。咱部里的老学究Java就不同意了呀,鹿又不是马,哪能参加赛马。
|
||||
|
||||
当然了,这种墨守成规的调用方式,自然是先进分子所不齿的。现在年轻人里流行的是鸭子类型(duck typing)[1],只要是跑起来像只马的,它就是一只马,也就能够参加赛马比赛。
|
||||
|
||||
```
|
||||
class Horse {
|
||||
public void race() {
|
||||
System.out.println("Horse.race()");
|
||||
}
|
||||
}
|
||||
|
||||
class Deer {
|
||||
public void race() {
|
||||
System.out.println("Deer.race()");
|
||||
}
|
||||
}
|
||||
|
||||
class Cobra {
|
||||
public void race() {
|
||||
System.out.println("How do you turn this on?");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
(如何用同一种方式调用他们的赛跑方法?)
|
||||
|
||||
说到了这里,如果我们将赛跑定义为对赛跑方法(对应上述代码中的race())的调用的话,那么这个故事的关键,就在于能不能在马场中调用非马类型的赛跑方法。
|
||||
|
||||
为了解答这个问题,我们先来回顾一下Java里的方法调用。在Java中,方法调用会被编译为invokestatic,invokespecial,invokevirtual以及invokeinterface四种指令。这些指令与包含目标方法类名、方法名以及方法描述符的符号引用捆绑。在实际运行之前,Java虚拟机将根据这个符号引用链接到具体的目标方法。
|
||||
|
||||
可以看到,在这四种调用指令中,Java虚拟机明确要求方法调用需要提供目标方法的类名。在这种体系下,我们有两个解决方案。一是调用其中一种类型的赛跑方法,比如说马类的赛跑方法。对于非马的类型,则给它套一层马甲,当成马来赛跑。
|
||||
|
||||
另外一种解决方式,是通过反射机制,来查找并且调用各个类型中的赛跑方法,以此模拟真正的赛跑。
|
||||
|
||||
显然,比起直接调用,这两种方法都相当复杂,执行效率也可想而知。为了解决这个问题,Java 7引入了一条新的指令invokedynamic。该指令的调用机制抽象出调用点这一个概念,并允许应用程序将调用点链接至任意符合条件的方法上。
|
||||
|
||||
```
|
||||
|
||||
public static void startRace(java.lang.Object)
|
||||
0: aload_0 // 加载一个任意对象
|
||||
1: invokedynamic race // 调用赛跑方法
|
||||
|
||||
|
||||
```
|
||||
|
||||
(理想的调用方式)
|
||||
|
||||
作为invokedynamic的准备工作,Java 7引入了更加底层、更加灵活的方法抽象 :方法句柄(MethodHandle)。
|
||||
|
||||
## 方法句柄的概念
|
||||
|
||||
方法句柄是一个强类型的,能够被直接执行的引用[2]。该引用可以指向常规的静态方法或者实例方法,也可以指向构造器或者字段。当指向字段时,方法句柄实则指向包含字段访问字节码的虚构方法,语义上等价于目标字段的getter或者setter方法。
|
||||
|
||||
这里需要注意的是,它并不会直接指向目标字段所在类中的getter/setter,毕竟你无法保证已有的getter/setter方法就是在访问目标字段。
|
||||
|
||||
方法句柄的类型(MethodType)是由所指向方法的参数类型以及返回类型组成的。它是用来确认方法句柄是否适配的唯一关键。当使用方法句柄时,我们其实并不关心方法句柄所指向方法的类名或者方法名。
|
||||
|
||||
打个比方,如果兔子的“赛跑”方法和“睡觉”方法的参数类型以及返回类型一致,那么对于兔子递过来的一个方法句柄,我们并不知道会是哪一个方法。
|
||||
|
||||
方法句柄的创建是通过MethodHandles.Lookup类来完成的。它提供了多个API,既可以使用反射API中的Method来查找,也可以根据类、方法名以及方法句柄类型来查找。
|
||||
|
||||
当使用后者这种查找方式时,用户需要区分具体的调用类型,比如说对于用invokestatic调用的静态方法,我们需要使用Lookup.findStatic方法;对于用invokevirtual调用的实例方法,以及用invokeinterface调用的接口方法,我们需要使用findVirtual方法;对于用invokespecial调用的实例方法,我们则需要使用findSpecial方法。
|
||||
|
||||
调用方法句柄,和原本对应的调用指令是一致的。也就是说,对于原本用invokevirtual调用的方法句柄,它也会采用动态绑定;而对于原本用invokespecial调用的方法句柄,它会采用静态绑定。
|
||||
|
||||
```
|
||||
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不同,其权限检查是在句柄的创建阶段完成的。在实际调用过程中,Java虚拟机并不会检查方法句柄的权限。如果该句柄被多次调用的话,那么与反射调用相比,它将省下重复权限检查的开销。
|
||||
|
||||
需要注意的是,方法句柄的访问权限不取决于方法句柄的创建位置,而是取决于Lookup对象的创建位置。
|
||||
|
||||
举个例子,对于一个私有字段,如果Lookup对象是在私有字段所在类中获取的,那么这个Lookup对象便拥有对该私有字段的访问权限,即使是在所在类的外边,也能够通过该Lookup对象创建该私有字段的getter或者setter。
|
||||
|
||||
由于方法句柄没有运行时权限检查,因此,应用程序需要负责方法句柄的管理。一旦它发布了某些指向私有方法的方法句柄,那么这些私有方法便被暴露出去了。
|
||||
|
||||
## 方法句柄的操作
|
||||
|
||||
方法句柄的调用可分为两种,一是需要严格匹配参数类型的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指令对应的方法描述符,和该方法句柄的类型是否严格匹配。在不匹配的情况下,便会在运行时抛出异常。
|
||||
|
||||
如果你需要自动适配参数类型,那么你可以选取方法句柄的第二种调用方式invoke。它同样是一个签名多态性的方法。invoke会调用MethodHandle.asType方法,生成一个适配器方法句柄,对传入的参数进行适配,再调用原方法句柄。调用原方法句柄的返回值同样也会先进行适配,然后再返回给调用者。
|
||||
|
||||
方法句柄还支持增删改参数的操作,这些操作都是通过生成另一个方法句柄来实现的。这其中,改操作就是刚刚介绍的MethodHandle.asType方法。删操作指的是将传入的部分参数就地抛弃,再调用另一个方法句柄。它对应的API是MethodHandles.dropArguments方法。
|
||||
|
||||
增操作则非常有意思。它会往传入的参数中插入额外的参数,再调用另一个方法句柄,它对应的API是MethodHandle.bindTo方法。Java 8中捕获类型的Lambda表达式便是用这种操作来实现的,下一篇我会详细进行解释。
|
||||
|
||||
增操作还可以用来实现方法的柯里化[3]。举个例子,有一个指向f(x, y)的方法句柄,我们可以通过将x绑定为4,生成另一个方法句柄g(y) = f(4, y)。在执行过程中,每当调用g(y)的方法句柄,它会在参数列表最前面插入一个4,再调用指向f(x, y)的方法句柄。
|
||||
|
||||
## 方法句柄的实现
|
||||
|
||||
下面我们来看看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会对参数的类型进行校验,并在不匹配的情况下抛出异常。如果它直接调用了方法句柄所指向的方法,那么这部分参数类型校验的逻辑将无处安放。因此,唯一的可能便是Java虚拟机隐藏了部分栈信息。
|
||||
|
||||
当我们启用了-XX:+ShowHiddenFrames这个参数来打印被Java虚拟机隐藏了的栈信息时,你会发现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)
|
||||
|
||||
```
|
||||
|
||||
实际上,Java虚拟机会对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方法。
|
||||
|
||||
Java虚拟机同样会对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方法。估计你已经猜到了,Java虚拟机也会对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
|
||||
|
||||
```
|
||||
|
||||
可以看到,方法句柄的调用和反射调用一样,都是间接调用。因此,它也会面临无法内联的问题。不过,与反射调用不同的是,方法句柄的内联瓶颈在于即时编译器能否将该方法句柄识别为常量。具体内容我会在下一篇中进行详细的解释。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了invokedynamic底层机制的基石:方法句柄。
|
||||
|
||||
方法句柄是一个强类型的、能够被直接执行的引用。它仅关心所指向方法的参数类型以及返回类型,而不关心方法所在的类以及方法名。方法句柄的权限检查发生在创建过程中,相较于反射调用节省了调用时反复权限检查的开销。
|
||||
|
||||
方法句柄可以通过invokeExact以及invoke来调用。其中,invokeExact要求传入的参数和所指向方法的描述符严格匹配。方法句柄还支持增删改参数的操作,这些操作是通过生成另一个充当适配器的方法句柄来实现的。
|
||||
|
||||
方法句柄的调用和反射调用一样,都是间接调用,同样会面临无法内联的问题。
|
||||
|
||||
今天的实践环节,我们来测量一下方法句柄的性能。你可以尝试通过重构代码,将方法句柄变成常量,来提升方法句柄调用的性能。
|
||||
|
||||
```
|
||||
public class Foo {
|
||||
public void bar(Object o) {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Throwable {
|
||||
MethodHandles.Lookup l = MethodHandles.lookup();
|
||||
MethodType t = MethodType.methodType(void.class, Object.class);
|
||||
MethodHandle mh = l.findVirtual(Foo.class, "bar", t);
|
||||
|
||||
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;
|
||||
}
|
||||
mh.invokeExact(new Foo(), new Object());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
[1] [https://en.wikipedia.org/wiki/Duck_typing](https://en.wikipedia.org/wiki/Duck_typing)<br>
|
||||
[2]<br>
|
||||
[https://docs.oracle.com/javase/10/docs/api/java/lang/invoke/MethodHandle.html](https://docs.oracle.com/javase/10/docs/api/java/lang/invoke/MethodHandle.html)<br>
|
||||
[3]<br>
|
||||
[https://en.wikipedia.org/wiki/Currying](https://en.wikipedia.org/wiki/Currying)
|
||||
@@ -0,0 +1,528 @@
|
||||
<audio id="audio" title="09 | JVM是怎么实现invokedynamic的?(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/d0/ff432b97738d4bcee5c8c3d202ef8cd0.mp3"></audio>
|
||||
|
||||
上回讲到,为了让所有的动物都能参加赛马,Java 7引入了invokedynamic机制,允许调用任意类的“赛跑”方法。不过,我们并没有讲解invokedynamic,而是深入地探讨了它所依赖的方法句柄。
|
||||
|
||||
今天,我便来正式地介绍invokedynamic指令,讲讲它是如何生成调用点,并且允许应用程序自己决定链接至哪一个方法中的。
|
||||
|
||||
## invokedynamic指令
|
||||
|
||||
invokedynamic是Java 7引入的一条新指令,用以支持动态语言的方法调用。具体来说,它将调用点(CallSite)抽象成一个Java类,并且将原本由Java虚拟机控制的方法调用以及方法链接暴露给了应用程序。在运行过程中,每一条invokedynamic指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。
|
||||
|
||||
在第一次执行invokedynamic指令时,Java虚拟机会调用该指令所对应的启动方法(BootStrap Method),来生成前面提到的调用点,并且将之绑定至该invokedynamic指令中。在之后的运行过程中,Java虚拟机则会直接调用绑定的调用点所链接的方法句柄。
|
||||
|
||||
在字节码中,启动方法是用方法句柄来指定的。这个方法句柄指向一个返回类型为调用点的静态方法。该方法必须接收三个固定的参数,分别为一个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指令[1],所以接下来我会借助之前介绍过的字节码工具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的目的,也就是将调用点与目标方法的链接交由应用程序来做,并且依赖于应用程序对目标方法进行验证。所以,如果应用程序将赛跑方法链接至兔子的睡觉方法,那也只能怪应用程序自己了。
|
||||
|
||||
## Java 8的Lambda表达式
|
||||
|
||||
在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指令时,都会调用至这个方法中,并构造一个新的适配器类实例。
|
||||
|
||||
这个多出来的新建实例会对程序性能造成影响吗?
|
||||
|
||||
## Lambda以及方法句柄的性能分析
|
||||
|
||||
我再次请出测试反射调用性能开销的那段代码,并将其改造成使用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指令,Java虚拟机都会新建一个适配器类的实例。然而,实际运行结果还是与直接调用的性能一致。
|
||||
|
||||
```
|
||||
// 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倍。
|
||||
|
||||
尽管逃逸分析能够去除这些额外的新建实例开销,但是它也不是时时奏效。它需要同时满足两件事:invokedynamic指令所执行的方法句柄能够内联,和接下来的对accept方法的调用也能内联。
|
||||
|
||||
只有这样,逃逸分析才能判定该适配器实例不逃逸。否则,我们会在运行过程中不停地生成适配器类实例。所以,我们应当尽量使用非捕获的Lambda表达式。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了invokedynamic指令以及Lambda表达式的实现。
|
||||
|
||||
invokedymaic指令抽象出调用点的概念,并且将调用该调用点所链接的方法句柄。在第一次执行invokedynamic指令时,Java虚拟机将执行它所对应的启动方法,生成并且绑定一个调用点。之后如果再次执行该指令,Java虚拟机则直接调用已经绑定了的调用点所链接的方法。
|
||||
|
||||
Lambda表达式到函数式接口的转换是通过invokedynamic指令来实现的。该invokedynamic指令对应的启动方法将通过ASM生成一个适配器类。
|
||||
|
||||
对于没有捕获其他变量的Lambda表达式,该invokedynamic指令始终返回同一个适配器类的实例。对于捕获了其他变量的Lambda表达式,每次执行invokedynamic指令将新建一个适配器类实例。
|
||||
|
||||
不管是捕获型的还是未捕获型的Lambda表达式,它们的性能上限皆可以达到直接调用的性能。其中,捕获型Lambda表达式借助了即时编译器中的逃逸分析,来避免实际的新建适配器类实例的操作。
|
||||
|
||||
在上一篇的课后实践中,你应该测过这一段代码的性能开销了。我这边测得的结果约为直接调用的3.5倍。
|
||||
|
||||
```
|
||||
// v8版本
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
|
||||
public class Test {
|
||||
public static void target(int i) { }
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
MethodHandles.Lookup l = MethodHandles.lookup();
|
||||
MethodType t = MethodType.methodType(void.class, int.class);
|
||||
MethodHandle mh = l.findStatic(Test.class, "target", t);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
mh.invokeExact(128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
实际上,它与使用Lambda表达式或者方法引用的差别在于,即时编译器无法将该方法句柄识别为常量,从而无法进行内联。那么如果将它变成常量行不行呢?
|
||||
|
||||
一种方法便是将其赋值给final的静态变量,如下面的v9版本所示:
|
||||
|
||||
```
|
||||
// v9版本
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
|
||||
public class Test {
|
||||
public static void target(int i) { }
|
||||
|
||||
static final MethodHandle mh;
|
||||
static {
|
||||
try {
|
||||
MethodHandles.Lookup l = MethodHandles.lookup();
|
||||
MethodType t = MethodType.methodType(void.class, int.class);
|
||||
mh = l.findStatic(Test.class, "target", t);
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Throwable {
|
||||
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;
|
||||
}
|
||||
|
||||
mh.invokeExact(128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个版本测得的数据和直接调用的性能数据一致。也就是说,即时编译器能够将该方法句柄完全内联进来,成为空操作。
|
||||
|
||||
今天的实践环节,我们来继续探索方法句柄的性能。运行下面的v10版本以及v11版本,比较它们的性能并思考为什么。
|
||||
|
||||
```
|
||||
// v10版本
|
||||
import java.lang.invoke.*;
|
||||
|
||||
public class Test {
|
||||
public static void target(int i) {
|
||||
}
|
||||
|
||||
public static class MyCallSite {
|
||||
|
||||
public final MethodHandle mh;
|
||||
|
||||
public MyCallSite() {
|
||||
mh = findTarget();
|
||||
}
|
||||
|
||||
private static MethodHandle findTarget() {
|
||||
try {
|
||||
MethodHandles.Lookup l = MethodHandles.lookup();
|
||||
MethodType t = MethodType.methodType(void.class, int.class);
|
||||
return l.findStatic(Test.class, "target", t);
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final MyCallSite myCallSite = new MyCallSite();
|
||||
|
||||
public static void main(String[] args) throws Throwable {
|
||||
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;
|
||||
}
|
||||
|
||||
myCallSite.mh.invokeExact(128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v11版本
|
||||
import java.lang.invoke.*;
|
||||
|
||||
public class Test {
|
||||
public static void target(int i) {
|
||||
}
|
||||
|
||||
public static class MyCallSite extends ConstantCallSite {
|
||||
|
||||
public MyCallSite() {
|
||||
super(findTarget());
|
||||
}
|
||||
|
||||
private static MethodHandle findTarget() {
|
||||
try {
|
||||
MethodHandles.Lookup l = MethodHandles.lookup();
|
||||
MethodType t = MethodType.methodType(void.class, int.class);
|
||||
return l.findStatic(Test.class, "target", t);
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final MyCallSite myCallSite = new MyCallSite();
|
||||
|
||||
public static void main(String[] args) throws Throwable {
|
||||
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;
|
||||
}
|
||||
|
||||
myCallSite.getTarget().invokeExact(128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
感谢你的收听,我们下次再见。
|
||||
|
||||
[1] [http://openjdk.java.net/jeps/303](http://openjdk.java.net/jeps/303)
|
||||
|
||||
|
||||
171
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/10 | Java对象的内存布局.md
Normal file
171
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/10 | Java对象的内存布局.md
Normal file
@@ -0,0 +1,171 @@
|
||||
<audio id="audio" title="10 | Java对象的内存布局" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/14/3a8d369637ca245c3dca26cef8f41d14.mp3"></audio>
|
||||
|
||||
在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编译器会自动添加对父类构造器的调用。但是,如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器。
|
||||
|
||||
显式调用又可分为两种,一是直接使用“super”关键字调用父类构造器,二是使用“this”关键字调用同一个类中的其他构造器。无论是直接的显式调用,还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段。(不过这可以通过调用其他生成参数的方法,或者字节码注入来绕开。)
|
||||
|
||||
总而言之,当我们调用一个构造器时,它将优先调用父类的构造器,直至Object类。这些构造器的调用者皆为同一对象,也就是通过new指令新建而来的对象。
|
||||
|
||||
你应该已经发现了其中的玄机:通过new指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。
|
||||
|
||||
这些字段在内存中的具体分布是怎么样的呢?今天我们就来看看对象的内存布局。
|
||||
|
||||
## 压缩指针
|
||||
|
||||
在Java虚拟机中,每个Java对象都有一个对象头(object header),这个由标记字段和类型指针所构成。其中,标记字段用以存储Java虚拟机有关该对象的运行数据,如哈希码、GC信息以及锁信息,而类型指针则指向该对象的类。
|
||||
|
||||
在64位的Java虚拟机中,对象头的标记字段占64位,而类型指针又占了64位。也就是说,每一个Java对象在内存中的额外开销就是16个字节。以Integer类为例,它仅有一个int类型的私有字段,占4个字节。因此,每一个Integer对象的额外内存开销至少是400%。这也是为什么Java要引入基本类型的原因之一。
|
||||
|
||||
为了尽量较少对象的内存使用量,64位Java虚拟机引入了压缩指针[1]的概念(对应虚拟机选项-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)。
|
||||
|
||||
默认情况下,Java虚拟机堆中对象的起始地址需要对齐至8的倍数。如果一个对象用不到8N个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)。
|
||||
|
||||
在默认情况下,Java虚拟机中的32位压缩指针可以寻址到2的35次方个字节,也就是32GB的地址空间(超过32GB则会关闭压缩指针)。
|
||||
|
||||
在对压缩指针解引用时,我们需要将其左移3位,再加上一个固定偏移量,便可以得到能够寻址32GB地址空间的伪64位指针了。
|
||||
|
||||
此外,我们可以通过配置刚刚提到的内存对齐选项(-XX:ObjectAlignmentInBytes)来进一步提升寻址范围。但是,这同时也可能增加对象间填充,导致压缩指针没有达到原本节省空间的效果。
|
||||
|
||||
举例来说,如果规定每辆车都需要从偶数车位号停起,那么对于占据两个车位的小房车来说刚刚好,而对于需要三个车位的大房车来说,也仅是浪费一个车位。
|
||||
|
||||
但是如果规定需要从4的倍数号车位停起,那么小房车则会浪费两个车位,而大房车至多可能浪费三个车位。
|
||||
|
||||
当然,就算是关闭了压缩指针,Java虚拟机还是会进行内存对齐。此外,内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java虚拟机要求long字段、double字段,以及非压缩指针状态下的引用字段地址为8的倍数。
|
||||
|
||||
字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。
|
||||
|
||||
下面我来介绍一下对象内存布局另一个有趣的特性:字段重排列。
|
||||
|
||||
## 字段重排列
|
||||
|
||||
字段重排列,顾名思义,就是Java虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java虚拟机中有三种排列方法(对应Java虚拟机选项-XX:FieldsAllocationStyle,默认值为1),但都会遵循如下两个规则。
|
||||
|
||||
其一,如果一个字段占据C个字节,那么该字段的偏移量需要对齐至NC。这里偏移量指的是字段地址与对象的起始地址差值。
|
||||
|
||||
以long类为例,它仅有一个long类型的实例字段。在使用了压缩指针的64位虚拟机中,尽管对象头的大小为12个字节,该long类型字段的偏移量也只能是16,而中间空着的4个字节便会被浪费掉。
|
||||
|
||||
其二,子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。
|
||||
|
||||
在具体实现中,Java虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的64位虚拟机,子类第一个字段需要对齐至4N;而对于关闭了压缩指针的64位虚拟机,子类第一个字段则需要对齐至8N。
|
||||
|
||||
```
|
||||
class A {
|
||||
long l;
|
||||
int i;
|
||||
}
|
||||
|
||||
class B extends A {
|
||||
long l;
|
||||
int i;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我在文中贴了一段代码,里边定义了两个类A和B,其中B继承A。A和B各自定义了一个long类型的实例字段和一个int类型的实例字段。下面我分别打印了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)
|
||||
|
||||
```
|
||||
|
||||
当启用压缩指针时,可以看到Java虚拟机将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字节的空白。那么我们可不可以将B类的int字段移至前面的空白中,从而节省这8字节呢?
|
||||
|
||||
我认为是可以的,并且我修改过后的Java虚拟机也没有跑崩。由于HotSpot中的这块代码年久失修,公司的同事也已经记不得是什么原因了,那么姑且先认为是一些历史遗留问题吧。
|
||||
|
||||
Java 8还引入了一个新的注释@Contended,用来解决对象字段之间的虚共享(false sharing)问题[2]。这个注释也会影响到字段的排列。
|
||||
|
||||
虚共享是怎么回事呢?假设两个线程分别访问同一对象中不同的volatile字段,逻辑上它们并没有共享内容,因此不需要同步。
|
||||
|
||||
然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。(volatile字段和缓存行的故事我会在之后的篇章中详细介绍。)
|
||||
|
||||
Java虚拟机会让不同的@Contended字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。具体的分布算法属于实现细节,随着Java版本的变动也比较大,因此这里就不做阐述了。
|
||||
|
||||
如果你感兴趣,可以利用实践环节的工具,来查阅Contended字段的内存布局。注意使用虚拟机选项-XX:-RestrictContended。如果你在Java 9以上版本试验的话,在使用javac编译时需要添加 --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAME
|
||||
|
||||
## 总结和实践
|
||||
|
||||
今天我介绍了Java虚拟机构造对象的方式,所构造对象的大小,以及对象的内存布局。
|
||||
|
||||
常见的new语句会被编译为new指令,以及对构造器的调用。每个类的构造器皆会直接或者间接调用父类的构造器,并且在同一个实例中初始化相应的字段。
|
||||
|
||||
Java虚拟机引入了压缩指针的概念,将原本的64位指针压缩成32位。压缩指针要求Java虚拟机堆中对象的起始地址要对齐至8的倍数。Java虚拟机还会对每个类的字段进行重排列,使得字段也能够内存对齐。
|
||||
|
||||
今天的实践环节比较简单,你可以使用我在工具篇中介绍过的JOL工具,来打印你工程中的类的字段分布情况。
|
||||
|
||||
```
|
||||
curl -L -O http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar
|
||||
java -cp jol-cli-0.9-full.jar org.openjdk.jol.Main internals java.lang.String
|
||||
|
||||
```
|
||||
|
||||
[1] [https://wiki.openjdk.java.net/display/HotSpot/CompressedOops](https://wiki.openjdk.java.net/display/HotSpot/CompressedOops)<br />
|
||||
[2] [http://openjdk.java.net/jeps/142](http://openjdk.java.net/jeps/142)
|
||||
|
||||
|
||||
146
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/11 | 垃圾回收(上).md
Normal file
146
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/11 | 垃圾回收(上).md
Normal file
@@ -0,0 +1,146 @@
|
||||
<audio id="audio" title="11 | 垃圾回收(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/26/b16393811800cc02c9df9cca12125526.mp3"></audio>
|
||||
|
||||
你应该听说过这么一句话:免费的其实是最贵的。
|
||||
|
||||
Java虚拟机的自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。不过既然是自动机制,肯定没法做到像手动回收那般精准高效[1] ,而且还会带来不少与垃圾回收实现相关的问题。
|
||||
|
||||
接下来的两篇,我们会深入探索Java虚拟机中的垃圾回收器。今天这一篇,我们来回顾一下垃圾回收的基础知识。
|
||||
|
||||
## 引用计数法与可达性分析
|
||||
|
||||
垃圾回收,顾名思义,便是将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配。在Java虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。这里便涉及了一个关键的问题:如何辨别一个对象是存是亡?
|
||||
|
||||
我们先来讲一种古老的辨别方法:引用计数法(reference counting)。它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,便可以被回收了。
|
||||
|
||||
它的具体实现是这样子的:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器+1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器-1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。
|
||||
|
||||
除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。
|
||||
|
||||
举个例子,假设对象a与b相互引用,除此之外没有其他引用指向a或者b。在这种情况下,a和b实际上已经死了,但由于它们的引用计数器皆不为0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/85/b9/8546a9b3c6660a31ae24bef0ef0a35b9.png" alt="" />
|
||||
|
||||
目前Java虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列GC Roots作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。
|
||||
|
||||
那么什么是GC Roots呢?我们可以暂时理解为由堆外指向堆内的引用,一般而言,GC Roots包括(但不限于)如下几种:
|
||||
|
||||
1. Java方法栈桢中的局部变量;
|
||||
1. 已加载类的静态变量;
|
||||
1. JNI handles;
|
||||
1. 已启动且未停止的Java线程。
|
||||
|
||||
可达性分析可以解决引用计数法所不能解决的循环引用问题。举例来说,即便对象a和b相互引用,只要从GC Roots出发无法到达a或者b,那么可达性分析便不会将它们加入存活对象合集之中。
|
||||
|
||||
虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。
|
||||
|
||||
比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为null)或者漏报(将引用设置为未被访问过的对象)。
|
||||
|
||||
误报并没有什么伤害,Java虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致Java虚拟机崩溃。
|
||||
|
||||
## Stop-the-world以及安全点
|
||||
|
||||
怎么解决这个问题呢?在Java虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。
|
||||
|
||||
Java虚拟机中的Stop-the-world是通过安全点(safepoint)机制来实现的。当Java虚拟机收到Stop-the-world请求,它便会等待所有的线程都到达安全点,才允许请求Stop-the-world的线程进行独占的工作。
|
||||
|
||||
这篇博客[2]还提到了一种比较另类的解释:安全词。一旦垃圾回收线程喊出了安全词,其他非垃圾回收线程便会一一停下。
|
||||
|
||||
当然,安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。
|
||||
|
||||
举个例子,当Java程序通过JNI执行本地代码时,如果这段代码不访问Java对象、调用Java方法或者返回至原Java方法,那么Java虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。
|
||||
|
||||
只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
|
||||
|
||||
由于本地代码需要通过JNI的API来完成上述三个操作,因此Java虚拟机仅需在API的入口处进行安全点检测(safepoint poll),测试是否有其他线程请求停留在安全点里,便可以在必要的时候挂起当前线程。
|
||||
|
||||
除了执行JNI本地代码外,Java线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。阻塞的线程由于处于Java虚拟机线程调度器的掌控之下,因此属于安全点。
|
||||
|
||||
其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间。
|
||||
|
||||
对于解释执行来说,字节码与字节码之间皆可作为安全点。Java虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。
|
||||
|
||||
执行即时编译器生成的机器码则比较复杂。由于这些代码直接运行在底层硬件之上,不受Java虚拟机掌控,因此在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。HotSpot虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。
|
||||
|
||||
那么为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测呢?原因主要有两个。
|
||||
|
||||
第一,安全点检测本身也有一定的开销。不过HotSpot虚拟机已经将机器码中安全点检测简化为一个内存访问操作。在有安全点请求的情况下,Java虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个segfault处理器,来截获因访问该不可读内存而触发segfault的线程,并将它们挂起。
|
||||
|
||||
第二,即时编译器生成的机器码打乱了原本栈桢上的对象分布状况。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举GC Roots。
|
||||
|
||||
由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。
|
||||
|
||||
不过,不同的即时编译器插入安全点检测的位置也可能不同。以Graal为例,除了上述位置外,它还会在计数循环的循环回边处插入安全点检测。其他的虚拟机也可能选取方法入口而非方法出口来插入安全点检测。
|
||||
|
||||
不管如何,其目的都是在可接受的性能开销以及内存开销之内,避免机器码长时间不进入安全点的情况,间接地减少垃圾回收的暂停时间。
|
||||
|
||||
除了垃圾回收之外,Java虚拟机其他一些对堆栈内容的一致性有要求的操作也会用到安全点这一机制。我会在涉及的时侯再进行具体的讲解。
|
||||
|
||||
## 垃圾回收的三种方式
|
||||
|
||||
当标记完所有的存活对象时,我们便可以进行死亡对象的回收工作了。主流的基础回收方式可分为三种。
|
||||
|
||||
第一种是清除(sweep),即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/03/f225126be24826658ca5a899fcff5003.png" alt="" />
|
||||
|
||||
清除这种回收方式的原理及其简单,但是有两个缺点。一是会造成内存碎片。由于Java虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
|
||||
|
||||
另一个则是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
|
||||
|
||||
第二种是压缩(compact),即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/39/415ee8e4aef12ff076b42e41660dad39.png" alt="" />
|
||||
|
||||
第三种则是复制(copy),即把内存区域分为两等分,分别用两个指针from和to来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to指针指向的内存区域中,并且交换from指针和to指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/61/4749cad235deb1542d4ca3b232ebf261.png" alt="" />
|
||||
|
||||
当然,现代的垃圾回收器往往会综合上述几种回收方式,综合它们优点的同时规避它们的缺点。在下一篇中我们会详细介绍Java虚拟机中垃圾回收算法的具体实现。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了垃圾回收的一些基础知识。
|
||||
|
||||
Java虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。它从一系列GC Roots出发,边标记边探索所有被引用的对象。
|
||||
|
||||
为了防止在标记过程中堆栈的状态发生改变,Java虚拟机采取安全点机制来实现Stop-the-world操作,暂停其他非垃圾回收线程。
|
||||
|
||||
回收死亡对象的内存共有三种方式,分别为:会造成内存碎片的清除、性能开销较大的压缩、以及堆使用效率较低的复制。
|
||||
|
||||
今天的实践环节,你可以体验一下无安全点检测的计数循环带来的长暂停。你可以分别测单独跑foo方法或者bar方法的时间,然后与合起来跑的时间比较一下。
|
||||
|
||||
```
|
||||
|
||||
// time java SafepointTestp
|
||||
/ 你还可以使用如下几个选项
|
||||
// -XX:+PrintGC
|
||||
// -XX:+PrintGCApplicationStoppedTime
|
||||
// -XX:+PrintSafepointStatistics
|
||||
// -XX:+UseCountedLoopSafepoints
|
||||
public class SafepointTest {
|
||||
static double sum = 0;
|
||||
|
||||
public static void foo() {
|
||||
for (int i = 0; i < 0x77777777; i++) {
|
||||
sum += Math.sqrt(i);
|
||||
}
|
||||
}
|
||||
|
||||
public static void bar() {
|
||||
for (int i = 0; i < 50_000_000; i++) {
|
||||
new Object().hashCode();
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new Thread(SafepointTest::foo).start();
|
||||
new Thread(SafepointTest::bar).start();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
[1] [https://media.giphy.com/media/EZ8QO0myvsSk/giphy.gif](https://media.giphy.com/media/EZ8QO0myvsSk/giphy.gif)<br />
|
||||
[2] [http://psy-lob-saw.blogspot.com/2015/12/safepoints.html](http://psy-lob-saw.blogspot.com/2015/12/safepoints.html)
|
||||
|
||||
|
||||
180
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/12 | 垃圾回收(下).md
Normal file
180
极客时间专栏/深入拆解Java虚拟机/模块一:Java虚拟机基本原理/12 | 垃圾回收(下).md
Normal file
@@ -0,0 +1,180 @@
|
||||
<audio id="audio" title="12 | 垃圾回收(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/8f/7f291912c1698a70c4df55e7fa51a58f.mp3"></audio>
|
||||
|
||||
在读博士的时候,我曾经写过一个统计Java对象生命周期的动态分析,并且用它来跑了一些基准测试。
|
||||
|
||||
其中一些程序的结果,恰好验证了许多研究人员的假设,即大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长一段时间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/89/e235d25ca15b60a511a2d90317eb9589.png" alt="" />
|
||||
|
||||
(pmd中Java对象生命周期的直方图,红色的表示被逃逸分析优化掉的对象)
|
||||
|
||||
之所以要提到这个假设,是因为它造就了Java虚拟机的分代回收思想。简单来说,就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。
|
||||
|
||||
Java虚拟机可以给不同代使用不同的回收算法。对于新生代,我们猜测大部分的Java对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。
|
||||
|
||||
对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。
|
||||
|
||||
这时候,Java虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)
|
||||
|
||||
今天这一篇我们来关注一下针对新生代的Minor GC。首先,我们来看看Java虚拟机中的堆具体是怎么划分的。
|
||||
|
||||
## Java虚拟机的堆划分
|
||||
|
||||
前面提到,Java虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为Eden区,以及两个大小相同的Survivor区。
|
||||
|
||||
默认情况下,Java虚拟机采取的是一种动态分配的策略(对应Java虚拟机参数-XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。
|
||||
|
||||
当然,你也可以通过参数-XX:SurvivorRatio来固定这个比例。但是需要注意的是,其中一个Survivor区会一直为空,因此比例越低浪费的堆空间将越高。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/e5/2cc29b8de676d3747416416a3523e4e5.png" alt="" />
|
||||
|
||||
通常来说,当我们调用new指令时,它会在Eden区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。
|
||||
|
||||
否则,将有可能出现两个对象共用一段内存的事故。如果你还记得前两篇我用“停车位”打的比方的话,这里就相当于两个司机(线程)同时将车停入同一个停车位,因而发生剐蹭事故。
|
||||
|
||||
Java虚拟机的解决方法是为每个司机预先申请多个停车位,并且只允许该司机停在自己的停车位上。那么当司机的停车位用完了该怎么办呢(假设这个司机代客泊车)?
|
||||
|
||||
答案是:再申请多个停车位便可以了。这项技术被称之为TLAB(Thread Local Allocation Buffer,对应虚拟机参数-XX:+UseTLAB,默认开启)。
|
||||
|
||||
具体来说,每个线程可以向Java虚拟机申请一段连续的内存,比如2048字节,作为线程私有的TLAB。
|
||||
|
||||
这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向TLAB中空余内存的起始位置,一个则指向TLAB末尾。
|
||||
|
||||
接下来的new指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。
|
||||
|
||||
>
|
||||
我猜测会有留言问为什么不把bump the pointer翻译成指针碰撞。这里先解释一下,在英语中我们通常省略了bump up the pointer中的up。在这个上下文中bump的含义应为“提高”。另外一个例子是当我们发布软件的新版本时,也会说bump the version number。
|
||||
|
||||
|
||||
如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的TLAB。
|
||||
|
||||
当Eden区的空间耗尽了怎么办?这个时候Java虚拟机便会触发一次Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到Survivor区。
|
||||
|
||||
前面提到,新生代共有两个Survivor区,我们分别用from和to来指代。其中to指向的Survivior区是空的。
|
||||
|
||||
当发生Minor GC时,Eden区和from指向的Survivor区中的存活对象会被复制到to指向的Survivor区中,然后交换from和to指针,以保证下一次Minor GC时,to指向的Survivor区还是空的。
|
||||
|
||||
Java虚拟机会记录Survivor区中的对象一共被来回复制了几次。如果一个对象被复制的次数为15(对应虚拟机参数-XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个Survivor区已经被占用了50%(对应虚拟机参数-XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
|
||||
|
||||
总而言之,当发生Minor GC时,我们应用了标记-复制算法,将Survivor区中的老存活对象晋升到老年代,然后将剩下的存活对象和Eden区的存活对象复制到另一个Survivor区中。理想情况下,Eden区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记-复制算法的效果极好。
|
||||
|
||||
Minor GC的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots。
|
||||
|
||||
这样一来,岂不是又做了一次全堆扫描呢?
|
||||
|
||||
## 卡表
|
||||
|
||||
HotSpot给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
|
||||
|
||||
在进行Minor GC的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。
|
||||
|
||||
由于Minor GC伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。
|
||||
|
||||
在Minor GC之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。
|
||||
|
||||
首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么Java虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。
|
||||
|
||||
这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和volatile字段的写屏障混淆)。
|
||||
|
||||
写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。
|
||||
|
||||
因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。
|
||||
|
||||
这么一来,写屏障便可精简为下面的伪代码[1]。这里右移9位相当于除以512,Java虚拟机便是通过这种方式来从地址映射到卡表中的索引的。最终,这段代码会被编译成一条移位指令和一条存储指令。
|
||||
|
||||
```
|
||||
CARD_TABLE [this address >> 9] = DIRTY;
|
||||
|
||||
```
|
||||
|
||||
虽然写屏障不可避免地带来一些开销,但是它能够加大Minor GC的吞吐率( 应用运行时间/(应用运行时间+垃圾回收时间) )。总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题[2]。
|
||||
|
||||
在介绍对象内存布局中我曾提到虚共享问题,讲的是几个volatile字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。
|
||||
|
||||
在HotSpot中,卡表是通过byte数组来实现的。对于一个64字节的缓存行来说,如果用它来加载部分卡表,那么它将对应64张卡,也就是32KB的内存。
|
||||
|
||||
如果同时有两个Java线程,在这32KB内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。
|
||||
|
||||
为此,HotSpot引入了一个新的参数-XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:
|
||||
|
||||
```
|
||||
if (CARD_TABLE [this address >> 9] != DIRTY)
|
||||
CARD_TABLE [this address >> 9] = DIRTY;
|
||||
|
||||
```
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了Java虚拟机中垃圾回收具体实现的一些通用知识。
|
||||
|
||||
Java虚拟机将堆分为新生代和老年代,并且对不同代采用不同的垃圾回收算法。其中,新生代分为Eden区和两个大小一致的Survivor区,并且其中一个Survivor区是空的。
|
||||
|
||||
在只针对新生代的Minor GC中,Eden区和非空Survivor区的存活对象会被复制到空的Survivor区中,当Survivor区中的存活对象复制次数超过一定数值时,它将被晋升至老年代。
|
||||
|
||||
因为Minor GC只针对新生代进行垃圾回收,所以在枚举GC Roots的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。
|
||||
|
||||
由于篇幅的原因,我没有讲解Java虚拟机中具体的垃圾回收器。我在文章的末尾附了一段简单的介绍,如果你有兴趣的话可以参阅一下。
|
||||
|
||||
今天的实践环节,我们来看看Java对象的生命周期对垃圾回收的影响。
|
||||
|
||||
前面提到,Java虚拟机的分代垃圾回收是基于大部分对象只存活一小段时间,小部分对象却存活一大段时间的假设的。
|
||||
|
||||
然而,现实情况中并非每个程序都符合前面提到的假设。如果一个程序拥有中等生命周期的对象,并且刚移动到老年代便不再使用,那么将给默认的垃圾回收策略造成极大的麻烦。
|
||||
|
||||
下面这段程序将生成64G的Java对象。并且,我通过ALIVE_OBJECT_SIZE这一变量来定义同时存活的Java对象的大小。这也是一种对于垃圾回收器来说比较直观的生命周期。
|
||||
|
||||
当我们使用Java 8的默认GC,并且将新生代的空间限制在100M时,试着估算当ALIVE_OBJECT_SIZE为多少时,这段程序不会触发Full GC(提示一下,如果Survivor区没法存储所有存活对象,将发生什么。)。实际运行情况又是怎么样的?
|
||||
|
||||
```
|
||||
// Run with java -XX:+PrintGC -Xmn100M -XX:PretenureSizeThreshold=10000 LifetimeTest
|
||||
// You may also try with -XX:+PrintHeapAtGC,-XX:-UsePSAdaptiveSurvivorSizePolicy or -XX:SurvivorRatio=N
|
||||
public class LifetimeTest {
|
||||
private static final int K = 1024;
|
||||
private static final int M = K * K;
|
||||
private static final int G = K * M;
|
||||
|
||||
private static final int ALIVE_OBJECT_SIZE = 32 * M;
|
||||
|
||||
public static void main(String[] args) {
|
||||
int length = ALIVE_OBJECT_SIZE / 64;
|
||||
ObjectOf64Bytes[] array = new ObjectOf64Bytes[length];
|
||||
for (long i = 0; i < G; i++) {
|
||||
array[(int) (i % length)] = new ObjectOf64Bytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ObjectOf64Bytes {
|
||||
long placeholder0;
|
||||
long placeholder1;
|
||||
long placeholder2;
|
||||
long placeholder3;
|
||||
long placeholder4;
|
||||
long placeholder5;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 附录:Java虚拟机中的垃圾回收器
|
||||
|
||||
针对新生代的垃圾回收器共有三个:Serial,Parallel Scavenge和Parallel New。这三个采用的都是标记-复制算法。其中,Serial是一个单线程的,Parallel New可以看成Serial的多线程版本。Parallel Scavenge和Parallel New类似,但更加注重吞吐率。此外,Parallel Scavenge不能与CMS一起使用。
|
||||
|
||||
针对老年代的垃圾回收器也有三个:刚刚提到的Serial Old和Parallel Old,以及CMS。Serial Old和Parallel Old都是标记-压缩算法。同样,前者是单线程的,而后者可以看成前者的多线程版本。
|
||||
|
||||
CMS采用的是标记-清除算法,并且是并发的。除了少数几个操作需要Stop-the-world之外,它可以在应用程序运行过程中进行垃圾回收。在并发收集失败的情况下,Java虚拟机会使用其他两个压缩型垃圾回收器进行一次垃圾回收。由于G1的出现,CMS在Java 9中已被废弃[3]。
|
||||
|
||||
G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它已经打乱了前面所说的堆结构,直接将堆分成极其多个区域。每个区域都可以充当Eden区、Survivor区或者老年代中的一个。它采用的是标记-压缩算法,而且和CMS一样都能够在应用程序运行过程中并发地进行垃圾回收。
|
||||
|
||||
G1能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是G1名字的由来。
|
||||
|
||||
即将到来的Java 11引入了ZGC,宣称暂停时间不超过10ms。如果你感兴趣的话,可参考R大的这篇文章[4]。
|
||||
|
||||
[1]<br />
|
||||
[http://psy-lob-saw.blogspot.com/2014/10/the-jvm-write-barrier-card-marking.html](http://psy-lob-saw.blogspot.com/2014/10/the-jvm-write-barrier-card-marking.html)<br />
|
||||
[2]<br />
|
||||
[https://blogs.oracle.com/dave/false-sharing-induced-by-card-table-marking](https://blogs.oracle.com/dave/false-sharing-induced-by-card-table-marking)<br />
|
||||
[3]<br />
|
||||
[http://openjdk.java.net/jeps/291](http://openjdk.java.net/jeps/291)<br />
|
||||
[4] [https://www.zhihu.com/question/287945354/answer/458761494](https://www.zhihu.com/question/287945354/answer/458761494)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user