Multithreading & Concurrency

Threads, synchronisation, thread pools, and virtual threads in production Java

← Back to Index

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.
 */
Platform threads vs virtual threads (Java 21)

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
Always restore interrupted status
// ❌ 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
newCachedThreadPool() is a trap under load

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.
 */
Virtual threads vs reactive programming

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

🎓 Junior level

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.

🔥 Senior level

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.