JVM编译器调优

JVM编译器调优

JIT编译器

即时(Just-In-Time,JIT)编译器是Java虚拟机的核心。对JVM的性能影响最大的莫过于编译器,而选择编译器是运行Java程序的首要选择之一。幸运的是在绝大多数情况下,我们只需要对编译器做一些基本的调优。

概述

计算机的CPU只能执行相对较少的特定的指令,这些指令称为汇编码或者二进制码,因此CPU执行的所有程序都必须翻译成这种指令。

  • 有像C++,Fortran这样的语言被称为编译型语言,因为它们的程序都已二进制形式交付,并且这种二进制中的汇编码是针对特定CPU的,只要是兼容的CPU,都可以执行相同的二进制代码
  • 还有像PHP,Perl这样的解释型语言,只要机器上有相应的解释器,相同的程序可以在任何CPU上执行.

每种类型的编程语言都各有长处和不足,解释型的编程语言具有良好的可移植性,相同的代码可以在任何有适当解释器的机器上运行,但它执行起来可能就慢,因为每次执行前都必须要先解释。

Java试图走一条中间路线,Java应用会被编译成一种理想化的汇编语言,然后该汇编语言可以用Java执行,这使得Java成为一门平台独立的解释型语言。

Java程序运行的是理想化的二进制代码,所以它能在代码执行时将其编译成平台特定的二进制代码,由于这个编译是在程序执行时进行的,因此称为”即时编译“(JIT)。

热点编译

对程序而言,通常只有一部分代码被经常执行,而应用的性能就取决于这些代码执行有多快,这些关键代码段被称为应用的热点,代码执行的越多就被认为是越热。
因此JVM执行代码时只会编译热点代码,一般是经常执行的方法或者多次执行的循环体,这样才有意义。JVM执行特定的方法或者循环越多,它就越了解这段代码,使得JVM可以在编译时,做更多的优化,例如编译器和主内存优化。

小结

  1. Java的设计结合了脚本语言的平台独立性和编译型语言的本地性能。
  2. Java文件被翻译成中间语言(Java字节码),然后运行时被JVM进一步编译成汇编语言。
  3. 字节码编译成汇编语言的过程中有大量的优化,极大地改善了性能。

调优入门

编译器类型

有两种JIT编译器:

  • client,也被成为C1
  • server,也被成为C2

编译器标志

  • 标准的编译器标志:
    -client,-server,-d64
  • 分层编译
    -XX:+TieredCompilation
    备注:分层编译必须启用server编译器,如下启动参数意味着关闭分层编译
1
java -client -XX:+TieredCompilation other_args

两种编译器的主要差别是:

  • client编译器比server编译器开启编译的时机要早。意味着在代码执行的开始阶段client编译器比server编译器要快(因为client编译的代码相对server编译器而言要多)。
  • server编译器在编译代码时可以更好的优化,最终server编译器生成的代码要比client编译器生成的代码要快。

从用户的角度来说,权衡的取舍在于程序要运行多久,程序的启动时间有多重要。

分层编译:前期采用client编译,随着代码变热,由server编译器重新编译,分层编译从Java7开始引入,7u4版本开始可以发挥较好性能,默认关闭(-XX:+TieredCompilation为false),server编译器在编译代码时可以更好的优化,最终server编译器生成的代码要比client编译器生成的代码要快。Java8开始,分层编译默认启用。

优化启动

  • 如果应用的启动时间是首要的性能考量,那么client编译器就是最佳的选择.
  • 分层编译的启动时间非常接近于client编译器所获得的启动时间.

优化批处理

  • 对于计算量固定的任务来说,应该选择执行实际任务最快的编译器.
  • 分层编译是批处理任务合理的默认选择.

优化长时间运行的应用

  • 对于长时间运行的应用来说,应该一直使用server编译器,最好配合分层编译.

Java和JIT编译器的版本

编译器一共有三个版本

  • 32位client(-client)
  • 32位server(-server)
  • 64位server(-d64)

虚拟选择32位或者64位

如果是32位的操作系统必须使用32位的JVM,如果是64位的操作系统,你可以选择32位或者64位的JVM,如果堆小于3G,32位的Java会更快一些,因为JVM内部的指针只有32位,操作32位指针的代价要少于64位指针,而且32位指针占用的内存也少.虽然有普通对象指针压缩技术,但是64位JVM占用内存仍然大于32位的JVM,因为它所用的本地代码还是64位寻址.

