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, the interviewer nods, and then says: "What happens when two threads call withdraw() at the same time?" If your answer is "I'd add synchronized," you passed the minimum bar. If your answer explains why it breaks, what tool fits this specific case, and what trade-offs you are accepting, you just separated yourself from 90% of candidates.
Concurrency is not a niche topic. Every LLD system that handles requests from multiple users is a multi-threaded system. Interviewers use it to test whether you understand what actually happens at runtime, not just what the class diagram looks like.
This guide gives you a repeatable process: spot the risk, pick the right tool, explain the trade-off.
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.
Interviewers bring up concurrency 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 separates mid-level from senior. A mid-level candidate adds
synchronizedeverywhere. A senior candidate explains the cost of locking, proposes the right granularity, and considers lock-free alternatives. - It catches real bugs. The most expensive bugs in production are race conditions. If you can name one during an interview, the interviewer trusts you to find them in real code.
For your interview: when you draw any shared resource (account balance, seat map, inventory count), say out loud that concurrent access is a risk. Do not wait for the interviewer to ask. Raising it proactively is a strong signal.
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, you are safe. Immutable objects have no concurrency risk. Thread-local variables have no concurrency risk. Read-only access to shared state has no concurrency risk.
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 the most 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, both write 6. You lost an increment. This is why counter++ is never thread-safe without synchronization.
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.
I find that naming the exact pattern during an interview ("this is a check-then-act race condition") earns immediate credibility. Interviewers hear that you have debugged these in real systems, not just read about them.
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 (90%+ reads) | 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, move to ReentrantLock when you need tryLock or read-write separation, and consider atomics only when you are protecting 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, no synchronization is needed. Period. This is the cheapest form of thread safety because there is zero runtime cost.
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);
}
}
My recommendation: any value object in your LLD design (Money, Address, DateRange) should be a Java record. Say it out loud: "Money is immutable, so it is inherently thread-safe." Interviewers love hearing that because it shows you default to the simplest solution first.
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 is faster than locking under low-to-moderate contention because no thread ever blocks. Under very high contention, CAS spins and retries, which can waste CPU. In an interview, use atomics for counters and simple flags. Use locks for multi-step operations involving several fields.
Implementation: 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.
For your interview: 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." That walkthrough alone demonstrates senior-level concurrency thinking.
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
The mistake I see most often: candidates lock an entire method when only two lines inside it actually touch shared state. Smaller critical sections mean less contention and better throughput.
What interviewers want to hear
Concurrency is a signal-rich topic. Interviewers are not looking for memorized API names. They want to hear a reasoning process.
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" is a red flag. Interviewers hear "I do not understand the cost of locking." Always scope your locks to the minimum critical section. If something is immutable, say so. If something is thread-local, say so. Only lock what needs locking.
Common mistakes
These are the mistakes that cost candidates the most points. I have seen each one multiple times in mock interviews.
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 will not work without volatile. Without it, the JVM can cache the value in a CPU register, and the writing thread's update is invisible to the reading thread.
// 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: one lock per account. Each account's state is independent, so locking per-account gives maximum concurrency without correctness risk.
4. Forgetting finally
If you use ReentrantLock without a try/finally block and an exception is thrown inside the critical section, the lock is never released. Every other thread waiting on that lock hangs forever. This is an instant production outage.
// 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 requires three things simultaneously: shared state, mutable state, and no synchronization. Remove any one, and the risk disappears.
- 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 signals senior-level thinking.
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.