What is Concurrency and Why Does It Matter?
A thread is the smallest unit of execution in the JVM. Every Java program starts with one thread (the main thread). Concurrency means structuring a program so that multiple tasks can make progress within overlapping time periods — either truly in parallel on multiple CPU cores, or interleaved on a single core.
The problem it solves: modern hardware has many cores and most applications spend significant time waiting — for network responses, database queries, file reads. A single-threaded program wastes that waiting time. Concurrency lets the CPU do useful work while I/O is in flight, and distributes CPU-bound work across cores.
// Single-threaded: sequential, each task blocks until complete
User user = db.findUser(id); // 50ms — CPU waits
Account account = db.findAccount(id); // 50ms — CPU waits
Orders orders = db.findOrders(id); // 50ms — CPU waits
// Total: ~150ms
// Concurrent: all three queries in flight simultaneously
CompletableFuture<User> fu = CompletableFuture.supplyAsync(() -> db.findUser(id));
CompletableFuture<Account> fa = CompletableFuture.supplyAsync(() -> db.findAccount(id));
CompletableFuture<Orders> fo = CompletableFuture.supplyAsync(() -> db.findOrders(id));
CompletableFuture.allOf(fu, fa, fo).join();
// Total: ~50ms — limited by the slowest, not the sum
/*
* Concurrency vs Parallelism:
*
* CONCURRENCY — multiple tasks in progress at overlapping times
* Can run on a single core via time-slicing
* About STRUCTURE: how you design the program
*
* PARALLELISM — multiple tasks literally executing at the same instant
* Requires multiple cores
* About EXECUTION: hardware running things simultaneously
*
* All parallel programs are concurrent.
* Not all concurrent programs are parallel.
*
* Java gives you concurrency primitives.
* The OS and JVM decide what actually runs in parallel.
*/
Traditional Java threads are platform threads — each maps 1:1 to an OS thread (~1MB stack, limited to ~thousands per JVM). Java 21 introduced virtual threads — lightweight JVM-managed threads that unmount from the OS thread while blocked on I/O. You can run millions of virtual threads. This changes the fundamental concurrency model for I/O-bound applications — covered in detail later.
Thread Basics
Creating and starting threads
// ❌ Extending Thread — locks you into single inheritance
class MyThread extends Thread {
@Override public void run() { System.out.println("running"); }
}
// ✅ Runnable — separates task from thread mechanism
Thread t = new Thread(() -> System.out.println("running"));
t.setName("worker-1"); // name shows in stack traces — always do this
t.setDaemon(true); // JVM exits even if this thread is still running
t.start(); // creates new thread, calls run() asynchronously
// t.run(); // ❌ NO — executes on the CALLING thread, no new thread
// join(): wait for thread to finish
t.join(); // current thread blocks until t completes
t.join(5000); // timeout: wait at most 5 seconds
Thread lifecycle
/*
* NEW → RUNNABLE → (BLOCKED | WAITING | TIMED_WAITING) → TERMINATED
*
* NEW thread created, start() not called yet
* RUNNABLE running or ready; scheduler decides when it gets CPU
* BLOCKED waiting to acquire a monitor lock (synchronized)
* WAITING waiting indefinitely: Object.wait(), Thread.join(), LockSupport.park()
* TIMED_WAITING waiting with timeout: sleep(n), wait(n), join(n)
* TERMINATED run() returned or threw uncaught exception
*
* Virtual threads (Java 21) add a MOUNTED/UNMOUNTED distinction:
* an unmounted virtual thread is waiting for I/O but holds no OS thread.
*/
// Inspect state programmatically
Thread t = new Thread(() -> {
try { Thread.sleep(2000); } catch (InterruptedException e) {
Thread.currentThread().interrupt(); // ✅ restore interrupted status
}
});
t.start();
t.getState(); // Thread.State.TIMED_WAITING
t.join();
t.getState(); // Thread.State.TERMINATED
// ❌ Swallowing InterruptedException destroys the interruption signal
try { Thread.sleep(1000); } catch (InterruptedException e) {
e.printStackTrace(); // interrupt flag is now cleared — caller never knows
}
// ✅ Option 1: propagate upward (cleanest when possible)
void myMethod() throws InterruptedException {
Thread.sleep(1000);
}
// ✅ Option 2: restore the flag so callers can detect it
try { Thread.sleep(1000); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Thread Pools and ExecutorService
Creating a new Thread per task is expensive (~1ms overhead, ~1MB stack)
and uncontrolled — 10,000 concurrent requests would spawn 10,000 threads,
exhausting memory. Thread pools reuse a fixed set of threads and queue excess
tasks.
// ✅ Fixed pool — bounded parallelism, predictable resource usage
ExecutorService pool = Executors.newFixedThreadPool(10);
// Submit Runnable (fire-and-forget)
pool.execute(() -> processOrder(order));
// Submit Callable — returns a Future with the result
Future<User> future = pool.submit(() -> db.findUser(id));
User user = future.get(); // blocks until done
User user = future.get(5, TimeUnit.SECONDS); // timeout
// Always shut down the pool — otherwise the JVM won't exit
pool.shutdown(); // stop accepting new tasks, finish queued ones
pool.awaitTermination(30, TimeUnit.SECONDS);
pool.shutdownNow(); // interrupt running tasks immediately
// ✅ try-with-resources (Java 19+)
try (ExecutorService exec = Executors.newFixedThreadPool(4)) {
exec.submit(() -> processA());
exec.submit(() -> processB());
} // auto-shutdown and await termination
Choosing the right pool
| Factory method | Behaviour | Use when |
|---|---|---|
newFixedThreadPool(n) |
Exactly n threads, unbounded queue | CPU-bound work, bounded parallelism |
newCachedThreadPool() |
Grows unbounded, idle threads expire in 60s | Many short-lived tasks — dangerous under load |
newSingleThreadExecutor() |
1 thread, tasks queued in order | Sequential processing, event loops |
newScheduledThreadPool(n) |
Scheduled / periodic tasks | Cron-like jobs, retries with delay |
newVirtualThreadPerTaskExecutor() |
One virtual thread per task (Java 21) | I/O-bound work — replaces large fixed pools |
If tasks arrive faster than they complete, the pool spawns threads without limit — potentially thousands, exhausting memory. For production services, always use a bounded pool with a defined queue and rejection policy, or virtual threads (Java 21).
Synchronisation: Protecting Shared State
The race condition problem
// count++ is NOT atomic — it compiles to three operations:
// 1. read count
// 2. increment
// 3. write count
// Two threads can interleave these steps and lose updates.
class UnsafeCounter {
private int count = 0;
public void increment() { count++; } // ❌ race condition
}
// Two threads each calling increment() 1000 times:
// Expected: 2000. Actual: 1743, 1891, 1950 — varies every run.
synchronized — simplest fix, but coarse
class SyncCounter {
private int count = 0;
// Method-level: locks on 'this' — only one thread in any synchronized method
public synchronized void increment() { count++; }
public synchronized int getCount() { return count; }
// Block-level: finer grain — lock only the critical section
public void doWork() {
// expensive non-critical work here (no lock held)
synchronized (this) {
count++; // lock held only for the minimum time
}
// more non-critical work (no lock held)
}
}
AtomicInteger — lock-free, preferred for counters
import java.util.concurrent.atomic.*;
class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() { count.incrementAndGet(); }
public int getCount() { return count.get(); }
public void addIfPositive(int n) {
// compareAndSet: atomic "check then act" — no lock needed
count.updateAndGet(current -> current > 0 ? current + n : current);
}
}
// Also: AtomicLong, AtomicBoolean, AtomicReference<T>
// LongAdder: even better for high-contention increment-only counters
ReentrantLock — explicit lock with more control
import java.util.concurrent.locks.*;
class LockCounter {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // ALWAYS unlock in finally — even if exception thrown
}
}
// tryLock: non-blocking attempt — avoid deadlocks
public boolean tryIncrement() {
if (lock.tryLock()) {
try { count++; return true; }
finally { lock.unlock(); }
}
return false; // couldn't acquire lock — do something else
}
}
// ReentrantReadWriteLock: multiple concurrent readers, exclusive writers
// StampedLock (Java 8+): optimistic reads — even faster for read-heavy workloads
volatile — visibility without atomicity
class StopFlag {
// Without volatile, the JVM may cache 'running' in a CPU register.
// Thread B could see stale true even after Thread A sets it to false.
private volatile boolean running = true;
public void stop() { running = false; }
public boolean isRunning() { return running; }
}
// volatile guarantees: writes are immediately visible to all threads
// volatile does NOT make compound operations (check-then-act) atomic
// For compound ops: use AtomicXxx or synchronized
Concurrency Hazards
Deadlock
// Classic deadlock: two threads acquire locks in opposite order
Object lockA = new Object();
Object lockB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockA) { // t1 acquires A
Thread.sleep(50);
synchronized (lockB) { ... } // t1 waits for B — held by t2
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) { // t2 acquires B
Thread.sleep(50);
synchronized (lockA) { ... } // t2 waits for A — held by t1
}
});
// Both threads wait forever — deadlock
// ✅ Prevention: always acquire locks in the same global order
synchronized (lockA) { synchronized (lockB) { ... } } // both threads: A then B
// ✅ Or use tryLock() with timeout
if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) {
try { /* do work */ } finally { lockB.unlock(); }
}
} finally { lockA.unlock(); }
}
// Diagnose: jstack <pid> shows deadlock detection output
Livelock and starvation
/*
* LIVELOCK: threads keep responding to each other but make no progress.
* Like two people in a hallway stepping the same direction simultaneously.
* Fix: randomised retry delay, exponential backoff.
*
* STARVATION: a thread never gets CPU time because higher-priority
* threads always pre-empt it. Fix: fair locks (new ReentrantLock(true))
* or avoid priority-based scheduling entirely.
*/
Concurrent Collections
Never use HashMap, ArrayList, or
HashSet from multiple threads without external synchronisation.
The JDK provides thread-safe alternatives with better performance than
wrapping with Collections.synchronized*().
// ConcurrentHashMap — lock striping: reads are lock-free, writes lock only the bucket
Map<String, Integer> map = new ConcurrentHashMap<>();
map.put("key", 1);
map.computeIfAbsent("key", k -> expensive()); // atomic: compute + insert if absent
map.merge("key", 1, Integer::sum); // atomic: increment counter
// CopyOnWriteArrayList — writes copy the array; reads are lock-free
// Use when reads >> writes (listener lists, config snapshots)
List<EventListener> listeners = new CopyOnWriteArrayList<>();
// BlockingQueue — thread-safe producer-consumer channel
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100); // bounded
queue.put(task); // blocks if full
Task t = queue.take(); // blocks if empty
queue.offer(task, 1, TimeUnit.SECONDS); // non-blocking with timeout
// ConcurrentLinkedQueue — lock-free FIFO, high throughput
Queue<Event> events = new ConcurrentLinkedQueue<>();
/*
* Cheat sheet:
* HashMap → ConcurrentHashMap
* ArrayList → CopyOnWriteArrayList (read-heavy) or synchronised wrapper
* LinkedList queue → LinkedBlockingQueue (producer-consumer) or ConcurrentLinkedQueue
* TreeMap → ConcurrentSkipListMap (sorted, lock-free)
* TreeSet → ConcurrentSkipListSet
*/
CompletableFuture — Async Pipelines
CompletableFuture (Java 8+) is the modern API for async computation.
It replaces raw Future — which could only block and had no
composition — with a fluent pipeline model.
// Basic async task
CompletableFuture<User> future = CompletableFuture
.supplyAsync(() -> db.findUser(id)); // runs on ForkJoinPool.commonPool()
// Chain transformations (non-blocking)
CompletableFuture<String> emailFuture = future
.thenApply(User::getEmail) // transform result
.thenApply(String::toLowerCase);
// Compose: next step is itself async
CompletableFuture<Order> orderFuture = future
.thenCompose(user -> orderService.loadAsync(user.getId())); // flatMap equivalent
// Combine two independent futures
CompletableFuture<String> page = CompletableFuture
.supplyAsync(() -> db.getContent(id))
.thenCombine(
CompletableFuture.supplyAsync(() -> db.getMetadata(id)),
(content, meta) -> renderPage(content, meta)
);
// Wait for ALL to complete
CompletableFuture.allOf(future1, future2, future3).join();
// First one to complete wins
CompletableFuture.anyOf(primary, fallback).thenAccept(result -> handle(result));
// Error handling
future
.exceptionally(ex -> { log.error("failed", ex); return defaultUser; })
.whenComplete((result, ex) -> audit.log(result, ex)); // always runs
// Custom executor — avoid blocking ForkJoinPool with I/O
ExecutorService ioPool = Executors.newFixedThreadPool(20);
CompletableFuture.supplyAsync(() -> httpClient.get(url), ioPool);
Virtual Threads (Java 21) — The Game Changer
Platform threads cost ~1MB of stack and map 1:1 to OS threads — a JVM with 4GB heap can support ~4,000. Virtual threads cost a few KB and are managed by the JVM scheduler. You can run millions concurrently. When a virtual thread blocks on I/O, it unmounts from its carrier thread — the carrier thread is freed to run other virtual threads.
// Create virtual threads
Thread vt = Thread.ofVirtual().name("vt-1").start(() -> handleRequest());
Thread.startVirtualThread(() -> handleRequest()); // shorthand
// One virtual thread per task — the recommended pattern for I/O-bound work
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request req : requests) {
exec.submit(() -> process(req)); // each request gets its own virtual thread
}
} // auto-shutdown
// In Spring Boot 3.2+ — one line in application.properties:
// spring.threads.virtual.enabled=true
// Every @RestController request now runs on a virtual thread
/*
* What virtual threads solve:
* ─────────────────────────────────────────────────────
* BEFORE (platform threads):
* Thread pool of 200. Request does: DB query (50ms) + API call (100ms).
* 200 threads → 200 concurrent requests max.
* During I/O, threads are BLOCKED — wasting OS resources.
*
* AFTER (virtual threads):
* One virtual thread per request. While blocked on DB/API, the carrier
* thread is freed. 10,000 concurrent requests, 8 carrier threads.
* Throughput limited by I/O capacity, not thread count.
*
* What virtual threads do NOT solve:
* ─────────────────────────────────────────────────────
* CPU-bound work: if your code burns CPU, virtual threads don't help.
* Use ForkJoinPool or parallel streams for CPU-bound parallelism.
*
* Pinning: synchronized blocks and native methods PIN the virtual thread
* to its carrier thread — blocking it. Use ReentrantLock instead of
* synchronized in virtual-thread-heavy code.
*/
Reactive frameworks (WebFlux/Reactor) solve the same scalability problem — don't block threads during I/O — but at the cost of a completely different programming model (callbacks, Mono/Flux, no blocking calls anywhere). Virtual threads achieve the same throughput with imperative, blocking code. For new projects on Java 21+, virtual threads are the simpler choice. Reactive is still relevant for backpressure control and event streaming.
Structured Concurrency (Java 21 Preview)
Structured concurrency treats a group of concurrent tasks as a single unit of work — if any task fails, the others are cancelled. This prevents the "lost thread" problem where a subtask fails silently while the parent continues.
// Classic problem: manual subtask management is error-prone
Future<User> fu = exec.submit(() -> fetchUser(id));
Future<Product> fp = exec.submit(() -> fetchProduct(id));
// If fetchUser() throws, fetchProduct() keeps running — wasted resources
// Structured concurrency: tasks live and die together
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> su = scope.fork(() -> fetchUser(id));
Subtask<Product> sp = scope.fork(() -> fetchProduct(id));
scope.join(); // wait for all subtasks
scope.throwIfFailed(); // propagate first failure, cancels remaining
User user = su.get();
Product product = sp.get();
}
// Scope closes: any running subtasks are automatically cancelled
Interview Questions
Q: What is the difference between a process and a thread?
A process is an independent program with its own memory space. Threads live
inside a process and share its heap memory. Context-switching between threads
is cheaper than between processes because threads share memory — no need to
swap address spaces. Multiple threads in the same JVM share the heap but each
has its own stack.
Q: What is the difference between start() and run()?
start() creates a new OS thread and schedules the
run() method to execute on it. run() called directly
executes the method on the calling thread — no new thread is created,
no concurrency happens. This is the most common beginner mistake.
Q: What is a race condition?
When two threads access shared mutable state concurrently and the correctness
of the result depends on the interleaving order. count++ is
three operations (read, increment, write) — two threads can read the same
value and both write back the same incremented value, losing one update.
Q: What is the difference between synchronized and volatile?
synchronized provides mutual exclusion (only one thread in the
block at a time) AND visibility (changes are flushed to main memory on exit).
volatile provides visibility only — reads and writes go directly
to main memory, but compound operations are NOT atomic. Use
volatile for simple flags; synchronized or
AtomicXxx for compound operations.
Q: What is the Java Memory Model and why does it matter?
The JMM defines when writes by one thread become visible to other threads.
Without synchronisation, the JVM and CPU may reorder instructions, cache
values in registers, or defer writes to main memory for performance. The JMM
establishes happens-before relationships: a write that
happens-before a read is guaranteed to be visible. Synchronised blocks,
volatile writes, and thread start/join create happens-before edges. Without
them, you have no visibility guarantees regardless of what the code looks
like.
Q: What is the difference between ConcurrentHashMap and
Collections.synchronizedMap()?
synchronizedMap wraps every method with a single lock on the
whole map — all operations are serialised, one thread at a time.
ConcurrentHashMap uses lock striping (one lock per bucket group)
— concurrent reads are lock-free, concurrent writes to different buckets
proceed in parallel. It also provides atomic compound operations
(computeIfAbsent, merge) that
synchronizedMap cannot guarantee atomically. Under contention,
ConcurrentHashMap is dramatically faster.
Q: What is the pinning problem with virtual threads?
A virtual thread is pinned to its carrier thread when executing
a synchronized block or a native method. While pinned, it
behaves like a platform thread — blocking the carrier. Under high concurrency,
all carrier threads can be pinned simultaneously, exhausting the pool and
causing the same bottleneck as platform threads. Fix: replace
synchronized with ReentrantLock in code on the hot
path. JDK 24 is working to eliminate the synchronized pinning
limitation.
Q: When would you still use reactive programming (WebFlux) over
virtual threads?
Virtual threads solve the scalability bottleneck but don't provide
backpressure — a fast producer can overwhelm a slow consumer with no
signalling mechanism. Reactive frameworks model this explicitly with
Flux/Mono and their request-n protocol. Use
reactive when: (1) processing unbounded event streams where backpressure
matters, (2) the entire pipeline — including libraries — is already reactive,
(3) you need fine-grained control over scheduling and buffering. For standard
REST APIs with database/HTTP calls, virtual threads are simpler and
sufficient.