This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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程序员经常接触到的JDKJava开发工具包同样包含了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]
; 加载&quot;Hello, World!\n&quot;
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 &quot;Hello, World!&quot;
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 compilationJIT即将一个方法中包含的所有字节码编译成机器码后再执行。
<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(&quot;Hello, Java!&quot;);
if (flag == true) System.out.println(&quot;Hello, JVM!&quot;);
}
}' &gt; Foo.java
$ javac Foo.java
$ java Foo
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class &gt; Foo.jasm.1
$ awk 'NR==1,/iconst_1/{sub(/iconst_1/, &quot;iconst_2&quot;)} 1' Foo.jasm.1 &gt; 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)

View 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(&quot;吃了&quot;);
if (true == 吃过饭没) System.out.println(&quot;真吃了&quot;);
}
}
```
在上一篇结尾的小作业里我构造了这么一段代码它将一个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 &quot;吃了&quot;
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 &quot;真吃了&quot;
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又对应什么浮点数呢
这个数字对应的浮点数是NaNNot-a-Number
不仅如此,[0x7F800001, 0x7FFFFFFF]和[0xFF800001, 0xFFFFFFFF]对应的都是NaN。当然一般我们计算得出的NaN比如说通过+0.0F/+0.0F在内存中应为0x7FC00000。这个数值我们称之为标准的NaN而其他的我们称之为不标准的NaN。
NaN有一个有趣的特性除了“!=”始终返回true之外所有其他比较结果都会返回false。
举例来说“NaN&lt;1.0F”返回false而“NaN&gt;=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(&quot;Hello, Java!&quot;);
if (boolValue == true) System.out.println(&quot;Hello, JVM!&quot;);
}
}
```

View 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编译器置于同一方法中并把它命名为&lt; clinit &gt;
类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行&lt; clinit &gt;方法的过程。Java虚拟机会通过加锁来确保类的&lt; clinit &gt;方法仅被执行一次。
只有当初始化完成之后类才正式成为可执行的状态。这放在我们盖房子的例子中就是只有当房子装修过后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虚拟机中使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中解析阶段为非必须的。
初始化,则是为标记为常量值的字段赋值,以及执行&lt; clinit &gt;方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。
今天的实践环节,你可以来验证一下本篇中的理论知识。
通过JVM参数-verbose:class来打印类加载的先后顺序并且在LazyHolder的初始化方法中打印特定字样。在命令行中运行下述指令不包含提示符$
```
$ echo '
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
static {
System.out.println(&quot;LazyHolder.&lt;clinit&gt;&quot;);
}
}
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(&quot;----&quot;);
getInstance(false);
}
}' &gt; 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 &gt; Singleton\$LazyHolder.jasm.1
$ awk 'NR==1,/stack 1/{sub(/stack 1/, &quot;stack 0&quot;)} 1' Singleton\$LazyHolder.jasm.1 &gt; Singleton\$LazyHolder.jasm
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Singleton\$LazyHolder.jasm
$ java -verbose:class Singleton
```
问题2新建数组会导致LazyHolder的链接吗

View 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-boxingauto-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为接口符号引用指向接口方法&quot;客户.isVIP()&quot;。而#22为非接口符号引用指向静态方法&quot;奸商.价格歧视()&quot;。
$ 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&lt;T extends Customer&gt; {
public double actionPrice(double price, T customer) {
...
}
}
class VIPOnlyMerchant extends Merchant&lt;VIP&gt; {
@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)

View 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 tablevtable为例介绍方法表的用法。invokeinterface所使用的接口方法表interface method tableitable稍微复杂些但是原理其实是类似的。
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。
这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。
我们知道,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。
在执行过程中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 &lt;= 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 &lt; 1_000_000_000) ? a : b;
c.passThroughImmigration();
}
}
}
class ChinesePassenger extends Passenger {
@Override void passThroughImmigration() {}
}
class ForeignerPassenger extends Passenger {
@Override void passThroughImmigration() {}
}
```

View 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 indexbci用以定位字节码。
其中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(&quot;in0.txt&quot;));
...
try {
in1 = new FileInputStream(new File(&quot;in1.txt&quot;));
...
try {
in2 = new FileInputStream(new File(&quot;in2.txt&quot;));
...
} 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(&quot;Foo0&quot;); // try-with-resources
Foo foo1 = new Foo(&quot;Foo1&quot;);
Foo foo2 = new Foo(&quot;Foo2&quot;)) {
throw new RuntimeException(&quot;Initial&quot;);
}
}
}
// 运行结果:
Exception in thread &quot;main&quot; 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 &lt; 100; i++) {
try {
tryBlock = 0;
if (i &lt; 50) {
continue;
} else if (i &lt; 80) {
break;
} else {
return;
}
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
}
methodExit = 3;
}
}
```

