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

View File

@@ -0,0 +1,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 &gt;= 0)
return x;
else
return -x;
}
```
例如在上面这段代码中,实例字段`Foo.a`会被赋值为1。接下来的if语句将判断同一实例字段是否不小于0。经过字段读取优化之后`&gt;=`节点的两个输入参数分别为常数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 />

View 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 &lt; 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 &lt; 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 &lt; 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指向ar9d存放着i
0x02f7: add eax,edi // eax存放着sum
0x02f9: inc r9d // i++
0x02fc: cmp r9d,r10d // i &lt; 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 &lt; 0 || index &gt;= 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 &lt; a.length; i++) {
if (a == null) { // null check
throw new NullPointerException();
}
if (i &lt; 0 || i &gt;= 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 &lt; a.length; i++) {
if (a == null) { // now evluate to false
throw new NullPointerException();
}
if (i &lt; 0 || i &gt;= a.length) { // range check
throw new ArrayIndexOutOfBoundsException();
}
sum += a[i];
}
return sum;
}
```
除了null检测之外其他循环无关检测都能够按照这种方式外提至循环之前。甚至是循环有关的下标范围检测都能够借助循环预测来外提只不过具体的转换要复杂一些。
之所以说下标范围检测是循环有关的,是因为在我们的例子中,该检测的主体是循环控制变量`i`(检测它是否在`[0, a.length)`之间),它的值将随着循环次数的增加而改变。
由于外提该下标范围检测之后,我们无法再引用到循环变量`i`,因此,即时编译器需要转换检测条件。具体的转换方式如下所示:
```
for (int i = INIT; i &lt; LIMIT; i += STRIDE) {
if (i &lt; 0 || i &gt;= a.length) { // range check
throw new ArrayIndexOutOfBoundsException();
}
sum += a[i];
}
----------
// 经过下标范围检测外提之后:
if (INIT &lt; 0 || IMAX &gt;= a.length) {
// IMAX是i所能达到的最大值注意它不一定是LIMIT-1
detopimize(); // never returns
}
for (int i = INIT; i &lt; LIMIT; i += STRIDE) {
sum += a[i]; // 不包含下标范围检测
}
```
## 循环展开
另外一项非常重要的循环优化是循环展开Loop Unrolling。它指的是在循环体中重复多次循环迭代并减少循环次数的编译优化。
```
int foo(int[] a) {
int sum = 0;
for (int i = 0; i &lt; 64; i++) {
sum += (i % 2 == 0) ? a[i] : -a[i];
}
return sum;
}
```
举个例子,上面的代码经过一次循环展开之后将形成下面的代码:
```
int foo(int[] a) {
int sum = 0;
for (int i = 0; i &lt; 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 &lt; LIMIT; i += STRIDE) { .. }
// 等价于
int i = START;
while (i &lt; LIMIT) {
..
i += STRIDE;
}
```
在上面两种循环中,只要`LIMIT`是循环无关的数值,`STRIDE`是常数,而且循环中除了`i &lt; LIMIT`之外没有其他基于循环变量`i`的循环出口那么C2便会将该循环识别为计数循环。
循环展开的缺点显而易见:它可能会增加代码的冗余度,导致所生成机器码的长度大幅上涨。
不过,随着循环体的增大,优化机会也会不断增加。一旦循环展开能够触发进一步的优化,总体的代码复杂度也将降低。比如前面的例子经过循环展开之后便可以进一步优化为如下所示的代码:
```
int foo(int[] a) {
int sum = 0;
for (int i = 0; i &lt; 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 &lt; 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 &lt; a.length; i++) {
if (a.length &gt; 4) {
sum += a[i];
}
}
return sum;
}
```
举个例子,上面这段代码经过循环判断外提之后,将变成下面这段代码:
```
int foo(int[] a) {
int sum = 0;
if (a.length &gt; 4) {
for (int i = 0; i &lt; a.length; i++) {
sum += a[i];
}
} else {
for (int i = 0; i &lt; a.length; i++) {
}
}
return sum;
}
// 进一步优化为:
int foo(int[] a) {
int sum = 0;
if (a.length &gt; 4) {
for (int i = 0; i &lt; 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 &lt; a.length; i++) {
sum += a[j];
j = i;
}
return sum;
}
```
举个例子,上面这段代码剥离了第一个迭代后,将变成下面这段代码:
```
int foo(int[] a) {
int sum = 0;
if (0 &lt; a.length) {
sum += a[0];
for (int i = 1; i &lt; a.length; i++) {
sum += a[i - 1];
}
}
return sum;
}
```
## 总结与实践
今天我介绍了即时编译器所使用的循环优化。
循环无关代码外提将循环中值不变的表达式或者循环无关检测外提至循环之前以避免在循环中重复进行冗余计算。前者是通过Sea-of-Nodes IR以及节点调度来共同完成的而后者则是通过一个独立优化 —— 循环预测来完成的。循环预测还可以外提循环有关的数组下标范围检测。
循环展开是一种在循环中重复多次迭代,并且相应地减少循环次数的优化方式。它是一种以空间换时间的优化方式,通过增大循环体来获取更多的优化机会。循环展开的特殊形式是完全展开,将原本的循环转换成若干个循环体的顺序执行。
此外,我还简单地介绍了另外两种循环优化方式:循环判断外提以及循环剥离。
今天的实践环节,我们来看这么一段代码:
```
void foo(byte[] dst, byte[] src) {
for (int i = 0; i &lt; 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 &lt; 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
}
```

