Multithreading, Concurrency, and Race Conditions in Java: A Complete Guide from Classic Threads to Virtual Threads
24 min read

Multithreading, Concurrency, and Race Conditions in Java: A Complete Guide from Classic Threads to Virtual Threads

Java is one of the first languages to make concurrency a core part of its language design. Since version 1.0 released in 1995, Java has already had java.lang.Thread and the synchronized keyword as built-in concurrency primitives. Over three decades, Java’s concurrency model has kept evolving — from the simple thread and synchronized (Java 1.0), to the feature-rich java.util.concurrent (Java 5), then to CompletableFuture for asynchronous programming (Java 8), and finally to Virtual Threads through Project Loom, stabilized in Java 21 and refined in Java 24–25. Each of these layers emerged as a response to the limitations of the previous layer, and fully understanding this evolution is the key to writing correct, safe, and efficient concurrent Java code.

However, Java’s concurrency power also brings real complexity. Race conditions, deadlocks, livelocks, visibility problems — all are bugs that can be very hard to trace because they only appear under certain conditions and often can’t be reproduced consistently in a development environment. This article discusses the entire spectrum of concurrency in Java in depth: from how threads work at the most basic level, the various synchronization mechanisms and when to choose which, race conditions along with how to detect and prevent them, common concurrent patterns in production, to virtual threads and structured concurrency, which are the newest ways of thinking about concurrency in the Java ecosystem.

Platform Threads: The Foundation of Java Concurrency

Creating and Running Threads

There are two classic ways to create a thread in Java: extending Thread or implementing Runnable.

// Way 1: Extend Thread
class WorkerThread extends Thread {
    @Override
    public void run() {
        System.out.println("Running on thread: " + Thread.currentThread().getName());
    }
}

// Way 2: Implement Runnable (more recommended)
Runnable task = () -> {
    System.out.println("Running on thread: " + Thread.currentThread().getName());
};

// Running the thread
Thread t1 = new WorkerThread();
t1.start(); // start(), not run()!

Thread t2 = new Thread(task);
t2.start();
Don’t call run() directly — that just calls an ordinary method on the currently running thread (usually the main thread). What makes the code run on a new thread is start(), which asks the JVM to create a new OS thread and run run() there.

Thread Lifecycle

Every Java thread has a lifecycle defined by the Thread.State enum:

stateDiagram-v2
    [*] --> NEW : new Thread()
    NEW --> RUNNABLE : start()
    RUNNABLE --> BLOCKED : waiting for a monitor lock
    BLOCKED --> RUNNABLE : lock available
    RUNNABLE --> WAITING : wait() / join() / park()
    WAITING --> RUNNABLE : notify() / interrupt()
    RUNNABLE --> TIMED_WAITING : sleep(ms) / wait(ms)
    TIMED_WAITING --> RUNNABLE : timeout / notify()
    RUNNABLE --> TERMINATED : run() finishes
    TERMINATED --> [*]

Platform Thread vs Kernel Thread

Every platform thread in Java is mapped 1:1 to an OS thread. This means every thread carries significant overhead:

  • Fixed stack memory: usually 512 KB to 2 MB per thread (configurable with -Xss)
  • Context switch overhead: involves the OS kernel, slower than user-space switching
  • Practically limited thread count: hundreds to a few thousand before performance drops significantly
flowchart TD
    subgraph "JVM"
        T1[Java Thread 1]
        T2[Java Thread 2]
        T3[Java Thread 3]
    end
    subgraph "OS Kernel"
        KT1[OS Thread 1]
        KT2[OS Thread 2]
        KT3[OS Thread 3]
    end
    T1 <-->|1:1 mapping| KT1
    T2 <-->|1:1 mapping| KT2
    T3 <-->|1:1 mapping| KT3

This is what becomes the main bottleneck in modern high-load Java applications, and the main motivation for the birth of Virtual Threads, discussed later.


Race Conditions: The Main Enemy of Concurrent Code

A race condition occurs when two or more threads access shared data simultaneously without proper synchronization, and the program’s result becomes non-deterministic — depending on the thread execution order, not the program logic.

The Classic Example: Lost Update

// ANTI-PATTERN: race condition on a shared counter
public class UnsafeCounter {
    private int count = 0; // shared state

    public void increment() {
        count++; // NOT SAFE: not an atomic operation!
    }

    public int getCount() {
        return count;
    }
}

