Back to volatile
Q5Question 5 of 5

Why does while(flag){} never terminate without volatile?

Key Concepts
  • Without volatile, the JIT compiler may hoist the flag read out of the loop, caching it in a CPU register.
  • The hoisted form becomes 'if (flag) while(true) { }', which never re-reads the variable from memory.
  • This is a legal JIT optimization under the Java Memory Model because there is no happens-before relationship.
  • volatile prevents hoisting: every read must go to main memory, so the loop sees the updated value.
  • This bug is difficult to reproduce in debug mode because the JIT does not optimize uncompiled code.
  • Alternative fixes include synchronized reads, but volatile is cheaper and simpler for flags.

Definition

Without volatile, the JIT compiler can hoist a flag read out of a loop, caching it in a CPU register, so the loop never sees updates from another thread and runs forever.
  • JIT hoisting – the JIT compiler sees that a variable is not modified within the loop body. It legally optimizes while(flag) { work(); } into boolean 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 writes false to 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 volatile inserts 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.

JAVA
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?

JAVA
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();
    }
}
TEXT
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:

BASH
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.

JAVA
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:

TEXT
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=1 to prevent JIT from unrolling loops when testing visibility bugs.

Visual Overview

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate