4.5 高级编译器调优
本节将补充一些编译如何工作的细节,在此过程中探索一些可以影响编译的调优方法。不过,虽然可以更改这些值,但真的不建议这么做,因为这些调优标志很大程度上是为了帮助 JVM 工程诊断 JVM 行为的。如果你对编译器的工作原理非常好奇,那你会对本节感兴趣,如果不是,可直接跳过本节内容。
4.5.1 编译线程
4.4.2 节“编译阈值”曾提到,当方法(或循环)适合编译时,就会进入到编译队列。队列则由一个或多个后台线程处理。这是件好事,意味着编译过程是异步的;这使得即便是代码正在编译的时候,程序也能持续执行。如果是用标准编译所编译的方法,那下次调用该方法时就会执行编译后的方法;如果是用 OSR 编译的循环,那下次循环迭代时就会执行编译后的代码。
编译队列并不严格遵守先进先出的原则:调用计数次数多的方法有更高的优先级。所以,即便在程序开始执行并有大量代码需要编译时,这样的优先顺序仍然有助于确保最重要的代码优先编译。(这是为何 PrintCompilation 输出中的 ID 为乱序的另一个原因。)
当使用 client 编译器时,JVM 会开启一个编译线程;使用 server 编译器时,则会开启两个这样的线程。当启用分层编译时,JVM 默认开启多个 client 和 server 线程,线程数依据一个略复杂的等式而定,包括目标平台 CPU 数取双对数之后的数值。表 4-7 中显示的值即为计算出的数值。
表4-7:分层编译中编译器C1和C2的默认线程数
| CPU数量 | C1的线程数 | C2的线程数 |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 1 | 1 |
| 4 | 1 | 2 |
| 8 | 1 | 2 |
| 16 | 2 | 6 |
| 32 | 3 | 7 |
| 64 | 4 | 8 |
| 128 | 4 | 10 |
编译器的线程数(3 种编译器都是如此)可通过 -XX:CICompilerCount=N 标志来设置(默认值参见前表)。这是 JVM 处理队列的线程总数;对分层编译来说,其中三分之一(至少一个)将用来处理 client 编译器队列,其余的线程(至少一个)用来处理 server 编译器队列。
你何时需要考虑调整该参数值?如果程序运行在单 CPU 系统上,那么只有设置成单个编译器线程才可以得到些好处:对可用 CPU 受限的系统来说,在许多情况下只有减少争抢资源的线程数才有利于性能提升。但是,这种好处也仅限于初始的热身阶段;在此之后,已编译过的方法将不会再引起 CPU 竞争。当股票配比处理应用运行在单 CPU 机器上,并且编译器线程数限制为一时,初始计算会快大约 10%(因为不用经常争抢 CPU)。运行的轮次越多,初始时的整体收益就越小,直到所有热点方法都被编译之后,这种收益就消失了。
使用分层编译时,线程数很容易超过系统限制,特别是有多个 JVM 同时运行的时候(每个都开启很多编译线程)。在这种情况下,减少线程数有助于提升整体的吞吐量(尽管代价可能是热身期会持续得更长)。
与此类似,如果有额外可用的 CPU 周期,理论上程序将会受益——至少在热身期间——此时编译器线程数会增加。在实际工作中,这样的好处很难获得。进一步来说,如果有很多可用的 CPU,那么在应用的整个执行过程中,你都可以去尝试那些能充分发挥可用 CPU 周期的方法(而不仅仅在开始时加快编译),这样会好得多。
另外一个编译线程的设定参数是 -XX:+BackgroundCompilation 标志,默认值为 true。这意味着,和参数所描述的一样,编译队列的处理是异步执行的。但这个参数也可以设置为 false,在这种情况下,当一个方法适合编译,执行该方法的代码将一直等到它确实被编译之后才执行(而不是继续在解释器中执行)。用 -Xbatch 可以禁止后台编译。
快速小结
1. 放置在编译队列中的方法的编译会被异步执行。
2. 队列并不是严格按照先后顺序的;队列中的热点方法会在其他方法之前编译。这是编译输出日志中的 ID 为乱序的另一个原因。
4.5.2 内联
编译器所做的最重要的优化是方法内联。遵循面向对象设计的良好代码通常都会包括一些需要通过 getter(也可能包含 setter)访问的属性:
public class Point {private int x, y;public void getX() { return x; }public void setX(int i) { x = i; }}
此类方法调用的开销很大,特别是相对于方法的代码量而言。事实上,在早期的 Java 中,考虑到所有此类调用对性能的影响,性能调优小贴士常常会信誓旦旦地反对此类封装。幸运的是,现在的 JVM 通常都会用内联代码的方式执行这些方法。因此,你可以这样写代码:
Point p = getPoint();p.setX(p.getX() * 2);
而编译后的代码本质上执行的是:
Point p = getPoint();p.x = p.x * 2;
内联默认是开启的。可通过 -XX:-Inline 关闭,然而由于它对性能的影响巨大,事实上你永远不会这么做(例如,关闭内联的话,股票配比测试的性能会减少 50%)。由于内联非常重要,并且还因为有许多其他控制标志,所以通常都会建议对 JVM 内联进行调优。
不幸的是,基本上没法看到 JVM 是如何内联代码的。(如果你从源代码编译 JVM,那可以用 -XX:+PrintInlining 生成带调试信息的版本。这个参数会提供所有关于编译器如何进行内联决策的信息。)最好的方法是查看代码的分析信息,如果在分析信息的顶部附近有简单的方法,并且看起来这些方法应该内联,可用内联标志做些试验。
方法是否内联取决于它有多热以及它的大小。JVM 依据内部计算来判定方法是否是热点(譬如,调用很频繁);是否是热点并不直接与任何调优参数相关。如果方法因调用频繁而可以内联,那只有在它的字节码小于 325 字节时(或 -XX:MaxFreqInlineSize=N 所设定的任意值)才会内联。否则,只有方法很小时,即小于 35 字节(或 -XX:MaxInlineSize=N 所设定的任意值)时才会内联。
有时你会看到增加 MaxInlineSize 的值以便内联更多方法的建议。两者之间常被忽略的是,MaxInlineSize 超过 35 意味着第一次调用方法时就会被内联。然而,方法只有经常被调用时——在这种情况下它的性能会受更大影响——最终才值得内联(假定它的大小小于 325 字节)。否则,MaxInlineSize 调优的最终结果就是减少了热身测试所需的时间,但不太可能对长期运行的程序产生重大影响。
快速小结
1. 内联是编译器所能做的最有利的优化,特别是对属性封装良好的面向对象的代码来说。
2. 几乎用不着调节内联参数,且提倡这样做的建议往往忽略了常规内联和频繁调用内联之间的关系。当考察内联效应时,确保考虑这两种情况。
4.5.3 逃逸分析
如果开启逃逸分析(-XX:+DoEscapeAnalysis,默认为 true),server 编译器将会执行一些非常激进的优化措施。比如,考虑以下计算阶乘的类:
public class Factorial {private BigInteger factorial;private int n;public Factorial(int n) {this.n = n;}public synchronized BigInteger getFactorial() {if (factorial == null)factorial = ...;return factorial;}}
若想在数组中保存前 100 个阶乘值,使用以下代码:
ArrayList<BigInteger> list = new ArrayList<BigInteger>();for (int i = 0; i < 100; i++) {Factorial factorial = new Factorial(i);list.add(factorial.getFactorial());}
factorial 对象只在循环中引用;没有任何其他代码可以访问该对象。因此,JVM 会毫不犹豫地对这个对象进行一系列优化。
当调用
getFactorial()时,没必要获得同步锁。没必要在内存中保存
n;可以在寄存器中保存该值。同样,factorial也可以保存在寄存器中。事实上,根本就不需要分配实际的
factorial对象;可以只追踪这个对象的个别字段。
此类优化非常复杂:虽然这个例子非常简单,但此类优化可能会伴随更复杂的代码。由于所用的代码不同,并不是所有的优化都有必要使用。但逃逸分析可以决定哪些优化是可能的,并决定编译后的代码中哪些是必要的改变。
逃逸分析默认开启。极少数情况下,它会出错,在此类情况下关闭它会变得更快或更稳定。如果你发现了这种情况,最好的应对行为就是简化相关代码:代码越简单越好。(不过如果是 bug,则应该发送报告。)
快速小结
1. 逃逸分析是编译器能做得最复杂的优化。此类优化常常会导致微基准测试失败。
2. 逃逸分析常常会给不正确的同步代码引入“bug”。
快速小结