// Test
UnsafeCounter counter = new UnsafeCounter();
List<Thread> threads = new ArrayList<>();

for (int i = 0; i < 10; i++) {
    threads.add(new Thread(() -> {
        for (int j = 0; j < 1000; j++) {
            counter.increment();
        }
    }));
}

threads.forEach(Thread::start);
threads.forEach(t -> {
    try { t.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

System.out.println(counter.getCount()); // Not 10000! The result is unpredictable

As in other languages, count++ isn’t a single atomic instruction — it consists of three steps: read, add, write. When two threads do this simultaneously, a lost update occurs:

sequenceDiagram
    participant T1 as Thread 1
    participant Mem as Memory (count)
    participant T2 as Thread 2
    Note over Mem: count = 42
    T1->>Mem: READ (gets 42)
    T2->>Mem: READ (gets 42)
    T1->>T1: Compute 42 + 1 = 43
    T2->>T2: Compute 42 + 1 = 43
    T1->>Mem: WRITE 43
    T2->>Mem: WRITE 43
    Note over Mem: count = 43, not 44!<br/>One increment is lost.

The Visibility Problem: A More Hidden Issue

Besides data race conditions, Java also has a subtler visibility problem. Because the Java Memory Model (JMM) allows every thread to keep a copy of a variable in its own CPU cache, changes made by one thread may not be immediately visible to another thread.

// ANTI-PATTERN: visibility problem
public class StopTask {
    private boolean running = true; // no visibility guarantee

    public void stop() {
        running = false; // another thread might never see this change
    }

    public void run() {
        while (running) { // might loop forever!
            doWork();
        }
    }
}

The thread running run() might read running from its own CPU cache, which hasn’t been updated even though another thread already called stop(). The result: an infinite loop even though running was set to false.


Synchronized: The Most Basic Synchronization Mechanism

The synchronized keyword in Java provides two guarantees at once: mutual exclusion (only one thread can execute a synchronized block on one object at a time) and visibility (changes made inside a synchronized block are guaranteed visible to other threads that enter the next synchronized block on the same object).

Synchronized Method

// CORRECT: synchronized method
public class SafeCounter {
    private int count = 0;

    public synchronized void increment() {
        count++; // only one thread can execute this at a time
    }

    public synchronized int getCount() {
        return count;
    }
}

Synchronized Block

More flexible because it can narrow the lock scope to only the part that truly needs protection:

public class BetterCounter {
    private int count = 0;
    private final Object lock = new Object(); // dedicated object as the lock

    public void incrementAndLog() {
        // work that doesn't need the lock — run outside the block
        String logMsg = prepareLogMessage();

        synchronized (lock) { // lock only when accessing shared state
            count++;
        }

        // logging also doesn't need the lock
        logger.info(logMsg);
    }
}

Synchronized Static Method

For static members, synchronized uses the Class object as the monitor, not an instance:

public class Registry {
    private static int instanceCount = 0;

    public static synchronized void register() {
        instanceCount++; // lock on Registry.class, not on an instance
    }
}
synchronized on an instance method and synchronized on a static method use different locks. An instance method locks the instance object, a static method locks the Class object. The two don’t block each other, which can become a source of bugs if not well understood.

Volatile: Visibility Guarantee Without Mutual Exclusion

The volatile keyword solves the visibility problem without full synchronization overhead. A volatile variable is always read directly from main memory, and writes go directly to main memory — never cached in a thread’s CPU registers.

// CORRECT: volatile for a stopping flag
public class StopTask {
    private volatile boolean running = true; // volatile guarantees visibility

    public void stop() {
        running = false; // this change is immediately visible to other threads
    }

    public void run() {
        while (running) { // always reads from main memory
            doWork();
        }
        System.out.println("Stopped correctly");
    }
}

When Volatile Is Enough, When It Isn’t

Volatile is ENOUGH if:
  ✓ Only one thread writes, one or more read
  ✓ For a boolean flag set only once (stopping flag)
  ✓ For references to immutable objects replaced atomically

Volatile is NOT ENOUGH if:
  ✗ There are read-modify-write operations (count++, list.add())
  ✗ Several variables must be updated atomically
  ✗ There are check-then-act conditions (if (x == null) { x = new X(); })

java.util.concurrent: Java’s Modern Concurrency Toolkit

The java.util.concurrent package (introduced in Java 5) provides higher-level abstractions that are far safer and more expressive than raw synchronized.

ReentrantLock: More Flexible Lock Control

ReentrantLock provides abilities that synchronized doesn’t have:

import java.util.concurrent.locks.ReentrantLock;

public class SafeStore {
    private final ReentrantLock lock = new ReentrantLock();
    private Map<String, String> data = new HashMap<>();

    public void put(String key, String value) {
        lock.lock();
        try {
            data.put(key, value);
        } finally {
            lock.unlock(); // ALWAYS in a finally block!
        }
    }

    // Try to lock without blocking (non-blocking)
    public boolean tryPut(String key, String value) {
        if (lock.tryLock()) {
            try {
                data.put(key, value);
                return true;
            } finally {
                lock.unlock();
            }
        }
        return false; // failed to get the lock, but didn't block
    }

    // Lock with a timeout
    public boolean putWithTimeout(String key, String value, long timeout, TimeUnit unit)
            throws InterruptedException {
        if (lock.tryLock(timeout, unit)) {
            try {
                data.put(key, value);
                return true;
            } finally {
                lock.unlock();
            }
        }
        return false;
    }
}

ReadWriteLock: Optimization for Read-Heavy Workloads

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CachedData {
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private Map<String, Object> cache = new HashMap<>();

    // Many threads can read simultaneously
    public Object get(String key) {
        rwLock.readLock().lock();
        try {
            return cache.get(key);
        } finally {
            rwLock.readLock().unlock();
        }
    }

    // Only one thread can write, and no readers while a writer is active
    public void put(String key, Object value) {
        rwLock.writeLock().lock();
        try {
            cache.put(key, value);
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}
ConditionsynchronizedReentrantLockReadWriteLock
Ease of useEasiestMediumMedium
Interruptible lockNoYesYes
Try lock (non-blocking)NoYesYes
Fairness controlNoYesYes
Multiple conditionsNo (one wait set)YesYes
Read-heavy optimizationNoNoYes

Atomic Classes: Lock-Free Atomic Operations

The java.util.concurrent.atomic package provides classes supporting atomic operations using low-level CPU instructions (CAS — Compare-And-Swap), without mutex overhead:

import java.util.concurrent.atomic.*;

// AtomicInteger for a thread-safe counter
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();           // atomic increment, returns the new value
counter.getAndIncrement();           // atomic increment, returns the old value
counter.addAndGet(5);                // atomic add
counter.compareAndSet(10, 20);       // CAS: set 20 only if it's currently 10

// AtomicReference for object references
AtomicReference<String> ref = new AtomicReference<>("initial");
ref.compareAndSet("initial", "new");   // atomic CAS on a reference

// AtomicLong for large counters
AtomicLong bigCounter = new AtomicLong(0L);

// LongAdder: more efficient than AtomicLong for increment-only counters
// with many threads (reduces contention via internal striping)
LongAdder adder = new LongAdder();
adder.increment();
long total = adder.sum();
For counters only incremented from many threads and occasionally read as a total (a common pattern in monitoring/metrics), LongAdder is more efficient than AtomicLong because it reduces contention by maintaining several internal counters summed up when sum() is called.

Concurrent Collections

Don’t use HashMap, ArrayList, or HashSet from multiple threads without external synchronization — they aren’t thread-safe. Java provides safe concurrent implementations:

// ANTI-PATTERN: HashMap is not thread-safe
Map<String, Integer> map = new HashMap<>(); // race condition!

// CORRECT option 1: ConcurrentHashMap — more efficient than synchronizedMap
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("key", 1);
// putIfAbsent, computeIfAbsent, merge — all atomic
concurrentMap.computeIfAbsent("newKey", k -> k.length()); // atomic

// CORRECT option 2: CopyOnWriteArrayList — ideal for read-heavy lists
List<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("item"); // every write creates a new array copy
// Iteration is always safe, never a ConcurrentModificationException

// CORRECT option 3: BlockingQueue — for the producer-consumer pattern
BlockingQueue<String> queue = new LinkedBlockingQueue<>(100);
queue.put("item");           // blocks if the queue is full
queue.offer("item");         // returns false if the queue is full (non-blocking)
String item = queue.take();  // blocks if the queue is empty

Thread Pools and ExecutorService

Creating a new thread for every task is an anti-pattern — too expensive. ExecutorService provides a reusable thread pool:

import java.util.concurrent.*;

// Pool with a fixed number of threads
ExecutorService fixed = Executors.newFixedThreadPool(4);

// Pool that creates a new thread if all are busy (dangerous for production!)
ExecutorService cached = Executors.newCachedThreadPool();

// Single thread — guarantees execution order
ExecutorService single = Executors.newSingleThreadExecutor();

// Scheduled executor — for periodic tasks
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);

// Submitting a task to the executor
fixed.submit(() -> {
    System.out.println("Task running on the thread pool");
});

// Submit with a return value (Future)
Future<Integer> future = fixed.submit(() -> {
    Thread.sleep(1000);
    return 42;
});

Integer result = future.get(); // blocks until the task finishes
Integer resultWithTimeout = future.get(2, TimeUnit.SECONDS); // with a timeout

// Always shut down the executor when done
fixed.shutdown();
fixed.awaitTermination(10, TimeUnit.SECONDS);

ThreadPoolExecutor: Manual Configuration

For production, it’s better to configure the thread pool explicitly rather than relying on Executors factory methods:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    4,                              // corePoolSize: the minimum threads always alive
    8,                              // maximumPoolSize: the maximum threads that can be created
    60L, TimeUnit.SECONDS,          // keepAliveTime: idle time before excess threads are stopped
    new LinkedBlockingQueue<>(100), // workQueue: the queue of waiting tasks
    new ThreadFactory() {           // custom thread factory for naming
        private int count = 0;
        @Override
        public Thread newThread(Runnable r) {
            return new Thread(r, "worker-" + count++);
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy if the queue is full
);
Avoid Executors.newCachedThreadPool() in production without limits. This pool creates unbounded new threads if all threads are busy. During a load spike, this can create hundreds or thousands of threads in an instant, causing an OutOfMemoryError. Always use a ThreadPoolExecutor with clear limits, or Virtual Threads for I/O-bound cases.

CompletableFuture: Non-Blocking Asynchronous Programming

CompletableFuture (Java 8) enables chaining asynchronous operations without callback hell:

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> fetchUserFromDB(userId))         // async on the ForkJoinPool
    .thenApply(user -> enrichWithProfile(user))         // sync transformation
    .thenCompose(user -> fetchOrdersAsync(user.getId())) // async flatMap
    .thenCombine(
        fetchRecommendationsAsync(userId),              // run in parallel
        (orders, recs) -> buildResponse(orders, recs)  // combine the results
    )
    .exceptionally(ex -> {                              // error handling
        logger.error("Failed", ex);
        return defaultResponse();
    });

String result = future.get(); // wait for the result

// Run several futures in parallel, wait for all to finish
CompletableFuture<Void> allDone = CompletableFuture.allOf(
    futureA, futureB, futureC
);

// Wait for the first one to finish
CompletableFuture<Object> firstDone = CompletableFuture.anyOf(
    futureA, futureB, futureC
);
flowchart LR
    A[supplyAsync<br/>fetch user] --> B[thenApply<br/>enrich profile]
    B --> C[thenCompose<br/>fetch orders async]
    D[fetchRecommendations<br/>async - parallel] --> E[thenCombine<br/>combine]
    C --> E
    E --> F[exceptionally<br/>error handling]
    F --> G[Result]

Deadlock, Livelock, and Starvation

Deadlock

A deadlock occurs when two or more threads wait for a lock held by another thread, forming a dependency cycle that can’t be resolved:

// ANTI-PATTERN: classic deadlock
Object lockA = new Object();
Object lockB = new Object();

Thread t1 = new Thread(() -> {
    synchronized (lockA) {
        Thread.sleep(100);
        synchronized (lockB) { // waiting for lockB
            System.out.println("T1 done");
        }
    }
});

Thread t2 = new Thread(() -> {
    synchronized (lockB) {
        Thread.sleep(100);
        synchronized (lockA) { // waiting for lockA — DEADLOCK!
            System.out.println("T2 done");
        }
    }
});
flowchart LR
    T1[Thread 1<br/>holds lockA] -->|waits for| LB[lockB]
    T2[Thread 2<br/>holds lockB] -->|waits for| LA[lockA]
    LA -->|held by| T1
    LB -->|held by| T2
// CORRECT: always acquire locks in the same order
Thread t1 = new Thread(() -> {
    synchronized (lockA) {      // order: A first, then B
        synchronized (lockB) {
            System.out.println("T1 done");
        }
    }
});

Thread t2 = new Thread(() -> {
    synchronized (lockA) {      // order: A first, then B (same!)
        synchronized (lockB) {
            System.out.println("T2 done");
        }
    }
});

Detecting Deadlocks with a Thread Dump

When a Java application deadlocks, you can detect it with a thread dump:

# Send a signal to the Java process to print a thread dump to stdout
kill -3 <pid>

# Or use jstack
jstack <pid>

A thread dump will show lines like these if there’s a deadlock:

Found one Java-level deadlock:
=============================
"Thread-2":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "Thread-1"
"Thread-1":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "Thread-2"

Livelock

A livelock is a condition where threads keep moving but no progress is made — like two people yielding to each other in a narrow hallway but both keep moving in the same direction:

// Livelock example: two threads "yielding" to each other
public class LivelockExample {
    volatile boolean step1Done = false;
    volatile boolean step2Done = false;

    void worker1() {
        while (!step2Done) {
            step1Done = true;
            Thread.sleep(10);
            step1Done = false; // "yields" because it sees worker2 isn't done
        }
    }

    void worker2() {
        while (!step1Done) {
            step2Done = true;
            Thread.sleep(10);
            step2Done = false; // "yields" too
        }
    }
}

Starvation

Starvation occurs when one or several threads never get access to a resource because other threads keep taking the turn. This can happen if:

  • Low-priority threads are always beaten by high-priority threads
  • A particular thread always loses the “competition” to acquire a lock (non-fair lock)

A ReentrantLock with the fair = true parameter solves starvation with a FIFO queue for threads waiting on the lock:

// Fair lock: the thread waiting longest gets the lock first
ReentrantLock fairLock = new ReentrantLock(true);

Common Anti-Patterns in Java Concurrency

Anti-Pattern 1: Broken Double-Checked Locking

// ANTI-PATTERN: double-checked locking without volatile — NOT SAFE
public class Singleton {
    private static Singleton instance; // missing volatile!

    public static Singleton getInstance() {
        if (instance == null) {               // first check (without lock)
            synchronized (Singleton.class) {
                if (instance == null) {       // second check (with lock)
                    instance = new Singleton(); // compiler/CPU can reorder this!
                }
            }
        }
        return instance;
    }
}

// CORRECT: volatile guarantees the initialization is fully visible
public class Singleton {
    private static volatile Singleton instance; // volatile is required!

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

// BETTER: use the Initialization-on-Demand Holder idiom
public class Singleton {
    private Singleton() {}

    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
        // Class loading in Java is guaranteed thread-safe by the JVM
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

Anti-Pattern 2: Swallowing InterruptedException

// ANTI-PATTERN: swallowing InterruptedException
public void doWork() {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        // don't do this! The interrupt flag is lost!
    }
}

// CORRECT option 1: restore the interrupt flag
public void doWork() {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt(); // restore the flag
        // do cleanup if needed
        return;
    }
}

// CORRECT option 2: propagate the exception
public void doWork() throws InterruptedException {
    Thread.sleep(1000); // let the caller handle it
}

Anti-Pattern 3: Publishing an Object Before Initialization Completes

// ANTI-PATTERN: this escape — a reference is spread before the constructor finishes
public class EventListener {
    private final String name;

    public EventListener(EventBus bus) {
        bus.register(this); // BUG: 'this' may not be fully initialized yet!
        this.name = "listener"; // this isn't done when register is called
    }
}

// CORRECT: use a factory method
public class EventListener {
    private final String name;

    private EventListener() {
        this.name = "listener";
    }

    public static EventListener create(EventBus bus) {
        EventListener listener = new EventListener(); // constructor finished
        bus.register(listener); // only spread after fully initialized
        return listener;
    }
}

Anti-Pattern 4: Synchronizing on a Mutable Object

// ANTI-PATTERN: locking on a variable whose reference can change
public class BadStore {
    private List<String> items = new ArrayList<>();

    public void add(String item) {
        synchronized (items) { // dangerous!
            items.add(item);
        }
    }

    public void reset() {
        synchronized (items) {
            items = new ArrayList<>(); // replaces the items reference!
            // the lock is now different from the one used by add()
        }
    }
}

// CORRECT: lock on a final object that never changes
public class GoodStore {
    private final Object lock = new Object(); // dedicated lock object
    private List<String> items = new ArrayList<>();

    public void add(String item) {
        synchronized (lock) {
            items.add(item);
        }
    }

    public void reset() {
        synchronized (lock) {
            items = new ArrayList<>(); // the lock stays the same
        }
    }
}

Anti-Pattern 5: Thread Leak — Threads That Never Stop

// ANTI-PATTERN: a background thread that never stops
public class DataPoller {
    public DataPoller() {
        Thread t = new Thread(() -> {
            while (true) { // there's no way to stop this!
                pollData();
                Thread.sleep(1000);
            }
        });
        t.start(); // this thread lives forever, even after DataPoller is unused
    }
}

// CORRECT: a daemon thread or a stopping flag
public class DataPoller implements AutoCloseable {
    private volatile boolean running = true;
    private final Thread pollThread;

    public DataPoller() {
        pollThread = new Thread(() -> {
            while (running) {
                pollData();
                try { Thread.sleep(1000); }
                catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        });
        pollThread.setDaemon(true); // will stop when the main thread finishes
        pollThread.start();
    }

    @Override
    public void close() {
        running = false;
        pollThread.interrupt();
    }
}

Virtual Threads: The Concurrency Revolution in Java 21+

The Problem Virtual Threads Solve

The 1:1 platform thread to OS thread model has a hard ceiling. For web server applications that need to handle thousands of concurrent connections, every request doing I/O (database queries, HTTP calls to other services) will block an OS thread while waiting for the response. The result: thousands of OS threads are created just to sit and wait, wasting memory and context switch overhead.

The traditional solution is reactive programming (WebFlux, RxJava) — non-blocking code based on callbacks or monads. But this comes at a very high complexity cost: uninformative stack traces, difficult debugging, and a mental model very different from regular synchronous code.

Virtual Threads (Project Loom) arrive with a different promise: write simple blocking code as usual, but let the JVM optimize its execution so valuable OS threads aren’t blocked.

How Virtual Threads Work

Virtual Threads are mapped to platform threads (carrier threads) in an M:N fashion, similar to goroutines in Go. When a virtual thread does blocking I/O, the JVM automatically unmounts that virtual thread from its carrier thread, allowing the carrier thread to work on other virtual threads. When the I/O completes, the virtual thread is rescheduled onto an available carrier thread.

flowchart TD
    subgraph "Virtual Threads (thousands)"
        VT1[VThread 1<br/>waiting DB]
        VT2[VThread 2<br/>running]
        VT3[VThread 3<br/>waiting HTTP]
        VT4[VThread 4<br/>running]
    end
    subgraph "Carrier Threads (= number of CPU cores)"
        CT1[Carrier Thread 1]
        CT2[Carrier Thread 2]
    end
    VT2 --> CT1
    VT4 --> CT2
    VT1 -.unmounted during I/O.- CT1
    VT3 -.unmounted during I/O.- CT2

Creating a Virtual Thread

// Way 1: Thread.ofVirtual()
Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("Running on a virtual thread: " + Thread.currentThread());
});

// Way 2: Thread.startVirtualThread()
Thread vt = Thread.startVirtualThread(() -> doWork());

// Way 3: Executor with virtual threads (most commonly used in production)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

try (executor) { // ExecutorService implements AutoCloseable
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            // every task gets its own virtual thread
            String result = httpClient.get("https://api.example.com/data");
            processResult(result);
        });
    }
} // automatically shuts down and waits for all tasks to finish