32位JVM最大的不足是最多只能用4GB内存,有一个非常特殊的案例:因为32位JVM无法使用64位寄存器,所以大量使用long或者double变量的程序在32位JVM上就会比较慢.

在32位JVM运行的程序,只要与32位寻址空间吻合,无论机器是32位还是64位,都比在类似配置的64位JVM运行时快5%到%20.

在Java8中,所有JVM中默认的编译器为server编译器,并默认开启分层编译.Java8是最后一个Oracle官方提供32位jdk下载的版本,从Jdk9开始,Oracle官网不再提供32bit Jdk下载

编译器中级调优

调优代码缓存

JVM编译代码时,会在代码缓存中保留编译之后的汇编语言指令集,代码缓存的大小固定,一旦充满,JVM就无法编译更多代码了.
这个问题在使用client编译器和启动分层编译时很常见,使用常规的server编译器时,只有少量的类会被编译,不太可能充满代码缓存.
执行以下命令查看默认的代码缓存大小

1
java -XX:+PrintFlagsFinal -version 2>&1 | grep CodeCacheSize

输出

1
2
uintx InitialCodeCacheSize                     = 2555904                                {pd product} {default}
uintx ReservedCodeCacheSize = 251658240 {pd product} {ergonomic}
  • -XX:InitialCodeCacheSize=N 用于指定代码缓存的初始大小.
  • -XX:ReservedCodeCacheSize=N 用户指定代码缓存的最大值.

为了永远不超过空间而将代码缓存的最大值设得很大,这取决于目标机器有多少可用资源,例如代码缓存被设置为1GB,JVM会保留1GB的本地内存空间,虽然用的时候才会分配,但是它仍然会被保留,为了满足保留内存,你的机器必须有充足的虚拟内存.

备注: 保留内存是分配地址空间,已提交内存是分配内存(只有访问时,才会将分配实际的物理内存页)

代码缓存是一种有最大值的资源,它会影响JVM可运行的编译代码总量,分层编译很容易达到代码缓存默认配置的上限,使用分层编译时,应该监控代码缓存,必要时增加它的最大值.

编译阈值

当代码执行的次数达到编译阈值后,编译器就可以获得足够的信息编译代码了.
编译时基于两种JVM计数器:

  • 方法计数器
  • 方法中的循环回边计数器(回边实际可看作循环完成执行的次数)

JVM执行某个方法时,会检查两种计数器总数,判断是否适合编译,如果合适,就进入编译队列,这种编译称为标准编译.

如果循环比较长,或者所包含程序逻辑永远不退出,这个情况下JVM不等方法调用就会编译循环,所以循环每完成一次,回边计数器会被增加并被检测,如果循环的回边计数器超过阈值,那么这个循环(不是整个方法)就可以被编译,这种编译称为栈上替换(On-StackReplacement,OSR).

标准编译由 -XX:CompileThreshold=N 标志触发,client的默认值为1500,server默认值为10000.这个标志的值等于回边计数器加上方法计数器的总和.
运行以下命令查看默认值
Linux下

1
2
tqd@tqd-pc:/mnt/c/Users/tqd$ java -client -XX:+PrintFlagsFinal --version | grep CompileThreshold
intx CompileThreshold = 10000 {pd product} {default}

Windows 下

1
2
3
4
5
PS C:\Users\tqd> java -client -XX:+PrintFlagsFinal -version | findStr "CompileThreshold"
openjdk version "11.0.8" 2020-07-14
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.8+10)
OpenJDK 64-Bit Client VM AdoptOpenJDK (build 11.0.8+10, mixed mode)
intx CompileThreshold = 1500 {pd product} {default}

OSR编译的阈值为 OSR trigger = (CompileThreshold*(OnStackReplacePercentage - InterpreterProfilePercentage)/100)

