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 通过更积极、更耗时的优化重新编译代码,以提高性能:

1

综上所述, 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 将暂时回滚到解释状态。

当编译器的乐观假设被证明是错误的时, 就会发生去优化——例如,当配置文件信息与方法行为不匹配时:

2

在我们的示例中,一旦热路径发生变化,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. 方法编译

现在让我们看一下方法编译生命周期:

3

总之,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 上获取。