Comparison: Platform Thread vs Virtual Thread

// Platform thread: expensive, limited in number
try (ExecutorService executor = Executors.newFixedThreadPool(200)) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(1000); // blocks a platform thread for 1 second
        });
    }
    // 10,000 tasks, but only 200 threads — the rest queue up
}

// Virtual thread: cheap, can be millions
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(1000); // the virtual thread is unmounted, the carrier thread is free
        });
    }
    // 10,000 tasks, 10,000 virtual threads — all run "simultaneously"
}
AspectPlatform ThreadVirtual Thread
Stack size512 KB – 2 MBA few KB (dynamic)
Managed byOS KernelJVM
Practical countThousandsMillions
I/O blockingBlocks the OS threadUnmounts from the carrier thread
Context switchKernel mode (slow)User space (fast)
Suitable forCPU-intensive, thread poolsI/O-intensive, per-request

Virtual Threads Aren’t a Silver Bullet

There are several important things to understand so virtual threads aren’t misused:

// ANTI-PATTERN: thread-local with many virtual threads
// Thread-local works, but can worsen memory with millions of virtual threads
// because every virtual thread has its own ThreadLocal map
ThreadLocal<Connection> connectionLocal = new ThreadLocal<>();

// CORRECT: use Scoped Values (Java 21+) as a ThreadLocal replacement
// for data passed down the call stack
ScopedValue<Connection> CONNECTION = ScopedValue.newInstance();

