Sitemap
Vighneshwar Bhat

Vighneshwar Bhat

Java Concurrency Interview Questions for Senior Developers

22 min readApr 23, 2026

--

Concurrency is where senior engineers are made — and where interview candidates are separated. This is the complete guide to every question that actually gets asked.

Press enter or click to view image in full size

In fifteen years of building Java systems, I have never worked on a production application that didn’t eventually develop a concurrency problem. A race condition that only reproduced under load. A deadlock discovered at 2 AM. A ThreadLocal leak that corrupted user sessions in a thread pool. A CompletableFuture that swallowed exceptions silently for weeks.

Concurrency is hard. It is hard to write, hard to test, and brutally hard to debug. Which is exactly why interviewers use it to separate senior engineers from developers who merely have senior-level experience.

This guide covers every concurrency question asked at senior level — from the Java Memory Model to Virtual Threads in Java 21. Every answer goes beyond the definition to the production consequence. That is what senior interviews demand.

📘 [Java Backend Interview Questions]

It covers 300+ questions across all experience levels, with detailed explanations, common follow-up questions, and the reasoning interviewers are actually looking for.

📘 [Complete Backend Interview Questions]

It covers 1000+ core Java, collections, concurrency, JVM internals, Spring, design patterns, and system design — with the kind of detailed explanations that don’t just help you pass interviews, but make you a better Java developer.

Section 1: Java Memory Model — The Foundation Everything Else Builds On

Q1. Explain the Java Memory Model in depth. Why does it exist?

The Java Memory Model (JMM) is the specification that defines how threads interact through memory. It exists because of a gap between how developers think about memory and how modern hardware actually works.

How developers think: If Thread A writes x = 5, then Thread B reads x, Thread B sees 5. Simple.

How hardware actually works: Modern CPUs have multiple cores, each with their own L1 and L2 caches. A write by Thread A on Core 1 may sit in Core 1’s cache and never reach main memory. Thread B on Core 4 reads x from its own cache and sees the old value — forever. Additionally, both the compiler and the JVM are free to reorder instructions for optimization, as long as single-threaded behavior is preserved.

Core 1 (Thread A)          Main Memory          Core 4 (Thread B)
L1 Cache x = 0 L1 Cache
x = 5 (written) x = 0 (stale read)
[not yet flushed]

The JMM defines happens-before relationships — guarantees that certain operations make their results visible to subsequent operations in other threads.

Key happens-before rules:

// 1. volatile write happens-before volatile read
volatile int flag = 0;
// Thread A: flag = 1 → Thread B: reads flag — guaranteed to see 1
// 2. synchronized unlock happens-before subsequent lock
synchronized(lock) { x = 5; } // unlock
synchronized(lock) { /* reads x — guaranteed to see 5 */ } // next lock
// 3. Thread.start() happens-before any action in the started thread
x = 10;
thread.start(); // thread is guaranteed to see x = 10
// 4. All actions in a thread happen-before Thread.join() returns
thread.join(); // after this, all thread's writes are visible
// 5. Constructor completion happens-before finalizer
// 6. Transitive: if A hb B and B hb C, then A hb C

Without happens-before guarantees, your code has a data race — and data races produce undefined behavior in the JMM. The JVM and CPU can do anything: reorder, cache, optimize. “Undefined” means truly undefined — not just “usually works.”

Q2. What is a data race? Is it the same as a race condition?

These terms are frequently confused. They describe different problems.

A data race is a low-level memory model violation: two or more threads access the same variable concurrently, at least one access is a write, and there is no synchronization between them. The JMM says data races produce undefined behavior for non-volatile, non-atomic variables.

// DATA RACE — both threads read/write x with no synchronization
int x = 0;
Thread t1 = new Thread(() -> x = 1);
Thread t2 = new Thread(() -> System.out.println(x));

A race condition is a higher-level logic bug: the correctness of the program depends on the relative timing or interleaving of thread operations. A race condition can exist even in correctly synchronized code if the synchronization scope is wrong.

// RACE CONDITION — synchronized but logic is still broken
// check-then-act without atomic guarantee
if (!map.containsKey(key)) { // Thread A checks — key absent
// Thread B inserts key here
map.put(key, computeValue()); // Thread A inserts duplicate
}

Both threads call containsKey() and get false. Both call put(). Result: the computation runs twice, second write wins. Even if map is a ConcurrentHashMap, this is a race condition.

Fix: map.computeIfAbsent(key, k -> computeValue()) — atomically checks and computes.

