JIT Compilation Internals

Understand how the JVM's Just-In-Time compiler detects hot code, applies compilation thresholds, and manages the code cache for peak performance.

published: reading time: 30 min read author: GeekWorkBench

JIT Compilation Internals

The JVM starts your application by interpreting bytecode — executing each instruction one at a time without translating it to native machine code. As methods and loops execute repeatedly, the JVM identifies these hot spots and compiles them to native machine code using its Just-In-Time (JIT) compiler. This compilation happens at runtime, hence “just-in-time,” and the resulting native code runs directly on the CPU without interpretation overhead.

Understanding how the JIT compiler decides what to compile, when to compile it, and how it manages the compiled code cache helps you diagnose performance issues, tune JVM flags, and reason about why some code performs better than expected.

Introduction

The JVM starts your application by interpreting bytecode—executing each instruction without translating it to native machine code. As methods and loops execute repeatedly, the JVM identifies these hot spots and compiles them to native machine code using its Just-In-Time (JIT) compiler. This compilation happens at runtime, hence “just-in-time,” and the resulting native code runs directly on the CPU without interpretation overhead. Understanding how the JIT compiler decides what to compile, when to compile it, and how it manages the compiled code cache helps you diagnose performance issues, tune JVM flags, and reason about why some code performs better than expected.

JIT compilation introduces a performance model with two phases: a warmup period where code runs interpreted or through a quick C1 compilation, and a steady state where hot paths run as optimized native code. The transition between these phases is not instantaneous, which is why applications that handle short-lived requests (serverless functions, request-response services with low traffic) often perform worse than batch workloads that run long enough to fully warm up. The JIT compiler uses profiling data from interpreted execution to make optimization decisions—knowing the actual types flowing through a call site, the branch probabilities, and the object allocation patterns—information that static compilers do not have access to.

This post explains how the JVM identifies hot code through invocation and back-edge counters, how tiered compilation uses C1 for fast warmup and C2 for aggressive optimization, and how the code cache manages compiled code within fixed memory bounds. You will learn why code cache exhaustion causes dramatic performance drops, how on-stack replacement enables mid-loop compilation for long-running methods, and what deoptimization means for runtime performance. With this foundation, you can read JIT logs, tune compilation thresholds, and understand why certain code patterns warm up faster than others.

When to Use JIT Knowledge

JIT compilation internals become relevant when you need to:

  • Diagnose warmup issues — Applications that handle short-lived requests or serverless functions never warm up the JIT, running interpreted code throughout
  • Tune compilation thresholds — JVM flags like -XX:CompileThreshold and -XX:+TieredCompilation let you control compilation behavior
  • Read JIT logs — The -XX:+PrintCompilation flag produces output that reveals what the JIT is doing
  • Understand p99 latency spikes — Deoptimization events or compilation queue saturation can cause unexpected pauses
  • Optimize for tiered compilation — Knowing how C1 and C2 interact helps you set appropriate thresholds

When Not to Use

Most developers should not think about JIT internals during normal development. Write clear, correct Java code and let the JIT compiler handle optimization. Reach for JIT-specific investigation only when profiling data points to compilation-related issues.

Hot Spot Detection

The JVM does not compile every method. Compilation is expensive — it takes CPU time and memory to translate bytecode to native code and build optimization profiles. The JIT compiler waits until a method or loop becomes “hot” enough to justify this investment.

Invocation Counter

The JVM attaches an invocation counter to every method. Each time the method is called, the counter increments. When the counter reaches a threshold, the JVM queues the method for JIT compilation.

threshold = CompileThreshold (default: 10000)

The counter is not just a simple integer — it has two components: a hit counter for invocations and a back-edge counter for loop iterations. A loop that runs 10,000 times without triggering a method call still reaches the threshold through the back-edge counter.

On-Stack Replacement (OSR)

When a loop becomes hot while the method is already running, the JVM can perform on-stack replacement — swapping the running interpreted code for compiled code mid-loop. OSR is critical for long-running server applications where a loop might run for minutes before reaching the threshold.

