深入理解 Java 即时编译器(下)

本贴最后更新于 1967 天前,其中的信息可能已经沧海桑田

🌹🌹 如果您觉得我的文章对您有帮助的话,记得在 GitHub 上 star 一波哈 🌹🌹

🌹🌹GitHub_awesome-it-blog 🌹🌹


本文会介绍分层编译的机制,然后介绍即时编译器对应用启动性能的影响。

本文内容基于 HotSpot 虚拟机,设计 Java 版本的地方会在文中说明。

0 分层编译概述

在引入分层编译之前,我们需要手动的选择编译器。对于启动性能有要求的短时运行程序,我们会选择 C1 编译器,对应参数-client,对于长时间运行的对峰值性能有要求的程序,我们会选择 C2 编译器,对应参数-server。

Java7 引入了分层编译,使用-XX:+TieredCompilation 参数开启,它综合了 C1 的启动性能优势和 C2 的峰值性能优势。

在 Java8 中默认开启了分层编译,在 Java8 中,无论是开启还是关闭了分层编译,-cilent 和-server 参数都是无效的了。当关闭分层编译的情况下,JVM 会直接使用 C2。

分层编译将 JVM 中代码的执行状态分为了 5 个层次,五个层次分别是:

  • 0 - 解释执行
  • 1 - 执行不带 profiling 的 C1 代码
  • 2 - 执行仅带方法调用次数和循环回边次数 profiling 的 C1 代码
  • 3 - 执行带所有 profiling 的 C1 代码
  • 4 - 执行 C2 代码

(profiling 是指在程序执行过程中收集的程序执行状态数据,例如在上篇中提到的方法调用次数和循环回边次数)

这几个层次的代码执行效率由高到低排序如下:4 > 1 > 2 > 3 > 0

其中,1 > 2 > 3 的原因在于 profiling 越多,其性能开销也越大。

下图显示了几种可能的编译执行路径。

即时编译器 16.jpg

第一条执行路径,指的是在通常情况下,热点方法会被 3 层的 C1 编译,然后被 4 层的 C2 编译。

第二条执行路径,指的是字节码方法较少的情况下,如 getter 和 setter,此时没有什么可收集的 profiling,就会在 3 层编译后,直接交给 1 层来编译。

第三条执行路径,指的是 C1 繁忙时,JVM 会在解释执行时收集 profiling,然后直接有 4 层的 C2 编译。

第四条执行路径,指的是 C2 繁忙时,先由 2 层的 C1 编译再由 3 层的 C1 编译,这样可以减少方法在 3 层的执行时间,最终再交给 C2 执行。

1 分层编译实战

1.1 分层编译的触发

本小结说明了在开启分层编译的情况下,上述的五个层次的编译分别在什么时机触发。

在上篇的第二小节,介绍了在不开启分层编译的情况下,触发即时编译的时机与-XX:CompileThreshold 参数有关(具体可参考上篇)。

在开启分层编译的情况下,这个参数设定的阈值将失效,取而代之的是另一种计算阈值的方案,这个阈值是动态调整的(会乘一个系数 s),当方法调用次数和循环回边次数满足下述两个公式的任意一个时,将会触发第 X 层的即时编译({X}表示第 X 层)。

method_invoke_number > Tier{X}InvocationThreshold * s
or
method_invoke_number > Tier{X}MinInvocationThreshold * s 且 method_invoke_number + loop_number > Tier{X}CompileThreshold * s

说明:
* method_invoke_number:方法调用次数
* loop_number:循环回边次数
* Tier{X}InvocationThreshold:由JMV参数指定,X可取3或4,第3层的默认值为200,第4层的默认值为15000
* s:动态调整的系数(接下来会说明它的计算方式)
* Tier{X}MinInvocationThreshold:JVM设定的参数,X可取3或4,第3层的默认值为100,第4层的默认值为600
* Tier{X}CompileThreshold:JVM设定的参数,X可取2或3或4,第2层的默认值为0,第3层的默认值为2000,第4层的默认值为15000

