前言
前面我们讲解了C1和C2的基本知识,但是我们还未触及一个核心的策略,就是什么时候触发即使编译,也就是when的问题。
对于when的问题,相信大家多多少少都大概知道,每个方法都会有一个调用次数的计数器,当这个计数器的次数到达一定的次数时,就会被认为是热点方法,继而触发JIT编译。
但是本文要科普另外一种触发条件,和方法计数器类似。
方法计数器的问题
大部分人看来,维护一个方法被调用次数计数器当然是一个很完美的方案
但是有一类方法,即使在我们认知范围内,属于热点方法,但是却无法享受到这个计数器的好处。
如下代码:
1 | public class OSRExample { |
在Main函数中,有一个循环,在循环中并没有调用某个方法,而是一直在线性执行一些逻辑。
假如我们把循环中的逻辑看做一个函数,这个函数肯定是热点函数,需要进行JIT编译的,但是在这种场景下,并不是一个函数,也就是无法进行JIT。
如果JIT无法处理这种情况,将是非常可惜的。
Hot Loop优化
但是如果我们构造一个上面的代码的情况,并且使用计数器给每次循环的执行时间进行计时。
会发现下面这张时间和次数的图
从图中我们可以看出,大概在150次的时候,整个Loop的耗时突发的大大降低。
说明在HotSpot的JIT中,是可以处理这种情况的。
那么HotSpot究竟是怎么做的呢?
前面我们提到过,如果在Loop中调用的是方法,将不会存在上述的问题,但是实际的情况并不是调用的方法。
那么,我们能不能,把它包装成一个函数呢?
举个例子,原方法如下:
1 | public class OSRExample { |
我们把它改成如下的方法
1 | public class OSRExample { |
这样可以不可以呢?
当然是可以的。
但是!这是作者的猜测,HotSpot真实的情况并不是这样。
事实上,这种割裂整个main方法,动态把一部分代码进行修改的操作似乎消耗太大了,性价比并不高。
HotSpot并不会把Loop的内容动态生成一个函数,然后对该函数进行JIT。
而是对包含这个Loop的整个方法进行了JIT。
什么?对整个方法进行JIT?
要知道,这个方法在运行中啊,可能再也不会运行第二次,对整个方法进行JIT有什么意义呢?
稍安勿躁,虽然对整个方法进行了JIT,但是JIT后的代码和原来的函数其实还是有区别的。
如果我们需要将运行到一半的函数,从一个源代码替换到另外一个源代码,遇到的问题是什么呢?
首先,这个方法的循环执行到一半,这个i的具体数值肯定不是0了,是一个不可预测的值。
同时这个sum的值,肯定也是一个不好预测的值。
1 | for (int i = 0; i < 20000; i++) { |
如果要进行替换,需要把替换时的i和sum的值记录下来,那么替换后的源代码大概就长这样
1 | public static void main#jit(int i, int sum) { |
没错!把运行中动态的值作为参数传给JIT后的函数,就是HotSpot的JIT对于这种HotLoop的优化。
OSR
OSR的全称是On-Stack-Replacement。也就是栈上替换。
从上一节我们了解的可以知道,对于main函数,JIT进行编译的时候,直接把运行中的main函数源代码进行了替换,替换成了修改后的main函数。那么之前的main函数栈帧其实就完全失效了,被替换成了新的函数的栈帧。
这种JIT编译的方式就叫OSR编译。
这种栈上替换的方式其实并不是HotSpot独有的,很多其他的语言中也有这样的优化,如V8。
后续问题
OSR能够解决HotLoop的优化问题,但是其实在HotSpot中还是有几个值得深究的点。
如果这个main函数方法非常大,Loop只是很小的一部分,那么把整个函数进行JIT编译的性价比就值得商榷了。核心问题其实是,为什么必须要编译整个方法呢?
这个问题R大也给了我们解释,详细看文章
OSR其实并不是完美的解决方案,在某些场景下它会生成非常丑陋的代码,如果有多个Loop或者Loop进行嵌套的方法。
HotSpot在一篇文章中进行了解释,有兴趣可以看文章