Mature Counters and Decrement

After a method is compiled, its invocation counter continues running. If execution later diverges from the compiled code’s assumptions (a deoptimization event), the counter is decremented, and the method may be recompiled with more conservative assumptions.

Compilation Thresholds

The JVM exposes several flags to control when and how compilation occurs:

FlagDefaultPurpose
-XX:CompileThreshold10000Invocations needed to trigger compilation
-XX:OnStackReplacementPercentage933Loop iterations relative to CompileThreshold to trigger OSR
-XX:ReservedCodeCacheSize48MB (server)Maximum code cache size
-XX:InitialCodeCacheSize48KBInitial code cache allocation
-XX:+UseCodeCacheFlushingtrueEvict stale compiled code when cache fills
-XX:MaxInlineLevel9Maximum inlining depth

Tiered Compilation Thresholds

When tiered compilation is enabled (default in Java 8+), different thresholds apply at each tier:

TierThresholdCompilation Level
C1 (level 1)~1,500 invocationsFast compilation, less optimization
C1 (level 2)~5,000 invocationsSame as level 2, with profiling
C1 (level 3)~10,000 invocationsSame as level 3, with more profiling
C2 (level 4)~100,000 invocationsFull optimization, longest compile time

Code Cache Management

The JIT compiler stores all compiled native code in a region of memory called the code cache. Unlike the Java heap where objects live, the code cache holds raw machine instructions and associated metadata.

flowchart TD
    subgraph JVMProcess["JVM Process Memory"]
        direction TB
        subgraph CodeCache["Code Cache"]
            T1[Compiled Code\nTier 1 C1]
            T2[Compiled Code\nTier 2 C1]
            T3[Compiled Code\nTier 3 C1]
            T4[Compiled Code\nTier 4 C2]
        end
        METADATA[Metadata\nMethod info\nInline info]
        T1 --- METADATA
        T2 --- METADATA
        T3 --- METADATA
        T4 --- METADATA
    end

Code Cache Size Limits

The code cache has a fixed maximum size. When it fills up, the JVM stops JIT compilation — new methods compile as interpreted bytecode for the remainder of the process life.

-XX:ReservedCodeCacheSize=256M  # Set maximum code cache to 256MB

Running out of code cache manifests as a sudden, dramatic performance drop. The JVM prints warnings when the code cache fills past 90% and 98%:

VM warning: code cache is full. Compiler has been disabled.
VM warning: Try increasing the code cache size.

Code Cache Flushing

With -XX:+UseCodeCacheFlushing (enabled by default), the JVM evicts cold compiled methods when the cache reaches its occupancy threshold. This makes room for new compilations but may cause previously compiled methods to deoptimize and run interpreted again.

JIT Optimization Techniques

The JIT compiler applies aggressive optimizations that are impossible or impractical at static compile time because the JIT knows the actual runtime types and call patterns.

Method Inlining

Inlining replaces a method call with the method’s body. This eliminates call overhead, enables further optimizations across the call boundary, and is the foundation for most other JIT optimizations.

// Before inlining
public double distance(Vector a, Vector b) {
    return Math.sqrt(dx(a, b) + dy(a, b));
}
public double dx(Vector a, Vector b) { return a.x - b.x; }
public double dy(Vector a, Vector b) { return a.y - b.y; }

// After inlining (conceptually)
public double distance(Vector a, Vector b) {
    return Math.sqrt((a.x - b.x) + (a.y - b.y));
}

The JIT inlines small methods (typically under 35 bytes of bytecode), methods called only once, and hot methods called frequently enough to justify compilation time.

Escape Analysis

Escape analysis determines whether an object allocated within a method “escapes” the method — stored into a field, returned, or passed to another method. If the object does not escape, the JIT can:

  • Stack allocate the object instead of heap allocating — eliminates allocation cost and reduces GC pressure
  • Eliminate synchronization on objects that are proven thread-local
  • Fold away objects that are only used to pass arguments to another method
public String concat(String a, String b) {
    String tmp = a + b;  // tmp does not escape
    return tmp;
}
// The JIT can allocate tmp on the stack and eliminate the allocation entirely

