Menu

Earn Premium with Referrals

Invite your friends and earn Premium rewards through our referral program.

See how it works and start inviting friends.

Concurrency & Threads
JAVA

Concurrency & Threads

Practice 11 questions covering threads, synchronization, locks, concurrent programming, race conditions, and Java concurrency utilities.

1. What does the volatile keyword guarantee when applied to a variable?

Answer: It forces reads and writes to go directly to main memory, establishing visibility and ordering guarantees across threads.

Every CPU has its own cache. When a thread reads and writes a variable, the value can sit in that thread’s cache instead of main memory. Another thread, on another CPU, might keep reading a stale copy — it never sees the update. This is the classic visibility problem of multithreading.

Declaring a variable volatile solves visibility. The JVM is told that this variable is shared, so every read of a volatile variable reads from main memory, and every write goes straight to main memory. No thread is allowed to serve a stale cached copy. If thread A writes a volatile variable, thread B is guaranteed to see the new value on its next read.

volatile also provides an ordering guarantee: it establishes a happens-before relationship. Writes to a volatile variable happen-before subsequent reads of it by other threads, which means any other memory writes made before the volatile write are also visible afterward.

But volatile does not make compound operations atomic. A statement like count++ is really three steps — read, add one, write back. Two threads can interleave those steps and lose updates even with volatile. For that you need synchronization or atomic classes like AtomicInteger. volatile is for flags and simple shared state that are only read and written, never modified in place.

Answer:

It forces reads and writes to go directly to main memory, establishing visibility and ordering guarantees across threads.

Every CPU has its own cache. When a thread reads and writes a variable, the value can sit in that thread’s cache instead of main memory. Another thread, on another CPU, might keep reading a stale copy — it never sees the update. This is the classic visibility problem of multithreading.

Declaring a variable volatile solves visibility. The JVM is told that this variable is shared, so every read of a volatile variable reads from main memory, and every write goes straight to main memory. No thread is allowed to serve a stale cached copy. If thread A writes a volatile variable, thread B is guaranteed to see the new value on its next read.

volatile also provides an ordering guarantee: it establishes a happens-before relationship. Writes to a volatile variable happen-before subsequent reads of it by other threads, which means any other memory writes made before the volatile write are also visible afterward.

But volatile does not make compound operations atomic. A statement like count++ is really three steps — read, add one, write back. Two threads can interleave those steps and lose updates even with volatile. For that you need synchronization or atomic classes like AtomicInteger. volatile is for flags and simple shared state that are only read and written, never modified in place.

2. What happens when a thread calls Thread.interrupt() while sleeping?

Answer: InterruptedException is thrown, and the thread’s interrupted status flag is cleared to false.

Thread.sleep() is a blocking call that responds to interruption. When another thread calls interrupt() on a sleeping thread, the JVM wakes that thread up and throws InterruptedException from within the sleep() call.

Two things happen in that moment. First, the exception propagates, and the thread exits the sleep and resumes running its exception handler. Second, and this is the part interviews love to test, the interrupt flag — the boolean that records “this thread was asked to stop” — is cleared to false as part of the interruption.

That clearing matters for how you write interruption handling. A common pattern is:

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // re-set the flag
    return;
}

Because the exception handler sees the flag already cleared, good practice is to restore it by calling interrupt() again, so that any outer code checking Thread.interrupted() or isInterrupted() can still see that an interruption occurred.

The interview point: interruption of a sleeping thread surfaces as InterruptedException, and the interrupt status is automatically reset to false in the process.

Answer:

InterruptedException is thrown, and the thread’s interrupted status flag is cleared to false.

Thread.sleep() is a blocking call that responds to interruption. When another thread calls interrupt() on a sleeping thread, the JVM wakes that thread up and throws InterruptedException from within the sleep() call.

Two things happen in that moment. First, the exception propagates, and the thread exits the sleep and resumes running its exception handler. Second, and this is the part interviews love to test, the interrupt flag — the boolean that records “this thread was asked to stop” — is cleared to false as part of the interruption.

