mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
354
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/24 | 字段访问相关优化.md
Normal file
354
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/24 | 字段访问相关优化.md
Normal file
@@ -0,0 +1,354 @@
|
||||
<audio id="audio" title="24 | 字段访问相关优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c6/c1/c6610b8633a187daeaf4a80081e60bc1.mp3"></audio>
|
||||
|
||||
在上一篇文章中,我介绍了逃逸分析,也介绍了基于逃逸分析的优化方式锁消除、栈上分配以及标量替换等内容。
|
||||
|
||||
其中的标量替换,可以看成将对象本身拆散为一个个字段,并把原本对对象字段的访问,替换为对一个个局部变量的访问。
|
||||
|
||||
```
|
||||
class Foo {
|
||||
int a = 0;
|
||||
}
|
||||
|
||||
static int bar(int x) {
|
||||
Foo foo = new Foo();
|
||||
foo.a = x;
|
||||
return foo.a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上面这段代码中的`bar`方法,经过逃逸分析以及标量替换后,其优化结果如下所示。(确切地说,是指所生成的IR图与下述代码所生成的IR图类似。之后不再重复解释。)
|
||||
|
||||
```
|
||||
static int bar(int x) {
|
||||
int a = x;
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于Sea-of-Nodes IR的特性,局部变量不复存在,取而代之的是一个个值。在例子对应的IR图中,返回节点将直接返回所输入的参数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/5d/14c64d61e81b764253a2fc96795d095d.png" alt="" />
|
||||
|
||||
**经过标量替换的`bar`方法**
|
||||
|
||||
下面我列举了`bar`方法经由C2即时编译生成的机器码(这里略去了指令地址的前48位)。
|
||||
|
||||
```
|
||||
# {method} 'bar' '(I)I' in 'FieldAccessTest'
|
||||
# parm0: rsi = int // 参数x
|
||||
# [sp+0x20] (sp of caller)
|
||||
0x06a0: sub rsp,0x18 // 创建方法栈桢
|
||||
0x06a7: mov QWORD PTR [rsp+0x10],rbp // 无关指令
|
||||
0x06ac: mov eax,esi // 将参数x存入返回值eax中
|
||||
0x06ae: add rsp,0x10 // 弹出方法栈桢
|
||||
0x06b2: pop rbp // 无关指令
|
||||
0x06b3: mov r10,QWORD PTR [r15+0x70] // 安全点测试
|
||||
0x06b7: test DWORD PTR [r10],eax // 安全点测试
|
||||
0x06ba: ret
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
在X86_64的机器码中,每当使用call指令进入目标方法的方法体中时,我们需要在栈上为当前方法分配一块内存作为其栈桢。而在退出该方法时,我们需要弹出当前方法所使用的栈桢。
|
||||
|
||||
|
||||
>
|
||||
由于寄存器rsp维护着当前线程的栈顶指针,因此这些操作都是通过增减寄存器rsp来实现的,即上面这段机器码中偏移量为0x06a0以及0x06ae的指令。
|
||||
|
||||
|
||||
>
|
||||
在介绍安全点(safepoint)时我曾介绍过,HotSpot虚拟机的即时编译器将在方法返回时插入安全点测试指令,即图中偏移量为0x06b3以及0x06ba的指令。其中真正的安全点测试是0x06b7指令。
|
||||
|
||||
|
||||
>
|
||||
如果虚拟机需要所有线程都到达安全点,那么该test指令所访问的内存地址所在的页将被标记为不可访问,而该指令也将触发segfault,并借由segfault处理器进入安全点之中。通常,该指令会附带`; {poll_return}`这样子的注释,这里被我略去了。
|
||||
|
||||
|
||||
>
|
||||
在X8_64中,前几个传入参数会被放置于寄存器中,而返回值则需要存放在rax寄存器中。有时候你会看到返回值被存入eax寄存器中,这其实是同一个寄存器,只不过rax表示64位寄存器,而eax表示32位寄存器。具体可以参考x86 calling conventions[1]。
|
||||
|
||||
|
||||
当忽略掉创建、弹出方法栈桢,安全点测试以及其他无关指令之后,所剩下的方法体就只剩下偏移量为0x06ac的mov指令,以及0x06ba的ret指令。前者将所传入的int型参数x移至代表返回值的eax寄存器中,后者是退出当前方法并返回至调用者中。
|
||||
|
||||
虽然在部分情况下,逃逸分析以及基于逃逸分析的优化已经十分高效了,能够将代码优化到极其简单的地步,但是逃逸分析毕竟不是Java虚拟机的银色子弹。
|
||||
|
||||
在现实中,Java程序中的对象或许本身便是逃逸的,或许因为方法内联不够彻底而被即时编译器当成是逃逸的。这两种情况都将导致即时编译器无法进行标量替换。这时候,针对对象字段访问的优化也变得格外重要起来。
|
||||
|
||||
```
|
||||
static int bar(Foo o, int x) {
|
||||
o.a = x;
|
||||
return o.a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这段代码中,对象`o`是传入参数,不属于逃逸分析的范围(Java虚拟机中的逃逸分析针对的是新建对象)。该方法会将所传入的int型参数`x`的值存储至实例字段`Foo.a`中,然后再读取并返回同一字段的值。
|
||||
|
||||
这段代码将涉及两次内存访问操作:存储以及读取实例字段`Foo.a`。我们可以轻易地将其手工优化为直接读取并返回传入参数x的值。由于这段代码较为简单,因此它极大可能被编译为寄存器之间的移动指令(即将输入参数`x`的值移至寄存器eax中)。这与原本的内存访问指令相比,显然要高效得多。
|
||||
|
||||
```
|
||||
static int bar(Foo o, int x) {
|
||||
o.a = x;
|
||||
return x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那么即时编译器是否能够作出类似的自动优化呢?
|
||||
|
||||
## 字段读取优化
|
||||
|
||||
答案是可以的。即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。
|
||||
|
||||
当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值。
|
||||
|
||||
当即时编译器遇到对同一字段的存储节点时,它会更新所缓存的值。当即时编译器遇到可能更新字段的节点时,如方法调用节点(在即时编译器看来,方法调用会执行未知代码),或者内存屏障节点(其他线程可能异步更新了字段),那么它会采取保守的策略,舍弃所有缓存值。
|
||||
|
||||
在前面的例子中,我们见识了缓存字段存储节点的情况。下面我们来看一下缓存字段读取节点的情况。
|
||||
|
||||
```
|
||||
static int bar(Foo o, int x) {
|
||||
int y = o.a + x;
|
||||
return o.a + y;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这段代码中,实例字段`Foo.a`将被读取两次。即时编译器会将第一次读取的值缓存起来,并且替换第二次字段读取操作,以节省一次内存访问。
|
||||
|
||||
```
|
||||
static int bar(Foo o, int x) {
|
||||
int t = o.a;
|
||||
int y = t + x;
|
||||
return t + y;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果字段读取节点被替换成一个常量,那么它将进一步触发更多优化。
|
||||
|
||||
```
|
||||
static int bar(Foo o, int x) {
|
||||
o.a = 1;
|
||||
if (o.a >= 0)
|
||||
return x;
|
||||
else
|
||||
return -x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
例如在上面这段代码中,实例字段`Foo.a`会被赋值为1。接下来的if语句将判断同一实例字段是否不小于0。经过字段读取优化之后,`>=`节点的两个输入参数分别为常数1和0,因此可以直接替换为具体结果`true`。如此一来,else分支将变成不可达代码,可以直接删除,其优化结果如下所示。
|
||||
|
||||
```
|
||||
static int bar(Foo o, int x) {
|
||||
o.a = 1;
|
||||
return x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再来看另一个例子。下面这段代码的`bar`方法中,实例字段`a`会被赋值为`true`,后面紧跟着一个以`a`为条件的while循环。
|
||||
|
||||
```
|
||||
class Foo {
|
||||
boolean a;
|
||||
void bar() {
|
||||
a = true;
|
||||
while (a) {}
|
||||
}
|
||||
void whatever() { a = false; }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
同样,即时编译器会将while循环中读取实例字段`a`的操作直接替换为常量`true`,即下面代码所示的死循环。
|
||||
|
||||
```
|
||||
void bar() {
|
||||
a = true;
|
||||
while (true) {}
|
||||
}
|
||||
// 生成的机器码将陷入这一死循环中
|
||||
0x066b: mov r11,QWORD PTR [r15+0x70] // 安全点测试
|
||||
0x066f: test DWORD PTR [r11],eax // 安全点测试
|
||||
0x0672: jmp 0x066b // while (true)
|
||||
|
||||
```
|
||||
|
||||
在介绍Java内存模型时,我们便知道可以通过volatile关键字标记实例字段`a`,以此强制对它的读取。
|
||||
|
||||
实际上,即时编译器将在volatile字段访问前后插入内存屏障节点。这些内存屏障节点会阻止即时编译器将屏障之前所缓存的值用于屏障之后的读取节点之上。
|
||||
|
||||
就我们的例子而言,尽管在X86_64平台上,volatile字段读取操作前后的内存屏障是no-op,在即时编译过程中的屏障节点,还是会阻止即时编译器的字段读取优化,强制在循环中使用内存读取指令访问实例字段`Foo.a`的最新值。
|
||||
|
||||
```
|
||||
0x00e0: movzx r11d,BYTE PTR [rbx+0xc] // 读取a
|
||||
0x00e5: mov r10,QWORD PTR [r15+0x70] // 安全点测试
|
||||
0x00e9: test DWORD PTR [r10],eax // 安全点测试
|
||||
0x00ec: test r11d,r11d // while (a)
|
||||
0x00ef: jne 0x00e0 // while (a)
|
||||
|
||||
```
|
||||
|
||||
同理,加锁、解锁操作也同样会阻止即时编译器的字段读取优化。
|
||||
|
||||
## 字段存储优化
|
||||
|
||||
除了字段读取优化之外,即时编译器还将消除冗余的存储节点。如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么即时编译器可以将第一个字段存储给消除掉。
|
||||
|
||||
```
|
||||
class Foo {
|
||||
int a = 0;
|
||||
void bar() {
|
||||
a = 1;
|
||||
a = 2;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举例来说,上面这段代码中的`bar`方法先后存储了两次`Foo.a`实例字段。由于第一次存储之后没有读取`Foo.a`的值,因此,即时编译器会将其看成冗余存储,并将之消除掉,生成如下代码:
|
||||
|
||||
```
|
||||
void bar() {
|
||||
a = 2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
实际上,即便是在这两个字段存储操作之间读取该字段,即时编译器还是有可能在字段读取优化的帮助下,将第一个存储操作当成冗余存储给消除掉。
|
||||
|
||||
```
|
||||
class Foo {
|
||||
int a = 0;
|
||||
void bar() {
|
||||
a = 1;
|
||||
int t = a;
|
||||
a = t + 2;
|
||||
}
|
||||
}
|
||||
// 优化为
|
||||
class Foo {
|
||||
int a = 0;
|
||||
void bar() {
|
||||
a = 1;
|
||||
int t = 1;
|
||||
a = t + 2;
|
||||
}
|
||||
}
|
||||
// 进一步优化为
|
||||
class Foo {
|
||||
int a = 0;
|
||||
void bar() {
|
||||
a = 3;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然,如果所存储的字段被标记为volatile,那么即时编译器也不能将冗余的存储操作消除掉。
|
||||
|
||||
这种情况看似很蠢,但实际上并不少见,比如说两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。
|
||||
|
||||
## 死代码消除
|
||||
|
||||
除了字段存储优化之外,局部变量的死存储(dead store)同样也涉及了冗余存储。这是死代码消除(dead code eliminiation)的一种。不过,由于Sea-of-Nodes IR的特性,死存储的优化无须额外代价。
|
||||
|
||||
```
|
||||
int bar(int x, int y) {
|
||||
int t = x*y;
|
||||
t = x+y;
|
||||
return t;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段代码涉及两个存储局部变量操作。当即时编译器将其转换为Sea-of-Nodes IR之后,没有节点依赖于t的第一个值`x*y`。因此,该乘法运算将被消除,其结果如下所示:
|
||||
|
||||
```
|
||||
int bar(int x, int y) {
|
||||
return x+y;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
死存储还有一种变体,即在部分程序路径上有冗余存储。
|
||||
|
||||
```
|
||||
int bar(boolean f, int x, int y) {
|
||||
int t = x*y;
|
||||
if (f)
|
||||
t = x+y;
|
||||
return t;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上面这段代码中,如果所传入的boolean类型的参数`f`是`true`,那么在程序执行路径上将先后进行两次对局部变量`t`的存储。
|
||||
|
||||
同样,经过Sea-of-Nodes IR转换之后,返回节点所依赖的值是一个phi节点,将根据程序路径选择`x+y`或者`x*y`。也就是说,当`f`为`true`的程序路径上的乘法运算会被消除,其结果如下所示:
|
||||
|
||||
```
|
||||
int bar(boolean f, int x, int y) {
|
||||
int t;
|
||||
if (f)
|
||||
t = x+y;
|
||||
else
|
||||
t = x*y;
|
||||
return t;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另一种死代码消除则是不可达分支消除。不可达分支就是任何程序路径都不可到达的分支,我们之前已经多次接触过了。
|
||||
|
||||
在即时编译过程中,我们经常因为方法内联、常量传播以及基于profile的优化等,生成许多不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。
|
||||
|
||||
```
|
||||
int bar(int x) {
|
||||
if (false)
|
||||
return x;
|
||||
else
|
||||
return -x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,在上面的代码中,if语句将一直跳转至else分支之中。因此,另一不可达分支可以直接消除掉,形成下面的代码:
|
||||
|
||||
```
|
||||
int bar(int x) {
|
||||
return -x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了即时编译器关于字段访问的优化方式,以及死代码消除。
|
||||
|
||||
即时编译器将沿着控制流缓存字段存储、读取的值,并在接下来的字段读取操作时直接使用该缓存值。
|
||||
|
||||
这要求生成缓存值的访问以及使用缓存值的读取之间没有方法调用、内存屏障,或者其他可能存储该字段的节点。
|
||||
|
||||
即时编译器还会优化冗余的字段存储操作。如果一个字段的两次存储之间没有对该字段的读取操作、方法调用以及内存屏障,那么即时编译器可以将第一个冗余的存储操作给消除掉。
|
||||
|
||||
此外,我还介绍了死代码消除的两种形式。第一种是局部变量的死存储消除以及部分死存储消除。它们可以通过转换为Sea-of-Nodes IR来完成。第二种则是不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。
|
||||
|
||||
今天的实践环节,请思考即时编译器会怎么优化下面代码中的除法操作?
|
||||
|
||||
```
|
||||
int bar(int x, int y) {
|
||||
int t = x/y;
|
||||
t = x+y;
|
||||
return t;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
[1] [https://en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI](https://en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI)<br />
|
||||
|
||||
390
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/25 | 循环优化.md
Normal file
390
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/25 | 循环优化.md
Normal file
@@ -0,0 +1,390 @@
|
||||
<audio id="audio" title="25 | 循环优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/67/7ed655e74b24aa5c935f328a6fc70167.mp3"></audio>
|
||||
|
||||
在许多应用程序中,循环都扮演着非常重要的角色。为了提升循环的运行效率,研发编译器的工程师提出了不少面向循环的编译优化方式,如循环无关代码外提,循环展开等。
|
||||
|
||||
今天,我们便来了解一下,Java虚拟机中的即时编译器都应用了哪些面向循环的编译优化。
|
||||
|
||||
## 循环无关代码外提
|
||||
|
||||
所谓的循环无关代码(Loop-invariant Code),指的是循环中值不变的表达式。如果能够在不改变程序语义的情况下,将这些循环无关代码提出循环之外,那么程序便可以避免重复执行这些表达式,从而达到性能提升的效果。
|
||||
|
||||
```
|
||||
int foo(int x, int y, int[] a) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
sum += x * y + a[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
// 对应的字节码
|
||||
int foo(int, int, int[]);
|
||||
Code:
|
||||
0: iconst_0
|
||||
1: istore 4
|
||||
3: iconst_0
|
||||
4: istore 5
|
||||
6: goto 25
|
||||
// 循环体开始
|
||||
9: iload 4 // load sum
|
||||
11: iload_1 // load x
|
||||
12: iload_2 // load y
|
||||
13: imul // x*y
|
||||
14: aload_3 // load a
|
||||
15: iload 5 // load i
|
||||
17: iaload // a[i]
|
||||
18: iadd // x*y + a[i]
|
||||
19: iadd // sum + (x*y + a[i])
|
||||
20: istore 4 // sum = sum + (x*y + a[i])
|
||||
22: iinc 5, 1 // i++
|
||||
25: iload 5 // load i
|
||||
27: aload_3 // load a
|
||||
28: arraylength // a.length
|
||||
29: if_icmplt 9 // i < a.length
|
||||
// 循环体结束
|
||||
32: iload 4
|
||||
34: ireturn
|
||||
|
||||
```
|
||||
|
||||
举个例子,在上面这段代码中,循环体中的表达式`x*y`,以及循环判断条件中的`a.length`均属于循环不变代码。前者是一个整数乘法运算,而后者则是内存访问操作,读取数组对象`a`的长度。(数组的长度存放于数组对象的对象头中,可通过arraylength指令来访问。)
|
||||
|
||||
理想情况下,上面这段代码经过循环无关代码外提之后,等同于下面这一手工优化版本。
|
||||
|
||||
```
|
||||
int fooManualOpt(int x, int y, int[] a) {
|
||||
int sum = 0;
|
||||
int t0 = x * y;
|
||||
int t1 = a.length;
|
||||
for (int i = 0; i < t1; i++) {
|
||||
sum += t0 + a[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,无论是乘法运算`x*y`,还是内存访问`a.length`,现在都在循环之前完成。原本循环中需要执行这两个表达式的地方,现在直接使用循环之前这两个表达式的执行结果。
|
||||
|
||||
在Sea-of-Nodes IR的帮助下,循环无关代码外提的实现并不复杂。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/e6/6963da28cb3cf42cc43e4268a8f002e6.png" alt="" />
|
||||
|
||||
上图我截取了Graal为前面例子中的`foo`方法所生成的IR图(局部)。其中B2基本块位于循环之前,B3基本块为循环头。
|
||||
|
||||
`x*y`所对应的21号乘法节点,以及`a.length`所对应的47号读取节点,均不依赖于循环体中生成的数据,而且都为浮动节点。节点调度算法会将它们放置于循环之前的B2基本块中,从而实现这些循环无关代码的外提。
|
||||
|
||||
```
|
||||
0x02f0: mov edi,ebx // ebx存放着x*y的结果
|
||||
0x02f2: add edi,DWORD PTR [r8+r9*4+0x10]
|
||||
// [r8+r9*4+0x10]即a[i]
|
||||
// r8指向a,r9d存放着i
|
||||
0x02f7: add eax,edi // eax存放着sum
|
||||
0x02f9: inc r9d // i++
|
||||
0x02fc: cmp r9d,r10d // i < a.length
|
||||
// r10d存放着a.length
|
||||
0x02ff: jl 0x02f0
|
||||
|
||||
```
|
||||
|
||||
上面这段机器码是`foo`方法的编译结果中的循环。这里面没有整数乘法指令,也没有读取数组长度的内存访问指令。它们的值已在循环之前计算好了,并且分别保存在寄存器`ebx`以及`r10d`之中。在循环之中,代码直接使用寄存器`ebx`以及`r10d`所保存的值,而不用在循环中反复计算。
|
||||
|
||||
从生成的机器码中可以看出,除了`x*y`和`a.length`的外提之外,即时编译器还外提了int数组加载指令`iaload`所暗含的null检测(null check)以及下标范围检测(range check)。
|
||||
|
||||
如果将`iaload`指令想象成一个接收数组对象以及下标作为参数,并且返回对应数组元素的方法,那么它的伪代码如下所示:
|
||||
|
||||
```
|
||||
int iaload(int[] arrayRef, int index) {
|
||||
if (arrayRef == null) { // null检测
|
||||
throw new NullPointerException();
|
||||
}
|
||||
if (index < 0 || index >= arrayRef.length) { // 下标范围检测
|
||||
throw new ArrayIndexOutOfBoundsException();
|
||||
}
|
||||
return arrayRef[index];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`foo`方法中的null检测属于循环无关代码。这是因为它始终检测作为输入参数的int数组是否为null,而这与第几次循环无关。
|
||||
|
||||
为了更好地阐述具体的优化,我精简了原来的例子,并将`iaload`展开,最终形成如下所示的代码。
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a == null) { // null check
|
||||
throw new NullPointerException();
|
||||
}
|
||||
if (i < 0 || i >= a.length) { // range check
|
||||
throw new ArrayIndexOutOfBoundsException();
|
||||
}
|
||||
sum += a[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,null检测涉及了控制流依赖,因而无法通过Sea-of-Nodes IR转换以及节点调度来完成外提。
|
||||
|
||||
在C2中,null检测的外提是通过额外的编译优化,也就是循环预测(Loop Prediction,对应虚拟机参数`-XX:+UseLoopPredicate`)来实现的。该优化的实际做法是在循环之前插入同样的检测代码,并在命中的时候进行去优化。这样一来,循环中的检测代码便会被归纳并消除掉。
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
if (a == null) {
|
||||
deoptimize(); // never returns
|
||||
}
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a == null) { // now evluate to false
|
||||
throw new NullPointerException();
|
||||
}
|
||||
if (i < 0 || i >= a.length) { // range check
|
||||
throw new ArrayIndexOutOfBoundsException();
|
||||
}
|
||||
sum += a[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
除了null检测之外,其他循环无关检测都能够按照这种方式外提至循环之前。甚至是循环有关的下标范围检测,都能够借助循环预测来外提,只不过具体的转换要复杂一些。
|
||||
|
||||
之所以说下标范围检测是循环有关的,是因为在我们的例子中,该检测的主体是循环控制变量`i`(检测它是否在`[0, a.length)`之间),它的值将随着循环次数的增加而改变。
|
||||
|
||||
由于外提该下标范围检测之后,我们无法再引用到循环变量`i`,因此,即时编译器需要转换检测条件。具体的转换方式如下所示:
|
||||
|
||||
```
|
||||
for (int i = INIT; i < LIMIT; i += STRIDE) {
|
||||
if (i < 0 || i >= a.length) { // range check
|
||||
throw new ArrayIndexOutOfBoundsException();
|
||||
}
|
||||
sum += a[i];
|
||||
}
|
||||
----------
|
||||
// 经过下标范围检测外提之后:
|
||||
if (INIT < 0 || IMAX >= a.length) {
|
||||
// IMAX是i所能达到的最大值,注意它不一定是LIMIT-1
|
||||
detopimize(); // never returns
|
||||
}
|
||||
for (int i = INIT; i < LIMIT; i += STRIDE) {
|
||||
sum += a[i]; // 不包含下标范围检测
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 循环展开
|
||||
|
||||
另外一项非常重要的循环优化是循环展开(Loop Unrolling)。它指的是在循环体中重复多次循环迭代,并减少循环次数的编译优化。
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
sum += (i % 2 == 0) ? a[i] : -a[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上面的代码经过一次循环展开之后将形成下面的代码:
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 64; i += 2) { // 注意这里的步数是2
|
||||
sum += (i % 2 == 0) ? a[i] : -a[i];
|
||||
sum += ((i + 1) % 2 == 0) ? a[i + 1] : -a[i + 1];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在C2中,只有计数循环(Counted Loop)才能被展开。所谓的计数循环需要满足如下四个条件。
|
||||
|
||||
1. 维护一个循环计数器,并且基于计数器的循环出口只有一个(但可以有基于其他判断条件的出口)。
|
||||
1. 循环计数器的类型为int、short或者char(即不能是byte、long,更不能是float或者double)。
|
||||
1. 每个迭代循环计数器的增量为常数。
|
||||
1. 循环计数器的上限(增量为正数)或下限(增量为负数)是循环无关的数值。
|
||||
|
||||
```
|
||||
for (int i = START; i < LIMIT; i += STRIDE) { .. }
|
||||
// 等价于
|
||||
int i = START;
|
||||
while (i < LIMIT) {
|
||||
..
|
||||
i += STRIDE;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面两种循环中,只要`LIMIT`是循环无关的数值,`STRIDE`是常数,而且循环中除了`i < LIMIT`之外没有其他基于循环变量`i`的循环出口,那么C2便会将该循环识别为计数循环。
|
||||
|
||||
循环展开的缺点显而易见:它可能会增加代码的冗余度,导致所生成机器码的长度大幅上涨。
|
||||
|
||||
不过,随着循环体的增大,优化机会也会不断增加。一旦循环展开能够触发进一步的优化,总体的代码复杂度也将降低。比如前面的例子经过循环展开之后便可以进一步优化为如下所示的代码:
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 64; i += 2) {
|
||||
sum += a[i];
|
||||
sum += -a[i + 1];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
循环展开有一种特殊情况,那便是完全展开(Full Unroll)。当循环的数目是固定值而且非常小时,即时编译器会将循环全部展开。此时,原本循环中的循环判断语句将不复存在,取而代之的是若干个顺序执行的循环体。
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
sum += a[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上述代码将被完全展开为下述代码:
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
sum += a[0];
|
||||
sum += a[1];
|
||||
sum += a[2];
|
||||
sum += a[3];
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
即时编译器会在循环体的大小与循环展开次数之间做出权衡。例如,对于仅迭代三次(或以下)的循环,即时编译器将进行完全展开;对于循环体IR节点数目超过阈值的循环,即时编译器则不会进行任何循环展开。
|
||||
|
||||
## 其他循环优化
|
||||
|
||||
除了循环无关代码外提以及循环展开之外,即时编译器还有两个比较重要的循环优化技术:循环判断外提(loop unswitching)以及循环剥离(loop peeling)。
|
||||
|
||||
循环判断外提指的是将循环中的if语句外提至循环之前,并且在该if语句的两个分支中分别放置一份循环代码。
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a.length > 4) {
|
||||
sum += a[i];
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上面这段代码经过循环判断外提之后,将变成下面这段代码:
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
if (a.length > 4) {
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
sum += a[i];
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
// 进一步优化为:
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
if (a.length > 4) {
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
sum += a[i];
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
循环判断外提与循环无关检测外提所针对的代码模式比较类似,都是循环中的if语句。不同的是,后者在检查失败时会抛出异常,中止当前的正常执行路径;而前者所针对的是更加常见的情况,即通过if语句的不同分支执行不同的代码逻辑。
|
||||
|
||||
循环剥离指的是将循环的前几个迭代或者后几个迭代剥离出循环的优化方式。一般来说,循环的前几个迭代或者后几个迭代都包含特殊处理。通过将这几个特殊的迭代剥离出去,可以使原本的循环体的规律性更加明显,从而触发进一步的优化。
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int j = 0;
|
||||
int sum = 0;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
sum += a[j];
|
||||
j = i;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上面这段代码剥离了第一个迭代后,将变成下面这段代码:
|
||||
|
||||
```
|
||||
int foo(int[] a) {
|
||||
int sum = 0;
|
||||
if (0 < a.length) {
|
||||
sum += a[0];
|
||||
for (int i = 1; i < a.length; i++) {
|
||||
sum += a[i - 1];
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了即时编译器所使用的循环优化。
|
||||
|
||||
循环无关代码外提将循环中值不变的表达式,或者循环无关检测外提至循环之前,以避免在循环中重复进行冗余计算。前者是通过Sea-of-Nodes IR以及节点调度来共同完成的,而后者则是通过一个独立优化 —— 循环预测来完成的。循环预测还可以外提循环有关的数组下标范围检测。
|
||||
|
||||
循环展开是一种在循环中重复多次迭代,并且相应地减少循环次数的优化方式。它是一种以空间换时间的优化方式,通过增大循环体来获取更多的优化机会。循环展开的特殊形式是完全展开,将原本的循环转换成若干个循环体的顺序执行。
|
||||
|
||||
此外,我还简单地介绍了另外两种循环优化方式:循环判断外提以及循环剥离。
|
||||
|
||||
今天的实践环节,我们来看这么一段代码:
|
||||
|
||||
```
|
||||
void foo(byte[] dst, byte[] src) {
|
||||
for (int i = 0; i < dst.length; i++) {
|
||||
dst[i] = src[i];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段代码经过循环展开变成下面这段代码。请问你能想到进一步优化的机会吗?<br />
|
||||
(提示:数组元素在内存中的分布是连续的。假设`dst[0]`位于0x1000,那么`dst[1]`位于0x1001。)
|
||||
|
||||
```
|
||||
void foo(byte[] dst, byte[] src) {
|
||||
for (int i = 0; i < dst.length - 4; i += 4) {
|
||||
dst[i] = src[i];
|
||||
dst[i + 1] = src[i + 1];
|
||||
dst[i + 2] = src[i + 2];
|
||||
dst[i + 3] = src[i + 3];
|
||||
}
|
||||
... // post-loop
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
|
||||
241
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/26 | 向量化.md
Normal file
241
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/26 | 向量化.md
Normal file
@@ -0,0 +1,241 @@
|
||||
<audio id="audio" title="26 | 向量化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/c3/31fb9a7102357bbc0a5656e0b5aa4ec3.mp3"></audio>
|
||||
|
||||
在上一篇的实践环节中,我给你留了一个题目:如何进一步优化下面这段代码。
|
||||
|
||||
```
|
||||
void foo(byte[] dst, byte[] src) {
|
||||
for (int i = 0; i < dst.length - 4; i += 4) {
|
||||
dst[i] = src[i];
|
||||
dst[i+1] = src[i+1];
|
||||
dst[i+2] = src[i+2];
|
||||
dst[i+3] = src[i+3];
|
||||
}
|
||||
... // post-loop
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于X86_64平台不支持内存间的直接移动,上面代码中的`dst[i] = src[i]`通常会被编译为两条内存访问指令:第一条指令把`src[i]`的值读取至寄存器中,而第二条指令则把寄存器中的值写入至`dst[i]`中。
|
||||
|
||||
因此,上面这段代码中的一个循环迭代将会执行四条内存读取指令,以及四条内存写入指令。
|
||||
|
||||
由于数组元素在内存中是连续的,当从`src[i]`的内存地址处读取32位的内容时,我们将一并读取`src[i]`至`src[i+3]`的值。同样,当向`dst[i]`的内存地址处写入32位的内容时,我们将一并写入`dst[i]`至`dst[i+3]`的值。
|
||||
|
||||
通过综合这两个批量操作,我们可以使用一条内存读取指令以及一条内存写入指令,完成上面代码中循环体内的全部工作。如果我们用`x[i:i+3]`来指代`x[i]`至`x[i+3]`合并后的值,那么上述优化可以被表述成如下所示的代码:
|
||||
|
||||
```
|
||||
void foo(byte[] dst, byte[] src) {
|
||||
for (int i = 0; i < dst.length - 4; i += 4) {
|
||||
dst[i:i+3] = src[i:i+3];
|
||||
}
|
||||
... // post-loop
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## SIMD指令
|
||||
|
||||
在前面的示例中,我们使用的是byte数组,四个数组元素并起来也才4个字节。如果换成int数组,或者long数组,那么四个数组元素并起来将会是16字节或32字节。
|
||||
|
||||
我们知道,X86_64体系架构上通用寄存器的大小为64位(即8个字节),无法暂存这些超长的数据。因此,即时编译器将借助长度足够的XMM寄存器,来完成int数组与long数组的向量化读取和写入操作。(为了实现方便,byte数组的向量化读取、写入操作同样使用了XMM寄存器。)
|
||||
|
||||
所谓的XMM寄存器,是由SSE(Streaming SIMD Extensions)指令集所引入的。它们一开始仅为128位。自从X86平台上的CPU开始支持AVX(Advanced Vector Extensions)指令集后(2011年),XMM寄存器便升级为256位,并更名为YMM寄存器。原本使用XMM寄存器的指令,现将使用YMM寄存器的低128位。
|
||||
|
||||
前几年推出的AVX512指令集,更是将YMM寄存器升级至512位,并更名为ZMM寄存器。HotSpot虚拟机也紧跟时代,更新了不少基于AVX512指令集以及ZMM寄存器的优化。不过,支持AVX512指令集的CPU都比较贵,目前在生产环境中很少见到。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/f2/56cb8c99ce8a80d1c510ef50122616f2.png" alt="" />
|
||||
|
||||
SSE指令集以及之后的AVX指令集都涉及了一个重要的概念,那便是单指令流多数据流(Single Instruction Multiple Data,SIMD),即通过单条指令操控多组数据的计算操作。这些指令我们称之为SIMD指令。
|
||||
|
||||
SIMD指令将XMM寄存器(或YMM寄存器、ZMM寄存器)中的值看成多个整数或者浮点数组成的向量,并且批量进行计算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/cf/8ad6be0e44c4f14b45c7c8c4cf6eabcf.png" alt="" />
|
||||
|
||||
举例来说,128位XMM寄存器里的值可以看成16个byte值组成的向量,或者8个short值组成的向量,4个int值组成的向量,两个long值组成的向量;而SIMD指令`PADDB`、`PADDW`、`PADDD`以及`PADDQ`,将分别实现byte值、short值、int值或者long值的向量加法。
|
||||
|
||||
```
|
||||
void foo(int[] a, int[] b, int[] c) {
|
||||
for (int i = 0; i < c.length; i++) {
|
||||
c[i] = a[i] + b[i];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段代码经过向量化优化之后,将使用`PADDD`指令来实现`c[i:i+3] = a[i:i+3] + b[i:i+3]`。其执行过程中的数据流如下图所示,图片源自Vladimir Ivanov的演讲[1]。下图中内存的右边是高位,寄存器的左边是高位,因此数组元素的顺序是反过来的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/40/d61b5f38e3736acf3447412888a32b40.png" alt="" />
|
||||
|
||||
也就是说,原本需要`c.length`次加法操作的代码,现在最少只需要`c.length/4`次向量加法即可完成。因此,SIMD指令也被看成CPU指令级别的并行。
|
||||
|
||||
>
|
||||
这里`c.length/4`次是理论值。现实中,C2还将考虑缓存行对齐等因素,导致能够应用向量化加法的仅有数组中间的部分元素。
|
||||
|
||||
|
||||
## 使用SIMD指令的HotSpot Intrinsic
|
||||
|
||||
SIMD指令虽然非常高效,但是使用起来却很麻烦。这主要是因为不同的CPU所支持的SIMD指令可能不同。一般来说,越新的SIMD指令,它所支持的寄存器长度越大,功能也越强。
|
||||
|
||||
>
|
||||
目前几乎所有的X86_64平台上的CPU都支持SSE指令集,绝大部分支持AVX指令集,三四年前量产的CPU支持AVX2指令集,最近少数服务器端CPU支持AVX512指令集。AVX512指令集的提升巨大,因为它不仅将寄存器长度增大至512字节,而且引入了非常多的新指令。
|
||||
|
||||
|
||||
为了能够尽量利用新的SIMD指令,我们需要提前知道程序会被运行在支持哪些指令集的CPU上,并在编译过程中选择所支持的SIMD指令中最新的那些。
|
||||
|
||||
或者,我们可以在编译结果中纳入同一段代码的不同版本,每个版本使用不同的SIMD指令。在运行过程中,程序将根据CPU所支持的指令集,来选择执行哪一个版本。
|
||||
|
||||
>
|
||||
虽然程序中包含当前CPU可能不支持的指令,但是只要不执行到这些指令,程序便不会出问题。如果不小心执行到这些不支持的指令,CPU会触发一个中断,并向当前进程发出`sigill`信号。
|
||||
|
||||
|
||||
不过,这对于使用即时编译技术的Java虚拟机来说,并不是一个大问题。
|
||||
|
||||
我们知道,Java虚拟机所执行的Java字节码是平台无关的。它首先会被解释执行,而后反复执行的部分才会被Java虚拟机即时编译为机器码。换句话说,在进行即时编译的时候,Java虚拟机已经运行在目标CPU之上,可以轻易地得知其所支持的指令集。
|
||||
|
||||
然而,Java字节码的平台无关性却引发了另一个问题,那便是Java程序无法像C++程序那样,直接使用由Intel提供的,将被替换为具体SIMD指令的intrinsic方法[2]。
|
||||
|
||||
HotSpot虚拟机提供的替代方案是Java层面的intrinsic方法,这些intrinsic方法的语义要比单个SIMD指令复杂得多。在运行过程中,HotSpot虚拟机将根据当前体系架构来决定是否将对该intrinsic方法的调用替换为另一高效的实现。如果不,则使用原本的Java实现。
|
||||
|
||||
举个例子,Java 8中`Arrays.equals(int[], int[])`的实现将逐个比较int数组中的元素。
|
||||
|
||||
```
|
||||
public static boolean equals(int[] a, int[] a2) {
|
||||
if (a==a2)
|
||||
return true;
|
||||
if (a==null || a2==null)
|
||||
return false;
|
||||
int length = a.length;
|
||||
if (a2.length != length)
|
||||
return false;
|
||||
// 关键循环
|
||||
for (int i=0; i<length; i++)
|
||||
if (a[i] != a2[i])
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应的intrinsic高效实现会将数组的多个元素加载至XMM/YMM/ZMM寄存器中,然后进行按位比较。如果两个数组相同,那么其中若干个元素合并而成的值也相同,其按位比较也应成功。反过来,如果按位比较失败,则说明两个数组不同。
|
||||
|
||||
使用SIMD指令的HotSpot intrinsic是虚拟机开发人员根据其语义定制的,因而性能相当优越。
|
||||
|
||||
不过,由于开发成本及维护成本较高,这种类型的intrinsic屈指可数,如用于复制数组的`System.arraycopy`和`Arrays.copyOf`,用于比较数组的`Arrays.equals`,以及Java 9新加入的`Arrays.compare`和`Arrays.mismatch`,以及字符串相关的一些方法`String.indexOf`、`StringLatin1.inflate`。
|
||||
|
||||
>
|
||||
`Arrays.copyOf`将调用`System.arraycopy`,实际上只有后者是intrinsic。在Java 9之后,数组比较真正的intrinsic是`ArraySupports.vectorizedMismatch`方法,而`Arrays.equals`、`Arrays.compare`和`Arrays.mismatch`将调用至该方法中。
|
||||
|
||||
|
||||
另外,这些intrinsic方法只能做到点覆盖,在不少情况下,应用程序并不会用到这些intrinsic的语义,却又存在向量化优化的机会。这个时候,我们便需要借助即时编译器中的自动向量化(auto vectorization)。
|
||||
|
||||
## 自动向量化
|
||||
|
||||
即时编译器的自动向量化将针对能够展开的计数循环,进行向量化优化。如前面介绍过的这段代码,即时编译器便能够自动将其展开优化成使用`PADDD`指令的向量加法。
|
||||
|
||||
```
|
||||
void foo(int[] a, int[] b, int[] c) {
|
||||
for (int i = 0; i < c.length; i++) {
|
||||
c[i] = a[i] + b[i];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
关于计数循环的判定,我在上一篇介绍循环优化时已经讲解过了,这里我补充几点自动向量化的条件。
|
||||
|
||||
1. 循环变量的增量应为1,即能够遍历整个数组。
|
||||
1. 循环变量不能为long类型,否则C2无法将循环识别为计数循环。
|
||||
1. 循环迭代之间最好不要有数据依赖,例如出现类似于`a[i] = a[i-1]`的语句。当循环展开之后,循环体内存在数据依赖,那么C2无法进行自动向量化。
|
||||
1. 循环体内不要有分支跳转。
|
||||
1. 不要手工进行循环展开。如果C2无法自动展开,那么它也将无法进行自动向量化。
|
||||
|
||||
我们可以看到,自动向量化的条件较为苛刻。而且,C2支持的整数向量化操作并不多,据我所致只有向量加法,向量减法,按位与、或、异或,以及批量移位和批量乘法。C2还支持向量点积的自动向量化,即两两相乘再求和,不过这需要多条SIMD指令才能完成,因此并不是十分高效。
|
||||
|
||||
为了解决向量化intrinsic以及自动向量化覆盖面过窄的问题,我们在OpenJDK的Paname项目[3]中尝试引入开发人员可控的向量化抽象。
|
||||
|
||||
该抽象将提供一套通用的跨平台API,让Java程序能够定义诸如`IntVector<S256Bits>`的向量,并使用由它提供的一系列向量化intrinsic方法。即时编译器负责将这些intrinsic的调用转换为符合当前体系架构/CPU的SIMD指令。如果你感兴趣的话,可以参考Vladimir Ivanov今年在JVMLS上的演讲[4]。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了即时编译器中的向量化优化。
|
||||
|
||||
向量化优化借助的是CPU的SIMD指令,即通过单条指令控制多组数据的运算。它被称为CPU指令级别的并行。
|
||||
|
||||
HotSpot虚拟机运用向量化优化的方式有两种。第一种是使用HotSpot intrinsic,在调用特定方法的时候替换为使用了SIMD指令的高效实现。Intrinsic属于点覆盖,只有当应用程序明确需要这些intrinsic的语义,才能够获得由它带来的性能提升。
|
||||
|
||||
第二种是依赖即时编译器进行自动向量化,在循环展开优化之后将不同迭代的运算合并为向量运算。自动向量化的触发条件较为苛刻,因此也无法覆盖大多数用例。
|
||||
|
||||
今天的实践环节,我们来观察一下即时编译器的自动向量化的自适配性。
|
||||
|
||||
在支持256位YMM寄存器的机器上,C2会根据循环回边的执行次数以及方法的执行次数来推测每个循环的次数。如果超过一定值,C2会采用基于256位YMM寄存器的指令,相比起基于128位XMM寄存器的指令而言,单指令能处理的数据翻了一倍。
|
||||
|
||||
请采用Java 9以上的版本运行下述代码。(Java 8始终采用基于128位XMM寄存器指令的Bug可能仍未修复。)
|
||||
|
||||
```
|
||||
// Run with
|
||||
// java -XX:CompileCommand='dontinline VectorizationTest.foo' -XX:CompileCommand='print VectorizationTest.foo' -XX:-TieredCompilation VectorizationTest
|
||||
public class VectorizationTest {
|
||||
static void foo(int[] a, int[] b, int[] c) {
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
c[i] = a[i] + b[i];
|
||||
}
|
||||
}
|
||||
public static void main(String[] args) throws InterruptedException {
|
||||
int[] a = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
int[] c = new int[16];
|
||||
for (int i = 0; i < 20_000; i++) {
|
||||
foo(a, a, c);
|
||||
}
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出将包含如下机器码:
|
||||
|
||||
```
|
||||
0x000000011ce7c650: vmovdqu xmm0,XMMWORD PTR [rdx+rbx*4+0x10]
|
||||
0x000000011ce7c656: vpaddd xmm0,xmm0,XMMWORD PTR [rsi+rbx*4+0x10]
|
||||
0x000000011ce7c65c: vmovdqu XMMWORD PTR [rcx+rbx*4+0x10],xmm0
|
||||
|
||||
```
|
||||
|
||||
如果替换为:
|
||||
|
||||
```
|
||||
int[] a = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
int[] c = new int[32];
|
||||
|
||||
```
|
||||
|
||||
输出将包含如下机器码:
|
||||
|
||||
```
|
||||
0x000000010ff04d9c: vmovdqu ymm0,YMMWORD PTR [rdx+rbx*4+0x10]
|
||||
0x000000010ff04da2: vpaddd ymm0,ymm0,YMMWORD PTR [rsi+rbx*4+0x10]
|
||||
0x000000010ff04da8: vmovdqu YMMWORD PTR [rcx+rbx*4+0x10],ymm0
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
|
||||
你可以将`foo`方法更改为下述代码:
|
||||
|
||||
```
|
||||
static void foo(int[] a) {
|
||||
for (int i = 4; i < a.length; i++) {
|
||||
a[i] = a[i - 4];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
重复上述实验,看看会发生什么。
|
||||
|
||||
[1] [http://cr.openjdk.java.net/~vlivanov/talks/2017_Vectorization_in_HotSpot_JVM.pdf](http://cr.openjdk.java.net/~vlivanov/talks/2017_Vectorization_in_HotSpot_JVM.pdf)<br />
|
||||
[2] [https://software.intel.com/sites/landingpage/IntrinsicsGuide/](https://software.intel.com/sites/landingpage/IntrinsicsGuide/)<br />
|
||||
[3] [http://openjdk.java.net/projects/panama/](http://openjdk.java.net/projects/panama/)<br />
|
||||
[4]: [http://cr.openjdk.java.net/~vlivanov/talks/2018_JVMLS_VectorAPI.pdf](http://cr.openjdk.java.net/~vlivanov/talks/2018_JVMLS_VectorAPI.pdf)<br />
|
||||
|
||||
437
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/27 | 注解处理器.md
Normal file
437
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/27 | 注解处理器.md
Normal file
@@ -0,0 +1,437 @@
|
||||
<audio id="audio" title="27 | 注解处理器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/3d/8c26bebd8e5fa781cebcfe90ebfd943d.mp3"></audio>
|
||||
|
||||
注解(annotation)是Java 5引入的,用来为类、方法、字段、参数等Java结构提供额外信息的机制。我先举个例子,比如,Java核心类库中的`@Override`注解是被用来声明某个实例方法重写了父类的同名同参数类型的方法。
|
||||
|
||||
```
|
||||
package java.lang;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface Override {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`@Override`注解本身被另外两个元注解(即作用在注解上的注解)所标注。其中,`@Target`用来限定目标注解所能标注的Java结构,这里`@Override`便只能被用来标注方法。
|
||||
|
||||
`@Retention`则用来限定当前注解生命周期。注解共有三种不同的生命周期:`SOURCE`,`CLASS`或`RUNTIME`,分别表示注解只出现在源代码中,只出现在源代码和字节码中,以及出现在源代码、字节码和运行过程中。
|
||||
|
||||
这里`@Override`便只能出现在源代码中。一旦标注了`@Override`的方法所在的源代码被编译为字节码,该注解便会被擦除。
|
||||
|
||||
我们不难猜到,`@Override`仅对Java编译器有用。事实上,它会为Java编译器引入了一条新的编译规则,即如果所标注的方法不是Java语言中的重写方法,那么编译器会报错。而当编译完成时,它的使命也就结束了。
|
||||
|
||||
我们知道,Java的注解机制允许开发人员自定义注解。这些自定义注解同样可以为Java编译器添加编译规则。不过,这种功能需要由开发人员提供,并且以插件的形式接入Java编译器中,这些插件我们称之为注解处理器(annotation processor)。
|
||||
|
||||
除了引入新的编译规则之外,注解处理器还可以用于修改已有的Java源文件(不推荐),或者生成新的Java源文件。下面,我将用几个案例来详细阐述注解处理器的这些功能,以及它背后的原理。
|
||||
|
||||
## 注解处理器的原理
|
||||
|
||||
在介绍注解处理器之前,我们先来了解一下Java编译器的工作流程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/b8/64e93f67c3b422afd90966bfe9aaf5b8.png" alt="" />
|
||||
|
||||
如上图所示 出处[1],Java源代码的编译过程可分为三个步骤:
|
||||
|
||||
1. 将源文件解析为抽象语法树;
|
||||
1. 调用已注册的注解处理器;
|
||||
1. 生成字节码。
|
||||
|
||||
如果在第2步调用注解处理器过程中生成了新的源文件,那么编译器将重复第1、2步,解析并且处理新生成的源文件。每次重复我们称之为一轮(Round)。
|
||||
|
||||
也就是说,第一轮解析、处理的是输入至编译器中的已有源文件。如果注解处理器生成了新的源文件,则开始第二轮、第三轮,解析并且处理这些新生成的源文件。当注解处理器不再生成新的源文件,编译进入最后一轮,并最终进入生成字节码的第3步。
|
||||
|
||||
```
|
||||
package foo;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target({ ElementType.TYPE, ElementType.FIELD })
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface CheckGetter {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这段代码中,我定义了一个注解`@CheckGetter`。它既可以用来标注类,也可以用来标注字段。此外,它和`@Override`相同,其生命周期被限定在源代码中。
|
||||
|
||||
下面我们来实现一个处理`@CheckGetter`注解的处理器。它将遍历被标注的类中的实例字段,并检查有没有相应的`getter`方法。
|
||||
|
||||
```
|
||||
public interface Processor {
|
||||
|
||||
void init(ProcessingEnvironment processingEnv);
|
||||
|
||||
Set<String> getSupportedAnnotationTypes();
|
||||
|
||||
SourceVersion getSupportedSourceVersion();
|
||||
|
||||
boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv);
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所有的注解处理器类都需要实现接口`Processor`。该接口主要有四个重要方法。其中,`init`方法用来存放注解处理器的初始化代码。之所以不用构造器,是因为在Java编译器中,注解处理器的实例是通过反射API生成的。也正是因为使用反射API,每个注解处理器类都需要定义一个无参数构造器。
|
||||
|
||||
通常来说,当编写注解处理器时,我们不声明任何构造器,并依赖于Java编译器,为之插入一个无参数构造器。而具体的初始化代码,则放入`init`方法之中。
|
||||
|
||||
在剩下的三个方法中,`getSupportedAnnotationTypes`方法将返回注解处理器所支持的注解类型,这些注解类型只需用字符串形式表示即可。
|
||||
|
||||
`getSupportedSourceVersion`方法将返回该处理器所支持的Java版本,通常,这个版本需要与你的Java编译器版本保持一致;而`process`方法则是最为关键的注解处理方法。
|
||||
|
||||
JDK提供了一个实现`Processor`接口的抽象类`AbstractProcessor`。该抽象类实现了`init`、`getSupportedAnnotationTypes`和`getSupportedSourceVersion`方法。
|
||||
|
||||
它的子类可以通过`@SupportedAnnotationTypes`和`@SupportedSourceVersion`注解来声明所支持的注解类型以及Java版本。
|
||||
|
||||
下面这段代码便是`@CheckGetter`注解处理器的实现。由于我使用了Java 10的编译器,因此将支持版本设置为`SourceVersion.RELEASE_10`。
|
||||
|
||||
```
|
||||
package bar;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import javax.annotation.processing.*;
|
||||
import javax.lang.model.SourceVersion;
|
||||
import javax.lang.model.element.*;
|
||||
import javax.lang.model.util.ElementFilter;
|
||||
import javax.tools.Diagnostic.Kind;
|
||||
|
||||
import foo.CheckGetter;
|
||||
|
||||
@SupportedAnnotationTypes("foo.CheckGetter")
|
||||
@SupportedSourceVersion(SourceVersion.RELEASE_10)
|
||||
public class CheckGetterProcessor extends AbstractProcessor {
|
||||
|
||||
@Override
|
||||
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
|
||||
// TODO: annotated ElementKind.FIELD
|
||||
for (TypeElement annotatedClass : ElementFilter.typesIn(roundEnv.getElementsAnnotatedWith(CheckGetter.class))) {
|
||||
for (VariableElement field : ElementFilter.fieldsIn(annotatedClass.getEnclosedElements())) {
|
||||
if (!containsGetter(annotatedClass, field.getSimpleName().toString())) {
|
||||
processingEnv.getMessager().printMessage(Kind.ERROR,
|
||||
String.format("getter not found for '%s.%s'.", annotatedClass.getSimpleName(), field.getSimpleName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean containsGetter(TypeElement typeElement, String name) {
|
||||
String getter = "get" + name.substring(0, 1).toUpperCase() + name.substring(1).toLowerCase();
|
||||
for (ExecutableElement executableElement : ElementFilter.methodsIn(typeElement.getEnclosedElements())) {
|
||||
if (!executableElement.getModifiers().contains(Modifier.STATIC)
|
||||
&& executableElement.getSimpleName().toString().equals(getter)
|
||||
&& executableElement.getParameters().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
该注解处理器仅重写了`process`方法。这个方法将接收两个参数,分别代表该注解处理器所能处理的注解类型,以及囊括当前轮生成的抽象语法树的`RoundEnvironment`。
|
||||
|
||||
由于该处理器针对的注解仅有`@CheckGetter`一个,而且我们并不会读取注解中的值,因此第一个参数并不重要。在代码中,我直接使用了
|
||||
|
||||
```
|
||||
`roundEnv.getElementsAnnotatedWith(CheckGetter.class)`
|
||||
|
||||
```
|
||||
|
||||
来获取所有被`@CheckGetter`注解的类(以及字段)。
|
||||
|
||||
`process`方法涉及各种不同类型的`Element`,分别指代Java程序中的各个结构。如`TypeElement`指代类或者接口,`VariableElement`指代字段、局部变量、enum常量等,`ExecutableElement`指代方法或者构造器。
|
||||
|
||||
```
|
||||
package foo; // PackageElement
|
||||
|
||||
class Foo { // TypeElement
|
||||
int a; // VariableElement
|
||||
static int b; // VariableElement
|
||||
Foo () {} // ExecutableElement
|
||||
void setA ( // ExecutableElement
|
||||
int newA // VariableElement
|
||||
) {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这些结构之间也有从属关系,如上面这段代码所示(出处[2])。我们可以通过`TypeElement.getEnclosedElements`方法,获得上面这段代码中`Foo`类的字段、构造器以及方法。
|
||||
|
||||
我们也可以通过`ExecutableElement.getParameters`方法,获得`setA`方法的参数。具体这些`Element`类都有哪些API,你可以参考它们的Javadoc[3]。
|
||||
|
||||
在将该注解处理器编译成class文件后,我们便可以将其注册为Java编译器的插件,并用来处理其他源代码。注册的方法主要有两种。第一种是直接使用javac命令的`-processor`参数,如下所示:
|
||||
|
||||
```
|
||||
$ javac -cp /CLASSPATH/TO/CheckGetterProcessor -processor bar.CheckGetterProcessor Foo.java
|
||||
error: Class 'Foo' is annotated as @CheckGetter, but field 'a' is without getter
|
||||
1 error
|
||||
|
||||
```
|
||||
|
||||
第二种则是将注解处理器编译生成的class文件压缩入jar包中,并在jar包的配置文件中记录该注解处理器的包名及类名,即`bar.CheckGetterProcessor`。
|
||||
|
||||
```
|
||||
(具体路径及配置文件名为`META-INF/services/javax.annotation.processing.Processor`)
|
||||
|
||||
```
|
||||
|
||||
当启动Java编译器时,它会寻找classpath路径上的jar包是否包含上述配置文件,并自动注册其中记录的注解处理器。
|
||||
|
||||
```
|
||||
$ javac -cp /PATH/TO/CheckGetterProcessor.jar Foo.java
|
||||
error: Class 'Foo' is annotated as @CheckGetter, but field 'a' is without getter
|
||||
1 error
|
||||
|
||||
```
|
||||
|
||||
此外,我们还可以在IDE中配置注解处理器。这里我就不过多演示了,感兴趣的同学可以自行搜索。
|
||||
|
||||
## 利用注解处理器生成源代码
|
||||
|
||||
前面提到,注解处理器可以用来修改已有源代码或者生成源代码。
|
||||
|
||||
确切地说,注解处理器并不能真正地修改已有源代码。这里指的是修改由Java源代码生成的抽象语法树,在其中修改已有树节点或者插入新的树节点,从而使生成的字节码发生变化。
|
||||
|
||||
对抽象语法树的修改涉及了Java编译器的内部API,这部分很可能随着版本变更而失效。因此,我并不推荐这种修改方式。
|
||||
|
||||
如果你感兴趣的话,可以参考[Project Lombok][4]。这个项目自定义了一系列注解,并根据注解的内容来修改已有的源代码。例如它提供了`@Getter`和`@Setter`注解,能够为程序自动添加`getter`以及`setter`方法。有关对使用内部API的讨论,你可以参考[这篇博客][5],以及[Lombok的回应][6]。
|
||||
|
||||
用注解处理器来生成源代码则比较常用。我们以前介绍过的压力测试jcstress,以及接下来即将介绍的JMH工具,都是依赖这种方式来生成测试代码的。
|
||||
|
||||
```
|
||||
package foo;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface Adapt {
|
||||
Class<?> value();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这段代码中,我定义了一个注解`@Adapt`。这个注解将接收一个`Class`类型的参数`value`(如果注解类仅包含一个名为`value`的参数时,那么在使用注解时,我们可以省略`value=`),具体用法如这段代码所示。
|
||||
|
||||
```
|
||||
// Bar.java
|
||||
package test;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
import foo.Adapt;
|
||||
|
||||
public class Bar {
|
||||
@Adapt(IntBinaryOperator.class)
|
||||
public static int add(int a, int b) {
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们来实现一个处理`@Adapt`注解的处理器。该处理器将生成一个新的源文件,实现参数`value`所指定的接口,并且调用至被该注解所标注的方法之中。具体的实现代码比较长,建议你在[网页端](https://time.geekbang.org/column/108)观看。
|
||||
|
||||
```
|
||||
package bar;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.annotation.processing.*;
|
||||
import javax.lang.model.SourceVersion;
|
||||
import javax.lang.model.element.*;
|
||||
import javax.lang.model.type.TypeMirror;
|
||||
import javax.lang.model.util.ElementFilter;
|
||||
import javax.tools.JavaFileObject;
|
||||
|
||||
import javax.tools.Diagnostic.Kind;
|
||||
|
||||
@SupportedAnnotationTypes("foo.Adapt")
|
||||
@SupportedSourceVersion(SourceVersion.RELEASE_10)
|
||||
public class AdaptProcessor extends AbstractProcessor {
|
||||
|
||||
@Override
|
||||
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
|
||||
for (TypeElement annotation : annotations) {
|
||||
if (!"foo.Adapt".equals(annotation.getQualifiedName().toString())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ExecutableElement targetAsKey = getExecutable(annotation, "value");
|
||||
|
||||
for (ExecutableElement annotatedMethod : ElementFilter.methodsIn(roundEnv.getElementsAnnotatedWith(annotation))) {
|
||||
if (!annotatedMethod.getModifiers().contains(Modifier.PUBLIC)) {
|
||||
processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt on non-public method");
|
||||
continue;
|
||||
}
|
||||
if (!annotatedMethod.getModifiers().contains(Modifier.STATIC)) {
|
||||
// TODO support non-static methods
|
||||
continue;
|
||||
}
|
||||
|
||||
TypeElement targetInterface = getAnnotationValueAsTypeElement(annotatedMethod, annotation, targetAsKey);
|
||||
if (targetInterface.getKind() != ElementKind.INTERFACE) {
|
||||
processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt with non-interface input");
|
||||
continue;
|
||||
}
|
||||
|
||||
TypeElement enclosingType = getTopLevelEnclosingType(annotatedMethod);
|
||||
createAdapter(enclosingType, annotatedMethod, targetInterface);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void createAdapter(TypeElement enclosingClass, ExecutableElement annotatedMethod,
|
||||
TypeElement targetInterface) {
|
||||
PackageElement packageElement = (PackageElement) enclosingClass.getEnclosingElement();
|
||||
String packageName = packageElement.getQualifiedName().toString();
|
||||
String className = enclosingClass.getSimpleName().toString();
|
||||
String methodName = annotatedMethod.getSimpleName().toString();
|
||||
String adapterName = className + "_" + methodName + "Adapter";
|
||||
|
||||
ExecutableElement overriddenMethod = getFirstNonDefaultExecutable(targetInterface);
|
||||
|
||||
try {
|
||||
Filer filer = processingEnv.getFiler();
|
||||
JavaFileObject sourceFile = filer.createSourceFile(packageName + "." + adapterName, new Element[0]);
|
||||
|
||||
try (PrintWriter out = new PrintWriter(sourceFile.openWriter())) {
|
||||
out.println("package " + packageName + ";");
|
||||
out.println("import " + targetInterface.getQualifiedName() + ";");
|
||||
out.println();
|
||||
out.println("public class " + adapterName + " implements " + targetInterface.getSimpleName() + " {");
|
||||
out.println(" @Override");
|
||||
out.println(" public " + overriddenMethod.getReturnType() + " " + overriddenMethod.getSimpleName()
|
||||
+ formatParameter(overriddenMethod, true) + " {");
|
||||
out.println(" return " + className + "." + methodName + formatParameter(overriddenMethod, false) + ";");
|
||||
out.println(" }");
|
||||
out.println("}");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private ExecutableElement getExecutable(TypeElement annotation, String methodName) {
|
||||
for (ExecutableElement method : ElementFilter.methodsIn(annotation.getEnclosedElements())) {
|
||||
if (methodName.equals(method.getSimpleName().toString())) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
processingEnv.getMessager().printMessage(Kind.ERROR, "Incompatible @Adapt.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private ExecutableElement getFirstNonDefaultExecutable(TypeElement annotation) {
|
||||
for (ExecutableElement method : ElementFilter.methodsIn(annotation.getEnclosedElements())) {
|
||||
if (!method.isDefault()) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
processingEnv.getMessager().printMessage(Kind.ERROR,
|
||||
"Target interface should declare at least one non-default method.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private TypeElement getAnnotationValueAsTypeElement(ExecutableElement annotatedMethod, TypeElement annotation,
|
||||
ExecutableElement annotationFunction) {
|
||||
TypeMirror annotationType = annotation.asType();
|
||||
|
||||
for (AnnotationMirror annotationMirror : annotatedMethod.getAnnotationMirrors()) {
|
||||
if (processingEnv.getTypeUtils().isSameType(annotationMirror.getAnnotationType(), annotationType)) {
|
||||
AnnotationValue value = annotationMirror.getElementValues().get(annotationFunction);
|
||||
if (value == null) {
|
||||
processingEnv.getMessager().printMessage(Kind.ERROR, "Unknown @Adapt target");
|
||||
continue;
|
||||
}
|
||||
TypeMirror targetInterfaceTypeMirror = (TypeMirror) value.getValue();
|
||||
return (TypeElement) processingEnv.getTypeUtils().asElement(targetInterfaceTypeMirror);
|
||||
}
|
||||
}
|
||||
processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt should contain target()");
|
||||
return null;
|
||||
}
|
||||
|
||||
private TypeElement getTopLevelEnclosingType(ExecutableElement annotatedMethod) {
|
||||
TypeElement enclosingType = null;
|
||||
Element enclosing = annotatedMethod.getEnclosingElement();
|
||||
|
||||
while (enclosing != null) {
|
||||
if (enclosing.getKind() == ElementKind.CLASS) {
|
||||
enclosingType = (TypeElement) enclosing;
|
||||
} else if (enclosing.getKind() == ElementKind.PACKAGE) {
|
||||
break;
|
||||
}
|
||||
enclosing = enclosing.getEnclosingElement();
|
||||
}
|
||||
return enclosingType;
|
||||
}
|
||||
|
||||
private String formatParameter(ExecutableElement method, boolean includeType) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append('(');
|
||||
String separator = "";
|
||||
|
||||
for (VariableElement parameter : method.getParameters()) {
|
||||
builder.append(separator);
|
||||
if (includeType) {
|
||||
builder.append(parameter.asType());
|
||||
builder.append(' ');
|
||||
}
|
||||
builder.append(parameter.getSimpleName());
|
||||
separator = ", ";
|
||||
}
|
||||
builder.append(')');
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个注解处理器实现中,我们将读取注解中的值,因此我将使用`process`方法的第一个参数,并通过它获得被标注方法对应的`@Adapt`注解中的`value`值。
|
||||
|
||||
之所以采用这种麻烦的方式,是因为`value`值属于`Class`类型。在编译过程中,被编译代码中的`Class`常量未必被加载进Java编译器所在的虚拟机中。因此,我们需要通过`process`方法的第一个参数,获得`value`所指向的接口的抽象语法树,并据此生成源代码。
|
||||
|
||||
生成源代码的方式实际上非常容易理解。我们可以通过`Filer.createSourceFile`方法获得一个类似于文件的概念,并通过`PrintWriter`将具体的内容一一写入即可。
|
||||
|
||||
当将该注解处理器作为插件接入Java编译器时,编译前面的`test/Bar.java`将生成下述代码,并且触发新一轮的编译。
|
||||
|
||||
```
|
||||
package test;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
|
||||
public class Bar_addAdapter implements IntBinaryOperator {
|
||||
@Override
|
||||
public int applyAsInt(int arg0, int arg1) {
|
||||
return Bar.add(arg0, arg1);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
注意,该注解处理器没有处理所编译的代码包名为空的情况。
|
||||
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了Java编译器的注解处理器。
|
||||
|
||||
注解处理器主要有三个用途。一是定义编译规则,并检查被编译的源文件。二是修改已有源代码。三是生成新的源代码。其中,第二种涉及了Java编译器的内部API,因此并不推荐。第三种较为常见,是OpenJDK工具jcstress,以及JMH生成测试代码的方式。
|
||||
|
||||
Java源代码的编译过程可分为三个步骤,分别为解析源文件生成抽象语法树,调用已注册的注解处理器,和生成字节码。如果在第2步中,注解处理器生成了新的源代码,那么Java编译器将重复第1、2步,直至不再生成新的源代码。
|
||||
|
||||
今天的实践环节,请实现本文的案例`CheckGetterProcessor`中的TODO项,处理由`@CheckGetter`注解的字段。
|
||||
|
||||
[1] [http://openjdk.java.net/groups/compiler/doc/compilation-overview/index.html](http://openjdk.java.net/groups/compiler/doc/compilation-overview/index.html)<br />
|
||||
[2] [http://hannesdorfmann.com/annotation-processing/annotationprocessing101](http://hannesdorfmann.com/annotation-processing/annotationprocessing101)<br />
|
||||
[3] [https://docs.oracle.com/javase/10/docs/api/javax/lang/model/element/package-summary.html](https://docs.oracle.com/javase/10/docs/api/javax/lang/model/element/package-summary.html)<br />
|
||||
[4] [https://projectlombok.org/](https://projectlombok.org/)<br />
|
||||
[5] [http://notatube.blogspot.com/2010/11/project-lombok-trick-explained.html](http://notatube.blogspot.com/2010/11/project-lombok-trick-explained.html)<br />
|
||||
[6] [http://jnb.ociweb.com/jnb/jnbJan2010.html#controversy](http://jnb.ociweb.com/jnb/jnbJan2010.html#controversy)
|
||||
|
||||
|
||||
321
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/28 | 基准测试框架JMH(上).md
Normal file
321
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/28 | 基准测试框架JMH(上).md
Normal file
@@ -0,0 +1,321 @@
|
||||
<audio id="audio" title="28 | 基准测试框架JMH(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/63/8c9c5dfa9a6b815568f00314755ad463.mp3"></audio>
|
||||
|
||||
今天我们来聊聊性能基准测试(benchmarking)。
|
||||
|
||||
大家或许都看到过一些不严谨的性能测试,以及基于这些测试结果得出的令人匪夷所思的结论。
|
||||
|
||||
```
|
||||
static int foo() {
|
||||
int i = 0;
|
||||
while (i < 1_000_000_000) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上面这段代码中的`foo`方法,将进行10^9次加法操作及跳转操作。
|
||||
|
||||
不少开发人员,包括我在介绍反射调用那一篇中所做的性能测试,都使用了下面这段代码的测量方式,即通过`System.nanoTime`或者`System.currentTimeMillis`来测量每若干个操作(如连续调用1000次`foo`方法)所花费的时间。
|
||||
|
||||
```
|
||||
public class LoopPerformanceTest {
|
||||
static int foo() { ... }
|
||||
|
||||
public static void main(String[] args) {
|
||||
// warmup
|
||||
for (int i = 0; i < 20_000; i++) {
|
||||
foo();
|
||||
}
|
||||
// measurement
|
||||
long current = System.nanoTime();
|
||||
for (int i = 1; i <= 10_000; i++) {
|
||||
foo();
|
||||
if (i % 1000 == 0) {
|
||||
long temp = System.nanoTime();
|
||||
System.out.println(temp - current);
|
||||
current = System.nanoTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这种测量方式实际上过于理性化,忽略了Java虚拟机、操作系统,乃至硬件系统所带来的影响。
|
||||
|
||||
## 性能测试的坑
|
||||
|
||||
关于Java虚拟机所带来的影响,我们在前面的篇章中已经介绍过不少,如Java虚拟机堆空间的自适配,即时编译等。
|
||||
|
||||
在上面这段代码中,真正进行测试的代码(即`// measurement`后的代码)由于循环次数不多,属于冷循环,没有能触发OSR编译。
|
||||
|
||||
也就是说,我们会在`main`方法中解释执行,然后调用`foo`方法即时编译生成的机器码中。这种混杂了解释执行以及即时编译生成代码的测量方式,其得到的数据含义不明。
|
||||
|
||||
有同学认为,我们可以假设`foo`方法耗时较长(毕竟10^9次加法),因此`main`方法的解释执行并不会对最终计算得出的性能数据造成太大影响。上面这段代码在我的机器上测出的结果是,每1000次`foo`方法调用在20微秒左右。
|
||||
|
||||
这是否意味着,我这台机器的CPU已经远超它的物理限制,其频率达到100,000,000 GHz了。(假设循环主体就两条指令,每时钟周期指令数[1]为1。)这显然是不可能的,目前CPU单核的频率大概在2-5 GHz左右,再怎么超频也不可能提升七八个数量级。
|
||||
|
||||
你应该能够猜到,这和即时编译器的循环优化有关。下面便是`foo`方法的编译结果。我们可以看到,它将直接返回10^9,而不是循环10^9次,并在循环中重复进行加法。
|
||||
|
||||
```
|
||||
0x8aa0: sub rsp,0x18 // 创建方法栈桢
|
||||
0x8aa7: mov QWORD PTR [rsp+0x10],rbp // 无关指令
|
||||
0x8aac: mov eax,0x3b9aca00 // return 10^9
|
||||
0x8ab1: add rsp,0x10 // 弹出方法栈桢
|
||||
0x8ab5: pop rbp // 无关指令
|
||||
0x8ab6: mov r10,QWORD PTR [r15+0x70] // 安全点测试
|
||||
0x8aba: test DWORD PTR [r10],eax // 安全点测试
|
||||
0x8abd: ret
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
之前我忘记解释所谓的”无关指令“是什么意思。我指的是该指令和具体的代码逻辑无关。即时编译器生成的代码可能会将RBP寄存器作为通用寄存器,从而是寄存器分配算法有更多的选择。由于调用者(caller)未必保存了RBP寄存器的值,所以即时编译器会在进入被调用者(callee)时保存RBP的值,并在退出被调用者时复原RBP的值。
|
||||
|
||||
|
||||
```
|
||||
static int foo() {
|
||||
int i = 0;
|
||||
while (i < 1_000_000_000) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
// 优化为
|
||||
static int foo() {
|
||||
return 1_000_000_000;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
该循环优化并非循环展开。在默认情况下,即时编译器仅能将循环展开60次(对应虚拟机参数`-XX:LoopUnrollLimit`)。实际上,在介绍循环优化那篇文章中,我并没有提及这个优化。因为该优化实在是太过于简单,几乎所有开发人员都能够手工对其进行优化。
|
||||
|
||||
在即时编译器中,它是一个基于计数循环的优化。我们也已经学过计数循环的知识。也就是说,只要将循环变量`i`改为long类型,便可以“避免”这个优化。
|
||||
|
||||
关于操作系统和硬件系统所带来的影响,一个较为常见的例子便是电源管理策略。在许多机器,特别是笔记本上,操作系统会动态配置CPU的频率。而CPU的频率又直接影响到性能测试的数据,因此短时间的性能测试得出的数据未必可靠。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/97/07ca617893718782b8eb58344b7bb097.jpeg" alt="" />
|
||||
|
||||
例如我的笔记本,在刚开始进行性能评测时,单核频率可以达到 4.0 GHz。而后由于CPU温度升高,频率便被限制在3.0 GHz了。
|
||||
|
||||
除了电源管理之外,CPU缓存、分支预测器[2],以及超线程技术[3],都会对测试结果造成影响。
|
||||
|
||||
就CPU缓存而言,如果程序的数据本地性较好,那么它的性能指标便会非常好;如果程序存在false sharing的问题,即几个线程写入内存中属于同一缓存行的不同部分,那么它的性能指标便会非常糟糕。
|
||||
|
||||
超线程技术是另一个可能误导性能测试工具的因素。我们知道,超线程技术将为每个物理核心虚拟出两个虚拟核心,从而尽可能地提高物理核心的利用率。如果性能测试的两个线程被安排在同一物理核心上,那么得到的测试数据显然要比被安排在不同物理核心上的数据糟糕得多。
|
||||
|
||||
总而言之,性能基准测试存在着许多深坑(pitfall)。然而,除了性能测试专家外,大多数开发人员都没有足够全面的知识,能够绕开这些坑,因而得出的性能测试数据很有可能是有偏差的(biased)。
|
||||
|
||||
下面我将介绍OpenJDK中的开源项目 JMH[4](Java Microbenchmark Harness)。JMH是一个面向Java语言或者其他Java虚拟机语言的性能基准测试框架。它针对的是纳秒级别(出自官网介绍,个人觉得精确度没那么高)、微秒级别、毫秒级别,以及秒级别的性能测试。
|
||||
|
||||
由于许多即时编译器的开发人员参与了该项目,因此JMH内置了许多功能来控制即时编译器的优化。对于其他影响性能评测的因素,JMH也提供了不少策略来降低影响,甚至是彻底解决。
|
||||
|
||||
因此,使用这个性能基准测试框架的开发人员,可以将精力完全集中在所要测试的业务逻辑,并以最小的代价控制除了业务逻辑之外的可能影响性能的因素。
|
||||
|
||||
```
|
||||
REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial experiments, perform baseline and negative tests that provide experimental control, make sure the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts. Do not assume the numbers tell you what you want them to tell.
|
||||
|
||||
```
|
||||
|
||||
不过,JMH也不能完美解决性能测试数据的偏差问题。它甚至会在每次运行的输出结果中打印上述语句,所以,JMH的开发人员也给出了一个小忠告:我们开发人员不要轻信JMH的性能测试数据,不要基于这些数据乱下结论。
|
||||
|
||||
通常来说,性能基准测试的结果反映的是所测试的业务逻辑在所运行的Java虚拟机,操作系统,硬件系统这一组合上的性能指标,而根据这些性能指标得出的通用结论则需要经过严格论证。
|
||||
|
||||
在理解(或忽略)了JMH的忠告后,我们下面便来看看如何使用JMH。
|
||||
|
||||
## 生成JMH项目
|
||||
|
||||
JMH的使用方式并不复杂。我们可以借助JMH部署在maven上的archetype,生成预设好依赖关系的maven项目模板。具体的命令如下所示:
|
||||
|
||||
```
|
||||
$ mvn archetype:generate \
|
||||
-DinteractiveMode=false \
|
||||
-DarchetypeGroupId=org.openjdk.jmh \
|
||||
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
|
||||
-DgroupId=org.sample \
|
||||
-DartifactId=test \
|
||||
-Dversion=1.21
|
||||
$ cd test
|
||||
|
||||
```
|
||||
|
||||
该命令将在当前目录下生成一个`test`文件夹(对应参数`-DartifactId=test`,可更改),其中便包含了定义该maven项目依赖的`pom.xml`文件,以及自动生成的测试文件`src/main/org/sample/MyBenchmark.java`(这里`org/sample`对应参数`-DgroupId=org.sample`,可更改)。后者的内容如下所示:
|
||||
|
||||
```
|
||||
/*
|
||||
* Copyright ...
|
||||
*/
|
||||
package org.sample;
|
||||
|
||||
import org.openjdk.jmh.annotations.Benchmark;
|
||||
|
||||
public class MyBenchmark {
|
||||
|
||||
@Benchmark
|
||||
public void testMethod() {
|
||||
// This is a demo/sample template for building your JMH benchmarks. Edit as needed.
|
||||
// Put your benchmark code here.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面,类名`MyBenchmark`以及方法名`testMethod`并不重要,你可以随意更改。真正重要的是`@Benchmark`注解。被它标注的方法,便是JMH基准测试的测试方法。该测试方法默认是空的。我们可以填入需要进行性能测试的业务逻辑。
|
||||
|
||||
举个例子,我们可以测量新建异常对象的性能,如下述代码所示:
|
||||
|
||||
```
|
||||
@Benchmark
|
||||
public void testMethod() {
|
||||
new Exception();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通常来说,我们不应该使用这种貌似会被即时编译器优化掉的代码(在下篇中我会介绍JMH的`Blackhole`功能)。
|
||||
|
||||
不过,我们已经学习过逃逸分析了,知道native方法调用的调用者或者参数会被识别为逃逸。而`Exception`的构造器将间接调用至native方法`fillInStackTrace`中,并且该方法调用的调用者便是新建的`Exception`对象。因此,逃逸分析将判定该新建对象逃逸,而即时编译器也无法优化掉原本的新建对象操作。
|
||||
|
||||
当`Exception`的构造器返回时,Java虚拟机将不再拥有指向这一新建对象的引用。因此,该新建对象可以被垃圾回收。
|
||||
|
||||
## 编译和运行JMH项目
|
||||
|
||||
在上一篇介绍注解处理器时,我曾提到过,JMH正是利用注解处理器[5]来自动生成性能测试的代码。实际上,除了`@Benchmark`之外,JMH的注解处理器还将处理所有位于`org.openjdk.jmh.annotations`包[6]下的注解。(其他注解我们会在下一篇中详细介绍。)
|
||||
|
||||
我们可以运行`mvn compile`命令来编译这个maven项目。该命令将生成`target`文件夹,其中的`generated-sources`目录便存放着由JMH的注解处理器所生成的Java源代码:
|
||||
|
||||
```
|
||||
$ mvn compile
|
||||
$ ls target/generated-sources/annotations/org/sample/generated/
|
||||
MyBenchmark_jmhType.java MyBenchmark_jmhType_B1.java MyBenchmark_jmhType_B2.java MyBenchmark_jmhType_B3.java MyBenchmark_testMethod_jmhTest.java
|
||||
|
||||
```
|
||||
|
||||
在这些源代码里,所有以`MyBenchmark_jmhType`为前缀的Java类都继承自`MyBenchmark`。这是注解处理器的常见用法,即通过生成子类来将注解所带来的额外语义扩张成方法。
|
||||
|
||||
具体来说,它们之间的继承关系是`MyBenchmark_jmhType -> B3 -> B2 -> B1 -> MyBenchmark`(这里`A -> B`代表A继承B)。其中,B2存放着JMH用来控制基准测试的各项字段。
|
||||
|
||||
为了避免这些控制字段对`MyBenchmark`类中的字段造成false sharing的影响,JMH生成了B1和B3,分别存放了256个boolean字段,从而避免B2中的字段与`MyBenchmark`类、`MyBenchmark_jmhType`类中的字段(或内存里下一个对象中的字段)会出现在同一缓存行中。
|
||||
|
||||
>
|
||||
之所以不能在同一类中安排这些字段,是因为Java虚拟机的字段重排列。而类之间的继承关系,便可以避免不同类所包含的字段之间的重排列。
|
||||
|
||||
|
||||
除了这些`jmhType`源代码外,`generated-sources`目录还存放着真正的性能测试代码`MyBenchmark_testMethod_jmhTest.java`。当进行性能测试时,Java虚拟机所运行的代码很有可能便是这一个源文件中的热循环经过OSR编译过后的代码。
|
||||
|
||||
>
|
||||
在通过CompileCommand分析即时编译后的机器码时,我们需要关注的其实是`MyBenchmark_testMethod_jmhTest`中的方法。
|
||||
|
||||
|
||||
由于这里面的内容过于复杂,我将在下一篇中介绍影响该生成代码的众多功能性注解,这里就不再详细进行介绍了。
|
||||
|
||||
接下来,我们可以运行`mvn package`命令,将编译好的class文件打包成jar包。生成的jar包同样位于`target`目录下,其名字为`benchmarks.jar`。jar包里附带了一系列配置文件,如下所示:
|
||||
|
||||
```
|
||||
$ mvn package
|
||||
|
||||
$ jar tf target/benchmarks.jar META-INF
|
||||
META-INF/MANIFEST.MF
|
||||
META-INF/
|
||||
META-INF/BenchmarkList
|
||||
META-INF/CompilerHints
|
||||
META-INF/maven/
|
||||
META-INF/maven/org.sample/
|
||||
META-INF/maven/org.sample/test/
|
||||
META-INF/maven/org.sample/test/pom.xml
|
||||
META-INF/maven/org.sample/test/pom.properties
|
||||
META-INF/maven/org.openjdk.jmh/
|
||||
META-INF/maven/org.openjdk.jmh/jmh-core/
|
||||
META-INF/maven/org.openjdk.jmh/jmh-core/pom.xml
|
||||
META-INF/maven/org.openjdk.jmh/jmh-core/pom.properties
|
||||
META-INF/maven/net.sf.jopt-simple/
|
||||
META-INF/maven/net.sf.jopt-simple/jopt-simple/
|
||||
META-INF/maven/net.sf.jopt-simple/jopt-simple/pom.xml
|
||||
META-INF/maven/net.sf.jopt-simple/jopt-simple/pom.properties
|
||||
META-INF/LICENSE.txt
|
||||
META-INF/NOTICE.txt
|
||||
META-INF/maven/org.apache.commons/
|
||||
META-INF/maven/org.apache.commons/commons-math3/
|
||||
META-INF/maven/org.apache.commons/commons-math3/pom.xml
|
||||
META-INF/maven/org.apache.commons/commons-math3/pom.properties
|
||||
|
||||
$ unzip -c target/benchmarks.jar META-INF/MANIFEST.MF
|
||||
Archive: target/benchmarks.jar
|
||||
inflating: META-INF/MANIFEST.MF
|
||||
Manifest-Version: 1.0
|
||||
Archiver-Version: Plexus Archiver
|
||||
Created-By: Apache Maven 3.5.4
|
||||
Built-By: zhengy
|
||||
Build-Jdk: 10.0.2
|
||||
Main-Class: org.openjdk.jmh.Main
|
||||
|
||||
$ unzip -c target/benchmarks.jar META-INF/BenchmarkList
|
||||
Archive: target/benchmarks.jar
|
||||
inflating: META-INF/BenchmarkList
|
||||
JMH S 22 org.sample.MyBenchmark S 51 org.sample.generated.MyBenchmark_testMethod_jmhTest S 10 testMethod S 10 Throughput E A 1 1 1 E E E E E E E E E E E E E E E E E
|
||||
|
||||
$ unzip -c target/benchmarks.jar META-INF/CompilerHints
|
||||
Archive: target/benchmarks.jar
|
||||
inflating: META-INF/CompilerHints
|
||||
dontinline,*.*_all_jmhStub
|
||||
dontinline,*.*_avgt_jmhStub
|
||||
dontinline,*.*_sample_jmhStub
|
||||
dontinline,*.*_ss_jmhStub
|
||||
dontinline,*.*_thrpt_jmhStub
|
||||
inline,org/sample/MyBenchmark.testMethod
|
||||
|
||||
```
|
||||
|
||||
这里我展示了其中三个比较重要的配置文件。
|
||||
|
||||
<li>
|
||||
`MANIFEST.MF`中指定了该jar包的默认入口,即`org.openjdk.jmh.Main`[7]。
|
||||
</li>
|
||||
<li>
|
||||
`BenchmarkList`中存放了测试配置。该配置是根据`MyBenchmark.java`里的注解自动生成的,具体我会在下一篇中详细介绍源代码中如何配置。
|
||||
</li>
|
||||
<li>
|
||||
`CompilerHints`中存放了传递给Java虚拟机的`-XX:CompileCommandFile`参数的内容。它规定了无法内联以及必须内联的几个方法,其中便有存放业务逻辑的测试方法`testMethod`。
|
||||
</li>
|
||||
|
||||
在编译`MyBenchmark_testMethod_jmhTest`类中的测试方法时,JMH会让即时编译器强制内联对`MyBenchmark.testMethod`的方法调用,以避免调用开销。
|
||||
|
||||
打包生成的jar包可以直接运行。具体指令如下所示:
|
||||
|
||||
```
|
||||
$ java -jar target/benchmarks.jar
|
||||
WARNING: An illegal reflective access operation has occurred
|
||||
...
|
||||
Benchmark Mode Cnt Score Error Units
|
||||
MyBenchmark.testMethod thrpt 25 1004801,393 ± 4055,462 ops/s
|
||||
|
||||
```
|
||||
|
||||
这里JMH会有非常多的输出,具体内容我会在下一篇中进行讲解。
|
||||
|
||||
输出的最后便是本次基准测试的结果。其中比较重要的两项指标是`Score`和`Error`,分别代表本次基准测试的平均吞吐量(每秒运行`testMethod`方法的次数)以及误差范围。例如,这里的结果说明本次基准测试平均每秒生成10^6个异常实例,误差范围大致在4000个异常实例。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了OpenJDK的性能基准测试项目JMH。
|
||||
|
||||
Java程序的性能测试存在着许多深坑,有来自Java虚拟机的,有来自操作系统的,甚至有来自硬件系统的。如果没有足够的知识,那么性能测试的结果很有可能是有偏差的。
|
||||
|
||||
性能基准测试框架JMH是OpenJDK中的其中一个开源项目。它内置了许多功能,来规避由Java虚拟机中的即时编译器或者其他优化对性能测试造成的影响。此外,它还提供了不少策略来降低来自操作系统以及硬件系统的影响。
|
||||
|
||||
开发人员仅需将所要测试的业务逻辑通过`@Benchmark`注解,便可以让JMH的注解处理器自动生成真正的性能测试代码,以及相应的性能测试配置文件。
|
||||
|
||||
今天的实践环节,请生成一个JMH项目,并且在`MyBenchmark.testMethod`方法中填入自己的业务逻辑。(除非你已经提前了解`@State`等JMH功能,否则请不要在`MyBenchmark`中定义实例变量。)
|
||||
|
||||
[1] [https://en.wikipedia.org/wiki/Instructions_per_cycle](https://en.wikipedia.org/wiki/Instructions_per_cycle)<br />
|
||||
[2] [https://en.wikipedia.org/wiki/Branch_predictor](https://en.wikipedia.org/wiki/Branch_predictor)<br />
|
||||
[3] [https://en.wikipedia.org/wiki/Hyper-threading](https://en.wikipedia.org/wiki/Hyper-threading)<br />
|
||||
[4] [http://openjdk.java.net/projects/code-tools/jmh/](http://openjdk.java.net/projects/code-tools/jmh/)<br />
|
||||
[5] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-generator-annprocess/src/main/java/org/openjdk/jmh/generators/BenchmarkProcessor.java](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-generator-annprocess/src/main/java/org/openjdk/jmh/generators/BenchmarkProcessor.java)<br />
|
||||
[6] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/annotations](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/annotations)<br />
|
||||
[7] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/Main.java](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/Main.java)
|
||||
|
||||
|
||||
275
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/29 | 基准测试框架JMH(下).md
Normal file
275
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/29 | 基准测试框架JMH(下).md
Normal file
@@ -0,0 +1,275 @@
|
||||
<audio id="audio" title="29 | 基准测试框架JMH(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/c2/0cc7f4a0c58d802ef006f1c803ce47c2.mp3"></audio>
|
||||
|
||||
今天我们来继续学习基准测试框架JMH。
|
||||
|
||||
## @Fork和@BenchmarkMode
|
||||
|
||||
在上一篇的末尾,我们已经运行过由JMH项目编译生成的jar包了。下面是它的输出结果:
|
||||
|
||||
```
|
||||
$ java -jar target/benchmarks.jar
|
||||
...
|
||||
# JMH version: 1.21
|
||||
# VM version: JDK 10.0.2, Java HotSpot(TM) 64-Bit Server VM, 10.0.2+13
|
||||
# VM invoker: /Library/Java/JavaVirtualMachines/jdk-10.0.2.jdk/Contents/Home/bin/java
|
||||
# VM options: <none>
|
||||
# Warmup: 5 iterations, 10 s each
|
||||
# Measurement: 5 iterations, 10 s each
|
||||
# Timeout: 10 min per iteration
|
||||
# Threads: 1 thread, will synchronize iterations
|
||||
# Benchmark mode: Throughput, ops/time
|
||||
# Benchmark: org.sample.MyBenchmark.testMethod
|
||||
|
||||
# Run progress: 0,00% complete, ETA 00:08:20
|
||||
# Fork: 1 of 5
|
||||
# Warmup Iteration 1: 1023500,647 ops/s
|
||||
# Warmup Iteration 2: 1030767,909 ops/s
|
||||
# Warmup Iteration 3: 1018212,559 ops/s
|
||||
# Warmup Iteration 4: 1002045,519 ops/s
|
||||
# Warmup Iteration 5: 1004210,056 ops/s
|
||||
Iteration 1: 1010251,342 ops/s
|
||||
Iteration 2: 1005717,344 ops/s
|
||||
Iteration 3: 1004751,523 ops/s
|
||||
Iteration 4: 1003034,640 ops/s
|
||||
Iteration 5: 997003,830 ops/s
|
||||
|
||||
# Run progress: 20,00% complete, ETA 00:06:41
|
||||
# Fork: 2 of 5
|
||||
...
|
||||
|
||||
# Run progress: 80,00% complete, ETA 00:01:40
|
||||
# Fork: 5 of 5
|
||||
# Warmup Iteration 1: 988321,959 ops/s
|
||||
# Warmup Iteration 2: 999486,531 ops/s
|
||||
# Warmup Iteration 3: 1004856,886 ops/s
|
||||
# Warmup Iteration 4: 1004810,860 ops/s
|
||||
# Warmup Iteration 5: 1002332,077 ops/s
|
||||
Iteration 1: 1011871,670 ops/s
|
||||
Iteration 2: 1002653,844 ops/s
|
||||
Iteration 3: 1003568,030 ops/s
|
||||
Iteration 4: 1002724,752 ops/s
|
||||
Iteration 5: 1001507,408 ops/s
|
||||
|
||||
|
||||
Result "org.sample.MyBenchmark.testMethod":
|
||||
1004801,393 ±(99.9%) 4055,462 ops/s [Average]
|
||||
(min, avg, max) = (992193,459, 1004801,393, 1014504,226), stdev = 5413,926
|
||||
CI (99.9%): [1000745,931, 1008856,856] (assumes normal distribution)
|
||||
|
||||
|
||||
# Run complete. Total time: 00:08:22
|
||||
|
||||
...
|
||||
|
||||
Benchmark Mode Cnt Score Error Units
|
||||
MyBenchmark.testMethod thrpt 25 1004801,393 ± 4055,462 ops/s
|
||||
|
||||
```
|
||||
|
||||
在上面这段输出中,我们暂且忽略最开始的Warning以及打印出来的配置信息,直接看接下来貌似重复的五段输出。
|
||||
|
||||
```
|
||||
# Run progress: 0,00% complete, ETA 00:08:20
|
||||
# Fork: 1 of 5
|
||||
# Warmup Iteration 1: 1023500,647 ops/s
|
||||
# Warmup Iteration 2: 1030767,909 ops/s
|
||||
# Warmup Iteration 3: 1018212,559 ops/s
|
||||
# Warmup Iteration 4: 1002045,519 ops/s
|
||||
# Warmup Iteration 5: 1004210,056 ops/s
|
||||
Iteration 1: 1010251,342 ops/s
|
||||
Iteration 2: 1005717,344 ops/s
|
||||
Iteration 3: 1004751,523 ops/s
|
||||
Iteration 4: 1003034,640 ops/s
|
||||
Iteration 5: 997003,830 ops/s
|
||||
|
||||
```
|
||||
|
||||
你应该已经留意到`Fork: 1 of 5`的字样。这里指的是JMH会Fork出一个新的Java虚拟机,来运行性能基准测试。
|
||||
|
||||
之所以另外启动一个Java虚拟机进行性能基准测试,是为了获得一个相对干净的虚拟机环境。
|
||||
|
||||
在介绍反射的那篇文章中,我就已经演示过因为类型profile被污染,而导致无法内联的情况。使用新的虚拟机,将极大地降低被上述情况干扰的可能性,从而保证更加精确的性能数据。
|
||||
|
||||
在介绍虚方法内联的那篇文章中,我讲解过基于类层次分析的完全内联。新启动的Java虚拟机,其加载的与测试无关的抽象类子类或接口实现相对较少。因此,具体是否进行完全内联将交由开发人员来决定。
|
||||
|
||||
关于这种情况,JMH提供了一个性能测试案例[1]。如果你感兴趣的话,可以下载下来自己跑一遍。
|
||||
|
||||
除了对即时编译器的影响之外,Fork出新的Java虚拟机还会提升性能数据的准确度。
|
||||
|
||||
这主要是因为不少Java虚拟机的优化会带来不确定性,例如TLAB内存分配(TLAB的大小会变化),偏向锁、轻量锁算法,并发数据结构等。这些不确定性都可能导致不同Java虚拟机中运行的性能测试的结果不同,例如JMH这一性能的测试案例[2]。
|
||||
|
||||
在这种情况下,通过运行更多的Fork,并将每个Java虚拟机的性能测试结果平均起来,可以增强最终数据的可信度,使其误差更小。在JMH中,你可以通过`@Fork`注解来配置,具体如下述代码所示:
|
||||
|
||||
```
|
||||
@Fork(10)
|
||||
public class MyBenchmark {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
让我们回到刚刚的输出结果。每个Fork包含了5个预热迭代(warmup iteration,如`# Warmup Iteration 1: 1023500,647 ops/s`)以及5个测试迭代(measurement iteration,如`Iteration 1: 1010251,342 ops/s`)。
|
||||
|
||||
每个迭代后都跟着一个数据,代表本次迭代的吞吐量,也就是每秒运行了多少次操作(operations/s,或ops/s)。默认情况下,一次操作指的是调用一次测试方法`testMethod`。
|
||||
|
||||
除了吞吐量之外,我们还可以输出其他格式的性能数据,例如运行一次操作的平均时间。具体的配置方法以及对应参数如下述代码以及下表所示:
|
||||
|
||||
```
|
||||
@BenchmarkMode(Mode.AverageTime)
|
||||
public class MyBenchmark {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
一般来说,默认使用的吞吐量已足够满足大多数测试需求了。
|
||||
|
||||
## @Warmup和@Measurement
|
||||
|
||||
之所以区分预热迭代和测试迭代,是为了在记录性能数据之前,将Java虚拟机带至一个稳定状态。
|
||||
|
||||
这里的稳定状态,不仅包括测试方法被即时编译成机器码,还包括Java虚拟机中各种自适配优化算法能够稳定下来,如前面提到的TLAB大小,亦或者是使用传统垃圾回收器时的Eden区、Survivor区和老年代的大小。
|
||||
|
||||
一般来说,预热迭代的数目以及每次预热迭代的时间,需要由你根据所要测试的业务逻辑代码来调配。通常的做法便是在首次运行时配置较多次迭代,并监控性能数据达到稳定状态时的迭代数目。
|
||||
|
||||
不少性能评测框架都会自动检测稳定状态。它们所采用的算法是计算迭代之间的差值,如果连续几个迭代与前一迭代的差值均小于某个值,便将这几个迭代以及之后的迭代当成稳定状态。
|
||||
|
||||
这种做法有一个缺陷,那便是在达到最终稳定状态前,程序可能拥有多个中间稳定状态。例如通过Java上的JavaScript引擎Nashorn运行JavaScript代码,便可能出现多个中间稳定状态的情况。(具体可参考Aleksey Shipilev的devoxx 2013演讲[3]的第21页。)
|
||||
|
||||
总而言之,开发人员需要自行决定预热迭代的次数以及每次迭代的持续时间。
|
||||
|
||||
通常来说,我会在保持5-10个预热迭代的前提下(这样可以看出是否达到稳定状况),将总的预热时间优化至最少,以便节省性能测试的机器时间。(这在持续集成/回归测试的硬件资源跟不上代码提交速度的团队中非常重要。)
|
||||
|
||||
当确定了预热迭代的次数以及每次迭代的持续时间之后,我们便可以通过`@Warmup`注解来进行配置,如下述代码所示:
|
||||
|
||||
```
|
||||
@Warmup(iterations=10, time=100, timeUnit=TimeUnit.MILLISECONDS, batchSize=10)
|
||||
public class MyBenchmark {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`@Warmup`注解有四个参数,分别为预热迭代的次数`iterations`,每次迭代持续的时间`time`和`timeUnit`(前者是数值,后者是单位。例如上面代码代表的是每次迭代持续100毫秒),以及每次操作包含多少次对测试方法的调用`batchSize`。
|
||||
|
||||
测试迭代可通过`@Measurement`注解来进行配置。它的可配置选项和`@Warmup`的一致,这里就不再重复了。与预热迭代不同的是,每个Fork中测试迭代的数目越多,我们得到的性能数据也就越精确。
|
||||
|
||||
## @State、@Setup和@TearDown
|
||||
|
||||
通常来说,我们所要测试的业务逻辑只是整个应用程序中的一小部分,例如某个具体的web app请求。这要求在每次调用测试方法前,程序处于准备接收请求的状态。
|
||||
|
||||
我们可以把上述场景抽象一下,变成程序从某种状态到另一种状态的转换,而性能测试,便是在收集该转换的性能数据。
|
||||
|
||||
JMH提供了`@State`注解,被它标注的类便是程序的状态。由于JMH将负责生成这些状态类的实例,因此,它要求状态类必须拥有无参数构造器,以及当状态类为内部类时,该状态类必须是静态的。
|
||||
|
||||
JMH还将程序状态细分为整个虚拟机的程序状态,线程私有的程序状态,以及线程组私有的程序状态,分别对应`@State`注解的参数`Scope.Benchmark`,`Scope.Thread`和`Scope.Group`。
|
||||
|
||||
需要注意的是,这里的线程组并非JDK中的那个概念,而是JMH自己定义的概念。具体可以参考`@GroupThreads`注解[4],以及这个案例[5]。
|
||||
|
||||
`@State`的配置方法以及状态类的用法如下所示:
|
||||
|
||||
```
|
||||
public class MyBenchmark {
|
||||
@State(Scope.Benchmark)
|
||||
public static class MyBenchmarkState {
|
||||
String message = "exception";
|
||||
}
|
||||
|
||||
@Benchmark
|
||||
public void testMethod(MyBenchmarkState state) {
|
||||
new Exception(state.message);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,状态类是通过方法参数的方式传入测试方法之中的。JMH将负责把所构造的状态类实例传入该方法之中。
|
||||
|
||||
不过,如果`MyBenchmark`被标注为`@State`,那么我们可以不用在测试方法中定义额外的参数,而是直接访问`MyBenchmark`类中的实例变量。
|
||||
|
||||
和JUnit测试一样,我们可以在测试前初始化程序状态,在测试后校验程序状态。这两种操作分别对应`@Setup`和`@TearDown`注解,被它们标注的方法必须是状态类中的方法。
|
||||
|
||||
而且,JMH并不限定状态类中`@Setup`方法以及`@TearDown`方法的数目。当存在多个`@Setup`方法或者`@TearDown`方法时,JMH将按照定义的先后顺序执行。
|
||||
|
||||
JMH对`@Setup`方法以及`@TearDown`方法的调用时机是可配置的。可供选择的粒度有在整个性能测试前后调用,在每个迭代前后调用,以及在每次调用测试方法前后调用。其中,最后一个粒度将影响测试数据的精度。
|
||||
|
||||
这三种粒度分别对应`@Setup`和`@TearDown`注解的参数`Level.Trial`,`Level.Iteration`,以及`Level.Invocation`。具体的用法如下所示:
|
||||
|
||||
```
|
||||
public class MyBenchmark {
|
||||
@State(Scope.Benchmark)
|
||||
public static class MyBenchmarkState {
|
||||
int count;
|
||||
|
||||
@Setup(Level.Invocation)
|
||||
public void before() {
|
||||
count = 0;
|
||||
}
|
||||
|
||||
@TearDown(Level.Invocation)
|
||||
public void after() {
|
||||
// Run with -ea
|
||||
assert count == 1 : "ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
@Benchmark
|
||||
public void testMethod(MyBenchmarkState state) {
|
||||
state.count++;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 即时编译相关功能
|
||||
|
||||
JMH还提供了不少控制即时编译的功能,例如可以控制每个方法内联与否的`@CompilerControl`注解[6]。
|
||||
|
||||
另外一个更小粒度的功能则是`Blackhole`类。它里边的`consume`方法可以防止即时编译器将所传入的值给优化掉。
|
||||
|
||||
具体的使用方法便是为被`@Benchmark`注解标注了的测试方法增添一个类型为`Blackhole`的参数,并且在测试方法的代码中调用其实例方法`Blackhole.consume`,如下述代码所示:
|
||||
|
||||
```
|
||||
@Benchmark
|
||||
public void testMethod(Blackhole bh) {
|
||||
bh.consume(new Object()); // prevents escape analysis
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,它并不会阻止对传入值的计算的优化。举个例子,在下面这段代码中,我将`3+4`的值传入`Blackhole.consume`方法中。即时编译器仍旧会进行常量折叠,而`Blackhole`将阻止即时编译器把所得到的常量值7给优化消除掉。
|
||||
|
||||
```
|
||||
@Benchmark
|
||||
public void testMethod(Blackhole bh) {
|
||||
bh.consume(3+4);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
除了防止死代码消除的`consume`之外,`Blackhole`类还提供了一个静态方法`consumeCPU`,来消耗CPU时间。该方法将接收一个long类型的参数,这个参数与所消耗的CPU时间呈线性相关。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了基准测试框架JMH的进阶功能。我们来回顾一下。
|
||||
|
||||
- `@Fork`允许开发人员指定所要Fork出的Java虚拟机的数目。
|
||||
- `@BenchmarkMode`允许指定性能数据的格式。
|
||||
- `@Warmup`和`@Measurement`允许配置预热迭代或者测试迭代的数目,每个迭代的时间以及每个操作包含多少次对测试方法的调用。
|
||||
- `@State`允许配置测试程序的状态。测试前对程序状态的初始化以及测试后对程序状态的恢复或者校验可分别通过`@Setup`和`@TearDown`来实现。
|
||||
|
||||
今天的实践环节,请逐个运行JMH的官方案例[7],具体每个案例的意义都在代码注释之中。
|
||||
|
||||
最后给大家推荐一下Aleksey Shipilev的devoxx 2013演讲(Slides[8];视频[9],请自备梯子)。如果你已经完成本专栏前面两部分,特别是第二部分的学习,那么这个演讲里的绝大部分内容你应该都能理解。
|
||||
|
||||
[1] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_12_Forking.java](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_12_Forking.java)<br />
|
||||
[2] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_13_RunToRun.java](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_13_RunToRun.java)<br />
|
||||
[3] [https://shipilev.net/talks/devoxx-Nov2013-benchmarking.pdf](https://shipilev.net/talks/devoxx-Nov2013-benchmarking.pdf)<br />
|
||||
[4] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/annotations/GroupThreads.java](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/annotations/GroupThreads.java)<br />
|
||||
[5] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_15_Asymmetric.java](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_15_Asymmetric.java)<br />
|
||||
[6] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/annotations/CompilerControl.java](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/annotations/CompilerControl.java)<br />
|
||||
[7] [http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples)<br />
|
||||
[8] [https://shipilev.net/talks/devoxx-Nov2013-benchmarking.pdf](https://shipilev.net/talks/devoxx-Nov2013-benchmarking.pdf)<br />
|
||||
[9] [https://www.youtube.com/watch?v=VaWgOCDBxYw](https://www.youtube.com/watch?v=VaWgOCDBxYw)
|
||||
|
||||
|
||||
354
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/30 | Java虚拟机的监控及诊断工具(命令行篇).md
Normal file
354
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/30 | Java虚拟机的监控及诊断工具(命令行篇).md
Normal file
@@ -0,0 +1,354 @@
|
||||
<audio id="audio" title="30 | Java虚拟机的监控及诊断工具(命令行篇)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/43/ca/43dd5d504bd989b429810514b702e4ca.mp3"></audio>
|
||||
|
||||
今天,我们来一起了解一下JDK中用于监控及诊断工具。本篇中我将使用刚刚发布的Java 11版本的工具进行示范。
|
||||
|
||||
## jps
|
||||
|
||||
你可能用过`ps`命令,打印所有正在运行的进程的相关信息。JDK中的`jps`命令([帮助文档](https://docs.oracle.com/en/java/javase/11/tools/jps.html))沿用了同样的概念:它将打印所有正在运行的Java进程的相关信息。
|
||||
|
||||
在默认情况下,`jps`的输出信息包括Java进程的进程ID以及主类名。我们还可以通过追加参数,来打印额外的信息。例如,`-l`将打印模块名以及包名;`-v`将打印传递给Java虚拟机的参数(如`-XX:+UnlockExperimentalVMOptions -XX:+UseZGC`);`-m`将打印传递给主类的参数。
|
||||
|
||||
具体的示例如下所示:
|
||||
|
||||
```
|
||||
$ jps -mlv
|
||||
18331 org.example.Foo Hello World
|
||||
18332 jdk.jcmd/sun.tools.jps.Jps -mlv -Dapplication.home=/Library/Java/JavaVirtualMachines/jdk-11.jdk/Contents/Home -Xms8m -Djdk.module.main=jdk.jcmd
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,如果某Java进程关闭了默认开启的`UsePerfData`参数(即使用参数`-XX:-UsePerfData`),那么`jps`命令(以及下面介绍的`jstat`)将无法探知该Java进程。
|
||||
|
||||
当获得Java进程的进程ID之后,我们便可以调用接下来介绍的各项监控及诊断工具了。
|
||||
|
||||
## jstat
|
||||
|
||||
`jstat`命令([帮助文档](https://docs.oracle.com/en/java/javase/11/tools/jstat.html))可用来打印目标Java进程的性能数据。它包括多条子命令,如下所示:
|
||||
|
||||
```
|
||||
$ jstat -options
|
||||
-class
|
||||
-compiler
|
||||
-gc
|
||||
-gccapacity
|
||||
-gccause
|
||||
-gcmetacapacity
|
||||
-gcnew
|
||||
-gcnewcapacity
|
||||
-gcold
|
||||
-gcoldcapacity
|
||||
-gcutil
|
||||
-printcompilation
|
||||
|
||||
```
|
||||
|
||||
在这些子命令中,`-class`将打印类加载相关的数据,`-compiler`和`-printcompilation`将打印即时编译相关的数据。剩下的都是以`-gc`为前缀的子命令,它们将打印垃圾回收相关的数据。
|
||||
|
||||
默认情况下,`jstat`只会打印一次性能数据。我们可以将它配置为每隔一段时间打印一次,直至目标Java进程终止,或者达到我们所配置的最大打印次数。具体示例如下所示:
|
||||
|
||||
```
|
||||
# Usage: jstat -outputOptions [-t] [-hlines] VMID [interval [count]]
|
||||
$ jstat -gc 22126 1s 4
|
||||
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT
|
||||
17472,0 17472,0 0,0 0,0 139904,0 47146,4 349568,0 21321,0 30020,0 28001,8 4864,0 4673,4 22 0,080 3 0,270 0 0,000 0,350
|
||||
17472,0 17472,0 420,6 0,0 139904,0 11178,4 349568,0 21321,0 30020,0 28090,1 4864,0 4674,2 28 0,084 3 0,270 0 0,000 0,354
|
||||
17472,0 17472,0 0,0 403,9 139904,0 139538,4 349568,0 21323,4 30020,0 28137,2 4864,0 4674,2 34 0,088 4 0,359 0 0,000 0,446
|
||||
17472,0 17472,0 0,0 0,0 139904,0 0,0 349568,0 21326,1 30020,0 28093,6 4864,0 4673,4 38 0,091 5 0,445 0 0,000 0,536
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
当监控本地环境的Java进程时,VMID可以简单理解为PID。如果需要监控远程环境的Java进程,你可以参考jstat的帮助文档。
|
||||
|
||||
|
||||
在上面这个示例中,22126进程是一个使用了CMS垃圾回收器的Java进程。我们利用`jstat`的`-gc`子命令,来打印该进程垃圾回收相关的数据。命令最后的`1s 4`表示每隔1秒打印一次,共打印4次。
|
||||
|
||||
在`-gc`子命令的输出中,前四列分别为两个Survivor区的容量(Capacity)和已使用量(Utility)。我们可以看到,这两个Survivor区的容量相等,而且始终有一个Survivor区的内存使用量为0。
|
||||
|
||||
当使用默认的G1 GC时,输出结果则有另一些特征:
|
||||
|
||||
```
|
||||
$ jstat -gc 22208 1s
|
||||
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT
|
||||
0,0 16384,0 0,0 16384,0 210944,0 192512,0 133120,0 5332,5 28848,0 26886,4 4864,0 4620,5 19 0,067 1 0,016 2 0,002 0,084
|
||||
0,0 16384,0 0,0 16384,0 210944,0 83968,0 133120,0 5749,9 29104,0 27132,8 4864,0 4621,0 21 0,078 1 0,016 2 0,002 0,095
|
||||
0,0 0,0 0,0 0,0 71680,0 18432,0 45056,0 20285,1 29872,0 27952,4 4864,0 4671,6 23 0,089 2 0,063 2 0,002 0,153
|
||||
0,0 2048,0 0,0 2048,0 69632,0 28672,0 45056,0 18608,1 30128,0 28030,4 4864,0 4672,4 32 0,093 2 0,063 2 0,002 0,158
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
在上面这个示例中,`jstat`每隔1s便会打印垃圾回收的信息,并且不断重复下去。
|
||||
|
||||
你可能已经留意到,`S0C`和`S0U`始终为0,而且另一个Survivor区的容量(S1C)可能会下降至0。
|
||||
|
||||
这是因为,当使用G1 GC时,Java虚拟机不再设置Eden区、Survivor区,老年代区的内存边界,而是将堆划分为若干个等长内存区域。
|
||||
|
||||
每个内存区域都可以作为Eden区、Survivor区以及老年代区中的任一种,并且可以在不同区域类型之间来回切换。([参考链接](https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html))
|
||||
|
||||
换句话说,逻辑上我们只有一个Survivor区。当需要迁移Survivor区中的数据时(即Copying GC),我们只需另外申请一个或多个内存区域,作为新的Survivor区。
|
||||
|
||||
因此,Java虚拟机决定在使用G1 GC时,将所有Survivor内存区域的总容量以及已使用量存放至S1C和S1U中,而S0C和S0U则被设置为0。
|
||||
|
||||
当发生垃圾回收时,Java虚拟机可能出现Survivor内存区域内的对象**全**被回收或晋升的现象。
|
||||
|
||||
在这种情况下,Java虚拟机会将这块内存区域回收,并标记为可分配的状态。这样子做的结果是,堆中可能完全没有Survivor内存区域,因而相应的S1C和S1U将会是0。
|
||||
|
||||
`jstat`还有一个非常有用的参数`-t`,它将在每行数据之前打印目标Java进程的启动时间。例如,在下面这个示例中,第一列代表该Java进程已经启动了10.7秒。
|
||||
|
||||
```
|
||||
$ jstat -gc -t 22407
|
||||
Timestamp S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT
|
||||
10,7 0,0 0,0 0,0 0,0 55296,0 45056,0 34816,0 20267,8 30128,0 27975,3 4864,0 4671,6 33 0,086 3 0,111 2 0,001 0,198
|
||||
|
||||
```
|
||||
|
||||
我们可以比较Java进程的启动时间以及总GC时间(GCT列),或者两次测量的间隔时间以及总GC时间的增量,来得出GC时间占运行时间的比例。
|
||||
|
||||
如果该比例超过20%,则说明目前堆的压力较大;如果该比例超过90%,则说明堆里几乎没有可用空间,随时都可能抛出OOM异常。
|
||||
|
||||
`jstat`还可以用来判断是否出现内存泄漏。在长时间运行的Java程序中,我们可以运行`jstat`命令连续获取多行性能数据,并取这几行数据中OU列(即已占用的老年代内存)的最小值。
|
||||
|
||||
然后,我们每隔一段较长的时间重复一次上述操作,来获得多组OU最小值。如果这些值呈上涨趋势,则说明该Java程序的老年代内存已使用量在不断上涨,这意味着无法回收的对象在不断增加,因此很有可能存在内存泄漏。
|
||||
|
||||
>
|
||||
上面没有涉及的列(或者其他子命令的输出),你可以查阅帮助文档了解具体含义。至于文档中漏掉的CGC和CGCT,它们分别代表并发GC Stop-The-World的次数和时间。
|
||||
|
||||
|
||||
## jmap
|
||||
|
||||
在这种情况下,我们便可以请`jmap`命令([帮助文档](https://docs.oracle.com/en/java/javase/11/tools/jmap.html))出马,分析Java虚拟机堆中的对象。
|
||||
|
||||
`jmap`同样包括多条子命令。
|
||||
|
||||
1. `-clstats`,该子命令将打印被加载类的信息。
|
||||
1. `-finalizerinfo`,该子命令将打印所有待finalize的对象。
|
||||
1. `-histo`,该子命令将统计各个类的实例数目以及占用内存,并按照内存使用量从多至少的顺序排列。此外,`-histo:live`只统计堆中的存活对象。
|
||||
1. `-dump`,该子命令将导出Java虚拟机堆的快照。同样,`-dump:live`只保存堆中的存活对象。
|
||||
|
||||
我们通常会利用`jmap -dump:live,format=b,file=filename.bin`命令,将堆中所有存活对象导出至一个文件之中。
|
||||
|
||||
这里`format=b`将使`jmap`导出与[hprof](https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr008.html)(在Java 9中已被移除)、`-XX:+HeapDumpAfterFullGC`、`-XX:+HeapDumpOnOutOfMemoryError`格式一致的文件。这种格式的文件可以被其他GUI工具查看,具体我会在下一篇中进行演示。
|
||||
|
||||
下面我贴了一段`-histo`子命令的输出:
|
||||
|
||||
```
|
||||
$ jmap -histo 22574
|
||||
num #instances #bytes class name (module)
|
||||
-------------------------------------------------------
|
||||
1: 500004 20000160 org.python.core.PyComplex
|
||||
2: 570866 18267712 org.python.core.PyFloat
|
||||
3: 360295 18027024 [B (java.base@11)
|
||||
4: 339394 11429680 [Lorg.python.core.PyObject;
|
||||
5: 308637 11194264 [Ljava.lang.Object; (java.base@11)
|
||||
6: 301378 9291664 [I (java.base@11)
|
||||
7: 225103 9004120 java.math.BigInteger (java.base@11)
|
||||
8: 507362 8117792 org.python.core.PySequence$1
|
||||
9: 285009 6840216 org.python.core.PyLong
|
||||
10: 282908 6789792 java.lang.String (java.base@11)
|
||||
...
|
||||
2281: 1 16 traceback$py
|
||||
2282: 1 16 unicodedata$py
|
||||
Total 5151277 167944400
|
||||
|
||||
```
|
||||
|
||||
由于`jmap`将访问堆中的所有对象,为了保证在此过程中不被应用线程干扰,`jmap`需要借助安全点机制,让所有线程停留在不改变堆中数据的状态。
|
||||
|
||||
也就是说,由`jmap`导出的堆快照必定是安全点位置的。这可能导致基于该堆快照的分析结果存在偏差。举个例子,假设在编译生成的机器码中,某些对象的生命周期在两个安全点之间,那么`:live`选项将无法探知到这些对象。
|
||||
|
||||
另外,如果某个线程长时间无法跑到安全点,`jmap`将一直等下去。上一小节的`jstat`则不同。这是因为垃圾回收器会主动将`jstat`所需要的摘要数据保存至固定位置之中,而`jstat`只需直接读取即可。
|
||||
|
||||
关于这种长时间等待的情况,你可以通过下面这段程序来复现:
|
||||
|
||||
```
|
||||
// 暂停时间较长,约为二三十秒,可酌情调整。
|
||||
// CTRL+C的SIGINT信号无法停止,需要SIGKILL。
|
||||
static double sum = 0;
|
||||
|
||||
public static void main(String[] args) {
|
||||
for (int i = 0; i < 0x77777777; i++) { // counted loop
|
||||
sum += Math.log(i); // Math.log is an intrinsic
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`jmap`(以及接下来的`jinfo`、`jstack`和`jcmd`)依赖于Java虚拟机的[Attach API](https://docs.oracle.com/en/java/javase/11/docs/api/jdk.attach/com/sun/tools/attach/package-summary.html),因此只能监控本地Java进程。
|
||||
|
||||
一旦开启Java虚拟机参数`DisableAttachMechanism`(即使用参数`-XX:+DisableAttachMechanism`),基于Attach API的命令将无法执行。反过来说,如果你不想被其他进程监控,那么你需要开启该参数。
|
||||
|
||||
## jinfo
|
||||
|
||||
`jinfo`命令([帮助文档](https://docs.oracle.com/en/java/javase/11/tools/jinfo.html))可用来查看目标Java进程的参数,如传递给Java虚拟机的`-X`(即输出中的jvm_args)、`-XX`参数(即输出中的VM Flags),以及可在Java层面通过`System.getProperty`获取的`-D`参数(即输出中的System Properties)。
|
||||
|
||||
具体的示例如下所示:
|
||||
|
||||
```
|
||||
$ jinfo 31185
|
||||
Java System Properties:
|
||||
|
||||
gopherProxySet=false
|
||||
awt.toolkit=sun.lwawt.macosx.LWCToolkit
|
||||
java.specification.version=11
|
||||
sun.cpu.isalist=
|
||||
sun.jnu.encoding=UTF-8
|
||||
...
|
||||
|
||||
VM Flags:
|
||||
-XX:CICompilerCount=4 -XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=10 -XX:G1HeapRegionSize=2097152 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=536870912 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=8589934592 -XX:MaxNewSize=5152702464 -XX:MinHeapDeltaBytes=2097152 -XX:NonNMethodCodeHeapSize=5835340 -XX:NonProfiledCodeHeapSize=122911450 -XX:ProfiledCodeHeapSize=122911450 -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
|
||||
|
||||
VM Arguments:
|
||||
jvm_args: -Xlog:gc -Xmx1024m
|
||||
java_command: org.example.Foo
|
||||
java_class_path (initial): .
|
||||
Launcher Type: SUN_STANDARD
|
||||
|
||||
```
|
||||
|
||||
`jinfo`还可以用来修改目标Java进程的“manageable”虚拟机参数。
|
||||
|
||||
举个例子,我们可以使用`jinfo -flag +HeapDumpAfterFullGC <PID>`命令,开启`<PID>`所指定的Java进程的`HeapDumpAfterFullGC`参数。
|
||||
|
||||
你可以通过下述命令查看其他"manageable"虚拟机参数:
|
||||
|
||||
```
|
||||
$ java -XX:+PrintFlagsFinal -version | grep manageable
|
||||
intx CMSAbortablePrecleanWaitMillis = 100 {manageable} {default}
|
||||
intx CMSTriggerInterval = -1 {manageable} {default}
|
||||
intx CMSWaitDuration = 2000 {manageable} {default}
|
||||
bool HeapDumpAfterFullGC = false {manageable} {default}
|
||||
bool HeapDumpBeforeFullGC = false {manageable} {default}
|
||||
bool HeapDumpOnOutOfMemoryError = false {manageable} {default}
|
||||
ccstr HeapDumpPath = {manageable} {default}
|
||||
uintx MaxHeapFreeRatio = 70 {manageable} {default}
|
||||
uintx MinHeapFreeRatio = 40 {manageable} {default}
|
||||
bool PrintClassHistogram = false {manageable} {default}
|
||||
bool PrintConcurrentLocks = false {manageable} {default}
|
||||
java version "11" 2018-09-25
|
||||
Java(TM) SE Runtime Environment 18.9 (build 11+28)
|
||||
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)
|
||||
|
||||
```
|
||||
|
||||
## jstack
|
||||
|
||||
`jstack`命令([帮助文档](https://docs.oracle.com/en/java/javase/11/tools/jstack.html))可以用来打印目标Java进程中各个线程的栈轨迹,以及这些线程所持有的锁。
|
||||
|
||||
`jstack`的其中一个应用场景便是死锁检测。这里我用`jstack`获取一个已经死锁了的Java程序的栈信息。具体输出如下所示:
|
||||
|
||||
```
|
||||
$ jstack 31634
|
||||
...
|
||||
|
||||
"Thread-0" #12 prio=5 os_prio=31 cpu=1.32ms elapsed=34.24s tid=0x00007fb08601c800 nid=0x5d03 waiting for monitor entry [0x000070000bc7e000]
|
||||
java.lang.Thread.State: BLOCKED (on object monitor)
|
||||
at DeadLock.foo(DeadLock.java:18)
|
||||
- waiting to lock <0x000000061ff904c0> (a java.lang.Object)
|
||||
- locked <0x000000061ff904b0> (a java.lang.Object)
|
||||
at DeadLock$$Lambda$1/0x0000000800060840.run(Unknown Source)
|
||||
at java.lang.Thread.run(java.base@11/Thread.java:834)
|
||||
|
||||
"Thread-1" #13 prio=5 os_prio=31 cpu=1.43ms elapsed=34.24s tid=0x00007fb08601f800 nid=0x5f03 waiting for monitor entry [0x000070000bd81000]
|
||||
java.lang.Thread.State: BLOCKED (on object monitor)
|
||||
at DeadLock.bar(DeadLock.java:33)
|
||||
- waiting to lock <0x000000061ff904b0> (a java.lang.Object)
|
||||
- locked <0x000000061ff904c0> (a java.lang.Object)
|
||||
at DeadLock$$Lambda$2/0x0000000800063040.run(Unknown Source)
|
||||
at java.lang.Thread.run(java.base@11/Thread.java:834)
|
||||
|
||||
...
|
||||
|
||||
JNI global refs: 6, weak refs: 0
|
||||
|
||||
|
||||
Found one Java-level deadlock:
|
||||
=============================
|
||||
"Thread-0":
|
||||
waiting to lock monitor 0x00007fb083015900 (object 0x000000061ff904c0, a java.lang.Object),
|
||||
which is held by "Thread-1"
|
||||
"Thread-1":
|
||||
waiting to lock monitor 0x00007fb083015800 (object 0x000000061ff904b0, a java.lang.Object),
|
||||
which is held by "Thread-0"
|
||||
|
||||
Java stack information for the threads listed above:
|
||||
===================================================
|
||||
"Thread-0":
|
||||
at DeadLock.foo(DeadLock.java:18)
|
||||
- waiting to lock <0x000000061ff904c0> (a java.lang.Object)
|
||||
- locked <0x000000061ff904b0> (a java.lang.Object)
|
||||
at DeadLock$$Lambda$1/0x0000000800060840.run(Unknown Source)
|
||||
at java.lang.Thread.run(java.base@11/Thread.java:834)
|
||||
"Thread-1":
|
||||
at DeadLock.bar(DeadLock.java:33)
|
||||
- waiting to lock <0x000000061ff904b0> (a java.lang.Object)
|
||||
- locked <0x000000061ff904c0> (a java.lang.Object)
|
||||
at DeadLock$$Lambda$2/0x0000000800063040.run(Unknown Source)
|
||||
at java.lang.Thread.run(java.base@11/Thread.java:834)
|
||||
|
||||
Found 1 deadlock.
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,`jstack`不仅会打印线程的栈轨迹、线程状态(BLOCKED)、持有的锁(locked …)以及正在请求的锁(waiting to lock …),而且还会分析出具体的死锁。
|
||||
|
||||
## jcmd
|
||||
|
||||
你还可以直接使用`jcmd`命令([帮助文档](https://docs.oracle.com/en/java/javase/11/tools/jcmd.html)),来替代前面除了`jstat`之外的所有命令。具体的替换规则你可以参考下表。
|
||||
|
||||
至于`jstat`的功能,虽然`jcmd`复制了`jstat`的部分代码,并支持通过`PerfCounter.print`子命令来打印所有的Performance Counter,但是它没有保留`jstat`的输出格式,也没有重复打印的功能。因此,感兴趣的同学可以自行整理。
|
||||
|
||||
另外,我们将在下一篇中介绍`jcmd`中Java Flight Recorder相关的子命令。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了JDK中用于监控及诊断的命令行工具。我们再来回顾一下。
|
||||
|
||||
1. `jps`将打印所有正在运行的Java进程。
|
||||
1. `jstat`允许用户查看目标Java进程的类加载、即时编译以及垃圾回收相关的信息。它常用于检测垃圾回收问题以及内存泄漏问题。
|
||||
1. `jmap`允许用户统计目标Java进程的堆中存放的Java对象,并将它们导出成二进制文件。
|
||||
1. `jinfo`将打印目标Java进程的配置参数,并能够改动其中manageabe的参数。
|
||||
1. `jstack`将打印目标Java进程中各个线程的栈轨迹、线程状态、锁状况等信息。它还将自动检测死锁。
|
||||
1. `jcmd`则是一把瑞士军刀,可以用来实现前面除了`jstat`之外所有命令的功能。
|
||||
|
||||
今天的实践环节,你可以探索`jcmd`中的下述功能,看看有没有适合你项目的监控项:
|
||||
|
||||
```
|
||||
Compiler.CodeHeap_Analytics
|
||||
Compiler.codecache
|
||||
Compiler.codelist
|
||||
Compiler.directives_add
|
||||
Compiler.directives_clear
|
||||
Compiler.directives_print
|
||||
Compiler.directives_remove
|
||||
Compiler.queue
|
||||
GC.class_histogram
|
||||
GC.class_stats
|
||||
GC.finalizer_info
|
||||
GC.heap_dump
|
||||
GC.heap_info
|
||||
GC.run
|
||||
GC.run_finalization
|
||||
VM.class_hierarchy
|
||||
VM.classloader_stats
|
||||
VM.classloaders
|
||||
VM.command_line
|
||||
VM.dynlibs
|
||||
VM.flags
|
||||
VM.info
|
||||
VM.log
|
||||
VM.metaspace
|
||||
VM.native_memory
|
||||
VM.print_touched_methods
|
||||
VM.set_flag
|
||||
VM.stringtable
|
||||
VM.symboltable
|
||||
VM.system_properties
|
||||
VM.systemdictionary
|
||||
VM.unlock_commercial_features
|
||||
VM.uptime
|
||||
VM.version
|
||||
|
||||
```
|
||||
|
||||
|
||||
204
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/31 | Java虚拟机的监控及诊断工具(GUI篇).md
Normal file
204
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/31 | Java虚拟机的监控及诊断工具(GUI篇).md
Normal file
@@ -0,0 +1,204 @@
|
||||
<audio id="audio" title="31 | Java虚拟机的监控及诊断工具(GUI篇)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/58/811ab21a45317b53c02d2a87a9371558.mp3"></audio>
|
||||
|
||||
今天我们来继续了解Java虚拟机的监控及诊断工具。
|
||||
|
||||
## eclipse MAT
|
||||
|
||||
在上一篇中,我介绍了`jmap`工具,它支持导出Java虚拟机堆的二进制快照。eclipse的[MAT工具](https://www.eclipse.org/mat/)便是其中一个能够解析这类二进制快照的工具。
|
||||
|
||||
MAT本身也能够获取堆的二进制快照。该功能将借助`jps`列出当前正在运行的Java进程,以供选择并获取快照。由于`jps`会将自己列入其中,因此你会在列表中发现一个已经结束运行的`jps`进程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/7e/c9072149fb112312cbc217acc2660c7e.png" alt="" />
|
||||
|
||||
MAT获取二进制快照的方式有三种,一是使用Attach API,二是新建一个Java虚拟机来运行Attach API,三是使用`jmap`工具。
|
||||
|
||||
这三种本质上都是在使用Attach API。不过,在目标进程启用了`DisableAttachMechanism`参数时,前两者将不在选取列表中显示,后者将在运行时报错。
|
||||
|
||||
当加载完堆快照之后,MAT的主界面将展示一张饼状图,其中列举占据的Retained heap最多的几个对象。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/bf/da2e5894d0be535b6daa5084beb33ebf.png" alt="" />
|
||||
|
||||
这里讲一下MAT计算对象占据内存的[两种方式](https://help.eclipse.org/mars/topic/org.eclipse.mat.ui.help/concepts/shallowretainedheap.html?cp=46_2_1)。第一种是Shallow heap,指的是对象自身所占据的内存。第二种是Retained heap,指的是当对象不再被引用时,垃圾回收器所能回收的总内存,包括对象自身所占据的内存,以及仅能够通过该对象引用到的其他对象所占据的内存。上面的饼状图便是基于Retained heap的。
|
||||
|
||||
MAT包括了两个比较重要的视图,分别是直方图(histogram)和支配树(dominator tree)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/05/bbb59ca4d86c227dac23f30c360c9405.png" alt="" />
|
||||
|
||||
MAT的直方图和`jmap`的`-histo`子命令一样,都能够展示各个类的实例数目以及这些实例的Shallow heap总和。但是,MAT的直方图还能够计算Retained heap,并支持基于实例数目或Retained heap的排序方式(默认为Shallow heap)。此外,MAT还可以将直方图中的类按照超类、类加载器或者包名分组。
|
||||
|
||||
当选中某个类时,MAT界面左上角的Inspector窗口将展示该类的Class实例的相关信息,如类加载器等。(下图中的`ClassLoader @ 0x0`指的便是启动类加载器。)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/ab/dde7022060fad3945944fb7e4c9926ab.png" alt="" />
|
||||
|
||||
支配树的概念源自图论。在一则流图(flow diagram)中,如果从入口节点到b节点的所有路径都要经过a节点,那么a支配(dominate)b。
|
||||
|
||||
在a支配b,且a不同于b的情况下(即a严格支配b),如果从a节点到b节点的所有路径中不存在支配b的其他节点,那么a直接支配(immediate dominate)b。这里的支配树指的便是由节点的直接支配节点所组成的树状结构。
|
||||
|
||||
我们可以将堆中所有的对象看成一张对象图,每个对象是一个图节点,而GC Roots则是对象图的入口,对象之间的引用关系则构成了对象图中的有向边。这样一来,我们便能够构造出该对象图所对应的支配树。
|
||||
|
||||
MAT将按照每个对象Retained heap的大小排列该支配树。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/a6/0d4ea7f00d500db8a978ff0183a840a6.png" alt="" />
|
||||
|
||||
根据Retained heap的定义,只要能够回收上图右侧的表中的第一个对象,那么垃圾回收器便能够释放出13.6MB内存。
|
||||
|
||||
需要注意的是,对象的引用型字段未必对应支配树中的父子节点关系。假设对象a拥有两个引用型字段,分别指向b和c。而b和c各自拥有一个引用型字段,但都指向d。如果没有其他引用指向b、c或d,那么a直接支配b、c和d,而b(或c)和d之间不存在支配关系。
|
||||
|
||||
当在支配树视图中选中某一对象时,我们还可以通过Path To GC Roots功能,反向列出该对象到GC Roots的引用路径。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/e7/e04d55d955832bf681aba16dcffc2ee7.png" alt="" />
|
||||
|
||||
MAT还将自动匹配内存泄漏中的常见模式,并汇报潜在的内存泄漏问题。具体可参考该[帮助文档](https://help.eclipse.org/mars/topic/org.eclipse.mat.ui.help/tasks/runningleaksuspectreport.html?cp=46_3_1)以及[这篇博客](http://memoryanalyzer.blogspot.com/2008/05/automated-heap-dump-analysis-finding.html)。
|
||||
|
||||
## Java Mission Control
|
||||
|
||||
>
|
||||
注意:自Java 11开始,本节介绍的JFR已经开源。但在之前的Java版本,JFR属于Commercial Feature,需要通过Java虚拟机参数`-XX:+UnlockCommercialFeatures`开启。
|
||||
我个人不清楚也不能回答关于Java 11之前的版本是否仍需要商务许可(Commercial License)的问题。请另行咨询后再使用,或者直接使用Java 11。
|
||||
[Java Mission Control](http://jdk.java.net/jmc/)(JMC)是Java虚拟机平台上的性能监控工具。它包含一个GUI客户端,以及众多用来收集Java虚拟机性能数据的插件,如JMX Console(能够访问用来存放虚拟机各个子系统运行数据的[MXBeans](https://en.wikipedia.org/wiki/Java_Management_Extensions#Managed_beans)),以及虚拟机内置的高效profiling工具Java Flight Recorder(JFR)。
|
||||
|
||||
|
||||
JFR的性能开销很小,在默认配置下平均低于1%。与其他工具相比,JFR能够直接访问虚拟机内的数据,并且不会影响虚拟机的优化。因此,它非常适用于生产环境下满负荷运行的Java程序。
|
||||
|
||||
当启用时,JFR将记录运行过程中发生的一系列事件。其中包括Java层面的事件,如线程事件、锁事件,以及Java虚拟机内部的事件,如新建对象、垃圾回收和即时编译事件。
|
||||
|
||||
按照发生时机以及持续时间来划分,JFR的事件共有四种类型,它们分别为以下四种。
|
||||
|
||||
1. 瞬时事件(Instant Event),用户关心的是它们发生与否,例如异常、线程启动事件。
|
||||
1. 持续事件(Duration Event),用户关心的是它们的持续时间,例如垃圾回收事件。
|
||||
1. 计时事件(Timed Event),是时长超出指定阈值的持续事件。
|
||||
<li>取样事件(Sample Event),是周期性取样的事件。<br />
|
||||
取样事件的其中一个常见例子便是方法抽样(Method Sampling),即每隔一段时间统计各个线程的栈轨迹。如果在这些抽样取得的栈轨迹中存在一个反复出现的方法,那么我们可以推测该方法是热点方法。</li>
|
||||
|
||||
JFR的取样事件要比其他工具更加精确。以方法抽样为例,其他工具通常基于JVMTI([Java Virtual Machine Tool Interface](https://docs.oracle.com/en/java/javase/11/docs/specs/jvmti.html))的`GetAllStackTraces` API。该API依赖于安全点机制,其获得的栈轨迹总是在安全点上,由此得出的结论未必精确。JFR则不然,它不依赖于安全点机制,因此其结果相对来说更加精确。
|
||||
|
||||
JFR的启用方式主要有三种。
|
||||
|
||||
第一种是在运行目标Java程序时添加`-XX:StartFlightRecording=`参数。关于该参数的配置详情,你可以参考[该帮助文档](https://docs.oracle.com/en/java/javase/11/tools/java.html)(请在页面中搜索`StartFlightRecording`)。
|
||||
|
||||
下面我列举三种常见的配置方式。
|
||||
|
||||
- 在下面这条命令中,JFR将会在Java虚拟机启动5s后(对应`delay=5s`)收集数据,持续20s(对应`duration=20s`)。当收集完毕后,JFR会将收集得到的数据保存至指定的文件中(对应`filename=myrecording.jfr`)。
|
||||
|
||||
```
|
||||
# Time fixed
|
||||
$ java -XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,settings=profile MyApp
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
`settings=profile`指定了JFR所收集的事件类型。默认情况下,JFR将加载配置文件`$JDK/lib/jfr/default.jfc`,并识别其中所包含的事件类型。当使用了`settings=profile`配置时,JFR将加载配置文件`$JDK/lib/jfr/profile.jfc`。该配置文件所包含的事件类型要多于默认的`default.jfc`,因此性能开销也要大一些(约为2%)。
|
||||
`default.jfc`以及`profile.jfc`均为XML文件。后面我会介绍如何利用JMC来进行修改。
|
||||
|
||||
|
||||
- 在下面这条命令中,JFR将在Java虚拟机启动之后持续收集数据,直至进程退出。在进程退出时(对应`dumponexit=true`),JFR会将收集得到的数据保存至指定的文件中。
|
||||
|
||||
```
|
||||
# Continuous, dump on exit
|
||||
$ java -XX:StartFlightRecording=dumponexit=true,filename=myrecording.jfr MyApp
|
||||
|
||||
```
|
||||
|
||||
- 在下面这条命令中,JFR将在Java虚拟机启动之后持续收集数据,直至进程退出。该命令不会主动保存JFR收集得到的数据。
|
||||
|
||||
```
|
||||
# Continuous, dump on demand
|
||||
$ java -XX:StartFlightRecording=maxage=10m,maxsize=100m,name=SomeLabel MyApp
|
||||
Started recording 1.
|
||||
|
||||
Use jcmd 38502 JFR.dump name=SomeLabel filename=FILEPATH to copy recording data to file.
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
由于JFR将持续收集数据,如果不加以限制,那么JFR可能会填满硬盘的所有空间。因此,我们有必要对这种模式下所收集的数据进行限制。
|
||||
|
||||
在这条命令中,`maxage=10m`指的是仅保留10分钟以内的事件,`maxsize=100m`指的是仅保留100MB以内的事件。一旦所收集的事件达到其中任意一个限制,JFR便会开始清除不合规格的事件。
|
||||
|
||||
然而,为了保持较小的性能开销,JFR并不会频繁地校验这两个限制。因此,在实践过程中你往往会发现指定文件的大小超出限制,或者文件中所存储事件的时间超出限制。具体解释请参考[这篇帖子](https://community.oracle.com/thread/3514679)。
|
||||
|
||||
前面提到,该命令不会主动保存JFR收集得到的数据。用户需要运行`jcmd <PID> JFR.dump`命令方能保存。
|
||||
|
||||
这便是JFR的第二种启用方式,即通过`jcmd`来让JFR开始收集数据、停止收集数据,或者保存所收集的数据,对应的子命令分别为`JFR.start`,`JFR.stop`,以及`JFR.dump`。
|
||||
|
||||
`JFR.start`子命令所接收的配置及格式和`-XX:StartFlightRecording=`参数的类似。这些配置包括`delay`、`duration`、`settings`、`maxage`、`maxsize`以及`name`。前几个参数我们都已经介绍过了,最后一个参数`name`就是一个标签,当同一进程中存在多个JFR数据收集操作时,我们可以通过该标签来辨别。
|
||||
|
||||
在启动目标进程时,我们不再添加`-XX:StartFlightRecording=`参数。在目标进程运行过程中,我们可以运行`JFR.start`子命令远程启用目标进程的JFR功能。具体用法如下所示:
|
||||
|
||||
```
|
||||
$ jcmd <PID> JFR.start settings=profile maxage=10m maxsize=150m name=SomeLabel
|
||||
|
||||
```
|
||||
|
||||
上述命令运行过后,目标进程中的JFR已经开始收集数据。此时,我们可以通过下述命令来导出已经收集到的数据:
|
||||
|
||||
```
|
||||
$ jcmd <PID> JFR.dump name=SomeLabel filename=myrecording.jfr
|
||||
|
||||
```
|
||||
|
||||
最后,我们可以通过下述命令关闭目标进程中的JFR:
|
||||
|
||||
```
|
||||
$ jcmd <PID> JFR.stop name=SomeLabel
|
||||
|
||||
```
|
||||
|
||||
关于`JFR.start`、`JFR.dump`和`JFR.stop`的其他用法,你可以参考[该帮助文档](https://docs.oracle.com/javacomponents/jmc-5-5/jfr-runtime-guide/comline.htm#JFRRT185)。
|
||||
|
||||
第三种启用JFR的方式则是JMC中的JFR插件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/16/395900f606fd93570196a6dcbac75e16.png" alt="" />
|
||||
|
||||
在JMC GUI客户端左侧的JVM浏览器中,我们可以看到所有正在运行的Java程序。当点击右键弹出菜单中的`Start Flight Recording...`时,JMC便会弹出另一个窗口,用来配置JFR的启动参数,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/6a/31f86bc1cafc569f51e0364d716cab6a.png" alt="" />
|
||||
|
||||
这里的配置参数与前两种启动JFR的方式并无二致,同样也包括标签名、收集数据的持续时间、缓存事件的时间及空间限制,以及配置所要监控事件的`Event settings`。<br />
|
||||
(这里对应前两种启动方式的`settings=default|profile`)
|
||||
|
||||
>
|
||||
JMC提供了两个选择:Continuous和Profiling,分别对应`$JDK/lib/jfr/`里的`default.jfc`和`profile.jfc`。
|
||||
|
||||
|
||||
我们可以通过JMC的`Flight Recording Template Manager`导入这些jfc文件,并在GUI界面上进行更改。更改完毕后,我们可以导出为新的jfc文件,以便在服务器端使用。
|
||||
|
||||
当收集完成时,JMC会自动打开所生成的jfr文件,并在主界面中列举目标进程在收集数据的这段时间内的潜在问题。例如,`Parallel Threads`一节,便汇报了没有完整利用CPU资源的问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/7c/5a4302c29947518250e2b697aecc8d7c.png" alt="" />
|
||||
|
||||
客户端的左边则罗列了Java虚拟机的各个子系统。JMC将根据JFR所收集到的每个子系统的事件来进行可视化,转换成图或者表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/db/ff/dbc36a8713af058c79df2878379276ff.png" alt="" />
|
||||
|
||||
这里我简单地介绍其中两个。
|
||||
|
||||
垃圾回收子系统所对应的选项卡展示了JFR所收集到的GC事件,以及基于这些GC事件的数据生成的堆已用空间的分布图,Metaspace大小的分布图,最长暂停以及总暂停的直方分布图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/0c/56f9fb2932ffb63ffa29e95dc779100c.png" alt="" />
|
||||
|
||||
即时编译子系统所对应的选项卡则展示了方法编译时间的直方图,以及按编译时间排序的编译任务表。
|
||||
|
||||
后者可能出现同方法名同方法描述符的编译任务。其原因主要有两个,一是不同编译层次的即时编译,如3层的C1编译以及4层的C2编译。二是去优化后的重新编译。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/c8/6e7e41a6f8945a2b65d67c18ea5293c8.png" alt="" />
|
||||
|
||||
JMC的图表总体而言都不难理解。你可以逐个探索,我在这里便不详细展开了。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了两个GUI工具:eclipse MAT以及JMC。
|
||||
|
||||
eclipse MAT可用于分析由`jmap`命令导出的Java堆快照。它包括两个相对比较重要的视图,分别为直方图和支配树。直方图展示了各个类的实例数目以及这些实例的Shallow heap或Retained heap的总和。支配树则展示了快照中每个对象所直接支配的对象。
|
||||
|
||||
Java Mission Control是Java虚拟机平台上的性能监控工具。Java Flight Recorder是JMC的其中一个组件,能够以极低的性能开销收集Java虚拟机的性能数据。
|
||||
|
||||
JFR的启用方式有三种,分别为在命令行中使用`-XX:StartFlightRecording=`参数,使用`jcmd`的`JFR.*`子命令,以及JMC的JFR插件。JMC能够加载JFR的输出结果,并且生成各种信息丰富的图表。
|
||||
|
||||
今天的实践环节,请你试用JMC中的MBean Server功能,并通过JMC的帮助文档(`Help->Java Mission Control Help`),以及[该教程](https://docs.oracle.com/javase/tutorial/jmx/mbeans/index.html)来了解该功能的具体含义。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/7f/2a68f0f2b5de35f29b045fe82923ac7f.png" alt="" />
|
||||
|
||||
由于篇幅的限制,我就不再介绍[VisualVM](https://visualvm.github.io/index.html) 以及[JITWatch](https://github.com/AdoptOpenJDK/jitwatch) 了。感兴趣的同学可自行下载研究。
|
||||
|
||||
|
||||
347
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/32 | JNI的运行机制.md
Normal file
347
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/32 | JNI的运行机制.md
Normal file
@@ -0,0 +1,347 @@
|
||||
<audio id="audio" title="32 | JNI的运行机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/83/22/835e43c39382b3f56e7466537b3d5622.mp3"></audio>
|
||||
|
||||
我们经常会遇见Java语言较难表达,甚至是无法表达的应用场景。比如我们希望使用汇编语言(如X86_64的SIMD指令)来提升关键代码的性能;再比如,我们希望调用Java核心类库无法提供的,某个体系架构或者操作系统特有的功能。
|
||||
|
||||
在这种情况下,我们往往会牺牲可移植性,在Java代码中调用C/C++代码(下面简述为C代码),并在其中实现所需功能。这种跨语言的调用,便需要借助Java虚拟机的Java Native Interface(JNI)机制。
|
||||
|
||||
关于JNI的例子,你应该特别熟悉Java中标记为`native`的、没有方法体的方法(下面统称为native方法)。当在Java代码中调用这些native方法时,Java虚拟机将通过JNI,调用至对应的C函数(下面将native方法对应的C实现统称为C函数)中。
|
||||
|
||||
```
|
||||
public class Object {
|
||||
public native int hashCode();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,`Object.hashCode`方法便是一个native方法。它对应的C函数将计算对象的哈希值,并缓存在对象头、栈上锁记录(轻型锁)或对象监视锁(重型锁所使用的monitor)中,以确保该值在对象的生命周期之内不会变更。
|
||||
|
||||
## native方法的链接
|
||||
|
||||
在调用native方法前,Java虚拟机需要将该native方法链接至对应的C函数上。
|
||||
|
||||
链接方式主要有两种。第一种是让Java虚拟机自动查找符合默认命名规范的C函数,并且链接起来。
|
||||
|
||||
事实上,我们并不需要记住所谓的命名规范,而是采用`javac -h`命令,便可以根据Java程序中的native方法声明,自动生成包含符合命名规范的C函数的头文件。
|
||||
|
||||
举个例子,在下面这段代码中,`Foo`类有三个native方法,分别为静态方法`foo`以及两个重载的实例方法`bar`。
|
||||
|
||||
```
|
||||
package org.example;
|
||||
|
||||
public class Foo {
|
||||
public static native void foo();
|
||||
public native void bar(int i, long j);
|
||||
public native void bar(String s, Object o);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过执行`javac -h . org/example/Foo.java`命令,我们将在当前文件夹(对应`-h`后面跟着的`.`)生成名为`org_example_Foo.h`的头文件。其内容如下所示:
|
||||
|
||||
```
|
||||
/* DO NOT EDIT THIS FILE - it is machine generated */
|
||||
#include <jni.h>
|
||||
/* Header for class org_example_Foo */
|
||||
|
||||
#ifndef _Included_org_example_Foo
|
||||
#define _Included_org_example_Foo
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
/*
|
||||
* Class: org_example_Foo
|
||||
* Method: foo
|
||||
* Signature: ()V
|
||||
*/
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_foo
|
||||
(JNIEnv *, jclass);
|
||||
|
||||
/*
|
||||
* Class: org_example_Foo
|
||||
* Method: bar
|
||||
* Signature: (IJ)V
|
||||
*/
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
|
||||
(JNIEnv *, jobject, jint, jlong);
|
||||
|
||||
/*
|
||||
* Class: org_example_Foo
|
||||
* Method: bar
|
||||
* Signature: (Ljava/lang/String;Ljava/lang/Object;)V
|
||||
*/
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
|
||||
(JNIEnv *, jobject, jstring, jobject);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
```
|
||||
|
||||
这里我简单讲解一下该命名规范。
|
||||
|
||||
首先,native方法对应的C函数都需要以`Java_`为前缀,之后跟着完整的包名和方法名。由于C函数名不支持`/`字符,因此我们需要将`/`转换为`_`,而原本方法名中的`_`符号,则需要转换为`_1`。
|
||||
|
||||
举个例子,`org.example`包下`Foo`类的`foo`方法,Java虚拟机会将其自动链接至名为`Java_org_example_Foo_foo`的C函数中。
|
||||
|
||||
当某个类出现重载的native方法时,Java虚拟机还会将参数类型纳入自动链接对象的考虑范围之中。具体的做法便是在前面C函数名的基础上,追加`__`以及方法描述符作为后缀。
|
||||
|
||||
方法描述符的特殊符号同样会被替换掉,如引用类型所使用的`;`会被替换为`_2`,数组类型所使用的`[`会被替换为`_3`。
|
||||
|
||||
基于此命名规范,你可以手动拼凑上述代码中,`Foo`类的两个`bar`方法所能自动链接的C函数名,并用`javac -h`命令所生成的结果来验证一下。
|
||||
|
||||
第二种链接方式则是在C代码中主动链接。
|
||||
|
||||
这种链接方式对C函数名没有要求。通常我们会使用一个名为`registerNatives`的native方法,并按照第一种链接方式定义所能自动链接的C函数。在该C函数中,我们将手动链接该类的其他native方法。
|
||||
|
||||
举个例子,`Object`类便拥有一个`registerNatives`方法,所对应的C代码如下所示:
|
||||
|
||||
```
|
||||
// 注:Object类的registerNatives方法的实现位于java.base模块里的C代码中
|
||||
static JNINativeMethod methods[] = {
|
||||
{"hashCode", "()I", (void *)&JVM_IHashCode},
|
||||
{"wait", "(J)V", (void *)&JVM_MonitorWait},
|
||||
{"notify", "()V", (void *)&JVM_MonitorNotify},
|
||||
{"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
|
||||
{"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
|
||||
};
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_java_lang_Object_registerNatives(JNIEnv *env, jclass cls)
|
||||
{
|
||||
(*env)->RegisterNatives(env, cls,
|
||||
methods, sizeof(methods)/sizeof(methods[0]));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,上面这段代码中的C函数将调用`RegisterNatives` API,注册`Object`类中其他native方法所要链接的C函数。并且,这些C函数的名字并不符合默认命名规则。
|
||||
|
||||
当使用第二种方式进行链接时,我们需要在其他native方法被调用之前完成链接工作。因此,我们往往会在类的初始化方法里调用该`registerNatives`方法。具体示例如下所示:
|
||||
|
||||
```
|
||||
public class Object {
|
||||
private static native void registerNatives();
|
||||
static {
|
||||
registerNatives();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面我们采用第一种链接方式,并且实现其中的`bar(String, Object)`方法。如下所示:
|
||||
|
||||
```
|
||||
// foo.c
|
||||
#include <stdio.h>
|
||||
#include "org_example_Foo.h"
|
||||
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
|
||||
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
|
||||
printf("Hello, World\n");
|
||||
return;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们可以通过gcc命令将其编译成为动态链接库:
|
||||
|
||||
```
|
||||
# 该命令仅适用于macOS
|
||||
$ gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -o libfoo.dylib -shared foo.c
|
||||
|
||||
```
|
||||
|
||||
这里需要注意的是,动态链接库的名字须以`lib`为前缀,以`.dylib`(或Linux上的`.so`)为扩展名。在Java程序中,我们可以通过`System.loadLibrary("foo")`方法来加载`libfoo.dylib`,如下述代码所示:
|
||||
|
||||
```
|
||||
package org.example;
|
||||
|
||||
public class Foo {
|
||||
public static native void foo();
|
||||
public native void bar(int i, long j);
|
||||
public native void bar(String s, Object o);
|
||||
|
||||
int i = 0xDEADBEEF;
|
||||
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
System.loadLibrary("foo");
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
new Foo().bar("", "");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果`libfoo.dylib`不在当前路径下,我们可以在启动Java虚拟机时配置`java.library.path`参数,使其指向包含`libfoo.dylib`的文件夹。具体命令如下所示:
|
||||
|
||||
```
|
||||
$ java -Djava.library.path=/PATH/TO/DIR/CONTAINING/libfoo.dylib org.example.Foo
|
||||
Hello, World
|
||||
|
||||
```
|
||||
|
||||
## JNI的API
|
||||
|
||||
在C代码中,我们也可以使用Java的语言特性,如instanceof测试等。这些功能都是通过特殊的JNI函数([JNI Functions](https://docs.oracle.com/en/java/javase/11/docs/specs/jni/functions.html))来实现的。
|
||||
|
||||
Java虚拟机会将所有JNI函数的函数指针聚合到一个名为`JNIEnv`的数据结构之中。
|
||||
|
||||
这是一个线程私有的数据结构。Java虚拟机会为每个线程创建一个`JNIEnv`,并规定C代码不能将当前线程的`JNIEnv`共享给其他线程,否则JNI函数的正确性将无法保证。
|
||||
|
||||
这么设计的原因主要有两个。一是给JNI函数提供一个单独命名空间。二是允许Java虚拟机通过更改函数指针替换JNI函数的具体实现,例如从附带参数类型检测的慢速版本,切换至不做参数类型检测的快速版本。
|
||||
|
||||
在HotSpot虚拟机中,`JNIEnv`被内嵌至Java线程的数据结构之中。部分虚拟机代码甚至会从`JNIEnv`的地址倒推出Java线程的地址。因此,如果在其他线程中使用当前线程的`JNIEnv`,会使这部分代码错误识别当前线程。
|
||||
|
||||
JNI会将Java层面的基本类型以及引用类型映射为另一套可供C代码使用的数据结构。其中,基本类型的对应关系如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/ca/cb2c806532449f2c1edfe821990ac9ca.png" alt="">
|
||||
|
||||
引用类型对应的数据结构之间也存在着继承关系,具体如下所示:
|
||||
|
||||
```
|
||||
jobject
|
||||
|- jclass (java.lang.Class objects)
|
||||
|- jstring (java.lang.String objects)
|
||||
|- jthrowable (java.lang.Throwable objects)
|
||||
|- jarray (arrays)
|
||||
|- jobjectArray (object arrays)
|
||||
|- jbooleanArray (boolean arrays)
|
||||
|- jbyteArray (byte arrays)
|
||||
|- jcharArray (char arrays)
|
||||
|- jshortArray (short arrays)
|
||||
|- jintArray (int arrays)
|
||||
|- jlongArray (long arrays)
|
||||
|- jfloatArray (float arrays)
|
||||
|- jdoubleArray (double arrays)
|
||||
|
||||
```
|
||||
|
||||
我们回头看看`Foo`类3个native方法对应的C函数的参数。
|
||||
|
||||
```
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_foo
|
||||
(JNIEnv *, jclass);
|
||||
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
|
||||
(JNIEnv *, jobject, jint, jlong);
|
||||
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2 (JNIEnv *, jobject, jstring, jobject);
|
||||
|
||||
```
|
||||
|
||||
静态native方法`foo`将接收两个参数,分别为存放JNI函数的`JNIEnv`指针,以及一个`jclass`参数,用来指代定义该native方法的类,即`Foo`类。
|
||||
|
||||
两个实例native方法`bar`的第二个参数则是`jobject`类型的,用来指代该native方法的调用者,也就是`Foo`类的实例。
|
||||
|
||||
如果native方法声明了参数,那么对应的C函数将接收这些参数。在我们的例子中,第一个`bar`方法声明了int型和long型的参数,对应的C函数则接收jint和jlong类型的参数;第二个`bar`方法声明了String类型和Object类型的参数,对应的C函数则接收jstring和jobject类型的参数。
|
||||
|
||||
下面,我们继续修改上一小节中的`foo.c`,并在C代码中获取`Foo`类实例的`i`字段。
|
||||
|
||||
```
|
||||
// foo.c
|
||||
#include <stdio.h>
|
||||
#include "org_example_Foo.h"
|
||||
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
|
||||
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
|
||||
jclass cls = (*env)->GetObjectClass(env, thisObject);
|
||||
jfieldID fieldID = (*env)->GetFieldID(env, cls, "i", "I");
|
||||
jint value = (*env)->GetIntField(env, thisObject, fieldID);
|
||||
printf("Hello, World 0x%x\n", value);
|
||||
return;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,在JNI中访问字段类似于反射API:我们首先需要通过类实例获得`FieldID`,然后再通过`FieldID`获得某个实例中该字段的值。不过,与Java代码相比,上述代码貌似不用处理异常。事实果真如此吗?
|
||||
|
||||
下面我就尝试获取了不存在的字段`j`,运行结果如下所示:
|
||||
|
||||
```
|
||||
$ java org.example.Foo
|
||||
Hello, World 0x5
|
||||
Exception in thread "main" java.lang.NoSuchFieldError: j
|
||||
at org.example.Foo.bar(Native Method)
|
||||
at org.example.Foo.main(Foo.java:20)
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,`printf`语句照常执行并打印出`Hello, World 0x5`,但这个数值明显是错误的。当从C函数返回至main方法时,Java虚拟机又会抛出`NoSuchFieldError`异常。
|
||||
|
||||
实际上,当调用JNI函数时,Java虚拟机便已生成异常实例,并缓存在内存中的某个位置。与Java编程不一样的是,它并不会显式地跳转至异常处理器或者调用者中,而是继续执行接下来的C代码。
|
||||
|
||||
因此,当从可能触发异常的JNI函数返回时,我们需要通过JNI函数`ExceptionOccurred`检查是否发生了异常,并且作出相应的处理。如果无须抛出该异常,那么我们需要通过JNI函数`ExceptionClear`显式地清空已缓存的异常。
|
||||
|
||||
具体示例如下所示(为了控制代码篇幅,我仅在第一个`GetFieldID`后检查异常以及清空异常):
|
||||
|
||||
```
|
||||
// foo.c
|
||||
#include <stdio.h>
|
||||
#include "org_example_Foo.h"
|
||||
|
||||
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
|
||||
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
|
||||
jclass cls = (*env)->GetObjectClass(env, thisObject);
|
||||
jfieldID fieldID = (*env)->GetFieldID(env, cls, "j", "I");
|
||||
if((*env)->ExceptionOccurred(env)) {
|
||||
printf("Exception!\n");
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
fieldID = (*env)->GetFieldID(env, cls, "i", "I");
|
||||
jint value = (*env)->GetIntField(env, thisObject, fieldID);
|
||||
// we should put an exception guard here as well.
|
||||
printf("Hello, World 0x%x\n", value);
|
||||
return;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 局部引用与全局引用
|
||||
|
||||
在C代码中,我们可以访问所传入的引用类型参数,也可以通过JNI函数创建新的Java对象。
|
||||
|
||||
这些Java对象显然也会受到垃圾回收器的影响。因此,Java虚拟机需要一种机制,来告知垃圾回收算法,不要回收这些C代码中可能引用到的Java对象。
|
||||
|
||||
这种机制便是JNI的局部引用(Local Reference)和全局引用(Global Reference)。垃圾回收算法会将被这两种引用指向的对象标记为不可回收。
|
||||
|
||||
事实上,无论是传入的引用类型参数,还是通过JNI函数(除`NewGlobalRef`及`NewWeakGlobalRef`之外)返回的引用类型对象,都属于局部引用。
|
||||
|
||||
不过,一旦从C函数中返回至Java方法之中,那么局部引用将失效。也就是说,垃圾回收器在标记垃圾时不再考虑这些局部引用。
|
||||
|
||||
这就意味着,我们不能缓存局部引用,以供另一C线程或下一次native方法调用时使用。
|
||||
|
||||
对于这种应用场景,我们需要借助JNI函数`NewGlobalRef`,将该局部引用转换为全局引用,以确保其指向的Java对象不会被垃圾回收。
|
||||
|
||||
相应的,我们还可以通过JNI函数`DeleteGlobalRef`来消除全局引用,以便回收被全局引用指向的Java对象。
|
||||
|
||||
此外,当C函数运行时间极其长时,我们也应该考虑通过JNI函数`DeleteLocalRef`,消除不再使用的局部引用,以便回收被引用的Java对象。
|
||||
|
||||
另一方面,由于垃圾回收器可能会移动对象在内存中的位置,因此Java虚拟机需要另一种机制,来保证局部引用或者全局引用将正确地指向移动过后的对象。
|
||||
|
||||
HotSpot虚拟机是通过句柄(handle)来完成上述需求的。这里句柄指的是内存中Java对象的指针的指针。当发生垃圾回收时,如果Java对象被移动了,那么句柄指向的指针值也将发生变动,但句柄本身保持不变。
|
||||
|
||||
实际上,无论是局部引用还是全局引用,都是句柄。其中,局部引用所对应的句柄有两种存储方式,一是在本地方法栈帧中,主要用于存放C函数所接收的来自Java层面的引用类型参数;另一种则是线程私有的句柄块,主要用于存放C函数运行过程中创建的局部引用。
|
||||
|
||||
当从C函数返回至Java方法时,本地方法栈帧中的句柄将会被自动清除。而线程私有句柄块则需要由Java虚拟机显式清理。
|
||||
|
||||
进入C函数时对引用类型参数的句柄化,和调整参数位置(C调用和Java调用传参的方式不一样),以及从C函数返回时清理线程私有句柄块,共同造就了JNI调用的额外性能开销(具体可参考该stackoverflow上的[回答](https://stackoverflow.com/questions/24746776/what-does-a-jvm-have-to-do-when-calling-a-native-method/24747484#24747484))。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了JNI的运行机制。
|
||||
|
||||
Java中的native方法的链接方式主要有两种。一是按照JNI的默认规范命名所要链接的C函数,并依赖于Java虚拟机自动链接。另一种则是在C代码中主动链接。
|
||||
|
||||
JNI提供了一系列API来允许C代码使用Java语言特性。这些API不仅使用了特殊的数据结构来表示Java类,还拥有特殊的异常处理模式。
|
||||
|
||||
JNI中的引用可分为局部引用和全局引用。这两者都可以阻止垃圾回收器回收被引用的Java对象。不同的是,局部引用在native方法调用返回之后便会失效。传入参数以及大部分JNI API函数的返回值都属于局部引用。
|
||||
|
||||
今天的实践环节,请阅读[该文档](https://www.ibm.com/developerworks/java/library/j-jni/index.html)中的Performance pitfalls以及Correctness pitfalls两节。
|
||||
|
||||
|
||||
345
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/33 | Java Agent与字节码注入.md
Normal file
345
极客时间专栏/深入拆解Java虚拟机/模块三:代码优化/33 | Java Agent与字节码注入.md
Normal file
@@ -0,0 +1,345 @@
|
||||
<audio id="audio" title="33 | Java Agent与字节码注入" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/44/c2c03ab0bc40c20290ee2ebbb3ec8644.mp3"></audio>
|
||||
|
||||
关于Java agent,大家可能都听过大名鼎鼎的`premain`方法。顾名思义,这个方法指的就是在`main`方法之前执行的方法。
|
||||
|
||||
```
|
||||
package org.example;
|
||||
|
||||
public class MyAgent {
|
||||
public static void premain(String args) {
|
||||
System.out.println("premain");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我在上面这段代码中定义了一个`premain`方法。这里需要注意的是,Java虚拟机所能识别的`premain`方法接收的是字符串类型的参数,而并非类似于`main`方法的字符串数组。
|
||||
|
||||
为了能够以Java agent的方式运行该`premain`方法,我们需要将其打包成jar包,并在其中的MANIFEST.MF配置文件中,指定所谓的`Premain-class`。具体的命令如下所示:
|
||||
|
||||
```
|
||||
# 注意第一条命令会向manifest.txt文件写入两行数据,其中包括一行空行
|
||||
$ echo 'Premain-Class: org.example.MyAgent
|
||||
' > manifest.txt
|
||||
$ jar cvmf manifest.txt myagent.jar org/
|
||||
$ java -javaagent:myagent.jar HelloWorld
|
||||
premain
|
||||
Hello, World
|
||||
|
||||
```
|
||||
|
||||
除了在命令行中指定Java agent之外,我们还可以通过Attach API远程加载。具体用法如下面的代码所示:
|
||||
|
||||
```
|
||||
import java.io.IOException;
|
||||
|
||||
import com.sun.tools.attach.*;
|
||||
|
||||
public class AttachTest {
|
||||
public static void main(String[] args)
|
||||
throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
|
||||
if (args.length <= 1) {
|
||||
System.out.println("Usage: java AttachTest <PID> /PATH/TO/AGENT.jar");
|
||||
return;
|
||||
}
|
||||
VirtualMachine vm = VirtualMachine.attach(args[0]);
|
||||
vm.loadAgent(args[1]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
使用Attach API远程加载的Java agent不会再先于`main`方法执行,这取决于另一虚拟机调用Attach API的时机。并且,它运行的也不再是`premain`方法,而是名为`agentmain`的方法。
|
||||
|
||||
```
|
||||
public class MyAgent {
|
||||
public static void agentmain(String args) {
|
||||
System.out.println("agentmain");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
相应的,我们需要更新jar包中的manifest文件,使其包含`Agent-Class`的配置,例如`Agent-Class: org.example.MyAgent`。
|
||||
|
||||
```
|
||||
$ echo 'Agent-Class: org.example.MyAgent
|
||||
' > manifest.txt
|
||||
$ jar cvmf manifest.txt myagent.jar org/
|
||||
$ java HelloWorld
|
||||
Hello, World
|
||||
$ jps
|
||||
$ java AttachTest <pid> myagent.jar
|
||||
agentmain
|
||||
// 最后一句输出来自于运行HelloWorld的Java进程
|
||||
|
||||
```
|
||||
|
||||
Java虚拟机并不限制Java agent的数量。你可以在java命令后附上多个`-javaagent`参数,或者远程attach多个Java agent,Java虚拟机会按照定义顺序,或者attach的顺序逐个执行这些Java agent。
|
||||
|
||||
在`premain`方法或者`agentmain`方法中打印一些字符串并不出奇,我们完全可以将其中的逻辑并入`main`方法,或者其他监听端口的线程中。除此之外,Java agent还提供了一套instrumentation机制,允许应用程序拦截类加载事件,并且更改该类的字节码。
|
||||
|
||||
接下来,我们来了解一下基于这一机制的字节码注入。
|
||||
|
||||
## 字节码注入
|
||||
|
||||
```
|
||||
package org.example;
|
||||
|
||||
import java.lang.instrument.*;
|
||||
import java.security.ProtectionDomain;
|
||||
|
||||
public class MyAgent {
|
||||
public static void premain(String args, Instrumentation instrumentation) {
|
||||
instrumentation.addTransformer(new MyTransformer());
|
||||
}
|
||||
|
||||
static class MyTransformer implements ClassFileTransformer {
|
||||
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
|
||||
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
|
||||
System.out.printf("Loaded %s: 0x%X%X%X%X\n", className, classfileBuffer[0], classfileBuffer[1],
|
||||
classfileBuffer[2], classfileBuffer[3]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们先来看一个例子。在上面这段代码中,`premain`方法多出了一个`Instrumentation`类型的参数,我们可以通过它来注册类加载事件的拦截器。该拦截器需要实现`ClassFileTransformer`接口,并重写其中的`transform`方法。
|
||||
|
||||
`transform`方法将接收一个byte数组类型的参数,它代表的是正在被加载的类的字节码。在上面这段代码中,我将打印该数组的前四个字节,也就是Java class文件的魔数(magic number)0xCAFEBABE。
|
||||
|
||||
`transform`方法将返回一个byte数组,代表更新过后的类的字节码。当方法返回之后,Java虚拟机会使用所返回的byte数组,来完成接下来的类加载工作。不过,如果`transform`方法返回null或者抛出异常,那么Java虚拟机将使用原来的byte数组完成类加载工作。
|
||||
|
||||
基于这一类加载事件的拦截功能,我们可以实现字节码注入(bytecode instrumentation),往正在被加载的类中插入额外的字节码。
|
||||
|
||||
在工具篇中我曾经介绍过字节码工程框架ASM的用法。下面我将演示它的[tree包](https://search.maven.org/artifact/org.ow2.asm/asm-tree/7.0-beta/jar)(依赖于[基础包](https://search.maven.org/artifact/org.ow2.asm/asm/7.0-beta/jar)),用面向对象的方式注入字节码。
|
||||
|
||||
```
|
||||
package org.example;
|
||||
|
||||
import java.lang.instrument.*;
|
||||
import java.security.ProtectionDomain;
|
||||
import org.objectweb.asm.*;
|
||||
import org.objectweb.asm.tree.*;
|
||||
|
||||
public class MyAgent {
|
||||
public static void premain(String args, Instrumentation instrumentation) {
|
||||
instrumentation.addTransformer(new MyTransformer());
|
||||
}
|
||||
|
||||
static class MyTransformer implements ClassFileTransformer, Opcodes {
|
||||
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
|
||||
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
|
||||
ClassReader cr = new ClassReader(classfileBuffer);
|
||||
ClassNode classNode = new ClassNode(ASM7);
|
||||
cr.accept(classNode, ClassReader.SKIP_FRAMES);
|
||||
|
||||
for (MethodNode methodNode : classNode.methods) {
|
||||
if ("main".equals(methodNode.name)) {
|
||||
InsnList instrumentation = new InsnList();
|
||||
instrumentation.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
|
||||
instrumentation.add(new LdcInsnNode("Hello, Instrumentation!"));
|
||||
instrumentation
|
||||
.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false));
|
||||
|
||||
methodNode.instructions.insert(instrumentation);
|
||||
}
|
||||
}
|
||||
|
||||
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
|
||||
classNode.accept(cw);
|
||||
return cw.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段代码不难理解。我们将使用`ClassReader`读取所传入的byte数组,并将其转换成`ClassNode`。然后我们将遍历`ClassNode`中的`MethodNode`节点,也就是该类中的构造器和方法。
|
||||
|
||||
当遇到名字为`"main"`的方法时,我们会在方法的入口处注入`System.out.println("Hello, Instrumentation!");`。运行结果如下所示:
|
||||
|
||||
```
|
||||
$ java -javaagent:myagent.jar -cp .:/PATH/TO/asm-7.0-beta.jar:/PATH/TO/asm-tree-7.0-beta.jar HelloWorld
|
||||
Hello, Instrumentation!
|
||||
Hello, World!
|
||||
|
||||
```
|
||||
|
||||
Java agent还提供了另外两个功能`redefine`和`retransform`。这两个功能针对的是已加载的类,并要求用户传入所要`redefine`或者`retransform`的类实例。
|
||||
|
||||
其中,`redefine`指的是舍弃原本的字节码,并替换成由用户提供的byte数组。该功能比较危险,一般用于修复出错了的字节码。
|
||||
|
||||
`retransform`则将针对所传入的类,重新调用所有已注册的`ClassFileTransformer`的`transform`方法。它的应用场景主要有如下两个。
|
||||
|
||||
第一,在执行`premain`或者`agentmain`方法前,Java虚拟机早已加载了不少类,而这些类的加载事件并没有被拦截,因此也没有被注入。使用`retransform`功能可以注入这些已加载但未注入的类。
|
||||
|
||||
第二,在定义了多个Java agent,多个注入的情况下,我们可能需要移除其中的部分注入。当调用`Instrumentation.removeTransformer`去除某个注入类后,我们可以调用`retransform`功能,重新从原始byte数组开始进行注入。
|
||||
|
||||
Java agent的这些功能都是通过JVMTI agent,也就是C agent来实现的。JVMTI是一个事件驱动的工具实现接口,通常,我们会在C agent加载后的入口方法`Agent_OnLoad`处注册各个事件的钩子(hook)方法。当Java虚拟机触发了这些事件时,便会调用对应的钩子方法。
|
||||
|
||||
```
|
||||
JNIEXPORT jint JNICALL
|
||||
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
|
||||
|
||||
```
|
||||
|
||||
举个例子,我们可以为JVMTI中的`ClassFileLoadHook`事件设置钩子,从而在C层面拦截所有的类加载事件。关于JVMTI的其他事件,你可以参考该[链接](https://docs.oracle.com/en/java/javase/11/docs/specs/jvmti.html#EventIndex)。
|
||||
|
||||
## 基于字节码注入的profiler
|
||||
|
||||
我们可以利用字节码注入来实现代码覆盖工具(例如[JaCoCo](https://www.jacoco.org/jacoco/)),或者各式各样的profiler。
|
||||
|
||||
通常,我们会定义一个运行时类,并在某一程序行为的周围,注入对该运行时类中方法的调用,以表示该程序行为正要发生或者已经发生。
|
||||
|
||||
```
|
||||
package org.example;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class MyProfiler {
|
||||
public static ConcurrentHashMap<Class<?>, AtomicInteger> data = new ConcurrentHashMap<>();
|
||||
|
||||
public static void fireAllocationEvent(Class<?> klass) {
|
||||
data.computeIfAbsent(klass, kls -> new AtomicInteger())
|
||||
.incrementAndGet();
|
||||
}
|
||||
|
||||
public static void dump() {
|
||||
data.forEach((kls, counter) -> {
|
||||
System.err.printf("%s: %d\n", kls.getName(), counter.get());
|
||||
});
|
||||
}
|
||||
|
||||
static {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(MyProfiler::dump));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上面这段代码便是一个运行时类。该类维护了一个`HashMap`,用来统计每个类所新建实例的数目。当程序退出时,我们将逐个打印出每个类的名字,以及其新建实例的数目。
|
||||
|
||||
在Java agent中,我们会截获正在加载的类,并且在每条`new`字节码之后插入对`fireAllocationEvent`方法的调用,以表示当前正在新建某个类的实例。具体的注入代码如下所示:
|
||||
|
||||
```
|
||||
package org.example;
|
||||
|
||||
import java.lang.instrument.*;
|
||||
import java.security.ProtectionDomain;
|
||||
|
||||
import org.objectweb.asm.*;
|
||||
import org.objectweb.asm.tree.*;
|
||||
|
||||
public class MyAgent {
|
||||
|
||||
public static void premain(String args, Instrumentation instrumentation) {
|
||||
instrumentation.addTransformer(new MyTransformer());
|
||||
}
|
||||
|
||||
static class MyTransformer implements ClassFileTransformer, Opcodes {
|
||||
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
|
||||
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
|
||||
if (className.startsWith("java") ||
|
||||
className.startsWith("javax") ||
|
||||
className.startsWith("jdk") ||
|
||||
className.startsWith("sun") ||
|
||||
className.startsWith("com/sun") ||
|
||||
className.startsWith("org/example")) {
|
||||
// Skip JDK classes and profiler classes
|
||||
return null;
|
||||
}
|
||||
|
||||
ClassReader cr = new ClassReader(classfileBuffer);
|
||||
ClassNode classNode = new ClassNode(ASM7);
|
||||
cr.accept(classNode, ClassReader.SKIP_FRAMES);
|
||||
|
||||
for (MethodNode methodNode : classNode.methods) {
|
||||
for (AbstractInsnNode node : methodNode.instructions.toArray()) {
|
||||
if (node.getOpcode() == NEW) {
|
||||
TypeInsnNode typeInsnNode = (TypeInsnNode) node;
|
||||
|
||||
InsnList instrumentation = new InsnList();
|
||||
instrumentation.add(new LdcInsnNode(Type.getObjectType(typeInsnNode.desc)));
|
||||
instrumentation.add(new MethodInsnNode(INVOKESTATIC, "org/example/MyProfiler", "fireAllocationEvent",
|
||||
"(Ljava/lang/Class;)V", false));
|
||||
|
||||
methodNode.instructions.insert(node, instrumentation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
|
||||
classNode.accept(cw);
|
||||
return cw.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你或许已经留意到,我们不得不排除对JDK类以及该运行时类的注入。这是因为,对这些类的注入很可能造成死循环调用,并最终抛出`StackOverflowException`异常。
|
||||
|
||||
举个例子,假设我们在`PrintStream.println`方法入口处注入`System.out.println("blahblah")`,由于`out`是`PrintStream`的实例,因此当执行注入代码时,我们又会调用`PrintStream.println`方法,从而造成死循环。
|
||||
|
||||
解决这一问题的关键在于设置一个线程私有的标识位,用以区分应用代码的上下文以及注入代码的上下文。当即将执行注入代码时,我们将根据标识位判断是否已经位于注入代码的上下文之中。如果不是,则设置标识位并正常执行注入代码;如果是,则直接返回,不再执行注入代码。
|
||||
|
||||
字节码注入的另一个技术难点则是命名空间。举个例子,不少应用程序都依赖于字节码工程库ASM。当我们的注入逻辑依赖于ASM时,便有可能出现注入使用最新版本的ASM,而应用程序使用较低版本的ASM的问题。
|
||||
|
||||
JDK本身也使用了ASM库,如用来生成Lambda表达式的适配器类。JDK的做法是重命名整个ASM库,为所有类的包名添加`jdk.internal`前缀。我们显然不好直接更改ASM的包名,因此需要借助自定义类加载器来隔离命名空间。
|
||||
|
||||
除了上述技术难点之外,基于字节码注入的工具还有另一个问题,那便是观察者效应(observer effect)对所收集的数据造成的影响。
|
||||
|
||||
举个利用字节码注入收集每个方法的运行时间的例子。假设某个方法调用了另一个方法,而这两个方法都被注入了,那么统计被调用者运行时间的注入代码所耗费的时间,将不可避免地被计入至调用者方法的运行时间之中。
|
||||
|
||||
再举一个统计新建对象数目的例子。我们知道,即时编译器中的逃逸分析可能会优化掉新建对象操作,但它不会消除相应的统计操作,比如上述例子中对`fireAllocationEvent`方法的调用。在这种情况下,我们将统计没有实际发生的新建对象操作。
|
||||
|
||||
另一种情况则是,我们所注入的对`fireAllocationEvent`方法的调用,将影响到方法内联的决策。如果该新建对象的构造器调用恰好因此没有被内联,从而造成对象逃逸。在这种情况下,原本能够被逃逸分析优化掉的新建对象操作将无法优化,我们也将统计到原本不会发生的新建对象操作。
|
||||
|
||||
总而言之,当使用字节码注入开发profiler时,需要辩证地看待所收集的数据。它仅能表示在被注入的情况下程序的执行状态,而非没有注入情况下的程序执行状态。
|
||||
|
||||
## 面向方面编程
|
||||
|
||||
说到字节码注入,就不得不提面向方面编程(Aspect-Oriented Programming,AOP)。面向方面编程的核心理念是定义切入点(pointcut)以及通知(advice)。程序控制流中所有匹配该切入点的连接点(joinpoint)都将执行这段通知代码。
|
||||
|
||||
举个例子,我们定义一个指代所有方法入口的切入点,并指定在该切入点执行的“打印该方法的名字”这一通知。那么每个具体的方法入口便是一个连接点。
|
||||
|
||||
面向方面编程的其中一种实现方式便是字节码注入,比如[AspectJ](https://www.eclipse.org/aspectj/)。
|
||||
|
||||
在前面的例子中,我们也相当于使用了面向方面编程,在所有的`new`字节码之后执行了下面这样一段通知代码。
|
||||
|
||||
```
|
||||
`MyProfiler.fireAllocationEvent(<Target>.class)`
|
||||
|
||||
```
|
||||
|
||||
我曾经参与开发过一个应用了面向方面编程思想的字节码注入框架[DiSL](https://disl.ow2.org/)。它支持用注解来定义切入点,用普通Java方法来定义通知。例如,在方法入口处打印所在的方法名,可以简单表示为如下代码:
|
||||
|
||||
```
|
||||
@Before(marker = BodyMarker.class)
|
||||
static void onMethodEntry(MethodStaticContext msc) {
|
||||
System.out.println(msc.thisMethodFullName());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果有同学对这个工具感兴趣,或者有什么需求或者建议,欢迎你在留言中提出。
|
||||
|
||||
## 总结与实践
|
||||
|
||||
今天我介绍了Java agent以及字节码注入。
|
||||
|
||||
我们可以通过Java agent的类加载拦截功能,修改某个类所对应的byte数组,并利用这个修改过后的byte数组完成接下来的类加载。
|
||||
|
||||
基于字节码注入的profiler,可以统计程序运行过程中某些行为的出现次数。如果需要收集Java核心类库的数据,那么我们需要小心避免无限递归调用。另外,我们还需通过自定义类加载器来解决命名空间的问题。
|
||||
|
||||
由于字节码注入会产生观察者效应,因此基于该技术的profiler所收集到的数据并不能反映程序的真实运行状态。它所反映的是程序在被注入的情况下的执行状态。
|
||||
|
||||
今天的实践环节,请你思考如何注入方法出口。除了正常执行路径之外,你还需考虑异常执行路径。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user