November 2, 2026
Java Concurrency in Low-Level Design Interviews: The Bugs You Need to Know Before Choosing a Lock
A ticket-booking API passes every test in development. Then two users book the same seat at almost the same time, and both receive…

By The Turtle Code
14 min read
A ticket-booking API passes every test in development. Then two users book the same seat at almost the same time, and both receive confirmation.
A background worker keeps checking an empty queue and consumes an entire CPU core. Another service accepts jobs faster than its workers can process them, eventually running out of memory. Somewhere else, requests are waiting for database connections that never seem to become available.
Different symptoms. One underlying problem: the application was designed as if its operations happened one at a time.
In a real backend, several requests can execute simultaneously. Threads share memory, compete for resources, and depend on one another to finish work. An operation that looks correct in a single-threaded test can fail when its individual steps interleave with another operation.
This is why concurrency deserves attention during low-level design interviews.
You might be asked to design a ticket-booking system, a parking lot, a rate limiter, a job scheduler, or a database connection pool. The domain changes, but many of the underlying problems remain the same.
A useful way to approach them is to ask three questions:
- Correctness: Can concurrent operations leave the system in an invalid state?
- Coordination: How do threads communicate and wait for work?
- Scarcity: How do we control access to resources that cannot be used by everyone simultaneously?
These questions help you choose the right mechanism instead of reaching for synchronized, AtomicInteger, or Semaphore by default.
Correctness: Two Threads, One Piece of State
Consider a ticket-booking service with a simple requirement: a seat can be booked by only one customer.
The initial implementation might look like this:
class Seat {
private boolean booked;
public boolean book() {
if (booked) {
return false;
}
booked = true;
return true;
}
}class Seat {
private boolean booked;
public boolean book() {
if (booked) {
return false;
}
booked = true;
return true;
}
}Nothing looks particularly complicated. If the seat is available, book it. Otherwise, reject the request.
The problem appears when two threads execute book() concurrently.
Thread T1 checks booked and sees false. Before it updates the field, thread T2 checks the same field and also sees false. Both proceed and return success.
The seat ends up marked as booked, but two customers may have been told that they own it.
This is a check-then-act race condition. The decision depends on a check and a subsequent update being treated as one indivisible operation, but the code does not guarantee that.
The same pattern appears in inventory management, coupon redemption, account withdrawals, and job processing.
Protect the invariant, not just the line of code
In Java, a straightforward fix is to synchronize the complete operation:
class Seat {
private boolean booked;
public synchronized boolean book() {
if (booked) {
return false;
}
booked = true;
return true;
}
}class Seat {
private boolean booked;
public synchronized boolean book() {
if (booked) {
return false;
}
booked = true;
return true;
}
}When multiple threads call this method on the same object, only one can execute the synchronized method at a time. The first thread books the seat. The next thread enters afterward and sees that the seat is already booked.
Notice what needs protection: not merely the assignment to booked, but the entire decision that determines whether the assignment is allowed.
This is an important distinction in interviews. Adding synchronization to the wrong part of an operation does not necessarily fix the race.
There is also a boundary to this solution. A Java monitor protects an object inside one JVM. If several application instances can book the same seat, synchronizing one in-memory object does not coordinate those instances. The shared database must enforce the booking invariant through an appropriate transaction, locking strategy, or uniqueness constraint.
The correct synchronization boundary depends on where the shared state actually lives.
Read-Modify-Write: Why counter++ Can Lose Updates
Now consider a backend metric that tracks successful requests.
class Metrics {
private int successfulRequests;
public void recordSuccess() {
successfulRequests++;
}
}class Metrics {
private int successfulRequests;
public void recordSuccess() {
successfulRequests++;
}
}Suppose the counter starts at 10 and two threads increment it simultaneously.
Both threads can read 10, calculate 11, and write 11. The final result is 11 instead of 12.
The increment is a read-modify-write operation. Conceptually, it consists of reading the existing value, calculating a new value, and storing the result. Without appropriate synchronization, another thread can interfere between those steps.
For a simple counter, Java provides AtomicInteger:
private final AtomicInteger successfulRequests =
new AtomicInteger();
public void recordSuccess() {
successfulRequests.incrementAndGet();
}private final AtomicInteger successfulRequests =
new AtomicInteger();
public void recordSuccess() {
successfulRequests.incrementAndGet();
}The increment operation is atomic, so concurrent increments do not overwrite one another.
What makes an atomic variable different?
Many atomic operations rely on compare-and-set, commonly abbreviated as CAS.
CAS takes an expected value and a replacement value. It updates the value only if the current value still matches the expectation. If another thread has changed it, the operation fails, allowing the caller to retry.
For example, imagine a thread reads a counter value of 10 and wants to change it to 11. If another thread changes the counter first, the expected value no longer matches. The first thread cannot blindly overwrite the newer value.
This approach allows many simple operations to be implemented without a conventional exclusive lock around the entire operation.
However, atomic variables are not magic replacements for synchronization. They solve specific atomic state-update problems. They do not automatically make a sequence of operations across multiple fields transactional.
Consider an account with a balance and a cumulative debit total:
class Account {
private long balance;
private long totalDebited;
public synchronized boolean debit(long amount) {
if (amount <= 0 || balance < amount) {
return false;
}
balance -= amount;
totalDebited += amount;
return true;
}
}class Account {
private long balance;
private long totalDebited;
public synchronized boolean debit(long amount) {
if (amount <= 0 || balance < amount) {
return false;
}
balance -= amount;
totalDebited += amount;
return true;
}
}Both fields must change together according to the operation's rules. Replacing them with two independent atomic variables would not guarantee that the pair remains consistent at every observation point.
A lock is appropriate when the invariant spans several operations or fields.
For highly contended metrics, LongAdder may improve throughput by distributing updates across internal cells. However, its sum() is not an atomic snapshot of all those cells, so it is unsuitable when an exact value must be used as part of a correctness decision.
The practical rule is simple:
- Use atomic variables for independent atomic updates.
- Use locks when an operation must preserve a relationship between multiple pieces of state.
- Use database transactions and constraints when the invariant spans persisted data or multiple application instances.
Visibility: The State Changed, but Did Another Thread See It?
Not every concurrency bug involves two threads writing the same value.
Consider a worker with a shutdown flag:
class Worker {
private boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
processNextTask();
}
}
private void processNextTask() {
// Process available work.
}
}class Worker {
private boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
processNextTask();
}
}
private void processNextTask() {
// Process available work.
}
}One thread executes run(), while another calls stop().
Without appropriate synchronization, the Java Memory Model does not guarantee that the worker will observe the updated flag as intended. A program must establish the required visibility and ordering guarantees rather than assume that one thread's write will immediately become visible to another.
For a simple shutdown flag, volatile may be sufficient:
private volatile boolean running = true;private volatile boolean running = true;A volatile write happens-before a subsequent read that observes that write. This provides the relevant visibility and ordering guarantees for this use case.
But volatile does not make a compound operation atomic.
For example, this remains unsafe:
private volatile int counter;
public void increment() {
counter++;
}private volatile int counter;
public void increment() {
counter++;
}The increment still involves reading and updating the value. Two threads can lose updates.
It helps to distinguish three concepts:
- Atomicity: An operation cannot be interleaved in a way that violates its required indivisibility.
- Visibility: One thread can reliably observe another thread's changes under the applicable synchronization guarantees.
- Ordering: Operations are observed in an order consistent with the Java Memory Model.
Locks, volatile fields, and atomic classes provide different guarantees. Choosing between them requires knowing which guarantee the operation needs.
Coordination: What Should a Worker Do When There Is No Work?
Correctness is about protecting state. Coordination is about getting threads to cooperate efficiently.
Imagine an image-processing service. API threads validate incoming requests and submit tasks, while a group of background workers processes them.
The flow looks like this:
API threads → task queue → worker threads
When a worker finishes its current task, it needs to obtain the next one. What happens if the queue is empty?
There are several possible approaches, but not all are equally suitable for a backend service.
Busy spinning wastes CPU
while (true) {
if (!queue.isEmpty()) {
Task task = queue.remove();
process(task);
}
}while (true) {
if (!queue.isEmpty()) {
Task task = queue.remove();
process(task);
}
}The worker repeatedly checks for work. When the queue is empty, it still consumes CPU cycles despite having nothing useful to do.
Busy spinning can be useful in specialized, carefully measured low-latency systems. For ordinary background processing, it is usually an unnecessary expense.
Sleeping introduces polling delay
A common alternative is to check the queue and sleep when it is empty:
while (true) {
Task task = queue.poll();
if (task == null) {
Thread.sleep(100);
continue;
}
process(task);
}while (true) {
Task task = queue.poll();
if (task == null) {
Thread.sleep(100);
continue;
}
process(task);
}This reduces CPU usage, but it introduces a delay between checks.
Suppose the worker checks the queue, finds it empty, and goes to sleep for 100 milliseconds. A task arrives one millisecond later. The worker might not notice it until the sleep finishes.
Shortening the interval reduces potential delay but increases polling frequency. Increasing the interval reduces polling overhead but can make tasks wait longer.
A blocking queue avoids the need to choose a polling interval.
Use a blocking queue
Java's BlockingQueue provides operations designed for producer-consumer coordination.
BlockingQueue<Task> queue =
new ArrayBlockingQueue<>(500);BlockingQueue<Task> queue =
new ArrayBlockingQueue<>(500);A worker can wait for the next task:
Task task = queue.take();
process(task);Task task = queue.take();
process(task);If the queue is empty, take() blocks until an element becomes available or the waiting thread is interrupted.
A basic worker loop can handle interruption like this:
while (!Thread.currentThread().isInterrupted()) {
try {
Task task = queue.take();
process(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}while (!Thread.currentThread().isInterrupted()) {
try {
Task task = queue.take();
process(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}The interruption handling is important. Blocking operations can be interrupted, and a worker needs a deliberate shutdown policy rather than silently ignoring the signal.
Production code also needs a policy for task failures, retries, logging, and graceful shutdown. A blocking queue handles the handoff; it does not automatically provide durable job execution or exactly-once processing.
Backpressure: What Happens When Producers Are Faster Than Consumers?
Using a queue solves the empty-queue problem, but it creates another question: what happens when the queue is full?
Imagine an API that accepts 5,000 image-processing requests per second while the workers can process only 1,000 per second.
If the system keeps accepting tasks without limiting the queue, the backlog grows continuously. Each queued task consumes memory. Eventually, the service may experience long delays, garbage-collection pressure, or an out-of-memory failure.
A queue does not increase processing capacity. It only holds work waiting to be processed.
This is where backpressure matters: the system needs a way to prevent incoming work from overwhelming downstream capacity.
A bounded queue places a limit on the amount of work waiting in memory:
BlockingQueue<Task> queue =
new ArrayBlockingQueue<>(500);BlockingQueue<Task> queue =
new ArrayBlockingQueue<>(500);With put(), a producer waits when the queue is full:
queue.put(task);queue.put(task);With offer(), the producer can attempt insertion without waiting indefinitely:
boolean accepted = queue.offer(task);
if (!accepted) {
// Reject, defer, or otherwise handle the task.
}boolean accepted = queue.offer(task);
if (!accepted) {
// Reject, defer, or otherwise handle the task.
}The correct behavior depends on the service's requirements.
For an internal pipeline, blocking the producer may be appropriate. For a latency-sensitive API, waiting too long inside a request thread can consume the entire request-thread pool. In that case, rejecting excess work, returning a retryable response, or applying a timeout may be safer.
A bounded queue forces the system to confront overload rather than allowing it to accumulate invisibly.
But bounded queues are not a complete overload-control strategy. You still need to decide what clients experience when capacity is exhausted, how retries are controlled, and whether work can safely be rejected or deferred.
A useful production question is not simply, "What is the queue capacity?"
It is, "What should the system do when demand exceeds the rate at which it can process work?"
Scarcity: Limiting Access to a Finite Resource
Now consider a service that calls an external API with a strict limit on concurrent requests.
If every incoming request calls that API immediately, the application might exceed the provider's concurrency limit, overload its own connection pool, or create too many expensive in-flight operations.
The problem is no longer just shared-state correctness or task handoff. It is controlling access to a limited resource.
A semaphore is a useful primitive for this purpose.
How a semaphore works
A semaphore maintains a number of permits. A thread must acquire a permit before entering the protected section. When it finishes, it releases the permit.
If the semaphore has three permits, at most three threads can hold those permits simultaneously.
In Java:
private final Semaphore permits = new Semaphore(3);
public Response callExternalService() throws Exception {
permits.acquire();
try {
return makeRequest();
} finally {
permits.release();
}
}private final Semaphore permits = new Semaphore(3);
public Response callExternalService() throws Exception {
permits.acquire();
try {
return makeRequest();
} finally {
permits.release();
}
}The try/finally block is essential. If makeRequest() throws an exception and the permit is never returned, the service gradually loses capacity. After enough such failures, other threads may wait indefinitely for a permit.
A more defensive implementation may also need a timeout or interruption policy:
public Response callExternalService() throws Exception {
if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new RejectedExecutionException(
"External service capacity exhausted"
);
}
try {
return makeRequest();
} finally {
permits.release();
}
}public Response callExternalService() throws Exception {
if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new RejectedExecutionException(
"External service capacity exhausted"
);
}
try {
return makeRequest();
} finally {
permits.release();
}
}This version waits up to 200 milliseconds to obtain a permit and then rejects the operation if capacity remains unavailable. The timeout is an example; a real value should come from the service's latency budget.
A semaphore limits concurrent access. It does not, by itself, enforce a requests-per-second quota. Those are different constraints. A rate limiter is generally the right tool when the requirement concerns the rate of requests over time.
Semaphore versus lock
These primitives can both make threads wait, but their purpose differs.
A mutex-style lock generally grants exclusive access to one critical section at a time. A semaphore with multiple permits allows a configured number of concurrent operations.
Use a lock when multiple threads must not modify a protected state concurrently. Use a semaphore when you want to cap concurrent use of a resource or operation.
For example:
- Protecting a shared in-memory account object: a lock may be appropriate.
- Allowing at most ten concurrent image conversions: a semaphore may be appropriate.
- Limiting concurrent calls to an external service: a semaphore may help.
- Enforcing a contractual API quota of 100 requests per minute: use a rate-limiting mechanism, potentially alongside a concurrency limit.
Neither primitive automatically guarantees fairness in every configuration. If fairness is a requirement, consider the available fairness options and measure the resulting behavior.
Database Connection Pools: Scarcity With Reusable Objects
A database connection pool is a practical example of resource scarcity.
Opening a new database connection for every request is expensive. Instead, applications maintain a pool of reusable connections. A request borrows a connection, performs database work, and returns the connection for reuse.
The number of active connections is limited because databases have finite capacity. Too many concurrent queries can exhaust server resources and degrade performance.
Conceptually, a pool has to answer three questions:
- Is a connection available?
- If not, should the caller wait, time out, or fail?
- When is a borrowed connection safe to return to the pool?
A queue can help manage available connections, but a real pool also needs lifecycle management, validation, timeouts, leak detection, and failure handling.
In Java applications, libraries such as HikariCP commonly manage JDBC connection pools. Application code should normally borrow connections through the configured DataSource rather than implement its own pool from scratch.
For example:
try (Connection connection = dataSource.getConnection();
PreparedStatement statement =
connection.prepareStatement(
"SELECT id, status FROM orders WHERE id = ?"
)) {
statement.setLong(1, orderId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
String status = resultSet.getString("status");
// Use the result.
}
}
}try (Connection connection = dataSource.getConnection();
PreparedStatement statement =
connection.prepareStatement(
"SELECT id, status FROM orders WHERE id = ?"
)) {
statement.setLong(1, orderId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
String status = resultSet.getString("status");
// Use the result.
}
}
}The try-with-resources blocks close the JDBC resources reliably. For a pooled connection, closing the logical connection typically returns it to the pool rather than physically closing the underlying database connection.
This is the same resource-management principle seen with semaphores: every successful acquisition needs a reliable release path.
One important distinction: a semaphore can limit how many operations enter a section of code, but it does not provide a complete connection pool. Connections are stateful objects that must be borrowed, validated, and returned correctly.
The Concurrency Problems That Basic Examples Often Miss
Knowing the main primitives is only part of the job. The difficult bugs frequently appear in the interaction between them.
Deadlock: Everyone is waiting for someone else
Imagine two threads and two locks.
Thread T1 acquires lock A and waits for lock B. Thread T2 acquires lock B and waits for lock A.
Neither thread can proceed.
This is a deadlock.
A common prevention technique is to establish a global lock order. If every operation acquires lock A before lock B, the circular wait in this example can be avoided.
Other strategies include reducing nested locking, using timed acquisition where appropriate, and avoiding calls to unknown or slow code while holding locks.
Timeouts can help a system escape some indefinite waits, but they do not automatically make a partially completed business operation safe. Recovery behavior must be designed explicitly.
Lock granularity: How much work is protected?
A coarse-grained lock protects a large section of work. It is simple to reason about, but it can force unrelated operations to wait for one another.
A fine-grained design uses smaller locks to allow more operations to proceed independently. It can improve concurrency, but increases the complexity of lock ordering and consistency.
Start with the smallest synchronization design that correctly protects the invariant. Optimize only when contention is measurable and the extra complexity is justified.
Starvation: A thread waits too long
Starvation occurs when a thread repeatedly fails to make progress because other threads keep getting the resource or execution time it needs.
Depending on the primitive and workload, fairness is not always guaranteed. Fair locks and fair semaphores can reduce some forms of unfairness, though they may lower throughput.
In production, look beyond whether threads eventually finish in a test. Measure waiting time, queue depth, lock contention, and timeout rates.
Thread-pool exhaustion: Waiting can consume capacity
Suppose all request threads are blocked waiting for a semaphore permit. The requests that could release the constrained resource may themselves need work from the same exhausted pool.
This can create a resource dependency cycle even without a classic two-lock deadlock.
Avoid blocking indefinitely in request paths. Use explicit timeouts, separate pools for distinct workloads where justified, and clear limits on concurrent work.
Exceptions and cancellation: Does every resource get released?
Resources are often acquired before work begins and released afterward. Exceptions, timeouts, and interruption can cause the release path to be skipped if the code is not structured carefully.
Use finally for explicit lock and semaphore release, and try-with-resources for resources that implement AutoCloseable.
Also decide what should happen when a task is cancelled while holding a resource. Correct cleanup is part of the concurrency design, not an afterthought.
Retry storms: Does failure make the system worse?
A bounded queue may reject work during overload. If every client immediately retries, the system can receive even more traffic while it is already struggling.
Retries need limits, backoff, and often jitter. For state-changing operations, they may also require idempotency keys or other duplicate-protection mechanisms.
Concurrency control prevents certain classes of overload and corruption; it does not replace sound failure-handling policies.
A Practical Decision Table for Java Interviews
When the interviewer gives you a scenario, identify the required guarantee before naming the API.
Requirement | Likely starting point | Question to ask
---------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------
Protect a shared mutable object | synchronized or ReentrantLock | What state must change together?
Increment one shared counter safely | AtomicInteger or LongAdder | Is an exact instantaneous value required?
Publish a simple state flag across threads | volatile | Is visibility enough, or is the operation compound?
Transfer work from producers to consumers | BlockingQueue | What happens when the queue is empty or full?
Bound concurrent access to an operation | Semaphore | Should excess callers wait, time out, or fail?
Reuse database connections | Connection pool | How are connections acquired, validated, and returned?
Protect data across multiple application instances | Database transaction, constraints, or distributed coordination | Where is the authoritative shared state?
Limit requests over time | Rate limiter | Is the constraint concurrency, throughput, or both?Requirement | Likely starting point | Question to ask
---------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------
Protect a shared mutable object | synchronized or ReentrantLock | What state must change together?
Increment one shared counter safely | AtomicInteger or LongAdder | Is an exact instantaneous value required?
Publish a simple state flag across threads | volatile | Is visibility enough, or is the operation compound?
Transfer work from producers to consumers | BlockingQueue | What happens when the queue is empty or full?
Bound concurrent access to an operation | Semaphore | Should excess callers wait, time out, or fail?
Reuse database connections | Connection pool | How are connections acquired, validated, and returned?
Protect data across multiple application instances | Database transaction, constraints, or distributed coordination | Where is the authoritative shared state?
Limit requests over time | Rate limiter | Is the constraint concurrency, throughput, or both?These are starting points, not mechanical rules. The correct design depends on the invariant, the failure mode, and the scope of the shared resource.
How to Explain Concurrency in a Low-Level Design Interview
When an interviewer asks you to design a concurrent component, resist the temptation to begin by listing Java classes.
Start by describing the failure you need to prevent.
For a ticket-booking system, state that two concurrent requests must never successfully reserve the same seat. Then identify the shared state, define the atomic operation, and explain where the invariant must be enforced.
For a background-job processor, explain how producers submit work, how consumers wait, what happens when capacity is exhausted, and how shutdown and failures are handled.
For an external-service limiter, define the concurrency limit, explain permit acquisition and release, and decide what happens when a request cannot acquire capacity within its deadline.
Finally, discuss the operational consequences: deadlocks, contention, starvation, retries, resource leaks, and pool exhaustion.
This approach demonstrates more than knowledge of Java APIs. It shows that you can reason about a system's behavior under concurrent load.
Final Thoughts
Concurrency becomes easier to reason about when you stop treating every problem as a reason to add a lock.
A race condition needs a correctness guarantee. A worker waiting for a task needs coordination. A database pool or external-service limit needs resource management. A production system may need several of these mechanisms working together.
The hard part is not remembering the name of AtomicInteger or knowing that a semaphore has permits. It is understanding which invariant must hold, where the state lives, what should happen under overload, and how the system recovers when an operation fails halfway through.
In an interview, explain the failure first, choose the primitive second, and describe the edge cases before declaring the design complete.
That is the difference between knowing concurrency APIs and designing a system that remains correct when many threads use them at once.
If you're preparing for backend or low-level design interviews, the next useful exercise is to apply this framework to a ticket-booking service: identify its shared state, define its invariants, and explain how the design behaves when two requests target the same seat.