View 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(&quot;#&quot; + i).printStackTrace();
}
public static void main(String[] args) throws Exception {
Class&lt;?&gt; klass = Class.forName(&quot;Test&quot;);
Method method = klass.getMethod(&quot;target&quot;, 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(&quot;#&quot; + i).printStackTrace();
}
public static void main(String[] args) throws Exception {
Class&lt;?&gt; klass = Class.forName(&quot;Test&quot;);
Method method = klass.getMethod(&quot;target&quot;, int.class);
for (int i = 0; i &lt; 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.forNameClass.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&lt;?&gt; klass = Class.forName(&quot;Test&quot;);
Method method = klass.getMethod(&quot;target&quot;, int.class);
long current = System.currentTimeMillis();
for (int i = 1; i &lt;= 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&lt;?&gt; klass = Class.forName(&quot;Test&quot;);
Method method = klass.getMethod(&quot;target&quot;, int.class);
Object[] arg = new Object[1]; // 在循环外构造参数数组
arg[0] = 128;
long current = System.currentTimeMillis();
for (int i = 1; i &lt;= 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&lt;?&gt; klass = Class.forName(&quot;Test&quot;);
Method method = klass.getMethod(&quot;target&quot;, int.class);
method.setAccessible(true); // 关闭权限检查
long current = System.currentTimeMillis();
for (int i = 1; i &lt;= 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或者invokeinterfaceJava虚拟机会记录下调用者的具体类型我们称之为类型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&lt;?&gt; klass = Class.forName(&quot;Test&quot;);
Method method = klass.getMethod(&quot;target&quot;, int.class);
method.setAccessible(true); // 关闭权限检查
polluteProfile();
long current = System.currentTimeMillis();
for (int i = 1; i &lt;= 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(&quot;target1&quot;, int.class);
Method method2 = Test.class.getMethod(&quot;target2&quot;, int.class);
for (int i = 0; i &lt; 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&lt;?&gt; klass = Class.forName(&quot;Test&quot;);
Method method = klass.getMethod(&quot;target&quot;, int.class);
method.setAccessible(true); // 关闭权限检查
polluteProfile();
long current = System.currentTimeMillis();
for (int i = 1; i &lt;= 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(&quot;target&quot;, int.class);
Method method2 = Test.class.getMethod(&quot;target&quot;, int.class);
for (int i = 0; i &lt; 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)

View File

@@ -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(&quot;Horse.race()&quot;);
}
}
class Deer {
public void race() {
System.out.println(&quot;Deer.race()&quot;);
}
}
class Cobra {
public void race() {
System.out.println(&quot;How do you turn this on?&quot;);
}
}
```
(如何用同一种方式调用他们的赛跑方法?)
说到了这里如果我们将赛跑定义为对赛跑方法对应上述代码中的race())的调用的话,那么这个故事的关键,就在于能不能在马场中调用非马类型的赛跑方法。
为了解答这个问题我们先来回顾一下Java里的方法调用。在Java中方法调用会被编译为invokestaticinvokespecialinvokevirtual以及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(&quot;bar&quot;, Object.class);
MethodHandle mh0 = l.unreflect(m);
MethodType t = MethodType.methodType(void.class, Object.class);
MethodHandle mh1 = l.findStatic(Foo.class, &quot;bar&quot;, 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, &quot;bar&quot;, 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)=&gt;{
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 &lt;&lt;Foo.bar(Object)void/invokeStatic&gt;&gt;
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, &quot;bar&quot;, t);
long current = System.currentTimeMillis();
for (int i = 1; i &lt;= 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)

View File

@@ -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(&quot;Horse.race()&quot;);
}
}
class Deer {
public void race() {
System.out.println(&quot;Deer.race()&quot;);
}
}
// 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 = &quot;bootstrap&quot;;
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 = &quot;race&quot;;
private static final String TARGET_METHOD_DESC = &quot;(Ljava/lang/Object;)V&quot;;
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(&quot;Circuit&quot;);
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 (&quot;startRace&quot;.equals(name)) {
return new MyMethodVisitor(ASM6, visitor);
}
return visitor;
}
};
cr.accept(cv, ClassReader.SKIP_FRAMES);
Files.write(Paths.get(&quot;Circuit.class&quot;), 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&lt;?&gt; 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, &quot;invoke&quot;, 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 -&gt; i * 2).map(i -&gt; i * x);
```
举个例子上面这段代码会对IntStream中的元素进行两次映射。我们知道映射方法map所接收的参数是IntUnaryOperator这是一个函数式接口。也就是说在运行过程中我们需要将i-&gt;i**2和i-&gt;i**x 这两个Lambda表达式转化成IntUnaryOperator的实例。这个转化过程便是由invokedynamic来实现的。
在编译过程中Java编译器会对Lambda表达式进行解语法糖desugar生成一个方法来保存Lambda表达式的内容。该方法的参数列表不仅包含原本Lambda表达式的参数还包含它所捕获的变量。(注方法引用如Horse::race则不会生成生成额外的方法。)
在上面那个例子中第一个Lambda表达式没有捕获其他变量而第二个Lambda表达式也就是i-&gt;i*x则会捕获局部变量x。这两个Lambda表达式对应的方法如下所示。可以看到所捕获的变量同样也会作为参数传入生成的方法之中。
```
// i -&gt; i * 2
private static int lambda$0(int);
Code:
0: iload_0
1: iconst_2
2: imul
3: ireturn
// i -&gt; 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-&gt;i*2 对应的适配器类
final class LambdaTest$$Lambda$1 implements IntUnaryOperator {
private LambdaTest$$Lambda$1();
Code:
0: aload_0
1: invokespecial java/lang/Object.&quot;&lt;init&gt;&quot;:()V
4: return
public int applyAsInt(int);
Code:
0: iload_1
1: invokestatic LambdaTest.lambda$0:(I)I
4: ireturn
}
// i-&gt;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.&quot;&lt;init&gt;&quot;:()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 &quot;&lt;init&gt;&quot;:(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 &lt;= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
((IntConsumer) j -&gt; 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 &lt;= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
((IntConsumer) j -&gt; 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, &quot;target&quot;, t);
long current = System.currentTimeMillis();
for (int i = 1; i &lt;= 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, &quot;target&quot;, 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 &lt;= 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, &quot;target&quot;, 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 &lt;= 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, &quot;target&quot;, 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 &lt;= 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)

View 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)

View 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 &lt; 0x77777777; i++) {
sum += Math.sqrt(i);
}
}
public static void bar() {
for (int i = 0; i &lt; 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)

View 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虚拟机的解决方法是为每个司机预先申请多个停车位并且只允许该司机停在自己的停车位上。那么当司机的停车位用完了该怎么办呢假设这个司机代客泊车
答案是再申请多个停车位便可以了。这项技术被称之为TLABThread 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位相当于除以512Java虚拟机便是通过这种方式来从地址映射到卡表中的索引的。最终这段代码会被编译成一条移位指令和一条存储指令。
```
CARD_TABLE [this address &gt;&gt; 9] = DIRTY;
```
虽然写屏障不可避免地带来一些开销但是它能够加大Minor GC的吞吐率 应用运行时间/(应用运行时间+垃圾回收时间) 。总的来说还是值得的。不过在高并发环境下写屏障又带来了虚共享false sharing问题[2]。
在介绍对象内存布局中我曾提到虚共享问题讲的是几个volatile字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。
在HotSpot中卡表是通过byte数组来实现的。对于一个64字节的缓存行来说如果用它来加载部分卡表那么它将对应64张卡也就是32KB的内存。
如果同时有两个Java线程在这32KB内存中进行引用更新操作那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作因而间接影响程序性能。
为此HotSpot引入了一个新的参数-XX:+UseCondCardMark来尽量减少写卡表的操作。其伪代码如下所示
```
if (CARD_TABLE [this address &gt;&gt; 9] != DIRTY)
CARD_TABLE [this address &gt;&gt; 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 &lt; G; i++) {
array[(int) (i % length)] = new ObjectOf64Bytes();
}
}
}
class ObjectOf64Bytes {
long placeholder0;
long placeholder1;
long placeholder2;
long placeholder3;
long placeholder4;
long placeholder5;
}
```
## 附录Java虚拟机中的垃圾回收器
针对新生代的垃圾回收器共有三个SerialParallel 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]。
G1Garbage 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)