PS:在【附加】中提供了查看 JVM 参数默认值的方式

系数 s 的计算方式:

s = compiler_method_number_{X} / (Tier{X}LoadFeedback * compiler_thread_number_{X}) + 1
* compiler_method_number_{X}:第X层待编译方法的数目
* Tier{X}LoadFeedback:JVM参数,X可取3或4,第3层的默认值为5,第4层的默认值为3
* compiler_thread_number_{X}:第X层编译线程数目

compiler_thread_number_{X}的计算方式为:

在 64 位的 JVM 中,默认情况下编译线程的总数目 thread_total 是根据 CPU 的数量来调整的,thread_total 的计算方式如下所示,JVM 会把这些线程按照 1:2 的比例分配给 C1 和 C2。

thread_total = log2(N) * log2(log2(N)) * 3 / 2
* N为CPU核心数
例如一个4核的机器,总的编译线程数目thread_total = 3,那么会给C1分配1个线程,C2分配2个线程

由此可以计算出,JVM 默认配置情况下,4 核 CPU,第三层触发 C1 即时编译的阈值为:

假设第 3 层有 10000 个待编译的方法,系数 s = 10000 / (5 * 1) + 1 = 2001

那么

method_invoke_number > 200 * s = 200 * 2001 = 400200

也就是方法调用次数超过 400200 次的时候触发第 3 层的 C1 即时编译。

或者

method_invoke_number > 100 * s = 100 * 2001 = 200100 且 method_invoke_number + loop_number > 2000 * s = 2000 * 2001 = 4002000

即:方法调用次数 >200100 并且 方法调用次数 + 循环回边次数 >4002000 次时,触发 3 层的 C1 即时编译。

同理可以计算出第 4 层 C2 的即时编译阈值:

method_invoke_number > 30015000 时

或者

method_invoke_number > 1200600 且 method_invoke_number + loop_number > 30015000 时

会触发第 4 层的 C2 即时编译。

1.2 分层编译日志

以上篇的一段代码为例,说明分层编译的日志。

/**
*    添加JVM参数: -XX:+PrintCompilation ,打印编译日志
*/
public class JITDemo2 {

    private static Random random = new Random();

    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        int count = 0;
        int i = 0;
        while (i++ < 15000) {
            count += plus();
        }
    }

    // 调用时,编译器计数器+1
    private static int plus() {
        int count = 0;
        // 每次循环,编译器计数器+1
        for (int i = 0; i < 10; i++) {
            count += random.nextInt(10);
        }
        return random.nextInt(10);
    }
}

