An application may work perfectly with 10 users and start behaving very differently when thousands of requests arrive simultaneously. Threads compete for CPU, database connections become exhausted, locks create contention, queues fill up, and poorly designed asynchronous code can turn a performance problem into a production incident.
This is why Java concurrency and multithreading become increasingly important at the senior level.
Senior Java interviews often go beyond asking what synchronized or volatile means. Interviewers want to know whether you understand the Java Memory Model, race conditions, locks, thread pools, CompletableFuture, Virtual Threads, and how to troubleshoot concurrency problems in production.
In this article, we cover 50 senior-level Java concurrency and multithreading interview questions with practical answers, including real-world scenarios involving CPU usage, thread-pool exhaustion, database contention, blocked threads, and high-traffic microservices.
Modern backend applications are inherently concurrent.
A Spring Boot microservice may simultaneously handle:
These operations often execute concurrently, which means shared state, synchronization, resource limits, and execution strategies become critical.
A concurrency bug can be particularly difficult to reproduce because the application may work correctly most of the time and fail only under a specific timing or load condition.
A process is an independent execution environment with its own memory space and operating-system resources.
A thread is an execution path within a process. Threads belonging to the same process share resources such as heap memory.
Because threads share memory, communication between threads can be efficient, but it also introduces concurrency problems such as race conditions and data corruption.
For Java applications, multiple threads typically execute within the same JVM process.
The Java Memory Model defines how Java threads interact with memory and what guarantees the JVM provides for visibility and ordering of operations.
Without proper synchronization, one thread may not immediately observe changes made by another thread.
The JMM defines concepts such as:
Understanding the JMM is essential for explaining why mechanisms such as volatile, synchronized, and atomic classes work.
The happens-before relationship defines when the result of one operation is guaranteed to be visible to another operation.
For example, unlocking a monitor happens-before a subsequent lock of the same monitor.
Similarly, a write to a volatile variable happens-before a subsequent read of that variable.
The happens-before relationship is one of the most important concepts for understanding Java concurrency correctly.
volatile primarily provides visibility and ordering guarantees for a variable.
synchronized provides mutual exclusion as well as memory visibility guarantees.
For example, volatile does not make a compound operation such as:
count++;
atomic.
The operation consists of reading, modifying, and writing the value, and another thread can interfere between those steps.
For compound state changes, synchronization or atomic/concurrent mechanisms may be required.
A synchronized method locks the monitor associated with the object for an instance method.
public synchronized void update() {
...
}
A synchronized block allows you to specify exactly what code needs synchronization and which lock should be used.
public void update() {
synchronized (lock) {
...
}
}
Synchronized blocks can provide more precise locking and can reduce the amount of code executed while holding a lock.
A race condition occurs when the correctness of a program depends on the timing or ordering of concurrent operations.
For example:
if (balance >= amount) {
balance -= amount;
}
If multiple threads execute this logic concurrently without appropriate synchronization, both threads may observe the same balance and perform an update that should not have been allowed.
Common approaches include:
The best solution is often to design the application so that unnecessary shared mutable state does not exist in the first place.
A component is thread-safe when it behaves correctly when accessed concurrently by multiple threads according to its documented contract.
Thread safety can be achieved through:
A class being free of obvious exceptions does not automatically mean it is thread-safe.
An immutable object cannot be modified after it has been created.
Because its state cannot change, multiple threads can safely share the object without synchronization for state mutation.
For example, Java's String class is immutable.
Immutability can significantly simplify concurrent application design by reducing shared mutable state.
CAS stands for Compare-And-Swap.
It is an atomic operation that checks whether a value is still equal to an expected value and, if so, replaces it with a new value.
Conceptually:
if (currentValue == expectedValue) {
currentValue = newValue;
}
The comparison and update occur atomically at the hardware/JVM level.
CAS is a fundamental building block for many lock-free and non-blocking algorithms and is used by Java atomic classes.
Both can provide mutual exclusion, but ReentrantLock provides additional capabilities.
For example:
With explicit locks, correct unlocking is critical.
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
synchronized is often simpler and should not be replaced with ReentrantLock without a specific requirement.
ReentrantLock generally allows one thread at a time to hold the lock.
ReentrantReadWriteLock separates read and write operations.
Multiple readers can potentially access the protected resource concurrently, while writes require exclusive access.
It can be useful when reads significantly outnumber writes, but it introduces additional complexity and should be used only when the workload benefits from it.
StampedLock provides read, write, and optimistic read modes.
Optimistic reads can reduce synchronization overhead for certain read-heavy workloads.
However, StampedLock is more complex than ReentrantLock and has important usage rules, including validation of optimistic reads.
It should be used only when its characteristics provide a measurable benefit.
A deadlock occurs when two or more threads wait indefinitely for resources held by one another.
For example:
Thread A holds Lock 1 and waits for Lock 2.
Thread B holds Lock 2 and waits for Lock 1.
Neither thread can continue.
Deadlocks can be investigated using thread dumps and JVM diagnostic tools.
Prevention strategies include:
Thread dumps can often explicitly identify deadlocked threads.
Deadlock: threads are blocked waiting for each other and cannot progress.
Livelock: threads are active but continuously respond to each other without making useful progress.
Starvation: a thread is continually denied the resources or scheduling opportunity it needs to make progress.
All three can result in poor application behavior, but their symptoms and solutions differ.
Thread starvation occurs when a thread cannot obtain sufficient CPU time or required resources because other threads continuously consume them.
Possible causes include:
Lock contention occurs when multiple threads compete for the same lock.
When a thread spends significant time waiting for a lock, application throughput can decrease even when CPU utilization appears relatively low.
Profiling tools and thread dumps can help identify contention.
Lock-free programming uses atomic operations and concurrent algorithms without traditional mutual-exclusion locks.
It can reduce blocking and contention for specific workloads.
However, lock-free programming is significantly more complex and is not automatically faster.
Senior engineers should choose it only when the workload and performance requirements justify the complexity.
AtomicInteger is useful for simple atomic state operations such as incrementing or updating a counter.
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
For simple independent atomic updates, this can avoid explicit locking.
However, when multiple variables must change together as one consistent operation, an atomic integer alone may not be sufficient.
ExecutorService separates task submission from task execution.
Instead of manually creating a new thread for every task, applications submit tasks to an executor.
ExecutorService executor =
Executors.newFixedThreadPool(10);
executor.submit(() -> processOrder());
executor.shutdown();
This allows the application to control concurrency and reuse worker threads.
ThreadPoolExecutor manages a pool of worker threads and a queue of submitted tasks.
Important components include:
Understanding these components is important when diagnosing thread-pool saturation.
Core pool size represents the baseline number of worker threads maintained by the executor.
Maximum pool size represents the upper limit on worker threads.
The behavior between these values also depends on the queue configuration.
A common interview mistake is assuming that a ThreadPoolExecutor immediately creates threads up to maximumPoolSize. Queue behavior is an important part of how task submission works.
When the executor cannot accept another task because the pool has reached its configured limits and the work queue cannot accept more tasks, the configured RejectedExecutionHandler is invoked.
Common policies include:
The rejection strategy should be chosen based on the application's failure and backpressure requirements.
A fixed thread pool maintains a configured number of worker threads.
A cached thread pool can create additional threads as needed and is intended for short-lived asynchronous tasks.
Cached thread pools can create a very large number of threads under sustained load, so blindly using them in production can create resource problems.
ForkJoinPool is designed for parallel task execution and divide-and-conquer workloads.
It uses worker threads and a work-stealing mechanism to keep workers busy.
It is commonly associated with parallel streams and is also used by default for many asynchronous CompletableFuture operations when no executor is explicitly supplied.
In a work-stealing pool, each worker can maintain its own queue of tasks.
When one worker runs out of work, it can steal tasks from another worker's queue.
This helps balance workloads and can improve utilization for suitable parallel workloads.
ExecutorService is a general-purpose abstraction for executing tasks.
ForkJoinPool is specialized for parallel workloads and work stealing.
For simple independent tasks, a conventional executor may be appropriate.
For recursive or highly parallel divide-and-conquer workloads, ForkJoinPool can be a better fit.
For CPU-bound work, the number of useful worker threads is generally close to the number of available CPU cores, although the optimal value depends on the workload and environment.
Adding many more threads can increase context switching without improving throughput.
The correct value should be validated through load testing and production measurements.
I/O-bound tasks spend a significant amount of time waiting for external resources such as databases, HTTP services, or files.
Therefore, the optimal number of concurrent tasks can be higher than the CPU core count.
A simplified guideline is:
Threads ≈ CPU Cores × (1 + Wait Time / Compute Time)
This is only a starting point. Database connection limits, downstream capacity, memory, and request latency must also be considered.
Future represents the result of an asynchronous computation and provides methods such as get() to wait for the result.
CompletableFuture provides a much richer API for composing asynchronous operations.
For example:
CompletableFuture
.supplyAsync(() -> getCustomer())
.thenApply(customer -> enrichCustomer(customer));
CompletableFuture also supports composition, exception handling, combining multiple tasks, and explicit executors.
thenApply is used when the next operation transforms a result into another value.
future.thenApply(customer -> customer.getName());
thenCompose is used when the next operation itself returns a CompletableFuture.
future.thenCompose(
customer -> getOrdersAsync(customer.getId())
);
Using thenCompose avoids creating nested futures such as CompletableFuture<CompletableFuture<T>>.
thenCombine combines the results of two independent CompletableFutures.
futureA.thenCombine(
futureB,
(a, b) -> combine(a, b)
);
allOf waits for multiple futures to complete.
One important detail is that allOf itself does not directly return the individual results, so those results need to be retrieved separately.
CompletableFuture provides methods such as:
exceptionally()handle()whenComplete()For example:
future
.exceptionally(ex -> fallbackValue);
The important point is to ensure that exceptions in asynchronous pipelines are not silently ignored.
Modern Java versions provide timeout-related methods on CompletableFuture.
future
.orTimeout(2, TimeUnit.SECONDS);
You can also provide a fallback:
future
.completeOnTimeout(
fallbackValue,
2,
TimeUnit.SECONDS
);
Timeouts are particularly important when asynchronous operations depend on external services.
Many asynchronous CompletableFuture methods allow an explicit Executor to be provided.
ExecutorService executor =
Executors.newFixedThreadPool(20);
CompletableFuture
.supplyAsync(() -> callService(), executor);
This can prevent blocking work from accidentally consuming a shared executor.
If blocking operations consume a limited executor, they can exhaust the available worker threads.
For example, if many CompletableFuture tasks perform slow blocking database calls using the same small executor, tasks may queue up and application latency can increase significantly.
The solution may involve using an appropriate executor, reducing blocking, or using an architecture designed around the workload.
Suppose an API needs information from three independent services:
Customer Service + Product Service + Recommendation Service
These calls can potentially be executed concurrently.
CompletableFuture<Customer> customer =
getCustomerAsync();
CompletableFuture<Products> products =
getProductsAsync();
CompletableFuture<Recommendations> recommendations =
getRecommendationsAsync();
CompletableFuture.allOf(
customer,
products,
recommendations
).join();
The overall latency can approach the slowest dependency rather than the sum of all three latencies, assuming the calls are genuinely independent and the system has sufficient capacity.
However, concurrency should still be bounded. Launching unlimited parallel requests can overwhelm downstream services.
Virtual Threads are lightweight threads managed by the JVM and were introduced as a major feature of Project Loom.
They allow applications to support very large numbers of concurrent tasks without requiring one expensive operating-system thread per task.
When a virtual thread performs a supported blocking operation, the JVM can suspend it and allow the underlying carrier thread to execute other work.
This makes Virtual Threads especially useful for high-concurrency applications dominated by blocking I/O.
Platform threads are closely associated with operating-system threads and have significantly higher resource costs.
Virtual threads are lightweight JVM-managed threads designed to make thread-per-request programming practical at much larger concurrency levels.
Virtual Threads are not simply "faster threads." Their major advantage is the ability to support high concurrency with blocking-style programming at a much lower thread-management cost.
Virtual Threads are particularly useful for workloads with large numbers of concurrent blocking operations.
Examples include:
They can simplify application code by allowing developers to use straightforward synchronous programming models while still supporting high concurrency.
Virtual Threads are not a universal performance solution.
They do not make CPU-bound calculations faster.
They also do not increase the capacity of downstream systems.
For example, if your database can safely handle only 100 concurrent connections, creating 100,000 virtual threads does not mean you can execute 100,000 database queries simultaneously.
Concurrency still needs to be controlled at resource boundaries.
When a virtual thread performs supported blocking operations, the JVM can suspend the virtual thread while the underlying operation is waiting.
The carrier thread can then execute other virtual threads.
This is one of the key reasons Virtual Threads are useful for I/O-heavy applications.
With traditional platform threads, thread pools are commonly used to limit the number of simultaneously executing tasks because platform threads are relatively expensive.
Virtual Threads change the economics of thread creation because they are lightweight.
However, this does not mean every resource should have unlimited concurrency.
For example, you may allow many virtual threads while still limiting concurrent database operations using a semaphore or relying on the database connection pool as a resource boundary.
Important limitations and considerations include:
Virtual Threads should therefore be introduced based on workload characteristics and measurements.
I would first confirm the CPU spike using application and infrastructure metrics.
Then I would identify the JVM threads consuming CPU using operating-system and JVM diagnostic tools.
Useful tools include:
jstackI would then investigate whether the thread is stuck in an infinite loop, excessive computation, garbage collection, lock-related activity, serialization, or some other CPU-intensive operation.
Increasing thread count does not necessarily increase throughput.
Too many threads can cause:
For example, if the application has a database connection pool of 50 connections and you increase the worker thread pool to 500, many threads may simply wait for database connections.
The bottleneck has moved rather than disappeared.
I would take multiple thread dumps and analyze thread states.
Important questions include:
I would correlate this with application metrics, database metrics, connection-pool metrics, traces, and recent deployments.
The solution depends on the consistency requirements and database design.
Possible approaches include:
For example, optimistic locking can use a version column:
UPDATE account
SET balance = ?, version = version + 1
WHERE id = ?
AND version = ?;
If no row is updated, another transaction may have modified the record first.
This is where a senior engineer should use multiple signals rather than guessing.
| Possible Bottleneck | What to Investigate |
|---|---|
| CPU | CPU metrics, thread profiling, JFR, hot methods |
| Thread contention | Thread dumps, lock metrics, profiler data |
| Database | Connection pool, query latency, DB CPU, locks |
| External I/O | Distributed traces, HTTP client latency, timeouts |
| Thread pool | Active threads, queue depth, rejected tasks |
| JVM | Heap, GC, allocation rate, threads |
Distributed tracing can be especially useful for determining whether time is being spent inside the application or waiting on downstream dependencies.
The goal is to move from:
"The application is slow."
to:
"95% of the additional latency is coming from database connection acquisition."
That is the level of diagnosis expected from a senior engineer.
This is an opportunity to demonstrate real engineering experience.
A strong answer should explain:
For example, don't simply say:
"We increased the thread pool size and the problem was fixed."
A senior-level answer should explain why the pool was exhausted, whether the real bottleneck was CPU, database, external I/O, lock contention, or queue saturation, and how you verified the solution.
Many concurrency problems come from a few recurring design mistakes.
More threads do not automatically mean more throughput. At some point, CPU, memory, locks, or downstream resources become the bottleneck.
The easiest concurrency bug to fix is often the one that never exists. Immutable objects and well-defined ownership boundaries can significantly simplify concurrent systems.
Blocking database or HTTP calls should not accidentally consume an executor intended for CPU-bound work.
Retries can amplify an outage. Always consider backoff, jitter, maximum attempts, timeouts, and idempotency.
A service might be capable of creating thousands of concurrent tasks while the database can handle only a fraction of them.
Virtual Threads make high concurrency more practical, but they do not eliminate database limits, API limits, CPU constraints, memory limits, or application-level contention.
| Concept | Key Idea |
|---|---|
| Race Condition | Result depends on concurrent timing |
| Thread Safety | Correct behavior under concurrent access |
| volatile | Visibility and ordering guarantees |
| synchronized | Mutual exclusion and visibility |
| CAS | Atomic compare-and-update operation |
| ReentrantLock | Explicit lock with advanced capabilities |
| Deadlock | Threads wait indefinitely for each other |
| ExecutorService | Separates task submission from execution |
| ThreadPoolExecutor | Configurable thread pool implementation |
| ForkJoinPool | Parallel execution with work stealing |
| CompletableFuture | Composable asynchronous programming |
| Virtual Thread | Lightweight JVM-managed thread |
| Thread Dump | Snapshot of thread states and stack traces |
| Java Flight Recorder | Low-overhead JVM/application diagnostics |
A senior engineer should not look at concurrency as simply a question of how many threads the application can create.
The better question is:
Where is the actual bottleneck?
Consider this request flow:
Client → Spring Boot → Thread Pool → Database → External API
If the application becomes slow, increasing the thread pool may make things worse if the database is already saturated.
Similarly, introducing Virtual Threads may allow the application to handle significantly more concurrent requests, but it can also expose capacity limitations in databases and external services.
This is why concurrency decisions should always be connected to:
Java concurrency is not just about knowing synchronized, volatile, or creating threads.
At the senior level, you need to understand how concurrency affects the entire production system.
Thread pools affect database connections. Database latency affects thread utilization. External API failures can exhaust executors. Excessive concurrency can increase CPU contention. Virtual Threads can increase scalability while simultaneously exposing downstream bottlenecks.
That is why the strongest senior Java developers think about concurrency as a system-design and production-engineering problem, not simply a language feature.
When troubleshooting a high-traffic Java application, the goal is not to ask:
"How can I create more threads?"
The better question is:
"What resource is limiting throughput, and how can I prove it?"
If you are preparing for a senior Java, Spring Boot, or backend engineering interview, use these 50 questions to practice explaining not only the theory but also the production scenarios behind each concept.
Looking to build scalable, high-performance Java and Spring Boot applications? Explore more practical Java, Spring Boot, microservices, AWS, Kubernetes, and backend engineering content from LogicBrace.
There is no single topic. Senior interviews commonly focus on the Java Memory Model, happens-before, synchronization, locks, thread pools, CompletableFuture, Virtual Threads, and production troubleshooting.
Virtual Threads reduce the need to pool threads simply because threads are expensive. However, resource pools such as database connection pools are still important, and application concurrency may still need to be bounded.
Check active threads, queue size, task duration, rejected tasks, thread dumps, CPU, database connections, external calls, and distributed traces. The objective is to determine why tasks are occupying worker threads for too long.
Thread dumps can reveal threads waiting on locks held by each other. JVM diagnostic tools and profilers can also help identify lock contention and deadlocks.
CPU-bound workloads spend most of their time performing computation, while I/O-bound workloads spend significant time waiting for external resources such as databases, files, or network services. The appropriate concurrency strategy differs between the two.
No. CompletableFuture enables asynchronous composition and concurrency, but excessive parallelism can overload CPU, databases, external services, or executors. Parallelism should be controlled according to system capacity.
0 Comments