Dead Code Elimination

The JIT removes code whose result is never used. Even if your source code contains computations that feed into an unused variable, the JIT can eliminate the entire computation if it proves the result is discarded.

Loop Unrolling

For tight loops with small trip counts, the JIT can unroll the loop — generate multiple iterations per loop body — reducing loop branch overhead and enabling better instruction-level parallelism.

Production Failure Scenarios

Performance Regression After Warmup

An application that performs well after warmup but poorly at startup suffers from JIT warmup issues. The first requests run interpreted, and only after compilation do hot paths reach full speed. Solutions include:

  • -XX:+TieredCompilation — Use tiered compilation for faster warmup
  • -XX:CompileThreshold=1000 — Lower the compilation threshold (but increases CPU at startup)
  • Warmup endpoints before traffic arrives (precompile known hot paths)

Code Cache Exhaustion

Running out of code cache causes the JVM to disable JIT compilation entirely, falling back to interpretation for all code. Symptoms:

  • Throughput drops suddenly after running for hours
  • JIT log shows VM warning: code cache is full
  • The ReservedCodeCacheSize is too small for the workload

Increase -XX:ReservedCodeCacheSize and consider -XX:+UseCodeCacheFlushing if not already enabled.

Deoptimization Storms

If a compiled method’s assumptions are violated repeatedly, the JVM deoptimizes it, potentially recompiles, violates assumptions again, and so on. A deoptimization storm shows up as a periodic performance glitch. Common causes:

  • Loading a new class that makes a previously monomorphic call site megamorphic
  • Running with class unloading disabled (-XX:+ClassUnloading) and filling Metaspace, forcing GC
  • Using reflection with changing type profiles

P99 Latency Spikes from Compilation

When the JIT queue saturates during load spikes, newly hot methods wait in the queue while the CPU processes compilation tasks. This causes latency spikes for the requests that hit those methods. Tiered compilation helps by using C1 for faster initial compilation.

Trade-Off Table

AspectInterpretationJIT Compilation
Startup timeFast to begin, slow to runSlow to begin (compilation), fast once compiled
Memory overheadMinimalRequires code cache memory
OptimizationNoneAggressive, runtime-informed
DeoptimizationNot applicablePossible, with recompilation
CPU overheadLow but consistentHigh during compilation, low after
LatencyConsistent but higherConsistent low after warmup, spikes during compilation

Implementation Snippets

Enable JIT Logging

# Basic compilation log
java -XX:+PrintCompilation -XX:+PrintGCDetails -Xlog:class+load=info -cp . com.example.MyApp

# Continuous compilation log to file
java -XX:+UnlockDiagnosticVMOptions \
     -XX:+PrintCompilation \
     -XX:+LogCompilation \
     -XX:LogFile=/tmp/jit.log \
     -cp . com.example.MyApp

Inspect Code Cache Usage

# Use jstat to monitor code cache
jstat -printcompilation -Jinitthreads=1 <pid>

# Or query via jcmd
jcmd <pid> VM.flags | grep CodeCache
jcmd <pid> VM.native_memory summary

Disable JIT for Profiling

# Run entirely interpreted (useful for profiling with async-profiler)
java -Xint -cp . com.example.MyApp

# Force interpreter at startup, then enable JIT later
java -XX:+TieredCompilation -XX:TieredStopAtLevel=1 -cp . com.example.MyApp

Control Inline Size

# Limit maximum method size for inlining (in bytes)
java -XX:MaxInlineSize=35 -cp . com.example.MyApp

# Limit maximum inline level (call chain depth)
java -XX:MaxInlineLevel=9 -cp . com.example.MyApp

# Allow small methods to be inlined even if not hot
java -XX:MinInliningThreshold=100 -cp . com.example.MyApp

Observability Checklist

