multithreading
concurrency
synchronization
debugging
Java

Loop doesn't see value changed by other thread without a print statement

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

If a Java loop only stops when you add System.out.println, the real bug is not in printing. It is a memory-visibility problem: one thread updates a value, but another thread is allowed to keep reading a stale copy because the code has no proper happens-before relationship.

Why the Loop Can Miss the Update

In Java, threads do not automatically see each other's writes immediately. The Java Memory Model allows:

  • CPU caching
  • JIT optimizations
  • Reordering of reads and writes

That means a loop like this is broken:

java
1public class VisibilityDemo {
2    private static boolean done = false;
3
4    public static void main(String[] args) throws Exception {
5        Thread worker = new Thread(() -> {
6            while (!done) {
7                // busy wait
8            }
9            System.out.println("Stopped");
10        });
11
12        worker.start();
13        Thread.sleep(1000);
14        done = true;
15    }
16}

The worker thread is not guaranteed to observe the write to done. It may keep reading a cached value or allow the JIT to optimize the loop in a way that assumes done does not change.

Why println Seems to Fix It

System.out.println changes timing, and it also synchronizes internally because PrintStream methods are synchronized. That extra synchronization can create enough memory barriers to make the updated value visible by accident.

This is why adding logging sometimes makes a concurrency bug "go away." The code was never correct. The print statement only changed the execution conditions.

The Correct Fix: volatile

If the variable is a simple shared flag, mark it volatile:

java
1public class VisibilityDemo {
2    private static volatile boolean done = false;
3
4    public static void main(String[] args) throws Exception {
5        Thread worker = new Thread(() -> {
6            while (!done) {
7                Thread.onSpinWait();
8            }
9            System.out.println("Stopped");
10        });
11
12        worker.start();
13        Thread.sleep(1000);
14        done = true;
15    }
16}

volatile gives you two important guarantees:

  • A write by one thread becomes visible to readers in other threads
  • Reads and writes of that variable are not freely reordered across the visibility barrier

For a stop flag, that is usually exactly what you need.

When volatile Is Not Enough

volatile works for visibility, but it does not make compound operations atomic. For example, this is still unsafe:

java
1class Counter {
2    private volatile int count = 0;
3
4    public void increment() {
5        count++;
6    }
7}

count++ is read, modify, write. Multiple threads can still race. In that case, use synchronization or an atomic type:

java
1import java.util.concurrent.atomic.AtomicInteger;
2
3class Counter {
4    private final AtomicInteger count = new AtomicInteger();
5
6    public void increment() {
7        count.incrementAndGet();
8    }
9
10    public int get() {
11        return count.get();
12    }
13}

Other Correct Approaches

You can also fix visibility with:

  • 'synchronized blocks or methods'
  • 'Lock implementations from java.util.concurrent.locks'
  • Higher-level concurrency constructs such as CountDownLatch

For example, a latch expresses one-time completion much more cleanly than a spin loop:

java
1import java.util.concurrent.CountDownLatch;
2
3CountDownLatch latch = new CountDownLatch(1);
4
5Thread worker = new Thread(() -> {
6    try {
7        latch.await();
8        System.out.println("Stopped");
9    } catch (InterruptedException e) {
10        Thread.currentThread().interrupt();
11    }
12});

Common Pitfalls

  • Assuming a debug print proves the code is correct.
  • Using a plain shared boolean between threads without volatile or synchronization.
  • Using volatile for non-atomic compound operations and expecting it to solve race conditions.
  • Writing busy loops when a higher-level synchronization primitive would express the intent more clearly.

Summary

  • A loop missing another thread's update is a visibility bug.
  • 'println only changes timing and synchronization side effects; it is not a real fix.'
  • Use volatile for simple shared flags.
  • Use atomics or locks for compound updates.
  • In many cases, a latch, queue, or other concurrency primitive is better than a spin loop.

Course illustration
Course illustration

All Rights Reserved.