第四章 JIT编译器
即时(Just-In-Time,JIT)编译器是Java虚拟机的核心。对Java性能影响最大的是编译器,选择编译器是运行Java程序时首先要做的选择之一。在大多数情况下,只需要对编译器做基本的调优。
4.1 JIT编译器:概览
CPU只能执行相对少而特定的指令,这被称为二进制码。因此,CPU所执行的所有程序都必须翻译成这种指令。
C++这样的语言被称为编译型语言,因为它们的程序都以二进制(编译后的)形式交付:先写程序,然后用编译器静态生成二进制文件。这个二进制文件中的汇编码是针对特定CPU的。只要兼容的CPU,都可以执行相同的而进行代码。
想 PHP这样的语言,则是解释型的。只要机器上有合适的解释器(即称为php的程序),相同的程序代码可以在任何CPU上运行。执行程序时,解释器会将相应代码转换成二进制代码。
解释型语言的程序可移植:相同的代码放在任何有适当的解释器的机器上,都能运行。但是它运行起来可能就慢了。比如:当解释器执行循环体时,会重新翻译每一行代码。编译过后的代码就不必再重复做这样的转换。
Java走中间路线。Java应用会被编译,但不是编译成特定CPU专用的二进制代码,而是被编译成Java字节码,Java字节码可以用java运行(与拍黄片解释运行PHP脚本是相同的道理)。这使得Java称为一门平台独立的解释型语言。因为java程序运行的是java字节码,所以他能再代码执行时将其编译成平台特定的二进制代码。由于这个编译是再程序执行时进行的,因此被称为“即使编译”(JIT)。
热点编译
本书中Java实现是Oracle的HotSpot JVM。HotSpot的名字来自于它看待代码编译的方式。对于程序来说,通常只有一部分代码被经常执行,而应用的性能就取决于之恶写代码执行得有多快。这些关键代码段被称为应用得热点,代码执行的越多就被认为是越热。
JVM执行代码时,并不会立即编译代码。两个基本理由:如果代码只执行一次,那编译完全就是浪费资源;对于只执行一次的代码,解释执行Java字节码比先编译然后执行的速度快。
如果代码是被经常调用的方法,或者运行很多次迭代的循环,编译就值得:编译的代码更快,多次执行类级节约的时间超过了编译所花费的时间;JVM执行特定方法或者循环次数越多,就会越了解这段代码,使得JVM可以再编译代码时进行大量优化。
寄存器和主内存
编译器最重要的优化包括何时使用主内存中的值,以及何时再寄存器中存储值。
比如,累加。这种优化很高效,但意味着线程同步的语义对应用行为非常重要。一个线程无法看到另一个线程所用寄存器中保存变量的值,同步机制使得从寄存器写回主内存时其他线程可以准确的读到这个值。使用寄存器是编译器普遍采用的优化方法,当开启逃逸分析时,寄存器的使用更为频繁。
4.2 调优入门:选择编译器类型(Client、Server或者二者同用)
在两种JIT编译器中进行选择常常是应用运行时所需做的仅有的编译器调优。
这两种编译器分别称为client和server。名字来自于命令行上用于选择编译器的参数(例如-client或-server)。它们的名字似乎意味着如何选择编译器受程序运行的硬件平台的影响,但不完全正确。
两种编译器的最主要差别在于编译代码的时间不同。client编译器开启编译比server编译器要早。意味着在代码执行的开始阶段,client编译器比server编译器要快,因为它的编译代码相比server编译器而言要多。
server编译器在编译代码时可以更好地进行优化。最终,server编译器生成的代码要比client编译器快。从用户角度看,权衡的取舍在于程序要运行多久,程序的启动时间有多重要。
JVM在启动时用client编译器,然后随着代码变热使用server编译器,这种技术被称为分层编译。代码先由client编译器编译,随着代码变热,由server编译器重新编译。但是Java7中分层编译有瑕疵,没能称为默认值。Java8中,分层编译默认为开启。
4.2.1 优化启动
当快速启动时间是首要目标时,最常使用client编译器。不同应用使用不同编译器标志的差别见表:
例如Java GUI 应用。
4.2.2 优化批处理
对于批处理应用来说——处理的工作量固定——编译器的选择,归根到底取决于哪种编译器使得应用运行的时间最优。
4.2.3 优化长时间运行的应用
编译器的选择取决于安装的JVM是32位还是64位,以及传递给JVM的编译器参数。
4.3 Java和JIT编译器版本
确定默认的编译器
java -version
最后一行表示所用的编译器。
默认值基于一个理念,即启动时间对32位Windows机器来说最重要的,而基于Unix的系统一般来说更光柱与长期运行的性能。
4.4 编译器中级调优
在大多数情况下,所谓编译器调优,其实就知识位目标机器上的Java选择正确的JVM和编译器开关(-client、-server或-XX:+TieredCompilation)而已。分层编译通常是长期运行应用的最佳选择,而对于运行时间短的应用来说,分层编译与client编译器的性能差别也只在毫厘之内。
除了选择JVM和编译器开关,有些场景还需要进行额外的调优工作。
4.4.1 调优代码缓存
JVM编译代码时,会在代码缓存中保留编译之后的汇编语言指令集。代码缓存的大小固定,所以一旦填满,JVM就不能编译更多代码了。如果代码缓存过小,就可能会有问题。一些热点被编译了,而其他则没有,最终导致应用的大部分代码都是解释运行(非常慢)。
这个问题在使用client编译器或进行分层编译时很常见。使用常规的server编译器时,因为通常只有少量类会被编译,所以能被编译的类不太可能填满代码缓存。而用client编译器时,可被编译的类非常多(因此也适合开启分层编译)。
代码缓存填满时,JVM通常会发出一下警告,包含"Compiler has been disabled"或"code cache size using -XX: ReserveredCodeCacheSize="。
该类警告信息容易忽略,可以通过追踪输出的编译日志来判断百年一起是否停止编译代码。
Java 7开启分层编译时,默认的代码缓存通常就不够用,常常需要扩大。使用client编译器的大型程序也需要增加代码缓存的大小。
没有什么好的机制可以算出程序所需要的代码缓存。所以,如何增加代码缓存,基本上就是摸着石头过河,通常的做法是简单的增加1倍或3倍。
-XX:ReservedCodeCacheSize=N标志可以设置代码缓存的最大值。代码缓存的管理和大多数JVM内存一样,有初始值(由-XX:InitialCodeCacheSize=N)。代码缓存从初始大小开始分配,一旦充满就会增加,直至最大值。代码缓存的初始大小依据芯片架构和所用的JVM编译器而有所不提供。混村大小的自动调整在后台进行,不避讳对性能造成实际影响。所以通常只需要设定ReservedCodeCacheSize,也就是设定代码缓存的最大值。
为了永远不超出空间将代码缓存的最大值设得很大,这有什么患处?这取决于目标机器上有多少可用资源。代码缓存射为1GB,JVM就会保留1GB得本地内存空间。虽然这部分内存在需要时才会分配,但它仍然是倍保留得,这意味着为了满足保留内存,你得机器必须有足够得虚拟内存。
保留内存与已分配内存
理解JVM保留内存和分配内存方式之间的差别非常重要。这种差别在代码缓存、Java堆以及其他JVM本地内存结构中都存在。
如果是32位JVM,则进程占用的总内存不能超过4GB。这包括Java堆、JVM自身所有代码占用的空间(包括它的本地库和线程栈)、分配给应用本地内存(或者NIO库的直接内存),当然还有代码缓存。
鉴于以上原因,代码缓存总是受限的,大型应用(甚至使用分层编译是的中型应用)有时需要就此进行调优。对于64位机器,这个值设置得太高未必有实际效果,因为应用不可能超过进程的空间内存,且一般来说,操作系统会保留更多的内存。
通过jconsole Memory(内存)面板的Memory Pool Code Cache图表,可以监控代码缓存。
4.4.2 编译阈值
触发代码编译条件,最主要的因素是代码执行的频度。一旦执行达到一定次数,且达到了编译阈值,编译器就可以获得足够的信息编译代码了。
编译是基于两种JVM计数器的:方法调用计数器和方法中的循环回边计数器。回边实际上可看作是循环完成执行的次数,所谓魂环完成执行,包括达到循环自身的末尾,也包括执行了像contimue这样的分支语句。
JVM执行某个Java方法时,会检查该方法的两种计数器总数,然后判定该方法是适合编译。如果适合,该方法就进入编译队列。这种编译没有正式的名称,通常叫标准编译。
如果循环很长或因包含所有程序逻辑而永远不退出,会怎么做?在这种情况下,JVM不等方法调用就会编译循环。所以循环没完成一轮,回边计数器就会增加并被检测。如果循环的回边计数器超过阈值,那这个循环(不是整个方法)就可以被编译。
这种编译称为栈上替换(On-Stack Replacement,OSR)。由于仅仅编译循环还不够,JVM必须在循环进行的时候还能编译循环。在循环代码编译结束后,JVM就会替换还在栈上的代码,循环的下一次迭代就会执行快得多得编译代码。
标准编译由-XX:CompileThreshold=N标志触发。使用client编译器时,N得默认值是1500,使用server编译器时位10000.更爱CompileThreshold标志得值,将使编译器提早或延后编译。然而请注意,尽管有一个标志,但这个标志得阈值等于回边计数器加上方法调用计数器的总和。
更改OSR编译
更改OSR编译阈值的情况非常罕见。事实上,虽然OSR编译在基准测试(特别是微基准测试)中经常发生,但在实际运行时并不京城出现。
OSR编译由3个标志触发:
OSR trigger = (CompileThreshold*((OnStackReplacePercentage - InterpreterProfilePercentage) / 100))
所有编译器中的-XX:IntercepterProfilePercentage=N标志的默认值位33.client编译器-XX:OnStackReplacePercentage=N的默认值位933.在server编译器中,由于OnStackReplacePercentage默认值位140。对于分层编译来说,默认值完全取决于不同的标志。
使用较低的设置主要基于以下两个原因:
- 节约一点应用热身的时间
- 使得某些原本可能不会被server编译器编译的方法得以编译
第二点是一位内虽然计数器随着方法和循环的执行而增加,但它们也会随时间而减少。
每种计数器的值都会周期性减少(特别是当JVM达到安全点时)。实际上,计数器只是方法或循环最新热度的梁。由此带来的一个副作用是,执行不太频繁的代码永远不会编译,即便是永远运行的程序(相对于热来说,有时称这些方法位温热)。这就是通过减少阈值来进行优化的一种情况,他也是分层编译通常比单独的server编译器要快的原因之一。如果应用分析信息希纳是关键路径上的方法没有编译,那有时就可以通过降低编译器阈值来触发这些方法的编译。
4.4.3 检测编译过程
检测编译过程并不是优化本身,不会改善应用性能,只是让人看到编译器是如何工作的JVM标志和其他工具。其中最重要的是-XX:+PrintCompilation(默认为false)。
如果开启PrintCompilation,每次编译一个方法或循环时,JVM就会打印与i行被编译的内容信息。输出的信息在不同的Java发布版本之间会有所不同,这里的输出是Java 7中已经标准化的信息。
绝大多数编译日志的行具有以下格式:
timestamp compilation_id attributes(tiered_level) method_name size deopt