为什么 Java Just in Time Compiler 继续重新编译相同的方法并使方法不可租用

Why does Java Just in Time Compiler continue to recompile same methods and make methods non-rentrant

我在 Windows 上使用 AdoptJDk 11.0.7 Java 并启用了 -XX:+PrintCompilation 标志,所以我可以看到哪些方法是正在编译而不只是解释

我正在我的应用程序中调用一些功能(处理音频文件并创建文件的 html 报告)。我启动应用程序一次(GUI 有限),然后 运行 多次对同一组文件执行相同的任务。第二次调用它 运行s 比第一次快得多,第三次比第二次快一点,随后的 运行s 之间没有太大区别。但我注意到每个 运行 它仍在编译许多方法,并且很多方法都变得不可重入。

是分层编译,所以我明白同一个方法可以重新编译到更高的层次,但是编译的方法数量似乎没有太大变化。

我不明白为什么这么多方法变成不可重入(然后僵尸),我还没有做详细的分析,但似乎是相同的方法一遍又一遍地编译,为什么会这样?

我添加了 -XX:-BackgroundCompilation 选项来强制按顺序编译方法,并让代码等待编译版本,而不是在编译时使用解释版本。这似乎减少了 重入 方法的数量,所以这可能是因为它减少了多个线程尝试访问正在(重新)编译的方法的机会?

但似乎仍有许多方法需要重新编译

例如,在这里我可以看到它被编译到第 3 级,然后被编译到第 4 级,因此第 3 级编译成为非进入者和僵尸。但是随后第 4 级变得不可重入,它又回到第 4 级进行编译,依此类推。

简短的回答是,JIT 反优化会导致已编译的代码被禁用 ("made not entrant")、释放 ("made zombie"),并在再次调用时重新编译(足够次数)。

JVM方法缓存维护四种状态:

enum {
  in_use       = 0, // executable nmethod
  not_entrant  = 1, // marked for deoptimization but activations
                    // may still exist, will be transformed to zombie
                    // when all activations are gone
  zombie       = 2, // no activations exist, nmethod is ready for purge
  unloaded     = 3  // there should be no activations, should not be
                    // called, will be transformed to zombie immediately
};

一个方法可以是 in_use,它可能已被去优化禁用 (not_entrant) 但仍然可以被调用,或者它可以被标记为 zombie 如果它是 non_entrant 并且不再使用了。最后,该方法可以标记为卸载。

在分层编译的情况下,客户端编译器 (C1) 生成的初始编译结果可能会被服务器编译器 (C2) 的编译结果替换,具体取决于使用统计信息。

-XX:+PrintCompilation 输出中的编译级别范围从 040代表解释,13代表客户端编译器的不同优化级别,4代表服务端编译器。在您的输出中,您可以看到 java.lang.String.equals()3 过渡到 4。发生这种情况时,原始方法被标记为 not_entrant。它仍然可以被调用,但一旦不再被引用,它将转换为 zombie

JVM 清理器 (hotspot/share/runtime/sweeper.cpp),一个后台任务,负责管理方法生命周期并将 not_reentrant 个方法标记为 zombies。清除间隔取决于多种因素,其中之一是方法缓存的可用容量。低容量会增加背景扫描的次数。您可以使用 -XX:+PrintMethodFlushing(仅限 JVM 调试版本)监视扫描 activity。可以通过最小化缓存大小并最大化其攻击性阈值来增加扫描频率:

-XX:StartAggressiveSweepingAt=100 (JVM debug builds only)
-XX:InitialCodeCacheSize=4096 (JVM debug builds only)
-XX:ReservedCodeCacheSize=3m (JVM debug builds noly)

为了说明生命周期,-XX:MinPassesBeforeFlush=0(仅限 JVM 调试构建)可以设置为强制立即转换。

下面的代码将触发以下输出:

while (true) {
  String x = new String();
}
    517   11    b  3       java.lang.String::<init> (12 bytes)
    520   11       3       java.lang.String::<init> (12 bytes)   made not entrant
    520   12    b  4       java.lang.String::<init> (12 bytes)
    525   12       4       java.lang.String::<init> (12 bytes)   made not entrant
    533   11       3       java.lang.String::<init> (12 bytes)   made zombie
    533   12       4       java.lang.String::<init> (12 bytes)   made zombie
    533   15    b  4       java.lang.String::<init> (12 bytes)
    543   15       4       java.lang.String::<init> (12 bytes)   made not entrant
    543   13       4       java.lang.String::<init> (12 bytes)   made zombie

java.lang.String 的构造函数先用 C1 编译,然后用 C2 编译。 C1 的结果被标记为 not_entrantzombie。之后C2的结果也是如此,然后重新编译。

所有先前结果达到 zombie 状态会触发新的编译,即使该方法之前已成功编译。所以,这可能会一遍又一遍地发生。 zombie 状态可能会延迟(如您的情况),具体取决于编译代码的年龄(通过 -XX:MinPassesBeforeFlush 控制)、方法缓存的大小和可用容量以及 not_entrant 方法,命名主要因素。

现在,我们知道这种持续的重新编译很容易发生,就像您的示例中那样 (in_use -> not_entrant -> zombie -> in_use ).但是除了从C1过渡到C2,方法年龄限制和方法缓存大小限制之外,还有什么可以触发not_entrant以及如何可视化推理?

使用 -XX:+TraceDeoptimization(仅限 JVM 调试构建),您可以了解给定方法被标记为 not_entrant 的原因。在上面的示例中,输出为(shortened/reformatted 为了便于阅读):

