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.
Thread Fundamentals
Before diving into advanced topics, understand how threads work in Java.
Thread Lifecycle
NEW → RUNNABLE → RUNNING → BLOCKED/WAITING → 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 |
Creating Threads
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.
Runnable vs Callable
// 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 42Thread Methods
// 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;
}Synchronization
When multiple threads access shared data, you need synchronization to prevent race conditions.
The Problem: Race Conditions
// 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)synchronized Keyword
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
ReentrantLock
More flexible than synchronized, with additional features.
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()) |
volatile Keyword
Ensures visibility but not atomicity.
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
Lock-free thread-safe operations using CAS (compare-and-swap).
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);
// LongAdder - better for high contention
LongAdder adder = new LongAdder();
adder.increment(); // Multiple threads can increment concurrently
adder.sum(); // Get totalConcurrent Collections
Thread-safe collections optimized for concurrent access.
ConcurrentHashMap
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 not Collections.synchronizedMap?
// 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 simultaneouslyCopyOnWriteArrayList
Optimized for read-heavy, write-rare scenarios.
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)
BlockingQueue
Thread-safe queue with blocking operations—perfect for producer-consumer.
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 & Thread Pools
Don't create threads manually—use thread pools for production code.
Why Thread Pools?
// 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();Thread Pool Types
// 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();Custom Thread Pool
// 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 |
Submitting Tasks
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 resultProper Shutdown
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
Modern async programming—compose, chain, and combine async operations.
Basic Usage
// 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));Chaining Operations
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);Combining Futures
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);Error Handling
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);
}
});Timeout (Java 9+)
CompletableFuture.supplyAsync(() -> slowOperation())
.orTimeout(5, TimeUnit.SECONDS) // Throws on timeout
.completeOnTimeout("default", 5, SECONDS); // Default on timeoutVirtual Threads (Java 21+)
Project Loom brought lightweight threads to Java—a game-changer for I/O-bound applications.
Platform Threads vs Virtual Threads
Platform Threads (traditional):
┌─────────────────────────────────────────┐
│ JVM Thread (1:1 with OS thread) │
│ - ~1MB stack memory │
│ - Expensive to create │
│ - Limited to thousands │
└─────────────────────────────────────────┘
Virtual Threads (Java 21+):
┌─────────────────────────────────────────┐
│ Virtual Thread (managed by JVM) │
│ - ~1KB initial memory │
│ - Cheap to create │
│ - Millions possible │
│ - Runs on carrier (platform) threads │
└─────────────────────────────────────────┘
Creating Virtual Threads
// 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 Virtual Threads Shine
// 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 NOT to Use Virtual Threads
// 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();
}Structured Concurrency (Preview)
// 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 exitsCommon Concurrency Problems
Understanding these problems helps you avoid and debug them.
Race Condition
Multiple threads access shared data, result depends on timing.
// Race condition
class Counter {
private int count = 0;
// Two threads: read 0, read 0, write 1, write 1 → count is 1, not 2
public void increment() {
count++;
}
}
// Fix: synchronization or atomic
class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
}Deadlock
Two threads each hold a lock the other needs.
// 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
}
}
// Fix: Consistent lock ordering
// Both threads acquire locks in same order: A, then BPreventing Deadlocks
// 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
}Livelock
Threads keep responding to each other but make no progress.
// Livelock: both threads keep yielding to each other
while (resourceInUse) {
Thread.yield(); // "After you" "No, after you" forever
}Thread Starvation
Low-priority threads never get CPU time.
// 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 modeMemory Visibility
Without synchronization, threads may see stale values.
// 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
What's Next?
Concurrency is a deep topic—this guide covers the essentials for interviews. In practice, start with the simplest solution: immutable objects where possible, then thread-safe collections, then explicit synchronization only when needed.
For Java 21+, virtual threads change the game for I/O-bound applications. Understand when they help (I/O) and when they don't (CPU-bound work).
