JVM调优——JIT编译器

850 阅读13分钟

JIT编译器

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

        像 C++ 这样的语言被称为编译型语言,因为它们的程序都以二进制(编译后的)形式交付:先写程序,然后用编译器静态生成二进制文件。这个二进制文件中的汇编码是针对特定CPU的。

        还有一些像PHP,则是解释型的。只要机器上有合适的解释器(即称为php的程序),相同的程序代码可以在任何CPU上运行。执行程序时,解释器会将相应代码转换成二进制代码。

        解释型代码几乎总是明显比编译型代码要慢:编译器有足够的程序信息,这些信息可用来大量优化二进制代码,这些是简单解释器无法做到的。 解释型代码的优势在于可移植。

         Java的设计结合了脚本语言的平台独立性和编译型语言的本地性能。通过解释器进行解释执行,Java文件被编译成中间语言(Java字节码),当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),在运行时,虚拟机将会把这些代码编译成本地机器码。因此被称为“即时编译”(即JIT)。字节码编译成汇编语言的过程中有大量的优化,极大地改善了性能。   

       热点代码是指:多次调用的方法被多次执行的循环体。

      虚拟机中内同时包含解释器与编译器,解释器与编译器经常是相辅相成地配合工作。

   

JIT编译器类型

       JIT编译器有两种被称为 client 和 server。通常称这些编译器为C1(编译器1, client编译器)和C2(编译器2,server编译器)。JVM 会根据自身版本与硬件自动选择运行模式,用户也可以使用“-client ”或“ -server ”参数指定客户端模式还是服务端模式。

client 编译器

       两种编译器的最主要的差别在于编译代码的时机不同。 client 编译器开启编译比 server 编译器要早。意味着在代码执行的开始阶段, client 编译器比 server 编译器要快,因为它的编译代码相比 server 编译器而言要多。

server 编译器

       server 编译器在编译代码时可以更好地进行优化。最终,server 编译器生成的代码要比 clien 编译器快。 

分层编译

       JVM 在启动时用 client 编译器,然后随着代码变热使用 server 编译器,这种技术被称为分层编译。代码先由 clien编译器编译,随着代码变热,由 server 编译器重新编译。Java8 中,分层编译默认为开启。通过 -XX:+ TieredCompilation 开启或者关闭分层编译。

小结

  • 如果应用的启动时间是首要的性能考量,那 client 编译器就是最有用的。 
  • 分层编译的启动时间可以非常接近于 client 编译器所获得的启动时间。
  • 对于长时间运行的应用来说,应该一直使用 server 编译器,最好配合分层编译。

JIT编译器版本

    JIT编译器有3种版本:

  • 32位 client编译器(- client) 
  • 32位 server编译器(- server) 
  • 64位 server编译器(-d64)

      32位 JVM 有两种编译器,而64位只有一种编译器。(实际上64位 JVM 也有两种编译器,因为分层编译需要有 client 编译器的支持。但在只有 client 编译器时,64位 JVM 无法运行)。通过java -version 命令可以查看当前默认的编译器。 

>java   -version
java version "1.8.0_212"
Java(TM) SE Runtime Environment (build 1.8.0_212-b10)
Java HotSpot(TM) 64-Bit Server VM (build 25.212-b10, mixed mode)

最后一行 64-Bit Server 表示所用的编译器版本。解释器与编译器搭配使用的方式被称为“混合模式”(Mixed Mode)

编译器调优

调优代码缓存

        JVM编译代码时,会在代码缓存中保留编译之后的汇编语言指令集。代码缓存的大小固定,所以一旦填满,JVM就不能编译更多代码了。

      如果代码缓存过小,一些热点被编译了,而其他则没有,最终导致应用的大部分代码都是解释运行(非常慢)。

       这个问题在使用 client 编译器或进行分层编译时很常见。使用常规的 server 编译器时,因为通常只有少量类会被编译,所以能被编译的类不太可能填满代码缓存。而用 client 编译器时,可被编译的类可能会非常多(因此也适合开启分层编译)。

      代码缓存填满时,JVM(通常)会发出以下警告:

Java HotSpot (TM)64-Bit Server VM warning: Code Cache is full.
                 Compiler has been disabled
Java HotSpot(TM)64-Bit Server VM warning: Try increasing the      
                code cache size using -XX: Reserved Code Cachesize=

