- Java concurrency interviews test four layers: thread mechanics, visibility and atomicity (the Java Memory Model), coordination tools (locks, executors, CompletableFuture) and liveness (deadlock, starvation).
- The single most important idea:
volatilefixes visibility, not atomicity.synchronizedand locks fix both. - Know exactly how
ThreadPoolExecutordecides between a new thread, the queue and rejection. It is asked constantly and answered wrong constantly. - Be ready to write producer-consumer two ways (wait/notify and
BlockingQueue) and to explain why the wait sits inside awhileloop. - For 2026 interviews, add virtual threads (Java 21) and the synchronized pinning fix in Java 24.
Concurrency is where Java interviews separate people who have read about threads from people who have debugged them. The questions below are grouped the way interviewers escalate: start with lifecycle basics, move to the memory model, then to the java.util.concurrent toolkit, and finish with coding. Each answer is written so you can say it out loud in about a minute, followed by the follow-up you should expect.
If your Java fundamentals are rusty, skim our core Java interview questions first. If the OS side (context switches, scheduling, mutex vs semaphore) is shaky, the operating system interview guide covers it.
What Java Concurrency Interviews Ask at Each Level
| Level | Typical questions | What they are checking |
|---|---|---|
| Fresher / campus | Thread vs process, ways to create a thread, start vs run, sleep vs wait, what is deadlock | Vocabulary and the basic lifecycle |
| 1-3 years (SDE-1) | volatile vs synchronized, ExecutorService, Callable/Future, ConcurrentHashMap, producer-consumer | Can you write correct thread-safe code at work |
| 3-6 years (SDE-2) | ThreadPoolExecutor internals, CompletableFuture chains, lock choice, pool sizing, debugging a deadlock from a thread dump | Production judgment: overload, timeouts, failures |
| Senior / staff | Happens-before, false sharing, lock-free structures, virtual threads migration, contention profiling | Depth and trade-off reasoning |
Thread Basics: Questions 1-10
Q1. What is the difference between a process and a thread?
A process is an independent program with its own address space, file handles and resources. A thread is a unit of execution inside a process; threads of the same process share the heap and open resources but each has its own stack, program counter and registers. That sharing makes threads cheap to create and fast to communicate, and it is exactly why they need synchronization. In Java, every thread you create runs inside one JVM process.
Follow-up: "What is shared and what is not?" Heap objects and static fields are shared. Local variables and method parameters live on each thread's stack and are thread-confined, unless the object they reference escapes.
Q2. What are the ways to create a thread in Java?
Extend Thread and override run(); implement Runnable and pass it to a Thread; implement Callable<V> and submit it to an ExecutorService to get a Future; or, since Java 21, start a virtual thread with Thread.ofVirtual().start(task) or Executors.newVirtualThreadPerTaskExecutor(). In real code you almost never create raw threads; you submit tasks to an executor.
Runnable task = () -> System.out.println("running in " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker-1");
t.start();
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(() -> 6 * 7); // Callable<Integer>
System.out.println(f.get()); // 42, blocks until done
pool.shutdown();
Follow-up: "Why prefer Runnable over extending Thread?" Java has single inheritance, Runnable separates the task from the execution mechanism, and the same Runnable can go to a thread pool.
Q3. What happens if you call run() instead of start()?
run() is an ordinary method call: it executes on the current thread and no new thread is created. start() asks the JVM to create a new OS-level thread (or schedule a virtual thread) which then calls run(). Calling start() twice on the same Thread object throws IllegalThreadStateException.
Q4. What are the states in a Java thread's lifecycle?
Thread.State has six values: NEW (created, not started), RUNNABLE (running or ready to run; Java does not distinguish), BLOCKED (waiting to enter a synchronized block held by another thread), WAITING (in wait(), join() or LockSupport.park() with no timeout), TIMED_WAITING (sleep(ms), wait(ms), join(ms)) and TERMINATED.
Trap: a thread waiting on a ReentrantLock shows as WAITING, not BLOCKED, because locks are built on LockSupport.park. BLOCKED is specific to intrinsic monitors. This detail matters when reading thread dumps.
Q5. What is a daemon thread?
A daemon thread is a background thread that does not keep the JVM alive. When only daemon threads remain, the JVM exits without running their finally blocks. GC and JIT compiler threads are daemons. Mark a thread with setDaemon(true) before calling start(). Never use daemon threads for work that must complete, like writing to a file or flushing a queue.
Q6. What is the difference between sleep() and wait()?
| Thread.sleep(ms) | obj.wait() | |
|---|---|---|
| Defined in | Thread (static) | Object |
| Lock required | No | Must hold obj's monitor, else IllegalMonitorStateException |
| Releases locks | No, keeps everything it holds | Releases obj's monitor only |
| Wakes up on | Timeout or interrupt | notify/notifyAll, interrupt, timeout, or spuriously |
| Purpose | Pause | Inter-thread coordination on a condition |
Q7. What does join() do?
t.join() makes the calling thread wait until t terminates. It also establishes a happens-before edge: everything t did is visible to the joining thread after join() returns. That is why you can safely read results written by a worker thread after joining it, without volatile.
Q8. How does thread interruption work?
Interruption is cooperative. t.interrupt() sets a flag on t. If t is blocked in sleep, wait, join or an interruptible blocking-queue call, that call throws InterruptedException and clears the flag. Otherwise the thread must poll Thread.currentThread().isInterrupted() itself. Nothing is forcibly killed; Thread.stop() is deprecated because it releases locks mid-update and corrupts state.
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
Job job = queue.take(); // throws if interrupted while waiting
process(job);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag
break; // and exit
}
}
}
Follow-up: "Why restore the flag?" Catching InterruptedException clears it. If you swallow it, code higher up the stack (like the executor that owns this thread) never learns a shutdown was requested.
Q9. What do yield() and thread priorities do?
Thread.yield() hints that the current thread is willing to give up the CPU; the scheduler may ignore it. Priorities (1 to 10) are also only hints mapped to OS priorities in platform-specific ways. Correct programs never depend on either. If an interviewer asks, say so explicitly: relying on priority for correctness is a bug.
Q10. What is a race condition? Give an example.
A race condition is when correctness depends on the relative timing of threads. The classic is check-then-act: if (!map.containsKey(k)) map.put(k, v); where two threads both see "absent" and both insert. Another is read-modify-write: count++. The fix is to make the compound action atomic, for example map.putIfAbsent(k, v) on a ConcurrentHashMap, or a lock around the whole sequence.
Synchronization and the Java Memory Model: Questions 11-20
Q11. What does synchronized actually guarantee?
Two things. Mutual exclusion: only one thread holds a given object's monitor at a time. Visibility: releasing a monitor happens-before every subsequent acquire of the same monitor, so writes made inside the block are visible to the next thread that enters a block on the same lock. Developers usually remember the first and forget the second, which is why "I only read it, so no lock needed" bugs appear.
Q12. Synchronized method vs synchronized block?
An instance synchronized method locks this; a static synchronized method locks the Class object. A block lets you choose the lock and narrow the critical section to just the shared-state access. Prefer blocks on a private final lock object: it keeps the critical section small and prevents outside code from locking on your object and interfering.
public class Inventory {
private final Object lock = new Object();
private final Map<String, Integer> stock = new HashMap<>();
public boolean reserve(String sku, int qty) {
validate(sku, qty); // no lock needed
synchronized (lock) { // only the shared state
int have = stock.getOrDefault(sku, 0);
if (have < qty) return false;
stock.put(sku, have - qty);
return true;
}
}
}
Q13. Can two threads run a static synchronized method and an instance synchronized method of the same class at once?
Yes. They use different locks: the Class object and the instance. This surprises people who think "synchronized" means "the class is locked". Two instance synchronized methods on the same object cannot run concurrently; on different objects they can.
Q14. What is reentrancy?
Intrinsic locks and ReentrantLock are reentrant: a thread that holds a lock can acquire it again without deadlocking itself. The JVM keeps a hold count and releases the lock when it drops to zero. This is what lets a synchronized method call another synchronized method on the same object, or a subclass's synchronized override call super.
Q15. What does volatile do?
A volatile field guarantees that every read sees the most recent write by any thread, and that reads and writes are not reordered across it: a write to a volatile happens-before every subsequent read of that variable. It does not provide mutual exclusion. Use it for a single variable written by one thread and read by others, such as a running flag or a published immutable configuration reference.
class Poller implements Runnable {
private volatile boolean running = true; // without volatile the loop may never exit
public void stop() { running = false; }
public void run() {
while (running) { poll(); }
}
}
Q16. Why is volatile count++ not thread-safe?
count++ is read, add, write. With two threads and count = 5, both can read 5 and both write 6: one increment is lost. Volatile makes each read and write visible but cannot make the three steps atomic. Use AtomicInteger.incrementAndGet(), which uses a CPU compare-and-swap loop, or LongAdder when many threads increment and you read rarely; it stripes the count across cells to cut contention.
Q17. What is happens-before?
Happens-before is the Java Memory Model's rule for visibility: if action A happens-before B, then A's effects are visible to B. The edges interviewers expect you to list: program order within one thread; monitor unlock before subsequent lock of the same monitor; volatile write before subsequent read of the same field; Thread.start() before any action in the started thread; all actions in a thread before another thread's successful join() on it; and transitivity. Concurrent collections and executors also document their own edges (for example, putting into a BlockingQueue happens-before the take that removes it).
Without a happens-before edge, the JIT and CPU are allowed to reorder and cache, so a reader may see stale or partially constructed state.
Q18. Explain double-checked locking and why volatile is needed.
public final class ConfigRegistry {
private static volatile ConfigRegistry instance;
private ConfigRegistry() { /* load config */ }
public static ConfigRegistry getInstance() {
ConfigRegistry local = instance; // one volatile read on the fast path
if (local == null) {
synchronized (ConfigRegistry.class) {
local = instance;
if (local == null) {
instance = local = new ConfigRegistry();
}
}
}
return local;
}
}
instance = new ConfigRegistry() is allocate, construct, assign. Without volatile, the assignment can become visible before the constructor's writes, so a second thread on the unsynchronized fast path sees a non-null but half-built object. Volatile forbids that reordering. The simpler alternative interviewers love is the holder idiom: a nested static class whose static field is initialized lazily and thread-safely by the class loader. An enum singleton is also safe and serialization-proof.
Q19. What is compare-and-swap (CAS) and how do atomic classes use it?
CAS is a CPU instruction that sets a memory location to a new value only if it still holds an expected value, atomically, and reports success. AtomicInteger.incrementAndGet() conceptually loops: read current, compute current + 1, CAS; retry if another thread changed it in between. No thread ever blocks, which is why this is called lock-free. Under heavy contention the retries waste CPU, which is the trade-off.
Q20. What is the ABA problem?
Thread 1 reads value A and gets paused. Thread 2 changes A to B and back to A. Thread 1's CAS succeeds because the value is A again, even though the structure changed underneath (in a lock-free stack, the node may have been popped and reused). Java's fix is AtomicStampedReference, which pairs the reference with a version stamp so the CAS fails if the stamp moved.
Locks and Conditions: Questions 21-28
Q21. ReentrantLock vs synchronized?
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Release | Automatic on block exit, even on exception | Manual: must unlock() in finally |
| Try without blocking | No | tryLock(), tryLock(timeout, unit) |
| Interruptible wait | No | lockInterruptibly() |
| Fairness | No guarantee | Optional fair mode |
| Condition queues | One (wait/notify) | Many via newCondition() |
| Thread dump visibility | BLOCKED with owner shown | WAITING (parked) with lock info |
Default to synchronized for simple critical sections. Reach for ReentrantLock when you need a timeout, interruptibility, or separate "not full" and "not empty" conditions.
Q22. How does tryLock help avoid deadlock?
If a thread needs two locks and cannot get the second within a timeout, it releases the first and retries after a randomized backoff. Since no thread waits forever while holding a lock, the "hold and wait" condition is broken.
boolean transfer(Account from, Account to, long amount) throws InterruptedException {
while (true) {
if (from.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (to.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (from.balance < amount) return false;
from.balance -= amount;
to.balance += amount;
return true;
} finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); }
}
Thread.sleep(ThreadLocalRandom.current().nextInt(1, 20)); // backoff, avoid livelock
}
}
Q23. What is a fair lock and why is it not the default?
A fair ReentrantLock(true) grants the lock roughly in arrival order, preventing starvation. It is slower because a releasing thread cannot let a just-arrived running thread "barge" in; every hand-off goes to a parked thread that must be woken, costing context switches. Unfair locks have much higher throughput, which is why they are the default.
Q24. When would you use a ReadWriteLock?
ReentrantReadWriteLock lets many readers hold the read lock together while writers get exclusive access. It helps only when reads are frequent, long enough to matter, and writes are rare, such as an in-memory configuration or routing table. For tiny critical sections the extra bookkeeping can make it slower than a plain lock, and writers can starve under constant reads in unfair mode. A read lock cannot be upgraded to a write lock (attempting it deadlocks); downgrading write to read is allowed.
Q25. What is StampedLock?
Added in Java 8, StampedLock supports an optimistic read: get a stamp without locking, read fields into locals, then validate(stamp). If no write happened, you are done with zero contention; otherwise fall back to a real read lock. It is not reentrant and has no Condition support, so use it for small, hot, read-mostly data like a point's coordinates.
Q26. Why must wait() be called in a while loop?
Three reasons. Spurious wakeups are permitted by the specification. With notifyAll, several waiters wake but only one can consume the condition. And between being notified and reacquiring the monitor, another thread may have changed the state. So always re-check the condition after waking: while (!condition) lock.wait();. An if here is one of the most common bugs in written interview code.
Q27. notify() vs notifyAll()?
notify() wakes one arbitrary waiter; notifyAll() wakes all of them, and they compete for the monitor. With a single monitor shared by producers and consumers, notify() can wake the wrong kind of thread (a producer wakes another producer while the consumer sleeps forever). Use notifyAll() unless all waiters wait for the same condition and any one of them can proceed. With ReentrantLock you avoid the issue by using separate notFull and notEmpty conditions and calling signal() on the right one.
Q28. What is the Condition interface?
A Condition is the lock-based equivalent of wait/notify: await(), signal(), signalAll(), called while holding the owning lock. One lock can have multiple conditions, which is exactly how ArrayBlockingQueue is implemented internally. The same while-loop rule applies.
Executors and Thread Pools: Questions 29-37
Q29. Why use a thread pool instead of new Thread per task?
Platform threads are expensive: each reserves a stack (commonly around 1 MB by default on 64-bit JVMs, configurable with -Xss) and maps to an OS thread. Creating one per request wastes time and, under load, can exhaust memory or OS thread limits. A pool reuses a fixed set of threads, bounds concurrency, and gives you a single place for queueing, rejection, naming and shutdown.
Q30. Explain the ThreadPoolExecutor parameters and how a new task is handled.
The constructor takes corePoolSize, maximumPoolSize, keepAliveTime, a BlockingQueue, a ThreadFactory and a RejectedExecutionHandler. On execute(task):
- Fewer than core threads running? Start a new thread with this task, even if other threads are idle.
- Otherwise, try to queue it. If the queue accepts, done.
- Queue full? Start a new non-core thread if below
maximumPoolSize. - At max and queue full? Hand the task to the rejection handler.
The counter-intuitive consequence: with an unbounded queue, the pool never grows past core size, because step 2 always succeeds. Many candidates think the pool grows to max first and then queues. It is the other way around.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // core
32, // max
60, TimeUnit.SECONDS, // idle non-core threads die after 60s
new ArrayBlockingQueue<>(500), // bounded queue
r -> { // named threads make thread dumps readable
Thread t = new Thread(r);
t.setName("payment-worker-" + t.getId());
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()); // back-pressure instead of dropping
Q31. What are the rejection policies?
AbortPolicy (default) throws RejectedExecutionException. CallerRunsPolicy runs the task on the submitting thread, which naturally slows the producer down: built-in back-pressure. DiscardPolicy silently drops the task. DiscardOldestPolicy drops the oldest queued task and retries. Silent discarding is rarely acceptable for business work like payment callbacks; at minimum log and count rejections.
Q32. Why are Executors.newFixedThreadPool and newCachedThreadPool considered risky?
newFixedThreadPool(n) uses an unbounded LinkedBlockingQueue: if tasks arrive faster than they finish, the queue grows until OutOfMemoryError, and latency grows silently long before that. newCachedThreadPool() uses a SynchronousQueue and a max of Integer.MAX_VALUE threads, so a burst can create thousands of threads. Many teams' coding guidelines (Alibaba's Java manual is a well-known example) ban these factories in favour of an explicitly configured ThreadPoolExecutor.
Q33. Runnable vs Callable?
Runnable.run() returns nothing and cannot throw checked exceptions. Callable.call() returns a value and can throw Exception. Submitting either to an ExecutorService returns a Future; for a Runnable, get() returns null.
Q34. What happens to an exception thrown inside a task?
With submit(), the exception is captured in the Future and rethrown wrapped in ExecutionException when you call get(). If nobody calls get(), it disappears silently, a classic production mystery. With execute(), the exception propagates, kills that worker thread (the pool replaces it) and goes to the thread's UncaughtExceptionHandler, which by default prints the stack trace.
Q35. shutdown() vs shutdownNow()?
shutdown() stops accepting new tasks but lets queued and running tasks finish. shutdownNow() interrupts running tasks and returns the list of queued tasks that never started. Neither waits; follow with awaitTermination(timeout). The standard pattern is shutdown, await, then shutdownNow if the timeout passes. Since Java 19, ExecutorService is AutoCloseable, so try-with-resources waits for tasks to finish on close.
Q36. What is ForkJoinPool and work stealing?
ForkJoinPool is designed for divide-and-conquer tasks that split into subtasks (RecursiveTask). Each worker has its own double-ended queue. A worker pushes and pops its own subtasks from one end (LIFO, cache-friendly); an idle worker steals from the other end of a busy worker's deque. Parallel streams and default async CompletableFuture stages run on the shared ForkJoinPool.commonPool(), whose parallelism defaults to the number of available processors minus one.
Q37. How do you size a thread pool?
For CPU-bound work, about the number of cores (Runtime.getRuntime().availableProcessors()), perhaps plus one. For I/O-bound work, the classic formula from Java Concurrency in Practice is threads = cores × target utilization × (1 + wait time / compute time). Example: 8 cores, each request spends 90 ms waiting on a database and 10 ms computing: 8 × 1 × (1 + 9) = 80 threads. Then load-test, because the database's connection pool (say 30 connections) is often the real cap, and 80 threads fighting for 30 connections just moves the queue.
CompletableFuture: Questions 38-42
Q38. Why CompletableFuture over Future?
A plain Future only lets you block on get(). CompletableFuture lets you attach callbacks that run when the value is ready, chain dependent steps, combine independent ones, handle errors in the pipeline, and complete it manually. It turns "call A, then B with A's result, while C runs in parallel" into a non-blocking pipeline.
Q39. thenApply vs thenCompose vs thenCombine?
thenApply transforms the result with a plain function (like map). thenCompose takes a function that itself returns a CompletableFuture and flattens it (like flatMap); use it when the next step is another async call, otherwise you get CompletableFuture<CompletableFuture<T>>. thenCombine waits for two independent futures and merges their results.
ExecutorService io = Executors.newFixedThreadPool(16);
CompletableFuture<User> user =
CompletableFuture.supplyAsync(() -> userService.find(userId), io);
CompletableFuture<List<Order>> orders =
user.thenCompose(u -> CompletableFuture.supplyAsync(() -> orderService.recent(u), io));
CompletableFuture<Wallet> wallet =
CompletableFuture.supplyAsync(() -> walletService.balance(userId), io);
CompletableFuture<Dashboard> dashboard = orders
.thenCombine(wallet, Dashboard::new) // runs when both are done
.orTimeout(800, TimeUnit.MILLISECONDS) // Java 9+
.exceptionally(ex -> Dashboard.degraded(userId)); // fallback on any failure
Q40. How do exceptions propagate in a CompletableFuture chain?
If a stage throws, the future completes exceptionally and every dependent stage is skipped until a handler is reached. exceptionally(fn) recovers with a value. handle((value, ex) -> ...) sees both outcomes and can transform either. whenComplete observes but does not change the result. join() rethrows as unchecked CompletionException; get() throws checked ExecutionException.
Q41. What is the danger of supplyAsync without an executor?
It runs on ForkJoinPool.commonPool(), shared by the whole JVM, including parallel streams. Blocking I/O there (HTTP calls, JDBC) ties up a small number of threads sized for CPU work and starves everything else using the pool. Always pass a dedicated executor for blocking work.
Q42. allOf vs anyOf?
allOf(f1, f2, ...) returns a CompletableFuture<Void> that completes when all complete; you then join() each to read results, which no longer blocks. anyOf completes with the first one to finish, useful for hedged requests to replicas. Note that anyOf does not cancel the losers.
Concurrent Collections and Utilities: Questions 43-46
Q43. How does ConcurrentHashMap work internally (Java 8+)?
It is an array of bins like HashMap. Inserting into an empty bin uses CAS with no lock. Inserting into a non-empty bin locks only that bin's head node with synchronized. Reads are lock-free, relying on volatile reads of nodes. A bin whose list grows to 8 nodes is converted to a red-black tree once the table has at least 64 bins. Resizing is done cooperatively: threads that encounter a resize in progress help transfer bins. The size is tracked with striped counter cells, similar to LongAdder.
Before Java 8 it used segments, 16 by default, each a separately locked mini hash table. Interviewers still ask this to see if your knowledge is current. Also note: no null keys or values, because a null from get() would be ambiguous between "absent" and "mapped to null" in a concurrent setting. Iterators are weakly consistent and never throw ConcurrentModificationException.
Follow-up: "Is if (!map.containsKey(k)) map.put(k, v) safe on a ConcurrentHashMap?" No. Each call is atomic, the pair is not. Use putIfAbsent, computeIfAbsent or merge. See our hashing guide for the HashMap side.
Q44. CountDownLatch vs CyclicBarrier vs Phaser?
A CountDownLatch is one-shot: threads wait until a count reaches zero, decremented by other threads (for example, the main thread waits for 5 services to warm up). A CyclicBarrier makes a fixed group of threads wait for each other at a barrier point and is reusable across rounds, with an optional action when the barrier trips. A Phaser is a flexible, reusable barrier where parties can register and deregister dynamically.
Q45. What is a Semaphore used for?
A Semaphore holds a number of permits; acquire() takes one (blocking if none), release() returns one. Use it to bound concurrent access to a resource, for example allowing at most 10 parallel calls to a rate-limited partner API. Unlike a lock, a semaphore has no owner: any thread can release, which is powerful and easy to misuse.
Q46. What is ThreadLocal and how does it leak memory?
ThreadLocal gives each thread its own copy of a variable, commonly used for non-thread-safe objects like SimpleDateFormat or for request context (trace IDs, the logged-in user). Values live in the thread's own map. In a thread pool, threads never die, so a value set during one request survives into the next request handled by that thread: a memory leak and, worse, a data leak between users. Always remove() in a finally block. In app servers the leak can also pin a whole web-app class loader after redeploy.
Deadlock, Liveness and Modern Java: Questions 47-50
Q47. What is a deadlock and what are the four Coffman conditions?
A deadlock is a set of threads each waiting for a resource held by another in the set, so none can proceed. All four conditions must hold: mutual exclusion, hold and wait, no preemption, and circular wait. Break any one to prevent it. In practice you break circular wait with a global lock ordering (always lock the account with the smaller ID first) or hold-and-wait with tryLock timeouts.
void transfer(Account a, Account b, long amt) {
Account first = a.id < b.id ? a : b; // consistent global order
Account second = a.id < b.id ? b : a;
synchronized (first) {
synchronized (second) {
a.balance -= amt;
b.balance += amt;
}
}
}
Q48. How do you detect a deadlock in a running JVM?
Take a thread dump with jstack <pid>, jcmd <pid> Thread.print, or kill -3 on Linux. The JVM detects monitor and ownable-synchronizer deadlocks and prints "Found one Java-level deadlock" with the cycle of threads and the locks each holds and waits for. Programmatically, ThreadMXBean.findDeadlockedThreads() returns the IDs, which some teams wire into a health check.
Q49. What are livelock and starvation?
In a livelock, threads are not blocked but keep reacting to each other without progress, like two people stepping aside in a corridor in sync. The tryLock loop in Q22 can livelock if both threads back off for exactly the same time, which is why the backoff is randomized. Starvation is a thread never getting a resource because others keep winning, for example a writer under constant read traffic, or low-priority work behind an unfair lock.
Q50. What are virtual threads and when should you use them?
Virtual threads, final in Java 21 (JEP 444), are lightweight threads managed by the JVM. Many virtual threads are multiplexed onto a small pool of platform "carrier" threads. When a virtual thread blocks on I/O, the JVM unmounts it and the carrier runs another one. You can run hundreds of thousands of them, which makes the simple thread-per-request blocking style scale like async code.
- Use them for I/O-bound concurrency: services that call databases and other HTTP services.
- They do not speed up CPU-bound work; you still have only as many cores as you have.
- Do not pool them. Create one per task; bound concurrency to scarce resources with a
Semaphoreinstead. - Pinning: in Java 21-23, blocking inside a
synchronizedblock pinned the virtual thread to its carrier. Java 24 (JEP 491) removed that limitation forsynchronized; on older versions, preferReentrantLockaround blocking calls. - Be careful with large
ThreadLocalvalues, since a million threads means a million copies.
Coding Questions: Producer-Consumer, Odd-Even and More
These are the concurrency programs most often asked on a whiteboard or in a shared editor. Write them so they compile; interviewers do notice a missing throws InterruptedException.
Producer-consumer with wait/notify
import java.util.ArrayDeque;
import java.util.Queue;
public class ProducerConsumer {
static class BoundedBuffer<T> {
private final Queue<T> items = new ArrayDeque<>();
private final int capacity;
BoundedBuffer(int capacity) { this.capacity = capacity; }
public synchronized void put(T item) throws InterruptedException {
while (items.size() == capacity) {
wait(); // releases the monitor while waiting
}
items.add(item);
notifyAll(); // wake consumers (and producers, harmlessly)
}
public synchronized T take() throws InterruptedException {
while (items.isEmpty()) {
wait();
}
T item = items.poll();
notifyAll(); // a slot is free: wake producers
return item;
}
}
public static void main(String[] args) throws InterruptedException {
BoundedBuffer<Integer> buffer = new BoundedBuffer<>(5);
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 20; i++) {
buffer.put(i);
System.out.println("produced " + i);
}
buffer.put(-1); // poison pill tells the consumer to stop
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
while (true) {
int v = buffer.take();
if (v == -1) break;
System.out.println("consumed " + v);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
}
}
Dry run with capacity 5: the producer adds 1 to 5, finds the buffer full on 6 and waits, releasing the monitor. The consumer takes 1, calls notifyAll, and the producer wakes, re-checks the while condition (size is 4, so it proceeds) and adds 6. If the consumer runs first, it finds the buffer empty and waits until the first put. The poison pill (-1) is a clean shutdown signal; for multiple consumers, send one pill per consumer.
Producer-consumer with BlockingQueue (what you would write at work)
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
Runnable producer = () -> {
try {
for (int i = 1; i <= 20; i++) queue.put(i); // blocks when full
queue.put(-1);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
Runnable consumer = () -> {
try {
for (int v = queue.take(); v != -1; v = queue.take()) { // blocks when empty
System.out.println("consumed " + v);
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
Say both versions in the interview: the hand-written one proves you understand monitors, and the second shows you would not reinvent it in production.
Print odd and even numbers alternately with two threads
public class OddEvenPrinter {
private final int max;
private int current = 1;
OddEvenPrinter(int max) { this.max = max; }
private synchronized void print(boolean odd) throws InterruptedException {
while (current <= max) {
while (current <= max && (current % 2 == 1) != odd) {
wait(); // not my turn
}
if (current <= max) {
System.out.println(Thread.currentThread().getName() + ": " + current);
current++;
}
notifyAll(); // hand the turn over
}
}
public static void main(String[] args) throws InterruptedException {
OddEvenPrinter p = new OddEvenPrinter(10);
Thread odd = new Thread(() -> {
try { p.print(true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "odd");
Thread even = new Thread(() -> {
try { p.print(false); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "even");
odd.start(); even.start();
odd.join(); even.join();
}
}
The inner loop's current <= max check matters: after the last number is printed, the other thread must not wait forever. That end-condition is precisely what interviewers watch for. A variant uses two Semaphores (odd starts with 1 permit, even with 0, each releases the other's after printing), which generalizes neatly to N threads printing in round-robin.
Write code that deadlocks (and then fix it)
Object a = new Object(), b = new Object();
new Thread(() -> { synchronized (a) { sleepQuietly(50); synchronized (b) { } } }).start();
new Thread(() -> { synchronized (b) { sleepQuietly(50); synchronized (a) { } } }).start();
// Thread 1 holds a, wants b. Thread 2 holds b, wants a. Circular wait.
// Fix: both threads acquire a then b (global ordering).
Other programs worth practising: a thread-safe LRU cache (a LinkedHashMap guarded by a lock, see LRU cache explained), a token-bucket rate limiter with synchronized refill (the algorithm is covered in rate limiter design), and a simple thread pool built from a BlockingQueue and N worker threads.
How to Answer Concurrency Questions Well
- Always separate visibility from atomicity. Most wrong answers mix them up. State which one the problem needs.
- Talk about failure and overload. For any executor question, mention bounded queues, rejection handling and timeouts. This is what SDE-2 interviewers listen for.
- Use concrete scenarios. "Two users booking the last seat", "two UPI debits on the same wallet" and "a cache stampede when a hot key expires" make abstract answers memorable. Our BookMyShow design shows the distributed version of the same seat-locking problem.
- Prefer high-level utilities, but know what is underneath. Say "I would use a BlockingQueue", then explain that it is a lock with two conditions if asked.
- Admit what is version-specific. "In Java 21 virtual threads pinned in synchronized blocks; Java 24 fixed that" shows you track the platform.
if instead of while around wait(); forgetting unlock() in finally; swallowing InterruptedException; calling Thread.sleep to "fix" a race; claiming volatile makes increments atomic; and using Executors.newFixedThreadPool for request handling without mentioning the unbounded queue.A good preparation loop: write the three coding programs above from memory, explain each of the 50 answers aloud in under 90 seconds, then read a real thread dump from a deliberately deadlocked program. That last exercise turns Q4, Q47 and Q48 from memorised facts into something you have seen, and it shows in how you answer.
Frequently Asked Questions
Get Backend Interview Ready
Prepflix's backend track pairs Java concurrency drills with LLD and system design mocks, reviewed by engineers who have run these rounds.
Explore the Prepflix Course →