That clearing matters for how you write interruption handling. A common pattern is:

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // re-set the flag
    return;
}

Because the exception handler sees the flag already cleared, good practice is to restore it by calling interrupt() again, so that any outer code checking Thread.interrupted() or isInterrupted() can still see that an interruption occurred.

The interview point: interruption of a sleeping thread surfaces as InterruptedException, and the interrupt status is automatically reset to false in the process.

3. What does CompletableFuture.supplyAsync() use by default when no explicit executor is provided?

Answer: It uses the shared ForkJoinPool.commonPool().

CompletableFuture.supplyAsync(Supplier) is a convenience for running a task asynchronously. When you provide only the supplier, the framework still needs somewhere to run it — so it falls back to a default.

That default is the common ForkJoinPool, a process-wide pool shared by all async operations in the JVM. It is the same pool used by parallel streams and other parallel operations. It is sized by default to the number of processor cores, making it efficient for CPU-bound tasks.

There is an overload that takes an explicit executor: supplyAsync(supplier, executor). Passing your own Executor — say a dedicated thread pool sized for your workload — gives you control over thread count, naming, and isolation. That is the recommended approach when your tasks are I/O-bound (which block and tie up pool threads) or when you do not want one long task starving the shared pool.

The interview point: omit the executor, and the task runs on ForkJoinPool.commonPool(). Provide one, and your pool is used instead.

Answer:

It uses the shared ForkJoinPool.commonPool().

CompletableFuture.supplyAsync(Supplier) is a convenience for running a task asynchronously. When you provide only the supplier, the framework still needs somewhere to run it — so it falls back to a default.

That default is the common ForkJoinPool, a process-wide pool shared by all async operations in the JVM. It is the same pool used by parallel streams and other parallel operations. It is sized by default to the number of processor cores, making it efficient for CPU-bound tasks.

There is an overload that takes an explicit executor: supplyAsync(supplier, executor). Passing your own Executor — say a dedicated thread pool sized for your workload — gives you control over thread count, naming, and isolation. That is the recommended approach when your tasks are I/O-bound (which block and tie up pool threads) or when you do not want one long task starving the shared pool.

The interview point: omit the executor, and the task runs on ForkJoinPool.commonPool(). Provide one, and your pool is used instead.

4. What distinguishes Virtual Threads (Java 21) from traditional Platform Threads?

Answer: Virtual threads are lightweight user-mode threads managed by the JVM, so thousands of them can run on a handful of OS threads with minimal memory overhead.

The old model, now called platform threads, has one thread per OS thread. Each carries a dedicated stack and costs real memory — around a megabyte per thread, plus OS scheduler overhead. That makes “a thread per request” impractical at scale: a server handling 10,000 concurrent connections would need 10,000 heavyweight threads.

Virtual threads decouple the two. They are managed by the JVM, not the OS. Many virtual threads are multiplexed onto a small pool of platform “carrier” threads. When a virtual thread blocks — on an I/O call, for instance — it is unmounted from its carrier, the carrier picks up another virtual thread, and the blocked one resumes later. Blocking stops costing a thread.

The benefits are immediate for the classic Java server pattern: you can write straightforward blocking code — thread per request — and let thousands of virtual threads run on a handful of platform threads. That is the familiar, easy-to-reason-about style, now scalable.

It is important to be precise about what virtual threads do not change. They do not remove the need for synchronization or locks — shared mutable state still needs care. They are not limited to CPU computations; in fact they shine on blocking I/O. The difference is purely about how threads are scheduled and how cheaply you can have many of them.

Answer:

Virtual threads are lightweight user-mode threads managed by the JVM, so thousands of them can run on a handful of OS threads with minimal memory overhead.

