4.4 编译器中级调优

大多数情况下,所谓编译器调优,其实就只是为目标机器上的 Java 选择正确的 JVM 和编译器开关(-client-server-XX:+TieredCompilation)而已。分层编译通常是长期运行应用的最佳选择,而对于运行时间短的应用来说,分层编译与 client 编译器的性能差别也只在毫厘之内。

除了选择 JVM 和编译器开关,有些场景还需要进行额外的调优工作,本节就来探讨这一点。

4.4.1 调优代码缓存

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

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

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

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

  1. Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
  2. Compiler has been disabled.
  3. Java HotSpot(TM) 64-Bit Server VM warning: Try increasing the
  4. code cache size using -XX:ReservedCodeCacheSize=

有时候容易忽略这个信息,而且开启分层编译时,Java 7 某些版本所输出的信息也不正确。本节后面将讨论另一种判断编译器是否停止编译代码的方法,即追踪输出的编译日志。

表 4-6 列出了不同平台上代码缓存的默认值。

表4-6:各种平台上代码缓存的默认大小

JVM类型 代码缓存的默认大小
32 位 client,Java 8 32 MB
32 位 server,分层编译,Java 8 240 MB
64 位 server,分层编译,Java 8 240 MB
32 位 client,Java 7 32 MB
32 位 server,Java 7 32 MB
64 位 server,Java 7 48 MB
64 位 server,分层编译,Java 7 96 MB

Java 7 开启分层编译时,默认的代码缓存通常就不够用了,常常需要扩大。使用 client 编译器的大型程序也需要增加代码缓存的大小。

确实没有什么好的机制可以算出程序所需要的代码缓存。所以,如何增加代码缓存,基本上就是摸着石头过河,通常的做法是简单地增加 1 倍或 3 倍。

-XX:ReservedCodeCacheSize=N(对特定编译器来说,N 为默认的值)标志可以设置代码缓存的最大值。代码缓存的管理和大多数 JVM 内存一样,有初始值(由 -XX:InitialCodeCacheSize=N 指定)。代码缓存从初始大小开始分配,一旦充满就会增加(直至最大值)。代码缓存的初始大小依据芯片架构和所用的 JVM 编译器而有所不同(例如 Intel 机器的 client 编译器的初始代码缓存为 160 KB,server 编译器的初始代码缓存为 2496 KB)。缓存大小的自动调整在后台进行,不会对性能造成实际影响,所以通常只需要设定 ReservedCodeCacheSize(也就是设定代码缓存的最大值)。

为了永远不超出空间而将代码缓存的最大值设得很大,这有没有什么坏处?这取决于目标机器上有多少可用资源。代码缓存设为 1 GB,JVM 就会保留 1 GB 的本地内存空间。虽然这部分内存在需要时才会分配,但它仍然是被保留的,这意味着为了满足保留内存,你的机器必须有足够的虚拟内存。

保留内存与已分配内存

理解 JVM 保留内存和分配内存方式之间的差别非常重要。这种差别在代码缓存、Java 堆以及其他 JVM 本地内存结构中都存在。

关于这个主题的详细情况,请参见 8.1 节的“内存占用”。

此外,如果是 32 位 JVM,则进程占用的总内存不能超过 4 GB。这包括 Java 堆、JVM 自身所有代码占用的空间(包括它的本地库和线程栈)、分配给应用的本地内存(或者 NIO 库的直接内存),当然还有代码缓存。

鉴于上述原因,代码缓存总是受限的,大型应用(甚至使用分层编译时的中型应用)有时需要就此进行调优。然而,特别是在 64 位机器上,这个值设置得太高未必有实际效果,因为应用不可能超过进程的空间内存,且一般来说,操作系统会保留更多的内存。

通过 jconsoleMemory(内存)面板的 Memory Pool Code Cache 图表,可以监控代码缓存。

4.4 编译器中级调优 - 图1 快速小结