各种平台上代码缓存的默认大小

    -XX: ReservedCodeCachesize=N (对特定编译器来说,N为默认的值) 标志可以设置代码缓存的最大值。有初始值(由 -XX: InitialCodeCachesize=N 指定)。代码缓存从初始大小开始分配,直至最大值。通常只需要设定 ReservedCodeCachesize  代码缓存的最大值 。

      使用分层编译时,应该监控代码缓存,通过 jconsole Memory(内存)面板的 Memory Pool Code Cache图表,可以监控代码缓存。必要时应该增加它的大小。

编译条件与对象

         编译是基于两种JVM计数器的:方法调用计数器 和 方法中的循环回边计数器

        方法调用计数器统计的是一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time), 使用虚拟机参数**-XX:- UseCounterDecay**来关闭热度衰减。这样只要系统运行时间足够长,大部分方法都会被编译成本地代码。还可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

        回边实际上可看作是循环完成执行的次数,所谓循环完成执行,包括达到循环自身的末尾,也包括执行了像 continue 这样的分支语句。 

标准编译

       JVM执行某个Java方法时,会检查该方法的两种计数器总数,然后判定该方法是否适合编译。如果适合,该方法就进入编译队列。这种编译通常叫标准编译

        队列则由一个或多个后台线程处理。意味着编译过程是异步的,通过参数
XX:+ BackgroundCompilation 控制,默认为true。 编译队列并不严格遵守先进先出的原则:调用计数次数多的方法有更高的优先级。

       当使用 client 编译器时,JVM会开启一个编译线程;使用 server 编译器时,则会开启两个这样的线程。当启用分层编译时,JVM默认开启多个 client 和 server 线程,线程数= CPU数取双对数之后的数值。 

       编译器的线程数可通过 XX: CICompilerCount=N 标志来设置。对分层编译来说,其中三分之一(至少一个)将用来处理 client 编译器队列,其余的线程(至少一个)用来处理 server 编译器队列。 

      使用分层编译时,线程数很容易超过系统限制,特别是有多个JVM同时运行的时候。在这种情况下,减少线程数有助于提升整体的吞吐量(尽管代价可能是热身期会持续得更长)。  

        标准编译由 -XX: CompileThreshold=N 标志触发。使用 client 编译器时,N的默认值是1500,使用 server 编译器时为10000。更改 CompileThreshold 标志的值,将使编译器提早(或延后)编译。但这个标志的阈值等于回边计数器加上方法调用计数器的总和。

栈上替换(on- Stack Replacement,OSR)

      如果循环真的很长——或因包含所有程序逻辑而永远不退出,在这种情况下,JVM不等方法被调用就会编译循环。所以循环毎完成一轮,回边计数器就会增加并被检测。如果循环的回边计数器超过阈值,那这个循环(不是整个方法)就可以被编译。这种编译称为栈上替换(on- Stack Replacement,OSR)

         JVM必须在循环进行的时候还能编译循环。在循环代码编译结束后。JVM就会替换还在栈上的代码。循环的下一次迭代就会执行快得多的编译代码。 

       更改OSR编译阈值的情况非常罕见。事实上,虽然OSR编译在基准测试中经常发生,但在实际运行时并不经常出现。 OSR编译由3个标志触发: 

  • 方法调用计数器阈值(-XX: CompileThreshold)
  • OSR比率(-XX:OnStackReplacePercentage)
  • 解释器监控比率(-XX: InterpreterProfilePercentage)

       回边计数器的阈值,计算公式有如下两种:

//client 编译器   OnStackReplacePercentage 默认值为933,默认阈值为 13995
OSR trigger = (CompileThreshold * OnStackReplacePercentage)/100

//server 编译器  OnStackReplacePercentage默认值为140
//InterpreterProfilePercentage 默认值为33,默认阈值为 10700
OSR trigger = (CompileThreshold 
           *(( OnStackReplacePercentage - InterpreterProfilePercentage)/100))

内联

       编译器所做的最重要的优化是方法内联。比如:

public class Point {
    private Integer x;
    public Integer getX() { return x; }
    public void setX(Integer x) { this.x = x;}
}

       此类方法调用的开销很大(来自方法栈帧的生成、参数字段的压入、栈帧的弹出、还有指令执行地址的跳转)。JVM通常都会用内联代码的方式执行这些方法。