ScopedValue.where(CONNECTION, getConnection()).run(() -> {
    doWork(); // can access CONNECTION anywhere in this call stack
});
Pinning is a condition where a virtual thread can’t be unmounted from its carrier thread while blocking — this happens when code runs inside a synchronized block or calls a native method. Java 24 has fixed most pinning cases for synchronized. If you use Java 21-23 and see thread dumps with many “pinned” virtual threads, consider migrating to ReentrantLock as a temporary solution.
Virtual threads are optimized for I/O-bound workloads — applications doing lots of network calls, database queries, or file I/O. For CPU-bound workloads (heavy computation without I/O), virtual threads provide no advantage over platform threads, and the number of concurrent tasks should stay limited to the number of CPU cores.

Structured Concurrency: A New Way to Manage Subtasks

StructuredTaskScope (preview in Java 21, evolving in later versions) brings a new paradigm for managing several closely related concurrent tasks as one unit of work:

import java.util.concurrent.StructuredTaskScope;

// Wait for all subtasks to finish (ShutdownOnFailure: cancels everything if one fails)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    StructuredTaskScope.Subtask<User> userTask =
        scope.fork(() -> fetchUser(userId));
    StructuredTaskScope.Subtask<List<Order>> ordersTask =
        scope.fork(() -> fetchOrders(userId));
    StructuredTaskScope.Subtask<List<Rec>> recsTask =
        scope.fork(() -> fetchRecommendations(userId));

    scope.join();           // wait for all to finish
    scope.throwIfFailed();  // throw an exception if any failed

    // All subtasks succeeded
    User user = userTask.get();
    List<Order> orders = ordersTask.get();
    List<Rec> recs = recsTask.get();

    return buildResponse(user, orders, recs);
}
// if one subtask fails: the scope automatically cancels the others