When monitoring JIT behavior in production:

  • Track compilation queue depth — A growing queue means the JIT cannot keep up with load
  • Monitor code cache utilization — Use jstat or JMX to watch code cache fill level
  • Check deoptimization count — Frequent deoptimizations indicate unstable type profiles
  • Review JIT logs for “made nonentrant” — Indicates code was invalidated
  • Measure warmup time — Track request latency over the first minutes of process life
  • Watch for OSR compilations — On-stack replacements indicate hot loops detected mid-execution

Security Notes

JIT compilation produces native machine code that is stored in the code cache as executable memory. The JVM uses hardware-level protections (NX bit on modern CPUs) to mark the code cache as non-executable if the OS and CPU support it, preventing certain classes of exploits that inject executable code.

However, JIT compilers have historically been targets for vulnerabilities because they generate code in privileged contexts. The JVM sandbox model was designed to restrict what untrusted code can do, but in modern Java (9+), the module system provides stronger isolation. Keep your JVM updated, as JIT compiler vulnerabilities have historically been among the most severe JVM security issues.

Common Pitfalls / Anti-Patterns

  1. Assuming compilation always makes code faster — Very small, cold methods compile so slowly that the compilation time exceeds the interpreted execution time. The JIT is conservative about compiling anything below a size threshold.

  2. Ignoring code cache sizing — Default code cache size (48MB in server JVM) is too small for applications with thousands of compiled methods. Monitor and size appropriately.

  3. Forgetting that tiered compilation has trade-offs — Tiered compilation reduces warmup time but increases CPU usage because more methods are compiled more aggressively. In containerized environments with CPU limits, this can cause throttling.

  4. Misreading JIT logs — JIT log output is cryptic and spans multiple lines per compilation. Use tools like flamegraph/honest-profiler or the JITWatch visualizer to make sense of compilation data.

  5. Expecting consistent performance across JVM restarts — JIT compilation is runtime-dependent. Two identical runs with different random seeds or class loading order may produce different compiled code because type profiles differ.

Quick Recap Checklist

  • JIT compilation converts hot bytecode to native machine code at runtime
  • Invocation counter and back-edge counter detect hot methods and loops
  • On-stack replacement (OSR) swaps running interpreted code for compiled code mid-loop
  • Code cache stores compiled native code with fixed size limits
  • -XX:+PrintCompilation logs JIT activity
  • -XX:+TieredCompilation uses C1 first for fast warmup, then C2 for full optimization
  • Key optimizations: method inlining, escape analysis, dead code elimination, loop unrolling
  • Code cache exhaustion disables JIT and causes performance regression
  • Deoptimization recompiles with more conservative assumptions
  • Warmup latency matters for short-lived processes and serverless functions

Interview Questions

1. How does the JVM decide which methods to JIT compile?

The JVM attaches an invocation counter to every method and loop back-edge counter to every loop. When a method is called or a loop iterates, the corresponding counter increments. Once a counter reaches the compilation threshold (default 10,000 for the server JVM), the JVM queues the method for JIT compilation. The JIT compiler picks methods from the queue based on priority — hotter methods with higher counter values are compiled first. After compilation, the counter continues running; if assumptions are violated, the method deoptimizes and the counter decrements, potentially triggering recompilation with different optimization levels.

2. What is on-stack replacement (OSR) and why is it important?

On-stack replacement allows the JVM to replace the currently executing interpreted code of a method with compiled code while the method is still running. This is critical for long-running server applications where a method might take minutes to execute but contains a hot loop that runs thousands of times per second. Without OSR, the loop would have to finish before the compiled version could take over — which could take hours or never happen at all. With OSR, when the back-edge counter reaches the OSR threshold, the JVM creates a compiled version and seamlessly transfers execution to it at the next loop iteration boundary, even though the method never returned.

3. What is escape analysis and what optimizations does it enable?

Escape analysis determines whether an object allocated within a method escapes the method's scope — meaning it could be accessed by another thread, stored to a heap field, or returned from the method. If the JIT compiler proves an object does not escape, it can apply three powerful optimizations. First, it can allocate the object on the stack instead of the heap, eliminating allocation and deallocation cost entirely. Second, it can eliminate synchronization locks on objects proven to be thread-local, as no other thread can access them. Third, it can scalar replace the object — replacing references to the object with the object's fields spread across registers and stack slots, eliminating the object allocation altogether. These optimizations can dramatically reduce GC pressure in code that allocates many short-lived objects.