The old model, now called platform threads, has one thread per OS thread. Each carries a dedicated stack and costs real memory — around a megabyte per thread, plus OS scheduler overhead. That makes “a thread per request” impractical at scale: a server handling 10,000 concurrent connections would need 10,000 heavyweight threads.

Virtual threads decouple the two. They are managed by the JVM, not the OS. Many virtual threads are multiplexed onto a small pool of platform “carrier” threads. When a virtual thread blocks — on an I/O call, for instance — it is unmounted from its carrier, the carrier picks up another virtual thread, and the blocked one resumes later. Blocking stops costing a thread.

The benefits are immediate for the classic Java server pattern: you can write straightforward blocking code — thread per request — and let thousands of virtual threads run on a handful of platform threads. That is the familiar, easy-to-reason-about style, now scalable.

It is important to be precise about what virtual threads do not change. They do not remove the need for synchronization or locks — shared mutable state still needs care. They are not limited to CPU computations; in fact they shine on blocking I/O. The difference is purely about how threads are scheduled and how cheaply you can have many of them.

5. What is the behavior of ConcurrentHashMap regarding locks during write operations in modern Java?

Answer: ConcurrentHashMap avoids a global lock. It uses lock-free CAS (Compare-And-Swap) for inserting initial nodes and synchronizes only on the specific bucket’s head node for updates.

The whole point of ConcurrentHashMap is to be safe for concurrent use without paying the price of locking the entire map on every operation — which is exactly what a naively synchronized HashMap would do.

Modern ConcurrentHashMap (Java 8+) achieves this with a two-tier strategy. For first insertion into an empty bucket, it uses CAS — an atomic, lock-free operation that sets the bucket’s head node if the bucket is still empty. No lock is taken at all; the hardware guarantees the atomicity.

For operations that must modify an existing bucket — inserting when the bucket already has entries, replacing a value, removing a node — the map synchronizes only on that bucket’s head node. The synchronized block is scoped to the single bucket being touched. Other threads can freely read and write other buckets at the same time, because their locks are on different objects.

This is what people mean when they say the map “locks per bucket” rather than globally. The concurrency scales with the number of buckets instead of collapsing to one lock. Reads are effectively lock-free against these writes, and two writers on different buckets proceed in parallel.

The interview takeaway: modern ConcurrentHashMap = CAS for empty-bucket inserts + fine-grained locks on individual bucket head nodes for updates. No global lock, no read blocking.

Answer:

ConcurrentHashMap avoids a global lock. It uses lock-free CAS (Compare-And-Swap) for inserting initial nodes and synchronizes only on the specific bucket’s head node for updates.

The whole point of ConcurrentHashMap is to be safe for concurrent use without paying the price of locking the entire map on every operation — which is exactly what a naively synchronized HashMap would do.

Modern ConcurrentHashMap (Java 8+) achieves this with a two-tier strategy. For first insertion into an empty bucket, it uses CAS — an atomic, lock-free operation that sets the bucket’s head node if the bucket is still empty. No lock is taken at all; the hardware guarantees the atomicity.

For operations that must modify an existing bucket — inserting when the bucket already has entries, replacing a value, removing a node — the map synchronizes only on that bucket’s head node. The synchronized block is scoped to the single bucket being touched. Other threads can freely read and write other buckets at the same time, because their locks are on different objects.

This is what people mean when they say the map “locks per bucket” rather than globally. The concurrency scales with the number of buckets instead of collapsing to one lock. Reads are effectively lock-free against these writes, and two writers on different buckets proceed in parallel.

The interview takeaway: modern ConcurrentHashMap = CAS for empty-bucket inserts + fine-grained locks on individual bucket head nodes for updates. No global lock, no read blocking.

6. What is the result of attempting to invoke Thread.start() twice on the exact same thread object?

Answer: It throws IllegalThreadStateException at runtime.

A thread object has a strict lifecycle. It is created, started once, runs, and eventually terminates. It cannot be restarted.

