Thread safety basics

Part of the free Low-Level Design course on LogicWiz, in the chapter: Concurrency in LLD. The class-diagram exercises run in the app.

Week 10: The last packet of butter, sold twice

"Two customers bought the last packet of butter at the same second. Our stock now says minus one."

Priya, reading the bug report


Two threads, one packet of butter

Here is the class Arjun wrote for SwiftKart's stock. It passed every test he ran.

public class ProductStock {
    private int available = 1;

    public boolean reserve(int qty) {
        if (available >= qty) {     // check
            available -= qty;       // act
            return true;
        }
        return false;
    }
}

His tests called reserve one at a time. Production does not. A web server runs each request on its own thread. A thread is one path of execution inside the program, and many threads run at the same time. Two customers tapping "Buy" together means two threads inside reserve together.

sequenceDiagram
  participant A as Thread 1 (customer 1)
  participant S as ProductStock
  participant B as Thread 2 (customer 2)
  A->>S: check, available is 1, enough
  B->>S: check, available is 1, enough
  A->>S: subtract 1, available is 0
  B->>S: subtract 1, available is -1

Both threads passed the check before either one acted. This is a race condition: the result depends on which thread runs first, and some orders give a wrong answer. This shape has a name, check-then-act. You check a condition, then act on it, but the condition can change in between.

Code is thread-safe when it stays correct no matter how the threads interleave. Arjun's class is not.

Why count++ is three steps

Arjun tried a smaller fix first. He added a counter of orders placed today and marked it volatile.

private volatile int ordersToday = 0;

public void onOrderPlaced() {
    ordersToday++;   // read, add one, write back
}

"I made it volatile, so it is safe now," he said. It is not. ordersToday++ looks like one step, but the processor does three: read the value, add one, write it back. Two threads can both read 41 and both write 42. One order disappears from the count.

volatile solves a different problem, called visibility. Without it, a thread may keep using an old copy of a value, because the compiler and processor are allowed to reuse it. volatile makes every read see the latest write. It does not make the three steps happen as one. A step that cannot be split by another thread is called atomic.

Java gives you atomic classes for this:

private final AtomicInteger ordersToday = new AtomicInteger();
private final AtomicInteger available = new AtomicInteger(1);

public void onOrderPlaced() {
    ordersToday.incrementAndGet();           // one atomic step
}

public boolean reserve(int qty) {
    while (true) {
        int now = available.get();
        if (now < qty) return false;
        if (available.compareAndSet(now, now - qty)) return true;
        // another thread changed it first: read again and retry
    }
}

compareAndSet says: "set it to the new value only if it is still what I read". If another thread got there first, it returns false and the loop tries again. Atomics are fast and fit one variable well.

Locks: synchronized and ReentrantLock

When the rule covers more than one variable, use a lock. A lock lets only one thread at a time run a piece of code. That piece is the critical section.

The simplest lock in Java is synchronized:

public class ProductStock {
    private int available;

    public synchronized boolean reserve(int qty) {
        if (available < qty) {
            return false;
        }
        available -= qty;
        return true;
    }

    public synchronized int available() {
        return available;
    }
}

Now the check and the act happen inside one lock, so no thread can slip in between. Notice the getter is synchronized too. A lock also gives visibility, so readers need it as well.

ReentrantLock does the same job with more control. It can wait with a timeout using tryLock. Always release it in a finally block, so an exception cannot leave it held forever.

private final ReentrantLock lock = new ReentrantLock();

public boolean reserve(int qty) {
    lock.lock();
    try {
        if (available < qty) {
            return false;
        }
        available -= qty;
        return true;
    } finally {
        lock.unlock();
    }
}

Reentrant means the thread that already holds the lock can take it again, for example when one locked method calls another. The lock counts how many times it was taken, and is free only when the count is back to zero. synchronized is reentrant too.

💡 Tip: Keep the lock inside the class that owns the data. Callers should not lock for it. Then nobody can forget.

Try it below. The starter lets CheckoutService read and set a public count. Move the rule into ProductStock: a private count, its own lock, and one method that checks and reserves together.

Shared maps and immutable objects

SwiftKart keeps a count of how many carts hold each item. Arjun swapped HashMap for ConcurrentHashMap and called it done. A concurrent map is a map that many threads can use at once without breaking its insides. But look at his code:

if (!cartCount.containsKey(sku)) {
    cartCount.put(sku, 0);
}
cartCount.put(sku, cartCount.get(sku) + 1);   // still a race

Each call is safe on its own. The sequence of calls is not. It is check-then-act again. Use the map's atomic methods instead, which do the whole update for one key in one step:

cartCount.merge(sku, 1, Integer::sum);          // add one, or start at 1

stockBySku.compute(sku, (key, left) -> {
    if (left == null || left < qty) {
        return left;                            // not enough: unchanged
    }
    return left - qty;
});

The cheapest fix of all is to have nothing to change. An immutable object cannot change after it is built. A record Money(long paise, String currency) with no setters can be shared by any number of threads with no lock. To "change" a price, you build a new Money.

Here is a short guide to choosing:

  • One number such as a counter or a single stock level: an atomic class.
  • A rule across several fields, such as "booked never exceeds capacity": a lock.
  • A map of counts or values: ConcurrentHashMap with merge or compute.
  • Data that never changes after creation: an immutable class or record, with no lock at all.

Deadlock and lock ordering

Locks bring a new risk. SwiftKart sometimes moves stock between two dark stores (small warehouses). Thread 1 moves butter from store A to store B. It locks A, then waits for B. Thread 2 moves eggs from B to A. It locks B, then waits for A. Each waits for the other forever. This is a deadlock.

The standard fix is lock ordering: every thread takes locks in the same global order, for example by store id.

void move(Store from, Store to, String sku, int qty) {
    Store first = from.id().compareTo(to.id()) < 0 ? from : to;
    Store second = (first == from) ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.remove(sku, qty);
            to.add(sku, qty);
        }
    }
}

Now both threads lock A before B, so neither can hold B while waiting for A. Another option is tryLock with a timeout, which gives up and retries instead of waiting forever.

⚠️ Warning: Do not mark every method synchronized "to be safe". One big lock makes threads wait in a line, and locks held while calling other objects invite deadlock. Lock the smallest piece that keeps the rule true.

📌 Summary: A race happens when threads interleave a check and an act. volatile gives visibility, not atomicity. Use atomics for one value, a lock for a rule, and take locks in one fixed order.

What Arjun learns next

One packet of butter is a single counter. A delivery slot with 20 places, booked from many servers at once, is harder. Next week: double booking, optimistic and pessimistic locking, and a queue between the threads that take orders and the threads that send notifications.

Check your understanding

  1. Arjun marks a counter `volatile int ordersToday` and increments it with `ordersToday++` from many threads. What happens?
  2. A reviewer sees `if (available >= qty) { available -= qty; }` in a class used by many request threads. What is the name of this bug pattern?
  3. Arjun replaces `HashMap` with `ConcurrentHashMap` but keeps `if (!m.containsKey(k)) m.put(k, 0); m.put(k, m.get(k) + 1);`. Is the count now correct?
  4. Two threads move stock between stores. One locks store A then store B; the other locks B then A. Sometimes both hang forever. What is the standard fix?