Volatile vs. AtomicReference vs. Synchronized: A Flash Sale Case Study
Following up on how hardware caches and store buffers break multithreaded code, let’s explore the three standard concurrency tools in Java - volatile, AtomicReference, and synchronized using a real-world scenario.
The Scenario: 100 Laptops, 50,000 Shoppers
Generally flash sales happen like a race, where you buy that product when the time ticks with limited number of products in the inventory.
An e-commerce platform hosts a flash sale: 100 high-end laptops for $100 each, starting precisely at 12:00 PM. At noon, 50,000 shoppers hit the endpoint simultaneously.
Here is the naive code that handles flash sale feature.
public class FlashSaleService {
private boolean saleActive = false;
private int stock = 100;
public void startSale() {
saleActive = true;
}
public boolean purchase() {
if (saleActive && stock > 0) {
stock--;
return true;
}
return false;
}
}Under heavy concurrent traffic, this code triggers two bugs:
The Invisibility Bug: When the manager thread calls
startSale(), it writessaleActive = true. But the worker threads running on other cores have already compiled their loops they keep readingsaleActive = false. For some of the shoppers on those worker threads, the sale never opened.The Race Condition : Multiple threads reach
if (stock > 0)simultaneously whenstock = 1. All evaluate the check astrue, executestock--, and decrement the inventory into negative numbers. You’ve now oversold your physical stock.
Here is how each tool fixes a distinct layers of this problem.
Fix #1: volatile (Visibility & Ordering)
To fix the invisible sale opening, declare the flag as volatile:
What volatile Solves
It tells the JIT compiler never to cache
saleActivein a CPU register.It emits a memory barrier so that when
startSale()executes, Core 1 immediately drains its pending write to the cache hierarchy. Worker threads across all other cores seetrueon their very next read.
What it CANNOT do:
volatile does not fix stock--. Decrementing an integer is a three-step Read-Modify-Write operation. volatile guarantees visibility, not atomicity.
Fix #2: AtomicReference (Lock-Free Multi-Variable State)
We need to coordinate both stock and saleActive atomically without bottlenecking all 50,000 threads behind heavy OS-level locks. It might take the service down.
Instead of primitives, model the entire sale state as an immutable record managed by an AtomicReference:
public class FlashSaleService {
public record SaleState(int stock, boolean active) {}
private final AtomicReference<SaleState> state =
new AtomicReference<>(new SaleState(100, false));
public void startSale() {
state.updateAndGet(curr -> new SaleState(curr.stock(), true));
}
public boolean purchase() {
while (true) {
SaleState current = state.get();
if (!current.active() || current.stock() <=0) {
return false;
}
SaleState next = new SaleState(current.stock() - 1, current.active());
// Low-level hardware Compare-And-Swap (CAS)
if (state.compareAndSet(current, next)) {
return true;
}
// If another thread updated state first, retry loop executes
}
}
}What AtomicReference Solves
Instead of forcing threads to wait in queue, AtomicReference relies on a low-level CPU instruction called Compare-And-Swap (CAS). Here is what actually happens on the chip when two threads collide:
Both threads on Core 1 and Core 2 both see
stock: 100pointing to memory address0x1111.Each thread prepares a new state object with
stock: 99.Both try to point the reference to their new object. The CPU executes this check in a single hardware cycle: "Is the reference still points to
0x1111? If yes, update it to my new address."Core 1 gets there a fraction of a nanosecond earlier. It sets/swap
stockto 99 and points to new reference and returnstrue. A split-second later, Core 2’s check runs, sees the pointer is no longer0x1111, and fails, returningfalse.
The magic is Core 2 doesn’t get put to sleep by the operating system, nor does it sit in an expensive wait queue. It immediately spins through the while (true) loop again, reads the fresh value (99), calculates 98, and fires another swap attempt.
Fix #3: synchronized (Multi-Step Mutual Exclusion)
What if purchasing requires updating multiple independent in-memory data structures together ? For example:
Deduct user balance from an in-memory ledger.
Decrement inventory stock.
Add a record to an audit queue.
A single atomic CAS operation can only swap a single variable or reference. When multiple independent objects must transition together as an all-or-nothing unit in memory, you need mutual exclusion:
public class FlashSaleService {
private final Object lock = new Object();
public boolean purchase(User user, Item item) {
synchronized (lock) {
if (!saleActive || stock <= 0 || user.getBalance() < item.getPrice()) {
return false;
}
stock--;
user.deduct(item.getPrice());
auditQueue.add(new SaleRecord(user, item));
return true;
}
}
}What synchronized Solves
Only one thread can execute the protected block at any given time. Other threads are suspended until the holding thread exits and releases the monitor.
When entering a
synchronizedblock, the thread acquires the latest visible state of that object and when exiting, all modifications are committed and visible to the next acquiring thread.
(Rule of thumb: Keep synchronized blocks strictly limited to fast, in-memory modifications. Not for slow network or disk I/O operations.)
Summary
