HotSpot原理指南-OSR是什么

前言

前面我们讲解了C1和C2的基本知识,但是我们还未触及一个核心的策略,就是什么时候触发即使编译,也就是when的问题。

对于when的问题,相信大家多多少少都大概知道,每个方法都会有一个调用次数的计数器,当这个计数器的次数到达一定的次数时,就会被认为是热点方法,继而触发JIT编译。

但是本文要科普另外一种触发条件,和方法计数器类似。

方法计数器的问题

大部分人看来,维护一个方法被调用次数计数器当然是一个很完美的方案

但是有一类方法,即使在我们认知范围内,属于热点方法,但是却无法享受到这个计数器的好处。

如下代码:

1
2
3
4
5
6
7
8
9
public class OSRExample {
public static void main(String[] args) {
int sum = 0;
for (int i = 0; i < 200000; i++) {
//do a lot of things
}
System.out.println(sum);
}
}

在Main函数中,有一个循环,在循环中并没有调用某个方法,而是一直在线性执行一些逻辑。

假如我们把循环中的逻辑看做一个函数,这个函数肯定是热点函数,需要进行JIT编译的,但是在这种场景下,并不是一个函数,也就是无法进行JIT。

如果JIT无法处理这种情况,将是非常可惜的。

Hot Loop优化

但是如果我们构造一个上面的代码的情况,并且使用计数器给每次循环的执行时间进行计时。

会发现下面这张时间和次数的图

从图中我们可以看出,大概在150次的时候,整个Loop的耗时突发的大大降低。

说明在HotSpot的JIT中,是可以处理这种情况的。

那么HotSpot究竟是怎么做的呢?

前面我们提到过,如果在Loop中调用的是方法,将不会存在上述的问题,但是实际的情况并不是调用的方法。

那么,我们能不能,把它包装成一个函数呢?

举个例子,原方法如下:

1
2
3
4
5
6
7
8
9
10
11
public class OSRExample {
public static void main(String[] args) {
int sum = 0;
for (int i = 0; i < 20000; i++) {
sum += i;
sum *= sum;
sum |= sum;
}
System.out.println(sum);
}
}

我们把它改成如下的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class OSRExample {
public static void main(String[] args) {
int sum = 0;
for (int i = 0; i < 20000; i++) {
sum = doLoop(sum);
}
System.out.println(sum);
}

private static int doLoop(int sum) {
sum += i;
sum *= sum;
sum |= sum;
return sum;
}
}

这样可以不可以呢?

当然是可以的。

但是!这是作者的猜测,HotSpot真实的情况并不是这样。

事实上,这种割裂整个main方法,动态把一部分代码进行修改的操作似乎消耗太大了,性价比并不高。

HotSpot并不会把Loop的内容动态生成一个函数,然后对该函数进行JIT。

而是对包含这个Loop的整个方法进行了JIT

什么?对整个方法进行JIT?

要知道,这个方法在运行中啊,可能再也不会运行第二次,对整个方法进行JIT有什么意义呢?

稍安勿躁,虽然对整个方法进行了JIT,但是JIT后的代码和原来的函数其实还是有区别的。

如果我们需要将运行到一半的函数,从一个源代码替换到另外一个源代码,遇到的问题是什么呢?

首先,这个方法的循环执行到一半,这个i的具体数值肯定不是0了,是一个不可预测的值。

同时这个sum的值,肯定也是一个不好预测的值。

1
2
3
for (int i = 0; i < 20000; i++) {
sum = doLoop(sum);
}

如果要进行替换,需要把替换时的i和sum的值记录下来,那么替换后的源代码大概就长这样

1
2
3
4
5
6
7
8
public static void main#jit(int i, int sum) {
for (; i < 20000; i++) {
sum += i;
sum *= sum;
sum |= sum;
}
System.out.println(sum);
}

没错!把运行中动态的值作为参数传给JIT后的函数,就是HotSpot的JIT对于这种HotLoop的优化。

OSR

OSR的全称是On-Stack-Replacement。也就是栈上替换。

从上一节我们了解的可以知道,对于main函数,JIT进行编译的时候,直接把运行中的main函数源代码进行了替换,替换成了修改后的main函数。那么之前的main函数栈帧其实就完全失效了,被替换成了新的函数的栈帧。

这种JIT编译的方式就叫OSR编译。

这种栈上替换的方式其实并不是HotSpot独有的,很多其他的语言中也有这样的优化,如V8。

后续问题

OSR能够解决HotLoop的优化问题,但是其实在HotSpot中还是有几个值得深究的点。

  1. 如果这个main函数方法非常大,Loop只是很小的一部分,那么把整个函数进行JIT编译的性价比就值得商榷了。核心问题其实是,为什么必须要编译整个方法呢?

    这个问题R大也给了我们解释,详细看文章

    https://github.com/AdoptOpenJDK/jitwatch/wiki/Understanding-the-On-Stack-Replacement-(OSR)-optimisation-in-the-HotSpot-C1-compiler

  2. OSR其实并不是完美的解决方案,在某些场景下它会生成非常丑陋的代码,如果有多个Loop或者Loop进行嵌套的方法。

    HotSpot在一篇文章中进行了解释,有兴趣可以看文章

    What ths heck is osr and why is it bad or good?