1. 代码缓存是一种有最大值的资源,它会影响 JVM 可运行的编译代码总量。

2. 分层编译很容易达到代码缓存默认配置的上限(特别是在 Java 7 中)。使用分层编译时,应该监控代码缓存,必要时应该增加它的大小。

4.4.2 编译阈值

本章已经粗略地定义了触发代码编译的条件。其中最主要的因素是代码执行的频度。一旦执行达到一定次数,且达到了编译阈值,编译器就可以获得足够的信息编译代码了。

本节将讨论影响这些阈值的调优标志。不过,本节实际上是为了让你对编译器如何工作有个更深入的了解(并引入一些术语)。实际上只有一种情况需要调优编译阈值,将在本节最后讨论。

编译是基于两种 JVM 计数器的:方法调用计数器和方法中的循环回边计数器。回边实际上可看作是循环完成执行的次数,所谓循环完成执行,包括达到循环自身的末尾,也包括执行了像 continue 这样的分支语句。

JVM 执行某个 Java 方法时,会检查该方法的两种计数器总数,然后判定该方法是否适合编译。如果适合,该方法就进入编译队列(队列的详细信息请参见 4.5.1 节的“编译线程”)。这种编译没有正式的名称,通常叫标准编译。

但是,如果循环真的很长——或因包含所有程序逻辑而永远不退出,又该如何?在这种情况下,JVM 不等方法调用完成就会编译循环。所以循环每完成一轮,回边计数器就会增加并被检测。如果循环的回边计数器超过阈值,那这个循环(不是整个方法)就可以被编译。

这种编译称为栈上替换(On-Stack Replacement,OSR)。由于仅仅编译循环还不够,JVM 必须在循环进行的时候还能编译循环。在循环代码编译结束后,JVM 就会替换还在栈上的代码,循环的下一次迭代就会执行快得多的编译代码。

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

更改 OSR 编译

更改 OSR 编译阈值的情况非常罕见。事实上,虽然 OSR 编译在基准测试(特别是微基准测试)中经常发生,但在实际运行时并不经常出现。

具体来说,OSR 编译由 3 个标志触发:

  1. OSR trigger = (CompileThreshold *
  2. ((OnStackReplacePercentage - InterpreterProfilePercentage)/100))

所有编译器中的 -XX:InterpreterProfilePercentage=N 标志的默认值为 33。client 编译器 -XX:OnStackReplacePercentage=N 的默认值为 933,所以在它开始 OSR 编译前,回边计数器需要达到 13 500。在 server 编译器中,由于 OnStackReplacePercentage 默认值为 140,所以当回边计数器达到 10 700 时才开始 OSR 编译。注意,对于分层编译来说,虽然概念相同,但上述默认值完全取决于不同的标志。详情请看 4.5 节“高级编译器调优”

有一段时期,性能调优中常常会建议更改 CompileThreshold 标志。事实上,Java 的基准测试经常使用该标志(例如,常常在 server 编译器执行 8000 次迭代之后使用它)。

我们已经了解,client 编译器和 server 编译器的基本性能有很大差异,这些差异很大程度上取决于编译方法时编译器所能获得的信息。降低编译阈值,特别是对于 server 编译器来说,可能会减少编译代码的优化——不过,应用测试表明,事实上几乎没有什么差别,比如 8000 次调用和 10 000 次调用差别甚微。

你可以认为,JVM 供应商提交的基准测试已经验证过上述调优,不同设置的基准测试间并没有什么性能差异。他们使用较低的设置主要基于以下两个原因:

  • 节约一点应用热身的时间;

  • 使得某些原本可能不会被 server 编译器编译的方法得以编译。

第一点应该很好理解,但第二点,为什么有些重要方法永远都不会被编译呢?并不是还没达到编译阈值,而是永远都达不到。这是因为虽然计数器随着方法和循环的执行而增加,但它们也会随时间而减少。