4. What happens when the code cache fills up?

When the code cache reaches its maximum size, the JVM disables JIT compilation entirely for the remainder of the process life. New methods and loops that become hot run in interpreted mode forever. This causes a dramatic, sudden performance drop that is easy to mistake for a GC pause or other issue. The JVM prints warning messages when the code cache reaches 90% and 98% occupancy. To avoid this, set -XX:ReservedCodeCacheSize large enough for your workload — typically 100MB to 256MB for large applications. You can also enable -XX:+UseCodeCacheFlushing (on by default) to evict cold compiled code and make room, though this risks deoptimizing previously compiled hot methods.

5. What is the difference between C1 and C2 JIT compilers?

C1 (Client Compiler, also called c1.dll or tiered level 1-3) is the fast, low-optimization JIT compiler designed for quick compilation with minimal impact on application responsiveness. It produces native code faster than C2 but applies fewer aggressive optimizations — no advanced speculative optimizations, less aggressive inlining, no loop optimizations. C2 (Server Compiler, tiered level 4) is the slow, high-optimization compiler that takes much longer to compile but produces significantly faster code through aggressive inlining, escape analysis, range check elimination, loop unrolling, and speculative optimizations based on runtime type profiles. Tiered compilation uses C1 first (at lower invocation thresholds) to get quick native code for hot paths, then promotes the hottest methods to C2 for full optimization as they remain hot.

Further Reading

Adaptive Compilation and Speculative Optimization

The JIT compiler uses speculative optimizations based on observed runtime behavior. For example, when a virtual call site consistently receives the same receiver type, the JIT compiler assumes this will continue and compiles the call as a direct call — eliminating virtual dispatch overhead. If the speculation proves wrong (a different type appears), the JIT deoptimizes and recompiles with a more conservative assumption. This adaptive strategy allows the JVM to achieve near-native performance for real workloads while maintaining safety guarantees. Understanding speculative optimization helps explain why JIT-compiled code sometimes behaves differently from AOT-compiled code for the same source — the JIT can make aggressive bets that would be unsafe to make statically.

Code Cache Implementation and Memory Layout

The code cache is a contiguous region of virtual memory that the JIT compiler allocates for storing compiled native code. Its fixed maximum size (controlled by -XX:ReservedCodeCacheSize) means that when the cache fills, JIT compilation stops entirely. The cache is organized as a collection of code blobs — each blob holds the compiled code for one method plus metadata (method name, compile ID, inline information). When code cache flushing is enabled, the JVM evicts the least recently used compiled methods when the cache reaches a high-water mark. This keeps the cache from running completely full but can cause previously optimized code to run interpreted if it gets evicted and later becomes hot again.


6. How does the JIT compiler decide whether to inline a method call, and what factors limit inline depth?

The JIT compiler inlines methods when the call site is hot enough and the method is small enough that the compile time cost pays for itself in reduced call overhead. Criteria include: method size (typically under 35 bytes of bytecode), call site frequency (hotter calls are inlined first), and type profile stability (monomorphic calls are better candidates than megamorphic). The -XX:MaxInlineLevel flag limits how many levels of nested calls can be inlined together — a depth of 9 means the JIT can inline a chain of 9 method calls into one compiled sequence. Beyond the inline level, other limits exist: -XX:FreqInlineSize limits the size of frequently called methods, and -XX:MaxInlineMethodBytecodes caps the total bytecode size of inlineable methods. Inlining across virtual call boundaries requires the JIT to prove the receiver type, which happens more often when the call is monomorphic.

7. What is the relationship between deoptimization and recompilation in the JIT compiler?

Deoptimization occurs when compiled code's assumptions are violated at runtime — a call site becomes megamorphic, an assumption about nullness proves false, or a class used in the compiled code is unloaded. The JVM marks the compiled code as non-entrant (stops sending new calls to it) and existing activations are allowed to run to completion. This "zombie" code is later evicted when no threads are executing it. If the call site is still hot, the JVM queues the method for recompilation. On recompilation, the JIT compiler uses more conservative assumptions — for example, it may emit a type check before the previously speculated fast path, or it may avoid inlining across an interface that has become megamorphic. This create a compile-deoptimize-recompile loop that eventually converges to stable optimized code for stable runtime profiles.

8. How does the code cache size affect long-running application performance, and what symptoms indicate the cache is too small?

The code cache has a fixed maximum size, and when it fills, the JVM disables JIT compilation entirely for the remainder of the process. New hot methods run interpreted, causing a sudden and dramatic performance drop — often 50% or more throughput reduction. Before the cache fills completely, the JVM prints warnings at 90% and 98% occupancy. Applications that compile thousands of methods (common in Spring-based microservices) can exhaust the default 48MB code cache. Symptoms include: JIT log showing VM warning: code cache is full, throughput dropping after running for hours (cache gradually fills), and profiling showing high interpreter tick frequency on previously fast paths. Increase -XX:ReservedCodeCacheSize to 100-256MB for large applications, and ensure -XX:+UseCodeCacheFlushing is enabled so the JVM can evict cold compiled code proactively.

9. How does the JIT compiler decide whether to inline a method call, and what factors limit inline depth?

The JIT compiler inlines methods when the call site is hot enough and the method is small enough that the compile time cost pays for itself in reduced call overhead. Criteria include: method size (typically under 35 bytes of bytecode), call site frequency (hotter calls are inlined first), and type profile stability (monomorphic calls are better candidates than megamorphic). The -XX:MaxInlineLevel flag limits how many levels of nested calls can be inlined together — a depth of 9 means the JIT can inline a chain of 9 method calls into one compiled sequence. Beyond the inline level, other limits exist: -XX:FreqInlineSize limits the size of frequently called methods, and -XX:MaxInlineMethodBytecodes caps the total bytecode size of inlineable methods. Inlining across virtual call boundaries requires the JIT to prove the receiver type, which happens more often when the call is monomorphic.

10. What is the relationship between deoptimization and recompilation in the JIT compiler?

Deoptimization occurs when compiled code's assumptions are violated at runtime — a call site becomes megamorphic, an assumption about nullness proves false, or a class used in the compiled code is unloaded. The JVM marks the compiled code as non-entrant (stops sending new calls to it) and existing activations are allowed to run to completion. This "zombie" code is later evicted when no threads are executing it. If the call site is still hot, the JVM queues the method for recompilation. On recompilation, the JIT compiler uses more conservative assumptions — for example, it may emit a type check before the previously speculated fast path, or it may avoid inlining across an interface that has become megamorphic. This creates a compile-deoptimize-recompile loop that eventually converges to stable optimized code for stable runtime profiles.

11. What is adaptive compilation and how does speculative optimization work in the JIT?

Adaptive compilation is the JVM's strategy of making speculative bets based on observed runtime behavior. When a virtual call site consistently receives the same receiver type, the JIT compiler assumes this will continue and compiles the call as a direct call — eliminating virtual dispatch overhead. If the speculation proves wrong (a different type appears), the JIT deoptimizes and falls back to the interpreter or recompiles with a conservative type check. This approach allows the JVM to achieve near-native performance for real workloads while maintaining safety guarantees. For example, the JIT might assume a null check is never needed if the method has never returned null, and inline the fast path directly. If null is encountered, the deoptimization machinery unwinds to interpreter and triggers recompilation with the null check added. Speculative optimization explains why JIT-compiled code sometimes behaves differently from AOT-compiled code for the same source.

12. How does the JIT compiler use type profiling to optimize virtual call sites?