Q3. What is instruction reordering? How can it break your code?

The compiler, JIT, and CPU all reorder instructions to optimize performance. Single-threaded behavior is always preserved — but multi-threaded code can break.

Classic example — the broken singleton:

public class Singleton {
private static Singleton instance;
    public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // THIS LINE IS THE PROBLEM
}
}
}
return instance;
}
}

instance = new Singleton() is not atomic. It compiles to roughly three steps:

  1. Allocate memory for the object
  2. Initialize the object (call constructor)
  3. Assign the reference to instance

The JVM can reorder steps 2 and 3 — assigning the reference before the constructor completes. Another thread sees instance != null, skips synchronization, and uses a partially constructed object.

Fix: Declare instance as volatile. volatile prevents reordering across the write, ensuring the object is fully constructed before the reference is published.

private static volatile Singleton instance; // problem solved

Section 2: Synchronization Primitives

Q4. Explain volatile fully — both what it guarantees and what it does not.

volatile provides two guarantees:

Guarantee 1 — Visibility: Every write to a volatile variable is immediately written to main memory. Every read comes from main memory, not from a CPU cache. All threads see the same value.

Guarantee 2 — Ordering (happens-before): A write to a volatile variable happens-before every subsequent read of that variable. Additionally, volatile prevents reordering of instructions across the volatile read/write — all writes before the volatile write are visible after the volatile read.

// Safe flag — volatile guarantees visibility
private volatile boolean running = true;
// Thread A:
running = false; // immediately visible to all threads
// Thread B:
while (running) { doWork(); } // sees the update — loop exits

What volatile does NOT guarantee — Atomicity:

private volatile int counter = 0;
// Thread A and Thread B both call:
counter++; // NOT atomic — read-modify-write, 3 operations

Two threads can both read 0, both increment to 1, both write 1. The result should be 2. This is a race condition even with volatile. Use AtomicInteger or synchronized for compound operations.

Rule: Use volatile for: single-variable flags, status indicators, publishing immutable objects (safe publication). Do not use it for: compound operations, check-then-act patterns, incrementing counters.

Q5. Explain synchronized — intrinsic locks, monitor, and memory semantics.

Every Java object has an intrinsic lock (monitor). synchronized acquires this lock before executing the block and releases it on exit — even if an exception is thrown.

public class Counter {
private int count = 0;
private final Object lock = new Object();
    // Synchronized method — locks on 'this'
public synchronized void increment() {
count++;
}
// Synchronized block — locks on explicit object (preferred)
public void decrement() {
synchronized (lock) {
count--;
}
}
// Static synchronized — locks on the Class object
public static synchronized void staticMethod() { }
}

Memory semantics of synchronized:

  • On entry: The thread reads all variables from main memory (clears cache)
  • On exit: The thread writes all modified variables to main memory

This means synchronized provides both mutual exclusion (only one thread at a time) and visibility (changes are visible to the next thread that acquires the lock).

Reentrancy: Java’s intrinsic locks are reentrant — a thread that holds a lock can acquire it again without deadlocking.

public synchronized void outer() {
inner(); // same thread acquires the lock again — works fine
}
public synchronized void inner() { }

Q6. What is ReentrantLock? When would you use it over synchronized?

ReentrantLock is an explicit lock from java.util.concurrent.locks that provides more flexibility than synchronized.

private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock();
try {
// critical section
} finally {
lock.unlock(); // ALWAYS unlock in finally
}
}

When to use ReentrantLock over synchronized:

1. Timed locking — avoid deadlock:

if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try { /* work */ }
finally { lock.unlock(); }
} else {
// couldn't acquire — handle gracefully instead of blocking forever
}

2. Interruptible locking:

lock.lockInterruptibly(); // can be interrupted while waiting
// synchronized waiting cannot be interrupted

3. Fairness — prevent starvation:

ReentrantLock fairLock = new ReentrantLock(true); // fair mode
// Threads acquire in FIFO order — no thread starves
// Note: fair locks are slower due to scheduling overhead

4. Multiple condition variables:

Condition notFull  = lock.newCondition();
Condition notEmpty = lock.newCondition();
// producer waits on notFull, consumer waits on notEmpty
// synchronized only has one wait/notify queue per object

Rule: Use synchronized for simple mutual exclusion — it is cleaner and the JVM can optimize it heavily. Use ReentrantLock when you need timed locking, interruptibility, fairness, or multiple conditions.

Q7. What is ReadWriteLock? When does it improve performance?