// ShutdownOnSuccess: finishes as soon as one subtask succeeds
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> fetchFromPrimaryDB());
    scope.fork(() -> fetchFromReplicaDB());

    scope.join();
    return scope.result(); // the result of the first subtask that succeeded
}
flowchart TD
    A[StructuredTaskScope] --> B[fork: fetchUser]
    A --> C[fork: fetchOrders]
    A --> D[fork: fetchRecommendations]
    B --> E[scope.join]
    C --> E
    D --> E
    E --> F{All succeeded?}
    F -- Yes --> G[Process the results]
    F -- No --> H[Cancel other subtasks<br/>Throw an exception]

The advantages of Structured Concurrency over manual CompletableFuture:

  • Subtask lifetime is guaranteed not to exceed its scope (no leaks)
  • Correct automatic cancellation when there’s a failure
  • Better stack traces and observability
  • Code that’s far easier to read and reason about

Production Concurrency Patterns in Java

Producer-Consumer with BlockingQueue

public class DataPipeline {
    private final BlockingQueue<String> queue = new LinkedBlockingQueue<>(1000);
    private final ExecutorService producerPool = Executors.newFixedThreadPool(2);
    private final ExecutorService consumerPool = Executors.newFixedThreadPool(4);
    private volatile boolean running = true;

