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,230 @@
<audio id="audio" title="13 | Java内存模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/6e/91cd905b330d36bc6ffac2215199126e.mp3"></audio>
我们先来看一个反常识的例子。
```
int a=0, b=0;
public void method1() {
int r2 = a;
b = 1;
}
public void method2() {
int r1 = b;
a = 2;
}
```
这里我定义了两个共享变量a和b以及两个方法。第一个方法将局部变量r2赋值为a然后将共享变量b赋值为1。第二个方法将局部变量r1赋值为b然后将共享变量a赋值为2。请问r1r2的可能值都有哪些
在单线程环境下我们可以先调用第一个方法最终r1r210也可以先调用第二个方法最终为02
在多线程环境下假设这两个方法分别跑在两个不同的线程之上如果Java虚拟机在执行了任一方法的第一条赋值语句之后便切换线程那么最终结果将可能出现00的情况。
除上述三种情况之外Java语言规范第17.4小节[1]还介绍了一种看似不可能的情况12
造成这一情况的原因有三个,分别为即时编译器的重排序,处理器的乱序执行,以及内存系统的重排序。由于后两种原因涉及具体的体系架构,我们暂且放到一边。下面我先来讲一下编译器优化的重排序是怎么一回事。
首先需要说明一点即时编译器和处理器需要保证程序能够遵守as-if-serial属性。通俗地说就是在单线程情况下要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。
另外,如果两个操作之间存在数据依赖,那么即时编译器(和处理器)不能调整它们的顺序,否则将会造成程序语义的改变。
```
int a=0, b=0;
public void method1() {
int r2 = a;
b = 1;
.. // Code uses b
if (r2 == 2) {
..
}
}
```
在上面这段代码中我扩展了先前例子中的第一个方法。新增的代码会先使用共享变量b的值然后再使用局部变量r2的值。
此时,即时编译器有两种选择。
第一在一开始便将a加载至某一寄存器中并且在接下来b的赋值操作以及使用b的代码中避免使用该寄存器。第二在真正使用r2时才将a加载至寄存器中。这么一来在执行使用b的代码时我们不再霸占一个通用寄存器从而减少需要借助栈空间的情况。
```
int a=0, b=0;
public void method1() {
for (..) {
int r2 = a;
b = 1;
.. // Code uses r2 and rewrites a
}
}
```
另一个例子则是将第一个方法的代码放入一个循环中。除了原本的两条赋值语句之外我只在循环中添加了使用r2并且更新a的代码。由于对b的赋值是循环无关的即时编译器很有可能将其移出循环之前而对r2的赋值语句还停留在循环之中。
如果想要复现这两个场景,你可能需要添加大量有意义的局部变量,来给寄存器分配算法施加压力。
可以看到即时编译器的优化可能将原本字段访问的执行顺序打乱。在单线程环境下由于as-if-serial的保证我们无须担心顺序执行不可能发生的情况r1r2=12
然而在多线程情况下这种数据竞争data race的情况是有可能发生的。而且Java语言规范将其归咎于应用程序没有作出恰当的同步操作。
## Java内存模型与happens-before关系
为了让应用程序能够免于数据竞争的干扰Java 5引入了明确定义的Java内存模型。其中最为重要的一个概念便是happens-before关系。happens-before关系是用来描述两个操作的内存可见性的。如果操作X happens-before操作Y那么X的结果对于Y可见。
在同一个线程中字节码的先后顺序program order也暗含了happens-before关系在程序控制流路径中靠前的字节码happens-before靠后的字节码。然而这并不意味着前者一定在后者之前执行。实际上如果后者没有观测前者的运行结果即后者没有数据依赖于前者那么它们可能会被重排序。
除了线程内的happens-before关系之外Java内存模型还定义了下述线程间的happens-before关系。
1. 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。
1. volatile字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。
1. 线程的启动操作即Thread.starts() happens-before 该线程的第一个操作。
1. 线程的最后一个操作 happens-before 它的终止事件即其他线程通过Thread.isAlive()或Thread.join()判断该线程是否中止)。
1. 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件即被中断线程的InterruptedException异常或者第三个线程针对被中断线程的Thread.interrupted或者Thread.isInterrupted调用
1. 构造器中的最后一个操作 happens-before 析构器的第一个操作。
happens-before关系还具备传递性。如果操作X happens-before操作Y而操作Y happens-before操作Z那么操作X happens-before操作Z。
在文章开头的例子中程序没有定义任何happens-before关系仅拥有默认的线程内happens-before关系。也就是r2的赋值操作happens-before b的赋值操作r1的赋值操作happens-before a的赋值操作。
```
Thread1 Thread2
| |
b=1 |
| r1=b
| a=2
r2=a |
```
拥有happens-before关系的两对赋值操作之间没有数据依赖因此即时编译器、处理器都可能对其进行重排序。举例来说只要将b的赋值操作排在r2的赋值操作之前那么便可以按照赋值b赋值r1赋值a赋值r2的顺序得到12的结果。
那么如何解决这个问题呢答案是将a或者b设置为volatile字段。
比如说将b设置为volatile字段。假设r1能够观测到b的赋值结果1。显然这需要b的赋值操作在时钟顺序上先于r1的赋值操作。根据volatile字段的happens-before关系我们知道b的赋值操作happens-before r1的赋值操作。
```
int a=0;
volatile int b=0;
public void method1() {
int r2 = a;
b = 1;
}
public void method2() {
int r1 = b;
a = 2;
}
```
根据同一个线程中字节码顺序所暗含的happens-before关系以及happens-before关系的传递性我们可以轻易得出r2的赋值操作happens-before a的赋值操作。
这也就意味着当对a进行赋值时对r2的赋值操作已经完成了。因此在b为volatile字段的情况下程序不可能出现r1r212的情况。
由此可以看出解决这种数据竞争问题的关键在于构造一个跨线程的happens-before关系 操作X happens-before 操作Y使得操作X之前的字节码的结果对操作Y之后的字节码可见。
## Java内存模型的底层实现
在理解了Java内存模型的概念之后我们现在来看看它的底层实现。Java内存模型是通过内存屏障memory barrier来禁止重排序的。
对于即时编译器来说它会针对前面提到的每一个happens-before关系向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。
这些内存屏障会限制即时编译器的重排序操作。以volatile字段访问为例所插入的内存屏障将不允许volatile字段写操作之前的内存访问被重排序至其之后也将不允许volatile字段读操作之后的内存访问被重排序至其之前。
然后即时编译器将根据具体的底层体系架构将这些内存屏障替换成具体的CPU指令。以我们日常接触的X86_64架构来说读读、读写以及写写内存屏障是空操作no-op只有写读内存屏障会被替换成具体指令[2]。
在文章开头的例子中method1和method2之中的代码均属于先读后写假设r1和r2被存储在寄存器之中。X86_64架构的处理器并不能将读操作重排序至写操作之后具体可参考Intel Software Developer Manual Volumn 38.2.3.3小节。因此,我认为例子中的重排序必然是即时编译器造成的。
举例来说对于volatile字段即时编译器将在volatile字段的读写操作前后各插入一些内存屏障。
然而在X86_64架构上只有volatile字段写操作之后的写读内存屏障需要用具体指令来替代。HotSpot所选取的具体指令是lock add DWORD PTR [rsp],0x0而非mfence[3]。)
该具体指令的效果,可以简单理解为强制刷新处理器的写缓存。写缓存是处理器用来加速内存存储效率的一项技术。
在碰到内存写操作时处理器并不会等待该指令结束而是直接开始下一指令并且依赖于写缓存将更改的数据同步至主内存main memory之中。
强制刷新写缓存将使得当前线程写入volatile字段的值以及写缓存中已有的其他内存修改同步至主内存之中。
由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行因此可以认为其他处理器能够立即见到该volatile字段的最新值。
## 锁volatile字段final字段与安全发布
下面我来讲讲Java内存模型涉及的几个关键词。
前面提到锁操作同样具备happens-before关系。具体来说解锁操作 happens-before 之后对同一把锁的加锁操作。实际上在解锁时Java虚拟机同样需要强制刷新缓存使得当前线程所修改的内存对其他线程可见。
需要注意的是锁操作的happens-before规则的关键字是同一把锁。也就意味着如果编译器能够通过逃逸分析证明某把锁仅被同一线程持有那么它可以移除相应的加锁解锁操作。
因此也就不再强制刷新缓存。举个例子即时编译后的synchronized (new Object()) {},可能等同于空操作,而不会强制刷新缓存。
volatile字段可以看成一种轻量级的、不保证原子性的同步其性能往往优于至少不亚于锁操作。然而频繁地访问volatile字段也会因为不断地强制刷新缓存而严重影响程序的性能。
在X86_64平台上只有volatile字段的写操作会强制刷新缓存。因此理想情况下对volatile字段的使用应当多读少写并且应当只有一个线程进行写操作。
volatile字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说volatile字段的每次访问均需要直接从内存中读写。
final实例字段则涉及新建对象的发布问题。当一个对象包含final实例字段时我们希望其他线程只能看到已初始化的final实例字段。
因此即时编译器会在final字段的写操作后插入一个写写屏障以防某些优化将新建对象的发布即将实例对象写入一个共享引用中重排序至final字段的写操作之前。在X86_64平台上写写屏障是空操作。
新建对象的安全发布safe publication问题不仅仅包括final实例字段的可见性还包括其他实例字段的可见性。
当发布一个已初始化的对象时,我们希望所有已初始化的实例字段对其他线程可见。否则,其他线程可能见到一个仅部分初始化的新建对象,从而造成程序错误。这里我就不展开了。如果你感兴趣的话,可以参考这篇博客[4]。
## 总结与实践
今天我主要介绍了Java的内存模型。
Java内存模型通过定义了一系列的happens-before操作让应用程序开发者能够轻易地表达不同线程的操作之间的内存可见性。
在遵守Java内存模型的前提下即时编译器以及底层体系架构能够调整内存访问操作以达到性能优化的效果。如果开发者没有正确地利用happens-before规则那么将可能导致数据竞争。
Java内存模型是通过内存屏障来禁止重排序的。对于即时编译器来说内存屏障将限制它所能做的重排序优化。对于处理器来说内存屏障会导致缓存的刷新操作。
今天的实践环节我们来复现文章初始的例子。由于复现需要大量的线程切换事件因此我借助了OpenJDK CodeTools项目的jcstress工具[5],来对该例子进行并发情况下的压力测试。具体的命令如下所示:
```
$ mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.1.1 -DgroupId=org.sample -DartifactId=test -Dversion=1.0
$ cd test
$ echo 'package org.sample;
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.IntResult2;
@JCStressTest
@Outcome(id = {&quot;0, 0&quot;, &quot;0, 2&quot;, &quot;1, 0&quot;}, expect = Expect.ACCEPTABLE, desc = &quot;Normal outcome&quot;)
@Outcome(id = {&quot;1, 2&quot;}, expect = Expect.ACCEPTABLE_INTERESTING, desc = &quot;Abnormal outcome&quot;)
@State
public class ConcurrencyTest {
int a=0;
int b=0; //改成volatile试试
@Actor
public void method1(IntResult2 r) {
r.r2 = a;
b = 1;
}
@Actor
public void method2(IntResult2 r) {
r.r1 = b;
a = 2;
}
}' &gt; src/main/java/org/sample/ConcurrencyTest.java
$ mvn package
$ java -jar target/jcstress.jar
```
如果你想要复现非安全发布的情形,那么你可以试试这一测试用例[6]。
[1] [https://docs.oracle.com/javase/specs/jls/se10/html/jls-17.html#jls-17.4](https://docs.oracle.com/javase/specs/jls/se10/html/jls-17.html#jls-17.4)<br />
[2] [http://gee.cs.oswego.edu/dl/jmm/cookbook.html](http://gee.cs.oswego.edu/dl/jmm/cookbook.html)<br />
[3] [https://blogs.oracle.com/dave/instruction-selection-for-volatile-fences-:-mfence-vs-lock:add](https://blogs.oracle.com/dave/instruction-selection-for-volatile-fences-:-mfence-vs-lock:add)<br />
[4] [http://vlkan.com/blog/post/2014/02/14/java-safe-publication/](http://vlkan.com/blog/post/2014/02/14/java-safe-publication/)<br />
[5] [https://wiki.openjdk.java.net/display/CodeTools/jcstress](https://wiki.openjdk.java.net/display/CodeTools/jcstress)<br />
[6] [http://hg.openjdk.java.net/code-tools/jcstress/file/64f2cf32fa0a/tests-custom/src/main/java/org/openjdk/jcstress/tests/unsafe/UnsafePublication.java](http://hg.openjdk.java.net/code-tools/jcstress/file/64f2cf32fa0a/tests-custom/src/main/java/org/openjdk/jcstress/tests/unsafe/UnsafePublication.java)

View File

@@ -0,0 +1,196 @@
<audio id="audio" title="14 | Java虚拟机是怎么实现synchronized的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/bc/a1c75bf047fa0b395b36b8c3715b0abc.mp3"></audio>
在Java程序中我们可以利用synchronized关键字来对程序进行加锁。它既可以用来声明一个synchronized代码块也可以直接标记静态方法或者实例方法。
当声明synchronized代码块时编译而成的字节码将包含monitorenter和monitorexit指令。这两种指令均会消耗操作数栈上的一个引用类型的元素也就是synchronized关键字括号里的引用作为所要加锁解锁的锁对象。
```
public void foo(Object lock) {
synchronized (lock) {
lock.hashCode();
}
}
// 上面的Java代码将编译为下面的字节码
public void foo(java.lang.Object);
Code:
0: aload_1
1: dup
2: astore_2
3: monitorenter
4: aload_1
5: invokevirtual java/lang/Object.hashCode:()I
8: pop
9: aload_2
10: monitorexit
11: goto 19
14: astore_3
15: aload_2
16: monitorexit
17: aload_3
18: athrow
19: return
Exception table:
from to target type
4 11 14 any
14 17 14 any
```
我在文稿中贴了一段包含synchronized代码块的Java代码以及它所编译而成的字节码。你可能会留意到上面的字节码中包含一个monitorenter指令以及多个monitorexit指令。这是因为Java虚拟机需要确保所获得的锁在正常执行路径以及异常执行路径上都能够被解锁。
你可以根据我在介绍异常处理时介绍过的知识对照字节码和异常处理表来构造所有可能的执行路径看看在执行了monitorenter指令之后是否都有执行monitorexit指令。
当用synchronized标记方法时你会看到字节码中方法的访问标记包括ACC_SYNCHRONIZED。该标记表示在进入该方法时Java虚拟机需要进行monitorenter操作。而在退出该方法时不管是正常返回还是向调用者抛异常Java虚拟机均需要进行monitorexit操作。
```
public synchronized void foo(Object lock) {
lock.hashCode();
}
// 上面的Java代码将编译为下面的字节码
public synchronized void foo(java.lang.Object);
descriptor: (Ljava/lang/Object;)V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=1, locals=2, args_size=2
0: aload_1
1: invokevirtual java/lang/Object.hashCode:()I
4: pop
5: return
```
这里monitorenter和monitorexit操作所对应的锁对象是隐式的。对于实例方法来说这两个操作对应的锁对象是this对于静态方法来说这两个操作对应的锁对象则是所在类的Class实例。
关于monitorenter和monitorexit的作用我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行monitorenter时如果目标锁对象的计数器为0那么说明它没有被其他线程所持有。在这个情况下Java虚拟机会将该锁对象的持有线程设置为当前线程并且将其计数器加1。
在目标锁对象的计数器不为0的情况下如果锁对象的持有线程是当前线程那么Java虚拟机可以将其计数器加1否则需要等待直至持有线程释放该锁。
当执行monitorexit时Java虚拟机则需将锁对象的计数器减1。当计数器减为0时那便代表该锁已经被释放掉了。
之所以采用这种计数器的方式是为了允许同一个线程重复获取同一把锁。举个例子如果一个Java类中拥有多个synchronized方法那么这些方法之间的相互调用不管是直接的还是间接的都会涉及对同一把锁的重复加锁操作。因此我们需要设计这么一个可重入的特性来避免编程里的隐式约束。
说完抽象的锁算法下面我们便来介绍HotSpot虚拟机中具体的锁实现。
## 重量级锁
重量级锁是Java虚拟机中最为基础的锁实现。在这种状态下Java虚拟机会阻塞加锁失败的线程并且在目标锁被释放的时候唤醒这些线程。
Java线程的阻塞以及唤醒都是依靠操作系统来完成的。举例来说对于符合posix接口的操作系统如macOS和绝大部分的Linux上述操作是通过pthread的互斥锁mutex来实现的。此外这些操作将涉及系统调用需要从操作系统的用户态切换至内核态其开销非常之大。
为了尽量避免昂贵的线程阻塞、唤醒操作Java虚拟机会在线程进入阻塞状态之前以及被唤醒后竞争不到锁的情况下进入自旋状态在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了那么当前线程便无须进入阻塞状态而是直接获得这把锁。
与线程阻塞相比,自旋状态可能会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。
我们可以用等红绿灯作为例子。Java线程的阻塞相当于熄火停车而自旋状态相当于怠速停车。如果红灯的等待时间非常长那么熄火停车相对省油一些如果红灯的等待时间非常短比如说我们在synchronized代码块里只做了一个整型加法那么在短时间内锁肯定会被释放出来因此怠速停车更加合适。
然而对于Java虚拟机来说它并不能看到红灯的剩余时间也就没办法根据等待时间的长短来选择自旋还是阻塞。Java虚拟机给出的方案是自适应自旋根据以往自旋等待时是否能够获得锁来动态调整自旋的时间循环数目
就我们的例子来说,如果之前不熄火等到了绿灯,那么这次不熄火的时间就长一点;如果之前不熄火没等到绿灯,那么这次不熄火的时间就短一点。
自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。
## 轻量级锁
你可能见到过深夜的十字路口,四个方向都闪黄灯的情况。由于深夜十字路口的车辆来往可能比较少,如果还设置红绿灯交替,那么很有可能出现四个方向仅有一辆车在等红灯的情况。
因此,红绿灯可能被设置为闪黄灯的情况,代表车辆可以自由通过,但是司机需要注意观察(个人理解,实际意义请咨询交警部门)。
Java虚拟机也存在着类似的情形多个线程在不同的时间段请求同一把锁也就是说没有锁竞争。针对这种情形Java虚拟机采用了轻量级锁来避免重量级锁的阻塞以及唤醒。
在介绍轻量级锁的原理之前我们先来了解一下Java虚拟机是怎么区分轻量级锁和重量级锁的。
(你可以参照[HotSpot Wiki](https://wiki.openjdk.java.net/display/HotSpot/Synchronization)里这张图阅读。)
在对象内存布局那一篇中我曾经介绍了对象头中的标记字段mark word。它的最后两位便被用来表示该对象的锁状态。其中00代表轻量级锁01代表无锁或偏向锁10代表重量级锁11则跟垃圾回收算法的标记有关。
当进行加锁操作时Java虚拟机会判断是否已经是重量级锁。如果不是它会在当前线程的当前栈桢中划出一块空间作为该锁的锁记录并且将锁对象的标记字段复制到该锁记录中。
然后Java虚拟机会尝试用CAScompare-and-swap操作替换锁对象的标记字段。这里解释一下CAS是一个原子操作它会比较目标地址的值是否和期望值相等如果相等则替换为一个新的值。
假设当前锁对象的标记字段为X…XYZJava虚拟机会比较该字段是否为X…X01。如果是则替换为刚才分配的锁记录的地址。由于内存对齐的缘故它的最后两位为00。此时该线程已成功获得这把锁可以继续执行了。
如果不是X…X01那么有两种可能。第一该线程重复获取同一把锁。此时Java虚拟机会将锁记录清零以代表该锁被重复获取。第二其他线程持有该锁。此时Java虚拟机会将这把锁膨胀为重量级锁并且阻塞当前线程。
当进行解锁操作时如果当前锁记录你可以将一个线程的所有锁记录想象成一个栈结构每次加锁压入一条锁记录解锁弹出一条锁记录当前锁记录指的便是栈顶的锁记录的值为0则代表重复进入同一把锁直接返回即可。
否则Java虚拟机会尝试用CAS操作比较锁对象的标记字段的值是否为当前锁记录的地址。如果是则替换为锁记录中的值也就是锁对象原本的标记字段。此时该线程已经成功释放这把锁。
如果不是则意味着这把锁已经被膨胀为重量级锁。此时Java虚拟机会进入重量级锁的释放过程唤醒因竞争该锁而被阻塞了的线程。
## 偏向锁
如果说轻量级锁针对的情况很乐观,那么接下来的偏向锁针对的情况则更加乐观:从始至终只有一个线程请求某一把锁。
这就好比你在私家庄园里装了个红绿灯,并且庄园里只有你在开车。偏向锁的做法便是在红绿灯处识别来车的车牌号。如果匹配到你的车牌号,那么直接亮绿灯。
具体来说在线程进行加锁时如果该锁对象支持偏向锁那么Java虚拟机会通过CAS操作将当前线程的地址记录在锁对象的标记字段之中并且将标记字段的最后三位设置为101。
在接下来的运行过程中每当有线程请求这把锁Java虚拟机只需判断锁对象标记字段中最后三位是否为101是否包含当前线程的地址以及epoch值是否和锁对象的类的epoch值相同。如果都满足那么当前线程持有该偏向锁可以直接返回。
这里的epoch值是一个什么概念呢
我们先从偏向锁的撤销讲起。当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时而且epoch值相等如若不等那么当前线程可以将该锁重偏向至自己Java虚拟机需要撤销该偏向锁。这个撤销过程非常麻烦它要求持有偏向锁的线程到达安全点再将偏向锁替换成轻量级锁。
如果某一类锁对象的总撤销数超过了一个阈值对应Java虚拟机参数-XX:BiasedLockingBulkRebiasThreshold默认为20那么Java虚拟机会宣布这个类的偏向锁失效。
具体的做法便是在每个类中维护一个epoch值你可以理解为第几代偏向锁。当设置偏向锁时Java虚拟机需要将该epoch值复制到锁对象的标记字段中。
在宣布某个类的偏向锁失效时Java虚拟机实则将该类的epoch值加1表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的epoch值。
为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁Java虚拟机需要遍历所有线程的Java栈找出该类已加锁的实例并且将它们标记字段中的epoch值加1。该操作需要所有线程处于安全点状态。
如果总撤销数超过另一个阈值对应Java虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold默认值为40那么Java虚拟机会认为这个类已经不再适合偏向锁。此时Java虚拟机会撤销该类实例的偏向锁并且在之后的加锁过程中直接为该类实例设置轻量级锁。
## 总结与实践
今天我介绍了Java虚拟机中synchronized关键字的实现按照代价由高至低可分为重量级锁、轻量级锁和偏向锁三种。
重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。Java虚拟机采取了自适应自旋来避免线程在面对非常小的synchronized代码块时仍会被阻塞、唤醒的情况。
轻量级锁采用CAS操作将锁对象的标记字段替换为一个指针指向当前线程栈上的一块空间存储着锁对象原本的标记字段。它针对的是多个线程在不同时间段申请同一把锁的情况。
偏向锁只会在第一次请求时采用CAS操作在锁对象的标记字段中记录下当前线程的地址。在之后的运行过程中持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。
今天的实践环节我们来验证一个坊间传闻调用Object.hashCode()会关闭该对象的偏向锁[1]。
你可以采用参数-XX:+PrintBiasedLockingStatistics来打印各类锁的个数。由于C2使用的是另外一个参数-XX:+PrintPreciseBiasedLockingStatistics因此你可以限制Java虚拟机仅使用C1来即时编译对应参数-XX:TieredStopAtLevel=1
1. 通过参数-XX:+UseBiasedLocking比较开关偏向锁时的输出结果。
1. 在main方法的循环前添加lock.hashCode调用并查看输出结果。
1. 在Lock类中复写hashCode方法并查看输出结果。
1. 在main方法的循环前添加System.identityHashCode调用并查看输出结果。
```
// Run with -XX:+UnlockDiagnosticVMOptions -XX:+PrintBiasedLockingStatistics -XX:TieredStopAtLevel=1
public class SynchronizedTest {
static Lock lock = new Lock();
static int counter = 0;
public static void foo() {
synchronized (lock) {
counter++;
}
}
public static void main(String[] args) throws InterruptedException {
// lock.hashCode(); // Step 2
// System.identityHashCode(lock); // Step 4
for (int i = 0; i &lt; 1_000_000; i++) {
foo();
}
}
static class Lock {
// @Override public int hashCode() { return 0; } // Step 3
}
}
```
[1] [https://blogs.oracle.com/dave/biased-locking-in-hotspot](https://blogs.oracle.com/dave/biased-locking-in-hotspot)

View File

@@ -0,0 +1,309 @@
<audio id="audio" title="15 | Java语法糖与Java编译器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/62/ee/6238248e565e72304593d51aaeba62ee.mp3"></audio>
在前面的篇章中我们多次提到了Java语法和Java字节码的差异之处。这些差异之处都是通过Java编译器来协调的。今天我们便来列举一下Java编译器的协调工作。
## 自动装箱与自动拆箱
首先要提到的便是Java的自动装箱auto-boxing和自动拆箱auto-unboxing
我们知道Java语言拥有8个基本类型每个基本类型都有对应的包装wrapper类型。
之所以需要包装类型是因为许多Java核心类库的API都是面向对象的。举个例子Java核心类库中的容器类就只支持引用类型。
当需要一个能够存储数值的容器类时,我们往往定义一个存储包装类对象的容器。
对于基本类型的数值来说我们需要先将其转换为对应的包装类再存入容器之中。在Java程序中这个转换可以是显式也可以是隐式的后者正是Java中的自动装箱。
```
public int foo() {
ArrayList&lt;Integer&gt; list = new ArrayList&lt;&gt;();
list.add(0);
int result = list.get(0);
return result;
}
```
以上图中的Java代码为例。我构造了一个Integer类型的ArrayList并且向其中添加一个int值0。然后我会获取该ArrayList的第0个元素并作为int值返回给调用者。这段代码对应的Java字节码如下所示
```
public int foo();
Code:
0: new java/util/ArrayList
3: dup
4: invokespecial java/util/ArrayList.&quot;&lt;init&gt;&quot;:()V
7: astore_1
8: aload_1
9: iconst_0
10: invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
16: pop
17: aload_1
18: iconst_0
19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
22: checkcast java/lang/Integer
25: invokevirtual java/lang/Integer.intValue:()I
28: istore_2
29: iload_2
30: ireturn
```
当向泛型参数为Integer的ArrayList添加int值时便需要用到自动装箱了。在上面字节码偏移量为10的指令中我们调用了Integer.valueOf方法将int类型的值转换为Integer类型再存储至容器类中。
```
public static Integer valueOf(int i) {
if (i &gt;= IntegerCache.low &amp;&amp; i &lt;= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
```
这是Integer.valueOf的源代码。可以看到当请求的int值在某个范围内时我们会返回缓存了的Integer对象而当所请求的int值在范围之外时我们则会新建一个Integer对象。
在介绍反射的那一篇中我曾经提到参数java.lang.Integer.IntegerCache.high。这个参数将影响这里面的IntegerCache.high。
也就是说我们可以通过配置该参数扩大Integer缓存的范围。Java虚拟机参数-XX:+AggressiveOpts也会将IntegerCache.high调整至20000。
奇怪的是Java并不支持对IntegerCache.low的更改也就是说对于小于-128的整数我们无法直接使用由Java核心类库所缓存的Integer对象。
```
25: invokevirtual java/lang/Integer.intValue:()I
```
当从泛型参数为Integer的ArrayList取出元素时我们得到的实际上也是Integer对象。如果应用程序期待的是一个int值那么就会发生自动拆箱。
在我们的例子中自动拆箱对应的是字节码偏移量为25的指令。该指令将调用Integer.intValue方法。这是一个实例方法直接返回Integer对象所存储的int值。
## 泛型与类型擦除
你可能已经留意到了在前面例子生成的字节码中往ArrayList中添加元素的add方法所接受的参数类型是Object而从ArrayList中获取元素的get方法其返回类型同样也是Object。
前者还好但是对于后者在字节码中我们需要进行向下转换将所返回的Object强制转换为Integer方能进行接下来的自动拆箱。
```
13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
...
19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
22: checkcast java/lang/Integer
```
之所以会出现这种情况是因为Java泛型的类型擦除。这是个什么概念呢简单地说那便是Java程序里的泛型信息在Java虚拟机里全部都丢失了。这么做主要是为了兼容引入泛型之前的代码。
当然并不是每一个泛型参数被擦除类型后都会变成Object类。对于限定了继承类的泛型参数经过类型擦除后所有的泛型参数都将变成所限定的继承类。也就是说Java编译器将选取该泛型所能指代的所有类中层次最高的那个作为替换泛型的类。
```
class GenericTest&lt;T extends Number&gt; {
T foo(T t) {
return t;
}
}
```
举个例子在上面这段Java代码中我定义了一个T extends Number的泛型参数。它所对应的字节码如下所示。可以看到foo方法的方法描述符所接收参数的类型以及返回类型都为Number。方法描述符是Java虚拟机识别方法调用的目标方法的关键。
```
T foo(T);
descriptor: (Ljava/lang/Number;)Ljava/lang/Number;
flags: (0x0000)
Code:
stack=1, locals=2, args_size=2
0: aload_1
1: areturn
Signature: (TT;)TT;
```
不过字节码中仍存在泛型参数的信息如方法声明里的T foo(T)以及方法签名Signature中的“(TT;)TT;”。这类信息主要由Java编译器在编译他类时使用。
既然泛型会被类型擦除,那么我们还有必要用它吗?
我认为是有必要的。Java编译器可以根据泛型参数判断程序中的语法是否正确。举例来说尽管经过类型擦除后ArrayList.add方法所接收的参数是Object类型但是往泛型参数为Integer类型的ArrayList中添加字符串对象Java编译器是会报错的。
```
ArrayList&lt;Integer&gt; list = new ArrayList&lt;&gt;();
list.add(&quot;0&quot;); // 编译出错
```
## 桥接方法
泛型的类型擦除带来了不少问题。其中一个便是方法重写。在第四篇的课后实践中,我留了这么一段代码:
```
class Merchant&lt;T extends Customer&gt; {
public double actionPrice(T customer) {
return 0.0d;
}
}
class VIPOnlyMerchant extends Merchant&lt;VIP&gt; {
@Override
public double actionPrice(VIP customer) {
return 0.0d;
}
}
```
VIPOnlyMerchant中的actionPrice方法是符合Java语言的方法重写的毕竟都使用@Override来注解了。然而,经过类型擦除后,父类的方法描述符为(LCustomer;)D而子类的方法描述符为(LVIP;)D。这显然不符合Java虚拟机关于方法重写的定义。
为了保证编译而成的Java字节码能够保留重写的语义Java编译器额外添加了一个桥接方法。该桥接方法在字节码层面重写了父类的方法并将调用子类的方法。
```
class VIPOnlyMerchant extends Merchant&lt;VIP&gt;
...
public double actionPrice(VIP);
descriptor: (LVIP;)D
flags: (0x0001) ACC_PUBLIC
Code:
0: dconst_0
1: dreturn
public double actionPrice(Customer);
descriptor: (LCustomer;)D
flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
0: aload_0
1: aload_1
2: checkcast class VIP
5: invokevirtual actionPrice:(LVIP;)D
8: dreturn
// 这个桥接方法等同于
public double actionPrice(Customer customer) {
return actionPrice((VIP) customer);
}
```
在我们的例子中VIPOnlyMerchant类将包含一个桥接方法actionPrice(Customer)它重写了父类的同名同方法描述符的方法。该桥接方法将传入的Customer参数强制转换为VIP类型再调用原本的actionPrice(VIP)方法。
当一个声明类型为Merchant实际类型为VIPOnlyMerchant的对象调用actionPrice方法时字节码里的符号引用指向的是Merchant.actionPrice(Customer)方法。Java虚拟机将动态绑定至VIPOnlyMerchant类的桥接方法之中并且调用其actionPrice(VIP)方法。
需要注意的是在javap的输出中该桥接方法的访问标识符除了代表桥接方法的ACC_BRIDGE之外还有ACC_SYNTHETIC。它表示该方法对于Java源代码来说是不可见的。当你尝试通过传入一个声明类型为Customer的对象作为参数调用VIPOnlyMerchant类的actionPrice方法时Java编译器会报错并且提示参数类型不匹配。
```
Customer customer = new VIP();
new VIPOnlyMerchant().actionPrice(customer); // 编译出错
```
当然,如果你实在想要调用这个桥接方法,那么你可以选择使用反射机制。
```
class Merchant {
public Number actionPrice(Customer customer) {
return 0;
}
}
class NaiveMerchant extends Merchant {
@Override
public Double actionPrice(Customer customer) {
return 0.0D;
}
}
```
除了前面介绍的泛型重写会生成桥接方法之外如果子类定义了一个与父类参数类型相同的方法其返回类型为父类方法返回类型的子类那么Java编译器也会为其生成桥接方法。
```
class NaiveMerchant extends Merchant
public java.lang.Double actionPrice(Customer);
descriptor: (LCustomer;)Ljava/lang/Double;
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: dconst_0
1: invokestatic Double.valueOf:(D)Ljava/lang/Double;
4: areturn
public java.lang.Number actionPrice(Customer);
descriptor: (LCustomer;)Ljava/lang/Number;
flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: invokevirtual actionPrice:(LCustomer;)Ljava/lang/Double;
5: areturn
```
我之前曾提到过class文件里允许出现两个同名、同参数类型但是不同返回类型的方法。这里的原方法和桥接方法便是其中一个例子。由于该桥接方法同样标注了ACC_SYNTHETIC因此当在Java程序中调用NaiveMerchant.actionPrice时我们只会调用到原方法。
## 其他语法糖
在前面的篇章中我已经介绍过了变长参数、try-with-resources以及在同一catch代码块中捕获多种异常等语法糖。下面我将列举另外两个常见的语法糖。
foreach循环允许Java程序在for循环里遍历数组或者Iterable对象。对于数组来说foreach循环将从0开始逐一访问数组中的元素直至数组的末尾。其等价的代码如下面所示
```
public void foo(int[] array) {
for (int item : array) {
}
}
// 等同于
public void bar(int[] array) {
int[] myArray = array;
int length = myArray.length;
for (int i = 0; i &lt; length; i++) {
int item = myArray[i];
}
}
```
对于Iterable对象来说foreach循环将调用其iterator方法并且用它的hasNext以及next方法来遍历该Iterable对象中的元素。其等价的代码如下面所示
```
public void foo(ArrayList&lt;Integer&gt; list) {
for (Integer item : list) {
}
}
// 等同于
public void bar(ArrayList&lt;Integer&gt; list) {
Iterator&lt;Integer&gt; iterator = list.iterator();
while (iterator.hasNext()) {
Integer item = iterator.next();
}
}
```
字符串switch编译而成的字节码看起来非常复杂但实际上就是一个哈希桶。由于每个case所截获的字符串都是常量值因此Java编译器会将原来的字符串switch转换为int值switch比较所输入的字符串的哈希值。
由于字符串哈希值很容易发生碰撞因此我们还需要用String.equals逐个比较相同哈希值的字符串。
如果你感兴趣的话可以自己利用javap分析字符串switch编译而成的字节码。
## 总结与实践
今天我主要介绍了Java编译器对几个语法糖的处理。
基本类型和其包装类型之间的自动转换,也就是自动装箱、自动拆箱,是通过加入[Wrapper].valueOf如Integer.valueOf以及[Wrapper].[primitive]Value如Integer.intValue方法调用来实现的。
Java程序中的泛型信息会被擦除。具体来说Java编译器将选取该泛型所能指代的所有类中层次最高的那个作为替换泛型的具体类。
由于Java语义与Java字节码中关于重写的定义并不一致因此Java编译器会生成桥接方法作为适配器。此外我还介绍了foreach循环以及字符串switch的编译。
今天的实践环节你可以探索一下Java 10的var关键字是否保存了泛型信息是否支持自动装拆箱
```
public void foo() {
var value = 1;
var list = new ArrayList&lt;Integer&gt;();
list.add(value);
// list.add(&quot;1&quot;); 这一句能够编译吗?
}
```

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="16 | 即时编译(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/81/5986deee0bf5ea70e37288d0a210e381.mp3"></audio>
在专栏的第一篇中我曾经简单地介绍过即时编译。这是一项用来提升应用程序运行效率的技术。通常而言代码会先被Java虚拟机解释执行之后反复执行的热点代码则会被即时编译成为机器码直接运行在底层硬件之上。
今天我们便来详细剖析一下Java虚拟机中的即时编译。
## 分层编译模式
HotSpot虚拟机包含多个即时编译器C1、C2和Graal。
其中Graal是一个实验性质的即时编译器可以通过参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用并且替换C2。
在Java 7以前我们需要根据程序的特性选择对应的即时编译器。对于执行时间较短的或者对启动性能有要求的程序我们采用编译效率较快的C1对应参数-client。
对于执行时间较长的或者对峰值性能有要求的程序我们采用生成代码执行效率较快的C2对应参数-server。
Java 7引入了分层编译对应参数-XX:+TieredCompilation的概念综合了C1的启动性能优势和C2的峰值性能优势。
分层编译将Java虚拟机的执行状态分为了五个层次。为了方便阐述我用“C1代码”来指代由C1生成的机器码“C2代码”来指代由C2生成的机器码。五个层级分别是
1. 解释执行;
1. 执行不带profiling的C1代码
1. 执行仅带方法调用次数以及循环回边执行次数profiling的C1代码
1. 执行带所有profiling的C1代码
1. 执行C2代码。
通常情况下C2代码的执行效率要比C1代码的高出30%以上。然而对于C1代码的三种状态按执行效率从高至低则是1层 &gt; 2层 &gt; 3层。
其中1层的性能比2层的稍微高一些而2层的性能又比3层高出30%。这是因为profiling越多其额外的性能开销越大。
这里解释一下profiling是指在程序执行过程中收集能够反映程序执行状态的数据。这里所收集的数据我们称之为程序的profile。
你可能已经接触过许许多多的profiler例如JDK附带的hprof。这些profiler大多通过注入instrumentation或者JVMTI事件来实现的。Java虚拟机也内置了profiling。我会在下一篇中具体介绍Java虚拟机的profiling都在做些什么。
在5个层次的执行状态中1层和4层为终止状态。当一个方法被终止状态编译过后如果编译后的代码并没有失效那么Java虚拟机是不会再次发出该方法的编译请求的。
<img src="https://static001.geekbang.org/resource/image/c5/e5/c503010c157b7db7596893633b624fe5.png" alt="" /><br />
不同的编译路径,图片来源于我之前一篇[介绍Graal的博客](https://zhengyudi.github.io/2018/03/20/graal-intro/)。
这里我列举了4个不同的编译路径[Igor的演讲](http://cr.openjdk.java.net/~iveresov/tiered/Tiered.pdf)列举了更多的编译路径。通常情况下热点方法会被3层的C1编译然后再被4层的C2编译。
如果方法的字节码数目比较少如getter/setter而且3层的profiling没有可收集的数据。
那么Java虚拟机断定该方法对于C1代码和C2代码的执行效率相同。在这种情况下Java虚拟机会在3层编译之后直接选择用1层的C1编译。由于这是一个终止状态因此Java虚拟机不会继续用4层的C2编译。
在C1忙碌的情况下Java虚拟机在解释执行过程中对程序进行profiling而后直接由4层的C2编译。在C2忙碌的情况下方法会被2层的C1编译然后再被3层的C1编译以减少方法在3层的执行时间。
Java 8默认开启了分层编译。不管是开启还是关闭分层编译原本用来选择即时编译器的参数-client和-server都是无效的。当关闭分层编译的情况下Java虚拟机将直接采用C2。
如果你希望只是用C1那么你可以在打开分层编译的情况下使用参数-XX:TieredStopAtLevel=1。在这种情况下Java虚拟机会在解释执行之后直接由1层的C1进行编译。
## 即时编译的触发
Java虚拟机是根据方法的调用次数以及循环回边的执行次数来触发即时编译的。前面提到Java虚拟机在0层、2层和3层执行状态时进行profiling其中就包含方法的调用次数和循环回边的执行次数。
这里的循环回边是一个控制流图中的概念。在字节码中,我们可以简单理解为往回跳转的指令。(注意,这并不一定符合循环回边的定义。)
```
public static void foo(Object obj) {
int sum = 0;
for (int i = 0; i &lt; 200; i++) {
sum += i;
}
}
```
举例来说上面这段代码将被编译为下面的字节码。其中偏移量为18的字节码将往回跳至偏移量为7的字节码中。在解释执行时每当运行一次该指令Java虚拟机便会将该方法的循环回边计数器加1。
```
public static void foo(java.lang.Object);
Code:
0: iconst_0
1: istore_1
2: iconst_0
3: istore_2
4: goto 14
7: iload_1
8: iload_2
9: iadd
10: istore_1
11: iinc 2, 1
14: iload_2
15: sipush 200
18: if_icmplt 7
21: return
```
在即时编译过程中我们会识别循环的头部和尾部。在上面这段字节码中循环的头部是偏移量为14的字节码尾部为偏移量为11的字节码。
循环尾部到循环头部的控制流边就是真正意义上的循环回边。也就是说C1将在这个位置插入增加循环回边计数器的代码。
解释执行和C1代码中增加循环回边计数器的位置并不相同但这并不会对程序造成影响。
实际上Java虚拟机并不会对这些计数器进行同步操作因此收集而来的执行次数也并非精确值。不管如何即时编译的触发并不需要非常精确的数值。只要该数值足够大就能说明对应的方法包含热点代码。
具体来说,在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数-XX:CompileThreshold指定的阈值时使用C1时该值为1500使用C2时该值为10000便会触发即时编译。
当启用分层编译时Java虚拟机将不再采用由参数-XX:CompileThreshold指定的阈值该参数失效而是使用另一套阈值系统。在这套系统中阈值的大小是动态调整的。
所谓的动态调整其实并不复杂在比较阈值时Java虚拟机会将阈值与某个系数s相乘。该系数与当前待编译的方法数目成正相关与编译线程的数目成负相关。
```
系数的计算方法为:
s = queue_size_X / (TierXLoadFeedback * compiler_count_X) + 1
其中X是执行层次可取3或者4
queue_size_X是执行层次为X的待编译方法的数目
TierXLoadFeedback是预设好的参数其中Tier3LoadFeedback为5Tier4LoadFeedback为3
compiler_count_X是层次X的编译线程数目。
```
在64位Java虚拟机中默认情况下编译线程的总数目是根据处理器数量来调整的对应参数-XX:+CICompilerCountPerCPU默认为true当通过参数-XX:+CICompilerCount=N强制设定总编译线程数目时CICompilerCountPerCPU将被设置为false
Java虚拟机会将这些编译线程按照1:2的比例分配给C1和C2至少各为1个。举个例子对于一个四核机器来说总的编译线程数目为3其中包含一个C1编译线程和两个C2编译线程。
```
对于四核及以上的机器,总的编译线程的数目为:
n = log2(N) * log2(log2(N)) * 3 / 2
其中N为CPU核心数目。
```
当启用分层编译时,即时编译具体的触发条件如下。
```
当方法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数或者当方法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时便会触发X层即时编译。
触发条件为:
i &gt; TierXInvocationThreshold * s || (i &gt; TierXMinInvocationThreshold * s &amp;&amp; i + b &gt; TierXCompileThreshold * s)
```
其中i为调用次数b为循环回边次数。
## OSR编译
可以看到决定一个方法是否为热点代码的因素有两个方法的调用次数、循环回边的执行次数。即时编译便是根据这两个计数器的和来触发的。为什么Java虚拟机需要维护两个不同的计数器呢
实际上除了以方法为单位的即时编译之外Java虚拟机还存在着另一种以循环为单位的即时编译叫做On-Stack-ReplacementOSR编译。循环回边计数器便是用来触发这种类型的编译的。
OSR实际上是一种技术它指的是在程序执行过程中动态地替换掉Java方法栈桢从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。事实上去优化deoptimization采用的技术也可以称之为OSR。
在不启用分层编译的情况下触发OSR编译的阈值是由参数-XX:CompileThreshold指定的阈值的倍数。
该倍数的计算方法为:
```
(OnStackReplacePercentage - InterpreterProfilePercentage)/100
其中-XX:InterpreterProfilePercentage的默认值为33当使用C1时-XX:OnStackReplacePercentage为933当使用C2时为140。
```
也就是说默认情况下C1的OSR编译的阈值为13500而C2的为10700。
在启用分层编译的情况下触发OSR编译的阈值则是由参数-XX:TierXBackEdgeThreshold指定的阈值乘以系数。
OSR编译在正常的应用程序中并不多见。它只在基准测试时比较常见因此并不需要过多了解。
## 总结与实践
今天我详细地介绍了Java虚拟机中的即时编译。
从Java 8开始Java虚拟机默认采用分层编译的方式。它将执行分为五个层次分为为0层解释执行1层执行没有profiling的C1代码2层执行部分profiling的C1代码3层执行全部profiling的C1代码和4层执行C2代码。
通常情况下方法会首先被解释执行然后被3层的C1编译最后被4层的C2编译。
即时编译是由方法调用计数器和循环回边计数器触发的。在使用分层编译的情况下,触发编译的阈值是根据当前待编译的方法数目动态调整的。
OSR是一种能够在非方法入口处进行解释执行和编译后代码之间切换的技术。OSR编译可以用来解决单次调用方法包含热循环的性能优化问题。
今天的实践环节,你可以使用参数-XX:+PrintCompilation来打印你项目中的即时编译情况。
```
88 15 3 CompilationTest::foo (16 bytes)
88 16 3 java.lang.Integer::valueOf (32 bytes)
88 17 4 CompilationTest::foo (16 bytes)
88 18 4 java.lang.Integer::valueOf (32 bytes)
89 15 3 CompilationTest::foo (16 bytes) made not entrant
89 16 3 java.lang.Integer::valueOf (32 bytes) made not entrant
90 19 % 3 CompilationTest::main @ 5 (33 bytes)
```
简单解释一下该参数的输出第一列是时间第二列是Java虚拟机维护的编译ID。
接下来是一系列标识,包括%是否OSR编译s是否synchronized方法是否包含异常处理器b是否阻塞了应用线程可了解一下参数-Xbatchn是否为native方法。再接下来则是编译层次以及方法名。如果是OSR编译那么方法名后面还会跟着@以及循环所在的字节码
当发生去优化时你将看到之前出现过的编译不过被标记了“made not entrant&quot;。它表示该方法不能再被进入。
当Java虚拟机检测到所有的线程都退出该编译后的“made not entrant”时会将该方法标记为“made zombie”此时可以回收这块代码所占据的空间了。<br />

View File

@@ -0,0 +1,305 @@
<audio id="audio" title="17 | 即时编译(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e2/54/e29370255eec861f239912db5b3bb754.mp3"></audio>
今天我们来继续讲解Java虚拟机中的即时编译。
## Profiling
上篇提到分层编译中的0层、2层和3层都会进行profiling收集能够反映程序执行状态的数据。其中最为基础的便是方法的调用次数以及循环回边的执行次数。它们被用于触发即时编译。
此外0层和3层还会收集用于4层C2编译的数据比如说分支跳转字节码的分支profilebranch profile包括跳转次数和不跳转次数以及非私有实例方法调用指令、强制类型转换checkcast指令、类型测试instanceof指令和引用类型的数组存储aastore指令的类型profilereceiver type profile
分支profile和类型profile的收集将给应用程序带来不少的性能开销。据统计正是因为这部分额外的profiling使得3层C1代码的性能比2层C1代码的低30%。
在通常情况下我们不会在解释执行过程中收集分支profile以及类型profile。只有在方法触发C1编译后Java虚拟机认为该方法有可能被C2编译方才在该方法的C1代码中收集这些profile。
只要在比较极端的情况下例如等待C1编译的方法数目太多时Java虚拟机才会开始在解释执行过程中收集这些profile。
那么这些耗费巨大代价收集而来的profile具体有什么作用呢
答案是C2可以根据收集得到的数据进行猜测假设接下来的执行同样会按照所收集的profile进行从而作出比较激进的优化。
## 基于分支profile的优化
举个例子下面这段代码中包含两个条件判断。第一个条件判断将测试所输入的boolean值。
如果为true则将局部变量v设置为所输入的int值。如果为false则将所输入的int值经过一番运算之后再存入局部变量v之中。
第二个条件判断则测试局部变量v是否和所输入的int值相等。如果相等则返回0。如果不等则将局部变量v经过一番运算之后再将之返回。显然当所输入的boolean值为true的情况下这段代码将返回0。
```
public static int foo(boolean f, int in) {
int v;
if (f) {
v = in;
} else {
v = (int) Math.sin(in);
}
if (v == in) {
return 0;
} else {
return (int) Math.cos(v);
}
}
// 编译而成的字节码:
public static int foo(boolean, int);
Code:
0: iload_0
1: ifeq 9
4: iload_1
5: istore_2
6: goto 16
9: iload_1
10: i2d
11: invokestatic java/lang/Math.sin:(D)D
14: d2i
15: istore_2
16: iload_2
17: iload_1
18: if_icmpne 23
21: iconst_0
22: ireturn
23: iload_2
24: i2d
25: invokestatic java/lang/Math.cos:(D)D
28: d2i
29: ireturn
```
<img src="https://static001.geekbang.org/resource/image/53/0e/53d57c8c7645d8e2292a08ee97557b0e.png" alt="" />
假设应用程序调用该方法时所传入的boolean值皆为true。那么偏移量为1以及偏移量为18的条件跳转指令所对应的分支profile中跳转的次数都为0。
<img src="https://static001.geekbang.org/resource/image/90/cc/90eb47e4c9b202c45804ef7383a9d6cc.png" alt="" />
C2可以根据这两个分支profile作出假设在接下来的执行过程中这两个条件跳转指令仍旧不会发生跳转。基于这个假设C2便不再编译这两个条件跳转语句所对应的false分支了。
我们暂且不管当假设错误的时候会发生什么先来看一看剩下来的代码。经过“剪枝”之后在第二个条件跳转处v的值只有可能为所输入的int值。因此该条件跳转可以进一步被优化掉。最终的结果是在第一个条件跳转之后C2代码将直接返回0。
<img src="https://static001.geekbang.org/resource/image/d9/9a/d997a7ea02b7f85136974a54dce7589a.png" alt="" />
这里我打印了C2的编译结果。可以看到在地址为2cee的指令处进行过一次比较之后该机器码便直接返回0。
```
Compiled method (c2) 95 16 4 CompilationTest::foo (30 bytes)
...
CompilationTest.foo [0x0000000104fb2ce0, 0x0000000104fb2d38] 88 bytes
[Entry Point]
[Verified Entry Point]
[Constants]
# {method} {0x000000012629e380} 'foo' '(ZI)I' in 'CompilationTest'
# parm0: rsi = boolean
# parm1: rdx = int
# [sp+0x30] (sp of caller)
0x0000000104fb2ce0: mov DWORD PTR [rsp-0x14000],eax
0x0000000104fb2ce7: push rbp
0x0000000104fb2ce8: sub rsp,0x20
0x0000000104fb2cec: test esi,esi
0x0000000104fb2cee: je 0x0000000104fb2cfe // 跳转至?
0x0000000104fb2cf0: xor eax,eax // 将返回值设置为0
0x0000000104fb2cf2: add rsp,0x20
0x0000000104fb2cf6: pop rbp
0x0000000104fb2cf7: test DWORD PTR [rip+0xfffffffffca32303],eax // safepoint
0x0000000104fb2cfd: ret
...
```
总结一下根据条件跳转指令的分支profile即时编译器可以将从未执行过的分支剪掉以避免编译这些很有可能不会用到的代码从而节省编译时间以及部署代码所要消耗的内存空间。此外“剪枝”将精简程序的数据流从而触发更多的优化。
在现实中分支profile出现仅跳转或者仅不跳转的情况并不多见。当然即时编译器对分支profile的利用也不仅限于“剪枝”。它还会根据分支profile计算每一条程序执行路径的概率以便某些编译器优化优先处理概率较高的路径。
## 基于类型profile的优化
另外一个例子则是关于instanceof以及方法调用的类型profile。下面这段代码将测试所传入的对象是否为Exception的实例如果是则返回它的系统哈希值如果不是则返回它的哈希值。
```
public static int hash(Object in) {
if (in instanceof Exception) {
return System.identityHashCode(in);
} else {
return in.hashCode();
}
}
// 编译而成的字节码:
public static int hash(java.lang.Object);
Code:
0: aload_0
1: instanceof java/lang/Exception
4: ifeq 12
7: aload_0
8: invokestatic java/lang/System.identityHashCode:(Ljava/lang/Object;)I
11: ireturn
12: aload_0
13: invokevirtual java/lang/Object.hashCode:()I
16: ireturn
```
假设应用程序调用该方法时所传入的Object皆为Integer实例。那么偏移量为1的instanceof指令的类型profile仅包含Integer偏移量为4的分支跳转语句的分支profile中不跳转的次数为0偏移量为13的方法调用指令的类型profile仅包含Integer。
<img src="https://static001.geekbang.org/resource/image/2c/77/2c13a1af8632a2bbf77338e57c957b77.png" alt="" />
在Java虚拟机中instanceof测试并不简单。如果instanceof的目标类型是final类型那么Java虚拟机仅需比较测试对象的动态类型是否为该final类型。
在讲解对象的内存分布那一篇中,我曾经提到过,对象头存有该对象的动态类型。因此,获取对象的动态类型仅为单一的内存读指令。
如果目标类型不是final类型比如说我们例子中的Exception那么Java虚拟机需要从测试对象的动态类型开始依次测试该类该类的父类、祖先类该类所直接实现或者间接实现的接口是否与目标类型一致。
不过在我们的例子中instanceof指令的类型profile仅包含Integer。根据这个信息即时编译器可以假设在接下来的执行过程中所输入的Object对象仍为Integer实例。
因此生成的代码将测试所输入的对象的动态类型是否为Integer。如果是的话则继续执行接下来的代码。该优化源自Graal采用C2可能无法复现。
然后即时编译器会采用和第一个例子中一致的针对分支profile的优化以及对方法调用的条件去虚化内联。
我会在接下来的篇章中详细介绍内联这里先说结果生成的代码将测试所输入的对象动态类型是否为Integer。如果是的话则执行Integer.hashCode()方法的实质内容也就是返回该Integer实例的value字段。
```
public final class Integer ... {
...
@Override
public int hashCode() {
return Integer.hashCode(value);
}
public static int hashCode(int value) {
return value;
}
...
}
```
<img src="https://static001.geekbang.org/resource/image/ef/b6/ef02474d3474e96c6f55b07493652fb6.png" alt="" />
和第一个例子一样,根据数据流分析,上述代码可以最终优化为极其简单的形式。
<img src="https://static001.geekbang.org/resource/image/53/be/53e470037dd49d3d27695a5174fc3dbe.png" alt="" />
这里我打印了Graal的编译结果。可以看到在地址为1ab7的指令处进行过一次比较之后该机器码便直接返回所传入的Integer对象的value字段。
```
Compiled method (JVMCI) 600 23 4
...
----------------------------------------------------------------------
CompilationTest.hash (CompilationTest.hash(Object)) [0x000000011d811aa0, 0x000000011d811b00] 96 bytes
[Entry Point]
[Verified Entry Point]
[Constants]
# {method} {0x00000001157053c8} 'hash' '(Ljava/lang/Object;)I' in 'CompilationTest'
# parm0: rsi:rsi = 'java/lang/Object'
# [sp+0x20] (sp of caller)
0x000000011d811aa0: mov DWORD PTR [rsp-0x14000],eax
0x000000011d811aa7: sub rsp,0x18
0x000000011d811aab: mov QWORD PTR [rsp+0x10],rbp
// 比较[rsi+0x8]也就是所传入的Object参数的动态类型是否为Integer。这里0xf80022ad是Integer类的内存地址。
0x000000011d811ab0: cmp DWORD PTR [rsi+0x8],0xf80022ad
// 如果不是,跳转至?
0x000000011d811ab7: jne 0x000000011d811ad3
// 加载Integer.value。在启用压缩指针时该字段的偏移量为12也就是0xc
0x000000011d811abd: mov eax,DWORD PTR [rsi+0xc]
0x000000011d811ac0: mov rbp,QWORD PTR [rsp+0x10]
0x000000011d811ac5: add rsp,0x18
0x000000011d811ac9: test DWORD PTR [rip+0xfffffffff272f537],eax
0x000000011d811acf: vzeroupper
0x000000011d811ad2: ret
```
和基于分支profile的优化一样基于类型profile的优化同样也是作出假设从而精简控制流以及数据流。这两者的核心都是假设。
对于分支profile即时编译器假设的是仅执行某一分支对于类型profile即时编译器假设的是对象的动态类型仅为类型profile中的那几个。
那么,当假设失败的情况下,程序将何去何从?我们继续往下看。
## 去优化
Java虚拟机给出的解决方案便是去优化即从执行即时编译生成的机器码切换回解释执行。
在生成的机器码中即时编译器将在假设失败的位置上插入一个陷阱trap。该陷阱实际上是一条call指令调用至Java虚拟机里专门负责去优化的方法。与普通的call指令不一样的是去优化方法将更改栈上的返回地址并不再返回即时编译器生成的机器码中。
在上面的程序控制流图中,我画了很多红色方框的问号。这些问号便代表着一个个的陷阱。一旦踏入这些陷阱,便将发生去优化,并切换至解释执行。
去优化的过程相当复杂。由于即时编译器采用了许多优化方式,其生成的代码和原本的字节码的差异非常之大。
在去优化的过程中,需要将当前机器码的执行状态转换至某一字节码之前的执行状态,并从该字节码开始执行。这便要求即时编译器在编译过程中记录好这两种执行状态的映射。
举例来说经过逃逸分析之后机器码可能并没有实际分配对象而是在各个寄存器中存储该对象的各个字段标量替换具体我会在之后的篇章中进行介绍。在去优化过程中Java虚拟机需要还原出这个对象以便解释执行时能够使用该对象。
当根据映射关系创建好对应的解释执行栈桢后Java虚拟机便会采用OSR技术动态替换栈上的内容并在目标字节码处开始解释执行。
此外在调用Java虚拟机的去优化方法时即时编译器生成的机器码可以根据产生去优化的原因来决定是否保留这一份机器码以及何时重新编译对应的Java方法。
如果去优化的原因与优化无关即使重新编译也不会改变生成的机器码那么生成的机器码可以在调用去优化方法时传入Action_None表示保留这一份机器码在下一次调用该方法时重新进入这一份机器码。
如果去优化的原因与静态分析的结果有关例如类层次分析那么生成的机器码可以在调用去优化方法时传入Action_Recompile表示不保留这一份机器码但是可以不经过重新profile直接重新编译。
如果去优化的原因与基于profile的激进优化有关那么生成的机器码需要在调用去优化方法时传入Action_Reinterpret表示不保留这一份机器码而且需要重新收集程序的profile。
这是因为基于profile的优化失败的时候往往代表这程序的执行状态发生改变因此需要更正已收集的profile以更好地反映新的程序执行状态。
## 总结与实践
今天我介绍了Java虚拟机的profiling以及基于所收集的数据的优化和去优化。
通常情况下,解释执行过程中仅收集方法的调用次数以及循环回边的执行次数。
当方法被3层C1所编译时生成的C1代码将收集条件跳转指令的分支profile以及类型相关指令的类型profile。在部分极端情况下Java虚拟机也会在解释执行过程中收集这些profile。
基于分支profile的优化以及基于类型profile的优化都将对程序今后的执行作出假设。这些假设将精简所要编译的代码的控制流以及数据流。在假设失败的情况下Java虚拟机将采取去优化退回至解释执行并重新收集相关的profile。
今天的实践环节,你可以使用参数
```
-XX:CompileCommand='print,*ClassName.methodName'
```
来打印程序运行过程中即时编译器生成的机器码。官方的JDK可能不包含反汇编器动态链接库如hsdis-amd64.dylib。你可能需要另外下载。
```
// java -XX:CompileCommand='print,CompilationTest.foo' CompilationTestjava -XX:CompileCommand='print,CompilationTest.foo' CompilationTest
public class CompilationTest {
public static int foo(boolean f, int in) {
int v;
if (f) {
v = in;
} else {
v = (int) Math.sin(in);
}
if (v == in) {
return 0;
} else {
return (int) Math.cos(v);
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i &lt; 500000; i++) {
foo(true, 2);
}
Thread.sleep(2000);
}
}
// java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -XX:CompileCommand='print,CompilationTest2.hash' CompilationTest2
public class CompilationTest2 {
public static int hash(Object input) {
if (input instanceof Exception) {
return System.identityHashCode(input);
} else {
return input.hashCode();
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i &lt; 500000; i++) {
hash(i);
}
Thread.sleep(2000);
}
}
```

View File

@@ -0,0 +1,199 @@
<audio id="audio" title="18 | 即时编译器的中间表达形式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b9/3d/b9d003725c7dc2822ba51fb804e3293d.mp3"></audio>
在上一章中我利用了程序控制流图以及伪代码来展示即时编译器中基于profile的优化。不过这并非实际的优化过程。
## 1. 中间表达形式IR
在编译原理课程中我们通常将编译器分为前端和后端。其中前端会对所输入的程序进行词法分析、语法分析、语义分析然后生成中间表达形式也就是IRIntermediate Representation 。后端会对IR进行优化然后生成目标代码。
如果不考虑解释执行的话从Java源代码到最终的机器码实际上经过了两轮编译Java编译器将Java源代码编译成Java字节码而即时编译器则将Java字节码编译成机器码。
对于即时编译器来说所输入的Java字节码剥离了很多高级的Java语法而且其采用的基于栈的计算模型非常容易建模。因此即时编译器并不需要重新进行词法分析、语法分析以及语义分析而是直接将Java字节码作为一种IR。
不过Java字节码本身并不适合直接作为可供优化的IR。这是因为现代编译器一般采用静态单赋值Static Single AssignmentSSAIR。这种IR的特点是每个变量只能被赋值一次而且只有当变量被赋值之后才能使用。
```
y = 1;
y = 2;
x = y;
```
举个例子([来源](https://en.wikipedia.org/wiki/Static_single_assignment_form)上面这段代码所对应的SSA形式伪代码是下面这段
```
y1 = 1;
y2 = 2;
x1 = y2;
```
在源代码中我们可以轻易地发现第一个对y的赋值是冗余的但是编译器不能。传统的编译器需要借助数据流分析具体的优化叫[reaching definition](https://en.wikipedia.org/wiki/Reaching_definition)从后至前依次确认哪些变量的值被覆盖kill掉。
不过如果借助了SSA IR编译器则可以通过查找赋值了但是没有使用的变量来识别冗余赋值。
除此之外SSA IR对其他优化方式也有很大的帮助例如常量折叠constant folding、常量传播constant propagation、强度削减strength reduction以及死代码删除dead code elimination等。
```
示例:
x1=4*1024经过常量折叠后变为x1=4096
x1=4; y1=x1经过常量传播后变为x1=4; y1=4
y1=x1*3经过强度削减后变为y1=(x1&lt;&lt;1)+x1
if(2&gt;1){y1=1;}else{y2=1;}经过死代码删除后变为y1=1
```
部分同学可能会手动进行上述优化,以期望能够达到更高的运行效率。实际上,对于这些简单的优化,编译器会代为执行,以便程序员专注于代码的可读性。
SSA IR会带来一个问题那便是不同执行路径可能会对同一变量设置不同的值。例如下面这段代码if语句的两个分支中变量y分别被赋值为0或1并且在接下来的代码中读取y的值。此时根据不同的执行路径所读取到的值也很有可能不同。
```
x = ..;
if (x &gt; 0) {
y = 0;
} else {
y = 1;
}
x = y;
```
为了解决这个问题我们需要引入一个Phi函数的概念能够根据不同的执行路径选择不同的值。于是上面这段代码便可以转换为下面这段SSA伪代码。这里的Phi函数将根据前面两个分支分别选择y1、y2的值并赋值给y3。
```
x1 = ..;
if (x1 &gt; 0) {
y1 = 0;
} else {
y2 = 1;
}
y3 = Phi(y1, y2);
x2 = y3;
```
总之即时编译器会将Java字节码转换成SSA IR。更确切的说是一张包含控制流和数据流的IR图每个字节码对应其中的若干个节点注意有些字节码并没有对应的IR节点。然后即时编译器在IR图上面进行优化。
我们可以将每一种优化看成一个独立的图算法它接收一个IR图并输出经过转换后的IR图。整个编译器优化过程便是一个个优化串联起来的。
## 2. Sea-of-nodes
HotSpot里的C2采用的是一种名为Sea-of-Nodes的SSA IR。它的最大特点便是去除了变量的概念直接采用变量所指向的值来进行运算。
在上面这段SSA伪代码中我们使用了多个变量名x1、x2、y1和y2。这在Sea-of-Nodes将不复存在。
取而代之的则是对应的值比如说Phi(y1, y2)变成Phi(0, 1)后者本身也是一个值被其他IR节点所依赖。正因如此常量传播在Sea-of-Nodes中变成了一个no-op。
Graal的IR同样也是Sea-of-Nodes类型的并且可以认为是C2 IR的精简版本。由于Graal的IR系统更加容易理解而且工具支持相对来说也比较全、比较新所以下面我将围绕着Graal的IR系统来讲解。
尽管IR系统不同C2和Graal所实现的优化大同小异。对于那小部分不同的地方它们也在不停地相互“借鉴”。所以你无须担心不通用的问题。
为了方便你理解今天的内容我将利用IR可视化工具[Ideal Graph Visualizer](http://ssw.jku.at/General/Staff/TW/igv.html)IGV来展示具体的IR图。这里Ideal是C2中IR的名字。
```
public static int foo(int count) {
int sum = 0;
for (int i = 0; i &lt; count; i++) {
sum += i;
}
return sum;
}
```
上面这段代码所对应的IR图如下所示
<img src="https://static001.geekbang.org/resource/image/2d/fe/2d107fd56885909797a4ada966f2bdfe.png" alt="">
**IR图**
这里面0号Start节点是方法入口21号Return节点是方法出口。红色加粗线条为控制流蓝色线条为数据流而其他颜色的线条则是特殊的控制流或数据流。被控制流边所连接的是固定节点其他的皆属于浮动节点。若干个顺序执行的节点将被包含在同一个基本块之中如图中的B0、B1等。
<img src="https://static001.geekbang.org/resource/image/0b/8b/0be8e6fccbeedb821bd23bbef899f78b.png" alt="">
**基本块直接的控制流关系**
基本块是仅有一个入口和一个出口的指令序列IR节点序列。一个基本块的出口可以和若干个基本块的入口相连接反之亦然。
在我们的例子中B0和B2的出口与B1的入口连接代表在执行完B0或B2后可以跳转至B1并继续执行B1中的内容。而B1的出口则与B2和B3的入口连接。
可以看到上面的IR图已经没有sum或者i这样的变量名了取而代之的是一个个的值例如源程序中的i&lt;count被转换为10号&lt;节点其接收两个值分别为代表i的8号Phi节点以及代表输入第0个参数的1号P(0)节点。
关于8号Phi节点前面讲过它将根据不同的执行路径选择不同的值。如果是从5号End节点进入的则选择常量0如果是从20号LoopEnd节点跳转进入的则选择19号+节点。
你可以自己分析一下代表sum的7号Phi节点根据不同的执行路径都选择了哪些值。
浮动节点的位置并不固定。在编译过程中编译器需要多次计算浮动节点具体的排布位置。这个过程我们称之为节点调度node scheduling
节点调度是根据节点之间的依赖关系来进行的。举个例子在前面的IR图中10号&lt;节点是16号if节点用来判断是否跳转的条件因此它需要排布在16号if节点注意这是一个固定节点之前。同时它又依赖于8号Phi节点的值以及1号P(0)节点的值,因此它需要排布在这两个节点之后。
需要注意的是C2没有固定节点这一概念所有的IR节点都是浮动节点。它将根据各个基本块头尾之间的控制依赖以及数据依赖和内存依赖来进行节点调度。
这里的内存依赖是什么一个概念呢?假设一段程序往内存中存储了一个值,而后又读取同一内存,那么显然程序希望读取到的是所存储的值。即时编译器不能任意调度对同一内存地址的读写,因为它们之间存在依赖关系。
C2的做法便是将这种时序上的先后记录为内存依赖并让节点调度算法在进行调度时考虑这些内存依赖关系。Graal则将内存读写转换成固定节点。由于固定节点存在先后关系因此无须额外记录内存依赖。
## 3. Global Value Numbering
下面介绍一种因Sea-of-Nodes而变得非常容易的优化技术 —— Global Value NumberingGVN
GVN是一种发现并消除等价计算的优化技术。举例来说如果一段程序中出现了多次操作数相同的乘法那么即时编译器可以将这些乘法并为一个从而降低输出机器码的大小。如果这些乘法出现在同一执行路径上那么GVN还将省下冗余的乘法操作。
在Sea-of-Nodes中由于只存在值的概念因此GVN算法将非常简单如果一个浮动节点本身不存在内存副作用由于GVN可能影响节点调度如果有内存副作用的话那么将引发一些源代码中不可能出现的情况 那么即时编译器只需判断该浮动节点是否与已存在的浮动节点的类型相同所输入的IR节点是否一致便可以将这两个浮动节点归并成一个。
```
public static int foo(int a, int b) {
int sum = a * b;
if (a &gt; 0) {
sum += a * b;
}
if (b &gt; 0) {
sum += a * b;
}
return sum;
}
```
我们来看一个实际的案例。在上面这段代码中如果a和b都大于0那么我们需要做三次乘法。通过GVN之后我们只会在B0中做一次乘法并且在接下来的代码中直接使用乘法的结果也就是4号*节点所代表的值。
<img src="https://static001.geekbang.org/resource/image/f9/e1/f965693c5b1912f28065349b171832e1.png" alt="">
我们可以将GVN理解为在IR图上的公共子表达式消除Common Subexpression EliminationCSE
这两者的区别在于GVN直接比较值的相同与否而CSE则是借助词法分析器来判断两个表达式相同与否。因此在不少情况下CSE还需借助常量传播来达到消除的效果。
## 总结与实践
今天我介绍了即时编译器的内部构造。
即时编译器将所输入的Java字节码转换成SSA IR以便更好地进行优化。
具体来说C2和Graal采用的是一种名为Sea-of-Nodes的IR其特点用IR节点来代表程序中的值并且将源程序中基于变量的计算转换为基于值的计算。
此外我还介绍了C2和Graal的IR的可视化工具IGV以及基于IR的优化GVN。
今天的实践环节你可以尝试使用IGV来查看上一篇实践环节中的代码的具体编译过程。
你可以通过[该页面](https://github.com/oracle/graal/releases/tag/idealgraphvisualizer-543)下载当前版本的IGV。解压后可运行脚本位于bin/idealgraphvisualizer中。IGV启动完成后你可以通过下述指令将IR图打印至IGV中。需附带Graal编译器的Java 10或以上版本。
```
// java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -XX:CompileCommand='dontinline,CompilationTest::hash' -Dgraal.Dump=:3 -Dgraal.MethodFilter='CompilationTest.hash' -Dgraal.OptDeoptimizationGrouping=false CompilationTest
public class CompilationTest {
public static int hash(Object input) {
if (input instanceof Exception) {
return System.identityHashCode(input);
} else {
return input.hashCode();
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i &lt; 500000; i++) {
hash(i);
}
Thread.sleep(2000);
}
}
```

View File

@@ -0,0 +1,246 @@
<audio id="audio" title="19 | Java字节码基础篇" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3c/85/3c7a74dc020d97654babaf020b913c85.mp3"></audio>
在前面的篇章中有不少同学反馈对Java字节码并不是特别熟悉。那么今天我便来系统性地介绍一遍Java字节码。
## 操作数栈
我们知道Java字节码是Java虚拟机所使用的指令集。因此它与Java虚拟机基于栈的计算模型是密不可分的。
在解释执行过程中每当为Java方法分配栈桢时Java虚拟机往往需要开辟一块额外的空间作为操作数栈来存放计算的操作数以及返回结果。
具体来说便是执行每一条指令之前Java虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时Java虚拟机会将该指令所需的操作数弹出并且将指令的结果重新压入栈中。
<img src="https://static001.geekbang.org/resource/image/13/21/13720f6eb83d096ec600309648330821.png" alt="" />
以加法指令iadd为例。假设在执行该指令前栈顶的两个元素分别为int值1和int值2那么iadd指令将弹出这两个int并将求得的和int值3压入栈中。
<img src="https://static001.geekbang.org/resource/image/13/db/138c20e60c081c8698770ff8d5d93fdb.png" alt="" />
由于iadd指令只消耗栈顶的两个元素因此对于离栈顶距离为2的元素即图中的问号iadd指令并不关心它是否存在更加不会对其进行修改。
Java字节码中有好几条指令是直接作用在操作数栈上的。最为常见的便是dup 复制栈顶元素以及pop舍弃栈顶元素。
dup指令常用于复制new指令所生成的未经初始化的引用。例如在下面这段代码的foo方法中当执行new指令时Java虚拟机将指向一块已分配的、未初始化的内存的引用压入操作数栈中。
```
public void foo() {
Object o = new Object();
}
// 对应的字节码如下:
public void foo();
0 new java.lang.Object [3]
3 dup
4 invokespecial java.lang.Object() [8]
7 astore_1 [o]
8 return
```
接下来我们需要以这个引用为调用者调用其构造器也就是上面字节码中的invokespecial指令。要注意该指令将消耗操作数栈上的元素作为它的调用者以及参数不过Object的构造器不需要参数
因此我们需要利用dup指令复制一份new指令的结果并用来调用构造器。当调用返回之后操作数栈上仍有原本由new指令生成的引用可用于接下来的操作即偏移量为7的字节码下面会介绍到
pop指令则常用于舍弃调用指令的返回结果。例如在下面这段代码的foo方法中我将调用静态方法bar但是却不用其返回值。
由于对应的invokestatic指令仍旧会将返回值压入foo方法的操作数栈中因此Java虚拟机需要额外执行pop指令将返回值舍弃。
```
public static boolean bar() {
return false;
}
public void foo() {
bar();
}
// foo方法对应的字节码如下
public void foo();
0 invokestatic FooTest.bar() : boolean [24]
3 pop
4 return
```
需要注意的是上述两条指令只能处理非long或者非double类型的值这是因为long类型或者double类型的值需要占据两个栈单元。当遇到这些值时我们需要同时复制栈顶两个单元的dup2指令以及弹出栈顶两个单元的pop2指令。
除此之外不算常见但也是直接作用于操作数栈的还有swap指令它将交换栈顶两个元素的值。
在Java字节码中有一部分指令可以直接将常量加载到操作数栈上。以int类型为例Java虚拟机既可以通过iconst指令加载-1至5之间的int值也可以通过bipush、sipush加载一个字节、两个字节所能代表的int值。
Java虚拟机还可以通过ldc加载常量池中的常量值例如ldc #18将加载常量池中的第18项
这些常量包括int类型、long类型、float类型、double类型、String类型以及Class类型的常量。
<img src="https://static001.geekbang.org/resource/image/0d/8f/0dfbecf954660bcdc76eac65beac1e8f.jpg" alt="" />
**常数加载指令表**
正常情况下操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时Java虚拟机会清除操作数栈上的所有内容而后将异常实例压入操作数栈上。
## 局部变量区
Java方法栈桢的另外一个重要组成部分则是局部变量区字节码程序可以将计算的结果缓存在局部变量区之中。
实际上Java虚拟机将局部变量区当成一个数组依次存放this指针仅非静态方法所传入的参数以及字节码中的局部变量。
和操作数栈一样long类型以及double类型的值将占据两个单元其余类型仅占据一个单元。
```
public void foo(long l, float f) {
{
int i = 0;
}
{
String s = &quot;Hello, World&quot;;
}
}
```
以上面这段代码中的foo方法为例由于它是一个实例方法因此局部变量数组的第0个单元存放着this指针。
第一个参数为long类型于是数组的1、2两个单元存放着所传入的long类型参数的值。第二个参数则是float类型于是数组的第3个单元存放着所传入的float类型参数的值。
<img src="https://static001.geekbang.org/resource/image/22/d9/228d0f5f2d6437e7aca87c6df2d01bd9.png" alt="" />
在方法体里的两个代码块中我分别定义了两个局部变量i和s。由于这两个局部变量的生命周期没有重合之处因此Java编译器可以将它们编排至同一单元中。也就是说局部变量数组的第4个单元将为i或者s。
存储在局部变量区的值通常需要加载至操作数栈中方能进行计算得到计算结果后再存储至局部变量数组中。这些加载、存储指令是区分类型的。例如int类型的加载指令为iload存储指令为istore。
<img src="https://static001.geekbang.org/resource/image/83/f9/83ece83ecd260c2eda282747467e49f9.jpg" alt="" /><br />
**局部变量区访问指令表**
局部变量数组的加载、存储指令都需要指明所加载单元的下标。举例来说aload 0指的是加载第0个单元所存储的引用在前面示例中的foo方法里指的便是加载this指针。
在我印象中Java字节码中唯一能够直接作用于局部变量区的指令是iinc M NM为非负整数N为整数。该指令指的是将局部变量数组的第M个单元中的int值增加N常用于for循环中自增量的更新。
```
public void foo() {
for (int i = 100; i&gt;=0; i--) {}
}
// 对应的字节码如下:
public void foo();
0 bipush 100
2 istore_1 [i]
3 goto 9
6 iinc 1 -1 [i] // i--
9 iload_1 [i]
10 ifge 6
13 return
```
## 综合示例
下面我们来看一个综合的例子:
```
public static int bar(int i) {
return ((i + 1) - 2) * 3 / 4;
}
// 对应的字节码如下:
Code:
stack=2, locals=1, args_size=1
0: iload_0
1: iconst_1
2: iadd
3: iconst_2
4: isub
5: iconst_3
6: imul
7: iconst_4
8: idiv
9: ireturn
```
这里我定义了一个bar方法。它将接收一个int类型的参数进行一系列计算之后再返回。
对应的字节码中的stack=2, locals=1代表该方法需要的操作数栈空间为2局部变量数组空间为1。当调用bar(5)时,每条指令执行前后局部变量数组空间以及操作数栈的分布如下:
<img src="https://static001.geekbang.org/resource/image/c5/32/c57cb9c2222f0f79459bf4c58e1a4c32.png" alt="" />
## Java字节码简介
前面我已经介绍了加载常量指令、操作数栈专用指令以及局部变量区访问指令。下面我们来看看其他的类别。
Java相关指令包括各类具备高层语义的字节码即new后跟目标类生成该类的未初始化的对象instanceof后跟目标类判断栈顶元素是否为目标类/接口的实例。是则压入1否则压入0checkcast后跟目标类判断栈顶元素是否为目标类/接口的实例。如果不是便抛出异常athrow将栈顶异常抛出以及monitorenter为栈顶对象加锁和monitorexit为栈顶对象解锁
此外该类型的指令还包括字段访问指令即静态字段访问指令getstatic、putstatic和实例字段访问指令getfield、putfield。这四条指令均附带用以定位目标字段的信息但所消耗的操作数栈元素皆不同。
<img src="https://static001.geekbang.org/resource/image/da/d9/da3ff3aa4aaa2531d23286fec65b08d9.png" alt="" />
以putfield为例在上图中它会把值v存储至对象obj的目标字段之中。
方法调用指令包括invokestaticinvokespecialinvokevirtualinvokeinterface以及invokedynamic。这几条字节码我们已经反反复复提及了就不再具体介绍各自的含义了。
除invokedynamic外其他的方法调用指令所消耗的操作数栈元素是根据调用类型以及目标方法描述符来确定的。在进行方法调用之前程序需要依次压入调用者invokestatic不需要以及各个参数。
```
public int neg(int i) {
return -i;
}
public int foo(int i) {
return neg(neg(i));
}
// foo方法对应的字节码如下foo方法对应的字节码如下
public int foo(int i);
0 aload_0 [this]
1 aload_0 [this]
2 iload_1 [i]
3 invokevirtual FooTest.neg(int) : int [25]
6 invokevirtual FooTest.neg(int) : int [25]
9 ireturn
```
以上面这段代码为例当调用foo(2)时,每条指令执行前后局部变量数组空间以及操作数栈的分布如下所示:
<img src="https://static001.geekbang.org/resource/image/47/95/476fa1bcb6b36b5b651c2a4101073295.png" alt="" />
数组相关指令包括新建基本类型数组的newarray新建引用类型数组的anewarray生成多维数组的multianewarray以及求数组长度的arraylength。另外它还包括数组的加载指令以及存储指令。这些指令是区分类型的。例如int数组的加载指令为iaload存储指令为iastore。
<img src="https://static001.geekbang.org/resource/image/5d/1f/5d935dcdd55e9f8461a6e5b0ac22001f.jpg" alt="" />
**数组访问指令表**
控制流指令包括无条件跳转goto条件跳转指令tableswitch和lookupswtich前者针对密集的cases后者针对稀疏的cases返回指令以及被废弃的jsrret指令。其中返回指令是区分类型的。例如返回int值的指令为ireturn。
<img src="https://static001.geekbang.org/resource/image/f5/f0/f5195b5425a9547af9ce8371aef5c4f0.jpg" alt="" />
**返回指令表**
除返回指令外其他的控制流指令均附带一个或者多个字节码偏移量代表需要跳转到的位置。例如下面的abs方法中偏移量为1的条件跳转指令当栈顶元素小于0时跳转至偏移量为6的字节码。
```
public int abs(int i) {
if (i &gt;= 0) {
return i;
}
return -i;
}
// 对应的字节码如下所示:
public int abs(int i);
0 iload_1 [i]
1 iflt 6
4 iload_1 [i]
5 ireturn
6 iload_1 [i]
7 ineg
8 ireturn
```
剩余的Java字节码几乎都和计算相关这里就不再详细阐述了。
## 总结与实践
今天我简单介绍了各种类型的Java字节码。
Java方法的栈桢分为操作数栈和局部变量区。通常来说程序需要将变量从局部变量区加载至操作数栈中进行一番运算之后再存储回局部变量区中。
Java字节码可以划分为很多种类型如加载常量指令操作数栈专用指令局部变量区访问指令Java相关指令方法调用指令数组相关指令控制流指令以及计算相关指令。
今天的实践环节,你可以尝试自己分析一段较为复杂的字节码,在草稿上画出局部变量数组以及操作数栈分布图。当碰到不熟悉的指令时,你可以查阅[Java虚拟机规范第6.5小节](https://docs.oracle.com/javase/specs/jvms/se10/html/jvms-6.html#jvms-6.5) ,或者[此链接](https://cs.au.dk/~mis/dOvs/jvmspec/ref-Java.html)。

View File

@@ -0,0 +1,145 @@
<audio id="audio" title="20 | 方法内联(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/87/f65dffa812b78be771e37de834e3f287.mp3"></audio>
在前面的篇章中,我多次提到了方法内联这项技术。它指的是:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化。因此,它可以算是编译优化里最为重要的一环。
以getter/setter为例如果没有方法内联在调用getter/setter时程序需要保存当前方法的执行位置创建并压入用于getter/setter的栈帧、访问字段、弹出栈帧最后再恢复当前方法的执行。而当内联了对getter/setter的方法调用后上述操作仅剩字段访问。
在C2中方法内联是在解析字节码的过程中完成的。每当碰到方法调用字节码时C2将决定是否需要内联该方法调用。如果需要内联则开始解析目标方法的字节码。
>
复习一下即时编译器首先解析字节码并生成IR图然后在该IR图上进行优化。优化是由一个个独立的优化阶段optimization phase串联起来的。每个优化阶段都会对IR图进行转换。最后即时编译器根据IR图的节点以及调度顺序生成机器码。
同C2一样Graal也会在解析字节码的过程中进行方法调用的内联。此外Graal还拥有一个独立的优化阶段来寻找指代方法调用的IR节点并将之替换为目标方法的IR图。这个过程相对来说比较形象一些因此今天我就利用它来给你讲解一下方法内联。
```
方法内联的过程
public static boolean flag = true;
public static int value0 = 0;
public static int value1 = 1;
public static int foo(int value) {
int result = bar(flag);
if (result != 0) {
return result;
} else {
return value;
}
}
public static int bar(boolean flag) {
return flag ? value0 : value1;
}
```
上面这段代码中的foo方法将接收一个int类型的参数而bar方法将接收一个boolean类型的参数。其中foo方法会读取静态字段flag的值并作为参数调用bar方法。
<img src="https://static001.geekbang.org/resource/image/c0/59/c024b8b45570f25534f76f0c4d378559.png" alt="" /><br />
**foo方法的IR图内联前**
在编译foo方法时其对应的IR图中将出现对bar方法的调用即上图中的5号Invoke节点。如果内联算法判定应当内联对bar方法的调用时那么即时编译器将开始解析bar方法的字节码并生成对应的IR图如下图所示。
<img src="https://static001.geekbang.org/resource/image/96/55/96d8575326f7c1991c6677e6d2d17155.png" alt="" /><br />
**bar方法的IR图**
接下来即时编译器便可以进行方法内联把bar方法所对应的IR图纳入到对foo方法的编译中。具体的操作便是将foo方法的IR图中5号Invoke节点替换为bar方法的IR图。
<img src="https://static001.geekbang.org/resource/image/62/c8/6209f233f5518ee470eb08422c8d0bc8.png" alt="" /><br />
**foo方法的IR图内联后**
除了将被调用方法的IR图节点复制到调用者方法的IR图中即时编译器还需额外完成下述三项操作。
第一被调用方法的传入参数节点将被替换为调用者方法进行方法调用时所传入参数对应的节点。在我们的例子中就是将bar方法IR图中的1号P(0)节点替换为foo方法IR图中的3号LoadField节点。
第二在调用者方法的IR图中所有指向原方法调用节点的数据依赖将重新指向被调用方法的返回节点。如果被调用方法存在多个返回节点则生成一个Phi节点将这些返回值聚合起来并作为原方法调用节点的替换对象。
在我们的例子中就是将8号==节点以及12号Return节点连接到原5号Invoke节点的边重新指向新生成的24号Phi节点中。
第三,如果被调用方法将抛出某种类型的异常,而调用者方法恰好有该异常类型的处理器,并且该异常处理器覆盖这一方法调用,那么即时编译器需要将被调用方法抛出异常的路径,与调用者方法的异常处理器相连接。
经过方法内联之后即时编译器将得到一个新的IR图并且在接下来的编译过程中对这个新的IR图进行进一步的优化。不过在上面这个例子中方法内联后的IR图并没有能够进一步优化的地方。
```
public final static boolean flag = true;
public final static int value0 = 0;
public final static int value1 = 1;
public static int foo(int value) {
int result = bar(flag);
if (result != 0) {
return result;
} else {
return value;
}
}
public static int bar(boolean flag) {
return flag ? value0 : value1;
}
```
不过如果我们将代码中的三个静态字段标记为final那么Java编译器注意不是即时编译器会将它们编译为常量值ConstantValue并且在字节码中直接使用这些常量值而非读取静态字段。举例来说bar方法对应的字节码如下所示。
```
public static int bar(boolean);
Code:
0: iload_0
1: ifeq 8
4: iconst_0
5: goto 9
8: iconst_1
9: ireturn
```
在编译foo方法时一旦即时编译器决定要内联对bar方法的调用那么它会将调用bar方法所使用的参数也就是常数1替换bar方法IR图中的参数。经过死代码消除之后bar方法将直接返回常数0所需复制的IR图也只有常数0这么一个节点。
经过方法内联之后foo方法的IR图将变成如下所示<br />
<img src="https://static001.geekbang.org/resource/image/15/36/1506286ffb9c9d0d8a927e8174594536.png" alt="" />
该IR图可以进一步优化死代码消除并最终得到这张极为简单的IR图<br />
<img src="https://static001.geekbang.org/resource/image/6a/03/6affa54acd4d5f180efacdac93b02a03.png" alt="" />
## 方法内联的条件
方法内联能够触发更多的优化。通常而言,内联越多,生成代码的执行效率越高。然而,对于即时编译器来说,内联越多,编译时间也就越长,而程序达到峰值性能的时刻也将被推迟。
此外内联越多也将导致生成的机器码越长。在Java虚拟机里编译生成的机器码会被部署到Code Cache之中。这个Code Cache是有大小限制的由Java虚拟机参数-XX:ReservedCodeCacheSize控制
这就意味着生成的机器码越长越容易填满Code Cache从而出现Code Cache已满即时编译已被关闭的警告信息CodeCache is full. Compiler has been disabled
因此即时编译器不会无限制地进行方法内联。下面我便列举即时编译器的部分内联规则。其他的特殊规则如自动拆箱总会被内联、Throwable类的方法不能被其他类中的方法所内联你可以直接参考[JDK的源代码](http://hg.openjdk.java.net/jdk/jdk/file/da387726a4f5/src/hotspot/share/opto/bytecodeInfo.cpp#l197)。)
**首先,由-XX:CompileCommand中的inline指令指定的方法以及由@ForceInline注解的方法仅限于JDK内部方法会被强制内联。** 而由-XX:CompileCommand中的dontinline指令或exclude指令表示不编译指定的方法以及由@DontInline注解的方法仅限于JDK内部方法则始终不会被内联。
**其次如果调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化或者目标方法是native方法都将导致方法调用无法内联。**
**再次C2不支持内联超过9层的调用可以通过虚拟机参数-XX:MaxInlineLevel调整以及1层的直接递归调用可以通过虚拟机参数-XX:MaxRecursiveInlineLevel调整。**
>
如果方法a调用了方法b而方法b调用了方法c那么我们称b为a的1层调用而c为a的2层调用。
最后即时编译器将根据方法调用指令所在的程序路径的热度目标方法的调用次数及大小以及当前IR图的大小来决定方法调用能否被内联。
<img src="https://static001.geekbang.org/resource/image/49/c3/49fb3a3849e82ddcc74bd982a5e4eac3.jpg" alt="" />
我在上面的表格列举了一些C2相关的虚拟机参数。总体来说即时编译器中的内联算法更青睐于小方法。
## 总结与实践
今天我介绍了方法内联的过程以及条件。
方法内联是指,在编译过程中,当遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
即时编译器既可以在解析过程中替换方法调用字节码也可以在IR图中替换方法调用IR节点。这两者都需要将目标方法的参数以及返回值映射到当前方法来。
方法内联有许多规则。除了一些强制内联以及强制不内联的规则外即时编译器会根据方法调用的层数、方法调用指令所在的程序路径的热度、目标方法的调用次数及大小以及当前IR图的大小来决定方法调用能否被内联。
今天的实践环节,你可以利用虚拟机参数-XX:+PrintInlining来打印编译过程中的内联情况。具体每项内联信息所代表的意思你可以参考[这一网页](https://wiki.openjdk.java.net/display/HotSpot/Server+Compiler+Inlining+Messages)。

View File

@@ -0,0 +1,267 @@
<audio id="audio" title="21 | 方法内联(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/f3/b431f57c281a1f0623a95ab12c631bf3.mp3"></audio>
在上一篇中,我举的例子都是静态方法调用,即时编译器可以轻易地确定唯一的目标方法。
然而对于需要动态绑定的虚方法调用来说即时编译器则需要先对虚方法调用进行去虚化devirtualize即转换为一个或多个直接调用然后才能进行方法内联。
**即时编译器的去虚化方式可分为完全去虚化以及条件去虚化guarded devirtualization。**
**完全去虚化**是通过类型推导或者类层次分析class hierarchy analysis识别虚方法调用的唯一目标方法从而将其转换为直接调用的一种优化手段。它的关键在于证明虚方法调用的目标方法是唯一的。
**条件去虚化**则是将虚方法调用转换为若干个类型测试以及直接调用的一种优化手段。它的关键在于找出需要进行比较的类型。
在介绍具体的去虚化方式之前我们先来看一段代码。这里我定义了一个抽象类BinaryOp其中包含一个抽象方法apply。BinaryOp类有两个子类Add和Sub均实现了apply方法。
```
abstract class BinaryOp {
public abstract int apply(int a, int b);
}
class Add extends BinaryOp {
public int apply(int a, int b) {
return a + b;
}
}
class Sub extends BinaryOp {
public int apply(int a, int b) {
return a - b;
}
}
```
下面我便用这个例子来逐一讲解这几种去虚化方式。
## 基于类型推导的完全去虚化
基于类型推导的完全去虚化将通过数据流分析推导出调用者的动态类型,从而确定具体的目标方法。
```
public static int foo() {
BinaryOp op = new Add();
return op.apply(2, 1);
}
public static int bar(BinaryOp op) {
op = (Add) op;
return op.apply(2, 1);
}
```
举个例子上面这段代码中的foo方法和bar方法均会调用apply方法且调用者的声明类型皆为BinaryOp。这意味着Java编译器会将其编译为invokevirtual指令调用BinaryOp.apply方法。
前两篇中我曾提到过在Sea-of-Nodes的IR系统中变量不复存在取而代之的是具体值。这些具体值的类型往往要比变量的声明类型精确。<br />
<img src="https://static001.geekbang.org/resource/image/28/07/2874be42bccaece59ed2484aaa478407.png" alt="" />
**foo方法的IR图方法内联前**
<img src="https://static001.geekbang.org/resource/image/32/fc/32ce05f4929ae328ffbf5517175420fc.png" alt="" />
### bar方法的IR图方法内联前
在上面两张IR图中方法调用的调用者即8号CallTarget节点的第一个依赖值分别为2号New节点以及5号Pi节点。后者可以简单看成强制转换后的精确类型。由于这两个节点的类型均被精确为Add类因此原invokevirtual指令对应的9号invoke节点都被识别对Add.apply方法的调用。
经过对该具体方法的内联之后对应的IR图如下所示
<img src="https://static001.geekbang.org/resource/image/a9/0d/a90e99097591778a4dd5d145f84ed00d.png" alt="" />
### foo方法的IR图方法内联及逃逸分析后
<img src="https://static001.geekbang.org/resource/image/31/4a/3161d594415960a3675fad00812df94a.png" alt="" />
### bar方法的IR图方法内联后
可以看到通过将字节码转换为Sea-of-Nodes IR之后即时编译器便可以直接去虚化并将唯一的目标方法进一步内联进来。
```
public static int notInlined(BinaryOp op) {
if (op instanceof Add) {
return op.apply(2, 1);
}
return 0;
}
```
不过对于上面这段代码中的notInlined方法尽管理论上即时编译器能够推导出调用者的动态类型为Add但是C2和Graal都没有这么做。
其原因在于类型推导属于全局优化,本身比较浪费时间;另一方面,就算不进行基于类型推导的完全去虚化,也有接下来的基于类层次分析的去虚化,以及条件去虚化兜底,覆盖大部分的代码情况。
<img src="https://static001.geekbang.org/resource/image/14/2f/1492fc7d74c1e904d47196f8f63b682f.png" alt="" />
### notInlined方法的IR图方法内联失败后
因此C2和Graal决定如果生成Sea-of-Nodes IR后调用者的动态类型已能够直接确定那么就进行这项去虚化。如果需要额外的数据流分析方能确定那么干脆不做以节省编译时间并依赖接下来的去虚化手段进行优化。
## 基于类层次分析的完全去虚化
基于类层次分析的完全去虚化通过分析Java虚拟机中所有已被加载的类判断某个抽象方法或者接口方法是否仅有一个实现。如果是那么对这些方法的调用将只能调用至该具体实现中。
在上面的例子中假设在编译foo、bar或notInlined方法时Java虚拟机仅加载了Add。那么BinaryOp.apply方法只有Add.apply这么一个具体实现。因此当即时编译器碰到对BinaryOp.apply的调用时便可直接内联Add.apply的内容。
那么问题来了即时编译器如何保证在今后的执行过程中BinaryOp.apply方法还是只有Add.apply这么一个具体实现呢
事实上它无法保证。因为Java虚拟机有可能在上述编译完成之后加载Sub类从而引入另一个BinaryOp.apply方法的具体实现Sub.apply。
Java虚拟机的做法是为当前编译结果注册若干个假设assumption假定某抽象类只有一个子类或者某抽象方法只有一个具体实现又或者某类没有子类等。
之后每当新的类被加载Java虚拟机便会重新验证这些假设。如果某个假设不再成立那么Java虚拟机便会对其所属的编译结果进行去优化。
```
public static int test(BinaryOp op) {
return op.apply(2, 1);
}
```
以上面这段代码中的test方法为例。假设即时编译的时候如果类层次分析得出BinaryOp类只有Add一个子类的结论那么即时编译器可以注册一个假设假定抽象方法BinaryOp.apply有且仅有Add.apply这个具体实现。
基于这个假设原虚方法调用便可直接被去虚化为对Add.apply方法的调用。如果在之后的运行过程中Java虚拟机又加载了Sub类那么该假设失效Java虚拟机需要触发test方法编译结果的去优化。
```
public static int test(Add op) {
return op.apply(2, 1); // 仍需添加假设
}
```
事实上即便调用者的声明类型为Add即时编译器仍需为之添加假设。这是因为Java虚拟机不能保证没有重写了apply方法的Add类的子类。
为了保证这里apply方法的语义即时编译器需要假设Add类没有子类。当然通过将Add类标注为final可以避开这个问题。
可以看到即时编译器并不要求目标方法使用final修饰符。只要目标方法事实上是final的effective final便可以进行相应的去虚化以及内联。
不过如果使用了final修饰符即时编译器便可以不用生成对应的假设。这将使编译结果更加精简并减少类加载时所需验证的内容。
<img src="https://static001.geekbang.org/resource/image/f8/91/f87c733b714828c22c9d67c83911cd91.png" alt="" />
### test方法的IR图方法内联后
让我们回到原本的例子中。从test方法的IR图可以看出生成的代码无须检测调用者的动态类型是否为Add便直接执行内联之后的Add.apply方法中的内容2+1经过常量折叠之后得到3对应13号常数节点。这是因为动态类型检测已被移至假设之中了。
然而对于接口方法调用该去虚化手段则不能移除动态类型检测。这是因为在执行invokeinterface指令时Java虚拟机必须对调用者的动态类型进行测试看它是否实现了目标接口方法所在的接口。
>
Java类验证器将接口类型直接看成Object类型所以有可能出现声明类型为接口实际类型没有继承该接口的情况如下例所示。
```
// A.java
interface I {}
public class A {
public static void test(I obj) {
System.out.println(&quot;Hello World&quot;);
}
public static void main(String[] args) {
test(new B());
}
}
// B.java
public class B implements I { }
// Step 1: compile A.java and B.java
// Step 2: remove &quot;implements I&quot; from B.java, and compile B.java
// Step 3: run A
```
既然这一类型测试无法避免C2干脆就不对接口方法调用进行基于类层次分析的完全去虚化而是依赖于接下来的条件去虚化。
## 条件去虚化
前面提到,条件去虚化通过向代码中添加若干个类型比较,将虚方法调用转换为若干个直接调用。
具体的原理非常简单是将调用者的动态类型依次与Java虚拟机所收集的类型Profile中记录的类型相比较。如果匹配则直接调用该记录类型所对应的目标方法。
```
public static int test(BinaryOp op) {
return op.apply(2, 1);
}
```
我们继续使用前面的例子。假设编译时类型Profile记录了调用者的两个类型Sub和Add那么即时编译器可以据此进行条件去虚化依次比较调用者的动态类型是否为Sub或者Add并内联相应的方法。其伪代码如下所示
```
public static int test(BinaryOp op) {
if (op.getClass() == Sub.class) {
return 2 - 1; // inlined Sub.apply
} else if (op.getClass() == Add.class) {
return 2 + 1; // inlined Add.apply
} else {
... // 当匹配不到类型Profile中的类型怎么办
}
}
```
如果遍历完类型Profile中的所有记录仍旧匹配不到调用者的动态类型那么即时编译器有两种选择。
第一如果类型Profile是完整的也就是说所有出现过的动态类型都被记录至类型Profile之中那么即时编译器可以让程序进行去优化重新收集类型Profile对应的IR图如下所示这里27号TypeSwitch节点等价于前面伪代码中的多个if语句<br />
<img src="https://static001.geekbang.org/resource/image/88/6d/8885061253bc50be255cff736b683f6d.png" alt="" />
### 当匹配不到动态类型时进行去优化
第二如果类型Profile是不完整的也就是说某些出现过的动态类型并没有记录至类型Profile之中那么重新收集并没有多大作用。此时即时编译器可以让程序进行原本的虚调用通过内联缓存进行调用或者通过方法表进行动态绑定。对应的IR图如下所示
<img src="https://static001.geekbang.org/resource/image/dd/de/ddb0474fdad3031e935003c2d57a04de.png" alt="" />
### 当匹配不到动态类型时进行虚调用仅在Graal中使用。
在C2中如果类型Profile是不完整的即时编译器压根不会进行条件去虚化而是直接使用内联缓存或者方法表。
## 总结与实践
今天我介绍了即时编译器去虚化的几种方法。
完全去虚化通过类型推导或者类层次分析,将虚方法调用转换为直接调用。它的关键在于证明虚方法调用的目标方法是唯一的。
条件去虚化通过向代码中增添类型比较将虚方法调用转换为一个个的类型测试以及对应该类型的直接调用。它将借助Java虚拟机所收集的类型Profile。
今天的实践环节,我们来重现因类加载导致去优化的过程。
```
// Run with java -XX:CompileCommand='dontinline JITTest.test' -XX:+PrintCompilation JITTest
public class JITTest {
static abstract class BinaryOp {
public abstract int apply(int a, int b);
}
static class Add extends BinaryOp {
public int apply(int a, int b) {
return a + b;
}
}
static class Sub extends BinaryOp {
public int apply(int a, int b) {
return a - b;
}
}
public static int test(BinaryOp op) {
return op.apply(2, 1);
}
public static void main(String[] args) throws Exception {
Add add = new Add();
for (int i = 0; i &lt; 400_000; i++) {
test(add);
}
Thread.sleep(2000);
System.out.println(&quot;Loading Sub&quot;);
Sub[] array = new Sub[0]; // Load class Sub
// Expect output: &quot;JITTest::test (7 bytes) made not entrant&quot;
Thread.sleep(2000);
}
}
```

View File

@@ -0,0 +1,214 @@
<audio id="audio" title="22 | HotSpot虚拟机的intrinsic" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/19/44a3a902438a929abdda1b57d99d5b19.mp3"></audio>
前不久,有同学问我,`String.indexOf`方法和自己实现的`indexOf`方法在字节码层面上差不多,为什么执行效率却有天壤之别呢?今天我们就来看一看。
```
public int indexOf(String str) {
if (coder() == str.coder()) {
return isLatin1() ? StringLatin1.indexOf(value, str.value)
: StringUTF16.indexOf(value, str.value);
}
if (coder() == LATIN1) { // str.coder == UTF16
return -1;
}
return StringUTF16.indexOfLatin1(value, str.value);
}
```
为了解答这个问题,我们来读一下`String.indexOf`方法的源代码上面的代码截取自Java 10.0.2)。
>
在Java 9之前字符串是用char数组来存储的主要为了支持非英文字符。然而大多数Java程序中的字符串都是由Latin1字符组成的。也就是说每个字符仅需占据一个字节而使用char数组的存储方式将极大地浪费内存空间。
Java 9引入了Compact Strings[1]的概念当字符串仅包含Latin1字符时使用一个字节代表一个字符的编码格式使得内存使用效率大大提高。
假设我们调用`String.indexOf`方法的调用者以及参数均为只包含Latin1字符的字符串那么该方法的关键在于对`StringLatin1.indexOf`方法的调用。
下面我列举了`StringLatin1.indexOf`方法的源代码。你会发现,它并没有使用特别高明的算法,唯一值得注意的便是方法声明前的`@HotSpotIntrinsicCandidate`注解。
```
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, byte[] str) {
if (str.length == 0) {
return 0;
}
if (value.length == 0) {
return -1;
}
return indexOf(value, value.length, str, str.length, 0);
}
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, int valueCount, byte[] str, int strCount, int fromIndex) {
byte first = str[0];
int max = (valueCount - strCount);
for (int i = fromIndex; i &lt;= max; i++) {
// Look for first character.
if (value[i] != first) {
while (++i &lt;= max &amp;&amp; value[i] != first);
}
// Found first character, now look at the rest of value
if (i &lt;= max) {
int j = i + 1;
int end = j + strCount - 1;
for (int k = 1; j &lt; end &amp;&amp; value[j] == str[k]; j++, k++);
if (j == end) {
// Found whole string.
return i;
}
}
}
return -1;
}
```
在HotSpot虚拟机中所有被该注解标注的方法都是HotSpot intrinsic。对这些方法的调用会被HotSpot虚拟机替换成高效的指令序列。而原本的方法实现则会被忽略掉。
换句话说HotSpot虚拟机将为标注了`@HotSpotIntrinsicCandidate`注解的方法额外维护一套高效实现。如果Java核心类库的开发者更改了原本的实现那么虚拟机中的高效实现也需要进行相应的修改以保证程序语义一致。
需要注意的是其他虚拟机未必维护了这些intrinsic的高效实现它们可以直接使用原本的较为低效的JDK代码。同样不同版本的HotSpot虚拟机所实现的intrinsic数量也大不相同。通常越新版本的Java其intrinsic数量越多。
你或许会产生这么一个疑问:为什么不直接在源代码中使用这些高效实现呢?
这是因为高效实现通常依赖于具体的CPU指令而这些CPU指令不好在Java源程序中表达。再者换了一个体系架构说不定就没有对应的CPU指令也就无法进行intrinsic优化了。
下面我们便来看几个具体的例子。
## intrinsic与CPU指令
在文章开头的例子中,`StringLatin1.indexOf`方法将在一个字符串byte数组中查找另一个字符串byte数组并且返回命中时的索引值或者-1未命中
“恰巧”的是X86_64体系架构的SSE4.2指令集就包含一条指令PCMPESTRI让它能够在16字节以下的字符串中查找另一个16字节以下的字符串并且返回命中时的索引值。
因此HotSpot虚拟机便围绕着这一指令开发出X86_64体系架构上的高效实现并替换原本对`StringLatin1.indexOf`方法的调用。
另外一个例子则是整数加法的溢出处理。一般我们在做整数加法时,需要考虑结果是否会溢出,并且在溢出的情况下作出相应的处理,以保证程序的正确性。
Java核心类库提供了一个`Math.addExact`方法。它将接收两个int值或long值作为参数并返回这两个int值的和。当这两个int值之和溢出时该方法将抛出`ArithmeticException`异常。
```
@HotSpotIntrinsicCandidate
public static int addExact(int x, int y) {
int r = x + y;
// HD 2-12 Overflow iff both arguments have the opposite sign of the result
if (((x ^ r) &amp; (y ^ r)) &lt; 0) {
throw new ArithmeticException(&quot;integer overflow&quot;);
}
return r;
}
```
在Java层面判断int值之和是否溢出比较费事。我们需要分别比较两个int值与它们的和的符号是否不同。如果都不同那么我们便认为这两个int值之和溢出。对应的实现便是两个异或操作一个与操作以及一个比较操作。
在X86_64体系架构中大部分计算指令都会更新状态寄存器FLAGS register其中就有表示指令结果是否溢出的溢出标识位overflow flag。因此我们只需在加法指令之后比较溢出标志位便可以知道int值之和是否溢出了。对应的伪代码如下所示
```
public static int addExact(int x, int y) {
int r = x + y;
jo LABEL_OVERFLOW; // jump if overflow flag set
return r;
LABEL_OVERFLOW:
throw new ArithmeticException(&quot;integer overflow&quot;);
// or deoptimize
}
```
最后一个例子则是`Integer.bitCount`方法它将统计所输入的int值的二进制形式中有多少个1。
```
@HotSpotIntrinsicCandidate
public static int bitCount(int i) {
// HD, Figure 5-2
i = i - ((i &gt;&gt;&gt; 1) &amp; 0x55555555);
i = (i &amp; 0x33333333) + ((i &gt;&gt;&gt; 2) &amp; 0x33333333);
i = (i + (i &gt;&gt;&gt; 4)) &amp; 0x0f0f0f0f;
i = i + (i &gt;&gt;&gt; 8);
i = i + (i &gt;&gt;&gt; 16);
return i &amp; 0x3f;
}
```
我们可以看到,`Integer.bitCount`方法的实现还是很巧妙的但是它需要的计算步骤也比较多。在X86_64体系架构中我们仅需要一条指令`popcnt`便可以直接统计出int值中1的个数。
## intrinsic与方法内联
HotSpot虚拟机中intrinsic的实现方式分为两种。
一种是独立的桩程序。它既可以被解释执行器利用直接替换对原方法的调用也可以被即时编译器所利用它把代表对原方法的调用的IR节点替换为对这些桩程序的调用的IR节点。以这种形式实现的intrinsic比较少主要包括`Math`类中的一些方法。
另一种则是特殊的编译器IR节点。显然这种实现方式仅能够被即时编译器所利用。
在编译过程中即时编译器会将对原方法的调用的IR节点替换成特殊的IR节点并参与接下来的优化过程。最终即时编译器的后端将根据这些特殊的IR节点生成指定的CPU指令。大部分的intrinsic都是通过这种方式实现的。
这个替换过程是在方法内联时进行的。当即时编译器碰到方法调用节点时它将查询目标方法是不是intrinsic。
如果是则插入相应的特殊IR节点如果不是则进行原本的内联工作。即判断是否需要内联目标方法的方法体并在需要内联的情况下将目标方法的IR图纳入当前的编译范围之中。
也就是说如果方法调用的目标方法是intrinsic那么即时编译器会直接忽略原目标方法的字节码甚至根本不在乎原目标方法是否有字节码。即便是native方法只要它被标记为intrinsic即时编译器便能够将之&quot;内联&quot;进来并插入特殊的IR节点。
事实上不少被标记为intrinsic的方法都是native方法。原本对这些native方法的调用需要经过JNIJava Native Interface其性能开销十分巨大。但是经过即时编译器的intrinsic优化之后这部分JNI开销便直接消失不见并且最终的结果也十分高效。
举个例子,我们可以通过`Thread.currentThread`方法来获取当前线程。这是一个native方法同时也是一个HotSpot intrinsic。在X86_64体系架构中R13寄存器存放着当前线程的指针。因此对该方法的调用将被即时编译器替换为一个特殊IR节点并最终生成读取R13寄存器指令。
## 已有intrinsic简介
最新版本的HotSpot虚拟机定义了三百多个intrinsic。
在这三百多个intrinsic中有三成以上是`Unsafe`类的方法。不过,我们一般不会直接使用`Unsafe`类的方法,而是通过`java.util.concurrent`包来间接使用。
举个例子,`Unsafe`类中经常会被用到的便是`compareAndSwap`方法Java 9+更名为`compareAndSet``compareAndExchange`方法。在X86_64体系架构中对这些方法的调用将被替换为`lock cmpxchg`指令,也就是原子性更新指令。
除了`Unsafe`类的方法之外HotSpot虚拟机中的intrinsic还包括下面的几种。
1. `StringBuilder``StringBuffer`类的方法。HotSpot虚拟机将优化利用这些方法构造字符串的方式以尽量减少需要复制内存的情况。
<li>`String`类、`StringLatin1`类、`StringUTF16`类和`Arrays`类的方法。HotSpot虚拟机将使用SIMD指令single instruction multiple data即用一条指令处理多个数据对这些方法进行优化。<br />
举个例子,`Arrays.equals(byte[], byte[])`方法原本是逐个字节比较在使用了SIMD指令之后可以放入16字节的XMM寄存器中甚至是64字节的ZMM寄存器中批量比较。</li>
1. 基本类型的包装类、`Object`类、`Math`类、`System`类中各个功能性方法反射API、`MethodHandle`类中与调用机制相关的方法压缩、加密相关方法。这部分intrinsic则比较简单这里就不详细展开了。如果你有感兴趣的可以自行查阅资料或者在文末留言。
如果你想知道HotSpot虚拟机定义的所有intrinsic那么你可以直接查阅OpenJDK代码[2]。该链接是Java 12的intrinsic列表。Java 8的intrinsic列表可以查阅这一链接[3]。)
## 总结与实践
今天我介绍了HotSpot虚拟机中的intrinsic。
HotSpot虚拟机将对标注了`@HotSpotIntrinsicCandidate`注解的方法的调用替换为直接使用基于特定CPU指令的高效实现。这些方法我们便称之为intrinsic。
具体来说intrinsic的实现有两种。一是不大常见的桩程序可以在解释执行或者即时编译生成的代码中使用。二是特殊的IR节点。即时编译器将在方法内联过程中将对intrinsic的调用替换为这些特殊的IR节点并最终生成指定的CPU指令。
HotSpot虚拟机定义了三百多个intrinsic。其中比较特殊的有`Unsafe`类的方法基本上使用java.util.concurrent包便会间接使用到`Unsafe`类的intrinsic。除此之外`String`类和`Arrays`类中的intrinsic也比较特殊。即时编译器将为之生成非常高效的SIMD指令。
今天的实践环节,你可以体验一下`Integer.bitCount` intrinsic带来的性能提升。
```
// time java Foo
public class Foo {
public static int bitCount(int i) {
// HD, Figure 5-2
i = i - ((i &gt;&gt;&gt; 1) &amp; 0x55555555);
i = (i &amp; 0x33333333) + ((i &gt;&gt;&gt; 2) &amp; 0x33333333);
i = (i + (i &gt;&gt;&gt; 4)) &amp; 0x0f0f0f0f;
i = i + (i &gt;&gt;&gt; 8);
i = i + (i &gt;&gt;&gt; 16);
return i &amp; 0x3f;
}
public static void main(String[] args) {
int sum = 0;
for (int i = Integer.MIN_VALUE; i &lt; Integer.MAX_VALUE; i++) {
sum += bitCount(i); // In a second run, replace with Integer.bitCount
}
System.out.println(sum);
}
}
```
[1] [http://openjdk.java.net/jeps/254](http://openjdk.java.net/jeps/254)<br />
[2] [http://hg.openjdk.java.net/jdk/hs/file/46dc568d6804/src/hotspot/share/classfile/vmSymbols.hpp#l727](http://hg.openjdk.java.net/jdk/hs/file/46dc568d6804/src/hotspot/share/classfile/vmSymbols.hpp#l727)<br />
[3] [http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/2af8917ffbee/src/share/vm/classfile/vmSymbols.hpp#l647](http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/2af8917ffbee/src/share/vm/classfile/vmSymbols.hpp#l647)

View File

@@ -0,0 +1,285 @@
<audio id="audio" title="23 | 逃逸分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/0d/054b2598525125962d849f09ee3df40d.mp3"></audio>
我们知道Java中`Iterable`对象的foreach循环遍历是一个语法糖Java编译器会将该语法糖编译为调用`Iterable`对象的`iterator`方法,并用所返回的`Iterator`对象的`hasNext`以及`next`方法,来完成遍历。
```
public void forEach(ArrayList&lt;Object&gt; list, Consumer&lt;Object&gt; f) {
for (Object obj : list) {
f.accept(obj);
}
}
```
举个例子上面的Java代码将使用foreach循环来遍历一个`ArrayList`对象,其等价的代码如下所示:
```
public void forEach(ArrayList&lt;Object&gt; list, Consumer&lt;Object&gt; f) {
Iterator&lt;Object&gt; iter = list.iterator();
while (iter.hasNext()) {
Object obj = iter.next();
f.accept(obj);
}
}
```
这里我也列举了所涉及的`ArrayList`代码。我们可以看到,`ArrayList.iterator`方法将创建一个`ArrayList$Itr`实例。
```
public class ArrayList ... {
public Iterator&lt;E&gt; iterator() {
return new Itr();
}
private class Itr implements Iterator&lt;E&gt; {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
...
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings(&quot;unchecked&quot;)
public E next() {
checkForComodification();
int i = cursor;
if (i &gt;= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i &gt;= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
...
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
}
```
因此有同学认为我们应当避免在热点代码中使用foreach循环并且直接使用基于`ArrayList.size`以及`ArrayList.get`的循环方式如下所示以减少对Java堆的压力。
```
public void forEach(ArrayList&lt;Object&gt; list, Consumer&lt;Object&gt; f) {
for (int i = 0; i &lt; list.size(); i++) {
f.accept(list.get(i));
}
}
```
实际上Java虚拟机中的即时编译器可以将`ArrayList.iterator`方法中的实例创建操作给优化掉。不过,这需要方法内联以及逃逸分析的协作。
在前面几篇中我们已经深入学习了方法内联,今天我便来介绍一下逃逸分析。
## 逃逸分析
逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”(出处参见[1])。
在Java虚拟机的即时编译语境下逃逸分析将判断**新建**的对象是否**逃逸**。即时编译器判断对象是否逃逸的依据,一是对象是否被存入堆中(静态字段或者堆中对象的实例字段),二是对象是否被传入未知代码中。
前者很好理解:一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。
关于后者由于Java虚拟机的即时编译器是以方法为单位的对于方法中未被内联的方法调用即时编译器会将其当成未知代码毕竟它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中。因此我们可以认为方法调用的调用者以及参数是逃逸的。
通常来说,即时编译器里的逃逸分析是放在方法内联之后的,以便消除这些“未知代码”入口。
回到文章开头的例子。理想情况下,即时编译器能够内联对`ArrayList$Itr`构造器的调用,对`hasNext`以及`next`方法的调用,以及当内联了`Itr.next`方法后,对`checkForComodification`方法的调用。
如果这些方法调用均能够被内联,那么结果将近似于下面这段伪代码:
```
public void forEach(ArrayList&lt;Object&gt; list, Consumer&lt;Object&gt; f) {
Itr iter = new Itr; // 注意这里是new指令
iter.cursor = 0;
iter.lastRet = -1;
iter.expectedModCount = list.modCount;
while (iter.cursor &lt; list.size) {
if (list.modCount != iter.expectedModCount)
throw new ConcurrentModificationException();
int i = iter.cursor;
if (i &gt;= list.size)
throw new NoSuchElementException();
Object[] elementData = list.elementData;
if (i &gt;= elementData.length)
throw new ConcurrentModificationException();
iter.cursor = i + 1;
iter.lastRet = i;
Object obj = elementData[i];
f.accept(obj);
}
}
```
可以看到,这段代码所新建的`ArrayList$Itr`实例既没有被存入任何字段之中,也没有作为任何方法调用的调用者或者参数。因此,逃逸分析将断定该实例不逃逸。
## 基于逃逸分析的优化
即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。
我们先来看一下锁消除。如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。
实际上传统编译器仅需证明锁对象不逃逸出线程便可以进行锁消除。由于Java虚拟机即时编译的限制上述条件被强化为证明锁对象不逃逸出当前编译的方法。
在介绍Java内存模型时我曾提过`synchronized (new Object()) {}`会被完全优化掉。这正是因为基于逃逸分析的锁消除。由于其他线程不能获得该锁对象因此也无法基于该锁对象构造两个线程之间的happens-before规则。
`synchronized (escapedObject) {}`则不然。由于其他线程可能会对逃逸了的对象`escapedObject`进行加锁操作从而构造了两个线程之间的happens-before关系。因此即时编译器至少需要为这段代码生成一条刷新缓存的内存屏障指令。
不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁。事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。
我们知道Java虚拟机中对象都是在堆上分配的而堆上的内容对任何线程都是可见的。与此同时Java虚拟机需要对所分配的堆内存进行管理并且在对象不再被引用时回收其所占据的内存。
如果逃逸分析能够证明某些新建的对象不逃逸那么Java虚拟机完全可以将其分配至栈上并且在new语句所在的方法退出时通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来我们便无须借助垃圾回收器来处理不再被引用的对象。
不过由于实现起来需要更改大量假设了“对象只能堆分配”的代码因此HotSpot虚拟机**并没有**采用栈上分配,而是使用了标量替换这么一项技术。
所谓的标量就是仅能存储一个值的变量比如Java代码中的局部变量。与之相反聚合量则可能同时存储多个值其中一个典型的例子便是Java对象。
标量替换这项优化技术可以看成将原本对对象的字段的访问替换为一个个局部变量的访问。举例来说前面经过内联之后的forEach代码可以被转换为如下代码
```
public void forEach(ArrayList&lt;Object&gt; list, Consumer&lt;Object&gt; f) {
// Itr iter = new Itr; // 经过标量替换后该分配无意义,可以被优化掉
int cursor = 0; // 标量替换
int lastRet = -1; // 标量替换
int expectedModCount = list.modCount; // 标量替换
while (cursor &lt; list.size) {
if (list.modCount != expectedModCount)
throw new ConcurrentModificationException();
int i = cursor;
if (i &gt;= list.size)
throw new NoSuchElementException();
Object[] elementData = list.elementData;
if (i &gt;= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
lastRet = i;
Object obj = elementData[i];
f.accept(obj);
}
}
```
可以看到,原本需要在内存中连续分布的对象,现已被拆散为一个个单独的字段`cursor``lastRet`,以及`expectedModCount`。这些字段既可以存储在栈上,也可以直接存储在寄存器中。而该对象的对象头信息则直接消失了,不再被保存至内存之中。
由于该对象没有被实际分配,因此和栈上分配一样,它同样可以减轻垃圾回收的压力。与栈上分配相比,它对字段的内存连续性不做要求,而且,这些字段甚至可以直接在寄存器中维护,无须浪费任何内存空间。
## 部分逃逸分析
C2的逃逸分析与控制流无关相对来说比较简单。Graal则引入了一个与控制流有关的逃逸分析名为部分逃逸分析partial escape analysis[2]。它解决了所新建的实例仅在部分程序路径中逃逸的情况。
举个例子在下面这段代码中新建实例只会在进入if-then分支时逃逸。`hashCode`方法的调用是一个HotSpot intrinsic将被替换为一个无法内联的本地方法调用。
```
public static void bar(boolean cond) {
Object foo = new Object();
if (cond) {
foo.hashCode();
}
}
// 可以手工优化为:
public static void bar(boolean cond) {
if (cond) {
Object foo = new Object();
foo.hashCode();
}
}
```
假设if语句的条件成立的可能性只有1%那么在99%的情况下,程序没有必要新建对象。其手工优化的版本正是部分逃逸分析想要自动达到的成果。
部分逃逸分析将根据控制流信息判断出新建对象仅在部分分支中逃逸并且将对象的新建操作推延至对象逃逸的分支中。这将使得原本因对象逃逸而无法避免的新建对象操作不再出现在只执行if-else分支的程序路径之中。
综上与C2所使用的逃逸分析相比Graal所使用的部分逃逸分析能够优化更多的情况不过它编译时间也更长一些。
## 总结与实践
今天我介绍了Java虚拟机中即时编译器的逃逸分析以及基于逃逸分析的优化。
在Java虚拟机的即时编译语境下逃逸分析将判断新建的对象是否会逃逸。即时编译器判断对象逃逸的依据有两个一是看对象是否被存入堆中二是看对象是否作为方法调用的调用者或者参数。
即时编译器会根据逃逸分析的结果进行优化,如锁消除以及标量替换。后者指的是将原本连续分配的对象拆散为一个个单独的字段,分布在栈上或者寄存器中。
部分逃逸分析是一种附带了控制流信息的逃逸分析。它将判断新建对象真正逃逸的分支,并且支持将新建操作推延至逃逸分支。
今天的实践环节有两项内容。
第一项内容,我们来验证一下`ArrayList.iterator`中的新建对象能否被逃逸分析所优化。运行下述代码并观察GC的情况。你可以通过虚拟机参数`-XX:-DoEscapeAnalysis`来关闭默认开启的逃逸分析。
```
// Run with
// java -XX:+PrintGC -XX:+DoEscapeAnalysis EscapeTest
import java.util.ArrayList;
import java.util.function.Consumer;
public class EscapeTest {
public static void forEach(ArrayList&lt;Object&gt; list, Consumer&lt;Object&gt; f) {
for (Object obj : list) {
f.accept(obj);
}
}
public static void main(String[] args) {
ArrayList&lt;Object&gt; list = new ArrayList&lt;&gt;();
for (int i = 0; i &lt; 100; i++) {
list.add(i);
}
for (int i = 0; i &lt; 400_000_000; i++) {
forEach(list, obj -&gt; {});
}
}
}
```
第二项内容我们来看一看部分逃逸分析的效果。你需要使用附带Graal编译器的Java版本如Java 10来运行下述代码并且观察GC的情况。你可以通过虚拟机参数`-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler`来启用Graal。
```
// Run with
// java -Xlog:gc Foo
// java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -Xlog:gc Foo
public class Foo {
long placeHolder0;
long placeHolder1;
long placeHolder2;
long placeHolder3;
long placeHolder4;
long placeHolder5;
long placeHolder6;
long placeHolder7;
long placeHolder8;
long placeHolder9;
long placeHoldera;
long placeHolderb;
long placeHolderc;
long placeHolderd;
long placeHoldere;
long placeHolderf;
public static void bar(boolean condition) {
Foo foo = new Foo();
if (condition) {
foo.hashCode();
}
}
public static void main(String[] args) {
for (int i = 0; i &lt; Integer.MAX_VALUE; i++) {
bar(i % 100 == 0);
}
}
}
```
[1] [https://zh.wikipedia.org/wiki/逃逸分析](https://zh.wikipedia.org/wiki/%E9%80%83%E9%80%B8%E5%88%86%E6%9E%90)<br />
[2] [http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf](http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf)

View File

@@ -0,0 +1,520 @@
<audio id="audio" title="【工具篇】 常用工具介绍" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/08/bb192dd3752a1a6577e4653532582b08.mp3"></audio>
在前面的文章中,我曾使用了不少工具来辅助讲解,也收到了不少同学留言,说不了解这些工具,不知道都有什么用,应该怎么用。那么今天我便统一做一次具体的介绍。本篇代码较多,你可以点击文稿查看。
## javap查阅Java字节码
javap是一个能够将class文件反汇编成人类可读格式的工具。在本专栏中我们经常借助这个工具来查阅Java字节码。
举个例子,在讲解异常处理那一篇中,我曾经展示过这么一段代码。
```
public class Foo {
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}
```
编译过后我们便可以使用javap来查阅Foo.test方法的字节码。
```
$ javac Foo.java
$ javap -p -v Foo
Classfile ../Foo.class
Last modified ..; size 541 bytes
MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
Compiled from &quot;Foo.java&quot;
public class Foo
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // Foo
super_class: #8 // java/lang/Object
interfaces: 0, fields: 4, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #8.#23 // java/lang/Object.&quot;&lt;init&gt;&quot;:()V
#2 = Fieldref #7.#24 // Foo.tryBlock:I
#3 = Fieldref #7.#25 // Foo.finallyBlock:I
#4 = Class #26 // java/lang/Exception
#5 = Fieldref #7.#27 // Foo.catchBlock:I
#6 = Fieldref #7.#28 // Foo.methodExit:I
#7 = Class #29 // Foo
#8 = Class #30 // java/lang/Object
#9 = Utf8 tryBlock
#10 = Utf8 I
#11 = Utf8 catchBlock
#12 = Utf8 finallyBlock
#13 = Utf8 methodExit
#14 = Utf8 &lt;init&gt;
#15 = Utf8 ()V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 test
#19 = Utf8 StackMapTable
#20 = Class #31 // java/lang/Throwable
#21 = Utf8 SourceFile
#22 = Utf8 Foo.java
#23 = NameAndType #14:#15 // &quot;&lt;init&gt;&quot;:()V
#24 = NameAndType #9:#10 // tryBlock:I
#25 = NameAndType #12:#10 // finallyBlock:I
#26 = Utf8 java/lang/Exception
#27 = NameAndType #11:#10 // catchBlock:I
#28 = NameAndType #13:#10 // methodExit:I
#29 = Utf8 Foo
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/Throwable
{
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int catchBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int finallyBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int methodExit;
descriptor: I
flags: (0x0002) ACC_PRIVATE
public Foo();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object.&quot;&lt;init&gt;&quot;:()V
4: return
LineNumberTable:
line 1: 0
public void test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: iconst_0
2: putfield #2 // Field tryBlock:I
5: aload_0
6: iconst_2
7: putfield #3 // Field finallyBlock:I
10: goto 35
13: astore_1
14: aload_0
15: iconst_1
16: putfield #5 // Field catchBlock:I
19: aload_0
20: iconst_2
21: putfield #3 // Field finallyBlock:I
24: goto 35
27: astore_2
28: aload_0
29: iconst_2
30: putfield #3 // Field finallyBlock:I
33: aload_2
34: athrow
35: aload_0
36: iconst_3
37: putfield #6 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang<!-- [[[read_end]]] -->/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 9: 0
line 13: 5
line 14: 10
line 10: 13
line 11: 14
line 13: 19
line 14: 24
line 13: 27
line 14: 33
line 15: 35
line 16: 40
StackMapTable: number_of_entries = 3
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
frame_type = 7 /* same */
}
SourceFile: &quot;Foo.java&quot;
```
这里面我用到了两个选项。第一个选项是-p。默认情况下javap会打印所有非私有的字段和方法当加了-p选项后它还将打印私有的字段和方法。第二个选项是-v。它尽可能地打印所有信息。如果你只需要查阅方法对应的字节码那么可以用-c选项来替换-v。
javap的-v选项的输出分为几大块。
1.基本信息涵盖了原class文件的相关信息。
class文件的版本号minor version: 0major version: 54该类的访问权限flags: (0x0021) ACC_PUBLIC, ACC_SUPER该类this_class: #7以及父类super_class: #8的名字所实现接口interfaces: 0、字段fields: 4、方法methods: 2以及属性attributes: 1的数目。
这里属性指的是class文件所携带的辅助信息比如该class文件的源文件的名称。这类信息通常被用于Java虚拟机的验证和运行以及Java程序的调试一般无须深入了解。
```
Classfile ../Foo.class
Last modified ..; size 541 bytes
MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
Compiled from &quot;Foo.java&quot;
public class Foo
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // Foo
super_class: #8 // java/lang/Object
interfaces: 0, fields: 4, methods: 2, attributes: 1
```
class文件的版本号指的是编译生成该class文件时所用的JRE版本。由较新的JRE版本中的javac编译而成的class文件不能在旧版本的JRE上跑否则会出现如下异常信息。Java 8对应的版本号为52Java 10对应的版本号为54。
```
Exception in thread &quot;main&quot; java.lang.UnsupportedClassVersionError: Foo has been compiled by a more recent version of the Java Runtime (class file version 54.0), this version of the Java Runtime only recognizes class file versions up to 52.0
```
类的访问权限通常为ACC_开头的常量。具体每个常量的意义可以查阅Java虚拟机规范4.1小节[1]。
2.常量池,用来存放各种常量以及符号引用。
常量池中的每一项都有一个对应的索引(如#1),并且可能引用其他的常量池项(#1 = Methodref #8.#23)。
```
Constant pool:
#1 = Methodref #8.#23 // java/lang/Object.&quot;&lt;init&gt;&quot;:()V
...
#8 = Class #30 // java/lang/Object
...
#14 = Utf8 &lt;init&gt;
#15 = Utf8 ()V
...
#23 = NameAndType #14:#15 // &quot;&lt;init&gt;&quot;:()V
...
#30 = Utf8 java/lang/Object
```
举例来说上图中的1号常量池项是一个指向Object类构造器的符号引用。它是由另外两个常量池项所构成。如果将它看成一个树结构的话那么它的叶节点会是字符串常量如下图所示。
<img src="https://static001.geekbang.org/resource/image/f8/8c/f87469e321c52b21b0d2abb88e7b288c.png" alt="" />
3.字段区域,用来列举该类中的各个字段。
这里最主要的信息便是该字段的类型descriptor: I以及访问权限flags: (0x0002) ACC_PRIVATE。对于声明为final的静态字段而言如果它是基本类型或者字符串类型那么字段区域还将包括它的常量值。
```
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
```
另外Java虚拟机同样使用了“描述符”descriptor来描述字段的类型。具体的对照如下表所示。其中比较特殊的我已经高亮显示。
4.方法区域,用来列举该类中的各个方法。
除了方法描述符以及访问权限之外每个方法还包括最为重要的代码区域Code:)。
```
public void test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
...
10: goto 35
...
34: athrow
35: aload_0
...
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 9: 0
...
line 16: 40
StackMapTable: number_of_entries = 3
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
...
```
代码区域一开始会声明该方法中的操作数栈stack=2和局部变量数目locals=3的最大值以及该方法接收参数的个数args_size=1。注意这里局部变量指的是字节码中的局部变量而非Java程序中的局部变量。
接下来则是该方法的字节码。每条字节码均标注了对应的偏移量bytecode indexBCI这是用来定位字节码的。比如说偏移量为10的跳转字节码10: goto 35将跳转至偏移量为35的字节码35: aload_0。
紧跟着的异常表Exception table:也会使用偏移量来定位每个异常处理器所监控的范围由from到to的代码区域以及异常处理器的起始位置target。除此之外它还会声明所捕获的异常类型type。其中any指代任意异常类型。
再接下来的行数表LineNumberTable:则是Java源程序到字节码偏移量的映射。如果你在编译时使用了-g参数javac -g Foo.java那么这里还将出现局部变量表LocalVariableTable:展示Java程序中每个局部变量的名字、类型以及作用域。
行数表和局部变量表均属于调试信息。Java虚拟机并不要求class文件必备这些信息。
```
LocalVariableTable:
Start Length Slot Name Signature
14 5 1 e Ljava/lang/Exception;
0 41 0 this LFoo;
```
最后则是字节码操作数栈的映射表StackMapTable: number_of_entries = 3。该表描述的是字节码跳转后操作数栈的分布情况一般被Java虚拟机用于验证所加载的类以及即时编译相关的一些操作正常情况下你无须深入了解。
## 2.OpenJDK项目Code Tools实用小工具集
OpenJDK的Code Tools项目[2]包含了好几个实用的小工具。
在第一篇的实践环节中我们使用了其中的字节码汇编器反汇编器ASMTools[3]当前6.0版本的下载地址位于[4]。ASMTools的反汇编以及汇编操作所对应的命令分别为
```
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class &gt; Foo.jasm
```
```
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
```
该反汇编器的输出格式和javap的不尽相同。一般我只使用它来进行一些简单的字节码修改以此生成无法直接由Java编译器生成的类它在HotSpot虚拟机自身的测试中比较常见。
在第一篇的实践环节中我们需要将整数2赋值到一个声明为boolean类型的局部变量中。我采取的做法是将编译生成的class文件反汇编至一个文本文件中然后找到boolean flag = true对应的字节码序列也就是下面的两个。
```
iconst_1;
istore_1;
```
将这里的iconst_1改为iconst_2[5]保存后再汇编至class文件即可完成第一篇实践环节的需求。
除此之外你还可以利用这一套工具来验证我之前文章中的一些结论。比如我说过class文件允许出现参数类型相同、而返回类型不同的方法并且在作为库文件时Java编译器将使用先定义的那一个来决定具体的返回类型。
具体的验证方法便是在反汇编之后利用文本编辑工具复制某一方法并且更改该方法的描述符保存后再汇编至class文件。
Code Tools项目还包含另一个实用的小工具JOL[6]当前0.9版本的下载地址位于[7]。JOL可用于查阅Java虚拟机中对象的内存分布具体可通过如下两条指令来实现。
```
$ java -jar /path/to/jol-cli-0.9-full.jar internals java.util.HashMap
$ java -jar /path/to/jol-cli-0.9-full.jar estimates java.util.HashMap
```
## 3.ASMJava字节码框架
ASM[8]是一个字节码分析及修改框架。它被广泛应用于许多项目之中例如Groovy、Kotlin的编译器代码覆盖测试工具Cobertura、JaCoCo以及各式各样通过字节码注入实现的程序行为监控工具。甚至是Java 8中Lambda表达式的适配器类也是借助ASM来动态生成的。
ASM既可以生成新的class文件也可以修改已有的class文件。前者相对比较简单一些。ASM甚至还提供了一个辅助类ASMifier它将接收一个class文件并且输出一段生成该class文件原始字节数组的代码。如果你想快速上手ASM的话那么你可以借助ASMifier生成的代码来探索各个API的用法。
下面我将借助ASMifier来生成第一篇实践环节所用到的类。你可以通过该地址[9]下载6.0-beta版。
```
$ echo '
public class Foo {
public static void main(String[] args) {
boolean flag = true;
if (flag) System.out.println(&quot;Hello, Java!&quot;);
if (flag == true) System.out.println(&quot;Hello, JVM!&quot;);
}
}' &gt; Foo.java
# 这里的javac我使用的是Java 8版本的。ASM 6.0可能暂不支持新版本的javac编译出来的class文件
$ javac Foo.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar org.objectweb.asm.util.ASMifier Foo.class | tee FooDump.java
...
public class FooDump implements Opcodes {
public static byte[] dump () throws Exception {
ClassWriter cw = new ClassWriter(0);
FieldVisitor fv;
MethodVisitor mv;
AnnotationVisitor av0;
cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, &quot;Foo&quot;, null, &quot;java/lang/Object&quot;, null);
...
{
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, &quot;main&quot;, &quot;([Ljava/lang/String;)V&quot;, null, null);
mv.visitCode();
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
mv.visitVarInsn(ILOAD, 1);
...
mv.visitInsn(RETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();
}
...
```
可以看到ASMifier生成的代码中包含一个名为FooDump的类其中定义了一个名为dump的方法。该方法将返回一个byte数组其值为生成类的原始字节。
在dump方法中我们新建了功能类ClassWriter的一个实例并通过它来访问不同的成员例如方法、字段等等。
每当访问一种成员我们便会得到另一个访问者。在上面这段代码中当我们访问方法时即visitMethod便会得到一个MethodVisitor。在接下来的代码中我们会用这个MethodVisitor来访问这里等同于生成具体的指令。
这便是ASM所使用的访问者模式。当然这段代码仅包含ClassWriter这一个访问者因此看不出具体有什么好处。
我们暂且不管这个访问者模式先来看看如何实现第一篇课后实践的要求。首先main方法中的boolean flag = true;语句对应的代码是:
```
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
```
也就是说我们只需将这里的ICONST_1更改为ICONST_2便可以满足要求。下面我用另一个类Wrapper来调用修改过后的FooDump.dump方法。
```
$ echo 'import java.nio.file.*;
public class Wrapper {
public static void main(String[] args) throws Exception {
Files.write(Paths.get(&quot;Foo.class&quot;), FooDump.dump());
}
}' &gt; Wrapper.java
$ javac -cp /PATH/TO/asm-all-6.0_BETA.jar FooDump.java Wrapper.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar:. Wrapper
$ java Foo
```
这里的输出结果应和通过ASMTools修改的结果一致。
通过ASM来修改已有class文件则相对复杂一些。不过我们可以从下面这段简单的代码来开始学起
```
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader(&quot;Foo&quot;);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cr.accept(cw, ClassReader.SKIP_FRAMES);
Files.write(Paths.get(&quot;Foo.class&quot;), cw.toByteArray());
}
```
这段代码的功能便是读取一个class文件将之转换为ASM的数据结构然后再转换为原始字节数组。其中我使用了两个功能类。除了已经介绍过的ClassWriter外还有一个ClassReader。
ClassReader将读取“Foo”类的原始字节并且翻译成对应的访问请求。也就是说在上面ASMifier生成的代码中的各个访问操作现在都交给ClassReader.accept这一方法来发出了。
那么如何修改这个class文件的字节码呢原理很简单就是将ClassReader的访问请求发给另外一个访问者再由这个访问者委派给ClassWriter。
这样一来,新增操作可以通过在某一需要转发的请求后面附带新的请求来实现;删除操作可以通过不转发请求来实现;修改操作可以通过忽略原请求,新建并发出另外的请求来实现。
<img src="https://static001.geekbang.org/resource/image/2a/ce/2a5d6813e32b8f88abae2b9f7b151fce.png" alt="" />
```
import java.nio.file.*;
import org.objectweb.asm.*;
public class ASMHelper implements Opcodes {
static class MyMethodVisitor extends MethodVisitor {
private MethodVisitor mv;
public MyMethodVisitor(int api, MethodVisitor mv) {
super(api, null);
this.mv = mv;
}
@Override
public void visitCode() {
mv.visitCode();
mv.visitFieldInsn(GETSTATIC, &quot;java/lang/System&quot;, &quot;out&quot;, &quot;Ljava/io/PrintStream;&quot;);
mv.visitLdcInsn(&quot;Hello, World!&quot;);
mv.visitMethodInsn(INVOKEVIRTUAL, &quot;java/io/PrintStream&quot;, &quot;println&quot;, &quot;(Ljava/lang/String;)V&quot;, false);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 1);
mv.visitEnd();
}
}
static class MyClassVisitor extends ClassVisitor {
public MyClassVisitor(int api, ClassVisitor cv) {
super(api, cv);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
String[] exceptions) {
MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if (&quot;main&quot;.equals(name)) {
return new MyMethodVisitor(ASM6, visitor);
}
return visitor;
}
}
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader(&quot;Foo&quot;);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new MyClassVisitor(ASM6, cw);
cr.accept(cv, ClassReader.SKIP_FRAMES);
Files.write(Paths.get(&quot;Foo.class&quot;), cw.toByteArray());
}
}
```
这里我贴了一段代码在ClassReader和ClassWriter中间插入了一个自定义的访问者MyClassVisitor。它将截获由ClassReader发出的对名字为“main”的方法的访问请求并且替换为另一个自定义的MethodVisitor。
这个MethodVisitor会忽略由ClassReader发出的任何请求仅在遇到visitCode请求时生成一句“System.out.println(“Hello World!”);”。
由于篇幅的限制我就不继续深入介绍下去了。如果你对ASM有浓厚的兴趣可以参考这篇教程[10]。
你对这些常用工具还有哪些问题呢?可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。
[1]<br />
[https://docs.oracle.com/javase/specs/jvms/se10/html/jvms-4.html#jvms-4.1](https://docs.oracle.com/javase/specs/jvms/se10/html/jvms-4.html#jvms-4.1)<br />
[2]<br />
[http://openjdk.java.net/projects/code-tools/](http://openjdk.java.net/projects/code-tools/)<br />
[3]<br />
[https://wiki.openjdk.java.net/display/CodeTools/asmtools](https://wiki.openjdk.java.net/display/CodeTools/asmtools)<br />
[4]<br />
[https://adopt-openjdk.ci.cloudbees.com/view/OpenJDK/job/asmtools/lastSuccessfulBuild/artifact/asmtools-6.0.tar.gz](https://adopt-openjdk.ci.cloudbees.com/view/OpenJDK/job/asmtools/lastSuccessfulBuild/artifact/asmtools-6.0.tar.gz)<br />
[5]<br />
[https://cs.au.dk/~mis/dOvs/jvmspec/ref--21.html](https://cs.au.dk/~mis/dOvs/jvmspec/ref--21.html)<br />
[6]<br />
[http://openjdk.java.net/projects/code-tools/jol/](http://openjdk.java.net/projects/code-tools/jol/)<br />
[7]<br />
[http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar](http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar)<br />
[8]<br />
[https://asm.ow2.io/](https://asm.ow2.io/)<br />
[9]<br />
[https://repository.ow2.org/nexus/content/repositories/releases/org/ow2/asm/asm-all/6.0_BETA/asm-all-6.0_BETA.jar](https://repository.ow2.org/nexus/content/repositories/releases/org/ow2/asm/asm-all/6.0_BETA/asm-all-6.0_BETA.jar)<br />
[10]<br />
[http://web.cs.ucla.edu/~msb/cs239-tutorial/](http://web.cs.ucla.edu/~msb/cs239-tutorial/)