JIT Compiler, GraalVM: Java Code to Machine Code Translation Process
Structure and Role of MethodData (C1 -> C2 Profiling Data Transfer) #
In the past, before Java 7's PermGen era, the structure holding profiling data was an object (Oop, Ordinary Object Pointer) residing in the Java Heap/PermGen, hence it was called MethodDataOop.
However, with the introduction of Java 8's Metaspace, this structure changed from an Oop to a MethodData class (at the Cpp level) allocated in native memory.
From the perspective of the latest HotSpot VM, the role and structure of MethodData are as follows:
- Role: In a Tiered Compilation environment, it stores runtime execution statistics (profiled data) collected by the interpreter (Tier 0) and the profiling-enabled C1 compiler (Tier 2, 3). The C2 compiler (Tier 4) reads this data to perform aggressive and optimistic optimizations.
- Internal Structure (DataLayout):
MethodDatais not just a simple collection of counters, but a variable-length data stream mapped to bytecode indices (BCI). Internally, it is composed of units calledDataLayout.ReceiverTypeData: Records which class type actually came in as the receiver duringinvokevirtualorinvokeinterfacecalls. (Key for determining Monomorphic/Bimorphic Calls)BranchData: Records the probability of execution flow going in a certain direction (taken, not taken) in branch statements likeifstatements.CounterData: Tracks how many times a specific method or loop has been executed.
- Cooperation Mechanism: At compile time, C1 inserts native instructions (profiling stubs) that update
MethodDatathroughout the source code. Later, when C2 compiles that method, it reads thisMethodDataand makes assumptions (e.g., "this branch has never been executed," "this interface always receives String objects as implementations") to simplify the code.
C2 Compiler Escape Analysis and Scalar Replacement #
C2 uses an Ideal Graph (IR) in the form of a Sea of Nodes.
Escape Analysis and Scalar Replacement are powerful optimization processes that transform this IR.
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
public int calculateArea() {
Point p = new Point(10, 20); // Heap allocation occurs?
return p.x * p.y;
}
Let's examine step-by-step how this code is transformed at the C2 IR level internally.
- Initial IR is generated first. Initially, parsing the bytecode creates a complex dependency graph connecting
Allocatenodes (heap memory allocation) ->Initializenodes ->StoreI(storing 10, 20 into x, y fields) ->LoadI(reading x, y) ->MulI(multiplication) nodes. - Escape Analysis begins. C2 tracks whether an object's reference escapes the method's scope to construct the connected graph. In the example above, the
Pobject is not shared with other threads or returned, so its state is marked asNoEscape. - In the Scalar Replacement phase, for objects determined to be
NoEscape, C2 completely eliminates heap allocation.Allocatenodes andMemBarnodes for memory synchronization are removed from the Ideal Graph.- The object's fields x and y are no longer data dependent on memory but are treated as independent scalars (much like local variables).
- The C2 optimizer directly connects the
LoadInode's data source from the original heap memory address to the constant nodes (Constant Node: 10, 20) where the values were previously assigned.
- Final IR: All allocation, storage, and loading processes are eliminated, leaving only the
MulI(multiplication) node that receives 10 and 20 as input, directly resulting in a register operation.
C2 Machine Code vs. GraalVM AOT Machine Code (Runtime Assumptions and Deoptimization) #
While C2 (JIT) and GraalVM Native Image AOT both generate native binaries,
there is a fundamental difference in their assumptions about the runtime environment and their ability to recover from incorrect assumptions (deoptimization).
C2 JIT Compiler (Dynamic) #
Runtime Assumptions (Optimistic Assumptions): C2 implicitly trusts the MethodData mentioned in point 1 and makes very aggressive assumptions, such as "among the currently loaded classes, only ClassB implements InterfaceA" (CHA, Class Hierarchy Analysis). It then inlines virtual calls, generating fast and lean machine code.
Deoptimization: What happens if a new class is dynamically loaded at runtime, breaking the previous assumption? C2's machine code is embedded with Guard instructions and UncommonTraps to verify assumptions. The moment an assumption is violated, execution of the machine code is immediately halted, register states are restored to the interpreter's stack frame (deoptimization), and the execution flow is thrown back to a safe interpreter mode.
GraalVM AOT (Native Image) #
Runtime Assumptions (Closed-World Assumption): AOT compilation assumes a closed world where all classes and methods existing at build time are already determined. In its default state without PGO, it cannot make dynamic and speculative assumptions like "a specific branch will not be used" or "only a certain type will be passed," because it lacks runtime profiling data.
PGO: Profile-Guided Optimization uses data collected during actual program execution (profiles) to help the compiler generate faster and more efficient machine code.
Deoptimization (No Interpreter Fallback): Native images do not include the JVM interpreter or C1/C2 compilers in the binary to reduce memory footprint. This means there is no safety net to fall back to via deoptimization. Therefore, the machine code generated by AOT must include all possible branch handling and type checking logic, making it difficult to create extremely lightweight inline code like C2. However, if PGO (Profile-Guided Optimization) is applied, it can generate aggressive machine code similar to C2 based on pre-collected profiles. Even in this case, the fallback mechanism is to less optimized compiled code, not to an interpreter.
These differences explain why AOT is advantageous for short-running applications or environments with severe memory constraints, but C2 JIT remains powerful for long-running applications where runtime data accumulates, leading to peak performance.