How to handle concurrency scenarios in LLD interviews
Navigate concurrency questions with confidence: identify shared mutable state, choose the right synchronization tool, and explain trade-offs to the interviewer.
You are building a BankAccount class in an LLD round. Deposit, withdraw, check balance. You write the methods, and then the interviewer asks: "What happens when two threads call withdraw() at the same time?" A bare answer such as "I'd add synchronized" names a possible tool, but it does not explain the race, the invariant, or the scope of the protection. A stronger answer makes those decisions explicit.
Concurrency shows up whenever operations can overlap on shared state: a seat can be reserved twice, a counter can lose an update, or two transfers can wait on each other. The goal is to reason about those interleavings, not to add locks by reflex.
This guide gives you a repeatable process: spot the risk, define the atomicity boundary, pick the simplest suitable tool, and explain the trade-off.
TL;DR
The purpose of this article is to turn a vague "make it thread-safe" request into a small, defensible design decision.
- 30-second idea: Find shared mutable state, state the invariant that must remain true, make the smallest operation that preserves it atomic, and name the cost of your mechanism.
- Mental model:
shared? β mutable? β invariant? β atomicity boundary β mechanism β failure/recovery. - Repeatable process: map the state and its owner, show one bad interleaving, choose immutability/confinement/locking/atomics or a concurrent collection, then trace success, timeout, interruption, and failure paths.
- Adapt to constraints: prefer simple locking for a small in-memory design; use atomics for a single-variable invariant, queues or semaphores for bounded resources, and a transactional or distributed coordination mechanism when state crosses process boundaries.
Why concurrency comes up in LLD interviews
Most LLD problems are implicitly multi-threaded. A parking lot has multiple entry gates. A movie ticket system has hundreds of users trying to book the same seat. A bank account receives deposits and withdrawals from multiple channels simultaneously.
Concurrency questions are useful for three reasons:
- It reveals runtime thinking. Anyone can draw a class diagram. Concurrency questions test whether you understand how objects behave when multiple threads touch them at the same time.
- It exposes design judgment. Adding
synchronizedeverywhere is easy; choosing the right granularity and explaining the cost of locking requires more reasoning. - It maps to real bugs. Race conditions, visibility errors, and deadlocks can violate business invariants even when each individual method looks reasonable.
When you draw a shared resource such as an account balance, seat map, or inventory count, make concurrent access an explicit assumption. That tells the reader which parts of the design need an atomicity boundary.
Identifying concurrency risks
Not every piece of code has a concurrency problem. The risk exists only when three conditions are true at the same time:
- Shared state exists (multiple threads can see the same variable)
- Mutable state exists (at least one thread can write to it)
- No synchronization protects the access
If any one of those is absent, the particular race described here may not apply. Immutable objects and thread-local variables avoid shared mutation. Read-only access is safe when the object is safely published and all reachable state is effectively immutable; a read-only method on a mutable object may still need protection.
The three classic risk patterns you will see in interviews:
Check-then-act
if (balance >= amount) { // Thread A checks
balance -= amount; // Thread A acts
}
// Thread B can check between A's check and A's act
Thread A reads balance as 100, checks that 80 is affordable, and before it subtracts, Thread B also reads 100 and approves its own 80. Both withdrawals succeed. Balance goes to -60. This is a common race condition in LLD interviews.
Read-modify-write
counter++; // Looks atomic, but it's actually: read β add 1 β write
Two threads read 5, both add 1, and both write 6, so one increment is lost. counter++ is not an atomic operation when the counter is shared; use synchronization or an appropriate atomic type.
Compound operations
if (!map.containsKey(key)) {
map.put(key, value);
}
The check and the insert are separate operations. Another thread can insert between them. This is why ConcurrentHashMap.putIfAbsent() exists as a single atomic operation.
Naming the exact pattern ("this is a check-then-act race condition") makes the failure concrete and gives you a precise reason for the fix.
Java concurrency toolkit
Java gives you a gradient of tools from heavy (full mutual exclusion) to light (lock-free atomics). Picking the right one is a trade-off between safety, performance, and complexity.
| Tool | What it does | When to use | Cost |
|---|---|---|---|
synchronized | Mutual exclusion on a monitor | Simple critical sections, low contention | Blocks waiting threads, no timeout |
ReentrantLock | Explicit lock with tryLock, timeout, fairness | Complex locking, need timeout or try-lock | Slightly more overhead, must unlock in finally |
ReadWriteLock | Shared reads, exclusive writes | Read-heavy workloads with meaningful read concurrency | Complexity of managing two lock types |
AtomicInteger / AtomicReference | Lock-free CAS operations | Single variable updates, counters | No blocking, but only single-variable atomicity |
ConcurrentHashMap | Thread-safe map with fine-grained locking | Shared lookup tables, caches | Slightly higher memory, weaker iteration guarantees |
volatile | Visibility guarantee, no atomicity | Flags, status indicators (boolean/reference) | No mutual exclusion, only prevents stale reads |
Semaphore | Controls concurrent access count | Connection pools, rate limiting | Permits, not mutual exclusion |
CountDownLatch | One-time barrier for N threads | Wait for initialization, fan-out-then-join | Single use, cannot be reset |
The rule of thumb: start with synchronized for simple cases, use ReentrantLock when you need explicit ordering, tryLock, or interruptible acquisition, use ReadWriteLock when concurrent reads are useful, and consider atomics only when the invariant fits a single variable.
Interview tip: name the tool and the reason
Never just say "I'd synchronize this." Say "I'd use a ReentrantLock here because we need tryLock for deadlock prevention" or "This is a single counter, so AtomicInteger with CAS is cheaper than a lock." The tool name plus the reason is what earns points.
Patterns for thread safety
There are four fundamental strategies for making code thread-safe. Every concurrency solution in Java maps to one of these.
1. Immutability
If the object cannot change after construction, its state does not need synchronization after safe publication. This is often the cheapest form of thread safety because readers do not coordinate on every access.
public record Money(long cents, String currency) {
// Records are immutable by default.
// No setter, no mutable field, no race condition.
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch");
return new Money(this.cents + other.cents, this.currency);
}
}
Value objects such as Money, Address, and DateRange are good candidates for Java records when their components are themselves immutable. Say it out loud: "Money is immutable, so it can be shared safely after construction." A record containing a mutable list still needs a defensive copy.
2. Thread confinement
If only one thread can ever access the data, there is no sharing and no risk. This is what happens when each thread has its own copy.
// ThreadLocal gives each thread its own SimpleDateFormat
private static final ThreadLocal<SimpleDateFormat> formatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
In an interview, thread confinement shows up when you have per-request state. "Each request gets its own Order object, assembled in a single thread, and never shared. No synchronization needed."
3. Locking (mutual exclusion)
When you truly need shared mutable state, locking is the standard approach. The idea: only one thread can enter the critical section at a time. Everyone else waits.
private final ReentrantLock lock = new ReentrantLock();
public void withdraw(long amount) {
lock.lock();
try {
if (balance < amount) throw new InsufficientFundsException();
balance -= amount;
transactionLog.add(new Transaction(amount, TransactionType.WITHDRAWAL));
} finally {
lock.unlock(); // Always in finally. Always.
}
}
The cost: threads wait in line. Under high contention, that wait time dominates your latency. The benefit: correctness is easy to reason about. One lock, one critical section, one guarantee.
4. Lock-free (Compare-And-Swap)
For single-variable updates, CAS avoids locking entirely. The thread reads the current value, computes the new value, then atomically swaps only if the value has not changed since the read. If it changed, retry.
private final AtomicLong balance = new AtomicLong(0);
public void deposit(long amount) {
balance.addAndGet(amount); // Atomic, lock-free, thread-safe
}
public boolean tryWithdraw(long amount) {
while (true) {
long current = balance.get();
if (current < amount) return false;
if (balance.compareAndSet(current, current - amount)) return true;
// CAS failed: another thread changed balance. Retry.
}
}
CAS can reduce blocking under low-to-moderate contention because a thread retries instead of waiting for a lock. Under very high contention, retries can waste CPU. In an interview, use atomics for counters and simple flags; use locks for multi-step operations involving several fields or collections.
Worked example: thread-safe BankAccount
This is the classic LLD concurrency example. The requirements: deposit, withdraw, transfer between accounts, and a transaction log. All operations must be thread-safe.
/**
* Immutable value object. Thread-safe by design.
* No locks needed because state never changes after construction.
*/
public record Money(long cents, String currency) {
public Money {
if (cents < 0) throw new IllegalArgumentException("Negative amount");
if (currency == null || currency.isBlank())
throw new IllegalArgumentException("Currency required");
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(this.cents + other.cents, this.currency);
}
public Money subtract(Money other) {
requireSameCurrency(other);
if (this.cents < other.cents)
throw new IllegalArgumentException("Insufficient funds");
return new Money(this.cents - other.cents, this.currency);
}
public boolean isGreaterThanOrEqual(Money other) {
requireSameCurrency(other);
return this.cents >= other.cents;
}
private void requireSameCurrency(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch");
}
}Notice the design decisions in this implementation:
MoneyandTransactionare immutable records. No synchronization needed for value objects. This is the first thing to say in an interview.BankAccountusesReentrantLock, notsynchronized. We choseReentrantLockfor two reasons: lock ordering in transfers andtryLockwith timeout.TransferServicelocks both accounts in UUID order. This is the standard deadlock prevention technique. Without consistent ordering, Thread 1 locking A-then-B and Thread 2 locking B-then-A will deadlock.tryLockwith timeout is the safety net. If something unexpected prevents lock acquisition, we fail with a clear message instead of hanging forever.getTransactions()returns a defensive copy. The caller gets an unmodifiable snapshot, not a live reference to the internal list.
Walk through transfer() step by step: "First I determine lock order by comparing UUIDs. Then I acquire both locks with tryLock and a timeout. Then I debit and credit inside the critical section. Finally, I release in reverse order using finally blocks." This makes the synchronization story easy to verify.
Deadlock prevention
A deadlock happens when two threads each hold a lock the other needs. Neither can proceed. The application hangs silently, which makes deadlocks among the hardest bugs to diagnose in production.
Three techniques prevent deadlocks:
1. Lock ordering
Always acquire locks in the same global order. In the bank transfer example, we order by UUID. It does not matter what the order is, only that it is consistent.
// Always lock lower UUID first
if (from.getId().compareTo(to.getId()) < 0) {
first = from; second = to;
} else {
first = to; second = from;
}
This eliminates the circular wait condition. Thread 1 transferring A to B and Thread 2 transferring B to A both lock A first, then B. No cycle, no deadlock.
2. Timeout with tryLock
Even with lock ordering, defensive code uses tryLock with a timeout. If the lock is not acquired within the timeout, the thread backs off and reports failure instead of hanging.
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
// Back off, retry later, or fail explicitly
return TransferResult.timeout("Lock acquisition timed out");
}
This is your safety net. Lock ordering prevents deadlocks in theory. Timeouts prevent infinite hangs in practice.
3. Minimize lock scope
Hold locks for the shortest time possible. Never do I/O, network calls, or expensive computation inside a lock. The longer you hold a lock, the higher the chance of contention and the worse your throughput.
// Bad: holding lock during I/O
lock.lock();
try {
balance -= amount;
emailService.sendNotification(owner); // Network call under lock!
} finally {
lock.unlock();
}
// Good: narrow the critical section
lock.lock();
try {
balance -= amount;
} finally {
lock.unlock();
}
emailService.sendNotification(owner); // Outside the lock
One common mistake is locking an entire method when only two lines inside it actually touch shared state. Smaller critical sections usually mean less contention, but only if the invariant is still protected as one unit.
Trade-offs and adapting to constraints
The right concurrency mechanism depends on where the state lives, what must change atomically, and what should happen when access is delayed.
- Small, single-process design: use
synchronizedor oneReentrantLockaround the invariant. The simpler choice is usually easier to review and maintain. - Single-variable invariant: use
AtomicInteger,AtomicLong, orAtomicReferencewhen a CAS loop can express the complete update. Do not split a multi-field invariant across unrelated atomics. - Read-heavy state: consider
ReadWriteLockorConcurrentHashMap, but check that the reads are long or frequent enough to justify the extra coordination. - Bounded resources: use
Semaphoreor aBlockingQueuewhen the requirement is "at most N active users" or "wait until an item is available," rather than forcing every caller through one lock. - High contention: reduce the shared hotspot with lock striping, partitioning, or a different ownership model. Measure before replacing clear locking with a harder-to-review CAS design.
- Multiple processes or machines: a JVM lock protects only one process. Use a database transaction, conditional write, distributed lock, or message/queue protocol according to the consistency requirement.
- Timeouts and interruption: define the failure policy. A caller might retry, return a temporary failure, or propagate interruption; do not silently convert every timeout into success.
Step-by-step method: explain the concurrency decision
Concurrency is easier to explain when the reasoning follows the same order as the design.
The ideal flow in an interview
- Identify the risk. "This
balancefield is shared mutable state accessed by multiple threads. That is a check-then-act race condition." - Propose the simplest safe solution. "I'll make
Moneyimmutable and protectBankAccount.withdraw()with aReentrantLock." - Explain why this tool specifically. "I chose
ReentrantLockoversynchronizedbecause transfers need lock ordering, andtryLockgives us a timeout for deadlock prevention." - Acknowledge the trade-off. "The cost is that threads block while waiting for the lock. Under low contention that is fine. If this becomes a bottleneck, we could shard accounts across multiple lock stripes."
| Interviewer asks | Strong answer |
|---|---|
| "Is this thread-safe?" | "No. withdraw has a check-then-act race on balance. Two threads can both read 100, both approve 80, and overdraw." |
| "How would you fix it?" | "Wrap the check-and-subtract in a ReentrantLock. The lock scope covers only the balance mutation, not the entire method." |
| "What about transfers?" | "Lock ordering by account ID prevents deadlock. tryLock with timeout is the safety net. Debit and credit happen inside the same critical section." |
| "Could you use AtomicLong instead?" | "For a single balance, yes. But withdraw checks balance and deducts, so I need atomicity across two operations. CAS works for the simple case but not for compound invariants." |
| "What if you need high throughput?" | "Lock striping: partition accounts into N stripes, each with its own lock. Reduces contention from O(accounts) to O(accounts/N)." |
Red flag: over-synchronizing
Saying "I'd make everything synchronized" skips the important design question: what actually needs protection? Scope locks to the minimum critical section. If something is immutable or thread-confined, say so; only coordinate access to the shared mutable state.
30-second and 5-minute explanations
30-second explanation
"I first look for shared mutable state and the invariant it must preserve. Then I identify the smallest compound operation that must be atomic, choose the simplest mechanism that provides that guarantee, and state the cost. For a bank transfer, both accounts are locked in a consistent order, the debit and credit happen while both locks are held, and timeout or interruption has an explicit failure path."
5-minute explanation
Start with a bad interleaving, such as two withdrawals both passing the same balance check. Explain why immutability and thread confinement remove coordination, then compare a monitor, ReentrantLock, read/write locks, atomics, and concurrent collections. Walk through the BankAccount transfer: define the non-negative-balance invariant, acquire accounts in ID order, keep the critical section free of I/O, release locks in finally, and return a clear result on timeout or interruption. Finish by saying how the design changes for high contention or for state shared across multiple processes.
Common mistakes
These are common mistakes that make a concurrency design harder to trust.
1. Over-synchronizing
Making every method synchronized because "it is safer" kills performance and shows you do not understand what actually needs protection. If getOwnerName() returns a final String, it does not need a lock.
2. Forgetting volatile
A boolean running flag shared between threads needs a visibility mechanism such as volatile or a lock. Without a happens-before relationship, the reader is not guaranteed to observe the writer's update promptly.
// Broken: reader thread may never see `false`
private boolean running = true;
// Fixed: volatile guarantees visibility across threads
private volatile boolean running = true;
volatile does not provide atomicity. It only guarantees visibility. Use it for flags and status indicators, not for counters or compound operations.
3. Wrong lock granularity
Locking at the wrong level: too coarse (one lock for the entire bank, all accounts blocked) or too fine (separate locks for balance and transaction log, now they can get out of sync).
The right granularity for a bank account is often one lock per account. Each account's state is independent, so per-account locking can allow unrelated accounts to proceed concurrently while keeping each account's invariant together.
4. Forgetting finally
If you use ReentrantLock without a try/finally block and an exception is thrown inside the critical section, the lock may never be released. Subsequent callers can remain blocked, so the release must be structurally guaranteed.
// ALWAYS this pattern:
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
5. Leaking internal mutable state
Returning this.transactions directly from a locked method defeats the purpose. The caller now has a live reference to the internal list. Another thread can modify the list after the lock is released.
// Broken: returns live reference
public List<Transaction> getTransactions() {
lock.lock();
try { return transactions; } finally { lock.unlock(); }
}
// Fixed: defensive copy + unmodifiable wrapper
public List<Transaction> getTransactions() {
lock.lock();
try {
return Collections.unmodifiableList(new ArrayList<>(transactions));
} finally {
lock.unlock();
}
}
Concurrency in common LLD problems
Here is where concurrency shows up in popular LLD interview problems, and what to say:
| Problem | Shared mutable state | Recommended approach |
|---|---|---|
| Parking Lot | Available spots count, slot map | ReentrantLock per floor or AtomicInteger for spot count |
| Movie Ticket Booking | Seat availability map | ReentrantLock for seat selection (check + reserve is compound) |
| In-Memory Cache | Cache entries map (read-heavy) | ReadWriteLock or ConcurrentHashMap |
| Rate Limiter | Request counters per client | AtomicInteger for token bucket, synchronized for sliding window |
| Connection Pool | Available connections list | Semaphore for max size + ReentrantLock for checkout/return |
| Job Scheduler | Job queue | PriorityBlockingQueue (thread-safe by design) |
| Producer-Consumer | Shared buffer | BlockingQueue implementation (handles sync internally) |
The pattern: identify the shared mutable state first, then pick the lightest tool that provides the required atomicity.
Test Your Understanding
Quick Recap
- Concurrency risk is highest when shared mutable state is accessed without a mechanism that protects its invariant. Immutability, confinement, or coordinated access can remove that particular race.
- The three classic race conditions in LLD interviews are check-then-act, read-modify-write, and compound operations on collections.
- Default to immutability first (Java records), then thread confinement, then locking, then lock-free. Simpler tools first.
- Use
ReentrantLockoversynchronizedwhen you need lock ordering, tryLock, or read-write separation. - Prevent deadlocks with consistent lock ordering (by ID) and
tryLockwith timeout as a safety net. - Scope locks to the minimum critical section. Never do I/O or network calls inside a lock.
- In interviews, name the specific race condition, name the specific tool, and explain the trade-off. That three-part answer makes the design decision easy to evaluate.
Related Concepts
- OOD Interview Approach - The overall framework for structuring your LLD round. Concurrency is one dimension of the design you produce, not the entire answer.
- Thread Pool Pattern - Manages a pool of worker threads for task execution. Understanding thread pools helps you reason about contention and throughput in your concurrency design.
- Producer-Consumer Pattern - Decouples producers from consumers using a blocking queue. This pattern is the standard solution when threads need to communicate through shared data structures.
Related Articles
Understand processes, threads, tasks, shared state, and Java concurrency before choosing synchronization tools in low-level design.
A step-by-step framework for object-oriented design interviews, from clarifying requirements to implementing clean, extensible code in a bounded session.