所有编译器中的 -XX:InterpreterProfilePercentage=N 标志的默认值为33,client编译器中的-XX:OnStackReplacePercentage=N的默认值为933
server编译器中的-XX:OnStackReplacePercentage的默认值为140;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PS C:\Users\tqd> java -client -XX:+PrintFlagsFinal -version | findStr "OnStackReplacePercentage"
openjdk version "11.0.8" 2020-07-14
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.8+10)
OpenJDK 64-Bit Client VM AdoptOpenJDK (build 11.0.8+10, mixed mode)
intx OnStackReplacePercentage = 933 {pd product} {default}
PS C:\Users\tqd> java -server -XX:+PrintFlagsFinal -version | findStr "OnStackReplacePercentage"
openjdk version "11.0.8" 2020-07-14
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.8+10)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.8+10, mixed mode)
intx OnStackReplacePercentage = 140 {pd product} {default}
PS C:\Users\tqd> java -server -XX:+PrintFlagsFinal -version | findStr "InterpreterProfilePercentage"
openjdk version "11.0.8" 2020-07-14
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.8+10)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.8+10, mixed mode)
intx InterpreterProfilePercentage = 33 {product} {default}
PS C:\Users\tqd> java -client -XX:+PrintFlagsFinal -version | findStr "InterpreterProfilePercentage"
openjdk version "11.0.8" 2020-07-14
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.8+10)
OpenJDK 64-Bit Client VM AdoptOpenJDK (build 11.0.8+10, mixed mode)
intx InterpreterProfilePercentage = 33 {product} {default}

client编译器与server编译器的性能有很大的差异,这些差异很大程度上取决于编译方法时所获得的信息.降低编译阈值,特别是对server编译器来说,可能会减少编译代码的优化.不过应用测试表明,事实上几乎没有差别,比如8000次和10000次调用差别微乎其微.

使用较低的配置主要原因如下

  • 节约一点应用热身的时间
  • 使得某些原本可能不会被server编译器编译的方法得以编译

每种计数器的值都会周期性减少(特别是JVM达到安全点时),计数器只是方法的最新热度的度量,执行不太频繁的代码永远不会编译,即便是永远运行的程序(相对于热,这些方法被称为温热).一般通过减少编译阀值来优化,这也是分层编译通常比单独的server编译器更快的原因之一.

小结:当方法和执行循环达到某个阀值的时候,就会发生编译;改变阀值会导致代码提前或者推后编译;由于计数器会随时间而减少,以至于”温热”的方法可能永远都达不到编译的阈值(特别对于server编译器来说).

检测编译过程

PrintCompilation打印

启用标志: -XX:+PrintCompilation(默认为false)
开启PrintCompilation后,每编译一个方法就会打印一行被编译的内容信息,输出的信息在不同的Java发行版本有所不同,Java7中的标准化输出

1
timestamp compilation_id attributes (tiered_level) method_name size deopt
  • timestamp:编译时间戳,相对于JVM启动的时间.
  • compilation_id:内部任务Id,通常这个数字只是单调增长,使用server编译器会乱序输出(多个编译线程).
  • attributes: 是一组5个字符长的串,表示编译状态,如果给定编译被赋予特定属性,就会打印一下字符串,否则打印空格,5个字符属性串可以同时出现多个:
  • % :编译为OSR.
  • s :方法是同步的.
  • ! :方法有异常处理器.
  • b :阻塞模式发生的编译.
  • n :为封装本地方法所发生的编译.
    备注:前三个可以自解释,阻塞标志(b)在当前版本的Java中默认用于不会打印,表明编译不会发生在后台.n标志表明JVM生成了一些编译代码以便于调用本地方法.
  • tiered_level: 完成编译的级别.如不启用分层编译,这个字段为空.
  • method_name: 被编译方法的名称.
  • size: 编译代码的大小,这是java字节码的大小,不是编译后的代码的大小
  • deopt: 表明发生某种逆优化,通常是”made not entrant”或者”made zombie”

jstat打印编译日志

1
2
jstat -compiler [jvm pid]
jstat -compiler [jvm pid] [interval]

实例

1
2
3
4
#打印进程号为5003的编译日志
jstat -compiler 5003
#每1000ms打印一次
jstat -printcompilation 5003 1000

有时候有如下输出

1
timestamp compile_id COMPILE SKIPPED: reason

这行信息表明编译的给定方法有误,出现这种情况的原因有以下两种:

  • 代码缓存满了,可以通过ReservedCodeCacheSize标志增加
  • 编译的同时加载类:编译类的时候发生修改,JVM之后会再次编译.

小结:观察代码如何被编译的最好方法是开启PrintCompilation;PrintCompilation开启后所输出的信息可用来确认编译是否和预期一样.

高级调优

编译线程

