Java Concurrency Interview Questions
These questions focus on practical Java concurrency: race conditions, memory visibility, locks, thread pools, concurrent collections, cancellation, virtual threads, and production debugging.
1. What is a race condition?
A race condition happens when correctness depends on the timing of multiple threads accessing shared state. The classic example is count++, which reads, increments, and writes as separate steps. Two threads can read the same value and both write back the same incremented result. Fix it by removing shared mutable state, protecting it with a lock, using an atomic operation, or redesigning the workflow around message passing or queues.
class UnsafeCounter {
private int count;
void increment() {
count++; // read, add, write: not atomic
}
int value() {
return count;
}
} class SafeCounter {
private final AtomicInteger count = new AtomicInteger();
void increment() {
count.incrementAndGet(); // atomic update
}
int value() {
return count.get();
}
} 2. What does it mean for code to be thread-safe?
Thread-safe code behaves correctly when accessed by multiple threads at the same time. That means it protects invariants, prevents lost updates, and gives threads a consistent view of memory. Thread safety can come from immutability, confinement to one thread, synchronized access, concurrent data structures, atomic variables, or higher-level coordination primitives. A senior answer should explain the chosen mechanism and the invariant it protects.
3. What problems does synchronized solve?
synchronized provides mutual exclusion and memory visibility. Only one thread can hold a given monitor lock at a time, so it protects critical sections from concurrent mutation. It also creates happens-before relationships: writes made before releasing a monitor become visible to a later thread that acquires the same monitor. Use it when the protected state is small, lock ownership is simple, and block-structured locking is enough.
4. What is the difference between volatile and synchronized?
volatile gives visibility and ordering for a single variable, but it does not provide mutual exclusion. It is good for state flags, configuration references, or safely publishing a replaced immutable object. synchronized gives both visibility and exclusive access, so it can protect compound actions and multi-field invariants. volatile int count does not make count++ atomic; use AtomicInteger or a lock.
class Worker implements Runnable {
private volatile boolean running = true;
public void run() {
while (running) {
doWork();
}
}
void stop() {
running = false; // visible to the worker thread
}
} class BrokenCounter {
private volatile int count;
void increment() {
count++; // still not atomic
}
} 5. What is the Java Memory Model?
The Java Memory Model defines when writes by one thread are guaranteed to be visible to reads by another thread. The central concept is happens-before. Without a happens-before relationship, a thread may see stale data or observe operations in surprising orders. Locks, volatile reads/writes, thread start/join, concurrent collections, futures, and synchronizers can all create memory consistency guarantees.
6. What is safe publication?
Safe publication means making an object visible to other threads only after it is fully constructed and in a way that guarantees visibility of its state. Common techniques include publishing through a final field, a volatile field, a properly locked block, a thread-safe collection, or static initialization. Unsafe publication can expose partially initialized objects or stale field values, even when the constructor looks correct.
class ServiceHolder {
private final Config config;
ServiceHolder(Config config) {
this.config = config;
}
Config config() {
return config; // safely published through final field
}
} class ConfigRegistry {
private volatile Config current;
void reload(Config newConfig) {
current = newConfig; // readers see the fully assigned reference
}
Config current() {
return current;
}
} 7. How do atomic classes work, and when should you use them?
Atomic classes such as AtomicInteger and AtomicReference provide lock-free, thread-safe operations on single variables, often using compare-and-set. They are good for counters, state transitions, and simple references. They are not a replacement for locks when multiple variables must change together under one invariant. For high-contention counters, consider LongAdder when exact immediate reads are less important than scalable updates.
AtomicReference<State> state = new AtomicReference<>(State.NEW);
boolean start() {
return state.compareAndSet(State.NEW, State.RUNNING);
} class RequestMetrics {
private final LongAdder totalRequests = new LongAdder();
void recordRequest() {
totalRequests.increment();
}
long total() {
return totalRequests.sum();
}
} 8. When would you use ReentrantLock instead of synchronized?
Use ReentrantLock when you need features that intrinsic locks do not provide: timed lock acquisition, interruptible lock acquisition, fairness options, multiple Condition objects, or non-block-structured locking. For simple critical sections, synchronized is usually clearer. Whichever you choose, keep lock scopes small and always release explicit locks in finally.
class InventoryCounter {
private int stock;
synchronized void add(int amount) {
stock += amount;
}
synchronized boolean reserveOne() {
if (stock == 0) {
return false;
}
stock--;
return true;
}
} class InventoryCounter {
private final ReentrantLock lock = new ReentrantLock();
private int stock;
boolean reserveOne(long timeout, TimeUnit unit) throws InterruptedException {
if (!lock.tryLock(timeout, unit)) {
return false;
}
try {
if (stock == 0) {
return false;
}
stock--;
return true;
} finally {
lock.unlock();
}
}
} class BoundedCounter {
private final Lock lock = new ReentrantLock();
private final Condition belowLimit = lock.newCondition();
private int value;
void increment() throws InterruptedException {
lock.lock();
try {
while (value == 10) {
belowLimit.await();
}
value++;
} finally {
lock.unlock();
}
}
void reset() {
lock.lock();
try {
value = 0;
belowLimit.signalAll();
} finally {
lock.unlock();
}
}
} Use synchronized for straightforward monitor-protected state. Use ReentrantLock when the caller needs more control, such as giving up after a timeout instead of blocking forever. A Condition is the explicit-lock version of a wait set: the thread calls await() while holding the lock, releases the lock while waiting, and resumes after another thread changes the guarded state and calls signal() or signalAll().
9. How do deadlock, livelock, and starvation differ?
Deadlock occurs when threads wait forever for locks held by each other. Livelock occurs when threads keep reacting to each other but make no progress. Starvation occurs when a thread rarely or never gets access to a resource because other threads keep winning. Prevent deadlock with consistent lock ordering, timeouts, smaller critical sections, and avoiding callbacks while holding locks.
void transfer(Account from, Account to, Money amount) {
synchronized (from) {
synchronized (to) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
// Thread A: transfer(a, b, amount)
// Thread B: transfer(b, a, amount)
// Each thread can hold one lock while waiting for the other. void transfer(Account from, Account to, Money amount) {
Account first = from.id() < to.id() ? from : to;
Account second = from.id() < to.id() ? to : from;
synchronized (first) {
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
} 10. How would you design a producer-consumer workflow in Java?
Use a BlockingQueue between producers and consumers. Producers put work into the queue; consumers take work and process it. A bounded queue provides backpressure so producers cannot overwhelm memory or downstream systems. This is usually safer than hand-written wait and notify logic because the queue already handles coordination, memory visibility, and blocking behavior.
11. When should you use wait and notify?
Use them rarely, mainly when implementing a low-level coordination primitive. They require the caller to hold the object’s monitor, they can wake spuriously, and the condition must always be checked in a loop. In application code, prefer BlockingQueue, Semaphore, CountDownLatch, or CompletableFuture because they express intent more clearly and are harder to misuse.
class OneSlotBuffer<T> {
private T value;
private boolean hasValue;
synchronized void put(T newValue) throws InterruptedException {
while (hasValue) {
wait();
}
value = newValue;
hasValue = true;
notifyAll();
}
synchronized T take() throws InterruptedException {
while (!hasValue) {
wait();
}
T result = value;
value = null;
hasValue = false;
notifyAll();
return result;
}
} BlockingQueue<Job> queue = new ArrayBlockingQueue<>(1_000);
void producer(Job job) throws InterruptedException {
queue.put(job); // blocks when the queue is full
}
void consumer() throws InterruptedException {
while (!Thread.currentThread().isInterrupted()) {
Job job = queue.take(); // blocks when the queue is empty
process(job);
}
} class PaymentClient {
private final Semaphore permits = new Semaphore(50);
PaymentResult authorize(PaymentRequest request) throws InterruptedException {
if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new RejectedExecutionException("too many payment requests");
}
try {
return callPaymentGateway(request);
} finally {
permits.release();
}
}
} CountDownLatch ready = new CountDownLatch(3);
for (Service service : services) {
executor.submit(() -> {
service.warmUp();
ready.countDown();
});
}
if (!ready.await(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("services did not warm up in time");
}
startAcceptingTraffic(); CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> userClient.getUser(userId), ioPool);
CompletableFuture<List<Order>> ordersFuture =
CompletableFuture.supplyAsync(() -> orderClient.getOrders(userId), ioPool);
CompletableFuture<UserSummary> summaryFuture =
userFuture.thenCombine(ordersFuture, UserSummary::new)
.orTimeout(500, TimeUnit.MILLISECONDS);
UserSummary summary = summaryFuture.join(); The pattern is the same across all five examples: express the coordination rule directly. wait and notify can work, but they force you to manage the monitor, condition loop, and notifications yourself. The higher-level tools make the intended coordination model much clearer.
12. What is the role of ExecutorService?
ExecutorService decouples task submission from thread management. It manages queues, worker threads, lifecycle, and task results through Future. In production, prefer explicitly configured executors over global defaults so you can control pool size, queue bounds, rejection policy, thread names, and shutdown behavior. Always shut down executor services during application teardown.
ExecutorService executor = Executors.newFixedThreadPool(8);
try {
Future<Report> future = executor.submit(() -> buildReport(userId));
Report report = future.get(500, TimeUnit.MILLISECONDS);
publish(report);
} finally {
executor.shutdown();
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} 13. How do you choose a thread pool size?
Start by asking what kind of work the pool runs. For CPU-bound work, a good starting point is near the number of available CPU cores because extra runnable threads mostly compete for the same cores and add context-switching overhead. For blocking I/O work, the pool may need more threads because many of them spend time waiting on network, disk, or downstream services instead of using CPU.
The common sizing intuition is:
- CPU-bound pool: close to
Runtime.getRuntime().availableProcessors() - I/O-bound pool: larger than core count, based on expected wait time, latency target, and downstream capacity
- Mixed workload: split into separate pools so slow blocking calls do not starve CPU-heavy work
A useful back-of-the-envelope calculation is:
threads = cores * ((computeTime + waitTime) / computeTime)
For example, assume the service has 8 cores. Each task spends about 10 ms doing CPU work and 90 ms waiting on a database or HTTP call. The ratio is (10 + 90) / 10 = 10, so the estimate is 8 * 10 = 80 threads. That does not mean 80 is automatically correct; it means 80 is a reasonable starting point only if the downstream system can safely handle that much concurrency. If the database connection pool has 30 connections, the practical pool size may need to be closer to 30, or guarded by a separate semaphore/bulkhead.
The dangerous mistake is using an unbounded queue or unbounded cached pool and calling that “scalable.” It can hide overload temporarily, but eventually memory grows, latency explodes, and downstream services get hammered. A senior design should include bounded queues, timeouts, rejection policy, and metrics.
Tune using production signals: CPU utilization, queue depth, task wait time, task execution time, rejection count, downstream error rate, and p95/p99 latency. If the queue is always growing, increasing the thread count may only move the bottleneck somewhere else. If the downstream dependency can handle only 100 concurrent requests, your pool should respect that limit even if your service has more CPU available.
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuPool = new ThreadPoolExecutor(
cores, // corePoolSize: keep about one worker per CPU core
cores, // maximumPoolSize: do not add extra CPU-bound workers
0L, // keepAliveTime: unused here because core and max are equal
TimeUnit.MILLISECONDS, // time unit for keepAliveTime
new ArrayBlockingQueue<>(500), // bounded queue: cap waiting CPU tasks
new ThreadPoolExecutor.CallerRunsPolicy() // backpressure: caller runs when saturated
);
ExecutorService ioPool = new ThreadPoolExecutor(
32, // corePoolSize: keep enough workers for normal blocking I/O
64, // maximumPoolSize: allow temporary bursts while threads wait
30L, // keepAliveTime: retire extra burst workers after idle time
TimeUnit.SECONDS, // time unit for keepAliveTime
new ArrayBlockingQueue<>(1_000), // bounded queue: prevent unlimited memory growth
new ThreadPoolExecutor.AbortPolicy() // fail fast when the pool and queue are full
);
// CPU-bound work should not share a pool with slow blocking I/O.
cpuPool.submit(() -> calculateRecommendationScore(userId));
ioPool.submit(() -> paymentClient.authorize(paymentRequest)); In an interview, be ready to justify every knob: core size, max size, queue size, keep-alive time, and rejection policy. The 6th parameter is the RejectedExecutionHandler. It decides what happens when the pool is saturated: all worker threads are busy, the pool cannot grow further, and the queue is full. CallerRunsPolicy applies backpressure by making the submitting thread run the task, which slows producers down naturally. AbortPolicy fails fast with RejectedExecutionException, which is useful when you would rather reject work than hide overload and increase latency. The exact numbers are less important than showing that the pool has a capacity model and does not allow unlimited work to accumulate silently.
14. What is the difference between Future and CompletableFuture?
Future represents a pending result and supports blocking retrieval, cancellation, and completion status. CompletableFuture adds composition: chaining, combining, exception handling, and manually completing a result. It is useful for fan-out/fan-in workflows and asynchronous pipelines. Be careful with default executors, blocking inside async stages, exception propagation, and cancellation semantics.
Future<User> future = executor.submit(() -> userClient.getUser(userId));
// The caller blocks here until the result is ready.
User user = future.get(500, TimeUnit.MILLISECONDS); CompletableFuture<User> user =
CompletableFuture.supplyAsync(() -> userClient.getUser(userId), ioPool);
CompletableFuture<List<Order>> orders =
CompletableFuture.supplyAsync(() -> orderClient.getOrders(userId), ioPool);
CompletableFuture<UserSummary> summary =
user.thenCombine(orders, UserSummary::new); 15. How do concurrent collections differ from synchronized wrappers?
Synchronized wrappers usually protect a collection with one coarse lock. Concurrent collections are designed for multithreaded access and often allow better scalability. ConcurrentHashMap permits concurrent reads and high-concurrency updates; CopyOnWriteArrayList is good when reads greatly outnumber writes; blocking queues are useful for handoff and backpressure. Pick by access pattern, not by habit.
ConcurrentHashMap<String, UserSession> sessions = new ConcurrentHashMap<>();
UserSession sessionFor(String userId) {
return sessions.computeIfAbsent(userId, id -> loadSession(id));
} CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
void publish(Event event) {
for (Listener listener : listeners) {
listener.onEvent(event); // safe while other threads add listeners
}
} 16. How do you perform atomic compound updates on ConcurrentHashMap?
Use methods such as compute, computeIfAbsent, computeIfPresent, merge, and putIfAbsent. Avoid check-then-act sequences like if (!map.containsKey(k)) map.put(k, v) because another thread can change the map between the check and the write. Also remember that mapping functions should be fast and should not perform long blocking operations while the map is coordinating updates.
// Broken: another thread can add the key between containsKey and put.
if (!counts.containsKey(userId)) {
counts.put(userId, 0);
}
counts.put(userId, counts.get(userId) + 1); ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.merge(userId, 1, Integer::sum);
ConcurrentHashMap<String, UserProfile> profiles = new ConcurrentHashMap<>();
UserProfile profile =
profiles.computeIfAbsent(userId, id -> loadProfile(id)); 17. How should Java tasks handle cancellation?
Cancellation should be cooperative. Use interruption for blocking tasks, check cancellation flags for compute loops, and clean up resources in finally blocks. Do not swallow InterruptedException; either restore the interrupt status with Thread.currentThread().interrupt() or propagate the cancellation. For executor tasks, Future.cancel(true) requests interruption but cannot safely force arbitrary code to stop.
class ReportTask implements Runnable {
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
ReportChunk chunk = queue.take();
process(chunk);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // preserve cancellation signal
} finally {
cleanup();
}
}
} 18. What are ThreadLocal risks?
ThreadLocal gives each thread its own value, which is useful for request context, correlation IDs, or per-thread buffers. The risk is leakage in thread pools: a worker thread may keep a value after a request finishes and accidentally expose it to later work. Always clear thread-local values in finally, and be extra cautious with virtual threads or frameworks that manage request context for you.
class RequestContextFilter {
private static final ThreadLocal<String> requestId = new ThreadLocal<>();
void handle(Request request) {
requestId.set(request.id());
try {
process(request);
} finally {
requestId.remove(); // important for pooled worker threads
}
}
} 19. When should you use virtual threads?
Virtual threads are useful for high-throughput workloads with many concurrent tasks that spend most of their time blocked on I/O. They make thread-per-request code more scalable and easier to reason about. They are not meant to speed up CPU-bound work, and they should not be pooled as scarce resources. If you need to limit calls to a downstream dependency, use a Semaphore or other explicit concurrency limiter.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request request : requests) {
executor.submit(() -> handle(request));
}
} Semaphore paymentLimit = new Semaphore(50);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Payment payment : payments) {
executor.submit(() -> {
if (!paymentLimit.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new RejectedExecutionException("payment service is busy");
}
try {
paymentClient.authorize(payment);
} finally {
paymentLimit.release();
}
});
}
} 20. How do you debug concurrency issues in production?
Start with symptoms: high latency, CPU spikes, blocked threads, queue growth, error bursts, or throughput collapse. Capture thread dumps, executor metrics, lock contention, queue depth, GC logs, and request traces. Look for deadlocks, blocked I/O, unbounded queues, exhausted pools, hot locks, and tasks waiting on each other. For correctness bugs, add targeted logging around state transitions and reproduce with stress tests where possible.
Issue: Latency Spikes With Low CPU
If latency is high but CPU is low, threads are probably waiting rather than computing. Common causes include blocked downstream calls, database connection pool exhaustion, thread pool starvation, lock contention, or tasks waiting on other tasks in the same executor.
Look for:
- Many threads in
WAITING,TIMED_WAITING, or blocked socket reads in thread dumps - Growing executor queue depth
- Saturated database or HTTP client connection pools
- p95/p99 latency rising while request volume is steady
Possible fixes include adding timeouts, separating slow I/O work into its own executor, limiting concurrency to downstream services with semaphores or bulkheads, increasing connection pool size only when the downstream can handle it, and failing fast instead of letting work pile up.
Issue: High CPU With Poor Throughput
High CPU and low throughput usually means the service is doing too much compute, spinning, retrying, or constantly switching between threads. If threads are simply parked while waiting for a lock, CPU is usually not high because those threads are not actively running. CPU becomes expensive when many threads repeatedly contend for a hot lock or shared atomic variable, causing frequent wakeups, failed CAS retries, context switches, or cache-coherency overhead. It can also happen when retry loops spin aggressively instead of backing off.
Look for:
- Runnable thread count much higher than CPU core count
- Hot methods in CPU profiles
- Repeated lock acquisition, failed CAS retries, or spin loops in profiles/thread dumps
- Busy loops, aggressive polling, or retry loops without backoff
Possible fixes include reducing CPU-bound pool size, replacing coarse locks with finer-grained coordination, using concurrent structures appropriately, adding backoff to retry loops, caching expensive calculations, and moving expensive work off the request path.
Issue: Queue Depth Keeps Growing
Growing queue depth means producers are adding work faster than consumers can finish it. This is not only a thread pool problem; it is a capacity problem. Adding more worker threads helps only if the bottleneck is local thread availability. If the real bottleneck is the database, payment service, filesystem, or network, more threads can make the outage worse.
Look for:
- Executor queue size trending upward
- Task wait time increasing before execution starts
- Downstream latency or error rate increasing
- Memory pressure from queued tasks
Possible fixes include bounding the queue, applying backpressure, rejecting low-priority work, scaling workers horizontally, reducing per-task work, batching carefully, or adding a circuit breaker when downstream systems are unhealthy.
Issue: Deadlock or Threads Stuck Forever
Deadlock means two or more threads are waiting on resources held by each other. The JVM can often detect monitor deadlocks in thread dumps, but not every stuck-thread problem is a formal deadlock. Threads can also wait forever on futures, latches, blocking queues, or external calls with no timeout.
Look for:
- Thread dump sections reporting deadlock
- Threads blocked on the same locks
Future.get()orCompletableFuture.join()inside a task running on the same saturated executor- Missing timeouts around external calls
Possible fixes include enforcing a consistent lock acquisition order, keeping critical sections small, avoiding blocking waits inside bounded pools, using timeouts, and never calling unknown external code while holding a lock.
Issue: Data Corruption or Inconsistent State
Concurrency bugs are not always performance bugs. Sometimes the service is fast but wrong: duplicate processing, lost updates, invalid state transitions, or impossible combinations of fields. These usually come from unsafely shared mutable state, check-then-act races, missing transaction boundaries, or incorrect assumptions about concurrent collections.
Look for:
- Logs showing duplicate state transitions
- Counters that drift from expected totals
- Non-atomic check-then-write logic
- Mutable objects shared across requests
Possible fixes include making state immutable, using atomic operations, moving multi-field invariants under one lock or transaction, using database constraints for final correctness, and designing operations to be idempotent when retries or duplicate execution are possible.
Issue: Memory Growth Under Load
Memory growth under concurrent load often points to unbounded queues, retained futures, forgotten ThreadLocal values, caches without eviction, or request objects held by async callbacks longer than expected.
Look for:
- Heap dumps showing large queues, maps, futures, or request objects
- Thread-local values retained by pooled worker threads
- GC frequency increasing without memory returning to baseline
- Async pipelines that keep references alive after request completion
Possible fixes include bounding queues, clearing ThreadLocal values in finally, adding cache eviction, cancelling abandoned tasks, avoiding unnecessary object capture in lambdas, and making async lifecycle ownership explicit.
Practical Debugging Workflow
Start broad, then narrow:
- Identify the symptom: latency, CPU, memory, wrong results, or throughput collapse.
- Check executor metrics: active threads, pool size, queue depth, completed tasks, rejected tasks.
- Capture multiple thread dumps a few seconds apart so you can tell whether threads are stuck or just busy.
- Correlate application traces with downstream metrics.
- Inspect logs around state transitions for correctness bugs.
- Reproduce with a stress test once you have a hypothesis.
- Fix the capacity or correctness boundary, then add metrics so the same issue is visible earlier next time.