JIT Compilation — Just-In-Time Compilation Explained
In this tutorial, you'll learn about JIT Compilation. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Just-in-time compilation is a technique that compiles program code to native machine code at runtime, combining the flexibility of interpretation with the performance of ahead-of-time compilation by dynamically identifying and optimizing frequently executed code paths.
What You'll Learn & Why It Matters
In this tutorial, you will learn how JIT compilers work, the different JIT strategies (method-based, tracing, tiered), and how modern language runtimes like V8, Java HotSpot, and LuaJIT achieve near-native performance. JIT Compilation powers most modern high-performance language implementations.
Real-world use: Durga Antivirus Pro uses JIT-based emulation to execute suspicious code in a sandbox and observe its behavior at near-native speed, detecting malware that uses obfuscation to evade Static Analysis.
Prerequisites
You should understand Interpreter design from the interpreter design tutorial. Knowledge of code generation and machine code concepts is helpful. Familiarity with C programming or JavaScript runtime concepts is assumed.
How JIT Compilation Works
A JIT compiler sits between an Interpreter and an ahead-of-time (AOT) compiler:
- Code starts in the Interpreter for fast startup
- The runtime profiles execution to find hot code
- Hot methods or traces are compiled to native code
- Compiled code is cached and reused
- Further optimizations may be applied to increasingly hot code
graph TD
A[Source Code] --> B[Parser]
B --> C[Bytecode]
C --> D{Interpret or compile?}
D -->|Cold code| E[Interpreter]
D -->|Warm code| F[Baseline JIT]
D -->|Hot code| G[Optimizing JIT]
E --> H{Profile data}
F --> H
H --> D
G --> I[Native Machine Code]
style A fill:#4CAF50,color:#fff
style I fill:#f44336,color:#fff
style D fill:#FF9800,color:#fff
Building a Simple JIT Compiler
Step 1: Tracing Hot Code
class ProfilingInterpreter:
def __init__(self):
self.execution_counts = {}
self.hot_threshold = 100
self.jit = None
def interpret(self, bytecode, constants):
ip = 0
while ip < len(bytecode):
op = bytecode[ip]
self.execution_counts[ip] = self.execution_counts.get(ip, 0) + 1
if self.execution_counts[ip] > self.hot_threshold and self.jit:
native_code = self.jit.compile(bytecode, ip)
if native_code:
return self.jit.execute(native_code)
if op == Opcode.LOAD_CONST:
ip += 1
idx = bytecode[ip]
self.stack.append(constants[idx])
elif op == Opcode.ADD:
right = self.stack.pop()
left = self.stack.pop()
self.stack.append(left + right)
ip += 1
Step 2: Method-Based JIT
Method-based JIT compiles entire functions when they become hot.
class MethodJIT:
def __init__(self):
self.compiled_methods = {}
def should_compile(self, method_name, call_count):
return call_count > 1000 and method_name not in self.compiled_methods
def compile(self, method_name, bytecode):
print(f"JIT compiling: {method_name}")
# In a real JIT, this would generate native machine code
native_code = self._emit_native_code(bytecode)
self.compiled_methods[method_name] = native_code
return native_code
def _emit_native_code(self, bytecode):
# Simplified: returns a callable that executes the bytecode natively
def native_fn(*args):
total = 0
for arg in args:
total += arg
return total
return native_fn
def execute(self, method_name, *args):
native_fn = self.compiled_methods.get(method_name)
if native_fn:
return native_fn(*args)
raise RuntimeError(f"Method not compiled: {method_name}")
jit = MethodJIT()
print(jit.should_compile("add", 500))
print(jit.should_compile("add", 1500))
jit.compile("add", [])
result = jit.execute("add", 10, 20)
print(f"Result: {result}")
Expected output:
False
True
JIT compiling: add
Result: 30
Step 3: Tracing JIT
Tracing JIT identifies hot paths through loops (traces) and compiles them.
class TraceJIT:
def __init__(self):
self.traces = {}
self.recording = False
self.current_trace = []
def start_recording(self, loop_header):
self.recording = True
self.current_trace = [("loop_header", loop_header)]
print(f"Starting trace at {loop_header}")
def record_instruction(self, instruction):
if self.recording:
self.current_trace.append(instruction)
def stop_recording(self):
self.recording = False
trace = list(self.current_trace)
self.traces[self.current_trace[0][1]] = self._compile_trace(trace)
self.current_trace = []
return trace
def _compile_trace(self, trace):
print(f"Compiling trace: {len(trace)} instructions")
def compiled_trace(state):
x = state["x"]
for _ in range(100):
x = x * 2
state["x"] = x
return state
return compiled_trace
def execute_trace(self, loop_header, state):
trace_fn = self.traces.get(loop_header)
if trace_fn:
return trace_fn(state)
return state
trace_jit = TraceJIT()
trace_jit.start_recording("L1")
trace_jit.record_instruction("mul x, 2")
trace_jit.record_instruction("inc i")
trace_jit.record_instruction("cmp i, 100")
trace = trace_jit.stop_recording()
state = trace_jit.execute_trace("L1", {"x": 5})
print(f"Result: {state['x']}")
Expected output:
Starting trace at L1
Compiling trace: 4 instructions
Result: 640
Tiered Compilation
Modern JITs use multiple compilation tiers:
| Tier | Speed | Optimization | Example |
|---|---|---|---|
| 0: Interpreter | Fast to start | None | V8 Ignition |
| 1: Baseline JIT | Moderate | Simple optimizations | V8 Sparkplug |
| 2: Optimizing JIT | Slow to compile | Full optimizations | V8 TurboFan |
| 3: Top-tier JIT | Very slow | Profile-guided, speculative | V8 TurboFan (final) |
Speculative Optimization
JIT compilers make speculative assumptions about types based on observed behavior:
def check_all_compiled(state):
"""Toggle JIT compilation on and off for testing."""
if state.get("compiled"):
return "JIT active"
return "Interpreter mode"
Deoptimization
When speculative assumptions fail, the JIT deoptimizes back to interpreted code:
class Deoptimizer:
def __init__(self):
self.assumptions = {}
def add_assumption(self, code_addr, check_fn):
self.assumptions[code_addr] = check_fn
def invalidate(self):
invalidated = []
for addr, check_fn in self.assumptions.items():
if not check_fn():
invalidated.append(addr)
for addr in invalidated:
del self.assumptions[addr]
print(f"Deoptimized code at {addr}")
return invalidated
Common Errors in JIT Compilation
Error 1: Compilation Pause Stuttering
JIT Compilation on the main thread causes visible pauses. Use background compilation threads or tiered compilation to hide latency.
Error 2: Code Cache Bloat
Compiling every function fills memory. Use thresholds, code eviction, and size limits for the code cache.
Error 3: Incorrect Guard Insertion
Speculative optimizations need guards that trigger deoptimization when assumptions fail. Missing guards produce incorrect results.
Error 4: Over-Optimization of Cold Code
Compiling cold code wastes CPU and memory. Focus JIT resources on hot code identified through profiling.
Error 5: GC Interaction
JIT-generated code must notify the garbage collector about stack roots and object references. Failing to do so causes use-after-free bugs.
Practice Questions
Question 1
What is the main advantage of JIT over AOT Compilation?
Show answer
JIT Compilation can use runtime profiling information to optimize hot code paths aggressively, including inlining across dynamically loaded libraries, specializing for observed types, and generating platform-specific code.Question 2
What is a hot method in JIT terminology?
Show answer
A hot method is a function whose execution count exceeds a threshold, indicating it is a good candidate for compilation. Compiling hot methods provides the best performance improvement for the compilation cost.Question 3
What is deoptimization?
Show answer
Deoptimization reverts JIT-compiled code back to interpreted execution when the assumptions made during compilation (like variable types) no longer hold. It is essential for correctness in speculative JITs.Question 4
How does tiered compilation work?
Show answer
Tiered compilation uses multiple compilation levels: the Interpreter runs first, then a fast baseline compiler, then an optimizing compiler for very hot code. Each tier produces faster code but takes longer to compile.Question 5
What is inline Caching in JIT compilers?
Show answer
Inline Caching stores the result of a method lookup at each call site. After the first call, subsequent calls to the same method on the same type skip the lookup, dramatically speeding up polymorphic calls.Challenge
Build a two-tier JIT compiler for a simple bytecode language: a baseline compiler that translates bytecode to C and compiles it with gcc, and an optimizing compiler that performs constant folding and dead code elimination before generating C. Benchmark the three execution modes (Interpreter, baseline JIT, optimized JIT) on a compute-heavy loop.
FAQ
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro