the interpreter and the JIT
tiered compilation: how hot code climbs from tier 0 to C2.
- chapter
- chapter 13.07
- reading time
- 9 min read
every method starts out interpreted: the JVM reads its bytecode one instruction at a time, and counts how often it runs. once a method gets warm, C1 compiles it to machine code fast, with instrumentation that records how the code actually behaves. once it gets hot, C2 recompiles it slowly and carefully, using that profile.
that's tiered compilation: levels 0 to 4. hot code climbs; code that never matters never gets compiled at all.
the picture
you know how java is "slow to start, fast once it's going"? that's this chapter. it's not the JVM being lazy: it's deliberately waiting to see what your code does before it spends effort optimizing it.
think of learning a commute in a new city. the first week you follow the map turn by turn (the interpreter). after a few trips you know the route, and you start noticing things: this light is always red, that road is always empty at 9 (C1, with profiling). a month in, you take shortcuts based on everything you noticed (C2). and if one day a road you counted on is closed, you pull the map back out (deoptimization).
watch a method warm up
digitSum adds up the digits of a number. it's called in batches of 100, and every batch is timed. this is a real run on jdk 25: scrub through it, or warm it up.
static int digitSum(int n) {
int sum = 0;
while (n > 0) {
sum += n % 10;
n /= 10;
}
return sum;
}every call runs in the interpreter, which bumps the method's invocation counter (and the back-edge counter on every loop iteration: 9 per call here).
calls or press warm it up. the hot rung is the tier the method is running at. the curve is measured (jdk 25, M4 Pro): one representative run of five, where C1's code always landed after 900 to 1,100 calls and C2's after about 14,000 to 15,600.two things that surprise people. first, compilation doesn't pause your program: the method is queued, a compiler thread works on it in the background, and the interpreter keeps running the old way until the new code is installed (that's the gap between 256 and 900 calls). second, the curve has two steps, not one. the first is the jump from bytecode to machine code. the second is C2 doing actual optimization with what tier 3 learned.
the five tiers
| tier | runs as | what it's for |
|---|---|---|
| 0 | the interpreter | everything starts here. counts calls and loop back-edges |
| 1 | C1, no profiling | trivial methods (getters) where C2 couldn't do better anyway |
| 2 | C1, counters only | a stopgap while C2's queue is long |
| 3 | C1, full profiling | the normal middle step. records types at call sites, branch directions |
| 4 | C2 | the real optimizer. uses the tier 3 profile to bet on what will happen |
the usual path is 0 → 3 → 4. the thresholds are plain JVM flags (java -XX:+PrintFlagsFinal -version, jdk 25):
| flag | default | means |
|---|---|---|
Tier3InvocationThreshold | 200 | calls before a method is queued for tier 3 |
Tier3CompileThreshold | 2000 | or: calls + back-edges past this (with at least 100 calls) |
Tier3BackEdgeThreshold | 60000 | loop iterations before an OSR compile (see below) |
Tier4InvocationThreshold | 5000 | calls (counted in tier 3 code) before C2 |
Tier4CompileThreshold | 15000 | or: calls + back-edges past this (with at least 600 calls) |
Tier0InvokeNotifyFreqLog | 7 | the interpreter checks every 2^7 = 128 calls |
the thresholds also scale with how busy the compiler queues are, so "200" is a starting point, not a promise. that's why the logged run queued digitSum at 256: the first check after 200.
how much does each tier actually buy? same loop, same method, forcing the JVM to stop at each tier with -XX:TieredStopAtLevel (or -Xint for interpreter only):
tier 3 being twice as slow as tier 1 is the whole design in one number. profiling is expensive, so the JVM only does it for a while, only on code that's warm, and only so C2 can make bets that beat C1 by a wide margin.
reading -XX:+PrintCompilation
the JVM will tell you every compilation as it happens. here's a program with a hot method and a long loop in main:
public class Hot {
static int digitSum(int n) {
int sum = 0;
while (n > 0) {
sum += n % 10;
n /= 10;
}
return sum;
}
public static void main(String[] args) {
long total = 0;
for (int i = 0; i < 1_000_000; i++) {
total += digitSum(i);
}
System.out.println(total);
}
}14 9 3 Hot::digitSum (23 bytes)
14 10 4 Hot::digitSum (23 bytes)
14 9 3 Hot::digitSum (23 bytes) made not entrant: not used
15 11 % 3 Hot::main @ 4 (32 bytes)
15 12 3 Hot::main (32 bytes)
15 13 % 4 Hot::digitSum @ 2 (23 bytes)
15 11 % 3 Hot::main @ 4 (32 bytes) made not entrant: OSR invalidation back branch
15 14 % 3 Hot::main @ 4 (32 bytes)
15 15 % 4 Hot::main @ 4 (32 bytes)
16 14 % 3 Hot::main @ 4 (32 bytes) made not entrant: OSR invalidation of lower level
19 15 % 4 Hot::main @ 4 (32 bytes) made not entrant: uncommon trap
27000000the columns, left to right:
14- milliseconds since the JVM started. the whole climb happened in about 5 ms.
9- the compile id. the same id appears again when that compiled version is thrown away.
%- flags.
%is an OSR compile,ssynchronized,!has exception handlers,na native wrapper,bblocking. 3- the tier.
@ 4- for OSR: the bytecode index of the loop the compiled code is entered at.
(23 bytes)- size of the method's bytecode, not of the machine code.
made not entrant- this version can't be entered anymore. the reason follows: replaced by a better one, or an assumption broke.
on-stack replacement
look at main in that log. it's called exactly once, so its invocation counter never gets past 1. yet it got compiled, four times. that's on-stack replacement (OSR): the back-edge counter of its loop crossed the threshold while main was still inside the loop, so the JVM compiled a special version of main that starts at the loop (bytecode 4, the iload_3 that goto 4 jumps back to), copied the live locals out of the interpreter frame, and jumped into compiled code mid-loop.
0: lconst_0
1: lstore_1
2: iconst_0
3: istore_3
4: iload_3
5: ldc #7 // int 1000000
7: if_icmpge 24
10: lload_1
11: iload_3
12: invokestatic #8 // Method digitSum:(I)I
15: i2l
16: ladd
17: lstore_1
18: iinc 3, 1
21: goto 4the last line of the log is the next chapter in miniature. the C2 OSR version of main was built from a profile in which the loop never ended, so C2 didn't bother compiling the code after it. when i finally hit 1,000,000, execution reached that missing path, hit an uncommon trap, and the JVM threw the compiled code away and finished main in the interpreter. it printed 27000000 anyway. more on that in JIT optimizations.
the code cache, and what happens when it fills+
compiled code lives in the code cache, outside the heap (on this machine: 244 MB reserved, see JVM architecture). since java 9 it's segmented into three heaps: JVM-internal code, profiled code (tiers 2 and 3) and non-profiled code (tiers 1 and 4), so short-lived tier 3 code doesn't fragment the long-lived C2 code.
if a segment fills up, the JVM prints CodeCache is full. Compiler has been disabled. (or the per-segment version, ... is full. Compiler has been disabled.) and stops compiling new methods. everything keeps working, it just stays at whatever tier it reached, and anything new runs interpreted. on huge apps that's a real, slow-burning performance cliff: check jcmd <pid> Compiler.codecache and raise -XX:ReservedCodeCacheSize if you're near the top.
- every method starts interpreted. the JVM counts calls and loop iterations.
- warm → C1 (fast to compile, tier 3 records a profile). hot → C2 (slow to compile, uses the profile).
- compilation runs in the background. the interpreter keeps going until the code is ready.
- a long loop can be compiled while it runs: on-stack replacement, the
%in PrintCompilation. -XX:+PrintCompilationshows the whole climb. warmup is real, so measure it on purpose.