The JIT compiler collects type profiles at virtual and interface call sites during interpreted execution. Every time a call site executes, the JVM records the actual receiver type. Over time, the profile shows whether the site is monomorphic (one type), polymorphic (few types), or megamorphic (many types). For monomorphic sites, the JIT can devirtualize — replace the virtual call with a direct call to the known type's method, then add a type check that deoptimizes if a different type appears. For polymorphic sites, the JIT can emit a type switch that handles the known cases quickly and falls back to the slow path for unknown types. Megamorphic sites prevent most optimizations because the JIT cannot profitably inline any specific implementation. Type profiling data is carried through tier transitions, allowing C2 to specialize for the types it actually sees at runtime.

13. What is the difference between OSR compilation and regular method compilation?

Regular method compilation begins when a method is invoked — a new call triggers the invocation counter, and when it reaches the threshold, the JVM queues the method for compilation. The compiled version executes on subsequent invocations. OSR (on-stack replacement) compilation begins while a method is already running — a hot loop inside the method triggers the back-edge counter while the method is active, and the JVM compiles a replacement version that can take over mid-execution. OSR is harder because the compiler must construct a transition frame that transfers local variables and operand stack state from the running interpreter to the compiled code, while ensuring all currently executing instructions see a consistent state. Regular compilation is simpler — it starts fresh with the method entry as the only entry point. The JVM uses OSR specifically for long-running server methods where a hot loop would otherwise never get compiled until the method returned.

14. How does tiered compilation reduce warmup time compared to pure C2 compilation?

Pure C2 compilation waits until a method reaches ~10,000 invocations before compiling — and C2 itself takes seconds to minutes to produce optimized code. This means an application runs interpreted for a long time before reaching peak performance. Tiered compilation introduces C1 at lower thresholds (~1,500 invocations) with fast, low-optimization compilation that produces usable native code in ~100ms. The hot method gets compiled quickly and starts running faster than the interpreter. As invocation counts grow, the method is promoted to C2 for full optimization, which takes longer but produces significantly faster code. The net effect is that the application reaches good performance sooner (C1 kicks in fast) and peak performance eventually (C2 takes over for the hottest methods). Without tiered compilation, you must choose between fast startup (skip C2) or high peak performance (wait for C2), but tiered gives you both.

15. What is the compilation queue and how does the JIT prioritize methods for compilation?

The JIT compiler maintains separate compilation queues for C1 and C2. Methods are not served in FIFO order — the compiler picks methods based on a cost-benefit ratio: estimated compilation time divided by estimated performance benefit. Hotter methods with smaller bytecode bodies provide the best return on compilation investment. A method that runs 100,000 times and compiles in 50ms gets priority over a method that runs 10,000 times and compiles in 500ms. The JIT tracks estimated compilation cost using bytecode size and complexity metrics, and estimated benefit using current invocation counts and measured speedups from previous compilations. When the queue saturates (burst load pattern), methods wait in the queue, causing warmup latency for requests that hit those methods. Increasing -XX:CICompilerCount (default: 2-4 based on CPU count) allows more parallel compilation threads, which helps when many methods become hot simultaneously.

16. What is the difference between zombie code and non-entrant compiled code in the JIT?

Non-entrant code is compiled code that the JVM has marked as no longer accepting new calls — the call site no longer dispatches to this version. This happens during deoptimization when the assumptions the compiled code made are violated, or when a newer compiled version replaces an older one. Existing activations of the non-entrant code are allowed to finish executing — these are the "zombie" executions. When no threads are executing the zombie code, it becomes eligible for eviction from the code cache. Zombie code wastes space if it is never evicted, which is why the JVM has sweeping mechanisms to remove code that is both non-entrant and no longer in use. The distinction matters for debugging: "made not entrant" means the code is being deactivated but may still be running, while "zombie" means the code is no longer running at all but still occupies cache space until evicted.

17. How does the JIT compiler handle deoptimization triggered by class unloading?

When a class is unloaded, any compiled code that depends on that class becomes invalid — the compiled code may contain constant pool references, inline caches, and assumptions that no longer hold. The JVM marks affected compiled methods as non-entrant and triggers deoptimization for any currently executing frames. At the safepoint, the interpreter takes over for new calls to those methods. If the call site is still hot, the method is queued for recompilation — the recompilation will use the updated class list and avoid the unloaded class. Class unloading is triggered by GC when Metaspace approaches its limit, which means deoptimization from class unloading often occurs during GC pauses. With -XX:+ClassUnloading (default), the JIT compiler is more aggressive with optimizations because it knows classes can be unloaded to free space. Disabling class unloading (-XX:-ClassUnloading) makes the JIT more conservative because it cannot rely on classes being removed.