The start() method is what transitions a thread from the NEW state into a runnable state. When you call start() a second time on the same object, the JVM checks the thread’s state and finds it is no longer in the NEW state — it has already been started. The second call is invalid, and the JVM responds with IllegalThreadStateException.

The exception is thrown at runtime, not compile time. The compiler cannot know how many times you’ll call start() on a given thread, so nothing is flagged until execution.

The practical lesson: a thread is a one-shot object. If you need to run the same work again, don’t restart the thread — create a new thread object (or better, use an executor and submit the task again).

Answer:

It throws IllegalThreadStateException at runtime.

A thread object has a strict lifecycle. It is created, started once, runs, and eventually terminates. It cannot be restarted.

The start() method is what transitions a thread from the NEW state into a runnable state. When you call start() a second time on the same object, the JVM checks the thread’s state and finds it is no longer in the NEW state — it has already been started. The second call is invalid, and the JVM responds with IllegalThreadStateException.

The exception is thrown at runtime, not compile time. The compiler cannot know how many times you’ll call start() on a given thread, so nothing is flagged until execution.

The practical lesson: a thread is a one-shot object. If you need to run the same work again, don’t restart the thread — create a new thread object (or better, use an executor and submit the task again).

7. What is the scope of a variable stored inside a ThreadLocal<T>?

Answer: A ThreadLocal value is visible only to the thread that set it. Each thread that touches the same ThreadLocal object reads and writes its own isolated copy.

This is a mechanism for thread confinement — keeping per-thread data separate without synchronization.

Imagine several threads working through the same code. They all reference the same ThreadLocal instance, but the value each one reads or writes is private to it. Thread A sets a value; thread B, moments later, still sees its own default (or its own previously-set value). There is no shared state, no race condition — each thread’s copy lives in its own map of thread-locals held by that thread.

The classic use cases are per-request context in web servers (current user, request ID), and sharing a non-thread-safe object like SimpleDateFormat or a JDBC connection per thread, so each thread works with its own instance rather than contending over one shared one.

The critical caveat that trips people up: ThreadLocal values are not global, and they are not preserved across anything other than the same thread. If the work moves to a different thread — a thread pool, an executor, a virtual thread — the value does not follow. In a pooled-thread environment, a ThreadLocal can even leak stale state between tasks because the thread is reused. That is why the advice is to always remove() the value when done.

The interview answer: a ThreadLocal holds an independent copy per thread; only that thread can see it.

Answer:

A ThreadLocal value is visible only to the thread that set it. Each thread that touches the same ThreadLocal object reads and writes its own isolated copy.

This is a mechanism for thread confinement — keeping per-thread data separate without synchronization.

Imagine several threads working through the same code. They all reference the same ThreadLocal instance, but the value each one reads or writes is private to it. Thread A sets a value; thread B, moments later, still sees its own default (or its own previously-set value). There is no shared state, no race condition — each thread’s copy lives in its own map of thread-locals held by that thread.

The classic use cases are per-request context in web servers (current user, request ID), and sharing a non-thread-safe object like SimpleDateFormat or a JDBC connection per thread, so each thread works with its own instance rather than contending over one shared one.

The critical caveat that trips people up: ThreadLocal values are not global, and they are not preserved across anything other than the same thread. If the work moves to a different thread — a thread pool, an executor, a virtual thread — the value does not follow. In a pooled-thread environment, a ThreadLocal can even leak stale state between tasks because the thread is reused. That is why the advice is to always remove() the value when done.

The interview answer: a ThreadLocal holds an independent copy per thread; only that thread can see it.

8. What is the result of using Executors.newFixedThreadPool(10) regarding its task queue capacity?

Answer: It uses an unbounded LinkedBlockingQueue. If tasks arrive faster than the 10 threads can process them, the queue grows without limit and can exhaust memory (OutOfMemoryError).

The fixed thread pool is created with a fixed number of worker threads — here, 10. The mechanics of submission follow the standard ThreadPoolExecutor logic: if a worker is free, the task is handed to it; otherwise the task goes into the work queue.

