Java Just-In-Time (JIT) Compiler: Optimize JVM Performance

Learn about the Java Just-In-Time (JIT) compiler, an integral component of the Java Virtual Machine (JVM) that translates bytecode hotspots into machine-level code, significantly improving runtime performance. Discover how JIT optimizes code execution in real-time, making Java applications faster and more efficient.



Java - Just-In-Time (JIT) Compiler

The Just-In-Time (JIT) compiler is an internal compiler used by the Java Virtual Machine (JVM) to translate hotspots in bytecode into machine-understandable code. The primary purpose of the JIT compiler is to perform heavy optimizations to enhance performance.

Java Compilation Process

Java-compiled code targets the JVM. The javac compiler compiles Java code into bytecode. The JVM interprets this bytecode and executes it on the underlying hardware. When some code is executed repeatedly, the JVM identifies it as hotspots and compiles it further using the JIT compiler to native machine code, reusing the compiled code whenever needed.

Compiled vs. Interpreted Languages

Languages such as C, C++, and FORTRAN are compiled languages, producing binary code targeted at the underlying machine architecture. This means the high-level code is compiled into binary code all at once, and the resulting binary will not run on any other architecture.

In contrast, interpreted languages like Python and Perl can run on any machine with a valid interpreter, processing high-level code line-by-line into binary code. Interpreted code is generally slower than compiled code; for example, in a loop, an interpreter converts the corresponding code for each iteration, while a compiler translates it only once. Additionally, interpreters cannot perform significant optimizations like changing the execution order of statements.

Example of JIT Optimization

Consider the task of adding two numbers stored in memory. A good compiler will issue instructions to fetch the data from memory and execute the addition only when the data is available, allowing other instructions to execute in the meantime. An interpreter, however, lacks this optimization capability since it doesn't analyze the entire code at once.

Is Java Compiled or Interpreted?

Java strikes a balance between compiled and interpreted languages. The JVM sits between the javac compiler and the underlying hardware, with javac compiling Java code into bytecode, which is then converted to binary by the JVM using JIT compilation during execution.

Hotspots

In typical programs, only a small section of code is executed frequently, often impacting overall performance significantly. These sections are known as hotspots. If a section of code is executed only once, compiling it would be inefficient, making interpretation faster. However, if a section is executed multiple times, the JVM compiles it to enhance performance. The more the JVM executes a method or loop, the more information it gathers to optimize and generate faster binaries.

Working of JIT Compiler

The JIT compiler improves the execution time of Java programs by compiling certain hotspot codes to machine code. The JVM scans the complete code to identify hotspots and invokes the JIT compiler at runtime, enhancing program efficiency and speed.

Compilation Levels

The JVM supports five compilation levels:

  • Interpreter
  • C1 with full optimization (no profiling)
  • C1 with invocation and back-edge counters (light profiling)
  • C1 with full profiling
  • C2 (uses profiling data from previous steps)

To disable all JIT compilers and use only the interpreter, use the -Xint flag.

Client vs. Server JIT Compiler

Use -client and -server to activate respective modes. The client compiler (C1) begins compiling code sooner than the server compiler (C2). By the time C2 starts compilation, C1 may have already compiled sections of code. However, C2 profiles the code, providing more information than C1, leading to faster binaries despite the longer wait time.

From a user's perspective, the trade-off is between the program's startup time and its runtime performance. For applications that need quick startup (e.g., IDEs like NetBeans or Eclipse), C1 is ideal. For applications expected to run for long periods (typical of server deployments), C2 is better due to the faster code generation, which offsets any extra startup time.

Note that there are two versions of C1: 32b and 64b. C2 is only available in 64b.

Examples of JIT Compiler Optimizations

Below are examples showcasing JIT compiler optimizations:

Example of JIT Optimization with Objects

Consider the following code:

Code Snippet

for (int i = 0; i <= 100; i++) {
System.out.println(obj1.equals(obj2)); // two objects
}

If this code is interpreted, the interpreter would check the class of obj1 for each iteration. However, the JVM notices that obj1 is of class String and generates code directly corresponding to the equals() method of the String class, eliminating unnecessary lookups and speeding up execution.

Example of JIT Optimization with Primitive Values

Consider another example:

Code Snippet

int sum = 7;
for (int i = 0; i <= 100; i++) {
sum += i;
}

An interpreter would access the value of sum from memory in each loop iteration, which is costly in terms of CPU cycles. Since this loop runs multiple times, it qualifies as a hotspot. The JIT compiler optimizes this by storing a local copy of sum in a register for the thread. All operations would then use this register, writing back to memory only after the loop completes.

In cases where multiple threads access the variable, thread synchronization is necessary to avoid stale values. A simple way to achieve this is by declaring sum as volatile. This ensures that before accessing the variable, a thread flushes its local registers and fetches the updated value from memory.

Optimizations Done by Just-In-Time (JIT) Compiler

Here are some general optimizations performed by JIT compilers:

  • Method inlining
  • Dead code elimination
  • Heuristics for optimizing call sites
  • Constant folding