每种计数器的值都会周期性减少(特别是当 JVM 达到安全点时)。实际上,计数器只是方法或循环最新热度的度量。由此带来的一个副作用是,执行不太频繁的代码可能永远不会编译,即便是永远运行的程序(相对于热来说,有时称这些方法为温热 [lukewarm])。这就是通过减少编译阈值来进行优化的一种情况,它也是分层编译通常比单独的 server 编译器要快的原因之一。下节将展示对于特定方法如何判定是否需要编译。如果应用分析信息显示关键路径上的方法没有编译,那有时就可以通过降低编译器阈值来触发这些方法的编译。

4.4 编译器中级调优 - 图2 快速小结

1. 当方法和循环执行次数达到某个阈值的时候,就会发生编译。

2. 改变阈值会导致代码提早或推后编译。

3. 由于计数器会随着时间而减少,以至于“温热”的方法可能永远都达不到编译的阈值(特别是对 server 编译器来说)。

4.4.3 检测编译过程

关于中级优化,最后要讨论的内容并不是优化本身,这些调优措施并不会改善应用性能。更准确地说,它们就是可以让人看到编译器是如何工作的 JVM 标志(和其他工具)。其中最重要的是 -XX:+PrintCompilation(默认为 false)。

如果开启 PrintCompilation,每次编译一个方法(或循环)时,JVM 就会打印一行被编译的内容信息。输出的信息在不同的 Java 发布版之间会有所不同,这里的输出是 Java 7 中已经标准化的信息。

绝大多数编译日志的行具有以下格式:

  1. timestamp compilation_id attributes (tiered_level) method_name size deopt

此处的时间戳 timestamp 是编译完成的时间(相对于 JVM 开始的时间 0)。

compilation_id 是内部的任务 ID。通常这个数字只是简单地单调增长,不过在使用 server 编译器时(或者某个时刻编译器的线程数增加时),你有时会发现乱序的 compilation_id。这表明编译线程相对于其他线程快或者慢了,但不能就以此下结论,某个特定的编译任务因为某种原因变得特别慢了,因为这通常只是线程调度的缘故(尽管 OSR 编译比较慢,经常出现乱序)。

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

  • %:编译为 OSR。

  • s:方法是同步的。

  • !:方法有异常处理器。

  • b:阻塞模式时发生的编译。

  • n:为封装本地方法所发生的编译。

其中前 3 个可以自解释。阻塞标志在当前版本的 Java 中默认永远都不会打印,表明编译不会发生在后台(详情请参见 4.5.1 节“编译线程”)。最后,n 属性表明 JVM 生成了一些编译代码以便于调用本地方法。

如果程序没有使用分层编译的方式运行,下一个字段 tiered_level 就是空的。否则就会是数字,以表明所完成编译的级别(参见 4.7 节“分层编译级别”)。

下面一个是被编译方法(或者是被 OSR 编译的包含循环的方法)的名字,打印格式为 ClassName::method

接下来是编译后代码的大小(单位是字节)。这是 Java 字节码的大小,不是被编译代码的大小(所以很不幸,不能用来预估代码缓存的大小)。

最后,在某些情况下,编译日志行的结尾会有一条信息,表明发生了某种逆优化,通常是“made not entrant”或“made zombie”。详情参见 4.6 节“逆优化”。

jstat 检测编译

编译日志需要在程序启动时开启 -XX:+PrintCompilation。如果程序启动时没有开启这个标志,你可以用 jstat 了解编译器内部的部分工作情况。

jstat 有两个有关编译器信息的标志。-compiler 标志提供了关于多少方法被编译的概要信息(此处 5003 是被检测进程的 ID):

  1. % jstat -compiler 5003
  2. Compiled Failed Invalid Time FailedType FailedMethod
  3. 206 0 0 1.97 0

请注意,这里也列出了编译失败的方法个数和最近编译失败的方法名。如果你通过性能分析或其他信息推测某个方法比较慢是因为没有编译,那这行命令就是一个简单验证该假设的方法。

