1. 概述
JVM 在运行时解释并执行字节码。此外,它还利用即时(JIT)编译来提高性能。
在 Java 的早期版本中,我们必须在 Hotspot JVM 中提供的两种类型的 JIT 编译器之间手动进行选择。一种针对更快的应用程序启动进行了优化,而另一种则实现了更好的整体性能。 Java 7 引入了分层编译,以实现两全其美。
在本教程中,我们将了解客户端和服务器 JIT 编译器。我们将回顾分层编译及其五个编译级别。最后,我们将通过跟踪编译日志来了解方法编译的工作原理。
2.JIT编译器
JIT 编译器 将频繁执行的部分的字节码编译为本机代码 。这些部分称为热点,因此称为 Hotspot JVM。因此,Java 可以以与完全编译语言相似的性能运行。让我们看一下 JVM 中可用的两种类型的 JIT 编译器。
2.1. C1 – 客户端编译器
客户端编译器也称为 C1,是一种 针对更快的启动时间而优化的 JIT 编译器 。它尝试尽快优化和编译代码。
从历史上看,我们将 C1 用于短期应用程序和启动时间是重要非功能性要求的应用程序。在 Java 8 之前,我们必须指定 -client 标志才能使用 C1 编译器。然而,如果我们使用Java 8或更高版本,这个标志将不起作用。
2.2. C2 – 服务器编译器
服务器编译器也称为 C2,是一种 针对更好的整体性能而优化的 JIT 编译器 。与 C1 相比,C2 观察和分析代码的时间更长。这使得C2能够对编译后的代码进行更好的优化。
从历史上看,我们将 C2 用于长时间运行的服务器端应用程序。在 Java 8 之前,我们必须指定 -server 标志才能使用 C2 编译器。但是,该标志在 Java 8 或更高版本中不起作用。
我们应该注意到, Graal JIT 编译器自 Java 10 起也可用,作为 C2 的替代品。与 C2 不同,Graal 可以在即时和提前编译模式下运行以生成本机代码。
3. 分层编译
C2编译器通常需要更多的时间和消耗更多的内存来编译相同的方法。然而,它生成的本机代码比 C1 生成的代码更好。
分层编译概念最早在 Java 7 中引入。其目标是 混合使用 C1 和 C2 编译器,以实现快速启动和良好的长期性能 。
3.1.两全其美
应用程序启动时,JVM 最初解释所有字节码并收集有关它的分析信息。然后,JIT 编译器利用收集到的分析信息来查找热点。
首先,JIT编译器使用C1编译经常执行的代码部分,以快速达到本机代码性能。随后,当有更多分析信息可用时,C2 就会启动。 C2 通过更积极、更耗时的优化重新编译代码,以提高性能:
综上所述, C1的性能提升速度更快,而C2则基于更多的热点信息,做出更好的性能提升 。
3.2.准确的分析
分层编译的另一个好处是更准确的分析信息。在分层编译之前,JVM 仅在解释期间收集分析信息。
启用分层编译后, JVM 还会收集 有关 C1 编译代码的分析信息 。由于编译后的代码具有更好的性能,因此它允许 JVM 收集更多的分析样本。
3.3.代码缓存
代码缓存是 JVM 存储所有编译为本机代码的字节码的内存区域。分层编译将需要缓存的代码量增加了四倍。
从 Java 9 开始,JVM 将代码缓存分为三个区域:
- 非方法段 – JVM 内部相关代码(大约 5 MB,可通过 -XX:NonNMethodCodeHeapSize 配置)
- 配置文件代码段 – C1 编译的代码,生命周期可能较短(默认情况下约为 122 MB,可通过 -XX:ProfiledCodeHeapSize 配置)
- 非分析段 – C2 编译的代码,具有潜在的长生命周期(默认情况下类似 122 MB,可通过 -XX:NonProfiledCodeHeapSize 配置)
分段代码缓存 有助于提高代码局部性并减少内存碎片 。因此,它提高了整体性能。
3.4.去优化
尽管 C2 编译的代码经过高度优化且寿命较长,但它也可能会被取消优化。结果,JVM 将暂时回滚到解释状态。
当编译器的乐观假设被证明是错误的时, 就会发生去优化——例如,当配置文件信息与方法行为不匹配时:
在我们的示例中,一旦热路径发生变化,JVM 就会取消优化已编译和内联的代码。
4. 编译级别
尽管 JVM 仅使用一个解释器和两个 JIT 编译器工作,但仍有 五种可能的编译级别 。这背后的原因是C1编译器可以在三个不同的级别上运行。这三个级别之间的区别在于完成的分析数量。
4.1. 0 级——解释代码
最初,JVM 解释所有 Java 代码 。在这个初始阶段,性能通常不如编译语言好。
然而,JIT 编译器在预热阶段后启动并在运行时编译热代码。 JIT 编译器利用在此级别收集的分析信息来执行优化。
4.2.级别 1 – 简单的 C1 编译代码
在此级别上,JVM 使用 C1 编译器编译代码,但不收集任何分析信息。 JVM 将级别 1 用于 被认为不重要的方法 。
由于方法复杂度较低,C2 编译不会使其速度更快。因此,JVM 得出的结论是,对于无法进一步优化的代码,收集分析信息是没有意义的。
4.3. 2 级 – 有限 C1 编译代码
在第 2 级,JVM 使用 C1 编译器和轻度分析来编译代码。 当 C2 队列已满时, JVM 使用此级别。目标是尽快编译代码以提高性能。
随后,JVM 使用完整分析在第 3 级重新编译代码。最后,一旦 C2 队列不太忙,JVM 就会在级别 4 上重新编译它。
4.4. 3 级 – 完整的 C1 编译代码
在第 3 级,JVM 使用具有完整分析功能的 C1 编译器来编译代码。级别 3 是默认编译路径的一部分。因此,JVM 在 所有情况下都会使用它,除了简单的方法或编译器队列已满时 。
JIT编译中最常见的场景是解释后的代码直接从level 0跳转到level 3。
4.5. 4 级 – C2 编译代码
在此级别上,JVM 使用 C2 编译器来编译代码,以获得最大的长期性能。 Level 4 也是默认编译路径的一部分。 JVM 使用此级别来 编译除普通方法之外的所有方法 。
鉴于 4 级代码被认为是完全优化的,JVM 将停止收集分析信息。然而,它可能决定对代码进行去优化并将其发送回级别 0。
5. 编译参数
从 Java 8 开始,分层编译默认启用 。强烈建议使用它,除非有充分的理由禁用它。
5.1.禁用分层编译
我们可以通过设置 –XX:-TieredCompilation 标志来禁用分层编译*。* 当我们设置此标志时,JVM 将不会在编译级别之间转换。因此,我们需要选择使用哪个 JIT 编译器:C1 或 C2。
除非明确指定,否则 JVM 会根据我们的 CPU 来决定使用哪个 JIT 编译器。对于多核处理器或64位VM,JVM将选择C2。为了禁用 C2 并仅使用 C1 而没有分析开销,我们可以应用 -XX:TieredStopAtLevel=1 参数。
要完全禁用两个 JIT 编译器并使用解释器运行所有内容,我们可以应用 -Xint 标志。但是,我们应该注意, 禁用 JIT 编译器会对性能产生负面影响 。
5.2.设置级别阈值
编译阈值是 代码编译之前的方法调用次数 。在分层编译的情况下,我们可以为编译级别 2-4 设置这些阈值。例如,我们可以设置一个参数 -XX:Tier4CompileThreshold=10000 。
为了检查特定 Java 版本上使用的默认阈值,我们可以使用 -XX:+PrintFlagsFinal 标志运行 Java:
java -XX:+PrintFlagsFinal -version | grep CompileThreshold
intx CompileThreshold = 10000
intx Tier2CompileThreshold = 0
intx Tier3CompileThreshold = 2000
intx Tier4CompileThreshold = 15000
我们应该注意, 当启用分层编译时,JVM 不会使用通用的 CompileThreshold 参数 。
6. 方法编译
现在让我们看一下方法编译生命周期:
总之,JVM 最初解释一个方法,直到其调用达到 Tier3CompileThreshold 。然后,它 使用 C1 编译器编译该方法,同时继续收集分析信息 。最后,当调用达到 Tier4CompileThreshold 时,JVM 使用 C2 编译器编译该方法。最终,JVM 可能决定对 C2 编译的代码进行去优化。这意味着整个过程将重复。
6.1.编译日志
默认情况下,JIT 编译日志处于禁用状态。要启用它们,我们可以 设置 -XX:+PrintCompilation 标志 。编译日志的格式为:
- 时间戳 – 自应用程序启动以来的毫秒数
- 编译 ID – 每个编译方法的增量 ID
- 属性 – 具有五个可能值的编译状态:
- % – 发生堆栈上替换
- s – 该方法已同步
- ! – 该方法包含一个异常处理程序
- b – 编译发生在阻塞模式下
- n – 编译将包装器转换为本机方法
- 编译级别 – 0 到 4 之间
- 方法名称
- 字节码大小
- 去优化指标 – 有两个可能的值:
- 未进入 – 标准 C1 去优化或编译器的乐观假设被证明是错误的
- Made僵尸——垃圾收集器从代码缓存中释放空间的清理机制
6.2.一个例子
让我们通过一个简单的示例来演示方法编译生命周期。首先,我们将创建一个实现 JSON 格式化程序的类:
public class JsonFormatter implements Formatter {
private static final JsonMapper mapper = new JsonMapper();
@Override
public <T> String format(T object) throws JsonProcessingException {
return mapper.writeValueAsString(object);
}
}
接下来,我们将创建一个实现相同接口的类,但实现 XML 格式化程序:
public class XmlFormatter implements Formatter {
private static final XmlMapper mapper = new XmlMapper();
@Override
public <T> String format(T object) throws JsonProcessingException {
return mapper.writeValueAsString(object);
}
}
现在,我们将编写一个使用两种不同格式化程序实现的方法。在循环的前半部分,我们将使用 JSON 实现,然后在其余部分切换到 XML 实现:
public class TieredCompilation {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1_000_000; i++) {
Formatter formatter;
if (i < 500_000) {
formatter = new JsonFormatter();
} else {
formatter = new XmlFormatter();
}
formatter.format(new Article("Tiered Compilation in JVM", "Baeldung"));
}
}
}
最后,我们将设置 -XX:+PrintCompilation 标志,运行 main 方法,并观察编译日志。
6.3.查看日志
让我们重点关注三个自定义类及其方法的日志输出。
前两个日志条目显示 JVM 在级别 3 上编译了 main 方法和 format 方法的 JSON 实现。因此,这两个方法都是由 C1 编译器编译的。 C1编译的代码替换了最初解释的版本:
567 714 3 com.baeldung.tieredcompilation.JsonFormatter::format (8 bytes)
687 832 % 3 com.baeldung.tieredcompilation.TieredCompilation::main @ 2 (58 bytes)
几百毫秒后,JVM 在级别 4 上编译了这两个方法。因此, C2 编译版本取代了之前使用 C1 编译的版本 :
659 800 4 com.baeldung.tieredcompilation.JsonFormatter::format (8 bytes)
807 834 % 4 com.baeldung.tieredcompilation.TieredCompilation::main @ 2 (58 bytes)
仅仅几毫秒后,我们就看到了第一个去优化的例子。这里,JVM 将 C1 编译版本标记为过时(不是进入):
812 714 3 com.baeldung.tieredcompilation.JsonFormatter::format (8 bytes) made not entrant
838 832 % 3 com.baeldung.tieredcompilation.TieredCompilation::main @ 2 (58 bytes) made not entrant
一段时间后,我们会注意到另一个去优化的例子。此日志条目很有趣,因为 JVM 将完全优化的 C2 编译版本标记为过时(不是进入)。这意味着 JVM 在检测到完全优化的代码不再有效时回滚了该代码 :
1015 834 % 4 com.baeldung.tieredcompilation.TieredCompilation::main @ 2 (58 bytes) made not entrant
1018 800 4 com.baeldung.tieredcompilation.JsonFormatter::format (8 bytes) made not entrant
接下来,我们将第一次看到 format 方法的 XML 实现。 JVM 在第 3 级编译它以及 main 方法:
1160 1073 3 com.baeldung.tieredcompilation.XmlFormatter::format (8 bytes)
1202 1141 % 3 com.baeldung.tieredcompilation.TieredCompilation::main @ 2 (58 bytes)
几百毫秒后,JVM 在第 4 级编译了这两个方法。但是,这一次, main 方法使用的是 XML 实现:
1341 1171 4 com.baeldung.tieredcompilation.XmlFormatter::format (8 bytes)
1505 1213 % 4 com.baeldung.tieredcompilation.TieredCompilation::main @ 2 (58 bytes
和之前一样,几毫秒后,JVM 将 C1 编译版本标记为过时(不是进入):
1492 1073 3 com.baeldung.tieredcompilation.XmlFormatter::format (8 bytes) made not entrant
1508 1141 % 3 com.baeldung.tieredcompilation.TieredCompilation::main @ 2 (58 bytes) made not entrant
JVM 继续使用 4 级编译方法,直到我们的程序结束。
七、结论
在本文中,我们探讨了 JVM 中的分层编译概念。我们回顾了两种类型的 JIT 编译器以及分层编译如何使用这两种编译器来获得最佳结果。我们看到了五个编译级别,并了解了如何使用 JVM 参数来控制它们。
在示例中,我们通过观察编译日志探索了完整的方法编译生命周期。
与往常一样,源代码可以在 GitHub 上获取。