ssworks
← Feed
5 min read229 views

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 writes saleActive = true. But the worker threads running on other cores have already compiled their loops they keep reading saleActive = false. For some of the shoppers on those worker threads, the sale never opened.

  • The Race Condition : Multiple threads reach if (stock > 0) simultaneously when stock = 1. All evaluate the check as true, execute stock--, 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 saleActive in 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 see true on 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:

  1. Both threads on Core 1 and Core 2 both see stock: 100 pointing to memory address 0x1111.

  2. Each thread prepares a new state object with stock: 99.

  3. 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."

  4. Core 1 gets there a fraction of a nanosecond earlier. It sets/swap stock to 99 and points to new reference and returns true. A split-second later, Core 2’s check runs, sees the pointer is no longer 0x1111, and fails, returning false.

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:

  1. Deduct user balance from an in-memory ledger.

  2. Decrement inventory stock.

  3. 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 synchronized block, 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