Java Concurrency & Multithreading Interview Questions
Threads, synchronization, the java.util.concurrent toolkit, and modern Java concurrency (virtual threads, structured concurrency) -- built from a race condition happening on purpose, not memorized API lists.
← Learn this topic from scratch firstFoundations
What is the difference between a process and a thread?
beginnerA process is a running program with its own isolated memory space, including its own heap, stack, and code. Threads, by contrast, share the heap of their parent process but each thread maintains its own stack (typically 512KB to 1MB) and program counter. Because processes are isolated, communication between them requires inter-process communication mechanisms like pipes or sockets, whereas threads communicate directly through shared heap variables, which introduces the need for synchronization to avoid race conditions. Creating a new process is relatively expensive because the operating system must set up a new address space and associated data structures, while creating a new thread is cheap since it only requires a new stack and reuses the parent's existing resources.
What are the six states of a Java thread, and how does a thread move between them?
beginnerA Java thread's lifecycle passes through six distinct states. NEW means the thread object has been created but start() has not yet been called. RUNNABLE covers both a thread that is actively executing on the CPU and one that is ready to run and waiting for CPU time. BLOCKED means the thread is waiting to acquire a synchronized lock that another thread currently holds. WAITING means the thread is waiting indefinitely for another thread to act, typically via wait(), join(), or LockSupport.park(). TIMED_WAITING is the same idea but with a timeout, as with sleep(n), wait(n), or join(n). TERMINATED means the run() method has completed or the thread has thrown an uncaught exception. You can inspect a thread's current state at any time by calling thread.getState(); a thread can never move back to NEW once it has been started, and TERMINATED is a final state with no further transitions.
What is a race condition? Can you give an example?
beginnerA race condition happens when the correctness of a program's outcome depends on the unpredictable timing or interleaving of multiple threads. A classic example is the expression count++, which is not atomic even though it looks like a single operation: it actually compiles down to three steps -- read the current value, add one, and write the result back. If two threads both read count as 5 at the same time, both compute 6, and both write 6 back, the final value is 6 even though two increments happened and the expected result was 7 -- one update is silently lost. This is fixed by making the operation atomic, for example by wrapping it in a synchronized block, using AtomicInteger's incrementAndGet() method, or using LongAdder for high-throughput counters.
What does the volatile keyword guarantee in Java, and what does it not guarantee?
beginnervolatile guarantees visibility: a write to a volatile field is immediately flushed to main memory, and every subsequent read of that field loads directly from main memory rather than a possibly stale CPU cache. It also establishes a happens-before relationship, meaning a volatile write happens-before every subsequent volatile read of the same field, which forces the JVM and CPU to keep earlier writes properly ordered and visible. What volatile does not guarantee is atomicity for compound operations: with a declaration like volatile int x, the statement x++ is still a race condition, because increment is a read-modify-write sequence made of three separate, non-atomic steps. To get atomic compound operations you need a class like AtomicInteger instead of a bare volatile field.
What is the difference between a synchronized method and a synchronized block?
beginnerA synchronized instance method locks on the object instance (this) for the entire duration of the method call, while a synchronized static method locks on the Class object itself. A synchronized block, by contrast, lets you choose exactly which object to lock on and exactly which lines of code fall inside the critical section. Blocks are generally preferred over synchronizing whole methods because they give finer-grained control: the lock is held for less time (only around the code that actually needs protection), you can use a dedicated private lock object so external code can't accidentally interfere with your locking, and you can use multiple independent lock objects to protect different resources within the same class without unnecessarily serializing unrelated operations.
Why must a call to wait() always be inside a while loop rather than an if statement?
beginnerThere are two independent reasons. First, spurious wakeups: the JVM specification explicitly permits wait() to return even though notify() was never called, which can happen on certain OS and hardware combinations, so the waiting thread must re-check its condition after waking rather than assuming it is now true. Second, even when notifyAll() legitimately wakes every waiting thread, those threads still have to race each other for the lock -- the first one to acquire it may find the condition true and consume it, leaving the condition false again for everyone else. A while loop handles both cases correctly by re-checking the condition and calling wait() again if it is still false, whereas an if statement would let a thread proceed past a now-false condition and corrupt program state.
What is the difference between Thread.sleep() and Object.wait()?
beginnerThread.sleep(n) simply pauses the currently executing thread for n milliseconds without releasing any locks it currently holds, so other threads waiting for those same locks remain blocked for the entire sleep duration. Object.wait(), by contrast, must be called from inside a synchronized block or method, and when called it releases the lock it was holding and adds the thread to that object's internal wait-set, allowing other threads to acquire the lock and make progress. Another key difference is how each thread wakes up: a sleeping thread wakes automatically once the timeout elapses, while a waiting thread can only be woken by another thread calling notify() or notifyAll() on the same object (or by being interrupted) -- there is no automatic timeout unless you use the timed overload wait(n).
What is a daemon thread, and how do you create one?
beginnerA daemon thread is a background thread that does not prevent the JVM from shutting down. The JVM exits as soon as every non-daemon (user) thread has finished running, and at that point any remaining daemon threads are abruptly terminated without any further cleanup. You create one by calling thread.setDaemon(true) before calling start() -- calling it after the thread has already started throws an IllegalThreadStateException. A thread also inherits the daemon status of whichever thread created it, so any thread spawned from a daemon thread is itself a daemon by default. Daemon threads are a natural fit for background work that should never block shutdown, such as heartbeat pings, periodic cache refreshes, or log flushers.
What happens if you call run() directly instead of calling start() on a Thread?
beginnerCalling run() directly simply executes the Runnable's logic synchronously on whichever thread made the call -- it behaves exactly like calling any ordinary method, and no new thread is created. start() is what actually creates and launches a new operating-system thread, which then calls run() asynchronously on that new thread. This is a common beginner mistake because it doesn't throw an exception or fail loudly: the code appears to work correctly, but it silently runs single-threaded on the calling thread, completely defeating the purpose of introducing threading in the first place.
What does it mean for a class to be thread-safe, and what strategies can you use to achieve it?
beginnerA class is thread-safe if it behaves correctly when it is accessed concurrently by multiple threads, without requiring the calling code to add any extra synchronization of its own. There are several common strategies for achieving this. Making a class stateless -- with no instance variables, so every method call operates only on local variables -- automatically makes it thread-safe. Immutability, achieved through final fields, no setter methods, and defensive copies, also removes the possibility of unsafe concurrent mutation. Thread confinement restricts a piece of state so that only one thread ever touches it, for example using ThreadLocal. Explicit synchronization, using synchronized blocks, ReentrantLock, or atomic variables, protects mutable shared state directly. Finally, you can simply reuse existing thread-safe classes such as ConcurrentHashMap, BlockingQueue implementations, or AtomicInteger instead of writing your own synchronization logic.
What is the happens-before relationship in the Java Memory Model, and why does it matter?
beginnerThe happens-before relationship is a guarantee defined by the Java Memory Model: if action A happens-before action B, then every effect of A is guaranteed to be visible to the thread performing B. Several rules establish happens-before edges: unlocking a monitor happens-before any subsequent lock of that same monitor by another thread; a write to a volatile field happens-before any subsequent read of that field; every action taken inside a thread before it calls Thread.start() happens-before any action performed by the newly started thread; and every action performed by a thread before it finishes happens-before any action taken by a thread that successfully calls join() on it. Without an established happens-before relationship between two threads, there is no guarantee that one thread will ever see the latest values written by another -- it may keep reading stale, cached values indefinitely, which is why understanding these rules is essential for writing correct concurrent code.
How does calling interrupt() on a thread work, and how should InterruptedException be handled correctly?
beginnerCalling interrupt() simply sets a boolean interrupt flag on the target thread -- it does not forcibly stop the thread or unwind its stack. If the target thread happens to be blocked inside sleep(), wait(), or join() at the time, those methods immediately throw an InterruptedException and clear the interrupt flag as part of throwing it. There are two correct ways to handle that exception: you can propagate it by declaring throws InterruptedException on your own method and letting the caller decide what to do, or you can catch it, call Thread.currentThread().interrupt() to restore the interrupt flag, and then return or break out of whatever loop you were in. What you should never do is swallow it with an empty catch block, because that silently discards the cancellation signal and prevents the application from shutting down cleanly.
What is an intrinsic lock (monitor) in Java?
beginnerEvery object in Java has an associated intrinsic lock, also called a monitor, built into the object header. When a thread enters a synchronized method or a synchronized block, it acquires the intrinsic lock of the relevant object, and only one thread can hold that lock at any given time -- any other thread that tries to acquire the same lock is placed into the BLOCKED state until it becomes available. Intrinsic locks are also reentrant, meaning the same thread that already holds a lock can acquire it again without deadlocking itself, which happens naturally when a synchronized method calls another synchronized method on the same object; the JVM tracks this with a per-thread hold count that increments on each re-acquisition and decrements on each release, only fully releasing the lock once the count reaches zero.
Why is HashMap not thread-safe, and what should you use instead in concurrent code?
beginnerHashMap performs no internal synchronization, so concurrent put() calls from multiple threads can update the same bucket at the same time and silently lose one of the updates. In older versions of Java, concurrent resizing of a HashMap could even corrupt its internal bucket chains into a circular structure, causing get() to spin forever in an infinite loop. The recommended replacement is ConcurrentHashMap, which supports lock-free reads, uses fine-grained per-bucket locking for writes, and provides atomic compound operations like compute(), merge(), and computeIfAbsent(). It's also worth avoiding Collections.synchronizedMap() as a fix, because it wraps every single operation, including reads, in one coarse-grained lock, and compound operations performed on it (like check-then-act) still require external synchronization to be correct.
What is the difference between notify() and notifyAll(), and which should you use?
beginnernotify() wakes up exactly one arbitrary thread from the object's wait-set, while notifyAll() wakes every thread currently waiting on that object, and each one re-checks its own condition after waking to decide whether to proceed or go back to waiting. As a default, you should use notifyAll(): notify() is only safe in the narrow case where every waiting thread is waiting on the exact same condition and any one of them is equally able to proceed once woken. When there are different kinds of waiters on the same lock, for example producers and consumers both waiting on the same object, notify() might wake a producer when what's actually needed is a consumer, and repeating this pattern can leave threads waiting forever -- a lost wakeup. notifyAll() avoids this problem entirely because every thread gets a chance to re-evaluate its own condition, so it is always safe even though it can occasionally wake more threads than strictly necessary.
How do you safely stop a running thread in Java?
beginnerThe correct approach is cooperative cancellation using interruption rather than forcibly terminating the thread. The thread's own run loop periodically checks a flag, typically written as while (!Thread.currentThread().isInterrupted()) { doWork(); }, and another thread signals it to stop by calling thread.interrupt(). If the thread happens to be blocked in a call like sleep() or wait() when interrupted, that call throws InterruptedException immediately, which the thread should catch, use to restore the interrupt flag, and then use as a signal to break out of its loop. You should never use the deprecated Thread.stop() method, because it forcibly kills the thread in the middle of whatever it was doing, which can leave shared data structures in an inconsistent, partially updated state -- comparable to yanking the power cord out of a running computer.
What does it mean for a lock to be reentrant?
beginnerA lock is reentrant if a thread that already holds it is allowed to acquire it again without deadlocking itself. Both Java's intrinsic locks used by the synchronized keyword and the explicit ReentrantLock class are reentrant, and the JVM implements this by tracking a per-thread hold count: each time the owning thread re-acquires the lock the count goes up, and each unlock call decrements it, with the lock only becoming fully available to other threads once the count returns to zero. Reentrancy matters in practice because without it, something as ordinary as a synchronized method calling another synchronized method on the same object would deadlock the calling thread against itself, since it would already own the lock and yet be forced to wait to acquire it again.
What is a ThreadLocal, and when would you use one?
beginnerA ThreadLocal gives each thread its own independent copy of a variable, so reads and writes from one thread are never visible to another thread using the same ThreadLocal instance. Internally, each Thread object holds its own ThreadLocalMap, keyed by the ThreadLocal instances it has been given values for. Common use cases include carrying web request context, such as a user ID or request ID, that is set once at the entry point of a request filter and then read anywhere further down the call chain without having to pass it explicitly through every method signature; caching per-thread expensive objects like a SimpleDateFormat instance or a database connection; and carrying transaction context through a call stack. One important rule when using ThreadLocal inside a thread pool is to always call remove() in a finally block, because otherwise the value lingers on the pooled thread and leaks into whatever unrelated request is handled by that same thread next.
What is the double-checked locking pattern, and what makes it correct?
beginnerDouble-checked locking is a technique for lazily initializing a singleton while minimizing synchronization overhead: the code checks if the instance is null, and only if it is does it enter a synchronized block, check null a second time inside the lock, and then create the instance. The essential detail that makes this correct is declaring the instance field volatile. Without volatile, the JVM is free to reorder the steps of object construction, which means another thread could observe a non-null reference to the field before the constructor has actually finished initializing all of that object's fields -- it would see a partially constructed object. Marking the field volatile ensures that the write to the reference is fully visible only after all of the constructor's field assignments have completed, so any thread that sees a non-null reference is guaranteed to see a fully initialized object. This pattern was actually broken in Java versions before Java 5, and only became safe after the JSR-133 memory model revision introduced in Java 5.
How does ConcurrentHashMap achieve thread safety without locking on reads?
beginnerSince Java 8, ConcurrentHashMap stores its entries in an array of Node buckets where each node's value field is declared volatile. Because reads simply traverse the bucket chain using volatile field reads, and volatile alone guarantees visibility of the latest written value, no lock is needed at all for get() operations -- reads never block. Writes, on the other hand, only need to lock the head node of the specific bucket being modified: the very first insertion into an empty bucket is done with a compare-and-swap, and subsequent insertions into an already-occupied bucket use a synchronized block scoped to that bucket's head node. This means reads never block behind writes, and writes to different buckets never contend with each other at all. One consequence of this design is that the map's overall size is tracked using a distributed counter similar to LongAdder, which makes size() an approximate rather than perfectly exact count under concurrent modification.
Concurrency Utilities
What is the difference between submit() and execute() on an ExecutorService?
intermediateexecute(Runnable) simply runs the task and returns void, giving you no way to track when it finishes or to retrieve any exception it threw. submit(Runnable) instead returns a Future<?> that you can call get() on to block until the task completes and to discover whether it threw an exception; submitting a Callable<T> similarly returns a Future<T> that lets you retrieve the task's actual result. The key practical difference is exception handling: an unchecked exception thrown from a Runnable passed to execute() is silently reported to the thread's UncaughtExceptionHandler and never reaches the caller, whereas any exception thrown by a task submitted via submit() is captured inside its Future and re-thrown, wrapped, when you call get(). Because of this, submit() should generally be preferred in production code so failures aren't silently swallowed.
How do you correctly and safely shut down an ExecutorService?
intermediateThe recommended approach is a two-phase shutdown. First call executor.shutdown(), which stops the executor from accepting new tasks but allows any tasks already submitted to finish running. Then call executor.awaitTermination(60, TimeUnit.SECONDS) to block for up to a chosen timeout while those in-flight tasks complete. If the executor still hasn't terminated after that wait, call executor.shutdownNow(), which attempts to interrupt all actively running tasks and returns a list of the tasks that were queued but never started, and then call awaitTermination() again to confirm everything has actually stopped. As of Java 19, ExecutorService also implements AutoCloseable, so wrapping its use in a try-with-resources block, such as try (ExecutorService e = ...) { ... }, will automatically call shutdown() and wait for termination when the block exits.
What are the general guidelines for sizing a thread pool?
intermediateA widely used starting point, popularized by Brian Goetz, distinguishes CPU-bound from I/O-bound workloads. For CPU-bound work, the recommended pool size is roughly the number of CPU cores plus one, with the extra thread accounting for occasional page faults or brief pauses. For I/O-bound work, the formula scales with how much time each task spends waiting versus computing: number of threads equals number of CPUs multiplied by (1 + wait time / compute time). For example, with 4 CPUs and a task that spends 9ms waiting for every 1ms of actual computation, that works out to 4 x 10 = 40 threads. With virtual threads, introduced in Java 21, this sizing exercise mostly goes away for I/O-bound work -- instead of carefully sizing a pool, you simply use Executors.newVirtualThreadPerTaskExecutor() and let the JVM handle scheduling. In all cases, these formulas are only starting points, and the actual right size should be confirmed with real load testing.
What is the difference between Callable and Runnable in Java?
intermediateRunnable's run() method returns void and cannot declare or throw any checked exception, so any error condition has to be handled entirely inside the method itself. Callable<T>'s call() method, by contrast, returns a value of type T and is declared to throw Exception, meaning both a real return value and any checked exception can propagate out through it. Both interfaces are functional interfaces, so as of Java 8 both can be written as lambda expressions. When you submit a Callable to an ExecutorService, you get back a Future<T> that you can use to retrieve the eventual result; if the Callable throws, that exception is wrapped inside an ExecutionException when you call get(), and you retrieve the original cause by calling getCause() on it.
How does ReentrantLock differ from the synchronized keyword?
intermediateReentrantLock provides several capabilities that plain synchronized does not offer. tryLock() attempts to acquire the lock without blocking at all, and tryLock(timeout) gives up and returns false after a specified wait period instead of blocking indefinitely. lockInterruptibly() allows a thread that is currently waiting for the lock to be interrupted and abort the wait. A ReentrantLock can also be constructed in fair mode, with new ReentrantLock(true), which serves waiting threads strictly in the order they requested the lock rather than allowing arbitrary jumping of the queue. It also supports multiple independent Condition objects tied to the same lock, which lets you maintain separate wait-sets, for example one for producers and one for consumers, and wake only the relevant group with signal() instead of always waking everyone with signalAll(). It additionally exposes introspection methods to query the lock's current state. The tradeoff is that, unlike synchronized, ReentrantLock is never released automatically, so you must always call unlock() inside a finally block to avoid leaking the lock if an exception is thrown.
What is Compare-And-Swap (CAS), and how does AtomicInteger use it?
intermediateCompare-And-Swap is a single, hardware-supported CPU instruction (CMPXCHG on x86) that atomically does the following: if the current value in memory equals some expected value, replace it with a new value and report success; otherwise leave memory unchanged and report failure. AtomicInteger's incrementAndGet() method uses this in a retry loop: it reads the current value, computes current-plus-one, and then attempts a CAS that only succeeds if nobody else has changed the value in the meantime. If another thread modified the value between the read and the CAS attempt, the CAS fails and the loop simply retries with the new current value. Under low contention this loop typically succeeds on its very first try; under heavy contention it may retry a handful of times, but crucially it never blocks and can never deadlock. This style is called lock-free programming, because forward progress is still guaranteed overall -- a CAS failure for one thread always means some other thread successfully made progress.
When should you use LongAdder instead of AtomicLong?
intermediateLongAdder internally maintains a base value plus an array of separate cells, using an approach similar to the Striped64 technique, and each thread hashes to one of those cells to perform its increments rather than all threads fighting over a single shared value. Calling sum() adds up the base plus every cell to produce the total. Because different threads are typically writing to different cells, LongAdder scales close to linearly under high contention, whereas AtomicLong forces every incrementing thread to compete for compare-and-swap on the exact same memory location, which degrades badly as contention rises. The practical rule is to use LongAdder when many threads are incrementing frequently and you only need to read the total occasionally, such as for metrics collection or request counters, and to use AtomicLong when you need operations like compareAndSet(), need the precise current value at any moment, or expect only moderate contention.
What is the difference between CountDownLatch and CyclicBarrier, and when would you use each?
intermediateCountDownLatch is a one-shot, asymmetric coordination tool: some number of threads call countDown() to decrement a counter, while a possibly different set of threads call await() to block until that counter reaches zero. Once the count hits zero it stays at zero permanently -- a CountDownLatch cannot be reset or reused. It's well suited to situations like waiting for a fixed number of services to finish starting up, or waiting for a fixed number of independent parallel tasks to complete. CyclicBarrier, by contrast, is reusable and symmetric: all N participating threads call the same await() method, all of them block until the last one arrives, and then all of them are released together, optionally running a barrier action first. Because it automatically resets for the next round, it's well suited to multi-phase parallel computations where every thread must finish the current phase before any of them is allowed to begin the next one.
What is a Semaphore, and how is it different from a mutex?
intermediateA Semaphore maintains a fixed number of permits: calling acquire() takes one permit, blocking if none are currently available, and calling release() returns one permit back to the pool. A semaphore configured with exactly one permit behaves superficially like a mutex, but there are important differences. A mutex enforces ownership -- only the thread that acquired it is allowed to release it -- whereas a Semaphore has no notion of ownership at all and any thread can call release(), even one that never called acquire(). A Semaphore is fundamentally just a counter, not a lock with acquire-release semantics tied to a specific thread. It also doesn't support reentrancy: a thread that calls acquire() twice against a semaphore with no permits remaining will simply block forever, unlike a reentrant lock. Semaphores are commonly used for rate limiting a maximum number of concurrent operations, managing connection pools, or generally controlling access to a bounded resource.
What is ExecutorCompletionService, and when is it useful?
intermediateExecutorCompletionService wraps an underlying ExecutorService together with an internal BlockingQueue. As each submitted task finishes, its resulting Future is placed onto that queue in the order the tasks actually completed, rather than the order they were submitted in. Calling take() blocks until the next completed result becomes available, while poll() returns null immediately if nothing has finished yet. This is especially useful when you submit a batch of independent tasks and want to start processing each result as soon as it's ready, without being stuck waiting for a slower task to finish before you can look at the results of faster ones -- for example, submitting ten HTTP fetches and rendering each page as it arrives instead of waiting for the single slowest request to complete before showing anything.
What is the ABA problem in lock-free programming, and how do you solve it?
intermediateThe ABA problem occurs when Thread 1 reads a value A from memory, then before it acts on that value, Thread 2 changes the value from A to B and then back to A again. When Thread 1 finally performs its compare-and-swap expecting A, the CAS succeeds because the value is indeed A again -- but Thread 1 has no way of knowing the value was actually modified and restored in between, which it wrongly assumes never happened. In many simple cases this is harmless, but in lock-free data structures such as lock-free queues, it can silently corrupt internal state. The standard fix in Java is AtomicStampedReference<V>, which pairs the actual value with a monotonically increasing integer stamp; its compareAndSet checks both the value and the stamp together, so even if the value has cycled back to A, the stamp will have changed in the meantime, causing the CAS to correctly fail and forcing a retry.
What are the four method groups offered by BlockingQueue, and what does each do?
intermediateBlockingQueue exposes four different styles of insertion and removal, each handling the full-queue and empty-queue cases differently. The throwing group includes add(e), which throws an exception if the queue is full, and remove(), which throws if the queue is empty. The value-returning group includes offer(e), which returns false instead of throwing if the queue is full, and poll(), which returns null instead of throwing if the queue is empty. The blocking group includes put(e), which blocks the calling thread until space becomes available, and take(), which blocks until an item becomes available. The timed group includes offer(e, time, unit) and poll(time, unit), which wait only up to a specified timeout before giving up. In practice, put() and take() are the natural choice for producer-consumer pipelines because they provide automatic backpressure, offer() and poll() with a timeout suit non-blocking scenarios, and add()/remove() are best reserved for cases where the queue's capacity bounds are already guaranteed never to be hit.
What is StampedLock, and when should you prefer it over ReadWriteLock?
intermediateStampedLock supports three distinct modes of access. A write lock is fully exclusive, just like ReadWriteLock's write lock. A read lock is shared and allows multiple concurrent readers, also like ReadWriteLock's read lock. The third mode, optimistic read, is unique to StampedLock: calling tryOptimisticRead() returns a stamp immediately without acquiring any actual lock at all. The caller reads the data it needs and then calls validate(stamp); if no write occurred in the meantime, the stamp is still valid and the read was consistent, and if validation fails, the code falls back to acquiring a genuine read lock and trying again. StampedLock is the better choice when reads are extremely frequent and contention with writers is rare, because the optimistic-read path avoids acquiring any lock at all in the common case, making it faster than ReadWriteLock. The important caveat is that StampedLock is not reentrant: a thread that already holds the write lock and then tries to also acquire the read lock on the same instance will deadlock itself.
What are ConcurrentHashMap's compute methods, and why should you prefer them over a separate get and put?
intermediateCalling get(key) followed later by put(key, newVal) is not atomic as a pair -- another thread can modify the value in between your get and your put, silently causing a lost update. ConcurrentHashMap instead provides several atomic compound methods. compute(k, (k, v) -> newV) performs an atomic read-modify-write in a single call. computeIfAbsent(k, k -> v) computes and inserts a value only if the key is not already present, which is useful for lazily populated caches. computeIfPresent(k, (k, v) -> newV) updates the value only if the key is already present. merge(k, v, biFunction) inserts v if the key is absent, or otherwise applies the given function to combine the old and new values -- for example, a thread-safe word-count increment can be written concisely as map.merge(word, 1, Integer::sum). All of these methods guarantee atomicity without the caller needing any external synchronization.
What is the poison pill pattern, and when and how do you use it?
intermediateA poison pill is a special sentinel value placed onto a BlockingQueue specifically to tell consumer threads that it's time to stop processing and exit their loop. Once a producer has finished submitting all of the real work items, it puts one poison pill onto the queue for each consumer thread that needs to be told to stop. Each consumer, upon dequeuing the pill, recognizes it and breaks out of its processing loop instead of treating it as real work. Good practice is to use a single static final sentinel object of the same type as the real work items, and to check for it using reference identity (==) rather than equals(), to avoid accidentally matching a real item. An alternative approach uses an AtomicBoolean shutdown flag checked via a timed poll(timeout) instead of a blocking take(), which lets a consumer periodically notice the flag even without a matching pill. This pattern is a useful way to shut down a producer-consumer pipeline gracefully when interrupting the consumer threads directly is undesirable.
How does ForkJoinPool's work-stealing algorithm work?
intermediateEach worker thread in a ForkJoinPool maintains its own double-ended queue (deque) of tasks. A thread pushes new subtasks it creates onto the front of its own deque and also pops tasks to execute from that same front, which behaves like a LIFO stack and gives good cache locality since a thread tends to keep working on the subtasks it most recently created. When a thread's own deque runs empty, it becomes a "thief": it looks at another thread's deque and steals a task from the back of that queue instead, which behaves like FIFO and tends to grab the oldest, typically largest, available chunk of work. Because the owning thread only ever touches the front of its deque while thieves only ever touch the back, the two rarely need to synchronize against each other, which keeps contention low. The overall effect is that as long as there is work anywhere in the pool, idle threads will keep finding and stealing it rather than sitting unused.
When are parallel streams beneficial in Java, and when can they actually hurt performance?
intermediateParallel streams tend to help when the data set is large, roughly tens of thousands of elements or more, the work being done per element is CPU-bound rather than involving blocking I/O, the underlying data source splits cleanly (such as an ArrayList or a plain array), there is no shared mutable state being written to, and there's no requirement to preserve encounter order. They tend to hurt performance in several common situations: on small collections, where the overhead of splitting and coordinating threads outweighs any benefit; when the per-element work involves I/O, since that blocks threads in the shared ForkJoinPool.commonPool() and can starve every other parallel stream running in the same JVM; when the source is a LinkedList, which cannot be split efficiently; when a forEach writes into a shared mutable container, which reintroduces race conditions; and with certain stateful intermediate operations. As a rule, parallel streams should never be used for database queries or HTTP calls -- for that kind of work, CompletableFuture backed by a dedicated executor is the appropriate tool instead.
What is ReadWriteLock, and when should you use it?
intermediateReadWriteLock maintains a pair of locks: a shared read lock, which any number of threads can hold at the same time as long as no thread holds the write lock, and an exclusive write lock, which blocks every other reader and writer while it's held. It is well suited to workloads where reads vastly outnumber writes, for example 95 percent reads and 5 percent writes, because allowing concurrent reads gives a real throughput improvement compared with a plain synchronized block, which serializes even read-only access. Typical use cases include in-memory caches, configuration maps, and lookup tables that change rarely but are read constantly. One caution is that under sustained heavy write contention, readers can end up starved, waiting a long time for their turn. As of Java 8, StampedLock with its optimistic-read mode offers an even faster alternative for very read-heavy workloads.
What is a Phaser, and how does it differ from CyclicBarrier?
intermediatePhaser is a more flexible and reusable synchronization barrier that supports dynamic registration of participants, unlike CyclicBarrier which fixes its number of parties up front. Threads can call register() to join and arriveAndDeregister() to leave dynamically at runtime, whereas CyclicBarrier's party count never changes after construction. Phaser also supports partial arrival: a thread can call arriveAndDeregister() to contribute to the current phase without waiting around for the other participants to finish it. Termination behavior can be customized by overriding onAdvance(), returning true from it to terminate the phaser entirely. Phasers can also be arranged in a tiered, tree-like structure for hierarchical coordination across large numbers of threads. Phaser is the better choice whenever threads need to drop in and out between phases or the set of participants isn't known ahead of time, while CyclicBarrier remains simpler for a fixed group of threads synchronizing repeatedly.
How does CopyOnWriteArrayList work internally, and when is it appropriate to use?
intermediateEvery mutating operation on a CopyOnWriteArrayList, such as add(), set(), or remove(), copies the entire backing array, applies the change to that new copy, and then atomically swaps the internal reference to point at the new array. Because of this, reads never need any locking at all -- they simply read whatever array reference is currently visible and always see a fully consistent snapshot. Iteration is similarly snapshot-based: a ConcurrentModificationException can never occur, but any mutation that happens after an iterator was created will not be visible to that iterator. This makes CopyOnWriteArrayList appropriate when reads vastly outnumber writes, such as with event listener lists, routing tables, or observer lists, where the occasional O(n) cost of copying the array on a write is an acceptable tradeoff. It should be avoided for lists that are written to frequently or that are very large, since copying tens of thousands of elements on every single write becomes prohibitively expensive.
Advanced & Design
What is a deadlock, and what are the four Coffman conditions that must hold for one to occur?
advancedA deadlock occurs when two or more threads are blocked forever, each one waiting for a resource that is held by another thread in the same cycle, so none of them can ever make progress. Coffman's four conditions describe the necessary and jointly sufficient requirements for a deadlock, meaning all four must be true simultaneously for one to happen. Mutual exclusion means a resource can only be held by one thread at a time. Hold and wait means a thread is holding at least one resource while it waits to acquire another. No preemption means a resource cannot be forcibly taken away from the thread holding it. Circular wait means there is a cycle of threads, each waiting for a resource held by the next thread in the cycle. Preventing deadlock just requires breaking any single one of these four conditions, and the easiest one to break in practice is circular wait, typically by enforcing a consistent global ordering on lock acquisition, such as always locking objects in order of their ID, lowest first.
How do you detect a deadlock in a running JVM?
advancedThere are three practical ways to detect a deadlock. The command-line tool jstack <PID> prints a full stack trace for every thread in the target JVM, and if a deadlock exists it explicitly prints a "Found one Java-level deadlock:" section that lists which threads hold which locks and what each of them is currently waiting for. Graphical tools like VisualVM or JConsole offer a thread dump viewer with a built-in deadlock detection button that presents the same information visually. You can also detect deadlocks programmatically through JMX by calling ManagementFactory.getThreadMXBean().findDeadlockedThreads(), which returns the IDs of any deadlocked threads or null if there are none -- running this check periodically as a scheduled task in production lets you alert an on-call engineer before users even notice the problem.
What are the different ways to design a thread-safe Singleton in Java?
advancedThere are three commonly used correct approaches. Eager initialization declares the instance as private static final Singleton INSTANCE = new Singleton(); this relies on the JVM's class-loading process being inherently thread-safe, and while it's simple, it always creates the instance at class-load time even if it's never actually used. Double-checked locking checks for null, enters a synchronized block, checks null again inside the lock, and only then constructs the instance; the instance field must be declared volatile, since without it the JVM could reorder construction and expose a partially built object to another thread. The best of the three is usually the initialization-on-demand holder idiom: a private static nested class holds the instance as a static final field, so the instance is only created the first time the holder class is actually loaded, giving lazy initialization with no explicit synchronization needed at access time, guaranteed thread-safe purely by the class-loading mechanism. Java enums also provide an easy, inherently thread-safe way to implement a singleton.
What is a livelock, and how is it different from a deadlock?
advancedIn a deadlock, the involved threads sit in the BLOCKED or WAITING state, consuming essentially no CPU and never changing state again. In a livelock, by contrast, the threads remain in the RUNNABLE state and consume significant CPU, but they keep changing their behavior in direct response to each other without ever actually making useful progress. A classic example is two threads that both try to be polite: Thread A backs off whenever it notices Thread B needs a shared resource, and Thread B does the same for Thread A, so both keep yielding to each other in lockstep and neither one ever proceeds. The typical fix is to introduce a random backoff duration before retrying, so the two threads' timing desynchronizes and one of them eventually gets a clear opportunity to proceed -- this is the same idea behind the exponential random backoff used by CSMA/CD collision handling in classic Ethernet networking.
How would you implement a thread-safe bounded blocking queue from scratch?
advancedA clean implementation uses a single ReentrantLock paired with two separate Condition objects, one called notFull for producer threads and one called notEmpty for consumer threads, along with an internal array, a put index, a take index, and a count of current elements. The put() method acquires the lock, then loops with while (count == capacity) notFull.await() to block while the queue is full, adds the new item once space is available, increments the count, calls notEmpty.signal() to wake a waiting consumer, and releases the lock. The take() method mirrors this: it acquires the lock, loops with while (count == 0) notEmpty.await() to block while the queue is empty, removes an item, decrements the count, calls notFull.signal() to wake a waiting producer, and releases the lock. The key correctness details are using a while loop rather than an if for the condition checks, using the narrower signal() rather than signalAll() since the two separate Condition objects already guarantee the right kind of waiting thread is woken, and always releasing the lock inside a finally block. This is essentially how the JDK's own ArrayBlockingQueue is implemented internally.
What is the bulkhead pattern, and how would you implement it in Java?
advancedThe bulkhead pattern isolates the resources used to call each downstream dependency, so that one slow or failing service cannot exhaust a shared thread pool and drag down calls to every other service as well. A common implementation uses a separate ThreadPoolExecutor for each downstream dependency, for example a small pool of five threads dedicated to a payment service, ten threads for a user service, and eight threads for an inventory service, each with its own bounded queue and an abort policy so it fails fast rather than queuing indefinitely when overwhelmed. With virtual threads, an equivalent effect can be achieved with a plain Semaphore per service, for instance new Semaphore(20) to cap concurrent calls to the payment service at 20 regardless of how many virtual threads exist overall. The name comes from the watertight compartments, or bulkheads, built into ships: a breach in one compartment is contained there and doesn't sink the whole vessel.
What is the difference between CompletableFuture's thenCompose and thenApply, and when do you use each?
advancedthenApply(f) takes a synchronous function that transforms T into U and returns a CompletableFuture<U>. If the function you pass to thenApply happens to itself return a CompletableFuture<U>, the result ends up being an unwanted nested CompletableFuture<CompletableFuture<U>>, which is rarely what you actually want. thenCompose(f), by contrast, is designed for exactly that situation: it takes a function from T to CompletableFuture<U> and automatically flattens the result down to a plain CompletableFuture<U>, playing the same role that flatMap plays for Stream. The rule of thumb is simple: if the next step in your chain is a plain synchronous computation, use thenApply; if the next step is itself asynchronous and returns another CompletableFuture, use thenCompose to avoid ending up with a nested future.
How do you aggregate the results of N parallel CompletableFutures?
advancedThe standard approach is CompletableFuture.allOf(cf1, cf2, ..., cfN), which returns a CompletableFuture<Void> that completes only once every one of the given futures has completed. You then chain a step to collect the individual results, typically written as allOf(futures.toArray(...)).thenApply(v -> futures.stream().map(CompletableFuture::join).collect(toList())); calling join() inside that step is safe because allOf already guarantees every future is done by that point, so join() will never actually block. If you instead only care about whichever future finishes first, CompletableFuture.anyOf(cf1, cf2, cf3) returns a CompletableFuture<Object> that completes as soon as any one of them does, which you then cast to the expected type. As of Java 21, structured concurrency offers a cleaner alternative for this same pattern: StructuredTaskScope.open(Joiner.allSuccessfulOrThrow()) handles the fork-and-join bookkeeping for you without manual join() and allOf() calls.
How would you design a rate limiter that allows at most N requests per second?
advancedThere are a few common ways to implement this. One approach pairs a Semaphore initialized with N permits with a ScheduledExecutor that refills those N permits back once every second, and each incoming request calls acquire() before it's allowed to proceed. A second approach is a token bucket built on an AtomicLong that tracks both the current token count and the time of the last refill: on each incoming request, you compute how much time has elapsed since the last refill, add tokens to the bucket proportionally to that elapsed time, and if at least one token is available you decrement it and allow the request through, otherwise you reject it, with the whole read-modify-write sequence implemented as a CAS retry loop to keep it thread-safe. A third, simpler option in a single JVM is to reach for an existing library, such as Guava's RateLimiter.create(N), whose acquire() call blocks the caller until a permit becomes available. In a real distributed production system running across many instances, you would typically move this logic to Redis with an atomic Lua script, so the rate limit is enforced globally rather than separately per instance.
What is thread starvation? How do you detect and prevent it?
advancedStarvation happens when a thread is perpetually denied the CPU time or resource access it needs because other threads consistently get priority over it. Common causes include unfair locks that offer no FIFO guarantee, so a thread can keep losing out to newer arrivals indefinitely; locks that are held for a long time, forcing everyone else to queue for an extended period; and extreme thread priority differences that consistently favor higher-priority threads. You can detect starvation by monitoring ThreadMXBean.getThreadCpuTime(id) over time -- a thread that stays in the RUNNABLE state yet accumulates almost no actual CPU time is a strong sign it's being starved. Prevention strategies include using fair locks, such as new ReentrantLock(true), which serves waiting threads in strict arrival order; keeping critical sections as short as possible; never holding a lock across blocking I/O; avoiding extreme differences in thread priority; and using a fair semaphore, such as new Semaphore(N, true).
How does the pipeline pattern work in concurrent systems?
advancedIn a pipeline, successive processing stages are connected by BlockingQueues, with each stage reading its input from one queue, doing its processing, and writing its output to the next queue in the chain. Every stage runs on its own thread or set of threads and executes concurrently with the others, so while stage two is processing item five, stage one can already be working on item six. The number of threads dedicated to each stage should be scaled roughly in proportion to how long that stage takes, so that slower stages get more threads to keep overall throughput balanced. Backpressure happens automatically without any extra code: if a downstream stage's output queue becomes full, its put() call simply blocks, which naturally slows down the upstream stages feeding it. Because of this, the pipeline's total throughput is always limited by its single slowest stage, and monitoring the depth of each intermediate queue is an effective way to identify exactly which stage is the bottleneck.
How does immutability work as a concurrency strategy, and what actually makes a class truly immutable?
advancedA genuinely immutable object requires zero synchronization to use safely, because its state can never change after construction, which means it can be freely shared between any number of threads without any risk of a race condition. Several conditions are needed to make a class truly immutable: the class itself should be declared final so it cannot be subclassed in a way that breaks the immutability guarantee; every field should be private and final; the class should expose no setter methods, so nothing can mutate its state after construction; the constructor should take defensive copies of any mutable objects passed in, for example this.list = List.copyOf(list); and any getter that would otherwise return a reference to internal mutable state should also return a defensive copy. Java records automatically satisfy most of these requirements out of the box. Well-known examples of immutable classes in the JDK include BigInteger, String, and LocalDate. The main tradeoff is that every "modification" actually creates a brand-new object, which can add meaningful garbage collection pressure in workloads that mutate very frequently.
How do you implement a producer-consumer setup with multiple producers and multiple consumers?
advancedThe core building block is a BlockingQueue, such as ArrayBlockingQueue or LinkedBlockingQueue. Multiple producer threads simply call put(), which blocks them automatically once the queue is full, giving natural backpressure without any extra coordination code, and multiple consumer threads call take(), which blocks them automatically once the queue is empty. All of the internal synchronization needed to make this safe with many producers and many consumers is handled entirely inside the BlockingQueue implementation. A clean shutdown is typically done by having a producer enqueue one poison pill per consumer thread once all real work has been submitted, and each consumer exits its loop as soon as it dequeues its poison pill. For monitoring, tracking the queue's current size, and a separate dropped-item counter if offer() is used instead of the blocking put(), gives good visibility into whether the pipeline is keeping up. With virtual threads available since Java 21, it's now practical to dedicate one lightweight virtual thread per producer and per consumer and run them on an effectively unbounded virtual thread executor.
What are the dangers of calling alien methods while holding a lock?
advancedAn "alien" method is any method that your code doesn't fully control at the point you're calling it -- typically a callback, a listener, or some other externally supplied method whose implementation is unknown or overridable. Calling such a method while still holding a lock is risky for several reasons. If the alien method itself tries to acquire another lock, it can create a deadlock, especially if some other thread is holding that second lock and waiting on the first. If the alien method calls back into your own class's synchronized methods, the behavior can be surprising even though intrinsic locks are reentrant, because the reentrant call happens with your object in a possibly unexpected intermediate state. And if the alien method is simply slow, every other thread waiting on your lock is now blocked for that entire duration, which can lead to starvation. The fix is to hold the lock only long enough to read or update your own state, copying out whatever data you need, and to invoke any alien methods only after releasing the lock. This practice, often called making "open calls" -- invoking methods while holding no lock at all -- dramatically reduces the risk of deadlock in a system.
How can ThreadLocal cause a memory leak in web servers, and how do you prevent it?
advancedWeb servers typically run requests on a pooled set of worker threads that are reused across many different incoming requests. If a servlet or filter sets a value into a ThreadLocal during one request but never calls remove() on it, that value stays attached to the thread's internal ThreadLocalMap indefinitely, and the very next request that happens to land on that same pooled thread will silently see the leftover context from the previous, completely unrelated request -- a serious security concern in something like a multi-tenant system. It gets worse if the leaked value references an object loaded by a web application's own class loader, such as a Hibernate entity: because the ThreadLocalMap entry keeps that object reachable, the entire class loader for that web application can never be garbage collected, even after the application is redeployed, resulting in a slow but permanent memory leak that eventually exhausts Metaspace. The fix is straightforward -- always call threadLocal.remove() inside a finally block, typically implemented as a servlet Filter wrapping the request in a try/finally that guarantees cleanup runs no matter how the request handling exits.
How does CompletableFuture handle exceptions, and how do exceptionally, handle, and whenComplete compare?
advancedexceptionally(Function<Throwable, T>) only runs if the upstream stage completed with an exception, and it supplies a recovery value to use instead -- it behaves similarly to a catch block, and is skipped entirely on the success path. handle(BiFunction<T, Throwable, U>) always runs regardless of whether the upstream stage succeeded or failed, receiving both the result and the exception (one of which will be null), and it must return a new value, which lets it both recover from failure and transform a successful result in one step -- closer to a combined try-catch-transform. whenComplete(BiConsumer<T, Throwable>) also always runs and receives both the result and exception, but it can only observe them for side effects like logging or metrics and cannot change the outcome that flows to the next stage -- any unhandled exception is still propagated onward exactly as if whenComplete weren't there, much like a finally block. As a rule of thumb, use exceptionally to supply a fallback value, handle when you need to branch cleanly on success versus failure, and whenComplete purely for side effects that shouldn't alter the result.
How does ScopedValue improve on ThreadLocal for use with virtual threads?
advancedThreadLocal keeps a separate map per thread, which is manageable with a modest number of platform threads but becomes a real problem with virtual threads, where an application might have a million of them running: a million separate ThreadLocal maps adds up to significant heap overhead. Values also have to be cleaned up manually by calling remove(), and forgetting to do so leaks context between reuses. There's also no efficient built-in way to have a value automatically inherited by child virtual threads, since InheritableThreadLocal is both mutable and comparatively expensive to propagate. ScopedValue, introduced in Java 21, addresses all of this: its values are stored as part of a scope structure rather than duplicated per thread, so memory overhead stays minimal no matter how many threads exist; the value is automatically cleaned up the moment the scope exits, eliminating the leak risk entirely; child virtual threads automatically and efficiently inherit their parent's ScopedValues; and values are immutable for the duration of a scope, ruling out accidental mutation. For any new code targeting Java 21 or later, ScopedValue should generally be preferred over ThreadLocal.
What is thread pinning in virtual threads, and how do you diagnose and fix it?
advancedThread pinning happens when a virtual thread becomes stuck to its underlying carrier OS thread and cannot be unmounted from it during a blocking operation, which means the carrier thread itself is blocked and unavailable to run any other virtual thread instead of being returned to the pool. The two main causes are performing a blocking I/O operation inside a synchronized block, since the JVM cannot safely unmount a virtual thread while it holds a monitor, and calling into native code through JNI. You can diagnose pinning by running the JVM with the flag -Djdk.tracePinnedThreads=full, which prints a stack trace every time pinning actually occurs. The fix is to replace synchronized with ReentrantLock along any code path that also performs blocking I/O, since ReentrantLock, unlike the synchronized keyword, does support unmounting a virtual thread while it's held. For database access specifically, using a modern connection pool like HikariCP means virtual threads block while waiting for a connection from the pool rather than inside a synchronized block deep in the JDBC driver, avoiding pinning there as well.
Modern Java & Architecture
How do virtual threads work internally, end to end?
advancedA virtual thread is a lightweight thread managed entirely by the JVM rather than by the operating system. Each one is represented as a heap object with an elastic stack that starts out extremely small, around 200 bytes, and grows only as needed. The JVM's own scheduler, built on a ForkJoinPool, multiplexes potentially huge numbers of virtual threads onto a small, fixed pool of real operating-system "carrier" threads, whose count defaults to the number of available CPU cores. When a virtual thread makes a blocking call recognized by the JDK, such as a socket read, file I/O, or sleep, the JVM unmounts it: it saves the virtual thread's stack into the heap and immediately frees up the carrier thread to go run some other virtual thread. Once the blocking operation actually completes, the original virtual thread is remounted onto whichever carrier thread is available at that moment and resumes execution. This is why a million virtual threads only costs roughly gigabytes of heap for their stacks, rather than the terabyte of OS-level stack space a million platform threads would require. From the programmer's point of view, the code is written in an ordinary, sequential, blocking style, while the JVM transparently handles the actual asynchronous scheduling underneath.
What is Structured Concurrency, and what problem does it solve?
advancedStructured Concurrency, finalized under JEP 505, guarantees that no child task can ever outlive the scope that created it. It solves several concrete problems that plague unstructured concurrent code. Thread leaks are prevented because closing the scope automatically cancels every child task still running inside it, whereas in unstructured code one failed task can leave its siblings running as orphans indefinitely. Exception propagation is simplified because a single child task's failure automatically cancels its siblings and propagates the failure up to the parent, instead of requiring manual bookkeeping to notice and react to it. Observability also improves, because a thread dump now shows a clear parent-child tree of tasks rather than an opaque flat list. The finalized API is used as StructuredTaskScope.open(joiner), where the Joiner argument selects the coordination policy: Joiner.allSuccessfulOrThrow() cancels every remaining task the moment any one of them fails, which fits a fan-out where every result is required, while Joiner.anySuccessfulResultOrThrow() cancels everything else the moment any one task succeeds, which fits patterns like hedged requests where you only need the fastest response. Earlier preview versions of this API exposed the same ideas through subclasses named ShutdownOnFailure and ShutdownOnSuccess; encountering that older shape in a codebase is a sign it predates the finalized API.
How does Structured Concurrency compare to CompletableFuture for a fan-out service call?
advancedUsing CompletableFuture for a fan-out to three services typically means three separate supplyAsync() calls, an allOf() to wait for all of them, manually calling join() on each one individually, handling exceptions separately for each future, and manually canceling the others if one fails -- the result tends to be verbose, easy to get subtly wrong, and hard to read since the control flow isn't linear. Structured Concurrency handles the same scenario far more directly: try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) followed by three scope.fork() calls, a single scope.join(), and then reading each subtask's result with .get() -- the code reads almost like ordinary synchronous code, and sibling cancellation on failure happens automatically, with the scope itself guaranteeing every forked thread has finished by the time the try-with-resources block closes. As a rule, CompletableFuture remains the right tool when supporting Java versions before 21, or when the composition is genuinely complex and dynamic, such as a multi-stage pipeline; Structured Concurrency is the better fit on Java 21 and later for a known, fixed set of parallel tasks following a clean fan-out shape.
How would you implement a distributed rate limiter across multiple instances of a microservice?
advancedA rate limiter that only tracks state locally, such as a plain Semaphore or Guava's RateLimiter, only protects the single instance it's running in -- if you deploy ten pods of the same service, each one independently enforces the limit, so the effective total allowed traffic becomes N times the number of pods instead of N overall. To enforce a true global limit, you need shared state, typically kept in Redis using either a sliding-window or token-bucket algorithm. The check-increment-expire sequence needs to be atomic across concurrent requests hitting different pods, which is usually implemented as a single Redis Lua script executed with EVAL, called from Java through a client library such as Lettuce or Jedis. Rather than hand-rolling this, you can also lean on an existing framework, such as Resilience4j's RateLimiter backed by a Redis store, or the rate limiting built into Spring Cloud Gateway. Key design decisions include choosing between a fixed window, which is simple but can allow roughly double the intended burst right at window boundaries, a sliding window, which is more accurate but more expensive to compute, or a token bucket, which naturally smooths out bursts, as well as deciding whether the limit should apply per user, per IP address, or globally across the whole system.
How do you design a thread-safe LRU (least recently used) cache?
advancedThe simplest approach wraps a LinkedHashMap configured for access-order with Collections.synchronizedMap(), overriding removeEldestEntry() to evict the oldest entry once the cache exceeds its capacity -- this is correct but coarse-grained, since it serializes every single access, including reads, behind one lock. A better approach for higher concurrency combines a ConcurrentHashMap for O(1) key lookups with a separate doubly linked list that tracks recency order, with a ReentrantReadWriteLock protecting the linked-list updates that reordering on each access requires. For genuinely high-concurrency production use, a library like Caffeine is usually the right answer: it implements a Window TinyLFU eviction algorithm, uses lock striping to minimize contention across threads, achieves O(1) amortized performance, and handles frequency-based eviction more intelligently than a strict recency-only policy. In an interview setting, a good way to present this is to start with the simple LinkedHashMap-based approach, explain clearly why it doesn't scale under concurrency, and then describe the ConcurrentHashMap-plus-linked-list design as the natural next step.
What changes would you make when migrating an existing platform-thread codebase to use virtual threads?
advancedThe migration generally follows several concrete steps. First, replace fixed thread pools used for I/O-bound work, such as Executors.newFixedThreadPool(200), with Executors.newVirtualThreadPerTaskExecutor(). In a Spring Boot application, this can often be turned on with a single property, spring.threads.virtual.enabled=true. Next, hunt for thread pinning by running the application with -Djdk.tracePinnedThreads=full, and fix any spots where a synchronized block wraps blocking I/O by replacing it with ReentrantLock, which supports unmounting a virtual thread properly. Where practical, replace ThreadLocal usage with ScopedValue, since it scales much better to large numbers of virtual threads. Database connection pools like HikariCP generally don't need to grow -- a modest pool size such as the default of 10 remains appropriate, since making the pool artificially large just wastes database connections without actually improving throughput. Importantly, virtual threads themselves should never be pooled, since they're intentionally cheap to create fresh each time. Finally, genuinely CPU-bound work should remain on a bounded platform-thread pool rather than being moved to virtual threads, since virtual threads offer no benefit for computation that doesn't block.
How do the Java Memory Model's reordering rules work, and how do they affect concurrent code?
advancedBoth CPUs and the JIT compiler are free to reorder instructions for performance reasons -- a read might be hoisted to execute before a write that precedes it in the source code, or a write might be delayed. Within a single thread this reordering is invisible, because the processor always preserves the illusion of sequential execution for that thread's own instructions. Across threads, though, without any explicit synchronization, a second thread can observe the first thread's writes happening in a completely different order than they actually appear in the source code. The Java Memory Model formalizes exactly what visibility guarantees do exist between threads through the happens-before relationship -- without an established happens-before edge between two operations, no ordering guarantee exists at all. Declaring a field volatile establishes a memory barrier: every write that happened before a volatile write in program order is guaranteed to be flushed and visible, and every read that happens after a volatile read is guaranteed to see those flushed values. The synchronized keyword establishes equivalent memory barriers at the point a lock is acquired and at the point it is released. This is precisely why double-checked locking without a volatile field is broken: without the barrier a volatile write provides, another thread can see a non-null object reference before that object's own field writes performed inside its constructor have actually become visible.
How does a Resilience4j Circuit Breaker work together with thread pools?
advancedA circuit breaker tracks the outcomes of recent calls using a sliding window, either count-based or time-based, and moves through three states based on that data. In the CLOSED state, calls pass through normally. If the failure rate crosses a configured threshold, the circuit trips to OPEN, at which point it fast-fails every call immediately without sending any traffic downstream at all. After a configured wait period, it moves to HALF_OPEN, where it allows a small number of test calls through to see if the downstream service has recovered, returning to CLOSED if they succeed or back to OPEN if they don't. Combining this with the bulkhead pattern, using a separate bounded thread pool per downstream dependency, means the circuit breaker wraps each call: while OPEN, it throws a CallNotPermittedException immediately without ever consuming a thread from the pool, and while HALF_OPEN, it allows only a limited amount of traffic through. It's also common to pair a circuit breaker with a Retry policy, configured not to retry while the circuit is open, and with a TimeLimiter that times out slow calls before the circuit even has a chance to trip. The overall goal is to prevent cascading failures, so that one slow or failing downstream dependency doesn't exhaust the thread pool that upstream calls also depend on.
How do you test concurrent code reliably, given that concurrency bugs are often non-deterministic?
advancedBecause race conditions and other concurrency bugs depend on unpredictable thread interleaving, they frequently don't show up in a simple, single-run unit test even when the underlying bug is real. Several strategies help surface them more reliably. The jcstress harness, the Java Concurrency Stress test suite, is purpose-built for testing memory-model and concurrency correctness at a very fine-grained level. A CountDownLatch-based synchronized start pattern, where many threads are all made to wait on one latch and then released simultaneously, maximizes the chance of real contention and problematic interleavings during a test. Simply running a test tens of thousands of times in a loop is a blunt but effective way to let rare race conditions eventually manifest. Tools like Google's Thread Weaver can deliberately interleave thread execution at specific points in the bytecode to force particular orderings that would otherwise be rare. Property-based testing frameworks such as jqwik can run many concurrent workers against a shared object and continuously verify an invariant holds, for example that a total account balance remains constant across many concurrent transfers. Mutation testing tools like PItest can also be configured with concurrency-specific mutations to check whether your tests would actually catch subtle concurrency bugs if introduced.
What is backpressure, and how do you implement it in a reactive system?
advancedBackpressure refers to the requirement that when a consumer cannot keep up with the rate a producer is generating data, the producer must be slowed down rather than either silently dropping data or letting unbounded data pile up in memory. There are several ways to implement this. The simplest is a blocking put(): calling BlockingQueue.put() blocks the producer automatically once the queue is full, giving natural backpressure with almost no extra code. In reactive streams frameworks like Project Reactor, the subscriber explicitly signals how much data it's ready to receive by calling request(N), and the publisher only ever sends up to that many items at a time; when the subscriber is busy, it simply doesn't request more, and the producer naturally slows to match. Thread pool executors can use a CallerRunsPolicy, where once the pool and its queue are full, the calling thread itself is forced to execute the task, which prevents it from submitting new work any faster than it can actually be processed. At the network layer, TCP's own sliding window provides a similar form of flow control. In every case, backpressure propagates upstream from the slowest, most overwhelmed consumer through the entire pipeline back toward the original producer.
How would you debug a high-CPU issue caused by a thread spinning?
advancedThe first step is to identify which specific thread is consuming the CPU, which on Linux you can do with top -H -p <PID> to see per-thread CPU usage and note the thread ID (TID) of the hot thread. Next, convert that TID to hexadecimal, for example with printf "%x\n" TID, because Java thread dumps identify threads by their hexadecimal native ID. Then run jstack <PID> to capture a full thread dump, and locate the thread whose nid field matches the hex TID you computed -- its stack trace shows exactly what code that thread is currently executing. Common culprits behind a spinning thread include a hand-written busy-wait loop such as while (!ready) {}, a compare-and-swap loop retrying excessively under very high contention, an outright infinite-loop bug such as the classic Java 7 HashMap resize corruption under concurrent access, or lock contention inside String.intern(). The fix depends on the cause: replace a busy-wait with proper wait/notify or LockSupport.park(), switch a high-contention counter to LongAdder, or replace an unsafe HashMap with ConcurrentHashMap.
What is the difference between optimistic and pessimistic locking, and where does each apply?
advancedPessimistic locking assumes a conflict is likely and acquires a lock before touching the shared resource at all, as with synchronized, ReentrantLock, or a database row-level lock obtained through SELECT FOR UPDATE. It is always correct, but its throughput degrades under contention since threads have to queue up and wait their turn; it's the right choice for write-heavy workloads, situations where high contention is expected, or when the critical section is relatively long. Optimistic locking instead assumes conflicts are rare, proceeds without taking any lock up front, and only verifies at the point of commit that nothing else interfered, as with an AtomicInteger CAS loop, StampedLock's optimistic-read mode, or a database version column pattern where an update statement includes WHERE version = N and the caller checks that exactly one row was affected. It's fast when contention really is low, but performance can degrade if conflicts turn out to be common and the operation has to retry repeatedly; it fits read-heavy workloads with rare conflicts and short check-and-update operations well. In Java terms, a CAS loop built on AtomicReference is a form of optimistic locking, while the synchronized keyword is inherently pessimistic.