View 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 &lt; 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 &lt; 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寄存器是由SSEStreaming SIMD Extensions指令集所引入的。它们一开始仅为128位。自从X86平台上的CPU开始支持AVXAdvanced 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 DataSIMD即通过单条指令操控多组数据的计算操作。这些指令我们称之为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 &lt; 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&lt;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 &lt; 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&lt;S256Bits&gt;`的向量并使用由它提供的一系列向量化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 &lt; 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 &lt; 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 &lt; 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 />

View 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&lt;String&gt; getSupportedAnnotationTypes();
SourceVersion getSupportedSourceVersion();
boolean process(Set&lt;? extends TypeElement&gt; 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(&quot;foo.CheckGetter&quot;)
@SupportedSourceVersion(SourceVersion.RELEASE_10)
public class CheckGetterProcessor extends AbstractProcessor {
@Override
public boolean process(Set&lt;? extends TypeElement&gt; 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(&quot;getter not found for '%s.%s'.&quot;, annotatedClass.getSimpleName(), field.getSimpleName()));
}
}
}
return true;
}
private static boolean containsGetter(TypeElement typeElement, String name) {
String getter = &quot;get&quot; + name.substring(0, 1).toUpperCase() + name.substring(1).toLowerCase();
for (ExecutableElement executableElement : ElementFilter.methodsIn(typeElement.getEnclosedElements())) {
if (!executableElement.getModifiers().contains(Modifier.STATIC)
&amp;&amp; executableElement.getSimpleName().toString().equals(getter)
&amp;&amp; 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&lt;?&gt; 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(&quot;foo.Adapt&quot;)
@SupportedSourceVersion(SourceVersion.RELEASE_10)
public class AdaptProcessor extends AbstractProcessor {
@Override
public boolean process(Set&lt;? extends TypeElement&gt; annotations, RoundEnvironment roundEnv) {
for (TypeElement annotation : annotations) {
if (!&quot;foo.Adapt&quot;.equals(annotation.getQualifiedName().toString())) {
continue;
}
ExecutableElement targetAsKey = getExecutable(annotation, &quot;value&quot;);
for (ExecutableElement annotatedMethod : ElementFilter.methodsIn(roundEnv.getElementsAnnotatedWith(annotation))) {
if (!annotatedMethod.getModifiers().contains(Modifier.PUBLIC)) {
processingEnv.getMessager().printMessage(Kind.ERROR, &quot;@Adapt on non-public method&quot;);
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, &quot;@Adapt with non-interface input&quot;);
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 + &quot;_&quot; + methodName + &quot;Adapter&quot;;
ExecutableElement overriddenMethod = getFirstNonDefaultExecutable(targetInterface);
try {
Filer filer = processingEnv.getFiler();
JavaFileObject sourceFile = filer.createSourceFile(packageName + &quot;.&quot; + adapterName, new Element[0]);
try (PrintWriter out = new PrintWriter(sourceFile.openWriter())) {
out.println(&quot;package &quot; + packageName + &quot;;&quot;);
out.println(&quot;import &quot; + targetInterface.getQualifiedName() + &quot;;&quot;);
out.println();
out.println(&quot;public class &quot; + adapterName + &quot; implements &quot; + targetInterface.getSimpleName() + &quot; {&quot;);
out.println(&quot; @Override&quot;);
out.println(&quot; public &quot; + overriddenMethod.getReturnType() + &quot; &quot; + overriddenMethod.getSimpleName()
+ formatParameter(overriddenMethod, true) + &quot; {&quot;);
out.println(&quot; return &quot; + className + &quot;.&quot; + methodName + formatParameter(overriddenMethod, false) + &quot;;&quot;);
out.println(&quot; }&quot;);
out.println(&quot;}&quot;);
}
} 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, &quot;Incompatible @Adapt.&quot;);
return null;
}
private ExecutableElement getFirstNonDefaultExecutable(TypeElement annotation) {
for (ExecutableElement method : ElementFilter.methodsIn(annotation.getEnclosedElements())) {
if (!method.isDefault()) {
return method;
}
}
processingEnv.getMessager().printMessage(Kind.ERROR,
&quot;Target interface should declare at least one non-default method.&quot;);
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, &quot;Unknown @Adapt target&quot;);
continue;
}
TypeMirror targetInterfaceTypeMirror = (TypeMirror) value.getValue();
return (TypeElement) processingEnv.getTypeUtils().asElement(targetInterfaceTypeMirror);
}
}
processingEnv.getMessager().printMessage(Kind.ERROR, &quot;@Adapt should contain target()&quot;);
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 = &quot;&quot;;
for (VariableElement parameter : method.getParameters()) {
builder.append(separator);
if (includeType) {
builder.append(parameter.asType());
builder.append(' ');
}
builder.append(parameter.getSimpleName());
separator = &quot;, &quot;;
}
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)

View 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 &lt; 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 &lt; 20_000; i++) {
foo();
}
// measurement
long current = System.nanoTime();
for (int i = 1; i &lt;= 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 &lt; 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 -&gt; B3 -&gt; B2 -&gt; B1 -&gt; MyBenchmark`(这里`A -&gt; 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)

View 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: &lt;none&gt;
# 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 &quot;org.sample.MyBenchmark.testMethod&quot;:
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 = &quot;exception&quot;;
}
@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 : &quot;ERROR&quot;;
}
}
@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)

View 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 &lt; 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 &lt;PID&gt;`命令,开启`&lt;PID&gt;`所指定的Java进程的`HeapDumpAfterFullGC`参数。
你可以通过下述命令查看其他&quot;manageable&quot;虚拟机参数:
```
$ 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 &quot;11&quot; 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
...
&quot;Thread-0&quot; #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 &lt;0x000000061ff904c0&gt; (a java.lang.Object)
- locked &lt;0x000000061ff904b0&gt; (a java.lang.Object)
at DeadLock$$Lambda$1/0x0000000800060840.run(Unknown Source)
at java.lang.Thread.run(java.base@11/Thread.java:834)
&quot;Thread-1&quot; #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 &lt;0x000000061ff904b0&gt; (a java.lang.Object)
- locked &lt;0x000000061ff904c0&gt; (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:
=============================
&quot;Thread-0&quot;:
waiting to lock monitor 0x00007fb083015900 (object 0x000000061ff904c0, a java.lang.Object),
which is held by &quot;Thread-1&quot;
&quot;Thread-1&quot;:
waiting to lock monitor 0x00007fb083015800 (object 0x000000061ff904b0, a java.lang.Object),
which is held by &quot;Thread-0&quot;
Java stack information for the threads listed above:
===================================================
&quot;Thread-0&quot;:
at DeadLock.foo(DeadLock.java:18)
- waiting to lock &lt;0x000000061ff904c0&gt; (a java.lang.Object)
- locked &lt;0x000000061ff904b0&gt; (a java.lang.Object)
at DeadLock$$Lambda$1/0x0000000800060840.run(Unknown Source)
at java.lang.Thread.run(java.base@11/Thread.java:834)
&quot;Thread-1&quot;:
at DeadLock.bar(DeadLock.java:33)
- waiting to lock &lt;0x000000061ff904b0&gt; (a java.lang.Object)
- locked &lt;0x000000061ff904c0&gt; (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
```

View 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支配dominateb。
在a支配b且a不同于b的情况下即a严格支配b如果从a节点到b节点的所有路径中不存在支配b的其他节点那么a直接支配immediate dominateb。这里的支配树指的便是由节点的直接支配节点所组成的树状结构。
我们可以将堆中所有的对象看成一张对象图每个对象是一个图节点而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 RecorderJFR
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 &lt;PID&gt; 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 &lt;PID&gt; JFR.start settings=profile maxage=10m maxsize=150m name=SomeLabel
```
上述命令运行过后目标进程中的JFR已经开始收集数据。此时我们可以通过下述命令来导出已经收集到的数据
```
$ jcmd &lt;PID&gt; JFR.dump name=SomeLabel filename=myrecording.jfr
```
最后我们可以通过下述命令关闭目标进程中的JFR
```
$ jcmd &lt;PID&gt; 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-&gt;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) 了。感兴趣的同学可自行下载研究。