ReadWriteLock maintains two locks — a read lock and a write lock. Multiple threads can hold the read lock simultaneously. The write lock is exclusive — only one thread at a time.

private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private Map<String, String> cache = new HashMap<>();public String get(String key) {
readLock.lock();
try {
return cache.get(key); // multiple readers simultaneously
} finally {
readLock.unlock();
}
}
public void put(String key, String value) {
writeLock.lock();
try {
cache.put(key, value); // exclusive write access
} finally {
writeLock.unlock();
}
}

When it helps: Read-heavy workloads where writes are infrequent. If 90% of operations are reads, ReadWriteLock allows those 90% to proceed in parallel — a significant throughput improvement over synchronized which serializes everything.

When it does not help: Write-heavy workloads — the overhead of managing two locks can be worse than a simple synchronized. Always measure before switching.

Java 8 improvement — StampedLock: Provides optimistic reads — try reading without a lock, validate afterward. If validation fails, fall back to a read lock. Better throughput in low-contention scenarios.

Q8. What are Atomic classes? How do they work without locks?

java.util.concurrent.atomic provides lock-free thread-safe operations using CAS (Compare-And-Swap) — a hardware-level atomic instruction.

CAS takes three arguments: the memory address, the expected current value, and the new value. It atomically checks if the current value matches the expected value — if yes, swaps in the new value and returns true. If no, returns false (another thread changed it first).

AtomicInteger counter = new AtomicInteger(0);
// Thread-safe increment — lock free
int newValue = counter.incrementAndGet(); // CAS under the hood
int old = counter.getAndIncrement(); // get then increment
counter.compareAndSet(5, 10); // if value is 5, set to 10
AtomicReference<String> ref = new AtomicReference<>("initial");
ref.compareAndSet("initial", "updated"); // atomic reference update
AtomicLong sequence = new AtomicLong(0);
long next = sequence.incrementAndGet(); // safe sequence generator

Why CAS is faster than synchronized: No kernel-level context switching. No thread blocking. No OS involvement. Pure CPU instruction. Under low contention, CAS is dramatically faster. Under high contention, CAS can spin (retry loop) — synchronized or LongAdder may be better.

LongAdder vs AtomicLong: LongAdder is specifically optimized for high-contention counters. It maintains multiple cells — threads update different cells and the sum is computed on demand. For frequent increment/add operations under high concurrency, LongAdder outperforms AtomicLong.

Section 3: Thread Lifecycle and Management

Q9. Describe the complete thread lifecycle. What are all the states?

new Thread()
|
NEW
|
start()
|
RUNNABLE ←──────────────────┐
/ | \ |
scheduled running waiting for CPU |
| |
┌──────────┼──────────┐ |
| | | |
BLOCKED WAITING TIMED_WAITING |
| | | |
(lock free) (notified) (timeout/interrupt)|
└──────────┴──────────┘ |
| |
RUNNABLE ────────────────────┘
|
TERMINATED

BLOCKED: Waiting to acquire a monitor lock (another thread holds it).

WAITING: Indefinitely paused — waiting for another thread’s signal. Entered via Object.wait(), Thread.join(), LockSupport.park().

TIMED_WAITING: Paused for a specific duration. Entered via Thread.sleep(n), Object.wait(n), Thread.join(n), LockSupport.parkNanos(n).

Key distinction for interviews: BLOCKED is specifically for monitor locks. WAITING is for explicit signals. A thread waiting on ReentrantLock is actually in WAITING state (using LockSupport.park()), not BLOCKED.

Q10. What is the difference between sleep(), wait(), yield(), and join()?

// sleep() — pauses current thread for duration, does NOT release locks
Thread.sleep(1000); // milliseconds — keeps all held locks
// wait() — pauses current thread AND releases the object's monitor lock
// Must be called from synchronized block
synchronized (obj) {
while (!conditionMet) {
obj.wait(); // releases lock, waits for notify()
}
}
// yield() — hints to scheduler that thread can yield its slot
// No guarantee — scheduler may ignore it
Thread.yield(); // rarely used in modern code
// join() — current thread waits for another thread to finish
Thread worker = new Thread(() -> doHeavyWork());
worker.start();
worker.join(); // blocks until worker finishes
System.out.println("Worker done"); // guaranteed to run after worker

Method Releases Lock? Can be Interrupted? Guaranteed to Pause? sleep() No Yes Yes (for duration) wait() Yes Yes Until notify() yield() No No No (hint only) join() No Yes Until thread ends

Q11. What is ThreadLocal? Where does it leak in production?