当方法(或循环)适合编译时,就会进入到编译队列.队列由一个或者多个后台线程处理.
编译队列并不严格遵守先进先出原则:调用计数次数多的方法有更高的优先级.
当使用client编译器时,JVM会开启一个编译线程,使用server编译器时,则会开启两个这样的线程.当启用分层编译时,JVM默认开启多个client和server线程,线程数根据一个复杂的等式而定,包括目标平台CPU数取双对数后的数值.

编译器的线程数可以通过-XX:CICompilerCount=N标志来设置,这是JVM处理队列的线程总数;对分层编译而言,至少三分之一(至少一个)将用来处理client编译器队列,其余的线程(至少一个)用来处理server编译器队列.
另一个编译线程设定参数为-XX:+BackgroundCompilation标志,默认为true,编译队列的处理是异步执行,设置为false,当一个方法适合编译时,执行方法的代码会一直等待到它确实被编译之后才执行,用-Xbatch可以禁用后台编译.
小结:放置在编译队列中的方法的编译会被异步执行;队列不是严格按照先进先出;队列中的热点方法会在其他方法之前编译,这是编译输出日志的ID为乱序的另一个原因.

内联

编译器所做的最重要的优化就是方法内联,遵循面向对象设计的良好代码通常都会包括一些需要通过getter(setter)访问的属性

1
2
3
4
5
6
7
8
9
public class Point{
private int x;
public int getX(){
return x;
}
public int setX(int x){
this.x=x;
}
}

访问属性的代码

1
2
Point p=new Point();
p.setX(p.getX()*2)

等同于

1
2
Point p=new Point();
p.x=p.x*2;

内联是默认开启的可通过-XX:-Inline关闭,然而它对性能影响巨大,不幸的是,基本没法看JVM是如何内联(如果你从源码编译JVM,可以用-XX:+PrintInlining生成带调试信息的版本,这个参数会提供所有关于编译器如何进行内联决策的信息).

方法是否内联取决于它有多热以及它的大小,JVM依据内部计算来判断方法是否热点,是否是热点并不直接与任何调优参数相关,如果方法因调用频繁而可以内联,那么只有它的字节码小于325字节时(或-XX:MaxFreqInlineSize(FreqInlineSize)=N所设定的值)才会内联,否则,只有方法很小时,即小于35字节(或者-XX:MaxInlineSize=N所设定的值)时才会内联.

小结:内联是编译器所能做的最有利的优化,特别是对属性封装良好的面向对象的代码来说;几乎用不着调节内联参数,且提倡这样做的建议往往忽略了常规内联与频繁调用内联之间的关系,当考察内联效应时,确保考虑这两种情况.

逃逸分析

JVM默认开启逃匿分析(-XX:+DoEscapeAnalysis,默认为true)

1
2
tqd@tqd-pc:/$ java -XX:+PrintFlagsFinal -version 2>&1 | grep DoEscapeAnalysis
bool DoEscapeAnalysis = true {C2 product} {default}

server编译器将会执行一些非常激进的优化措施,比如去掉不必要的同步锁,将没必要保存到内存的值,保存到寄存器,不需要分配的对象,仅追踪其部分字段,此类优化非常复杂。逃逸分析默认开启,极少情况下它会出错,在此类情况下关闭它会变得更快或更稳定。如果你发现这种行为,最好的应对方法就是简化相关代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Factorial{
private BigInteger factorial;
private int n;
public Factorial(int n){
this.n=n;
}
public synchronized BigInteger getFactorial(){
if(factorial == null){
//没必要在内存中保留n,可以在寄存器这种保留该值
factorial = 0;//计算n的阶乘,此处省略
}
return factorial;
}
}
//sample code
List<BigInteger> list=new ArrayList<>();
for(int i=0;i<100;i++){
//实际上没必要分配factorial对象,只需要追踪其部分字段
Factorial factorial=new Factorial(i);
//此处getFactorial方法的同步锁库没必要获取,可能被优化掉
list.add(factorial.getFactorial())
}

小结:逃逸分析是编译器做得最复杂的优化。此类优化常常会导致微基准测试失败;逃逸分析常常会给不正确的同步代码引入Bug

逆优化

有两种逆优化的场景,代码状态分别为:

  • made no entrant(代码被丢弃)
  • made zombie(产生僵尸代码)

代码被丢弃

导致代码被丢弃的原因共两种:

  • 逆优化陷阱。
  • 分层编译。

逆优化陷阱

考虑如下代码

