DEV Community

Rohit Kori
Rohit Kori

Posted on

Java Concurrency & Multithreading: 40 Interview Questions

1. Process vs Thread

A process has its own memory space and OS resources; threads live inside a process and share its heap and file handles but keep their own stack and program counter. That shared memory is exactly why threads are cheap to create but dangerous to coordinate - every thread can see and corrupt the same data.

2. Platform Thread vs Virtual Thread

A platform thread maps 1:1 to an OS thread, so it's expensive (megabyte-sized stack, kernel scheduling) and you cap pools at a few hundred. A virtual thread (Project Loom, Java 21) is a lightweight thread managed by the JVM, backed by a small pool of "carrier" platform threads; when it blocks on I/O it unmounts from its carrier so the carrier can run another virtual thread. You can spawn millions of them.

Thread vt = Thread.ofVirtual().start(() -> System.out.println("running"));
Enter fullscreen mode Exit fullscreen mode

3. Java Memory Model (JMM)

The JMM defines what values a thread is guaranteed to see when another thread writes shared state, in the presence of compiler and CPU reordering and per-core caching. Without it, a write on thread A might sit in a CPU cache line or register and never become visible to thread B. volatile, synchronized, and final fields are the JMM's visibility guarantees.

4. Happens-Before

"A happens-before B" means every write A made is guaranteed visible to B - it's the JMM's ordering contract. Key sources: a monitor unlock happens-before the next lock on the same monitor; a volatile write happens-before a subsequent volatile read of the same field; a thread's actions happen-before another thread observes it has terminated via join().

5. synchronized vs ReentrantLock

synchronized is a JVM-managed intrinsic lock - simpler, auto-releases on exception, but no timeout, no fairness option, no interruptible acquire. ReentrantLock is an explicit java.util.concurrent.locks API giving you tryLock(timeout), lockInterruptibly(), and fairness, at the cost of needing a try/finally to guarantee release.

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // critical section
} finally {
    lock.unlock();
}
Enter fullscreen mode Exit fullscreen mode

6. volatile vs Atomic

volatile only guarantees visibility and ordering - it does not make compound operations like count++ atomic, because that's a read-modify-write of three separate steps. AtomicInteger/AtomicLong use CAS internally to make that compound operation atomic and visible in one step.

AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet(); // atomic, unlike volatile int counter; counter++;
Enter fullscreen mode Exit fullscreen mode

7. Compare-And-Swap (CAS)

CAS is a single CPU instruction: "if the memory location still holds the value I last read, swap in my new value; otherwise fail." It lets you build lock-free updates - read the current value, compute the new one, then CAS it in; if another thread beat you to it, loop and retry. This avoids blocking, which is why atomics and ConcurrentHashMap outperform coarse locks under contention.

8. How ConcurrentHashMap Achieves Thread Safety

Modern ConcurrentHashMap (Java 8+) locks per-bin, not the whole table: reads are lock-free (volatile reads of bin heads), and writes synchronize only on the specific bin (or use CAS for the very first insert into an empty bin). Resizing is done cooperatively - multiple threads can help move bins during a resize. Contrast this with the old Hashtable/Collections.synchronizedMap, which lock the entire map for every operation.

9. Race Condition

A race condition happens when the correctness of a result depends on the timing/interleaving of threads accessing shared mutable state without proper synchronization. Classic example: two threads both read balance = 100, both add 50, both write back 150 - one deposit is lost because neither read saw the other's write.

10. Preventing Race Conditions

Options in order of preference: eliminate shared mutable state (immutability, thread confinement); use higher-level concurrent utilities (ConcurrentHashMap, AtomicInteger, BlockingQueue); use explicit locking (synchronized, ReentrantLock) only around the smallest critical section needed. The intuition is always the same - make the read-modify-write sequence indivisible from other threads' perspective.

11. Deadlock

Deadlock is two or more threads each holding a resource the other needs, so all are blocked forever. Classic case: Thread A locks resource1 then wants resource2; Thread B locks resource2 then wants resource1.

synchronized (resource1) {
    synchronized (resource2) { /* ... */ }
}
// another thread does the reverse lock order -> deadlock
Enter fullscreen mode Exit fullscreen mode

