Great solutions are like essential civil engineering infrastructure elements: drinking water pipes, concrete foundations or electrical substations. If it works great, you can forget about its existence. The Java platform and its just-in-time (JIT) code compiler is very much like a civil engineering marvel. From time to time we forget about it and how important of a role it plays when it comes to Java software execution performance… until it breaks down.
Code execution
Code may execute in many different ways. Certain ways are faster, certain ways are more portable, others just make sense for different reasons. The following diagram shows a few examples of how software developed via different programming platforms will execute.
This article focuses on the highlighted runtime transformation: from Java bytecode to machine code. Before digging deep into how Java’s JIT works, let’s see a few basic concepts regarding different times when compilation may occur:
- ahead-of-time (AOT): Compile the software during build time, ship it, and execute the compiled artifacts later. This is the most classic approach of compilation e.g. used by the C language.
- interpreted (INT): Don’t compile the software during build time, just ship it, and deal with any compilation during execution. Traditionally, Python is a good example for an interpreted language.
- just-in-time (JIT): A combination of AOT and INT. May compile some of the software during build time, ship it, compile and re-compile some of it later, right before execution. Java and C# are the most commonly used software development languages that use JIT for compilation.
Just-in-time compiler
JIT’s dynamic compilation logic integrates the following ideas:
- Build time is cheap, execution time is expensive. Compiling in build time is cheaper than compiling in runtime. The Java compiler compiles processor-agnostic Java bytecode from Java source code.
- The later we perform compilation, the more we know about which code is executed frequently. The Java Virtual Machine continuously collects statistics on compiled and executed Java bytecode segments, so JIT knows which segments have the biggest impact on performance. Frequently executed code is labeled as “hot code”.
- Fewer abstraction layers means cheaper compilation. Java code is compiled to bytecode during build time, significantly reducing the layers of abstraction between shipped and processor-dependent native code. Reducing abstraction has a considerable impact on JIT performance, while still keeping the shipped code portable due to Java bytecode’s platform-agnostic property.
- Interpreted execution is more expensive than pre-compiled execution. Since Java bytecode takes less time to compile to native code than Java source code, the JVM is able to start the application faster, and with better overall execution performance.
Let’s dig a little bit deeper into the relationship between JIT and hot code.
Hot code is by definition executed frequently. Let’s see the following Java source code, and identify hot code in it:
package com.sysagnostic;
import java.io.PrintStream;
public class App {
public static void main(String[] args) {
PrintStream outStream = System.out;
outStream.println("Begin!");
for (int i = 0; i < 9; ++i) {
Integer boxed = Integer.valueOf(i);
System.out.println(boxed.toString());
}
outStream.println("End!");
}
}
Let’s compile the code with javac
into bytecode.
$ javac com/sysagnostic/App.java
Now let’s take a look at the generated bytecode.
$ javap -c com.sysagnostic.App
Compiled from "App.java"
public class com.sysagnostic.App {
public com.sysagnostic.App();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: astore_1
4: aload_1
5: ldc #13 // String Begin!
7: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: iconst_0
11: istore_2
12: iload_2
13: bipush 9
15: if_icmpge 39
18: iload_2
19: invokestatic #21 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
22: astore_3
23: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
26: aload_3
27: invokevirtual #27 // Method java/lang/Integer.toString:()Ljava/lang/String;
30: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
33: iinc 2, 1
36: goto 12
39: aload_1
40: ldc #31 // String End!
42: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
45: return
}
Let’s make a bit more sense of javap
’s output. Comments in bytecode section 12-36
are only valid for the first iteration of the for
loop, where i=0
:
Compiled from "App.java"
public class com.sysagnostic.App {
public com.sysagnostic.App();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // push a class static field identified by constant pool location 7 (java.lang.System.out) to stack (stack: [ref out], var1: n/a, var2: n/a, var3: n/a)
3: astore_1 // pop field reference from stack, and store it in variable 1 (stack: [], var1: ref out, var2: n/a, var3: n/a)
4: aload_1 // load reference from variable 1 and push to stack (stack: [ref out], var1: ref out, var2: n/a, var3: n/a)
5: ldc #13 // push the constant from constant pool location 13 (String "Begin!") to stack
7: invokevirtual #15 // call method on object with reference 15 (java.io.PrintStream class's println function) with argument on stack (String "Begin!")
10: iconst_0 // push integer 0 to stack (stack: [0], var1: ref out, var2: n/a, var3: n/a)
11: istore_2 // pop integer from stack and store in variable 2 (stack: [], var1: ref out, var2: 0, var3: n/a)
12: iload_2 // load integer from variable 2 and push to stack (stack: [0], var1: ref out, var2: 0, var3: n/a)
13: bipush 9 // convert byte with value 0x09 to integer and push to stack (stack: [9, 0], var1: ref out, var2: 0, var3: n/a)
15: if_icmpge 39 // compare 2 top stack elements, if greater than equal, jump to code 39 (stack: [], var1: ref out, var2: 0, var3: n/a)
18: iload_2 // load integer from variable 2 and push to stack (stack: [0], var1: ref out, var2: 0, var3: n/a)
19: invokestatic #21 // integer boxing, invoke static method java.lang.Integer.valueOf with the top stack element, push result to stack (stack: [integer ref 0], var1: ref out, var2: 0, var3: n/a)
22: astore_3 // pop integer reference 0, store in variable 3 (stack: [], var1: ref out, var2: 0, var3: int ref 0)
23: getstatic #7 // get class' static field reference for constant pool location 7 (field java.lang.System.out) and push to stack stack: [ref out], var1: ref out, var2: 0, var3: int ref 0)
26: aload_3 // load reference from variable 3 and push to stack (stack: [int ref 0, ref out], var1: ref out, var2: 0, var3: int ref 0)
27: invokevirtual #27 // call virtual method 27 (java.lang.Integer class's toString function) on object reference on stack (int ref 0) an push result to stack (stack: [str ref 0, ref out], var1: ref out, var2: 0, var3: int ref 0)
30: invokevirtual #15 // call virtual method 15 (java.io.PrintStream class's println function) on object reference on stack (ref out) with//argument on stack (str ref 0) (stack: [], var1: ref out, var2: 0, var3: int ref 0)
33: iinc 2, 1 // increment variable 2 by 0x01 (stack: [], var1: ref out, var2: 1, var3: int ref 0)
36: goto 12 // go to byte code 12
39: aload_1
40: ldc #31 // push the constant from constant pool location 13 (String "End!") to stack (stack: [str ref End!, ref out], ...)
42: invokevirtual #15 // call virtual method 15 (java.io.PrintStream class's println function) on object reference on stack (ref out) with//argument on stack (str ref 0) (stack: [], var1: ref out, var2: 0, var3: int ref 0)
45: return // exit application
}
Let’s see how JIT compiles bytecode during runtime. For now, let’s disable tiered compilation, since it complicates things.
$ java -cp . -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:-TieredCompilation com.sysagnostic.App
17 1 n jdk.internal.misc.Unsafe::getReferenceVolatile (native)
Begin!
End!
JIT compiled the jdk.internal.misc.Unsafe::getReferenceVolatile
wrapper method (with compilation identifier 1) to native code 17ms after JVM startup. This output was generated without defining a value for the -XX:CompileThreshold
parameter, which defaults to 5000
. The jdk.internal.misc.Unsafe::getReferenceVolatile
method is compiled to native code since the method is invoked at least 5000 times in 17ms after JVM startup, so it qualifies as hot code by definintion. All other code is interpreted as bytecode by the JVM for the duration of the execution, including our own code.
In Part 2 of this blog post series, we dive deep into how the JIT tiered compilation manages hot code optimization, how code cache is handled by the JVM, and the JIT compiler options that allow fine-tuning your production workloads.