执行结果如下:

    176    1       3       java.util.Arrays::copyOf (19 bytes)
    176    6       3       java.io.ExpiringCache::entryFor (57 bytes)
    177    7       3       java.util.LinkedHashMap::get (33 bytes)
    177    8       2       java.lang.String::hashCode (55 bytes)
    177    9       3       java.lang.String::equals (81 bytes)
    178   10       2       java.lang.CharacterData::of (120 bytes)
    178   11       2       java.lang.CharacterDataLatin1::getProperties (11 bytes)
    179   12       3       java.lang.String::<init> (82 bytes)
    179   17     n 0       java.lang.System::arraycopy (native)   (static)
    179   13       2       java.lang.String::indexOf (70 bytes)
    179    3       4       java.lang.Object::<init> (1 bytes)
    179    2       4       java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)
    179    5       4       java.lang.String::length (6 bytes)
    179   15       3       java.lang.Math::min (11 bytes)
    180   14       3       java.util.Arrays::copyOfRange (63 bytes)
    180    4       4       java.lang.String::charAt (29 bytes)
    180   16       3       java.lang.String::indexOf (7 bytes)
    180   18       3       java.util.HashMap::hash (20 bytes)
    180   19       3       java.lang.String::substring (79 bytes)
    181   20       4       java.util.TreeMap::parentOf (13 bytes)
    181   21       3       java.lang.Character::toUpperCase (6 bytes)
    181   22       3       java.lang.Character::toUpperCase (9 bytes)
    181   23       3       java.lang.CharacterDataLatin1::toUpperCase (53 bytes)
    181   24       3       java.lang.String::getChars (62 bytes)
    182   25       3       java.io.File::isInvalid (47 bytes)
    184   26       3       java.lang.String::startsWith (7 bytes)
    184   28       3       sun.nio.cs.UTF_8$Encoder::encode (359 bytes)
    184   31  s    4       java.lang.StringBuffer::append (13 bytes)
    184   32       4       java.lang.AbstractStringBuilder::append (29 bytes)
    185   33       4       java.io.WinNTFileSystem::isSlash (18 bytes)
    185   29       3       java.lang.String::indexOf (166 bytes)
    185   27       3       java.lang.String::startsWith (72 bytes)
    186   30       3       java.lang.String::toCharArray (25 bytes)
    186   34       3       java.lang.StringBuffer::<init> (6 bytes)
    186   35       3       java.lang.AbstractStringBuilder::<init> (12 bytes)
    186   37     n 0       sun.misc.Unsafe::getObjectVolatile (native)   
    186   36       3       java.util.concurrent.ConcurrentHashMap::tabAt (21 bytes)
    187   38     n 0       sun.misc.Unsafe::compareAndSwapLong (native)   
    187   41       3       java.util.Random::nextInt (74 bytes)
    187   39       3       java.util.concurrent.atomic.AtomicLong::get (5 bytes)
    187   40       3       java.util.concurrent.atomic.AtomicLong::compareAndSet (13 bytes)
    187   42       3       java.util.Random::next (47 bytes)
    188   43       1       java.util.concurrent.atomic.AtomicLong::get (5 bytes)
    188   39       3       java.util.concurrent.atomic.AtomicLong::get (5 bytes)   made not entrant
    188   44       1       java.util.concurrent.atomic.AtomicLong::compareAndSet (13 bytes)
    188   46       4       java.util.Random::nextInt (74 bytes)
    188   40       3       java.util.concurrent.atomic.AtomicLong::compareAndSet (13 bytes)   made not entrant
*   188   45       3       com.example.demo.gcdemo.JITDemo2::plus (36 bytes)
    188   47       4       java.util.Random::next (47 bytes)
    189   42       3       java.util.Random::next (47 bytes)   made not entrant
*   189   48       4       com.example.demo.gcdemo.JITDemo2::plus (36 bytes)
    189   41       3       java.util.Random::nextInt (74 bytes)   made not entrant
*   191   45       3       com.example.demo.gcdemo.JITDemo2::plus (36 bytes)   made not entrant

说明一下日志格式(最前面的*号忽略,这是为了标记出 plus 方法):

  • 第一列:时间(毫秒)
  • 第二列:JVM 维护的编译 ID
  • 第三列:一些标识,比如上面出现的 n 和 s,n 表示是否是 native 方法,显示在日志中为 true,没显示为 false。s 表示是否是 synchronized 方法。此外还有:% 表示是否是 OSR 编译,!表示是否包含异常处理器,b 表示是否阻塞应用线程。
  • 第四列:编译的层次,0-4 层
  • 第五列:编译的方法名
  • made not entrant:之前被编译过的方法发生了“去优化”,这个在上篇中已经提到过

从日志可以观察出,plus 方法首先触发了 3 层的 C1 即时编译,然后触发了 4 层的 C2 的即时编译,最后被标记为 made not entrant,即 plus 方法发生了去优化。

这里为什么会发生去优化呢,笔者猜想,made not entrant 也就是不会再被进入,因为即时编译器会将编译完的代码存入 CodeCache,而 CodeCache 是在堆外内存的,JVM 进程的结束不会释放这块堆外内存,这样会造成内存泄漏。那么为了释放 CodeCache,就需要在 JVM 结束前对其所有内存进行回收,而 CodeCache 中的内容被回收的依据是所有线程都退出被标记为 made not entrant 方法时,该方法的 CodeCache 就可以被回收。

