英文:
Why does Java Just in Time Compiler continue to recompile same methods and make methods non-rentrant
问题
我正在使用 AdoptJDk 11.0.7 版本的 Java 在 Windows 上,并且启用了 -XX:+PrintCompilation 标志,以便我可以看到哪些方法正在被编译,而不仅仅是解释执行。
我正在我的应用程序中调用一些功能(处理音频文件并在文件上创建 HTML 报告)。我启动应用程序一次(它具有有限的图形界面),然后连续多次运行相同的任务在同一组文件上。第二次调用时,它运行得比第一次快得多,第三次比第二次稍快,然后后续运行之间的差异不大。但我注意到在每次运行时,仍然在编译许多方法,并且许多方法变得不可重入。
这是分层编译,所以我理解同一方法可以重新编译为更高级别,但是被编译的方法数量似乎没有太大变化。
我不明白为什么会有这么多方法变为不可重入(然后变为僵尸状态),我还没有进行详细的分析,但似乎同样的方法一遍又一遍地被编译,这是为什么呢?
我已经添加了 -XX:-BackgroundCompilation
选项,以强制按顺序编译方法,并让代码在编译版本准备好之前等待,而不是在编译过程中使用解释版本。这似乎减少了许多可重入方法,所以也许是因为它降低了多个线程尝试访问正在(重新)编译的方法的机会?
但仍然有很多方法似乎会被重新编译
例如,这里我可以看到它被编译到级别 3,然后它被编译到级别 4,因此级别 3 的编译变为不可重入并变为僵尸状态。但然后级别 4 变为不可重入,然后回到级别 4 进行编译,依此类推。
英文:
I am using AdoptJDk 11.0.7 Java on Windows and have enabled the -XX:+PrintCompilation flag so I can see what methods are being compiled rather just interpreted
I'm invoking some functionality in my application (which process audio files and create an html report on the files). I start the application once (whihch has a limited GUI) and then run the same task over the same set of files a number of times. The second time it's invoked it runs significantly quicker than the first, the third is slightly faster than the second, and then there is not much difference between subsequent runs. But I notice on each run it is still compiling a number of methods, and a lot of methods are becoming non-reentrant.
It is tiered compilation, so I understand that the same method can be recompiled to a higher level but the number of methods being compiled doesn't seem to change much.
I don't understand why so many methods become non-reentrant (and then zombie), I haven't yet done a detailed analysis but it seems the same methods are being compiled over and over again, why would that be ?
I have added the -XX:-BackgroundCompilation
option to force methods to be compiled in order and for the code to wait for the compiled versions rather than using the interpreted version whilst it compiles. This seems to reduce the number of reentrant methods so maybe that is because it reduces the chances of multiple threads trying to access a method that is being (re)compiled ?
But still many methods seem to get recompiled
e.g here I can see it gets compiled to level 3, then it gets compiled to level 4 so level 3 compile is made non-entrant and the zombied. But then level 4 gets non re-entrant, and it goers back to compiling at level 4 and so on.
答案1
得分: 20
答案简而言之,JIT去优化导致编译代码被禁用("不可进入"),释放("做成僵尸"),并且在再次调用时重新编译(足够多次)。JVM方法缓存维护四种状态:in_use
,not_entrant
,zombie
,unloaded
。方法可能处于in_use
状态,可能已被去优化(not_entrant
),但仍可调用,或者如果它是non_entrant
且不再使用,可能被标记为zombie
。最后,方法可能被标记为要卸载。
在分层编译的情况下,客户端编译器(C1)产生的初始编译结果可能会根据使用统计信息替换为服务器编译器(C2)的编译结果。
在-XX:+PrintCompilation
输出中,编译级别从0
到4
。0
表示解释,1
到3
表示客户端编译器的不同优化级别,4
表示服务器编译器。你的输出中,可以看到java.lang.String.equals()
从3
过渡到4
。当发生这种情况时,原始方法被标记为not_entrant
。它仍然可以被调用,但一旦不再被引用,它将过渡到zombie
状态。
JVM清除器(hotspot/share/runtime/sweeper.cpp
)是一个后台任务,负责管理方法的生命周期,并将not_entrant
方法标记为zombie
。清扫间隔取决于许多因素,其中一个因素是方法缓存的可用容量。低容量将增加后台清扫的次数。你可以使用-XX:+PrintMethodFlushing
(仅限JVM调试构建)来监视清扫活动。通过最小化缓存大小并最大化其激进阈值,可以增加清扫频率:
-XX:StartAggressiveSweepingAt=100(仅限JVM调试构建)
-XX:InitialCodeCacheSize=4096(仅限JVM调试构建)
-XX:ReservedCodeCacheSize=3m(仅限JVM调试构建)
为了说明生命周期,可以将-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_entrant
和zombie
。稍后,C2的结果也是如此,然后再次进行新的编译。
对于所有先前结果达到zombie
状态,即使该方法之前已经成功编译过,也会触发新的编译。因此,这可能会一次又一次地发生。zombie
状态可能会延迟(如您的情况),这取决于编译代码的年龄(通过-XX:MinPassesBeforeFlush
控制)、方法缓存的大小和可用容量,以及not_entrant
方法的使用等因素。
除了从C1过渡到C2、方法年龄限制和方法缓存大小限制外,还有什么可以触发not_entrant
的呢?以及如何将推理可视化呢?
通过-XX:+TraceDeoptimization
(仅限JVM调试构建),可以了解为什么给定方法被标记为not_entrant
的原因。在上述示例中,输出为:
在java.lang.String::<init>中发生了不常见的陷阱
原因=tenured
操作=make_not_entrant
这里,原因是由-XX:MinPassesBeforeFlush=0
所施加的年龄限制:
Reason_tenured, // 代码的年龄已达到限制
JVM还知道以下关于去优化的主要原因:
Reason_null_check, // 遇到意外的null或零除数(@bci)
Reason_null_assert, // 遇到意外的非null或非零(@bci)
Reason_range_check, // 遇到意外的数组索引(@bci)
Reason_class_check, // 遇到意外的对象类(@bci)
Reason_array_check, // 遇到意外的数组类(aastore @bci)
Reason_intrinsic, // 遇到意外的内部操作数(@bci)
Reason_bimorphic, // 遇到意外的对象类在双态条件下
Reason_profile_predicate, // 编译器生成的
<details>
<summary>英文:</summary>
The short answer is that JIT deoptimization causes compiled code to be disabled ("made not entrant"), freed ("made zombie"), and recompiled if called again (a sufficient number of times).
The JVM method cache maintains four states:
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
};
A method can be `in_use`, it might have been disabled by deoptimization (`not_entrant`) but can still be called, or it can be marked as a `zombie` if it's `non_entrant` and not in use anymore. Lastly, the method can be marked for unloading.
In case of tiered compilation, the initial compilation result produced by the client compiler (C1) might be replaced with a compilation result from server compiler (C2) depending on usage statistics.
The compilation level in the `-XX:+PrintCompilation` output ranges from `0` to `4`. `0` represents interpretation, `1` to `3` represents different optimization levels of the client compiler, `4` represents the server compiler. In your output, you can see `java.lang.String.equals()` transitioning from `3` to `4`. When that happens, the original method is marked as `not_entrant`. It can still be called but it will transition to `zombie` as soon as it is not referenced anymore.
The JVM sweeper (`hotspot/share/runtime/sweeper.cpp`), a background task, is responsible for managing the method lifecycle and marking `not_reentrant` methods as `zombie`s. The sweeping interval depends on a number of factors, one being the available capacity of the method cache. A low capacity will increase the number of background sweeps. You can monitor the sweeping activity using `-XX:+PrintMethodFlushing` (JVM debug builds only). The sweep frequency can be increased by minimizing the cache size and maximizing its aggressiveness threshold:
-XX:StartAggressiveSweepingAt=100 (JVM debug builds only)
-XX:InitialCodeCacheSize=4096 (JVM debug builds only)
-XX:ReservedCodeCacheSize=3m (JVM debug builds noly)
To illustrate the lifecycle, `-XX:MinPassesBeforeFlush=0` (JVM debug builds only) can be set to force an immediate transition.
The code below will trigger the following output:
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
The constructor of `java.lang.String` gets compiled with C1, then C2. The result of C1 gets marked as `not_entrant` and `zombie`. Later, the same is true for the C2 result and a new compilation takes place thereafter.
Reaching the `zombie` state for all previous results triggers a new compilation even though the method was compiled successfully before. So, this can happen over and over again. The `zombie` state might be delayed (as in your case) depending on the age of the compiled code (controlled via `-XX:MinPassesBeforeFlush`), the size and available capacity of the method cache, and the usage of `not_entrant` methods, to name the main factors.
Now, we know that this continual recompilation can easily happen, as it does in your example (`in_use` -> `not_entrant` -> `zombie` -> `in_use`). But what can trigger `not_entrant` besides transitioning from C1 to C2, method age constraints and method cache size contraints and how can the reasoning be visualized?
With `-XX:+TraceDeoptimization` (JVM debug builds only), you can get to the reason why a given method is being marked as `not_entrant`. In case of the example above, the output is (shortened/reformatted for the sake of readability):
Uncommon trap occurred in java.lang.String::<init>
reason=tenured
action=make_not_entrant
Here, the reason is the age constraint imposed by `-XX:MinPassesBeforeFlush=0`:
Reason_tenured, // age of the code has reached the limit
The JVM knows about the following other main reasons for deoptimization:
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.
With that information, we can move on to the more interesting example that directly relates to `java.lang.String.equals()` - your scenario:
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);
}
The code starts off by comparing two `String` instances. After 100 million comparisons, it sets `b` to `null` and continues. This is what happens at that point (shortened/reformatted for the sake of readability):
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)
Based on statistics, the compiler determined that the null check in `instanceof` used by `java.lang.String.equals()` (`if (anObject instanceof String) {`) can be eliminated because `b` was never null. After 100 million operations, that invariant was violated and the trap was triggered, leading to recompilation with the null check.
We can turn things around to illustrate yet another deoptimization reason by starting of with `null` and assigning `b` after 100 million iterations:
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
In this instance, the compiler determined that the branch corresponding to the `instanceof` condition (`if (anObject instanceof String) {`) is never taken because `anObject` is always null. The whole code block including the condition can be eliminated. After 100 million operations, that invariant was violated and the trap was triggered, leading to recompilation/interpretation without branch elimination.
Optimizations performed by the compiler are based on statistics collected during code execution. The assumptions of the optimizer are recorded and checked by means of traps. If any of those invariants are violated, a trap is triggered that will lead to recompilation or interpretation. If the execution pattern changes, recompilations may be triggered as a result even though a previous compilation result exists. If a compilation result gets removed from the method cache for reasons outlined above, the compiler might be triggered again for the affected methods.
</details>
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论