View 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 InterfaceJNI机制。
关于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 &lt;jni.h&gt;
/* Header for class org_example_Foo */
#ifndef _Included_org_example_Foo
#define _Included_org_example_Foo
#ifdef __cplusplus
extern &quot;C&quot; {
#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[] = {
{&quot;hashCode&quot;, &quot;()I&quot;, (void *)&amp;JVM_IHashCode},
{&quot;wait&quot;, &quot;(J)V&quot;, (void *)&amp;JVM_MonitorWait},
{&quot;notify&quot;, &quot;()V&quot;, (void *)&amp;JVM_MonitorNotify},
{&quot;notifyAll&quot;, &quot;()V&quot;, (void *)&amp;JVM_MonitorNotifyAll},
{&quot;clone&quot;, &quot;()Ljava/lang/Object;&quot;, (void *)&amp;JVM_Clone},
};
JNIEXPORT void JNICALL
Java_java_lang_Object_registerNatives(JNIEnv *env, jclass cls)
{
(*env)-&gt;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 &lt;stdio.h&gt;
#include &quot;org_example_Foo.h&quot;
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
printf(&quot;Hello, World\n&quot;);
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(&quot;foo&quot;);
} catch (UnsatisfiedLinkError e) {
e.printStackTrace();
System.exit(1);
}
new Foo().bar(&quot;&quot;, &quot;&quot;);
}
}
```
如果`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 &lt;stdio.h&gt;
#include &quot;org_example_Foo.h&quot;
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)-&gt;GetObjectClass(env, thisObject);
jfieldID fieldID = (*env)-&gt;GetFieldID(env, cls, &quot;i&quot;, &quot;I&quot;);
jint value = (*env)-&gt;GetIntField(env, thisObject, fieldID);
printf(&quot;Hello, World 0x%x\n&quot;, value);
return;
}
```
我们可以看到在JNI中访问字段类似于反射API我们首先需要通过类实例获得`FieldID`,然后再通过`FieldID`获得某个实例中该字段的值。不过与Java代码相比上述代码貌似不用处理异常。事实果真如此吗
下面我就尝试获取了不存在的字段`j`,运行结果如下所示:
```
$ java org.example.Foo
Hello, World 0x5
Exception in thread &quot;main&quot; 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 &lt;stdio.h&gt;
#include &quot;org_example_Foo.h&quot;
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)-&gt;GetObjectClass(env, thisObject);
jfieldID fieldID = (*env)-&gt;GetFieldID(env, cls, &quot;j&quot;, &quot;I&quot;);
if((*env)-&gt;ExceptionOccurred(env)) {
printf(&quot;Exception!\n&quot;);
(*env)-&gt;ExceptionClear(env);
}
fieldID = (*env)-&gt;GetFieldID(env, cls, &quot;i&quot;, &quot;I&quot;);
jint value = (*env)-&gt;GetIntField(env, thisObject, fieldID);
// we should put an exception guard here as well.
printf(&quot;Hello, World 0x%x\n&quot;, 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两节。

View 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(&quot;premain&quot;);
}
}
```
我在上面这段代码中定义了一个`premain`方法。这里需要注意的是Java虚拟机所能识别的`premain`方法接收的是字符串类型的参数,而并非类似于`main`方法的字符串数组。
为了能够以Java agent的方式运行该`premain`方法我们需要将其打包成jar包并在其中的MANIFEST.MF配置文件中指定所谓的`Premain-class`。具体的命令如下所示:
```
# 注意第一条命令会向manifest.txt文件写入两行数据其中包括一行空行
$ echo 'Premain-Class: org.example.MyAgent
' &gt; 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 &lt;= 1) {
System.out.println(&quot;Usage: java AttachTest &lt;PID&gt; /PATH/TO/AGENT.jar&quot;);
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(&quot;agentmain&quot;);
}
}
```
相应的我们需要更新jar包中的manifest文件使其包含`Agent-Class`的配置,例如`Agent-Class: org.example.MyAgent`
```
$ echo 'Agent-Class: org.example.MyAgent
' &gt; manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java HelloWorld
Hello, World
$ jps
$ java AttachTest &lt;pid&gt; myagent.jar
agentmain
// 最后一句输出来自于运行HelloWorld的Java进程
```
Java虚拟机并不限制Java agent的数量。你可以在java命令后附上多个`-javaagent`参数或者远程attach多个Java agentJava虚拟机会按照定义顺序或者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&lt;?&gt; classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.printf(&quot;Loaded %s: 0x%X%X%X%X\n&quot;, className, classfileBuffer[0], classfileBuffer[1],
classfileBuffer[2], classfileBuffer[3]);
return null;
}
}
}
```
我们先来看一个例子。在上面这段代码中,`premain`方法多出了一个`Instrumentation`类型的参数,我们可以通过它来注册类加载事件的拦截器。该拦截器需要实现`ClassFileTransformer`接口,并重写其中的`transform`方法。
`transform`方法将接收一个byte数组类型的参数它代表的是正在被加载的类的字节码。在上面这段代码中我将打印该数组的前四个字节也就是Java class文件的魔数magic number0xCAFEBABE。
`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&lt;?&gt; 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 (&quot;main&quot;.equals(methodNode.name)) {
InsnList instrumentation = new InsnList();
instrumentation.add(new FieldInsnNode(GETSTATIC, &quot;java/lang/System&quot;, &quot;out&quot;, &quot;Ljava/io/PrintStream;&quot;));
instrumentation.add(new LdcInsnNode(&quot;Hello, Instrumentation!&quot;));
instrumentation
.add(new MethodInsnNode(INVOKEVIRTUAL, &quot;java/io/PrintStream&quot;, &quot;println&quot;, &quot;(Ljava/lang/String;)V&quot;, 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`节点,也就是该类中的构造器和方法。
当遇到名字为`&quot;main&quot;`的方法时,我们会在方法的入口处注入`System.out.println(&quot;Hello, Instrumentation!&quot;);`。运行结果如下所示:
```
$ 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&lt;Class&lt;?&gt;, AtomicInteger&gt; data = new ConcurrentHashMap&lt;&gt;();
public static void fireAllocationEvent(Class&lt;?&gt; klass) {
data.computeIfAbsent(klass, kls -&gt; new AtomicInteger())
.incrementAndGet();
}
public static void dump() {
data.forEach((kls, counter) -&gt; {
System.err.printf(&quot;%s: %d\n&quot;, 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&lt;?&gt; classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (className.startsWith(&quot;java&quot;) ||
className.startsWith(&quot;javax&quot;) ||
className.startsWith(&quot;jdk&quot;) ||
className.startsWith(&quot;sun&quot;) ||
className.startsWith(&quot;com/sun&quot;) ||
className.startsWith(&quot;org/example&quot;)) {
// 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, &quot;org/example/MyProfiler&quot;, &quot;fireAllocationEvent&quot;,
&quot;(Ljava/lang/Class;)V&quot;, 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(&quot;blahblah&quot;)`,由于`out``PrintStream`的实例,因此当执行注入代码时,我们又会调用`PrintStream.println`方法,从而造成死循环。
解决这一问题的关键在于设置一个线程私有的标识位,用以区分应用代码的上下文以及注入代码的上下文。当即将执行注入代码时,我们将根据标识位判断是否已经位于注入代码的上下文之中。如果不是,则设置标识位并正常执行注入代码;如果是,则直接返回,不再执行注入代码。
字节码注入的另一个技术难点则是命名空间。举个例子不少应用程序都依赖于字节码工程库ASM。当我们的注入逻辑依赖于ASM时便有可能出现注入使用最新版本的ASM而应用程序使用较低版本的ASM的问题。
JDK本身也使用了ASM库如用来生成Lambda表达式的适配器类。JDK的做法是重命名整个ASM库为所有类的包名添加`jdk.internal`前缀。我们显然不好直接更改ASM的包名因此需要借助自定义类加载器来隔离命名空间。
除了上述技术难点之外基于字节码注入的工具还有另一个问题那便是观察者效应observer effect对所收集的数据造成的影响。
举个利用字节码注入收集每个方法的运行时间的例子。假设某个方法调用了另一个方法,而这两个方法都被注入了,那么统计被调用者运行时间的注入代码所耗费的时间,将不可避免地被计入至调用者方法的运行时间之中。
再举一个统计新建对象数目的例子。我们知道,即时编译器中的逃逸分析可能会优化掉新建对象操作,但它不会消除相应的统计操作,比如上述例子中对`fireAllocationEvent`方法的调用。在这种情况下,我们将统计没有实际发生的新建对象操作。
另一种情况则是,我们所注入的对`fireAllocationEvent`方法的调用,将影响到方法内联的决策。如果该新建对象的构造器调用恰好因此没有被内联,从而造成对象逃逸。在这种情况下,原本能够被逃逸分析优化掉的新建对象操作将无法优化,我们也将统计到原本不会发生的新建对象操作。
总而言之当使用字节码注入开发profiler时需要辩证地看待所收集的数据。它仅能表示在被注入的情况下程序的执行状态而非没有注入情况下的程序执行状态。
## 面向方面编程
说到字节码注入就不得不提面向方面编程Aspect-Oriented ProgrammingAOP。面向方面编程的核心理念是定义切入点pointcut以及通知advice。程序控制流中所有匹配该切入点的连接点joinpoint都将执行这段通知代码。
举个例子,我们定义一个指代所有方法入口的切入点,并指定在该切入点执行的“打印该方法的名字”这一通知。那么每个具体的方法入口便是一个连接点。
面向方面编程的其中一种实现方式便是字节码注入,比如[AspectJ](https://www.eclipse.org/aspectj/)。
在前面的例子中,我们也相当于使用了面向方面编程,在所有的`new`字节码之后执行了下面这样一段通知代码。
```
`MyProfiler.fireAllocationEvent(&lt;Target&gt;.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所收集到的数据并不能反映程序的真实运行状态。它所反映的是程序在被注入的情况下的执行状态。
今天的实践环节,请你思考如何注入方法出口。除了正常执行路径之外,你还需考虑异常执行路径。