Execution Engine Overview
The execution engine constitutes one of the core components of the Java Virtual Machine. Unlike physical machines whose execution engines are built directly upon processors, caches, instruction sets, and operating systems, JVM execution engines are software-based implementations. This design enables customization of instruction formats and engine architectures independent of hardware constraints, allowing execution of instructions not natively supported by physical processors.
The JVM's primary responsibility involves loading bytecode files, but bytecode cannot execute directly on operating systems. Bytecode instructions differ from native machine instructions—they contain only bytecodes recognizable by the JVM, symbol tables, and auxiliary metadata.
The execution engine bridges this gap by interpreting or compiling bytecode instructions into platform-specific native machine instructions. In essence, the execution engine serves as a translator converting high-level language into machine code.
Execution Engine Workflow
- The execution engine relies on the PC register to determine which bytecode instructions require execution.
- After completing each instruction, the PC register updates to point to the next instruction to execute.
- During execution, the engine may use object references stored in the local variable table to locate object instances in the Java heap and access type metadata through object headers.
- Visually, all JVM execution engines follow an identical input-processing-output pattern: input consists of bytecode binary streams, processing involves bytecode interpretation and JIT compilation equivalent operations, and output represents execution results.
Java Code Compilation and Execution
Interpreted vs Compiled Execution
Before source code transforms into physical machine code or executable virtual machine instructions, it passes through several stages:
The initial compilation phase (generating bytecode files via the javac compiler) occurs independently of the JVM. The actual JVM processing includes both interpreted execution and JIT compilation phases.
Interpreter vs JIT Compiler
Interpreter: When the JVM starts, it executes bytecode line-by-line according to predefined specifications, translating each bytecode instruction into corresponding native machine instructions for the target platform.
JIT Compiler (Just-In-Time Compiler): The virtual machine compiles source code directly into platform-specific machine language in a single pass, though execution does not occur immediately.
Why Java is Both Compiled and Interpreted
Java was originally designed as an interpreted language. Over time, compilers capable of generating native code emerged. Modern JVMs combine interpretation with compilation: the JIT compiler translates bytecode to native code and caches the result in the method area's JIT code cache for improved performance, while also performing optimizations during translation.
Machine Code, Instructions, and Assembly Language
Machine Code
Machine instructions encoded in binary format are called machine code. Early programmers wrote code directly in machine language. While machines can execute machine code directly, this format proves difficult for humans to read, write, and maintain. Different CPU architectures require different machine instructions, creating portability challenges.
Instructions and Instruction Sets
Instructions simplify machine code by replacing binary sequences with readable mnemonics like MOV or INC. However, the same operation on different hardware platforms may generate different machine code.
Instruction Sets represent the collection of instructions supported by a particular hardware platform. Common examples include the x86 instruction set for x86 architecture and the ARM instruction set for ARM architecture.
Assembly Language
Assembly language improves readability by using mnemonics for opcodes and symbols or labels for addresses. Since assembly language varies across hardware platforms, assembler programs must translate assembly code into machine instructions. Computers ultimately execute only machine code.
High-Level Languages
High-level languages like Java, C++, and Python approximate human language, making programming more accessible. These languages require interpreters or compilers to translate source code into machine-executable instructions.
Bytecode
Bytecode represents an intermediate binary format between source code and machine code. It requires translation by an interpreter or virtual machine before execution. Bytecode enables platform independence—compilers produce bytecode, and platform-specific virtual machines translate it to native instructions. Java bytecode exemplifies this approach.
C and C++ Compilation Process
The compilation process comprises two stages: compilation and assembly. The compilation phase reads source code as character streams, performs lexical and syntactic analysis, and converts high-level instructions into equivalent assembly code. The assembly phase translates assembly code into target machine instructions.
Interpreter
Why Interpreters Exist
JVM designers aimed to achieve cross-platform compatibility by avoiding static compilation of high-level languages into native code. This necessity gave rise to the interpreter—a runtime translator that converts bytecode into native machine instructions. After interpreting one bytecode instruction, the interpreter retrieves the next instruction address from the PC register and continues execution.
Interpreter Types
Java's history includes two interpreter implementations: the legacy bytecode interppreter and the modern template interpreter.
- The bytecode interpreter simulates bytecode execution through pure software emulation, resulting in poor performance.
- The template interpreter associates each bytecode instruction with a template function containing generated native machine code, significantly improving performance.
HotSpot VM's interpreter comprises the Interpreter module (implementing core interpreter functionality) and the Code module (managing runtime-generated native instructions).
Current State of Interpreters
Despite their simplicity in design and implementation, interpreters remain fundamental to many languages including Python, Perl, and Ruby. However, interpreter-based execution now carries a reputation for inefficiency. To address this, JVMs support just-in-time compilation, which compiles entire function bodies into machine code, eliminating repeated interpretation overhead.
JIT Compiler
Java Code Execution Models
Two primary approaches exist for executing Java code:
- Compile source code to bytecode first, then interpret bytecode to machine code at runtime
- Compile directly to machine code for native execution
Modern virtual machines employ JIT compilation to compile methods into machine code before execution, significantly improving performance.
HotSpot VM represents a high-performance virtual machine using an architecture combining interpreter and JIT compiler. These components collaborate during runtime, each compensating for the other's weaknesses and optimizing execution efficiency by balancing compilation time against interpretation overhead.
Modern Java applications achieve performance levels comparable to C and C++ programs.
Why Interpreters Remain Necessary
Developers often question why interpreters persist when JIT compilers are available. JRockit VM, for instance, operates without an interpreter—relying solely on JIT compilation.
Two fundamental principles explain this coexistence:
- Interpreters activate immediately upon program startup, providing fast response times without compilation delays.
- JIT compilers require compilation time to transform code into native instructions, but the resulting machine code executes more efficiently.
Although JRockit delivers superior runtime performance, its programs experience longer startup times due to compilation requirements. For server applications where startup time matters less than sustained performance, JRockit's approach proves advantageous. However, applications requiring quick startup benefit from the interpreter-JIT hybrid model: the interpreter begins execution immediately while the JIT compiler progressively optimizes frequently-called methods.
Additionally, interpreted execution serves as a fallback mechanism when the compiler's aggressive optimizations prove incorrect.
Practical Example
Consider a production deployment scenario: when a server starts, the interpreter executes code immediately while JIT compilation proceeds. As the system warms up, the compiler identifies and optimizes frequently-called code paths.
Critical consideration: Servers in warm state (after running) can handle significantly higher loads than cold state (just started) servers. Deploying at full warm-state capacity against cold servers causes failures. In one incident, a deployment system accidentally split servers into two batches instead of eight. Since newly started JVMs hadn't yet completed hot code profiling and JIT compilation, the first half of deployed servers crashed under load.
public class WarmupBenchmark {
public static void main(String[] args) {
ArrayList<String> data = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
data.add("Optimizing performance through JIT compilation");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
Monitor JIT compiler activity using JVisualVM to observe compilation counts and optimization effects.
JIT Compiler Variants
Java's "compilation" encompasses multiple distinct operations:
- Frontend Compiler: Transforms
.javafiles to.classfiles (javac, Eclipse JDT incremental compiler) - Backend Runtime Compiler (JIT): Converts bytecode to machine code (HotSpot's C1 and C2 compilers)
- AOT Compiler: Compiles
.javadirectly to native machine code before execution (GCJ, Excelsior JET)
Hot Code Detection
Hotspot Detection Methods
JIT compilation activates based on code execution frequency. Frequently executed code segments—called hotspot code—undergo aggressive optimization and native machine code generation to improve Java application performance.
Methods called multiple times or loops with high iteration counts qualify as hotspots. Since compilation occurs during method execution, this technique is called On-Stack Replacement (OSR).
HotSpot VM employs counter-based hotspot detection. The JVM maintains two counters per method:
- Invocation Counter: Tracks method call frequency
- Back Edge Counter: Counts loop iteration frequency
Invocation Counter
This counter tracks method invocation frequency. Default thresholds are 1500 invocations in Client mode and 10000 in Server mode. The -XX:CompileThreshold parameter adjusts these values.
When invoking a method, the JVM checks for existing JIT-compiled versions:
- If a compiled version exists, the JVM uses it directly
- Otherwise, the JVM increments the invocation counter and evaluates whether combined counter values exceed the threshold
- When thresholds are exceeded, the JVM submits a compilation request
- Below threshold, the interpreter handles execution
Decay Mechanism
Invocation counters measure relative execution frequency within time windows rather than absolute counts. If methods don't accumulate sufficient invocations within the time period, their counters decay to half value. This decay occurs during garbage collection cycles.
The -XX:-UseCounterDecay parameter disables decay, making counters track absolute invocation counts. The -XX:CounterHalfLifeTime parameter (in seconds) controls the decay period.
Back Edge Counter
This counter tracks loop body execution frequency. Instructions causing control flow to jump backward are called "back edges." Back edge counters trigger OSR compilation.
Execution Mode Configuration
HotSpot VM supports multiple execution modes configurable via command-line parameters:
-Xint: Pure interpreter mode-Xcomp: Pure JIT compiler mode (interpreter handles compilation failures)-Xmixed: Hybrid mode combining interpreter and JIT compiler
public class CompilationModeTest {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
calculatePrimes(1000000);
long finishTime = System.currentTimeMillis();
System.out.println("Execution time: " + (finishTime - startTime) + "ms");
}
public static void calculatePrimes(int limit) {
for (int candidate = 0; candidate < limit; candidate++) {
outer: for (int divisor = 2; divisor <= 100; divisor++) {
for (int factor = 2; factor <= Math.sqrt(divisor); factor++) {
if (divisor % factor == 0) {
continue outer;
}
}
}
}
}
}
Benchmark results demonstrate interpreter performance limitations: -Xint requires approximately 6520ms, while -Xcomp and -Xmixed complete in roughly 950ms and 936ms respectively.
HotSpot JIT Compiler Variants
HotSpot VM embeds two JIT compilers: Client Compiler (C1) and Server Compiler (C2). The -client flag activates C1 compilation with simple, reliable optimizations prioritizing fast compilation. The -server flag enables C2 compilation with aggressive, time-intensive optimizations producing highly efficient code.
C1 vs C2 Optimization Strategies
C1 optimizations include method inlining, devirtualization, and dead code elimination:
- Method Inlining: Embeds called function code at invocation sites, reducing stack frame creation, parameter passing, and jump overhead
- Devirtualization: Inlines implementations for unique interface or abstract class implementations
- Dead Code Elimination: Removes unreachable or unnecessary code paths
C2 optimizations operate at the global level, with escape analysis as the foundation:
- Scalar Replacement: Substitutes scalar values for aggregate object properties
- Stack Allocation: Allocates non-escaping objects on the stack instead of the heap
- Synchronization Elimination: Removes unnecessary synchronized blocks
Note that escape analysis only triggers in C2 (Server mode), potentially limiting certain optimizations when using C1.
Tiered Compilation
Tiered compilation combines interpreter execution with both C1 and C2 compilation phases. Initial execution triggers C1 compilation with optional performance monitoring. C2 compilation then applies aggressive optimizations based on collected profiling data.
Since Java 7, the -server flag enables tiered compilation by default, with C1 and C2 compilers cooperating on compilation tasks.
Generally, JIT-compiled code outperforms interpreted execution. C2 requires longer warmup time but ultimately executes faster than C1 for stabilized workloads.
Graal Compiler
JDK 10 introduced the Graal compiler, a new JIT compiler achieving compilation quality comparable to the C2 compiler within years of its release. The corresponding Graal VM represents a potential successor to HotSpot. Currently experimental, Graal requires activation via:
-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
AOT Compiler
JDK 9 introduced AOT compilation (Ahead-of-Time), leveraging the Graal compiler to transform Java class files into native machine code stored in dynamic shared libraries:
.java → .class → (jaotc) → .so
Unlike JIT compilation occurring during execution, AOT compilation transforms bytecode to native code beforehand.
Advantages:
- Pre-compiled binaries load and execute immediately
- Eliminates JIT warmup delays and the "first-run slowness" issue
Disadvantages:
- Compromises Java's "write once, run anywhere" principle—requires platform-specific binaries
- Reduces dynamic linking flexibility
- Requires complete knowledge of all code at compilation time
- Currently limited to Linux x64 Java base images, requiring further optimization