ThreadLocal provides each thread with its own independent copy of a variable. No synchronization needed — threads never share the same instance.

// Each thread gets its own DateFormat instance (DateFormat is not thread-safe)
private static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatDate(Date date) {
return dateFormat.get().format(date); // thread's own instance
}

Classic use cases: Per-request user context in web applications, per-thread database connections, per-thread random number generators, transaction context propagation.

The production leak — thread pools:

// In a Spring web application with thread pool:
public void handleRequest(HttpServletRequest request) {
USER_CONTEXT.set(currentUser); // sets in current thread
processRequest();
// forgot to call USER_CONTEXT.remove() !
}

When the request finishes, the thread returns to the pool. The ThreadLocal value persists. The next request on the same thread inherits the previous user's context. In a financial application, this means User A could see User B's data. This is a real security vulnerability.

Fix:

try {
USER_CONTEXT.set(currentUser);
processRequest();
} finally {
USER_CONTEXT.remove(); // always clean up
}

Spring’s RequestContextHolder does this correctly — study its implementation as a reference pattern.

Section 4: Concurrent Collections

Q12. How does ConcurrentHashMap work internally in Java 8+?

In Java 7, ConcurrentHashMap used segment-level locking — the map was divided into 16 segments, each independently locked.

In Java 8+, the implementation was completely redesigned:

  • Structure: Same array of buckets as HashMap, plus linked lists converting to red-black trees at 8+ nodes
  • Read operations: Completely lock-free. Nodes are read with volatile semantics
  • Write operations (no collision): Use CAS to atomically insert into an empty bucket — no lock acquired
  • Write operations (collision): Synchronized only on the first node of the affected bucket — only one bucket locked at a time, all others remain accessible
  • Resize: Uses a cooperative protocol — multiple threads help with the rehashing
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Atomic operations — no external synchronization needed
map.putIfAbsent("key", 1);
map.computeIfAbsent("key", k -> expensiveCompute(k));
map.compute("counter", (k, v) -> v == null ? 1 : v + 1); // atomic increment
map.merge("key", 1, Integer::sum); // atomic merge
// Bulk operations (Java 8+)
map.forEach(2, (k, v) -> process(k, v)); // parallelism threshold = 2
map.reduce(1, (k, v) -> v, Integer::sum); // parallel reduce
long count = map.mappingCount(); // use instead of size() for large maps

Why not just use Collections.synchronizedMap(new HashMap())? Synchronized map locks the entire map for every operation — all threads serialize. ConcurrentHashMap locks only individual buckets (or uses CAS) — vastly higher throughput under concurrent access.

Q13. What is CopyOnWriteArrayList? When is it appropriate?

CopyOnWriteArrayList creates a fresh copy of the underlying array on every write operation. All read operations operate on the existing snapshot — never blocked, never fail-fast.

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Java"); // creates new array copy
list.add("Spring"); // creates another new array copy
// Reads — lock-free, iterate snapshot
for (String s : list) {
System.out.println(s);
list.add("new"); // safe — iterating the old snapshot, no CME
}

When appropriate:

  • Read operations vastly outnumber writes
  • Iteration must never throw ConcurrentModificationException
  • Event listener lists (rarely modified, frequently iterated)
  • Observer pattern implementations

When NOT appropriate:

  • Frequent writes — each write copies the entire array (O(n))
  • Large collections — memory pressure from multiple array copies
  • High-frequency update scenarios

Q14. What are BlockingQueue implementations? How is LinkedBlockingQueue different from ArrayBlockingQueue?

BlockingQueue is a thread-safe queue that blocks on put() when full and blocks on take() when empty — perfect for producer-consumer patterns.

