Concurrency is where Java interviews separate juniors from seniors. Anyone can write single-threaded code—understanding thread safety, synchronization, and modern concurrency APIs shows real expertise.
Java's concurrency model has evolved significantly. From raw threads and synchronized blocks to ExecutorService, CompletableFuture, and now virtual threads in Java 21. Interviewers expect you to know when to use each approach.
This guide covers the concurrency concepts that come up in Java backend interviews—from fundamentals to modern patterns.
Table of Contents
- Thread Fundamentals Questions
- Synchronization Questions
- Atomic Classes Questions
- Concurrent Collections Questions
- ExecutorService and Thread Pool Questions
- CompletableFuture Questions
- Virtual Threads Questions
- Deadlock Questions
- Concurrency Problems Questions
Thread Fundamentals Questions
Before diving into advanced topics, understanding how threads work in Java is essential for any concurrency discussion.
What are the different thread states in Java?
A thread in Java goes through several states during its lifecycle. Understanding these states helps you debug threading issues and reason about thread behavior in your applications.
When you create a thread object, it starts in the NEW state. Calling start() moves it to RUNNABLE, meaning it's ready to run but waiting for CPU time. The thread scheduler decides when it actually runs. During execution, a thread might enter BLOCKED state while waiting to acquire a lock, or WAITING/TIMED_WAITING when explicitly pausing. Finally, it reaches TERMINATED when execution completes.
flowchart LR
NEW --> RUNNABLE
RUNNABLE --> RUNNING
RUNNING --> BW["BLOCKED/WAITING"]
BW --> RUNNABLE
RUNNING --> TERMINATED| State | Description |
|---|---|
| NEW | Thread created, not yet started |
| RUNNABLE | Ready to run, waiting for CPU |
| RUNNING | Currently executing |
| BLOCKED | Waiting to acquire a lock |
| WAITING | Waiting indefinitely (wait(), join()) |
| TIMED_WAITING | Waiting with timeout (sleep(), wait(timeout)) |
| TERMINATED | Execution completed |
What are the different ways to create threads in Java?
Java provides two primary ways to create threads: extending the Thread class or implementing the Runnable interface. While both work, implementing Runnable is the preferred approach in modern Java development.
Extending Thread wastes Java's single inheritance—your class can only extend one class, and using it on Thread means you can't extend anything else. Implementing Runnable separates the task definition from the execution mechanism, making your code more flexible and testable.
Extending Thread (not recommended):
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Running in: " + Thread.currentThread().getName());
}
}
MyThread thread = new MyThread();
thread.start(); // start(), not run()Implementing Runnable (preferred):
Runnable task = () -> {
System.out.println("Running in: " + Thread.currentThread().getName());
};
Thread thread = new Thread(task);
thread.start();Why Runnable is better:
- Java allows single inheritance—extending Thread wastes it
- Runnable separates task from execution mechanism
- Same Runnable can be executed by Thread, ExecutorService, etc.
What is the difference between Runnable and Callable?
Both Runnable and Callable represent tasks that can be executed by threads, but they differ in two important ways: return values and exception handling.
Runnable's run() method returns void and cannot throw checked exceptions. This makes it suitable for fire-and-forget tasks where you don't need a result. Callable's call() method returns a value of any type and can throw checked exceptions, making it ideal when you need to get results back from concurrent tasks.
// Runnable: no return value, no checked exceptions
Runnable runnable = () -> {
System.out.println("Fire and forget");
};
// Callable: returns value, can throw exceptions
Callable<Integer> callable = () -> {
Thread.sleep(1000);
return 42;
};
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(callable);
Integer result = future.get(); // Blocks until complete, returns 42What are the essential thread methods you should know?
Java's Thread class provides several methods for controlling thread execution. The most important ones are sleep(), join(), and interrupt()—each serving a distinct purpose in thread coordination.
The sleep() method pauses the current thread for a specified duration. The join() method makes the current thread wait for another thread to complete. The interrupt() method signals a thread that it should stop what it's doing, though the thread must cooperate by checking its interrupt status.
// Sleep - pause current thread
Thread.sleep(1000); // Throws InterruptedException
// Join - wait for another thread to finish
Thread worker = new Thread(task);
worker.start();
worker.join(); // Current thread waits for worker to complete
// Interrupt - signal thread to stop
worker.interrupt();
// In the worker thread:
if (Thread.interrupted()) {
// Clean up and exit
return;
}
// Or handle InterruptedException
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
// Thread was interrupted during sleep
Thread.currentThread().interrupt(); // Restore interrupt status
return;
}What is the difference between start() and run()?
This is a classic interview question that tests whether you understand how threads actually work. Calling start() creates a new thread of execution and runs your code concurrently. Calling run() directly just executes the method in the current thread—no new thread is created.
When you call start(), the JVM allocates resources for a new thread, adds it to the scheduler, and eventually calls run() in that new thread. If you call run() directly, you're just making a normal method call that executes synchronously in your current thread.
Thread thread = new Thread(() -> {
System.out.println("Thread: " + Thread.currentThread().getName());
});
// WRONG: Runs in main thread, no concurrency
thread.run(); // Prints "Thread: main"
// CORRECT: Runs in new thread
thread.start(); // Prints "Thread: Thread-0"Synchronization Questions
When multiple threads access shared data, synchronization prevents race conditions and ensures data consistency.
What is a race condition and how do you prevent it?
A race condition occurs when multiple threads access shared data concurrently, and the final result depends on the unpredictable timing of thread execution. The classic example is incrementing a counter—the increment operation isn't atomic, so two threads can read the same value and both write the same incremented result.
The read-modify-write sequence in count++ involves three steps: read the current value, add one, write the result. If two threads interleave these steps, updates get lost. Prevention requires making the operation atomic through synchronization or using atomic classes.
// NOT thread-safe
class Counter {
private int count = 0;
public void increment() {
count++; // Read-modify-write: not atomic!
}
public int getCount() {
return count;
}
}
// Two threads incrementing 1000 times each
// Expected: 2000, Actual: unpredictable (often less)How does the synchronized keyword work in Java?
The synchronized keyword provides mutual exclusion—only one thread can execute a synchronized block or method at a time. It works by acquiring a lock on an object, and any other thread trying to enter a synchronized block on the same object must wait.
When you synchronize a method, the lock is on this for instance methods or the Class object for static methods. For finer control, you can synchronize on any object using a synchronized block. Beyond mutual exclusion, synchronized also guarantees visibility—changes made in a synchronized block are visible to other threads that subsequently synchronize on the same lock.
class Counter {
private int count = 0;
// Synchronized method - locks on 'this'
public synchronized void increment() {
count++;
}
// Synchronized block - more granular control
public void incrementWithBlock() {
synchronized (this) {
count++;
}
}
// Synchronize on specific lock object
private final Object lock = new Object();
public void incrementWithLock() {
synchronized (lock) {
count++;
}
}
}synchronized guarantees:
- Mutual exclusion: Only one thread executes the block at a time
- Visibility: Changes are visible to other threads after unlock
- Happens-before: Actions before unlock happen-before actions after lock
What is ReentrantLock and when should you use it over synchronized?
ReentrantLock is a more flexible alternative to synchronized with additional features like tryLock, timeouts, and fairness policies. While synchronized is simpler and automatically releases the lock, ReentrantLock gives you explicit control at the cost of more verbose code.
The name "reentrant" means a thread can acquire the same lock multiple times without deadlocking—it just increments a hold count. The critical difference from synchronized is the ability to attempt lock acquisition without blocking, which enables timeout-based deadlock prevention.
import java.util.concurrent.locks.ReentrantLock;
class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // ALWAYS unlock in finally
}
}
// tryLock - non-blocking attempt
public boolean tryIncrement() {
if (lock.tryLock()) {
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false; // Couldn't acquire lock
}
// tryLock with timeout
public boolean tryIncrementWithTimeout() throws InterruptedException {
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false; // Timeout
}
}ReentrantLock vs synchronized:
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Syntax | Simpler | More verbose |
| Unlock | Automatic | Manual (try-finally) |
| tryLock | No | Yes |
| Timeout | No | Yes |
| Fairness | No | Optional |
| Multiple conditions | No | Yes (newCondition()) |
| Interruptible | No | Yes (lockInterruptibly()) |
What is the volatile keyword and when should you use it?
The volatile keyword ensures visibility of variable changes across threads. Without volatile, a thread might cache a variable's value in a CPU register and never see updates made by other threads. Volatile forces reads and writes to go directly to main memory.
However, volatile only guarantees visibility—it doesn't provide atomicity. A volatile variable can be read and written atomically, but compound operations like increment still have race conditions. Use volatile for simple flags or state variables where one thread writes and others read.
class Flag {
// Without volatile, other threads might not see the change
private volatile boolean running = true;
public void stop() {
running = false; // Immediately visible to other threads
}
public void run() {
while (running) {
// Do work
}
}
}When to use volatile:
- Simple flags read by multiple threads
- Double-checked locking pattern
- Single writer, multiple readers
When NOT to use volatile:
// volatile doesn't make this atomic!
private volatile int count = 0;
count++; // Still a race condition
// Use AtomicInteger instead
private AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // Atomic operationAtomic Classes Questions
Java's atomic classes provide lock-free thread-safe operations using hardware-level compare-and-swap (CAS) instructions.
What are Atomic classes and how do they work?
Atomic classes like AtomicInteger, AtomicLong, and AtomicReference provide thread-safe operations without explicit locking. They use compare-and-swap (CAS) operations at the CPU level, which atomically compare a value and update it only if it matches the expected value.
CAS operations are faster than locks for low-to-moderate contention because they avoid the overhead of context switching. However, under very high contention, threads may repeatedly retry failed CAS operations, potentially degrading performance.
import java.util.concurrent.atomic.*;
// Atomic primitives
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // Returns new value
counter.getAndIncrement(); // Returns old value
counter.addAndGet(5); // Add and return new value
counter.compareAndSet(5, 10); // If current is 5, set to 10
AtomicLong longCounter = new AtomicLong();
AtomicBoolean flag = new AtomicBoolean(true);
// Atomic reference
AtomicReference<User> userRef = new AtomicReference<>(initialUser);
userRef.compareAndSet(oldUser, newUser);
// Atomic update with function
counter.updateAndGet(x -> x * 2);
counter.accumulateAndGet(5, Integer::sum);What is LongAdder and when should you use it over AtomicLong?
LongAdder is optimized for high-contention scenarios where many threads frequently update a counter. Instead of having all threads compete to update a single value, LongAdder maintains multiple cells that threads can update independently, then sums them when you need the total.
For counters that are written frequently but read rarely (like metrics collection), LongAdder significantly outperforms AtomicLong. However, if you need the exact current value frequently, AtomicLong is more appropriate since LongAdder's sum() may return a stale value if updates are in progress.
// LongAdder - better for high contention
LongAdder adder = new LongAdder();
adder.increment(); // Multiple threads can increment concurrently
adder.add(10);
adder.sum(); // Get total
// LongAccumulator - custom accumulation function
LongAccumulator maxAccumulator = new LongAccumulator(Long::max, Long.MIN_VALUE);
maxAccumulator.accumulate(100);
maxAccumulator.get(); // Returns maximum value seenConcurrent Collections Questions
Thread-safe collections optimized for concurrent access are essential for building scalable multi-threaded applications.
How does ConcurrentHashMap achieve thread safety?
ConcurrentHashMap uses fine-grained locking to allow multiple threads to read and write concurrently. Unlike Collections.synchronizedMap which locks the entire map for every operation, ConcurrentHashMap locks only individual buckets. This means threads accessing different parts of the map don't block each other.
In Java 8 and later, ConcurrentHashMap uses even finer-grained synchronization with CAS operations for many operations and node-level locking only when necessary. It also provides atomic compound operations like computeIfAbsent that are impossible to implement atomically with regular HashMap.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Basic operations are thread-safe
map.put("key", 1);
map.get("key");
map.remove("key");
// Atomic compound operations
map.putIfAbsent("key", 1); // Only puts if key doesn't exist
map.computeIfAbsent("key", k -> 1); // Compute value if absent
map.computeIfPresent("key", (k, v) -> v + 1); // Update if present
map.merge("key", 1, Integer::sum); // Merge with existing value
// Atomic update
map.compute("counter", (key, value) ->
value == null ? 1 : value + 1
);
// Bulk operations (parallel-friendly)
map.forEach(2, (key, value) ->
System.out.println(key + ": " + value)
);
long sum = map.reduceValues(2, Long::sum);Why shouldn't you use Collections.synchronizedMap?
Collections.synchronizedMap wraps a regular HashMap with synchronized methods, but this approach has significant limitations. Every operation locks the entire map, preventing any concurrent access even for reads. Additionally, iteration requires external synchronization because the iterator isn't thread-safe.
ConcurrentHashMap is designed from the ground up for concurrency. Multiple threads can read simultaneously, writes to different segments don't block each other, and iterators are weakly consistent—they reflect the map's state at some point and don't throw ConcurrentModificationException.
// synchronizedMap locks entire map for every operation
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// ConcurrentHashMap uses fine-grained locking
// Multiple threads can read/write different buckets simultaneously
ConcurrentHashMap<String, Integer> concMap = new ConcurrentHashMap<>();When should you use CopyOnWriteArrayList?
CopyOnWriteArrayList is optimized for read-heavy, write-rare scenarios. Every modification creates a new copy of the underlying array, so reads never block and iterators see a snapshot of the list at creation time. This makes it perfect for situations like maintaining a list of event listeners.
The trade-off is that writes are expensive—copying the entire array for each modification. Use it when reads vastly outnumber writes and you need safe iteration. Avoid it when writes are frequent, as the copying overhead becomes prohibitive.
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// Every write creates a new copy of the underlying array
list.add("item");
// Reads never block, even during writes
// Iterators see a snapshot at the time of creation
for (String item : list) {
// Safe even if another thread is adding/removing
System.out.println(item);
}Use when: Many reads, few writes, need safe iteration
Avoid when: Frequent writes (expensive copying)
What is BlockingQueue and when would you use it?
BlockingQueue is a thread-safe queue with blocking operations, making it the perfect foundation for producer-consumer patterns. When you try to take from an empty queue, the operation blocks until an element is available. When you try to put into a full queue (if bounded), it blocks until space is available.
This blocking behavior eliminates the need for explicit synchronization and wait/notify loops. The producer just calls put() and the consumer calls take()—the queue handles all the coordination.
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
// Producer
public void produce(Task task) throws InterruptedException {
queue.put(task); // Blocks if queue is full
}
// Consumer
public void consume() throws InterruptedException {
Task task = queue.take(); // Blocks if queue is empty
process(task);
}
// Non-blocking alternatives
queue.offer(task); // Returns false if full
queue.offer(task, 1, SECONDS); // Wait up to 1 second
queue.poll(); // Returns null if empty
queue.poll(1, SECONDS); // Wait up to 1 secondBlockingQueue implementations:
| Implementation | Characteristics |
|---|---|
| LinkedBlockingQueue | Optionally bounded, FIFO |
| ArrayBlockingQueue | Bounded, FIFO, fair option |
| PriorityBlockingQueue | Unbounded, priority ordering |
| SynchronousQueue | Zero capacity, direct handoff |
| DelayQueue | Elements available after delay |
ExecutorService and Thread Pool Questions
Don't create threads manually—use thread pools for production code to manage resources efficiently.
Why should you use thread pools instead of creating threads manually?
Creating threads is expensive—each thread requires memory allocation for its stack (typically 1MB), OS resources, and scheduler overhead. Creating thousands of threads can exhaust system resources and actually degrade performance through excessive context switching.
Thread pools maintain a fixed number of reusable threads. When you submit a task, it's queued and executed by an available thread. This bounds resource usage, amortizes thread creation cost across many tasks, and provides management features like graceful shutdown.
// Bad: Creating threads is expensive
for (int i = 0; i < 1000; i++) {
new Thread(task).start(); // 1000 threads = resource exhaustion
}
// Good: Reuse a fixed number of threads
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
executor.submit(task); // Tasks queued, executed by 10 threads
}
executor.shutdown();What are the different types of thread pools in Java?
Java provides several pre-configured thread pool types through the Executors factory class. Each is optimized for different use cases—fixed pools for predictable resource usage, cached pools for bursty workloads, and scheduled pools for timed or periodic tasks.
Choosing the right pool type depends on your workload characteristics. CPU-bound tasks benefit from a pool sized to the number of processors. I/O-bound tasks can use larger pools since threads spend time waiting. Virtual thread executors (Java 21+) are ideal for I/O-heavy workloads.
// Fixed thread pool - predictable resource usage
ExecutorService fixed = Executors.newFixedThreadPool(10);
// Cached thread pool - grows/shrinks based on demand
ExecutorService cached = Executors.newCachedThreadPool();
// Single thread - guarantees sequential execution
ExecutorService single = Executors.newSingleThreadExecutor();
// Scheduled - for delayed/periodic tasks
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(5);
// Work-stealing (ForkJoinPool) - for parallel divide-and-conquer
ExecutorService workStealing = Executors.newWorkStealingPool();
// Virtual thread per task (Java 21+)
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();How do you create a custom thread pool with ThreadPoolExecutor?
For fine-grained control over thread pool behavior, create a ThreadPoolExecutor directly. You can configure core and maximum pool sizes, idle thread timeout, queue type and capacity, thread naming, and rejection policies when the queue is full.
Understanding these parameters is crucial for production systems. The core size is maintained even when idle. Threads beyond core are created when the queue is full (up to maximum). The queue type affects behavior—unbounded queues mean maximum size is never reached.
// Full control with ThreadPoolExecutor
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize
20, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAliveTime for idle threads
new LinkedBlockingQueue<>(100), // work queue
new ThreadFactory() { // custom thread factory
private int count = 0;
public Thread newThread(Runnable r) {
return new Thread(r, "worker-" + count++);
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);Rejection policies (when queue is full):
| Policy | Behavior |
|---|---|
| AbortPolicy | Throws RejectedExecutionException |
| CallerRunsPolicy | Caller thread runs the task |
| DiscardPolicy | Silently discards the task |
| DiscardOldestPolicy | Discards oldest queued task |
How do you submit tasks to an ExecutorService?
ExecutorService provides several methods for task submission. The execute() method is fire-and-forget for Runnables. The submit() method returns a Future that lets you check completion, get results, or cancel the task. For multiple tasks, invokeAll() waits for all to complete while invokeAny() returns the first successful result.
Understanding the difference between execute and submit matters for error handling. Exceptions from execute propagate to the thread's UncaughtExceptionHandler. With submit, exceptions are captured in the Future and rethrown when you call get().
ExecutorService executor = Executors.newFixedThreadPool(10);
// Submit Runnable - no result
executor.execute(runnable);
Future<?> future = executor.submit(runnable);
// Submit Callable - get result
Future<Integer> result = executor.submit(callable);
Integer value = result.get(); // Blocks until complete
Integer value = result.get(1, SECONDS); // With timeout
// Submit multiple tasks
List<Callable<Integer>> tasks = List.of(task1, task2, task3);
List<Future<Integer>> futures = executor.invokeAll(tasks);
Integer first = executor.invokeAny(tasks); // First completed resultHow do you properly shut down an ExecutorService?
Properly shutting down an ExecutorService is critical for application cleanup. The shutdown() method stops accepting new tasks but lets queued and running tasks complete. The shutdownNow() method attempts to stop all tasks immediately by interrupting running threads.
Always use the two-phase shutdown pattern: call shutdown, wait for termination with a timeout, then call shutdownNow if tasks haven't completed. This gives tasks a chance to finish gracefully while ensuring the application eventually terminates.
ExecutorService executor = Executors.newFixedThreadPool(10);
// Graceful shutdown
executor.shutdown(); // No new tasks, complete existing
// Wait for completion
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
// Timeout - force shutdown
executor.shutdownNow(); // Interrupt running tasks
}
// Or combine in try-with-resources (Java 19+)
try (ExecutorService exec = Executors.newFixedThreadPool(10)) {
exec.submit(task);
} // Auto-shutdown on closeCompletableFuture Questions
CompletableFuture enables modern async programming—compose, chain, and combine asynchronous operations declaratively.
What is CompletableFuture and how do you use it?
CompletableFuture represents a future result of an asynchronous computation. Unlike Future, which only lets you block and wait, CompletableFuture provides a rich API for composing async operations, handling errors, and combining results—all without blocking.
The key insight is that CompletableFuture lets you describe what should happen when a result is available, rather than waiting for it. This enables non-blocking pipelines where the thread is free to do other work while waiting for I/O.
// Run async task
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("Running in: " + Thread.currentThread().getName());
});
// Supply async result
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return fetchDataFromApi();
});
// Get result (blocking)
String result = future.get();
String result = future.get(1, TimeUnit.SECONDS);
// Non-blocking result handling
future.thenAccept(result -> System.out.println("Got: " + result));How do you chain operations with CompletableFuture?
CompletableFuture's power comes from its chaining methods. Each method takes the result of the previous stage and produces a new stage. thenApply transforms the result, thenAccept consumes it, and thenRun executes an action ignoring the result.
The async variants (thenApplyAsync, etc.) run the callback in a thread pool rather than the completing thread. Use these when the callback does blocking work to avoid blocking the thread that completed the future.
CompletableFuture.supplyAsync(() -> fetchUserId())
.thenApply(userId -> fetchUser(userId)) // Transform result
.thenApply(user -> user.getEmail()) // Transform again
.thenAccept(email -> sendNotification(email)) // Consume result
.thenRun(() -> System.out.println("Done")) // Run action
.exceptionally(ex -> { // Handle errors
log.error("Failed", ex);
return null;
});
// Async versions for blocking operations
CompletableFuture.supplyAsync(() -> fetchUserId())
.thenApplyAsync(userId -> fetchUser(userId)) // Run in thread pool
.thenApplyAsync(user -> user.getEmail(), customExecutor);How do you combine multiple CompletableFutures?
When you need results from multiple independent async operations, CompletableFuture provides methods to combine them. thenCombine merges two futures into one result. allOf waits for multiple futures to complete. anyOf returns when the first completes.
These combinators enable parallel execution of independent operations. Instead of fetching user then orders sequentially, you can fetch both in parallel and combine when both complete.
CompletableFuture<User> userFuture = fetchUserAsync(userId);
CompletableFuture<List<Order>> ordersFuture = fetchOrdersAsync(userId);
// Combine two futures
CompletableFuture<UserWithOrders> combined = userFuture
.thenCombine(ordersFuture, (user, orders) ->
new UserWithOrders(user, orders)
);
// Wait for both (no result combination)
CompletableFuture<Void> both = CompletableFuture
.allOf(userFuture, ordersFuture);
// Wait for first completed
CompletableFuture<Object> first = CompletableFuture
.anyOf(future1, future2, future3);How do you handle errors in CompletableFuture?
CompletableFuture provides multiple ways to handle errors. exceptionally handles exceptions and provides a fallback value. handle receives both the result and exception, letting you handle success and failure uniformly. whenComplete executes a side effect but doesn't transform the result.
Choose the right method based on your needs: exceptionally for recovery with a default, handle for transforming both success and failure, whenComplete for logging or cleanup without changing the result.
CompletableFuture.supplyAsync(() -> {
if (error) throw new RuntimeException("Failed");
return "success";
})
.exceptionally(ex -> {
// Handle exception, return default
log.error("Error", ex);
return "default";
})
.handle((result, ex) -> {
// Handle both success and failure
if (ex != null) {
return "error: " + ex.getMessage();
}
return "success: " + result;
})
.whenComplete((result, ex) -> {
// Side effect, doesn't transform result
if (ex != null) {
log.error("Failed", ex);
}
});How do you add timeout to CompletableFuture?
Java 9 added timeout methods to CompletableFuture. orTimeout completes exceptionally with TimeoutException if the future doesn't complete in time. completeOnTimeout provides a default value instead of throwing.
These methods are essential for building resilient systems. Without timeouts, a slow service can cause your threads to wait indefinitely. Always set reasonable timeouts on external calls.
CompletableFuture.supplyAsync(() -> slowOperation())
.orTimeout(5, TimeUnit.SECONDS) // Throws on timeout
.completeOnTimeout("default", 5, SECONDS); // Default on timeoutVirtual Threads Questions
Project Loom brought lightweight threads to Java 21—a game-changer for I/O-bound applications.
What is the difference between platform threads and virtual threads?
Platform threads are the traditional Java threads, mapped 1:1 to operating system threads. Each platform thread consumes about 1MB of stack memory and requires significant OS resources. Creating thousands of them quickly exhausts system resources.
Virtual threads are lightweight threads managed by the JVM, not the OS. They use a small initial memory footprint (around 1KB) and can scale to millions. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier (platform) thread, which can then run other virtual threads.
flowchart TB
subgraph platform["Platform Threads (traditional)"]
PT["JVM Thread (1:1 with OS thread)<br/>• ~1MB stack memory<br/>• Expensive to create<br/>• Limited to thousands"]
end
subgraph virtual["Virtual Threads (Java 21+)"]
VT["Virtual Thread (managed by JVM)<br/>• ~1KB initial memory<br/>• Cheap to create<br/>• Millions possible<br/>• Runs on carrier threads"]
endHow do you create virtual threads in Java 21+?
Java 21 provides several ways to create virtual threads. You can create them directly using Thread.ofVirtual(), or more commonly, use Executors.newVirtualThreadPerTaskExecutor() which creates a new virtual thread for each submitted task.
The executor approach is recommended because it integrates with existing code that uses ExecutorService. Simply replace your fixed thread pool with the virtual thread executor and each task gets its own virtual thread.
// Direct creation
Thread vThread = Thread.ofVirtual().start(() -> {
System.out.println("Running in virtual thread");
});
// Named virtual thread
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.start(task);
// Virtual thread executor
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Each task gets its own virtual thread
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// I/O operation
fetchFromDatabase();
});
}
} // Waits for all tasks to completeWhen should you use virtual threads?
Virtual threads excel at I/O-bound workloads where threads spend most of their time waiting—database queries, HTTP calls, file operations. When a virtual thread blocks on I/O, its carrier thread is freed to run other virtual threads, maximizing hardware utilization.
The traditional advice to limit thread pool size no longer applies with virtual threads. Instead of carefully sizing pools, you can create a virtual thread per request or task, simplifying your code while improving throughput.
// Perfect for I/O-bound tasks
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
// 10,000 HTTP requests - each in its own virtual thread
for (String url : urls) {
futures.add(executor.submit(() -> {
return httpClient.send(request, BodyHandlers.ofString())
.body();
}));
}
// When a virtual thread blocks on I/O,
// its carrier thread runs other virtual threads
}When should you avoid virtual threads?
Virtual threads don't help CPU-bound tasks. If your code is computing rather than waiting, virtual threads provide no benefit—you're still limited by the number of CPU cores. Use platform thread pools sized to available processors for computational work.
Another limitation involves synchronized blocks. When a virtual thread enters a synchronized block, it becomes "pinned" to its carrier thread and can't unmount. This wastes carrier thread capacity. Use ReentrantLock instead of synchronized when working with virtual threads and blocking operations.
// CPU-bound work - no benefit from virtual threads
// The carrier threads are still limited
executor.submit(() -> {
// Heavy computation - use platform threads
return fibonacci(1000000);
});
// synchronized blocks pin the carrier thread
synchronized (lock) {
// Virtual thread cannot unmount during synchronized
// Use ReentrantLock instead
blockingOperation();
}
// Better with ReentrantLock
lock.lock();
try {
blockingOperation(); // Virtual thread can unmount
} finally {
lock.unlock();
}What is structured concurrency in Java?
Structured concurrency, previewing in Java 21+, treats groups of related tasks as a single unit of work. When you fork tasks within a scope, they all belong to that scope. If the scope exits (normally or via exception), all tasks are cancelled. If any task fails, siblings can be cancelled automatically.
This eliminates the common problem of "leaked" threads—tasks that continue running after their results are no longer needed. It also simplifies error handling by ensuring related tasks fail together.
// Java 21 preview - manage related tasks as a unit
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> userFuture = scope.fork(() -> fetchUser(id));
Future<List<Order>> ordersFuture = scope.fork(() -> fetchOrders(id));
scope.join(); // Wait for all
scope.throwIfFailed(); // Propagate errors
return new UserWithOrders(
userFuture.resultNow(),
ordersFuture.resultNow()
);
} // All tasks cancelled if scope exitsDeadlock Questions
Deadlocks are one of the most challenging concurrency problems to debug and prevent.
What is a deadlock and how does it occur?
A deadlock occurs when two or more threads are blocked forever, each waiting to acquire a lock held by another thread in the cycle. The classic scenario involves two threads and two locks: Thread 1 holds Lock A and waits for Lock B, while Thread 2 holds Lock B and waits for Lock A.
Four conditions must all be true for deadlock: mutual exclusion (locks are exclusive), hold and wait (threads hold locks while waiting for others), no preemption (locks can't be forcibly taken), and circular wait (a cycle exists in the wait graph).
// Deadlock waiting to happen
Object lockA = new Object();
Object lockB = new Object();
// Thread 1
synchronized (lockA) {
synchronized (lockB) {
// work
}
}
// Thread 2
synchronized (lockB) {
synchronized (lockA) {
// work - DEADLOCK if Thread 1 holds A, Thread 2 holds B
}
}How do you prevent deadlocks in Java?
The most reliable prevention strategy is consistent lock ordering—always acquire locks in the same global order across all code. If every thread acquires Lock A before Lock B, circular wait is impossible.
When lock ordering isn't feasible, use timeouts with ReentrantLock's tryLock. If you can't acquire a lock within a timeout, release any locks you hold and retry. This breaks the "hold and wait" condition.
// 1. Lock ordering - always acquire in same order
synchronized (lockA) { // Always A first
synchronized (lockB) {
// work
}
}
// 2. Lock timeout - give up instead of waiting forever
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// work
} finally {
lock.unlock();
}
} else {
// Handle timeout - maybe retry later
}
// 3. Lock all or nothing
boolean gotBoth = false;
while (!gotBoth) {
if (lockA.tryLock()) {
try {
if (lockB.tryLock()) {
try {
// work
gotBoth = true;
} finally {
lockB.unlock();
}
}
} finally {
lockA.unlock();
}
}
if (!gotBoth) Thread.sleep(100); // Back off
}Concurrency Problems Questions
Understanding common concurrency problems helps you avoid and debug them in production systems.
What is a livelock and how is it different from deadlock?
A livelock occurs when threads keep responding to each other but make no progress—they're not blocked, but they're not accomplishing useful work either. Unlike deadlock where threads are frozen, livelocked threads are actively running but stuck in a loop of mutual politeness.
The classic analogy is two people meeting in a hallway, each stepping aside to let the other pass, but both stepping the same direction repeatedly. The solution is to introduce randomness or asymmetry to break the symmetry.
// Livelock: both threads keep yielding to each other
while (resourceInUse) {
Thread.yield(); // "After you" "No, after you" forever
}
// Fix: Add randomness or backoff
while (resourceInUse) {
Thread.sleep(random.nextInt(100)); // Random backoff
}What is thread starvation?
Thread starvation occurs when a thread can never get the resources it needs to proceed. This often happens with unfair locks or priority-based scheduling where high-priority threads monopolize resources, preventing low-priority threads from ever running.
Prevention involves using fair locks (ReentrantLock with fairness enabled), avoiding priority manipulation, or ensuring all threads eventually get access to shared resources.
// Starvation: high-priority threads monopolize executor
executor.submit(highPriorityTask);
executor.submit(highPriorityTask);
executor.submit(lowPriorityTask); // May never run
// Fix: Fair locks, separate queues, or priority aging
ReentrantLock fairLock = new ReentrantLock(true); // Fair modeWhat is the memory visibility problem in Java?
Without proper synchronization, a thread may see stale values for variables modified by other threads. This happens because modern CPUs cache memory locally, and without memory barriers, changes aren't propagated to main memory or other caches.
The Java Memory Model defines rules for when writes become visible to other threads. Synchronization, volatile, and atomic classes all provide memory visibility guarantees. Without them, the JVM is free to reorder operations and cache values indefinitely.
// Thread 1 may never see running = false
class Worker {
private boolean running = true; // Not volatile!
public void stop() {
running = false;
}
public void run() {
while (running) { // May be cached in CPU register
// work
}
}
}
// Fix: volatile
private volatile boolean running = true;Quick Reference
Synchronization options:
synchronized- Simple mutual exclusionReentrantLock- tryLock, timeout, fairnessvolatile- Visibility only (no atomicity)Atomic*- Lock-free atomic operations
Thread pools:
newFixedThreadPool(n)- Fixed number of threadsnewCachedThreadPool()- Grows/shrinks on demandnewVirtualThreadPerTaskExecutor()- Virtual thread per task
Concurrent collections:
ConcurrentHashMap- Fine-grained locking mapCopyOnWriteArrayList- Read-heavy, write-rareBlockingQueue- Producer-consumer pattern
CompletableFuture:
supplyAsync- Async with resultthenApply- Transform resultthenCombine- Combine two futuresexceptionally- Handle errors
Virtual threads (Java 21+):
- Cheap, lightweight threads
- Best for I/O-bound work
- Avoid synchronized (use ReentrantLock)
- Use
newVirtualThreadPerTaskExecutor()
Related Articles
- Complete Java Backend Developer Interview Guide - Full Java backend interview guide
- Java Core Interview Guide - Java fundamentals
- Java 24 New Features Interview Guide - Latest Java features
- Spring Boot Interview Guide - Spring async patterns
