A Stack Overflow user noticed frequent recompilations, even after a substantial JVM uptime and couldn’t find an explanation based on the common knowledge on JIT compilers.
We know that the Java JIT compiler compiles and recompiles methods based on usage statistics. As a method gets called repeatedly, interpretation moves to JIT compilation. More frequent calls can trigger recompilation with a higher optimization level. Despite of that theoretially simple optimization chain, the actual compilation invocations visualized with -XX:+PrintCompilation
can look like they’re not following that strategy all all.
The output based on Java 7 was as follows:
200 10 3 java.lang.String: :equals (65 bytes)
1052 438 4 java.lang.String: :equals (65 bytes)
1058 10 3 java.lang.String: :equals (65 bytes) made not entrant
4854 10 3 java.lang.String: :equals (65 bytes) made zombie
4918 438 4 java.lang.String: :equals (65 bytes) made not entrant
4978 1390 3 java.lang.String: :equals (65 bytes)
5059 1425 4 java.lang.String: :equals (65 bytes)
5066 1396 3 java.lang.String: :equals (65 bytes) made not entrant
5562 1425 4 java.lang.String: :equals (65 bytes) made not entrant
5511 1481 4 java.lang.String: :equals (65 bytes)
6403 438 4 java.lang.String: :equals (65 bytes) made zombie
6404 1396 3 java.lang.String: :equals (65 bytes) made zombie
7600 1425 4 java.lang.String: :equals (65 bytes) made zombie
166576 1481 4 java.lang.String: :equals (65 bytes) made not entrant
166571 6917 3 java.lang.String: :equals (65 bytes)
166599 6932 4 java.lang.String: :equals (65 bytes)
166604 6917 3 java.lang.String: :equals (65 bytes) made not entrant
183156 1481 4 java.lang.String: :equals (65 bytes) made zombie
183311 6917 3 java.lang.String: :equals (65 bytes) made zombie
Even after a substantial JVM uptime (the first column represents the uptime in ms), recompilations of the same method were triggered. At first sight, That doesn’t seem to correspond to the optimization chain outlined above.
The short answer for the impatient: 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 the user’s 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 only)
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 the user’s 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 the user’s 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 constraints 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()
- the user’s 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.