12. Detecting and Preventing Deadlocks

Detect: take a thread dump (jstack <pid>) - the JVM explicitly reports "Found one Java-level deadlock" with the cycle of threads and locks. Prevent: always acquire multiple locks in a fixed global order, use tryLock with a timeout instead of blocking lock(), or avoid holding more than one lock at a time.

13. Deadlock vs Livelock vs Starvation

Deadlock: threads are blocked, nothing moves. Livelock: threads keep changing state in response to each other (e.g., both stepping aside repeatedly) but neither makes progress - busy but stuck. Starvation: a thread never gets CPU time or a lock because other threads keep getting priority, e.g., a low-priority thread constantly passed over.

14. Thread Starvation

A specific thread is perpetually denied a resource it needs, often because an unfair lock keeps granting access to other threads, or a thread has low priority in a priority-based scheduler. Fix with ReentrantLock(true) for fairness, or redesign so no single thread type can be permanently outcompeted.

15. ExecutorService vs ForkJoinPool

ExecutorService (e.g., ThreadPoolExecutor) runs independent tasks from a shared queue - good for I/O-bound or unrelated work. ForkJoinPool is built for divide-and-conquer: a task splits into subtasks, and idle worker threads steal work from busier threads' queues (work-stealing), which is ideal for CPU-bound recursive problems like parallel sort or parallelStream().

16. How ThreadPoolExecutor Works

It holds a work queue and a pool of worker threads. Submitting a task: if threads < corePoolSize, spawn a new thread; else queue the task; if the queue is full and threads < maximumPoolSize, spawn an overflow thread; if that's also maxed out, the RejectedExecutionHandler kicks in. Idle threads beyond corePoolSize die after keepAliveTime.

new ThreadPoolExecutor(
    4, 10, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(100),
    new ThreadPoolExecutor.CallerRunsPolicy()
);
Enter fullscreen mode Exit fullscreen mode

17. Choosing Core and Maximum Pool Size

For CPU-bound work, size core threads near Runtime.getRuntime().availableProcessors() - more threads than cores just adds context-switching overhead. For I/O-bound work, threads spend time blocked/waiting, so you can go much higher; a common formula is cores * (1 + waitTime/computeTime). Maximum pool size is your burst capacity ceiling, bounded by memory and downstream systems (DB connections, etc.).

18. Queue Full in ThreadPoolExecutor

Once the queue is full and the pool is at maximumPoolSize, new submissions hit the RejectedExecutionHandler. The four built-in policies: AbortPolicy (throws RejectedExecutionException, default), CallerRunsPolicy (runs the task on the submitting thread itself, a natural backpressure mechanism), DiscardPolicy (silently drops it), DiscardOldestPolicy (drops the oldest queued task and retries).

19. CompletableFuture vs Future

A plain Future only lets you get() and block for the result - no way to chain, combine, or get notified on completion. CompletableFuture supports callback chaining (thenApply, thenCompose), combining multiple futures, exception handling (exceptionally, handle), and manual completion - it turns async code into a readable pipeline instead of blocking calls.

CompletableFuture.supplyAsync(() -> fetchUser())
    .thenApply(User::getName)
    .thenAccept(System.out::println);
Enter fullscreen mode Exit fullscreen mode

20. How CompletableFuture Works Internally

It's a state machine holding a result (or exception) plus a list of dependent actions ("completion stages"). When you call .thenApply(fn), if the future is already complete, fn runs immediately (possibly on the calling thread); if not, fn is stored and triggered later by whichever thread eventually completes the future. By default the callback thread is the completing thread or the common ForkJoinPool; the Async variants (thenApplyAsync) submit to a pool explicitly.

21. thenApply vs thenCompose vs thenCombine

thenApply(fn): transform the result, fn returns a plain value - use when you have T -> R. thenCompose(fn): fn returns another CompletableFuture<R>, and it flattens the nesting - use it to chain dependent async calls (avoids CompletableFuture<CompletableFuture<R>>). thenCombine(other, fn): waits for two independent futures and merges their results - use it when two async calls don't depend on each other.

22. CountDownLatch