此外,你可以用 -printcompilation 标志获取最近被编译的方法。jstat 借助一个可选参数反复执行操作,你可以看到随时间变化有哪些方法被编译了。在本例中,jstat 每 1 秒(1000 毫秒)输出一次进程 ID 为 5003 的信息:

  1. % jstat -printcompilation 5003 1000
  2. Compiled Size Type Method
  3. 207 64 1 java/lang/CharacterDataLatin1 toUpperCase
  4. 208 5 1 java/math/BigDecimal$StringBuilderHelper getCharArray

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

  1. timestamp compile_id COMPILE SKIPPED: reason

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

代码缓存满了

  需用 ReservedCodeCache 标志增大代码缓存的大小。

编译的同时加载类

  编译类的时候会发生修改。JVM 之后会再次编译,你可以在之后的日志中看到方法被再次编译。

在所有这些情况(除了代码缓存被填满)中,编译都可以再次尝试。如果不能,说明代码编译出了错。虽然通常是编译器的缺陷,但常用的解决方法是将代码重构得更简单,以使编译器能够处理。

以下是股票的 servlet Web 应用开启 PrintCompilation 时的几行输出:

  1. 28015 850 net.sdo.StockPrice::getClosingPrice (5 bytes)
  2. 28179 905 s net.sdo.StockPriceHistoryImpl::process (248 bytes)
  3. 28226 25 % net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
  4. 28244 935 net.sdo.MockStockPriceEntityManagerFactory$\
  5. MockStockPriceEntityManager::find (507 bytes)
  6. 29929 939 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
  7. 106805 1568 ! net.sdo.StockServlet::processRequest (197 bytes)

输出包括仅有的一些与股票相关的已编译方法。几个有趣的地方值得注意:首个与股票相关的方法在应用服务器启动 28 秒之后才被编译,在它之前有 849 个被编译的方法。在这个例子中,这些都是应用服务器的方法(从输出日志中可以过滤出来)。应用服务器启动用了 2 秒,剩下的没开始编译之前的 26 秒基本上是空闲,因为应用服务器在等待请求。

输出中剩余的几行显示了一些重要特性。process() 是同步方法,这和你在代码列表中所见到的一样。内部类的编译和其他类一样,在日志中遵循 Java 的命名规则:outer-classname$inner-classname。不出所料,processRequest() 有异常处理器。

最后,回顾一下 StockPriceHistoryImpl 的构造函数,它包含了一个大循环:

  1. public StockPriceHistoryImpl(String s, Date startDate, Date endDate) {
  2. EntityManager em = emf.createEntityManager();
  3. Date curDate = new Date(startDate.getTime());
  4. symbol = s;
  5. while (!curDate.after(endDate)) {
  6. StockPrice sp = em.find(StockPrice.class, new StockPricePK(s, curDate));
  7. if (sp != null) {
  8. if (firstDate == null) {
  9. firstDate = (Date) curDate.clone();
  10. }
  11. prices.put((Date) curDate.clone(), sp);
  12. lastDate = (Date) curDate.clone();
  13. }
  14. curDate.setTime(curDate.getTime() + msPerDay);
  15. }
  16. }

这个循环的执行次数比构造函数本身多,所以是 OSR 编译的目标。请注意,编译构造函数花费了一点时间,开始时它的编译 ID 为 25,但直到编译其他方法又过了 900 多个编译 ID 之后才再次出现该方法。(这个例子中的 OSR 行信息容易被误读成 25%,你会好奇其他 75% 是什么。请注意,这里的数字只是编译 ID,而 % 只表示 OSR 编译。)这是典型的 OSR 编译,栈上替换比较困难,期间还会持续进行其他编译。

4.4 编译器中级调优 - 图3 快速小结

1. 观察代码如何被编译的最好方法是开启 PrintCompilation

2. PrintCompilation 开启后所输出的信息可用来确认编译是否和预期一样。