Uncommon trap occurred in java.lang.String::<init>
  reason=tenured
  action=make_not_entrant

这里的原因是-XX:MinPassesBeforeFlush=0施加的年龄限制:

Reason_tenured,               // age of the code has reached the limit

JVM 知道以下其他去优化的主要原因:

Reason_null_check,            // saw unexpected null or zero divisor (@bci)
Reason_null_assert,           // saw unexpected non-null or non-zero (@bci)
Reason_range_check,           // saw unexpected array index (@bci)
Reason_class_check,           // saw unexpected object class (@bci)
Reason_array_check,           // saw unexpected array class (aastore @bci)
Reason_intrinsic,             // saw unexpected operand to intrinsic (@bci)
Reason_bimorphic,             // saw unexpected object class in bimorphic 
Reason_profile_predicate,     // compiler generated predicate moved from
                              // frequent branch in a loop failed

Reason_unloaded,              // unloaded class or constant pool entry
Reason_uninitialized,         // bad class state (uninitialized)
Reason_unreached,             // code is not reached, compiler
Reason_unhandled,             // arbitrary compiler limitation
Reason_constraint,            // arbitrary runtime constraint violated
Reason_div0_check,            // a null_check due to division by zero
Reason_age,                   // nmethod too old; tier threshold reached
Reason_predicate,             // compiler generated predicate failed
Reason_loop_limit_check,      // compiler generated loop limits check
                              // failed
Reason_speculate_class_check, // saw unexpected object class from type
                              // speculation
Reason_speculate_null_check,  // saw unexpected null from type speculation
Reason_speculate_null_assert, // saw unexpected null from type speculation
Reason_rtm_state_change,      // rtm state change detected
Reason_unstable_if,           // a branch predicted always false was taken
Reason_unstable_fused_if,     // fused two ifs that had each one untaken
                              // branch. One is now taken.

根据这些信息,我们可以继续讨论与 java.lang.String.equals() 直接相关的更有趣的示例 - 您的场景:

String a = "a";
Object b = "b";
int i = 0;
while (true) {
  if (++i == 100000000) {
    System.out.println("Calling a.equals(b) with b = null");
    b = null;
  }
  a.equals(b);
}

代码从比较两个 String 实例开始。经过 1 亿次比较后,它将 b 设置为 null 并继续。这就是当时发生的事情(shortened/reformatted 为了便于阅读):

Calling a.equals(b) with b = null
Uncommon trap occurred in java.lang.String::equals
  reason=null_check
  action=make_not_entrant
    703   10       4       java.lang.String::equals (81 bytes)   made not entrant
DEOPT PACKING thread 0x00007f7aac00d800 Compiled frame 
     nmethod    703   10       4       java.lang.String::equals (81 bytes)

     Virtual frames (innermost first):
java.lang.String.equals(String.java:968) - instanceof @ bci 8

DEOPT UNPACKING thread 0x00007f7aac00d800
     {method} {0x00007f7a9b0d7290} 'equals' '(Ljava/lang/Object;)Z'
     in 'java/lang/String' - instanceof @ bci 8 sp = 0x00007f7ab2ac3700
    712   14       4       java.lang.String::equals (81 bytes)

根据统计信息,编译器确定可以消除 java.lang.String.equals() (if (anObject instanceof String) {) 使用的 instanceof 中的空检查,因为 b 永远不会为空。经过 1 亿次操作后,不变量被违反,陷阱被触发,导致重新编译并进行空检查。

我们可以扭转局面,通过从 null 开始并在 1 亿次迭代后分配 b 来说明另一个去优化原因:

String a = "a";
Object b = null;
int i = 0;
while (true) {
  if (++i == 100000000) {
    System.out.println("Calling a.equals(b) with b = 'b'");
    b = "b";
  }
  a.equals(b);
}
Calling a.equals(b) with b = 'b'
Uncommon trap occurred in java.lang.String::equals
  reason=unstable_if
  action=reinterpret
    695   10       4       java.lang.String::equals (81 bytes)   made not entrant
DEOPT PACKING thread 0x00007f885c00d800
     nmethod    695   10       4       java.lang.String::equals (81 bytes)

     Virtual frames (innermost first):
java.lang.String.equals(String.java:968) - ifeq @ bci 11 

DEOPT UNPACKING thread 0x00007f885c00d800
     {method} {0x00007f884c804290} 'equals' '(Ljava/lang/Object;)Z'
     in 'java/lang/String' - ifeq @ bci 11 sp = 0x00007f88643da700
    705   14       2       java.lang.String::equals (81 bytes)
    735   17       4       java.lang.String::equals (81 bytes)
    744   14       2       java.lang.String::equals (81 bytes)   made not entrant

在此实例中,编译器确定对应于 instanceof 条件 (if (anObject instanceof String) {) 的分支永远不会被执行,因为 anObject 始终为空。可以消除包括条件的整个代码块。经过 1 亿次运算后,违反了该不变量并触发了陷阱,导致 recompilation/interpretation 没有分支消除。

编译器执行的优化基于代码执行期间收集的统计信息。优化器的假设通过陷阱记录和检查。如果这些不变量中的任何一个被违反,就会触发陷阱,导致重新编译或解释。如果执行模式发生变化,即使存在先前的编译结果,也可能会因此触发重新编译。如果由于上述原因从方法缓存中删除了编译结果,则可能会为受影响的方法再次触发编译器。