    public void start() {
        // Producer
        producerPool.submit(() -> {
            while (running) {
                try {
                    String data = fetchData();
                    queue.put(data); // blocks if the queue is full
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        });

        // Consumer
        for (int i = 0; i < 4; i++) {
            consumerPool.submit(() -> {
                while (running || !queue.isEmpty()) {
                    try {
                        String data = queue.poll(100, TimeUnit.MILLISECONDS);
                        if (data != null) processData(data);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        break;
                    }
                }
            });
        }
    }

    public void stop() throws InterruptedException {
        running = false;
        producerPool.shutdown();
        consumerPool.shutdown();
        producerPool.awaitTermination(5, TimeUnit.SECONDS);
        consumerPool.awaitTermination(5, TimeUnit.SECONDS);
    }
}

Parallel Processing with Fork/Join

ForkJoinPool and RecursiveTask are suitable for recursive computation that can be split into smaller subtasks (divide and conquer):

import java.util.concurrent.*;

public class ParallelSum extends RecursiveTask<Long> {
    private static final int THRESHOLD = 1000;
    private final long[] array;
    private final int start, end;

    public ParallelSum(long[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            // Base case: compute directly
            long sum = 0;
            for (int i = start; i < end; i++) sum += array[i];
            return sum;
        }

        // Divide: split into two subtasks
        int mid = (start + end) / 2;
        ParallelSum left = new ParallelSum(array, start, mid);
        ParallelSum right = new ParallelSum(array, mid, end);

        left.fork(); // run left asynchronously
        long rightResult = right.compute(); // run right on this thread
        long leftResult = left.join(); // wait for left to finish

        return leftResult + rightResult;
    }
}

// Usage
ForkJoinPool pool = ForkJoinPool.commonPool();
long[] data = new long[1_000_000];
Long result = pool.invoke(new ParallelSum(data, 0, data.length));

Java Concurrent Code Review Checklist

BASIC THREAD SAFETY:
  □ Is every access to shared mutable state protected (synchronized/Lock/atomic)?
  □ Is volatile used only for single-write / flag visibility, not compound ops?
  □ Is there no object published before the constructor finishes (this escape)?
  □ Is the lock object final and never has its reference changed?

SYNCHRONIZED AND LOCK:
  □ Is unlock always in a finally block?
  □ Is the lock acquisition order consistent across the codebase (preventing deadlock)?
  □ Are there no nested synchronized blocks using different locks?
  □ Is the lock scope as narrow as possible?

COLLECTIONS:
  □ Is there no HashMap/ArrayList/HashSet accessed from multiple threads?
  □ Are ConcurrentHashMap.computeIfAbsent/merge used for atomic operations?
  □ Is a BlockingQueue used for producer-consumer, not busy-waiting?

THREAD POOLS AND EXECUTORS:
  □ Is the ExecutorService always shut down after use?
  □ Is the thread pool configured with the right size (not unbounded)?
  □ Is there no unbounded Executors.newCachedThreadPool() in production?

VIRTUAL THREADS (Java 21+):
  □ Are virtual threads used for I/O-bound, not CPU-bound tasks?
  □ Is ThreadLocal considered for replacement with ScopedValue if there are millions of vthreads?
  □ Are there no synchronized blocks in the virtual thread hot path (check pinning)?

INTERRUPTION:
  □ Is InterruptedException not swallowed (always restore the flag or propagate)?
  □ Do thread loops check the interrupt flag periodically?

LIFECYCLE:
  □ Are there no background threads without a stopping mechanism?
  □ Are all executors and resources closed properly (try-with-resources)?

DEADLOCK:
  □ Is the thread dump verified to have no lock cycles?
  □ Is a timeout used on tryLock to prevent permanent deadlocks?

Summary

  • Platform threads in Java are mapped 1:1 to OS threads — expensive, practically limited to hundreds or thousands, and become a bottleneck for I/O-heavy workloads.
  • synchronized provides both mutual exclusion and visibility, but can’t be interrupted and has no timeout — use ReentrantLock if you need these capabilities.
  • volatile only guarantees visibility, not atomicity — not enough for compound operations like count++.
  • java.util.concurrent.atomic provides CAS-based atomic operations without mutex overhead — use AtomicInteger/AtomicLong for counters, LongAdder for high-contention increment-only counters.
  • ReentrantLock vs synchronized: choose Lock if you need tryLock, timeouts, interruptibility, or multiple Conditions; synchronized for simple cases.
  • ReadWriteLock is optimal for data read far more often than written.
  • Use ConcurrentHashMap, CopyOnWriteArrayList, and BlockingQueue instead of regular collections for concurrent access.
  • ExecutorService with a thread pool explicitly configured via ThreadPoolExecutor is safer than the unbounded Executors.newCachedThreadPool().
  • CompletableFuture for chaining non-blocking asynchronous operations; avoid blocking get() inside an executor because it can cause thread exhaustion.
  • Deadlock is prevented by consistency in lock acquisition order; Livelock is solved with randomization or backoff; Starvation is solved with fair locks.
  • Virtual Threads (stable in Java 21, mature in Java 24-25) enable millions of concurrent I/O-bound tasks with regular blocking code — no reactive programming needed.
  • Virtual threads aren’t the solution for CPU-bound workloads; the number of concurrent CPU tasks must still be limited to the number of cores.
  • Structured Concurrency (StructuredTaskScope) provides lifetime guarantees, automatic cancellation, and better observability than manual CompletableFuture.allOf().
  • Always run tests with -ea (assertions) and consider tools like jcstress or ThreadSanitizer to find race conditions not visible from code review.

Portfolio