The version that oversells with an atomic integer
The first mistake is believing that AtomicInteger makes the code safe.
if (stock.get() > 0) {
stock.decrementAndGet();
return true;
}
return false;
With one unit left, two threads both read 1, both pass the check, and both decrement. Stock ends at minus one and two customers were told yes. This is the same check-then-act shape as the last-seat race in a booking class. The fix is the same idea too: the check and the write have to be one atomic step.
Optimistic locking with compare-and-set
Optimistic locking takes no lock. It assumes interference is rare, detects it when it happens, and retries.
import java.util.concurrent.atomic.AtomicInteger;
final class Stock {
private final AtomicInteger remaining;
Stock(int initial) { remaining = new AtomicInteger(initial); }
boolean tryTake() {
while (true) {
int current = remaining.get();
if (current <= 0) return false;
if (remaining.compareAndSet(current, current - 1)) return true;
}
}
int remaining() { return remaining.get(); }
}
The decision "there is at least one" and the write "now there is one fewer" are joined by the compare-and-set. The write succeeds only if the value is still the one the decision was based on. The same loop can be written as remaining.getAndUpdate(n -> n > 0 ? n - 1 : n) > 0. The Javadoc warns that the function may be re-applied under contention, so it must have no side effects.
Version-number retry, familiar from databases, is the same pattern on a bigger state. You keep an AtomicReference to an immutable snapshot and swap in a new snapshot. A failed swap means somebody else published first. The database form is the conditional UPDATE in two customers buying the last item.
Pessimistic locking, and what contention costs each way
The pessimistic version is a ReentrantLock (or synchronized) around a plain int, with the check and the decrement inside lock() and unlock() in a finally. It is just as correct.
The costs differ under a flash sale, where every thread wants the same counter in the same second. Both designs move one cache line between cores on every successful sale, and that traffic is the floor neither escapes. On top of it, a failed compare-and-set costs one re-read and another attempt, and the thread stays running. A contended lock costs parking and waking threads, which means trips through the scheduler. When the critical section is a few instructions, the retry is usually cheaper than the park. Treat that as something to measure on your hardware, not a number to quote.
The balance tips when the critical section grows. If the purchase must also check a per-user limit, reserve a coupon and append to an order list, a retry repeats all of that work. Worse, a snapshot swap that spans several objects becomes awkward to write correctly. A lock lets you write the invariant as straight-line code, and under heavy contention it avoids wasted attempts. A compare-and-set loop is also lock-free, not wait-free: the system always makes progress, but one unlucky thread has no bound on its retries.
One primitive to avoid is LongAdder. It spreads increments across cells to avoid contention, but it cannot express "decrement only if positive". Its sum() is not an atomic snapshot under concurrent updates. It is a fine sales counter and the wrong stock counter.
Proving nothing oversold
"I ran it and the numbers looked right" is not a proof, and the interviewer is listening for the difference.
Start with the argument. Every successful tryTake is a compare-and-set from some n > 0 to n - 1. Each such step consumes exactly one unit, the steps are atomic, and the count only falls through them. So the number of successes cannot exceed the initial stock. That reasoning is the proof. A test is evidence that your code matches the reasoning.
Then write a test that gives the race its best chance.
Demand is set well above stock, so the assertion is exact: precisely 100 sales and zero left. A test that only checks "stock is not negative" passes against code that sells nothing.
Then point the same test at the broken check-then-decrement version. On a multi-core machine it usually reports more than 100 sales within a few runs. A concurrency test that has never failed has not shown it can detect anything. Even so, a passing run only means no bad interleaving happened that time. For stronger evidence on the JVM, the OpenJDK jcstress harness exists to run small concurrent tests far more often, and in more thread and timing combinations, than a hand-written loop.
Choosing under a flash sale
Neither choice scales past one hot cache line. When a single SKU's counter becomes the bottleneck, split the stock into several buckets, each with its own counter. Each bucket can never go below zero, and the buckets add up to the initial stock, so the total cannot oversell. The cost is extra work when a bucket empties while others still hold units.
Once the service runs on more than one instance, the in-memory counter stops being the source of truth. The guard moves into the shared store, and the questions in a flash sale that oversold while the ledger never went negative take over.
Optimistic or pessimistic is a question about the size of the critical section. The proof that nothing oversold is an argument about each unit being taken once, backed by a stress test you have watched fail against the broken version.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).