PS:通过下面代码可以在程序中获取 CodeCache 的使用情况

// 查看Code Cache使用量
List<MemoryPoolMXBean> beans = ManagementFactory.getMemoryPoolMXBeans();
for (MemoryPoolMXBean bean : beans) {
    if ("Code Cache".equalsIgnoreCase(bean.getName())) {
        System.out.println("max: " + bean.getUsage().getMax() + " bytes, used: " + bean.getUsage().getUsed() + " bytes");
    }
}

2 即时编译器对应用程序启动的影响

先来说一下发现的问题:应用启动后,CPU 使用率和负载飙升,导致部分请求失败,频繁报警,大概会持续 1 分钟左右。

然后考虑是否是即时编译器的影响。当时我们在生产环境使用的是 jdk1.7.0_67,且没有开启分层编译,然后想到 java8 对编译器做了一些优化,并且是默认开启分层编译的,然后将其中的一台机器升级到 java8,再重新启动,发现 CPU 使用率和负载都降低了。

由于当时的截图没有了,这里我自己做了一个 web 程序的小 demo。

下面会分别比较 java7 环境和 java8 环境的启动后 CPU 使用率和负载变化。

java7 默认 JVM 参数情况下的 CPU 使用率和复杂变化(不开启分层编译):

CPU 使用率:

即时编译器 17.jpg

CPU 负载:

即时编译器 18.jpg

Java8 默认 JVM 参数情况下的 CPU 使用率和复杂变化(开启分层编译):

CPU 使用率:

即时编译器 19.jpg

CPU 负载:

即时编译器 20.jpg

由此可以看出,同为即时编译器默认参数情况下,java8 在启动性能上提升了很多。

那如何确定分层编译是否会影响启动性能呢?因为在 java7 中已经支持了分层编译,所以在 java7 环境下将分层编译打开,就可以进行比对。

需要说明的是,这个比对并不严格,java7 在 CodeCache 的回收上做的不好,这方面在 java8 中得到了改进,除此之外还有一些其他方面的改进,所以这是一个不严格的测试,但大体能说明问题。

将 java7 的启动参数加上-XX:+TieredCompilation,下面是 CPU 使用率和 CPU 负载的变化情况。

CPU 使用率的变化:

即时编译器 21.jpg

CPU 负载的变化:

即时编译器 22.jpg

由此可见分层编译的开启有利于提升应用的启动性能。

3 思考:分层编译对代码执行性能的影响

3.1 从分层编译的模式考虑

  • 在不开启分层编译的情况下,代码以混合模式执行,当方法调用次数和循环回边次数达到设定的阈值时,会触发对应编译器的即时编译,这个设定的阈值是固定的。
  • 在开启分层编译的情况下,每一层即时编译触发的阈值是动态计算的,而且会根据 JVM 当前执行状态的不同,选用不同的编译器编译,例如 C1 繁忙时,会直接提交给 C2 执行,C2 繁忙时,会先有 C1 编译,在逐步的提交给 C2 执行。

3.2 CodeCache 方面

  • 不开启分层编译的情况下,64 位 JVM 的 CodeCache 默认大小为 48M
  • 开启分层编译的情况下,64 位 JVM 的 CodeCache 的默认大小为 256M

由于 CodeCache 如果越小,GC 的次数越频繁,越影响编译器的性能,CodeCache 过大也不好,会提高单词 GC 需要的时间,所以 CodeCache 尽可能要调整成最合适的大小。

PS:CodeCache 的 GC 笔者没有研究过,所以这里 GC 对其的影响也是一个猜测。

4 附加

查看 JVM 默认值的方式:

-XX:+PrintFlagsFinal

例如:java -XX:+PrintFlagsFinal -version > options.txt

结果如下:

即时编译器 23.jpg

5 参考文档

[1] 极客时间《深入拆解 Java 虚拟机》 郑雨迪

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3190 引用 • 8214 回帖 • 1 关注
  • jit
    2 引用
  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖 • 2 关注
  • 编译原理
    21 引用 • 39 回帖 • 2 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...