1
2
3
4
5
6
7
8
9
10
11
12
public class Hello{
private IService service;

public String request(String log){
if(log!=null && log.equals("A")){
service=A();
}else{
service=B();
}
return service.doSomething();
}
}

如果开始有大量的请求log不为空,此时service实际时A类型,然后它将内联代码,进行优化,后续只有大量log为空的请求,之前编译器根据service类型做的假设将不成立了,之前的优化也失效了,产生逆优化陷阱(deoptimization trap),如果有跟多log不为空的请求,JVM会中止此部分代码编译,而开始新的编译。
注意:OSR编译过的构造函数和标准编译过的方法都被标记成made no entrant,过一会,它们又被标记为made zombie.
逆优化听起来不好,但是逆优化之后,如果代码再次被调用,又会重新编译,逆优化,除了进入陷阱的短暂时间,对于其他方面没有太大的影响。

分层编译

在分层编译中,代码首先由client编译器编译,然后由server编译器编译,当server编译器编译好代码后,JVM必须替换client编译器编译的代码,这些代码将被标记为废弃。这种“逆优化”实际使得代码运行更快。

逆优化僵尸代码

编译器日志显示产生了僵尸代码,即JVM已经回收了之前被丢弃的代码,
在上面的例子中,当log为空时A类编译的代码就被丢弃了,但是A类的对象未被回收,最终A类的对象全部被GC回收,回收后编译器就会注意到,这个类就适合标记为僵尸代码了。

从性能的角度来看这是好事,编译代码保存在固定大小的代码缓存中,如果发现僵尸代码,意味着有问题的代码可以从代码缓存中移除,腾出空间给其他被编译的代码

不足之处:如果代码被僵尸化后被再次加载并且重新编译,JVM需要重新编译和重新优化代码。但这种情况对应用的性能没有太大的影响。

小结:逆优化使得编译器可以回到之前版本的编译代码;先前的优化不再有效时,才会发生代码逆优化;代码逆优化,会对性能产生小而短暂的影响,新编译的代码会尽快地再次热身;分层编译时,之前被client编译器编译而现在由server编译器优化,就会发生逆优化。

分层编译级别

当使用分层编译时,编译日志中会输出代码所编译的级别。一共两种编译器,再加上解释器,client编译器有3种编辑级别,总计5种编译级别:

  • 0: 解释代码
  • 1: 简单c1编译代码
  • 2: 受限的c1编译代码
  • 3: 完全c1编译代码
  • 4: c2编译代码

典型的编译路径 级别3 -> 级别4;多数方法第一次被编译成级别3,当方法运行足够频繁,它会被编译成级别4.最常见的情况是: client编译器从获取了代码如何使用的信息进行优化时才开始编译。

如果server编译器队列满了,会从server队列中取出方法,以级别2进行编译,这个级别上,client编译器使用方法调用计数器和回边计数器(但不需要分析性能),编译更快,而方法在client编译器收集分析信息后被编译成级别3,最终server编译器队列不太忙时被编译成级别4.

如果client编译器队列全忙,原本排程在级别3编译的方法就既可以等待级别3编译,也适合进行级别4的编译。在这种情况下,方法编译会很快转到级别2,然后由级别2转到级别4。

那些不重要的方法可以从级别2或者级别3编译,但随后会因为它们的重要性没那么高而转为级别1。另外如果server编译器处于某些原因无法编译代码,也会转为级别1。

当然,代码在逆编译时会转为级别0->级别3->级别4编译时,性能可以达到最优。如果方法经常被编译成级别2并且还有多余的cpu周期,那么可以考虑增大编译器的线程数,从而减少server编译器队列的长度,如果没有额外的cpu周期,那么你只能减少应用的大小。

小结:分层编译可以在两种编译器和5种级别之间进行;不建议人为更改级别;

小结

final关键字不会影响性能,即便曾经会,但也是太久以前了。

  1. 不用担心小方法,它们很容易被内联。
  2. 需要编译的代码在编译队列中,队列中代码越多,程序达到最佳性能的时间越久。
  3. 代码缓存虽然可以调整,但它仍然是有限资源。
  4. 代码越简单,优化越多。分析反馈和逃逸分析使得代码更快,但复杂的循环结构和大方法限制它的有效性。
  5. 审视编译器做什么很重要。

参考

  1. 已提交内存与保留内存
  2. 检测编译过程实验