Java 21 Visibility: The Case of the Disappearing Update

java java21 scala scala3 kotlin visibility volatile java-memory-model happens-before concurrency

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?
It means a reader thread is allowed to rely on seeing a writer thread's earlier action. Without that rule, a value may have changed in memory but still look old to another thread. With a volatile write/read pair, your code gets a concrete visibility guarantee instead of wishful thinking.
Why can count++ still lose updates when count is volatile?
Because increment is three steps: read, compute, write. Volatile makes each read/write visible, but it does not merge those steps into one atomic operation. Two threads can still read the same old value and both write back the same next value.
When should I choose an atomic type over volatile?
Choose an atomic type when you need to change a value based on its current value safely, like incrementing counters or swapping references with compare-and-set. Use volatile when you only need one thread's write to become visible to another thread.
How does immutability help with visibility?
Immutability means readers cannot corrupt the shared object after they receive it, which removes a whole category of bugs. You still need correct publication of the reference itself, though, so readers reliably see the latest object when that reference changes.

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:


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.