A one-time gate: initialized with a count, threads call countDown() as they finish, and any thread waiting on await() unblocks once the count hits zero. Common use: main thread waits for N worker threads to finish startup before proceeding. It cannot be reset or reused.

CountDownLatch latch = new CountDownLatch(3);
// each worker: doWork(); latch.countDown();
latch.await(); // blocks until all 3 call countDown
Enter fullscreen mode Exit fullscreen mode

23. CountDownLatch vs CyclicBarrier

CountDownLatch is one-shot and asymmetric - some threads count down, a different thread (or threads) waits. CyclicBarrier is reusable and symmetric - the same group of N threads all call await() and all get released together once N have arrived, then the barrier resets automatically for the next round (useful for iterative parallel algorithms where every thread must finish a phase before any starts the next).

24. Semaphore

A Semaphore guards access to a limited number of permits - think of it as a counter-based lock allowing up to N concurrent holders instead of just one. Typical use: limiting concurrent connections to a database or external API to, say, 10 at a time.

Semaphore semaphore = new Semaphore(10);
semaphore.acquire();
try { callExternalApi(); } finally { semaphore.release(); }
Enter fullscreen mode Exit fullscreen mode

25. Phaser

Phaser is a more flexible, reusable barrier than CyclicBarrier - the number of participating threads can change dynamically between phases (threads can register and deregister), and it supports both blocking and non-blocking (arriveAndAwaitAdvance vs arrive) coordination across multiple phases. It's rarely needed in practice; CyclicBarrier covers most fixed-thread-count phase scenarios.

26. ThreadLocal

ThreadLocal<T> gives each thread its own independent copy of a variable - reads and writes never cross threads, so no synchronization is needed. Classic use: SimpleDateFormat (not thread-safe) given one instance per thread, or storing a per-request user context in a web server.

private static final ThreadLocal<SimpleDateFormat> FORMAT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
Enter fullscreen mode Exit fullscreen mode

27. Problems ThreadLocal Can Cause

The big one is memory leaks in pooled-thread environments (app servers, executor pools): pool threads live forever, so if you don't call remove() after use, the value stays referenced from the thread forever, and with class reloading (as in app server redeploys) this can leak entire classloaders. Always clear it in a finally block.

28. False Sharing

False sharing happens when two threads modify different variables that happen to sit on the same CPU cache line (typically 64 bytes) - even though the variables are logically independent, the CPU cache-coherence protocol invalidates the whole line on every write, forcing expensive cross-core synchronization. Fix by padding fields (or using @Contended in the JDK) so hot variables land on separate cache lines.

29. ForkJoinPool

A specialized executor for divide-and-conquer tasks using the work-stealing algorithm: each worker thread has its own deque of subtasks, pushes/pops from one end for its own work, and idle threads steal from the other end of a busy thread's deque. This keeps all cores busy even when task sizes are uneven. RecursiveTask<V> (returns a value) and RecursiveAction (no return) are the building blocks.

class SumTask extends RecursiveTask<Long> {
    protected Long compute() {
        if (small enough) return sequentialSum();
        SumTask left = new SumTask(...); left.fork();
        SumTask right = new SumTask(...);
        return right.compute() + left.join();
    }
}
Enter fullscreen mode Exit fullscreen mode

30. How Parallel Streams Use Threads

parallelStream() splits the source into chunks (via Spliterator) and processes them using the common ForkJoinPool - the same pool the whole JVM shares by default. Each chunk runs compute()-style, recursively splitting until chunks are small enough, and results are combined.

31. Why Parallel Streams Can Hurt Performance

Because they share the single common ForkJoinPool JVM-wide, a blocking I/O call inside a parallel stream can starve every other parallel stream and CompletableFuture in the application. Also, splitting has overhead - for small collections or a data structure that splits poorly (like a LinkedList), the coordination cost outweighs the parallel gain. Only use it for CPU-bound work on large, easily-splittable collections (arrays, ArrayList).

32. How Virtual Threads Work Internally

