Concurrency · LLD
Safely changing object state
A concurrent state transition must move from one valid status to another as one atomic step. Example: a ticket is ACTIVE. Two cashiers tap Pay at the same time. Both see ACTIVE. Both mark PAID. Both open the till. The car leaves once, but the money is collected twice.
The state machine on paper said ACTIVE → PAID. The code did a read, then a write, and two cashiers fit in the gap. The is the critical section, not the enum field.
The real question
Is the transition one step, or a read plus a write?
Machine on paper
Legal arrows, one winner per arrow
CREATED → ACTIVE → PAID → CLOSED ACTIVE → PAID only one cashier may win PAID → CLOSED only after money moved CLOSED → ACTIVE illegal even on a retry FREE → OCCUPIED same story as spot 101
Illegal transitions still matter when two threads arrive. CLOSED → ACTIVE is wrong on one cashier. ACTIVE → PAID twice is wrong with two. Design that refuse both.
Two cashiers, one arrow
- T0Ticket T-1 is ACTIVE. Till closed. Car still in 101.
- T1Cashier A reads ACTIVE. Decides to collect.
- T2Cashier B reads ACTIVE. Same decision.
- T3A writes PAID, opens till, takes twenty rupees.
- T4B writes PAID, opens till, takes twenty rupees. Car left once.
The board of ticket status looks PAID at the end. The lost update is the extra debit. Same shape as two tickets for spot 101. Here the shared fact is the status arrow.
The arrow is the critical section
Make the transition the operation. tryPay() succeeds only from ACTIVE to PAID. tryOccupy() succeeds only from FREE to OCCUPIED. The object returns boolean, and the loser does not pretend it won. is a good first Java sentence after that story, not before it.
class Ticket {
private final AtomicReference<TicketStatus> status =
new AtomicReference<>(TicketStatus.ACTIVE);
boolean tryPay() {
return status.compareAndSet(TicketStatus.ACTIVE, TicketStatus.PAID);
}
boolean tryClose() {
return status.compareAndSet(TicketStatus.PAID, TicketStatus.CLOSED);
}
}
// Cashier: if (!ticket.tryPay()) reject("already paid");Do not expose setStatus
Any caller can write CLOSED → ACTIVE. A retry webhook can resurrect a closed ticket. Commands like pay() and close() can refuse. A setter cannot. If the interviewer asks for “a status field,” give them an enum that only moves through methods.
void setStatus(TicketStatus next) { this.status = next; } // anyone may cheat
boolean tryPay() {
return status.compareAndSet(ACTIVE, PAID); // one atomic step
}Keep the rest of the work out of the arrow
Do not send SMS inside tryPay while you hold a lock. Claim the , then notify. If the notify fails, you have a paid ticket and a retryable — which is a better mess than two payments. on an already PAID ticket returns false; the till stays shut.
Inside the arrow
Expected state, next state, maybe a version bump. Nothing that calls the network, the printer, or another lock.
After the arrow wins
Open the till, enqueue SMS, print. If those fail, retry the side effect, not the pay. The ticket is already PAID.
Pessimistic twin
If several fields must move with the status — paidAt, cashierId, remaining balance — a lock around the whole if-then is honest. Still treat it as one command, not a setter.
synchronized (ticket) {
if (ticket.status() != TicketStatus.ACTIVE) return false;
ticket.markPaid(cashierId, Clock.systemUTC().instant());
return true;
}
// then, outside: till.open(); bus.enqueue(new PaidEvent(ticket.id()));Movie hold, then book
Seats often have two arrows: FREE → HELD, HELD → BOOKED. Status, holder, and expiry must move together. CAS-ing FREE → HELD and then writing the holder in a second field leaves a window where another thread sees HELD with no owner — and may book or release a hold it does not own. Put the whole seat in one immutable value and replace that value atomically.
record SeatState(SeatStatus status, HolderId owner, Instant expiresAt) {}
private final AtomicReference<SeatState> state =
new AtomicReference<>(new SeatState(FREE, null, null));
boolean tryHold(HolderId who, Instant expiresAt) {
for (;;) {
SeatState cur = state.get();
if (cur.status() != FREE) return false;
SeatState next = new SeatState(HELD, who, expiresAt);
if (state.compareAndSet(cur, next)) return true;
}
}
boolean tryBook(HolderId who) {
for (;;) {
SeatState cur = state.get();
if (cur.status() != HELD || !who.equals(cur.owner())) return false;
SeatState next = new SeatState(BOOKED, who, null);
if (state.compareAndSet(cur, next)) return true;
}
}Two counters both call tryHold: only one CAS wins. tryBook succeeds only when status is HELD and the owner matches — one coherent value, not a status CAS plus a separate holder write. The hold-then-book pair is still two arrows. Do not glue them with a lock that also charges the card. Claim HELD, release, charge, then tryBook. If charge fails, tryRelease back to FREE. If tryBook fails, you charged a seat you do not own — that is why owner lives on the state.
Illegal on one thread, still illegal with two
CLOSED → ACTIVE is not a concurrency bug. It is a domain bug that concurrency makes louder. Write the legal arrows first, as commands. Then make each command one step. A concurrent mess on an illegal setter is two bugs stacked. The state-machines page is the paper diagram; this page is what happens when two cashiers share that diagram.
Common mistakes
Exposing setStatus
Any caller can write CLOSED → ACTIVE. Commands like pay() and close() can refuse. A setter cannot.
Guarding with a read, then writing
if (status == ACTIVE) status = PAID is two steps. Two cashiers both pass the if. Use compareAndSet or lock the whole if-then.
SMS inside the latch
Holding a lock across I/O invites deadlock and double-wait. Claim PAID, release, then talk to the world. Retry the send, not the pay.
Vending, elevator, ATM — same arrow
A vending machine IDLE → COLLECTING → DISPENSING: two coins arriving as two threads is unusual in a single kiosk, but a GUI plus a timeout thread is two actors on one state. tryStartVend must win once. An elevator cab MOVING → STOPPED: two requests to stop at 5 must not both open doors twice as two arrivals. An ATM session AUTHED → DISPENSING: a second withdraw command on the same session must lose. The nouns change. The rule does not: commands, one winner per arrow, side effects after.
If the object already has a State pattern, the transition methods on the state objects still need CAS or a lock. The pattern names the states. It does not make the arrow atomic.
Exercises
- Spot 101: FREE → OCCUPIED → FREE on exit. Which command is tryOccupy’s twin on the way out? Can two exits both succeed?
- A webhook retries pay() after a timeout. Ticket is already PAID. What should the second call return, and what should the till do?
- Why is CLOSED → ACTIVE illegal even if only one thread runs? What API shape prevents it?
- A timer thread expires HELD seats while a counter calls tryBook. Which CAS must the timer use so a late book cannot lose the seat and the money?
Write the legal arrows first. Then make each a command that returns boolean. Then make that command one step. Side effects after the win. That order is the round.
A State pattern without an atomic arrow is a named race. CAS without commands is an atomic setter. You want both: names that refuse, and a step that cannot split.
Read next
Where the arrow still splits
tryPay succeeds, then you write paidAt on a plain field without a handshake: another thread sees PAID and a null Instant. Glue payload and status, or write payload first under the same lock. A webhook that calls setStatus(PAID) because tryPay returned false “but we know it paid” reopens CLOSED tickets. Trust the command. If the provider says paid and the ticket is already PAID, that is idempotent success, not a setter.
Timeouts that auto-close HELD seats from a timer thread are a second actor on the arrow. tryRelease must CAS the whole SeatState from HELD → FREE only if the owner still matches. Otherwise a late book and a timeout both “win.”
Takeaway
The arrow is the critical section. Commands refuse illegal moves and lose cleanly when a second cashier arrives. CAS or a lock makes the arrow one step. Side effects live after the win. No setters on status.
Hold-then-book is two arrows on one atomic SeatState — status, owner, and expiry together. A timer that expires holds is a second actor on the same arrow. Glue payload with status in one CAS. Idempotent pay is a false from tryPay, not a second debit. Paper arrows first, then one step each.
A State pattern names the rooms. CAS or a lock is the latch on each door. You need both.