18. What is the relationship between `-XX:CompileThreshold` and tiered compilation invocation thresholds?

-XX:CompileThreshold (default 10,000) sets the base threshold for C2 compilation in non-tiered mode. In tiered mode, C1 thresholds are derived from CompileThreshold using tier-specific multipliers and offsets, not from the full C2 threshold. Tier 1 (C1 simple) kicks in at ~1,500 invocations (roughly CompileThreshold / 6). Tier 2 (C1 limited profiling) at ~5,000. Tier 3 (C1 full profiling) at ~10,000. Tier 4 (C2) at ~100,000. These are not direct multiples of CompileThreshold — they are hard-coded ratios that reflect the JVM's empirically determined sweet spots. Changing CompileThreshold only affects the base; it does not proportionally scale the tier thresholds. For aggressive tuning, you set tier thresholds directly via flags like -XX:Tier4InvocationThreshold=50000 rather than relying on CompileThreshold to cascade.

19. How does the JIT compiler use branch probability profiling to optimize control flow?

During interpreted execution, the JVM tracks how frequently each branch is taken — not just that a branch exists, but which direction is the common case. This profile is passed to the JIT compiler when it compiles the method. With branch probability data, the JIT can optimize by placing the common path linearly and the uncommon path in a side branch, improving instruction cache locality. The JIT can also eliminate branches entirely when one path is never taken (dead code elimination via profile data). For switch statements, the profile shows which cases are common and allows the JIT to emit a jump table with the hot cases first for faster dispatch. Branch probability also guides loop unrolling decisions — a loop that iterates 10 times on average but has an exit branch gets different unrolling treatment than one that iterates millions of times. This runtime profile is the key advantage JIT has over static compilers.

20. What is the `-XX:+PrintCompilation` log format and how do you interpret common entries?

-XX:+PrintCompilation outputs one line per compilation event. The format is: <thread_id> <compilation_level> <method_name> <tier> <properties> @ <bytecode_offset> <reason>. A line like 3 1 Example.method() @ 42 osr means thread 3 compiled Example.method() at tier 1 (C1) at bytecode offset 42 via on-stack replacement. 3 1 Example.method() made not entrant @ 42 means the compiled version was deoptimized. The tier column shows 1-4 for C1 levels and 4 for C2. The properties column shows flags like % (osr), ! (has exception handler), s (synchronized). "made not entrant" marks code as inactive; "deoptimized" interrupts an active frame. You can correlate these with -XX:+LogCompilation output (XML) for deeper analysis with JITWatch. Frequent "made not entrant" or "deoptimized" entries indicate type profile instability or assumption violations.

Conclusion

You now understand how the JVM identifies hot code, compiles it through tiered compilation, and manages the code cache. Apply this when tuning JVM flags like -XX:CompileThreshold and -XX:ReservedCodeCacheSize, and when diagnosing warmup latency or P99 spikes. Pair this knowledge with Tiered Compilation to understand the full tier model from interpreter to C2, and how OSR enables mid-execution transitions.

Category

Related Posts

Deoptimization Debugging: When JIT Compiled Code Reverts

Learn what causes the JVM to deoptimize JIT-compiled code, how to detect deoptimization events, and how to fix the underlying issues.

#jvm #jit #deoptimization

JIT Optimization: Inlining, Escape Analysis, Dead Code Elimination

Understand how JVM JIT compiler optimizes code through inlining, escape analysis, and dead code elimination for peak application performance.

#jvm #jit #compiler-optimization

Tiered Compilation in the JVM

Explore C1 and C2 JIT compilers, on-stack replacement, and deoptimization handling in the JVM's tiered compilation model.

#java #jit #jvm