Point p = new Point();
p.setX(p.getX()*2);
//编译之后,本质上执行
Point p = new Point();
p.x = p.x * 2;

       内联默认是开启的。可通过 -XX:- Inline 关闭,然而由于它对性能的影响巨大,事实上你永远不会这么做。由于内联非常重要,并且还因为有许多其他控制标志,所以通常都会建议对JVM内联进行调优。 

       方法是否内联取决于方法是否是热点以及它的大小。如果方法因调用频繁而可以内联,那只有在它的字节码小于325字节( -XX: MaxFreqInlineSize=N 所设定的值) 才会内联。否则,只有方法很小时,即小于35字节( -XX: MaxInlineSize=N 所设定的值) 时才会内联。   

       对于 MaxInlineSize 调优的最终结果就是减少了热身测试所需的时间,但不太可能对长期运行的程序产生重大影响。

逃逸分析

      逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;

       JVM 会为对象实例采取不同程度的优化:栈上分配、标量替换、同步消除

 //完全未优化的代码
public int test(int x) {
	int xx = x + 2;
	Point p = new Point(xx, 42);
	return p.getX();
}
// 标量替换后的样子
public int test(int x) {
	int xx = x + 2;
	int px = xx;
	int py = 42;
	return px;
}
// 做无效代码消除后的样子
public int test(int x) {
	return x + 2;
}

        使用参数 -XX:+DoEscapeAnalysis 来开启逃逸分析,默认开启。极少数情况下,它会出错,在此类情况下关闭它会变得更快或更稳定。如果你发现了这种情况,最好的应对行为就是简化相关代码:代码越简单越好。

分层编译级别

         程序使用分层编译时,编译日志中会输出代码所编译的分层级别。 因为 client 编译器有3种级别,所以总共有5种执行级别。

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

      多数方法第一次编译的级别是3,即完全C1编译。(当然,所有方法都从级别0开始。)如果方法运行得足够频繁,它就会编译成级别4(级别3的代码就会被丢弃)。

      当方法按期望的顺序,即级别0→级别3→级别4编译时,性能可以达到最优。如果方法经常被编译为级别2,并且还额外有可用的CPU周期,那就可以考虑增加编译器的线程数,从而减少server 编译器队列的长度。如果没有额外可用的CPU周期,那你唯一能做的就是尽力减小应用的大小。

检测编译过程

        通过设置 -XX:+PrintCompilation -verbose:gc 开启编译日志。
-XX:+ PrintCompilation (默认为 false)。 如果开启 PrintCompilation,毎次编译一个方法(或循环)时,JVM 就会打印一行被编译的内容信息。 绝大多数编译日志的行具有以下格式: 

timestamp compilation_id attributes (tiered_level) method_name size deopt 

timestamp:编译完成的时间(相对于JVM开始的时间0)。 

compilation_id:是内部的任务ID。通常这个数字只是简单地单调增长,不过在使用 server 编译器时(或者某个时刻编译器的线程数增加时),你有时会发现乱序的 compilation_id 。

attributes:是一组5个字符长的串,表示代码编译的状态。如果给定的编译被赋予了特定属性,就会打印下面列表中所显示的字符,否则该属性就打印一个空格。因此,5字符属性串可以同时出现2个或多个字符。

  • %:编译为OSR。
  • s:方法是同步的。 
  • !:方法有异常处理器。
  • b:阻塞模式时发生的编译。 
  • n:为封装本地方法所发生的编译。

tiered_level:编译的级别。需要开启分层编译,否则为空。

method_name :是被编译方法,打印格式为CLassName:: method

size: 编译后代码的大小(单位是字节),是Java字节码的大小。

         编译日志还会包括类似下面这行信息: 

 timestamp compile id COMPILE SKIPPED: reason

       这行信息(包括文本文字 COMPILE SKIPPED)表示编译给定的方法有错误。出现这个错可能有以下两种原因。 

  • 代码缓存满了:需用 ReservedCache标志增大代码缓存的大小
  • 编译的同时加载类:编译类的时候会发生修改。JVM之后会再次编译,你可以在之后的日志中看到方法被再次编译。

      在某些情况下,编译日志行的结尾会有一条信息,表明发生了某种逆优化,有两种逆优化的情形:代码状态分别为“ made not entrant”(代码被丢弃) 和“made zombie ”(产生僵尸代码)。 逆优化使得编译器可以回到之前版本的编译代码。分层编译时,如果代码之前由 client 编译器编译而现在由 server 编译器优化,就会发生逆优化。

参考

Java性能权威指南
深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)
Java虚拟机——程序编译与代码优化