The key detail is what work queue is used. newFixedThreadPool uses a LinkedBlockingQueue with no capacity bound. That means under sustained load — tasks submitted faster than 10 threads drain them — the queue simply grows, holding more and more pending tasks. Nothing rejects the surplus.

Over time that accumulation is dangerous. Each queued Runnable holds references (and thus memory). Under a long enough burst, the queue can consume all available heap, and the JVM dies with OutOfMemoryError.

That is the practical argument for preferring a bounded pool in production: newFixedThreadPool never rejects tasks, which sounds generous but can be fatal. A ThreadPoolExecutor with a bounded queue and an explicit rejection policy protects you — it rejects (or otherwise handles) overflow instead of silently piling up work.

The interview answer: fixed thread pools use an unbounded LinkedBlockingQueue; overflow means unbounded memory growth and possible OutOfMemoryError.

Answer:

It uses an unbounded LinkedBlockingQueue. If tasks arrive faster than the 10 threads can process them, the queue grows without limit and can exhaust memory (OutOfMemoryError).

The fixed thread pool is created with a fixed number of worker threads — here, 10. The mechanics of submission follow the standard ThreadPoolExecutor logic: if a worker is free, the task is handed to it; otherwise the task goes into the work queue.

The key detail is what work queue is used. newFixedThreadPool uses a LinkedBlockingQueue with no capacity bound. That means under sustained load — tasks submitted faster than 10 threads drain them — the queue simply grows, holding more and more pending tasks. Nothing rejects the surplus.

Over time that accumulation is dangerous. Each queued Runnable holds references (and thus memory). Under a long enough burst, the queue can consume all available heap, and the JVM dies with OutOfMemoryError.

That is the practical argument for preferring a bounded pool in production: newFixedThreadPool never rejects tasks, which sounds generous but can be fatal. A ThreadPoolExecutor with a bounded queue and an explicit rejection policy protects you — it rejects (or otherwise handles) overflow instead of silently piling up work.

The interview answer: fixed thread pools use an unbounded LinkedBlockingQueue; overflow means unbounded memory growth and possible OutOfMemoryError.

9. What happens when ReentrantLock is acquired multiple times by the same holding thread?

Answer: The lock’s hold count increments by 1 for each acquisition, and execution continues seamlessly. It’s reentrant.

A lock is reentrant if the thread that holds it can acquire it again without deadlocking on itself. ReentrantLock implements exactly that.

Internally the lock tracks a hold count. When the same thread acquires the lock it already holds, the count goes from 1 to 2, and so on — each nested acquisition just bumps the count. The thread proceeds; there is no deadlock, no exception.

The key discipline that makes this work: each lock() must be balanced by a matching unlock(). When the thread has acquired the lock three times, it must unlock it three times before the lock is actually released to other threads. The count has to return to zero.

This reentrancy mirrors synchronized, which is also reentrant — a synchronized method calling another synchronized method on the same object is perfectly legal. The interview point: ReentrantLock is reentrant via a hold count, and balanced acquire/release pairs are required.

Answer:

The lock’s hold count increments by 1 for each acquisition, and execution continues seamlessly. It’s reentrant.

A lock is reentrant if the thread that holds it can acquire it again without deadlocking on itself. ReentrantLock implements exactly that.

Internally the lock tracks a hold count. When the same thread acquires the lock it already holds, the count goes from 1 to 2, and so on — each nested acquisition just bumps the count. The thread proceeds; there is no deadlock, no exception.

The key discipline that makes this work: each lock() must be balanced by a matching unlock(). When the thread has acquired the lock three times, it must unlock it three times before the lock is actually released to other threads. The count has to return to zero.

This reentrancy mirrors synchronized, which is also reentrant — a synchronized method calling another synchronized method on the same object is perfectly legal. The interview point: ReentrantLock is reentrant via a hold count, and balanced acquire/release pairs are required.

