Definition
- JIT hoisting – the JIT compiler sees that a variable is not modified within the loop body. It legally optimizes
while(flag) { work(); }intoboolean cached = flag; while(cached) { work(); }. The variable is read once and cached in a register. - Register caching – the CPU register holds the stale value (e.g.,
true). Even when another thread writesfalseto main memory, the loop thread never reads main memory again. - JMM legality – without a happens-before relationship (volatile, synchronized, or Lock), the JVM is not required to guarantee cross-thread visibility. The optimization is legal.
- volatile fix – declaring the variable
volatileinserts a memory barrier that prevents hoisting and register caching. Every read must fetch from main memory.
A bank security guard checks the "OPEN" sign once when their shift starts. The guard writes "OPEN=true" on a sticky note (CPU register) and keeps checking the sticky note all day. When the manager flips the sign to "CLOSED" at 5PM, the guard still reads "OPEN" from the sticky note and never locks the doors. volatile forces the guard to look at the actual sign every time, not the sticky note.
Understanding the Concepts
Why does the loop never terminate?
The JIT compiler is allowed to hoist invariant reads out of loops. If the variable is not marked volatile and is not accessed through a synchronized block, the JIT treats it as loop-invariant.
package com.bank.core;
public class InfiniteLoopBug {
private static boolean running = true; // NOT volatile
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
// JIT may optimize to: if (running) while(true) { count++; }
long count = 0;
while (running) {
count++;
}
System.out.println("Exited after " + count + " iterations");
});
worker.start();
Thread.sleep(2000);
running = false;
System.out.println("Set running=false");
worker.join(3000);
if (worker.isAlive()) {
System.out.println("BUG: worker still running after 3 seconds!");
worker.interrupt();
}
}
}
In optimized (production) mode, the worker thread may never exit because the JIT cached running=true in a register.
How does volatile fix it?
package com.bank.core;
public class InfiniteLoopFix {
private static volatile boolean running = true; // volatile!
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long count = 0;
while (running) { // volatile read: must access main memory
count++;
}
System.out.println("Exited after " + count + " iterations");
});
worker.start();
Thread.sleep(2000);
running = false; // volatile write: flushes to main memory
worker.join();
}
}
Exited after 847263951 iterations
Why is this hard to reproduce in development?
The JIT compiler only optimizes code after it has been executed many times (the compilation threshold is typically 10,000 invocations). In debug mode or with small loop counts, the code runs in interpreted mode where every read accesses memory. The bug only manifests in production after the JIT compiles the hot loop.
You can force JIT compilation to reproduce it:
java -XX:+PrintCompilation -server InfiniteLoopBug
What are the alternative fixes?
Using synchronized for every read also provides visibility, but it is much more expensive than volatile for a simple flag.
package com.bank.core;
public class SynchronizedFix {
private static boolean running = true;
private static final Object lock = new Object();
public static boolean isRunning() {
synchronized (lock) { return running; }
}
public static void stop() {
synchronized (lock) { running = false; }
}
}
This works because the synchronized block establishes a happens-before relationship, but acquiring a monitor lock on every loop iteration is wasteful. volatile is the right tool for this pattern.
What does the JIT actually do?
The JIT transformation looks like this:
Before JIT (interpreted, correct):
while (running) { // reads from memory each iteration
work();
}
After JIT (compiled, may hoist):
boolean cached = running; // read ONCE, store in register
while (cached) { // never reads memory again!
work();
}
vollatile prevents this by inserting a memory barrier that forces the CPU to load the variable from main memory on every access.
What should a senior engineer know?
- x86 vs ARM: on x86, volatile reads are cheap (just prevents register caching; the load itself is normal). On ARM, volatile reads require explicit acquire barriers, which are more expensive.
- Thread.onSpinWait() (Java 9): in spin-wait loops, call
Thread.onSpinWait()to hint the CPU. It emits a PAUSE instruction on x86, reducing power consumption and improving hyperthreading performance. - JMH for testing: use JMH (Java Microbenchmark Harness) with
-XX:LoopUnrollLimit=1to prevent JIT from unrolling loops when testing visibility bugs.
Visual Overview
Without volatile (JIT hoisting):
┌──────────────────────────────────────────┐
│ Worker Thread │
│ │
│ boolean cached = running; // read ONCE │
│ while (cached) { // register │
│ work(); // forever! │
│ } │
│ │
│ Main Memory: running = false │
│ (worker never reads it again) │
└──────────────────────────────────────────┘
With volatile:
┌──────────────────────────────────────────┐
│ Worker Thread │
│ │
│ while (running) { // volatile read │
│ // reads main memory EVERY iteration │
│ work(); │
│ } │
│ │
│ Main Memory: running = false │
│ (worker sees it on next iteration!) │
└──────────────────────────────────────────┘
Key Takeaway
Without volatile, the JIT compiler can hoist a flag read out of a loop, caching the value in a CPU register. The loop becomes while(true) because the thread never reads from main memory again. Even though another thread writes false to main memory, the loop thread is stuck with the stale true in its register.
This is a legal optimization under the Java Memory Model. Without a happens-before relationship, the JVM is not required to guarantee cross-thread visibility. volatile fixes this by inserting a memory barrier that prevents hoisting and forces every read to access main memory.
This bug is particularly insidious because it does not manifest in debug mode or with small loop counts. The JIT only hoists after compiling hot code, so the bug appears only in production.
Remember one sentence: without volatile, the JIT may cache a loop flag in a CPU register, causing an infinite loop that only volatile or synchronized can fix.
---