Java Multithreading Interview Questions 2026 (Experienced)
Java Multithreading Interview Questions for Experienced Developers 2026 (With Real Answers)
If you’ve been writing Java for 4+ years and have an interview lined up at TCS, EPAM, a fintech startup, or anywhere in between — multithreading is the section that will either make or break your L2/L3 round. I’ve sat on both sides of that table, and I can tell you: interviewers don’t want textbook definitions. They want to know if you’ve actually debugged a race condition at 2 AM. This post covers the multithreading questions I see most in 2025-2026 interviews, with answers that actually hold up under follow-up questions.
The Core Questions You Will Face
1. What is the happens-before relationship in Java Memory Model?
This is an L3-level question that separates people who’ve read the JVM spec from people who’ve just used synchronized without thinking. The Java Memory Model (JMM) guarantees that if action A happens-before action B, then the results of A are visible to B. It’s not about physical time — it’s about visibility guarantees.
Key happens-before rules to know cold:
- A write to a
volatilevariable happens-before every subsequent read of that variable. - Releasing a monitor (exiting
synchronized) happens-before acquiring that same monitor. Thread.start()happens-before any action in the started thread.- All actions in a thread happen-before
Thread.join()returns.
// Without happens-before guarantee — data race!
class BadExample {
private boolean ready = false;
private int value = 0;
public void writer() {
value = 42; // (1)
ready = true; // (2) — no guarantee (1) is visible before (2) to other threads
}
public void reader() {
while (!ready) { } // may spin forever or read stale value
System.out.println(value); // might print 0!
}
}
// Fixed with volatile
class GoodExample {
private volatile boolean ready = false;
private int value = 0;
public void writer() {
value = 42; // guaranteed visible before ready=true
ready = true; // volatile write — happens-before subsequent reads
}
public void reader() {
while (!ready) { }
System.out.println(value); // guaranteed to print 42
}
}
When an interviewer follows up with “but why not just make value volatile too?” — the answer is: the volatile write to ready already flushes the write to value because of the happens-before chain. Making value volatile would be redundant here, not wrong.
2. volatile vs synchronized — when do you use which?
volatile guarantees visibility but NOT atomicity. synchronized gives you both. This trips up a LOT of mid-level developers. The classic mistake: using volatile int counter for a counter you’re incrementing — counter++ is actually three operations (read, increment, write) so it’s still not thread-safe.
// DON'T do this — volatile doesn't help with compound actions
private volatile int counter = 0;
public void increment() {
counter++; // read-modify-write: NOT atomic!
}
// DO this instead
import java.util.concurrent.atomic.AtomicInteger;
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // truly atomic via CAS
}
// OR use synchronized if you have multiple fields to update together
private int counter = 0;
private int lastUpdatedBy = 0;
public synchronized void increment(int threadId) {
counter++;
lastUpdatedBy = threadId; // both updates as one atomic unit
}
Use volatile when: one thread writes, others read, and the write is a single field assignment (not a compound action). Use synchronized when you have compound actions or need to protect multiple related state changes together. Check out more on advanced Java concurrency patterns here.
3. ReentrantLock vs synchronized — what’s actually different?
Both achieve mutual exclusion, but ReentrantLock gives you control that synchronized simply doesn’t have. The key differences that interviewers want to hear:
- Interruptible lock acquisition:
lockInterruptibly()— you can bail out if the thread is interrupted while waiting. - Timed lock attempts:
tryLock(timeout, unit)— try for N milliseconds, then give up. Great for avoiding deadlocks. - Fairness policy:
new ReentrantLock(true)— longest-waiting thread gets the lock first.synchronizedgives no such guarantee. - Multiple condition variables:
lock.newCondition()— one lock, multiple wait/signal queues. Withsynchronizedyou only get one.
import java.util.concurrent.locks.*;
public class BoundedBuffer {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Object[] items;
private int head, tail, count;
public BoundedBuffer(int capacity) {
items = new Object[capacity];
}
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await(); // wait on THIS condition only
items[tail] = item;
tail = (tail + 1) % items.length;
count++;
notEmpty.signal(); // wake up consumers, not producers
} finally {
lock.unlock(); // always in finally!
}
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
T item = (T) items[head];
head = (head + 1) % items.length;
count--;
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
4. How does ThreadPoolExecutor actually work internally?
Every senior Java developer should be able to explain the lifecycle of a task submitted to a ThreadPoolExecutor. This question comes up constantly at product companies and EPAM-style interviews.
The flow is: task submitted → if active threads < corePoolSize, create new thread → else, try to enqueue in workQueue → if queue full and active threads < maxPoolSize, create new thread → else, invoke RejectedExecutionHandler.
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize
10, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime for excess threads
new ArrayBlockingQueue<>(100), // bounded work queue
new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger(1);
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "worker-" + count.getAndIncrement());
t.setDaemon(false);
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // backpressure: caller runs the task
);
// Monitor your pool in production
System.out.println("Active: " + executor.getActiveCount());
System.out.println("Queue size: " + executor.getQueue().size());
System.out.println("Completed: " + executor.getCompletedTaskCount());
Pro tip for interviews: mention that Executors.newFixedThreadPool() uses an unbounded LinkedBlockingQueue — in a high-traffic system this can silently consume all your heap memory. Always configure your own ThreadPoolExecutor with a bounded queue in production.
5. How do you detect and prevent deadlocks in Java?
Deadlock happens when thread A holds lock X and waits for lock Y, while thread B holds lock Y and waits for lock X. The classic four conditions: mutual exclusion, hold-and-wait, no preemption, circular wait. Break any one condition and you break the deadlock.
// Classic deadlock setup
Object lockA = new Object();
Object lockB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockA) {
Thread.sleep(50); // makes deadlock more likely
synchronized (lockB) { /* work */ }
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) {
synchronized (lockA) { /* work */ } // opposite order = deadlock!
}
});
// Prevention: always acquire locks in the same global order
// or use tryLock with timeout:
ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();
public boolean doWorkSafely() throws InterruptedException {
while (true) {
if (lock1.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
// do work
return true;
} finally { lock2.unlock(); }
}
} finally { lock1.unlock(); }
}
Thread.sleep(10); // back off before retry
}
}
For detection in production: run jstack <pid> and look for “Found one Java-level deadlock” in the output. Also, ThreadMXBean can detect deadlocks programmatically.
6. CompletableFuture — chaining and exception handling
This shows up in almost every 4+ year experience interview now. The key is understanding async composition and how exceptions propagate through the chain.
CompletableFuture pipeline = CompletableFuture
.supplyAsync(() -> fetchUserId(), executorService)
.thenApplyAsync(userId -> fetchUserProfile(userId), executorService)
.thenApplyAsync(profile -> enrichProfile(profile), executorService)
.exceptionally(ex -> {
log.error("Pipeline failed: {}", ex.getMessage());
return fallbackProfile(); // return default on any stage failure
})
.whenComplete((result, ex) -> {
if (ex == null) metrics.recordSuccess();
else metrics.recordFailure();
});
// Combining multiple futures
CompletableFuture userData = CompletableFuture.supplyAsync(() -> getUser(id));
CompletableFuture orderData = CompletableFuture.supplyAsync(() -> getOrders(id));
CompletableFuture dashboard = userData
.thenCombine(orderData, (user, orders) -> new DashboardData(user, orders));
// Wait for all — fails fast if any fails
CompletableFuture.allOf(userData, orderData).join();
Key distinction: thenApply is synchronous (runs on the same thread), thenApplyAsync uses the ForkJoinPool or your provided executor. In production code, always provide your own executor to thenApplyAsync — don’t let it steal from the common ForkJoinPool.
7. CAS operations and the java.util.concurrent.atomic package
Compare-And-Swap (CAS) is the hardware-level instruction that powers lock-free programming in Java. AtomicInteger, LongAdder, AtomicReference — all built on CAS. Understand the ABA problem too, that’s a common follow-up.
// How CAS works conceptually
AtomicInteger ai = new AtomicInteger(5);
// compareAndSet(expected, update): only updates if current value == expected
boolean updated = ai.compareAndSet(5, 10); // true, value is now 10
boolean failed = ai.compareAndSet(5, 20); // false, value is 10 not 5
// LongAdder beats AtomicLong under HIGH contention
// Uses cell striping — different threads update different cells, sum at the end
LongAdder adder = new LongAdder();
adder.increment();
long total = adder.sum(); // approximate but fast
// ABA problem: CAS sees value A, thinks nothing changed, but it went A -> B -> A
// Fix: AtomicStampedReference tracks a version/stamp alongside the value
AtomicStampedReference ref = new AtomicStampedReference<>("initial", 0);
int[] stampHolder = new int[1];
String current = ref.get(stampHolder);
int currentStamp = stampHolder[0];
ref.compareAndSet(current, "updated", currentStamp, currentStamp + 1);
8. ForkJoinPool and work stealing — explain the algorithm
ForkJoinPool is designed for recursive, divide-and-conquer tasks. Each worker thread has its own deque. When a thread finishes its own tasks, it steals from the tail of another thread’s deque. This minimizes contention because the owner pushes/pops from the head while thieves steal from the tail.
class MergeSortTask extends RecursiveAction {
private final int[] arr;
private final int left, right;
private static final int THRESHOLD = 1000;
MergeSortTask(int[] arr, int left, int right) {
this.arr = arr; this.left = left; this.right = right;
}
@Override
protected void compute() {
if (right - left < THRESHOLD) {
Arrays.sort(arr, left, right + 1); // base case: sequential
return;
}
int mid = (left + right) / 2;
MergeSortTask leftTask = new MergeSortTask(arr, left, mid);
MergeSortTask rightTask = new MergeSortTask(arr, mid + 1, right);
invokeAll(leftTask, rightTask); // fork both, join both
merge(arr, left, mid, right);
}
}
ForkJoinPool pool = new ForkJoinPool(Runtime.getRuntime().availableProcessors());
pool.invoke(new MergeSortTask(data, 0, data.length - 1));
9. Virtual Threads in Java 21 — what changes for existing multithreading code?
This is the hottest multithreading topic going into 2026. Virtual threads (Project Loom) are lightweight threads managed by the JVM, not the OS. You can spin up millions of them. They're mounted on carrier threads (OS threads from a ForkJoinPool) and unmounted when they block on I/O. For Java advanced topics, this one is non-negotiable now.
// Old way: platform thread per request (expensive at scale)
Thread t = new Thread(runnable);
t.start();
// Virtual thread — same API, completely different implementation
Thread vt = Thread.ofVirtual().name("virtual-worker").start(runnable);
// With ExecutorService — drop-in for most web servers / REST handlers
try (ExecutorService vtExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i ->
vtExecutor.submit(() -> {
Thread.sleep(Duration.ofMillis(100)); // blocking is fine — JVM unmounts!
return processRequest(i);
})
);
}
What NOT to do with virtual threads: Don't pool them (defeats the purpose), and avoid synchronized blocks around blocking I/O — use ReentrantLock instead, since synchronized can pin the virtual thread to its carrier thread, blocking it.
10. CountDownLatch vs CyclicBarrier vs Phaser
These three come up together constantly. Know them cold.
// CountDownLatch: one-shot, cannot be reset
CountDownLatch latch = new CountDownLatch(3);
// 3 worker threads each call latch.countDown() when done
// main thread calls latch.await() to wait for all 3
// CyclicBarrier: reusable, all parties wait for each other at the barrier
CyclicBarrier barrier = new CyclicBarrier(3, () ->
System.out.println("All threads reached barrier — processing next batch"));
// Each thread calls barrier.await() — when all 3 arrive, barrier action runs, resets
// Phaser: most flexible, dynamic party count, multiple phases
Phaser phaser = new Phaser(1); // register main thread
for (int i = 0; i < 3; i++) {
phaser.register(); // register each worker
new Thread(() -> {
phaser.arriveAndAwaitAdvance(); // phase 1
doPhase1Work();
phaser.arriveAndAwaitAdvance(); // phase 2
doPhase2Work();
phaser.arriveAndDeregister();
}).start();
}
phaser.arriveAndDeregister(); // deregister main thread
11. ThreadLocal — real use cases and the memory leak you need to explain
ThreadLocal gives each thread its own isolated copy of a variable. Used heavily in frameworks — Spring's transaction management, Hibernate's session context, MDC logging. The danger in thread pool environments: if you don't call remove(), the value stays in the thread's map forever since pooled threads don't die.
private static final ThreadLocal dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatDate(Date date) {
return dateFormat.get().format(date); // each thread gets its own instance
}
// In a servlet filter or interceptor — ALWAYS clean up!
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
try {
userContext.set(extractUser(req)); // set at entry
chain.doFilter(req, res);
} finally {
userContext.remove(); // CRITICAL — otherwise thread pool leaks context
}
}
12. Common concurrency bugs interviewers love to probe
Beyond the theory — know these patterns by heart because interviewers will show you broken code and ask you to spot the bug.
- Double-checked locking without volatile: the classic broken singleton. Fix: make the instance field
volatile. - Escaping
thisin constructor: registering a listener inside a constructor — another thread can see a partially constructed object. - Iterating a shared collection without locking: even
ConcurrentHashMapdoesn't protect you if you're doing a compound check-then-act operation. - Forgetting
unlock()in an exception path: always use try-finally withReentrantLock.
// Broken double-checked locking
public class BrokenSingleton {
private static BrokenSingleton instance;
public static BrokenSingleton getInstance() {
if (instance == null) {
synchronized (BrokenSingleton.class) {
if (instance == null)
instance = new BrokenSingleton(); // NOT safe without volatile
}
}
return instance;
}
}
// Fixed — add volatile
public class SafeSingleton {
private static volatile SafeSingleton instance; // volatile = the fix
public static SafeSingleton getInstance() {
if (instance == null) {
synchronized (SafeSingleton.class) {
if (instance == null)
instance = new SafeSingleton();
}
}
return instance;
}
}
Comparison Tables
Synchronization Primitives — Quick Reference
| Mechanism | Visibility | Atomicity | Reentrant | Interruptible | Best For |
|---|---|---|---|---|---|
volatile |
✅ | ❌ | N/A | N/A | Simple flags, single-writer |
synchronized |
✅ | ✅ | ✅ | ❌ | Simple mutual exclusion |
ReentrantLock |
✅ | ✅ | ✅ | ✅ | Complex locking scenarios |
AtomicInteger |
✅ | ✅ | N/A | N/A | Lock-free counters |
StampedLock |
✅ | ✅ | ❌ | ✅ | Read-heavy with optimistic reads |
Thread Coordination Utilities
| Utility | Reusable | Dynamic Parties | Barrier Action | Use Case |
|---|---|---|---|---|
CountDownLatch |
❌ | ❌ | ❌ | Wait for N events to complete |
CyclicBarrier |
✅ | ❌ | ✅ | Iterative algorithms, batch phases |
Phaser |
✅ | ✅ | ✅ | Multi-phase tasks, dynamic participants |
Semaphore |
✅ | N/A | ❌ | Resource pool, connection limiting |
Exchanger |
✅ | N/A | ❌ | Two-thread data handoff |
Quick Tips from the Interview Room
💡 Mention the JVM, not just the API. When talking about
volatile, bring up the memory barrier instruction it generates. Interviewers at EPAM and product startups LOVE this. It shows you think at the right level.💡 Always ask about the contention level. If asked "which is faster, AtomicLong or LongAdder?", the right answer is "it depends on contention." Under low contention,
AtomicLongis slightly faster. Under high contention,LongAdderwins by a large margin due to cell striping.💡 For TCS and Infosys L2 interviews — they often ask lifecycle-based questions: thread states (NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED) and what transitions them. Know this diagram. See company-specific question patterns here.
💡 Virtual threads ≠ async programming. This is a common misconception interviewers probe. Virtual threads are still synchronous code that blocks — the JVM just handles the blocking efficiently. They're NOT coroutines in the Kotlin sense.
💡 If you don't know, say so but build around it. "I haven't used
StampedLockin production, but I understand it adds optimistic read mode on top ofReadWriteLocksemantics — if the optimistic read fails, you fall back to a full read lock." That answer is better than silence.
Wrapping Up
Multithreading is the section where experienced Java developers prove they understand the platform, not just the syntax. If you can talk about the JMM, explain happens-before with a real example, walk through ThreadPoolExecutor's internal flow, and have an opinion on when virtual threads change the game — you're already in the top 20% of candidates I've interviewed.
The honest truth: most people know the surface-level answers. What gets you the offer at a product company or EPAM is the ability to connect the theory to production scenarios — a deadlock you diagnosed with jstack, a thread pool misconfiguration that caused an OutOfMemoryError, a ThreadLocal leak you hunted down in a staging environment.
Check out Java core fundamentals here if you want to brush up on the foundation before going deep on concurrency. And if you want to actually practice answering these under real interview pressure with feedback — book a mock interview session here. I'll grill you the same way a senior engineer at a product company would, and you'll come out knowing exactly what to fix.
Go build something concurrent. That's the only way this stuff really sticks.
