JMM, Visibility & Atomicity

Stop Flag Without volatile

Stop Flag Without volatile: practice a Java concurrency bug with symptoms like Worker never stops, Process still alive, Flaky behavior. Inspect runnable...

  • Memory visibility
  • Visibility
  • Happens-before
  • Java
  • Beginner

Production symptoms

  • Worker never stops
  • Process still alive
  • Flaky behavior

Failure scenario

Code

Java example
class Worker implements Runnable {
    private boolean running = true;

    public void run() {
        while (running) {
            // do background work
        }
    }

    void stop() {
        running = false;
    }
}

Prod Symptoms

A background worker checks a boolean stop flag, and the shutdown path sets that flag. Under some runtime conditions, the worker keeps running after shutdown starts.

  • The service receives a stop signal but does not fully exit
  • Executor or graceful shutdown waits until a timeout
  • A worker thread continues after logs say shutdown started
  • The behavior changes across JVM warm-up, load, hardware, or small code changes
  • Adding logging inside the loop can make the issue disappear

Run Locally

  • Some runs print worker observed stop and exit normally
  • Some runs may print worker alive after stop = true and keep the JVM alive
  • The worker can spin even though main assigned running = false
  • Adding println inside the loop may change the outcome because it changes synchronization and timing behavior

Inspect hints

  • If the process is still alive, inspect busy-worker in the thread dump
  • The thread dump can show where the worker is looping, but it cannot prove the stale read by itself
Run
javac StopFlagNoVolatileDemo.java
java StopFlagNoVolatileDemo
for i in {1..10}; do java StopFlagNoVolatileDemo; done
Inspect if stuck
jps
jstack <pid>
jcmd <pid> Thread.print
StopFlagNoVolatileDemo.java
public class StopFlagNoVolatileDemo {
    private static boolean running = true;

    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            while (running) {
                // Tight loop. No volatile read, no synchronization.
            }
            System.out.println("worker observed stop");
        }, "busy-worker");

        worker.start();

        Thread.sleep(1_000);
        System.out.println("main sends stop");
        running = false;

        worker.join(1_000);
        System.out.println("worker alive after stop = " + worker.isAlive());

        if (worker.isAlive()) {
            System.out.println("Bug reproduced. Use Ctrl+C to stop the process.");
        }
    }
}

Note: This demo uses a tight loop to make the missing visibility edge easier to observe. It may not fail on every machine or every run.

Diagnosis and fix

Explanation

The stop flag is shared between threads without a happens-before relationship.

Key signal: A stop signal must be visible to the thread that is supposed to stop.

  • The main thread writes running = false
  • The worker thread reads running inside a loop
  • Without volatile, synchronized, interrupt, or another concurrency primitive, the worker is not guaranteed to observe the write
  • The Java Memory Model allows ordinary reads to be optimized or cached when no visibility edge exists
  • This is a visibility bug; boolean assignment itself is atomic, but visibility is missing

How to Diagnose

Diagnose this by combining shutdown evidence with code review.

  • Confirm the shutdown path actually ran
  • Confirm the worker is still alive after the stop signal
  • Check whether the worker reads a plain field written by another thread
  • Look for missing volatile, missing synchronized, or missing higher-level concurrency primitive
  • Be suspicious if println, sleep, or small timing changes alter the behavior
  • A thread dump may show the worker still running, but it will not prove the stale read by itself
If the demo is stuck
jps
jcmd <pid> Thread.print
Possible output
main sends stop
worker alive after stop = true
Bug reproduced. Use Ctrl+C to stop the process.

Note: A thread dump might show busy-worker RUNNABLE in the loop. That supports the symptom, but the missing happens-before edge is in the code.

How to Fix

  • Use volatile for a simple cross-thread visibility flag
  • Use AtomicBoolean when the codebase wants an explicit atomic holder or compare-and-set transitions
  • Prefer Thread.interrupt() for workers blocked in sleep, wait, join, queue, or I/O-like APIs
  • Check the stop signal between bounded units of work
  • Do not rely on logging, sleeping, or incidental synchronization to publish the flag
StopFlagVolatileFixed.java
public class StopFlagVolatileFixed {
    private static volatile boolean running = true;

    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            while (running) {
                // Background work would check the flag between units of work.
            }
            System.out.println("worker observed stop");
        }, "busy-worker");

        worker.start();

        Thread.sleep(1_000);
        System.out.println("main sends stop");
        running = false;

        worker.join(1_000);
        System.out.println("worker alive after stop = " + worker.isAlive());
    }
}

Note: volatile creates the visibility guarantee needed for this simple stop signal.