Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

java

jit

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.

enter image description here

like image 866
Paul Taylor Avatar asked May 19 '20 12:05

Paul Taylor


People also ask

Why is a just in time compiler useful for executing Java programs?

The JIT compiler helps improve the performance of Java programs by compiling bytecodes into native machine code at run time. The JIT compiler is enabled by default. When a method has been compiled, the JVM calls the compiled code of that method directly instead of interpreting it.

What does a just in time JIT compiler do in general )?

A Just-In-Time (JIT) compiler is a feature of the run-time interpreter, that instead of interpreting bytecode every time a method is invoked, will compile the bytecode into the machine code instructions of the running machine, and then invoke this object code instead.

What is a JIT compiler what are its potential advantages over interpretation conventional compilation?

The JIT compiler aids in improving the performance of Java programs by compiling bytecode into native machine code at run time. The JIT compiler is enabled throughout, while it gets activated when a method is invoked. For a compiled method, the JVM directly calls the compiled code, instead of interpreting it.

What is just in time JIT compilation in Java?

The Just-In-Time (JIT) compiler is a component of the Java™ Runtime Environment that improves the performance of Java applications at run time. Java programs consists of classes, which contain platform-neutral bytecodes that can be interpreted by a JVM on many different computer architectures.


Video Answer


1 Answers

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 zombies. 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.

like image 71
horstr Avatar answered Oct 18 '22 13:10

horstr