本文会介绍分层编译的机制, 然后介绍即时编译器对应用启动性能的影响.
本文内容基于 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 越多, 其性能开销也越大.
下图显示了几种可能的编译执行路径.
第一条执行路径, 指的是在通常情况下, 热点方法会被 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 使用率:
CPU 负载:
Java8 默认 JVM 参数情况下的 CPU 使用率和复杂变化(开启分层编译):
CPU 使用率:
CPU 负载:
由此可以看出, 同为即时编译器默认参数情况下, java8 在启动性能上提升了很多.
那如何确定分层编译是否会影响启动性能呢? 因为在 java7 中已经支持了分层编译, 所以在 java7 环境下将分层编译打开, 就可以进行比对.
需要说明的是, 这个比对并不严格, java7 在 CodeCache 的回收上做的不好, 这方面在 java8 中得到了改进, 除此之外还有一些其他方面的改进, 所以这是一个不严格的测试, 但大体能说明问题.
将 java7 的启动参数加上 - XX:+TieredCompilation, 下面是 CPU 使用率和 CPU 负载的变化情况.
CPU 使用率的变化:
CPU 负载的变化:
由此可见分层编译的开启有利于提升应用的启动性能.
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
结果如下:
5 参考文档
[1] 极客时间《深入拆解 Java 虚拟机》 郑雨迪
来源: https://juejin.im/post/5c890f996fb9a049a712ad20