10. What is guaranteed by the AtomicInteger class?

Answer: Lock-free, thread-safe atomic operations on a single integer, built on hardware Compare-And-Swap (CAS) instructions.

AtomicInteger wraps an int with thread-safety that doesn’t rely on the synchronized keyword or Lock objects. Instead, it uses CAS: a CPU instruction that compares a memory location to an expected value and, only if they match, writes a new value — all in one atomic step. The hardware guarantees the compare-and-swap is indivisible.

Operations like incrementAndGet(), getAndAdd(5), and compareAndSet(expected, updated) run lock-free. Multiple threads can call them concurrently without blocking each other, because each operation is a single atomic primitive — no monitor is held, no thread is parked.

The practical meaning of “guaranteed”: a ++ on a plain int is not atomic (a read-modify-write that can interleave), but incrementAndGet() on an AtomicInteger is, so the count is always correct under contention. The cost is far lower than a lock because there’s no blocking — the trade-off being a modest risk of retry loops under extreme contention.

The interview answer: AtomicInteger gives lock-free, thread-safe atomic mutations via CAS, avoiding the overhead of heavy locks.

Answer:

Lock-free, thread-safe atomic operations on a single integer, built on hardware Compare-And-Swap (CAS) instructions.

AtomicInteger wraps an int with thread-safety that doesn’t rely on the synchronized keyword or Lock objects. Instead, it uses CAS: a CPU instruction that compares a memory location to an expected value and, only if they match, writes a new value — all in one atomic step. The hardware guarantees the compare-and-swap is indivisible.

Operations like incrementAndGet(), getAndAdd(5), and compareAndSet(expected, updated) run lock-free. Multiple threads can call them concurrently without blocking each other, because each operation is a single atomic primitive — no monitor is held, no thread is parked.

The practical meaning of “guaranteed”: a ++ on a plain int is not atomic (a read-modify-write that can interleave), but incrementAndGet() on an AtomicInteger is, so the count is always correct under contention. The cost is far lower than a lock because there’s no blocking — the trade-off being a modest risk of retry loops under extreme contention.

The interview answer: AtomicInteger gives lock-free, thread-safe atomic mutations via CAS, avoiding the overhead of heavy locks.

11. Which state is a thread in when waiting to acquire a Java synchronized block lock?

Answer: BLOCKED.

The Thread.State enum has six values, and they map to specific situations:

  • NEW — created, not yet started.
  • RUNNABLE — executing or ready to run.
  • BLOCKED — waiting to acquire a monitor lock (entering or re-entering a synchronized block or method held by another thread).
  • WAITING — waiting indefinitely for another thread to act (wait(), join() without timeout).
  • TIMED_WAITING — waiting with a timeout (sleep, wait(timeout), join(timeout)).
  • TERMINATED — finished.

The distinguishing detail: BLOCKED specifically means blocked on a lock, whereas WAITING/TIMED_WAITING mean waiting for another thread’s notification or a timeout. When a thread can’t get past a synchronized statement, it’s BLOCKED. That’s the answer.

Answer:

BLOCKED.

The Thread.State enum has six values, and they map to specific situations:

  • NEW — created, not yet started.
  • RUNNABLE — executing or ready to run.
  • BLOCKED — waiting to acquire a monitor lock (entering or re-entering a synchronized block or method held by another thread).
  • WAITING — waiting indefinitely for another thread to act (wait(), join() without timeout).
  • TIMED_WAITING — waiting with a timeout (sleep, wait(timeout), join(timeout)).
  • TERMINATED — finished.

The distinguishing detail: BLOCKED specifically means blocked on a lock, whereas WAITING/TIMED_WAITING mean waiting for another thread’s notification or a timeout. When a thread can’t get past a synchronized statement, it’s BLOCKED. That’s the answer.

My Private Notes

Notes are auto-saved locally to this device.