You flip a shared running flag from true to false, but another thread keeps looping like nothing happened. The update did happen. The mystery is that the reader thread has no synchronization relationship that guarantees it must observe that write.
The Problem / Context
A visibility bug is not about arithmetic mistakes. It is about one thread writing a value and another thread not being guaranteed to see that write yet.
This matters because JVM compilers and CPUs are allowed to reorder and cache reads/writes when there is no rule forcing cross-thread visibility. So this code has a data race:
boolean running = true;
// writer thread
running = false;
// reader thread
while (running) {
Thread.onSpinWait();
}
Sometimes it exits quickly. Sometimes it does not. We should not promise one specific runtime outcome, because data races are intentionally hard to reproduce reliably.
Key Concepts
| Concept | Plain-language meaning | Why it matters |
|---|---|---|
| Java Memory Model (JMM) | Rules for what one thread is allowed to see from another | Prevents “it worked on my machine” reasoning for concurrency |
| Happens-before | A guarantee that earlier actions become visible before later actions in another thread | Lets readers trust published writes |
volatile write/read |
A volatile write is visible to later volatile reads of the same field | Fixes visibility for flags and published references |
| Atomicity | An operation happens as one indivisible step | Needed for count++ style updates |
The Solution / Implementation
Use a volatile flag so the writer’s update is visible to the reader.
private volatile boolean running = true;
public void stop() {
running = false;
}
while (running) { Thread.onSpinWait(); }
@volatile private var running = true
def stop(): Unit =
running = false
while running do Thread.onSpinWait()
@Volatile
private var running: Boolean = true
fun stop() {
running = false
}
while (running) { Thread.onSpinWait() }
The shared meaning across all three examples is JVM-level: a volatile write to running happens-before a subsequent volatile read of running in another thread.
The Second Trick: volatile Does Not Fix count++
volatile gives visibility, not atomicity.
volatile int count = 0;
count++; // read, add, write
Another thread can intervene between the read and the write. That is why the visibility sample tests include a deterministic lost-update demonstration.
For the fix, use atomic updates such as AtomicInteger.incrementAndGet() or compare-and-set loops. For a full compare-and-set walk-through, see the Java 21 AtomicTicketOffice example.
Kotlin’s JVM concurrency guidance makes the same distinction: visibility (@Volatile) is useful, but arithmetic updates still need atomic coordination.
Bridge from the Immutability Series
Immutability and visibility fit together:
- An immutable object is safer to share across threads after publication.
- But if you replace the reference over time, you still need safe publication for that changing reference.
So the rule is: immutable values reduce mutation risk, and visibility rules make publication reliable. If you missed the first part, start with Immutable Data with Java Records.
Decision Guide
| Need | Preferred tool | Why |
|---|---|---|
| A simple stop flag or a published reference | volatile |
Guarantees reader visibility with low ceremony |
| One value that must be updated atomically | Atomic type (AtomicInteger, AtomicReference, etc.) |
The update happens as one indivisible action |
| Several values that must change together | Lock (synchronized/ReentrantLock) or one immutable snapshot + CAS |
Preserves cross-field consistency |
Visibility in Practice
What does "happens-before" mean in practical terms?
Why can count++ still lose updates when count is volatile?
When should I choose an atomic type over volatile?
How does immutability help with visibility?
Conclusion
For Scala developers moving to Java 21, visibility is the missing rule behind many “ghost” concurrency bugs: your write exists, but another thread is not guaranteed to see it yet. Use volatile for visibility, atomic types for read-modify-write, and locks or immutable snapshots when several values must move together.
Code Samples
All examples in this post are runnable. Find them in the repository:
- Java 21 VisibilityExamples
- Scala 3 VisibilityExamples
- Kotlin VisibilityExamples
- Java 21 tests
- Scala 3 tests
- Kotlin tests
This is part of our Java 21 Interview Preparation Guide - Your Roadmap to Success. Next related posts: Immutable Data with Java Records and Virtual Threads and Structured Concurrency.