A virtual thread is a JVM-level continuation running on top of a small pool of carrier platform threads (by default, one per CPU core). When a virtual thread calls a blocking operation that the JDK has been updated to recognize (most I/O, Thread.sleep, java.util.concurrent locks as of Java 21+), it "parks" - the JVM saves its continuation state and frees the carrier thread to run a different virtual thread. When the blocking operation completes, the virtual thread is rescheduled onto any available carrier.

33. When NOT to Use Virtual Threads

Don't use them for CPU-bound work - since they still ultimately run on the same small number of carrier threads, spawning a million CPU-bound virtual threads gives you no more parallelism than the core count and adds scheduling overhead; ForkJoinPool/parallel streams are better there. Also avoid them with synchronized blocks around blocking calls in older code paths - until Java 21's pinning fixes, a virtual thread blocked inside synchronized "pins" its carrier thread and can't unmount, effectively becoming a platform thread and defeating the purpose.

34. Virtual Threads vs CompletableFuture

Both solve the "don't block a scarce platform thread" problem, but differently. CompletableFuture achieves it by making code asynchronous and callback-based, which is scalable but harder to read/debug (broken stack traces, nested chains). Virtual threads let you write plain sequential, blocking-looking code (InputStream.read(), result = future.get()) while the JVM handles the unmounting under the hood - same scalability, much simpler code.

35. Handling Blocking Operations with Virtual Threads

Just call them directly - that's the whole point. Standard blocking I/O (socket reads, JDBC calls once drivers are updated, Thread.sleep) automatically yields the carrier thread. The one thing to actively avoid is wrapping blocking calls in synchronized blocks (use ReentrantLock instead) since that can still pin the carrier in some JDK versions, and to avoid pooling virtual threads yourself - create a fresh one per task instead of reusing them in a fixed-size pool, since that defeats their purpose.

36. Debugging a Production Thread Issue

Take a thread dump (jstack <pid> or kill -3 <pid>) to see every thread's state and stack trace at that instant. Look for threads stuck in BLOCKED state waiting on the same monitor (contention or deadlock), or many threads WAITING/TIMED_WAITING on the same queue (backpressure). Take 2–3 dumps a few seconds apart to distinguish "stuck" from "just slow."

37. Investigating High CPU from Java Threads

Use top -H -p <pid> to find which native thread IDs are burning CPU, convert that thread ID to hex, then match it against the nid=0x... field in a jstack thread dump to identify exactly which Java thread and stack trace is responsible. Often it's a busy-wait loop, an infinite retry without backoff, or a GC issue (in which case jstat -gcutil tells you if GC is actually the CPU consumer).

38. Identifying Thread Pool Exhaustion

Symptoms: tasks queueing up and response times climbing while thread count stays flat at maximumPoolSize. In a thread dump, you'll see all pool worker threads busy (often all blocked on the same downstream call, like a slow DB), and the executor's queue size (exposed via getQueue().size() or JMX) growing. Fix by finding why individual tasks are taking longer (usually a slow dependency) rather than just raising the pool size, which can just push the bottleneck further downstream.

39. Designing a Thread-Safe Cache

Use ConcurrentHashMap as the backing store for fine-grained locking, and use computeIfAbsent to atomically check-and-populate on a miss so two threads never both run an expensive load for the same key.

ConcurrentHashMap<String, Data> cache = new ConcurrentHashMap<>();
Data value = cache.computeIfAbsent(key, k -> loadFromDb(k));
Enter fullscreen mode Exit fullscreen mode

For eviction, add size/TTL logic - either roll your own with a ScheduledExecutorService sweeping expired entries, or use Caffeine, which already handles concurrent eviction correctly.

40. A Real-World Concurrency Problem

A good structure for this answer: describe the symptom (e.g., "we saw intermittent duplicate messages processed under load in a high-throughput microservice"), the root cause (e.g., "two consumer threads both read the same DB row as 'unprocessed' before either had committed its update - a check-then-act race"), the fix (e.g., "moved to a single atomic UPDATE ... WHERE status = 'PENDING' with SELECT ... FOR UPDATE, or a ConcurrentHashMap-based in-memory lock keyed by message ID"), and how you verified it (load test reproducing the race, then confirming it was gone). Bring a genuine example from your BICS or Arcesium work if you have one - interviewers value specificity over textbook answers here.

Top comments (0)