mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
230
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/13 | Java内存模型.md
Normal file
230
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/13 | Java内存模型.md
Normal 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。请问(r1,r2)的可能值都有哪些?
|
||||
|
||||
在单线程环境下,我们可以先调用第一个方法,最终(r1,r2)为(1,0);也可以先调用第二个方法,最终为(0,2)。
|
||||
|
||||
在多线程环境下,假设这两个方法分别跑在两个不同的线程之上,如果Java虚拟机在执行了任一方法的第一条赋值语句之后便切换线程,那么最终结果将可能出现(0,0)的情况。
|
||||
|
||||
除上述三种情况之外,Java语言规范第17.4小节[1]还介绍了一种看似不可能的情况(1,2)。
|
||||
|
||||
造成这一情况的原因有三个,分别为即时编译器的重排序,处理器的乱序执行,以及内存系统的重排序。由于后两种原因涉及具体的体系架构,我们暂且放到一边。下面我先来讲一下编译器优化的重排序是怎么一回事。
|
||||
|
||||
首先需要说明一点,即时编译器(和处理器)需要保证程序能够遵守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的保证,我们无须担心顺序执行不可能发生的情况,如(r1,r2)=(1,2)。
|
||||
|
||||
然而,在多线程情况下,这种数据竞争(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的顺序得到(1,2)的结果。
|
||||
|
||||
那么如何解决这个问题呢?答案是,将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字段的情况下,程序不可能出现(r1,r2)为(1,2)的情况。
|
||||
|
||||
由此可以看出,解决这种数据竞争问题的关键在于构造一个跨线程的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 3,8.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 = {"0, 0", "0, 2", "1, 0"}, expect = Expect.ACCEPTABLE, desc = "Normal outcome")
|
||||
@Outcome(id = {"1, 2"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "Abnormal outcome")
|
||||
@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;
|
||||
}
|
||||
}' > 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)
|
||||
|
||||
|
||||
196
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/14 | Java虚拟机是怎么实现synchronized的?.md
Normal file
196
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/14 | Java虚拟机是怎么实现synchronized的?.md
Normal 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虚拟机会尝试用CAS(compare-and-swap)操作替换锁对象的标记字段。这里解释一下,CAS是一个原子操作,它会比较目标地址的值是否和期望值相等,如果相等,则替换为一个新的值。
|
||||
|
||||
假设当前锁对象的标记字段为X…XYZ,Java虚拟机会比较该字段是否为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 < 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)
|
||||
|
||||
|
||||
309
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/15 | Java语法糖与Java编译器.md
Normal file
309
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/15 | Java语法糖与Java编译器.md
Normal 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<Integer> list = new ArrayList<>();
|
||||
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."<init>":()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 >= IntegerCache.low && i <= 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<T extends Number> {
|
||||
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<Integer> list = new ArrayList<>();
|
||||
list.add("0"); // 编译出错
|
||||
|
||||
```
|
||||
|
||||
## 桥接方法
|
||||
|
||||
泛型的类型擦除带来了不少问题。其中一个便是方法重写。在第四篇的课后实践中,我留了这么一段代码:
|
||||
|
||||
```
|
||||
class Merchant<T extends Customer> {
|
||||
public double actionPrice(T customer) {
|
||||
return 0.0d;
|
||||
}
|
||||
}
|
||||
|
||||
class VIPOnlyMerchant extends Merchant<VIP> {
|
||||
@Override
|
||||
public double actionPrice(VIP customer) {
|
||||
return 0.0d;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
VIPOnlyMerchant中的actionPrice方法是符合Java语言的方法重写的,毕竟都使用@Override来注解了。然而,经过类型擦除后,父类的方法描述符为(LCustomer;)D,而子类的方法描述符为(LVIP;)D。这显然不符合Java虚拟机关于方法重写的定义。
|
||||
|
||||
为了保证编译而成的Java字节码能够保留重写的语义,Java编译器额外添加了一个桥接方法。该桥接方法在字节码层面重写了父类的方法,并将调用子类的方法。
|
||||
|
||||
```
|
||||
class VIPOnlyMerchant extends Merchant<VIP>
|
||||
...
|
||||
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 < length; i++) {
|
||||
int item = myArray[i];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于Iterable对象来说,foreach循环将调用其iterator方法,并且用它的hasNext以及next方法来遍历该Iterable对象中的元素。其等价的代码如下面所示:
|
||||
|
||||
```
|
||||
public void foo(ArrayList<Integer> list) {
|
||||
for (Integer item : list) {
|
||||
}
|
||||
}
|
||||
// 等同于
|
||||
public void bar(ArrayList<Integer> list) {
|
||||
Iterator<Integer> 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<Integer>();
|
||||
list.add(value);
|
||||
// list.add("1"); 这一句能够编译吗?
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
|
||||
195
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/16 | 即时编译(上).md
Normal file
195
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/16 | 即时编译(上).md
Normal 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层 > 2层 > 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 < 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为5,Tier4LoadFeedback为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 > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s)
|
||||
|
||||
```
|
||||
|
||||
其中i为调用次数,b为循环回边次数。
|
||||
|
||||
## OSR编译
|
||||
|
||||
可以看到,决定一个方法是否为热点代码的因素有两个:方法的调用次数、循环回边的执行次数。即时编译便是根据这两个计数器的和来触发的。为什么Java虚拟机需要维护两个不同的计数器呢?
|
||||
|
||||
实际上,除了以方法为单位的即时编译之外,Java虚拟机还存在着另一种以循环为单位的即时编译,叫做On-Stack-Replacement(OSR)编译。循环回边计数器便是用来触发这种类型的编译的。
|
||||
|
||||
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(是否阻塞了应用线程,可了解一下参数-Xbatch),n(是否为native方法)。再接下来则是编译层次,以及方法名。如果是OSR编译,那么方法名后面还会跟着@以及循环所在的字节码。
|
||||
|
||||
当发生去优化时,你将看到之前出现过的编译,不过被标记了“made not entrant"。它表示该方法不能再被进入。
|
||||
|
||||
当Java虚拟机检测到所有的线程都退出该编译后的“made not entrant”时,会将该方法标记为“made zombie”,此时可以回收这块代码所占据的空间了。<br />
|
||||
|
||||
305
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/17 | 即时编译(下).md
Normal file
305
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/17 | 即时编译(下).md
Normal 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编译的数据,比如说分支跳转字节码的分支profile(branch profile),包括跳转次数和不跳转次数,以及非私有实例方法调用指令、强制类型转换checkcast指令、类型测试instanceof指令,和引用类型的数组存储aastore指令的类型profile(receiver 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 < 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 < 500000; i++) {
|
||||
hash(i);
|
||||
}
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
199
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/18 | 即时编译器的中间表达形式.md
Normal file
199
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/18 | 即时编译器的中间表达形式.md
Normal 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)
|
||||
|
||||
在编译原理课程中,我们通常将编译器分为前端和后端。其中,前端会对所输入的程序进行词法分析、语法分析、语义分析,然后生成中间表达形式,也就是IR(Intermediate Representation )。后端会对IR进行优化,然后生成目标代码。
|
||||
|
||||
如果不考虑解释执行的话,从Java源代码到最终的机器码实际上经过了两轮编译:Java编译器将Java源代码编译成Java字节码,而即时编译器则将Java字节码编译成机器码。
|
||||
|
||||
对于即时编译器来说,所输入的Java字节码剥离了很多高级的Java语法,而且其采用的基于栈的计算模型非常容易建模。因此,即时编译器并不需要重新进行词法分析、语法分析以及语义分析,而是直接将Java字节码作为一种IR。
|
||||
|
||||
不过,Java字节码本身并不适合直接作为可供优化的IR。这是因为现代编译器一般采用静态单赋值(Static Single Assignment,SSA)IR。这种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<<1)+x1
|
||||
if(2>1){y1=1;}else{y2=1;}经过死代码删除后变为y1=1
|
||||
|
||||
```
|
||||
|
||||
部分同学可能会手动进行上述优化,以期望能够达到更高的运行效率。实际上,对于这些简单的优化,编译器会代为执行,以便程序员专注于代码的可读性。
|
||||
|
||||
SSA IR会带来一个问题,那便是不同执行路径可能会对同一变量设置不同的值。例如下面这段代码if语句的两个分支中,变量y分别被赋值为0或1,并且在接下来的代码中读取y的值。此时,根据不同的执行路径,所读取到的值也很有可能不同。
|
||||
|
||||
```
|
||||
x = ..;
|
||||
if (x > 0) {
|
||||
y = 0;
|
||||
} else {
|
||||
y = 1;
|
||||
}
|
||||
x = y;
|
||||
|
||||
```
|
||||
|
||||
为了解决这个问题,我们需要引入一个Phi函数的概念,能够根据不同的执行路径选择不同的值。于是,上面这段代码便可以转换为下面这段SSA伪代码。这里的Phi函数将根据前面两个分支分别选择y1、y2的值,并赋值给y3。
|
||||
|
||||
```
|
||||
x1 = ..;
|
||||
if (x1 > 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 < 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<count被转换为10号<节点,其接收两个值,分别为代表i的8号Phi节点,以及代表输入第0个参数的1号P(0)节点。
|
||||
|
||||
关于8号Phi节点,前面讲过,它将根据不同的执行路径选择不同的值。如果是从5号End节点进入的,则选择常量0;如果是从20号LoopEnd节点跳转进入的,则选择19号+节点。
|
||||
|
||||
你可以自己分析一下代表sum的7号Phi节点,根据不同的执行路径都选择了哪些值。
|
||||
|
||||
浮动节点的位置并不固定。在编译过程中,编译器需要(多次)计算浮动节点具体的排布位置。这个过程我们称之为节点调度(node scheduling)。
|
||||
|
||||
节点调度是根据节点之间的依赖关系来进行的。举个例子,在前面的IR图中,10号<节点是16号if节点用来判断是否跳转的条件,因此它需要排布在16号if节点(注意这是一个固定节点)之前。同时它又依赖于8号Phi节点的值以及1号P(0)节点的值,因此它需要排布在这两个节点之后。
|
||||
|
||||
需要注意的是,C2没有固定节点这一概念,所有的IR节点都是浮动节点。它将根据各个基本块头尾之间的控制依赖,以及数据依赖和内存依赖,来进行节点调度。
|
||||
|
||||
这里的内存依赖是什么一个概念呢?假设一段程序往内存中存储了一个值,而后又读取同一内存,那么显然程序希望读取到的是所存储的值。即时编译器不能任意调度对同一内存地址的读写,因为它们之间存在依赖关系。
|
||||
|
||||
C2的做法便是将这种时序上的先后记录为内存依赖,并让节点调度算法在进行调度时考虑这些内存依赖关系。Graal则将内存读写转换成固定节点。由于固定节点存在先后关系,因此无须额外记录内存依赖。
|
||||
|
||||
## 3. Global Value Numbering
|
||||
|
||||
下面介绍一种因Sea-of-Nodes而变得非常容易的优化技术 —— Global Value Numbering(GVN)。
|
||||
|
||||
GVN是一种发现并消除等价计算的优化技术。举例来说,如果一段程序中出现了多次操作数相同的乘法,那么即时编译器可以将这些乘法并为一个,从而降低输出机器码的大小。如果这些乘法出现在同一执行路径上,那么GVN还将省下冗余的乘法操作。
|
||||
|
||||
在Sea-of-Nodes中,由于只存在值的概念,因此GVN算法将非常简单:如果一个浮动节点本身不存在内存副作用(由于GVN可能影响节点调度,如果有内存副作用的话,那么将引发一些源代码中不可能出现的情况) ,那么即时编译器只需判断该浮动节点是否与已存在的浮动节点的类型相同,所输入的IR节点是否一致,便可以将这两个浮动节点归并成一个。
|
||||
|
||||
```
|
||||
public static int foo(int a, int b) {
|
||||
int sum = a * b;
|
||||
if (a > 0) {
|
||||
sum += a * b;
|
||||
}
|
||||
if (b > 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 Elimination,CSE)。
|
||||
|
||||
这两者的区别在于,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 < 500000; i++) {
|
||||
hash(i);
|
||||
}
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
246
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/19 | Java字节码(基础篇).md
Normal file
246
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/19 | Java字节码(基础篇).md
Normal 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 = "Hello, World";
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
以上面这段代码中的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 N(M为非负整数,N为整数)。该指令指的是将局部变量数组的第M个单元中的int值增加N,常用于for循环中自增量的更新。
|
||||
|
||||
```
|
||||
public void foo() {
|
||||
for (int i = 100; i>=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,否则压入0),checkcast(后跟目标类,判断栈顶元素是否为目标类/接口的实例。如果不是便抛出异常),athrow(将栈顶异常抛出),以及monitorenter(为栈顶对象加锁)和monitorexit(为栈顶对象解锁)。
|
||||
|
||||
此外,该类型的指令还包括字段访问指令,即静态字段访问指令getstatic、putstatic,和实例字段访问指令getfield、putfield。这四条指令均附带用以定位目标字段的信息,但所消耗的操作数栈元素皆不同。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/d9/da3ff3aa4aaa2531d23286fec65b08d9.png" alt="" />
|
||||
|
||||
以putfield为例,在上图中,它会把值v存储至对象obj的目标字段之中。
|
||||
|
||||
方法调用指令,包括invokestatic,invokespecial,invokevirtual,invokeinterface以及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),返回指令,以及被废弃的jsr,ret指令。其中返回指令是区分类型的。例如,返回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 >= 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)。
|
||||
|
||||
|
||||
145
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/20 | 方法内联(上).md
Normal file
145
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/20 | 方法内联(上).md
Normal 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)。
|
||||
|
||||
|
||||
267
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/21 | 方法内联(下).md
Normal file
267
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/21 | 方法内联(下).md
Normal 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("Hello World");
|
||||
}
|
||||
|
||||
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 "implements I" 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 < 400_000; i++) {
|
||||
test(add);
|
||||
}
|
||||
|
||||
Thread.sleep(2000);
|
||||
System.out.println("Loading Sub");
|
||||
Sub[] array = new Sub[0]; // Load class Sub
|
||||
// Expect output: "JITTest::test (7 bytes) made not entrant"
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
214
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/22 | HotSpot虚拟机的intrinsic.md
Normal file
214
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/22 | HotSpot虚拟机的intrinsic.md
Normal 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 <= max; i++) {
|
||||
// Look for first character.
|
||||
if (value[i] != first) {
|
||||
while (++i <= max && value[i] != first);
|
||||
}
|
||||
// Found first character, now look at the rest of value
|
||||
if (i <= max) {
|
||||
int j = i + 1;
|
||||
int end = j + strCount - 1;
|
||||
for (int k = 1; j < end && 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) & (y ^ r)) < 0) {
|
||||
throw new ArithmeticException("integer overflow");
|
||||
}
|
||||
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("integer overflow");
|
||||
// or deoptimize
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后一个例子则是`Integer.bitCount`方法,它将统计所输入的int值的二进制形式中有多少个1。
|
||||
|
||||
```
|
||||
@HotSpotIntrinsicCandidate
|
||||
public static int bitCount(int i) {
|
||||
// HD, Figure 5-2
|
||||
i = i - ((i >>> 1) & 0x55555555);
|
||||
i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
|
||||
i = (i + (i >>> 4)) & 0x0f0f0f0f;
|
||||
i = i + (i >>> 8);
|
||||
i = i + (i >>> 16);
|
||||
return i & 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,即时编译器便能够将之"内联"进来,并插入特殊的IR节点。
|
||||
|
||||
事实上,不少被标记为intrinsic的方法都是native方法。原本对这些native方法的调用需要经过JNI(Java 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 >>> 1) & 0x55555555);
|
||||
i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
|
||||
i = (i + (i >>> 4)) & 0x0f0f0f0f;
|
||||
i = i + (i >>> 8);
|
||||
i = i + (i >>> 16);
|
||||
return i & 0x3f;
|
||||
}
|
||||
public static void main(String[] args) {
|
||||
int sum = 0;
|
||||
for (int i = Integer.MIN_VALUE; i < 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)
|
||||
|
||||
|
||||
285
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/23 | 逃逸分析.md
Normal file
285
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/23 | 逃逸分析.md
Normal 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<Object> list, Consumer<Object> f) {
|
||||
for (Object obj : list) {
|
||||
f.accept(obj);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
举个例子,上面的Java代码将使用foreach循环来遍历一个`ArrayList`对象,其等价的代码如下所示:
|
||||
|
||||
```
|
||||
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
|
||||
Iterator<Object> iter = list.iterator();
|
||||
while (iter.hasNext()) {
|
||||
Object obj = iter.next();
|
||||
f.accept(obj);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里我也列举了所涉及的`ArrayList`代码。我们可以看到,`ArrayList.iterator`方法将创建一个`ArrayList$Itr`实例。
|
||||
|
||||
```
|
||||
public class ArrayList ... {
|
||||
public Iterator<E> iterator() {
|
||||
return new Itr();
|
||||
}
|
||||
private class Itr implements Iterator<E> {
|
||||
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("unchecked")
|
||||
public E next() {
|
||||
checkForComodification();
|
||||
int i = cursor;
|
||||
if (i >= size)
|
||||
throw new NoSuchElementException();
|
||||
Object[] elementData = ArrayList.this.elementData;
|
||||
if (i >= 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<Object> list, Consumer<Object> f) {
|
||||
for (int i = 0; i < 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<Object> list, Consumer<Object> f) {
|
||||
Itr iter = new Itr; // 注意这里是new指令
|
||||
iter.cursor = 0;
|
||||
iter.lastRet = -1;
|
||||
iter.expectedModCount = list.modCount;
|
||||
while (iter.cursor < list.size) {
|
||||
if (list.modCount != iter.expectedModCount)
|
||||
throw new ConcurrentModificationException();
|
||||
int i = iter.cursor;
|
||||
if (i >= list.size)
|
||||
throw new NoSuchElementException();
|
||||
Object[] elementData = list.elementData;
|
||||
if (i >= 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<Object> list, Consumer<Object> f) {
|
||||
// Itr iter = new Itr; // 经过标量替换后该分配无意义,可以被优化掉
|
||||
int cursor = 0; // 标量替换
|
||||
int lastRet = -1; // 标量替换
|
||||
int expectedModCount = list.modCount; // 标量替换
|
||||
while (cursor < list.size) {
|
||||
if (list.modCount != expectedModCount)
|
||||
throw new ConcurrentModificationException();
|
||||
int i = cursor;
|
||||
if (i >= list.size)
|
||||
throw new NoSuchElementException();
|
||||
Object[] elementData = list.elementData;
|
||||
if (i >= 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<Object> list, Consumer<Object> f) {
|
||||
for (Object obj : list) {
|
||||
f.accept(obj);
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
ArrayList<Object> list = new ArrayList<>();
|
||||
for (int i = 0; i < 100; i++) {
|
||||
list.add(i);
|
||||
}
|
||||
for (int i = 0; i < 400_000_000; i++) {
|
||||
forEach(list, obj -> {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
第二项内容,我们来看一看部分逃逸分析的效果。你需要使用附带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 < 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)
|
||||
|
||||
|
||||
520
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/【工具篇】 常用工具介绍.md
Normal file
520
极客时间专栏/深入拆解Java虚拟机/模块二:高效编译/【工具篇】 常用工具介绍.md
Normal 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 "Foo.java"
|
||||
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."<init>":()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 <init>
|
||||
#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 // "<init>":()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."<init>":()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: "Foo.java"
|
||||
|
||||
```
|
||||
|
||||
这里面我用到了两个选项。第一个选项是-p。默认情况下javap会打印所有非私有的字段和方法,当加了-p选项后,它还将打印私有的字段和方法。第二个选项是-v。它尽可能地打印所有信息。如果你只需要查阅方法对应的字节码,那么可以用-c选项来替换-v。
|
||||
|
||||
javap的-v选项的输出分为几大块。
|
||||
|
||||
1.基本信息,涵盖了原class文件的相关信息。
|
||||
|
||||
class文件的版本号(minor version: 0,major 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 "Foo.java"
|
||||
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对应的版本号为52,Java 10对应的版本号为54。)
|
||||
|
||||
```
|
||||
Exception in thread "main" 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."<init>":()V
|
||||
...
|
||||
#8 = Class #30 // java/lang/Object
|
||||
...
|
||||
#14 = Utf8 <init>
|
||||
#15 = Utf8 ()V
|
||||
...
|
||||
#23 = NameAndType #14:#15 // "<init>":()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 index,BCI),这是用来定位字节码的。比如说偏移量为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 > 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.ASM:Java字节码框架
|
||||
|
||||
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("Hello, Java!");
|
||||
if (flag == true) System.out.println("Hello, JVM!");
|
||||
}
|
||||
}' > 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, "Foo", null, "java/lang/Object", null);
|
||||
|
||||
...
|
||||
|
||||
{
|
||||
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", 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("Foo.class"), FooDump.dump());
|
||||
}
|
||||
}' > 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("Foo");
|
||||
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
|
||||
cr.accept(cw, ClassReader.SKIP_FRAMES);
|
||||
Files.write(Paths.get("Foo.class"), 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, "java/lang/System", "out", "Ljava/io/PrintStream;");
|
||||
mv.visitLdcInsn("Hello, World!");
|
||||
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", 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 ("main".equals(name)) {
|
||||
return new MyMethodVisitor(ASM6, visitor);
|
||||
}
|
||||
return visitor;
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ClassReader cr = new ClassReader("Foo");
|
||||
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
|
||||
ClassVisitor cv = new MyClassVisitor(ASM6, cw);
|
||||
cr.accept(cv, ClassReader.SKIP_FRAMES);
|
||||
Files.write(Paths.get("Foo.class"), 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/)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user