4.6 逆优化
PrintCompilation 标志输出的讨论中曾提到两种代码逆优化的情况。逆优化意味着编译器不得不“撤销”之前的某些编译;结果是应用的性能降低——至少是直到编译器重新编译相应代码为止。
有两种逆优化的情形:代码状态分别为“made not entrant”(代码被丢弃)和“made zombie”(产生僵尸代码)时。
4.6.1 代码被丢弃
有两种原因导致代码被丢弃。可能是和类与接口的工作方式有关,也可能与分层编译的实现细节有关。
先考虑第一种情况。回想一下 stock 应用的接口 StockPriceHistory。示例代码中有两种接口实现:基本实现(StockPriceHistoryImpl)和每个操作带有日志的实现(StockPriceHistoryLogger)。servlet 代码依据请求 URL 上的 log 参数来选择实现:
StockPriceHistory sph;String log = request.getParameter("log");if (log != null && log.equals("true")) {sph = new StockPriceHistoryLogger(...);}else {sph = new StockPriceHistoryImpl(...);}// 然后JSP调用:sph.getHighPrice();sph.getStdDev();// 等等
如果向 http://localhost:8080/StockServlet 发起一组请求调用(没有 log 参数),那么编译器会看到 sph 的实际类型为 StockPriceHistoryImpl。然后它将内联代码,并据此执行其他优化。
之后再向 http://localhost:8080/StockServlet?log=true 发起一次调用。现在编译器依据 sph 类型所做的假定就不成立了,之前的优化也失效了。这就产生了逆优化陷阱(deoptimization trap),之前的优化也被废弃了。如果有更多这样带有 log=true 的请求调用,JVM 就会很快终止这部分代码编译,而开始新的编译。
该场景的编译日志包括了以下信息:
841113 25 % net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)made not entrant841113 937 s net.sdo.StockPriceHistoryImpl::process (248 bytes)made not entrant1322722 25 % net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)made zombie1322722 937 s net.sdo.StockPriceHistoryImpl::process (248 bytes)made zombie
请注意,OSR 编译过的构造函数和标准编译过的方法都被标记成 made not entrant,过了一会,它们又被标记成 made zombie。
逆优化听起来不太好,至少作为性能优化的术语是这样,但并非总是如此。本章的第一个例子中用了股票的 servlet 应用,只测了触发 StockPriceHistoryImpl 的 URL。测试热身时间 300 秒,使用分层编译,达到 24.4 OPS。
假定上述测试之后立刻用 StockPriceHistoryLogger 测试——也就是我刚刚列举的那个逆优化例子。PrintCompilation 的完整输出显示,请求带日志的 StockPriceHistory 实现时,StockPriceHistoryImpl 类的所有方法都被逆优化了。不过,逆优化之后,如果再次请求 StockPriceHistoryImpl,这些代码又会重新编译(原先的假设会发生少许差异),最终我们仍将看到大约 24.4 OPS(在新一轮热身之后)。
当然这是最好的情况。如果混合这些调用,使得编译器无法假定采用哪种代码路径,会发生什么?因为有额外的日志,所以通过 servlet 访问带日志的路径大约需要 24.1 OPS。如果混合操作,大约为 24.3 OPS,与期望的平均值相近。在批处理程序中也能观察到类似的结果。所以,除了进入陷阱的短暂时间,逆优化对其他方面没有产生什么重大影响。
第二种导致代码被丢弃的原因是分层编译。在分层编译中,代码先由 client 编译器编译,然后由 server 编译器编译(实际上要比这复杂一些,下一节会进一步讨论)。当 server 编译器编译好代码之后,JVM 必须替换 client 编译器所编译的代码。它会将老代码标记为废弃,也用同样的办法替换新编译(和更有效)的代码。因此,当程序使用分层编译时,编译日志就会显示许多被丢弃的方法。不要慌张,这种“逆优化”事实上使代码变得更快了。
可以通过观察编译日志中的层次级别信息来检测逆优化:
40915 84 % 3 net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)40923 3697 3 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)41418 87 % 4 net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)41434 84 % 3 net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)made not entrant41458 3749 4 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)41469 3697 3 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)made not entrant42772 3697 3 net.sdo.StockPriceHistoryImpl::<init> (156 bytes)made zombie42861 84 % 3 net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)made zombie
这里,构造方法首先是级别 3 的 OSR 编译,然后在级别 3 得到完整编译。很快,OSR 代码变得适合级别 4 的编译,所以 JVM 在级别 4 上编译代码,而原先级别 3 的代码被丢弃。相同的过程在标准编译中也会发生,而级别 3 的编译代码最后会变成僵尸代码。
4.6.2 逆优化僵尸代码
编译日志显示产生了僵尸代码,意思是说 JVM 已经回收了之前被丢弃的代码。在上面的例子中,测试过 StockPriceHistoryLogger 之后,StockPriceHistoryImpl 类的编译代码就被丢弃了。但是 StockPriceHistoryImpl 类的对象仍然存在。最终,所有这些对象都会被 GC 回收。全部回收之后,编译器就会注意到,这个类现在适合标记为僵尸代码了。
从性能角度来看,这是好事。回想一下,编译代码保存在有固定大小的代码缓存中。如果发现僵尸方法,这意味着这些有问题的代码可以从代码缓存中移除,腾出空间给其他将被编译的代码(或者限制 JVM 之后需要分配的内存量)。
可能产生的不足是,如果代码被僵尸化以后被再次加载并且频繁使用,JVM 就需要重新编译和重新优化代码。而且情形就像上面所描述的那样,测试一会没日志,一会有日志,然后又没有日志。这种情况下,性能并没有受到明显的影响。一般来说,像僵尸代码重编译这样小的操作对大多数应用都不会有显著的影响。
快速小结
1. 逆优化使得编译器可以回到之前版本的编译代码。
2. 先前的优化不再有效时(例如,所涉及的对象类型发生了更改),才会发生代码逆优化。
3. 代码逆优化时,会对性能产生一些小而短暂的影响,不过新编译的代码会尽快地再次热身。
4. 分层编译时,如果代码之前由 client 编译器编译而现在由 server 编译器优化,就会发生逆优化。
快速小结