BlockingQueue<String> queue = new LinkedBlockingQueue<>(100);
// Producer thread
Thread producer = new Thread(() -> {
try {
queue.put("task"); // blocks if queue is full
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Consumer thread
Thread consumer = new Thread(() -> {
try {
String task = queue.take(); // blocks if queue is empty
process(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

Key implementations:

Get Vighneshwar Bhat’s stories in your inbox

Join Medium for free to get updates from this writer.

Implementation Capacity Locking Use Case ArrayBlockingQueue Fixed (bounded) Single lock Bounded buffer, backpressure LinkedBlockingQueue Optional (default unbounded) Two locks (head/tail) Higher throughput producer-consumer PriorityBlockingQueue Unbounded Single lock Priority-ordered processing SynchronousQueue Zero (no storage) CAS Direct handoff between threads DelayQueue Unbounded Single lock Scheduled task execution

LinkedBlockingQueue vs ArrayBlockingQueue: LinkedBlockingQueue uses two separate locks — one for the head (consumer) and one for the tail (producer). Producers and consumers can operate simultaneously without contention. ArrayBlockingQueue uses a single lock — producer and consumer cannot run at the same time. Under high throughput, LinkedBlockingQueue is faster.

Section 5: Synchronizers

Q15. Explain CountDownLatch vs CyclicBarrier. When would you use each?

CountDownLatch: A one-time-use synchronizer. Initialized with a count. Threads call countDown() to decrement. One or more threads wait with await() until count reaches zero.

int numServices = 3;
CountDownLatch latch = new CountDownLatch(numServices);
// Three service threads
ExecutorService executor = Executors.newFixedThreadPool(3);
executor.submit(() -> { initDatabase(); latch.countDown(); });
executor.submit(() -> { initCache(); latch.countDown(); });
executor.submit(() -> { initMQ(); latch.countDown(); });
// Main thread waits for all services
latch.await(); // blocks until count = 0
System.out.println("All services ready — starting application");

CyclicBarrier: Reusable synchronizer. All participating threads call await() — each blocks until ALL threads have called await(). Then they all proceed together. The "cyclic" part: it resets after each use.

int numThreads = 4;
CyclicBarrier barrier = new CyclicBarrier(numThreads, () -> {
System.out.println("All threads reached barrier — proceeding to next phase");
});
for (int i = 0; i < numThreads; i++) {
executor.submit(() -> {
doPhase1Work();
barrier.await(); // wait for all threads
doPhase2Work(); // all threads start phase 2 together
barrier.await(); // wait again
doPhase3Work(); // all start phase 3 together
});
}

Key difference:

  • CountDownLatch: Threads decrement and continue independently. Waiting threads are released when count hits zero. Not reusable.
  • CyclicBarrier: All threads arrive at the barrier together. All are released simultaneously. Reusable.

Use CountDownLatch for: waiting for multiple initialization tasks, waiting for multiple events before proceeding. Use CyclicBarrier for: parallel algorithms with synchronized phases, iterative simulations, parallel batch processing.

Q16. What is Semaphore? How does it control resource access?

A Semaphore controls access to a shared resource by maintaining a set of permits. Threads acquire permits before accessing the resource and release them afterward.

// Allow maximum 3 concurrent database connections
Semaphore dbPool = new Semaphore(3);
public void queryDatabase() {
try {
dbPool.acquire(); // blocks if no permits available
try {
executeQuery();
} finally {
dbPool.release(); // always release
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

Semaphore(1) vs synchronized: A binary semaphore (permits=1) provides mutual exclusion like synchronized. The difference: a semaphore can be released by a different thread than the one that acquired it — useful for signaling patterns where one thread signals another.

tryAcquire() for non-blocking attempts:

if (dbPool.tryAcquire(500, TimeUnit.MILLISECONDS)) {
try { executeQuery(); }
finally { dbPool.release(); }
} else {
return fallbackResult(); // couldn't get a permit in time
}

Q17. What is Phaser? How is it more powerful than CountDownLatch and CyclicBarrier?

Phaser (Java 7+) is the most flexible synchronizer — it combines and extends the capabilities of both CountDownLatch and CyclicBarrier.

Phaser phaser = new Phaser(3); // 3 registered parties
for (int i = 0; i < 3; i++) {
final int threadNum = i;
executor.submit(() -> {
System.out.println("Thread " + threadNum + " starting phase 0");
phaser.arriveAndAwaitAdvance(); // barrier for phase 0
System.out.println("Thread " + threadNum + " starting phase 1");
phaser.arriveAndAwaitAdvance(); // barrier for phase 1
// Thread 2 leaves after phase 1
if (threadNum == 2) {
phaser.arriveAndDeregister(); // remove self from phaser
return;
}
System.out.println("Thread " + threadNum + " starting phase 2");
phaser.arriveAndAwaitAdvance(); // only 2 threads now
});
}

What makes Phaser powerful:

  • Dynamic registration: parties can be added and removed dynamically
  • Multiple phases without re-creating the synchronizer
  • Hierarchical phasers for tree-structured parallelism
  • Query methods: getPhase(), getRegisteredParties(), getArrivedParties()

Section 6: Thread Pools and ExecutorService

Q18. Explain ThreadPoolExecutor parameters. What happens when the queue is full?

ThreadPoolExecutor is the foundation of all thread pool implementations:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // corePoolSize: minimum threads kept alive
10, // maximumPoolSize: maximum threads allowed
60L, // keepAliveTime: idle time before killing extra threads
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100), // workQueue: holds tasks before execution
new ThreadFactory() { ... }, // threadFactory: how to create threads
new ThreadPoolExecutor.CallerRunsPolicy() // rejectionHandler
);

What happens when work queue is full and threads = maximumPoolSize: The rejection handler is invoked. Four built-in policies:

// AbortPolicy (default) — throws RejectedExecutionException
new ThreadPoolExecutor.AbortPolicy()
// CallerRunsPolicy — caller's thread runs the task (provides backpressure)
new ThreadPoolExecutor.CallerRunsPolicy()
// DiscardPolicy — silently drops the task
new ThreadPoolExecutor.DiscardPolicy()
// DiscardOldestPolicy — drops the oldest queued task, retries submission
new ThreadPoolExecutor.DiscardOldestPolicy()

The production gotcha with Executors.newFixedThreadPool:

// This uses an UNBOUNDED LinkedBlockingQueue
ExecutorService pool = Executors.newFixedThreadPool(10);
// If tasks pile up faster than they're processed:
// Queue grows without bound → OutOfMemoryError

In production, always use ThreadPoolExecutor directly with a bounded queue and explicit rejection policy.

Q19. What is ForkJoinPool and how does Work Stealing work?

ForkJoinPool is designed for divide-and-conquer parallelism. Tasks can fork (split into subtasks) and join (wait for subtask results).

class SumTask extends RecursiveTask<Long> {
private final long[] array;
private final int start, end;
static final int THRESHOLD = 1000;
    @Override
protected Long compute() {
if (end - start <= THRESHOLD) {
// Small enough — compute directly
long sum = 0;
for (int i = start; i < end; i++) sum += array[i];
return sum;
}
// Split into two halves
int mid = (start + end) / 2;
SumTask left = new SumTask(array, start, mid);
SumTask right = new SumTask(array, mid, end);
left.fork(); // submit left asynchronously
long rightResult = right.compute(); // compute right directly
long leftResult = left.join(); // wait for left
return leftResult + rightResult;
}
}
ForkJoinPool pool = new ForkJoinPool();
long total = pool.invoke(new SumTask(array, 0, array.length));

Work Stealing: Each thread in ForkJoinPool has its own deque of tasks. When a thread's deque is empty, it steals tasks from the tail of another thread's deque. This keeps all threads busy with minimal contention — threads work from the front of their own deque, stealing happens from the back.

Java 8’s parallel streams and CompletableFuture.supplyAsync() (without explicit executor) use the ForkJoin common pool. Be careful: blocking the common pool (with database calls, for instance) can starve all parallel stream operations across your JVM.

Section 7: CompletableFuture — Deep Dive

Q20. How does CompletableFuture handle errors in a pipeline?

Error handling in CompletableFuture is one of the most misunderstood topics at senior level.

CompletableFuture<String> pipeline = CompletableFuture
.supplyAsync(() -> fetchUser()) // may throw
.thenApply(user -> fetchOrders(user)) // may throw
.thenApply(orders -> formatResponse(orders));
// Without error handling — exception is SILENTLY SWALLOWED
// pipeline.get() will throw ExecutionException, but you must remember to call get()
// exceptionally — handles exception, provides fallback
CompletableFuture<String> withFallback = pipeline
.exceptionally(ex -> {
log.error("Pipeline failed", ex);
return "default response";
});
// whenComplete — runs whether success or failure, does not transform
pipeline.whenComplete((result, ex) -> {
if (ex != null) log.error("Failed", ex);
else log.info("Succeeded: " + result);
});
// handle — combines thenApply and exceptionally
pipeline.handle((result, ex) -> {
if (ex != null) return "error: " + ex.getMessage();
return result.toUpperCase();
});

The subtle trap — exception in thenApply:

CompletableFuture.supplyAsync(() -> "data")
.thenApply(data -> { throw new RuntimeException("processing failed"); })
.thenApply(data -> data.toUpperCase()) // this stage is SKIPPED
.exceptionally(ex -> "recovered"); // exception reaches here

Exceptions short-circuit the pipeline — all intermediate stages are skipped until an exceptionally or handle stage catches it.

Q21. How do you run CompletableFuture operations on a custom thread pool?

ExecutorService ioExecutor  = Executors.newFixedThreadPool(20);
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
CompletableFuture
.supplyAsync(() -> fetchFromDatabase(), ioExecutor) // I/O thread pool
.thenApplyAsync(data -> processData(data), cpuExecutor) // CPU thread pool
.thenApplyAsync(result -> saveToCache(result), ioExecutor) // back to I/O
.exceptionally(ex -> handleError(ex));

Without explicit executors, thenApply runs on the completing thread (which may be a ForkJoin common pool thread) and thenApplyAsync uses the ForkJoin common pool. In production, always specify executors for predictable behavior and to avoid starving the common pool.

Section 8: Classic Concurrency Problems

Q22. What is a deadlock, livelock, and starvation? How do you detect each?

Deadlock: Two or more threads waiting for each other’s locks — circular wait, no progress.

// Detection: jstack <pid> shows threads in BLOCKED state with circular dependencies
// "Thread-1" waiting for lock held by "Thread-2"
// "Thread-2" waiting for lock held by "Thread-1"

Prevention: consistent lock ordering, tryLock() with timeout, lock-free algorithms.

Livelock: Threads are not blocked but keep changing state in response to each other — no progress. Like two people in a hallway both stepping aside in the same direction forever.

// Classic livelock — both threads keep "yielding" to each other
while (true) {
if (otherThread.isActive()) {
Thread.yield(); // I'll let you go first
// other thread does same thing — neither proceeds
} else {
doWork();
break;
}
}

Prevention: introduce randomness in retry timing, use a coordinator/arbiter.

Starvation: A thread is repeatedly denied CPU time or lock access because other threads always take priority. In fair=false lock mode, a busy lock can perpetually exclude low-priority threads.

Prevention: fair locking (new ReentrantLock(true)), priority adjustment, work-stealing queues.

Detection tools:

# Thread dump — shows all thread states and lock ownership
jstack <pid>
# Java Flight Recorder — production-safe continuous profiling
# VisualVM, JConsole — GUI thread monitoring
# Deadlock detector in JVM: ThreadMXBean.findDeadlockedThreads()

Q23. Implement a thread-safe producer-consumer with BlockingQueue.

The cleanest implementation uses BlockingQueue — it handles all the wait/notify complexity internally:

public class ProducerConsumer<T> {
private final BlockingQueue<T> queue;
private final int numConsumers;
private final ExecutorService executor;
private volatile boolean running = true;
    public ProducerConsumer(int capacity, int numConsumers) {
this.queue = new LinkedBlockingQueue<>(capacity);
this.numConsumers = numConsumers;
this.executor = Executors.newFixedThreadPool(numConsumers + 1);
}
public void startProducer(Supplier<T> source) {
executor.submit(() -> {
try {
while (running) {
T item = source.get();
if (item != null) {
queue.put(item); // blocks if full — natural backpressure
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
public void startConsumers(Consumer<T> processor) {
for (int i = 0; i < numConsumers; i++) {
executor.submit(() -> {
try {
while (running || !queue.isEmpty()) {
T item = queue.poll(100, TimeUnit.MILLISECONDS);
if (item != null) processor.accept(item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
public void shutdown() {
running = false;
executor.shutdown();
}
}

Section 9: Java 21 — Virtual Threads and Structured Concurrency

Q24. What are Virtual Threads? How do they change concurrency programming?

Virtual Threads (Project Loom, Java 21) are the most significant concurrency feature since java.util.concurrent in Java 5.

The problem: Platform threads map to OS threads. OS threads are expensive — ~1MB stack, context switch overhead, limited to thousands. Thread pools with fixed sizes create a configuration burden and can still cause starvation under load spikes.

What Virtual Threads provide:

// Old way — fixed thread pool, always sizing anxiety
ExecutorService pool = Executors.newFixedThreadPool(200);
// Java 21 — virtual thread per task, no sizing needed
ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
// Or directly:
Thread.ofVirtual().start(() -> handleRequest());

When a virtual thread blocks on I/O (JDBC, HTTP call, file read), the JVM unmounts it from its carrier (OS) thread. The carrier thread picks up another virtual thread. Millions of virtual threads can exist simultaneously — each blocked on I/O without consuming an OS thread.

// This is fine in Java 21 — 100,000 virtual threads blocking on I/O
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
var result = jdbcTemplate.queryForObject(sql, String.class); // blocks I/O
process(result);
})
);
} // waits for all to complete

The pinning problem — critical senior knowledge:

Virtual threads cannot unmount when they are inside a synchronized block or a synchronized method — the carrier thread is pinned and cannot serve other virtual threads.

// BAD — synchronized blocks virtual thread unmounting (pinning)
public synchronized void dbOperation() {
jdbcTemplate.query(...); // blocks I/O — pins carrier thread
}
// GOOD — ReentrantLock allows virtual thread to unmount
private final ReentrantLock lock = new ReentrantLock();
public void dbOperation() {
lock.lock();
try {
jdbcTemplate.query(...); // blocks I/O — virtual thread unmounts cleanly
} finally {
lock.unlock();
}
}

In virtual-thread-heavy code, replace synchronized with ReentrantLock wherever blocking I/O can occur inside the critical section.

Q25. What is Structured Concurrency (Java 21 Preview)?

Structured Concurrency enforces that forked tasks must complete within the scope that forked them — eliminating orphaned tasks, fire-and-forget bugs, and complex cancellation logic.

// Traditional CompletableFuture — fire and forget risk
CompletableFuture<User> userFuture = fetchUserAsync(userId);
CompletableFuture<Orders> ordersFuture = fetchOrdersAsync(userId);
// If one fails, the other keeps running — resource leak
// Structured Concurrency — clean shutdown guaranteed
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<User> user = scope.fork(() -> fetchUser(userId));
StructuredTaskScope.Subtask<Orders> orders = scope.fork(() -> fetchOrders(userId));
scope.join(); // wait for all subtasks
scope.throwIfFailed(); // if any failed, throw the exception
// Both succeeded — safe to use results
return new UserWithOrders(user.get(), orders.get());
}
// Scope exit: if either subtask failed, the other is cancelled
// No orphaned threads, no resource leaks

ShutdownOnFailure: Cancel all remaining tasks if any task fails. ShutdownOnSuccess: Cancel all remaining tasks as soon as one succeeds (race pattern).

Why it matters architecturally: Structured concurrency makes concurrent code as predictable and maintainable as sequential code. Error handling, cancellation, and lifetime management become as natural as try-with-resources.

The Senior Concurrency Checklist

Before any senior Java interview, confirm you can answer all of these fluently:

Java Memory Model

  • [ ] Explain happens-before with four concrete examples
  • [ ] Why does instruction reordering break double-checked locking without volatile?
  • [ ] What is a data race vs. a race condition?

Synchronization

  • [ ] Both dimensions of volatile — visibility AND ordering
  • [ ] When ReentrantLock beats synchronized — four specific scenarios
  • [ ] How CAS works and when AtomicInteger is better than synchronized
  • [ ] ReadWriteLock use case and when it hurts performance

Concurrent Collections

  • [ ] ConcurrentHashMap Java 8+ internals — CAS, bucket-level sync
  • [ ] CopyOnWriteArrayList trade-off — when it helps and when it explodes
  • [ ] ArrayBlockingQueue vs LinkedBlockingQueue — the two-lock advantage

Thread Pool

  • [ ] All five ThreadPoolExecutor parameters
  • [ ] Why Executors.newFixedThreadPool is dangerous in production
  • [ ] The four rejection policies and when to use each

Synchronizers

  • [ ] CountDownLatch vs CyclicBarrier — the one-time vs. reusable distinction
  • [ ] Semaphore for resource pool limiting
  • [ ] Phaser for dynamic participant count

Java 21

  • [ ] Virtual threads — what problem they solve, how unmounting works
  • [ ] The pinning problem — synchronized vs ReentrantLock in virtual thread code
  • [ ] StructuredTaskScope — ShutdownOnFailure vs ShutdownOnSuccess

Concurrency is not a topic you can cram the night before. It requires understanding built through writing multi-threaded code, debugging production incidents, and reasoning carefully about ordering, visibility, and atomicity.

The engineers who answer these questions with depth and confidence have earned it. They have sat with thread dumps. They have traced race conditions through logs. They have fixed ThreadLocal leaks in production.

📘 [Java Backend Interview Questions]

If you want 200+ interview questions like these — covering Core Java, Collections, Java 8 through Java 21, JVM Internals, Spring Boot, and System Design — with the same depth and production grounding.

📘 [Complete Backend Interview Questions]

It covers 1000+ core Java, collections, concurrency, JVM internals, Spring, design patterns, and system design — with the kind of detailed explanations that don’t just help you pass interviews, but make you a better Java developer.

What is the hardest concurrency bug you have ever debugged? Drop it in the comments — I guarantee it will be educational for everyone.

#Java #Concurrency #Multithreading #InterviewPrep #Java21 #VirtualThreads #SeniorEngineer #SoftwareEngineering #Programming

--

--