Skip to content
Home
Java Multithreading: Concurrency, Threads, Synchronization, Locks

Java Multithreading: Concurrency, Threads, Synchronization, Locks

Java Java 8 min read 1497 words Beginner ExcellentWiki Editorial Team

Concurrency is one of the hardest topics in software engineering. Java provides a rich set of primitives for multi-threaded programming — from the low-level synchronized keyword and java.util.concurrent.locks to high-level abstractions like executors, CompletableFuture, and (in Java 21) virtual threads. This guide covers thread lifecycle, safety, the Java Memory Model (JMM), synchronization techniques, the executor framework, and modern alternatives.

Threads and the JVM

A thread is the smallest unit of execution. The JVM maps Java threads to OS threads on most platforms. Each thread has its own program counter, stack, and local variables, but shares heap memory with all other threads (JLS §17).

The Thread class (java.lang) represents a thread. Creating a thread by extending Thread or implementing Runnable:

// Runnable approach (preferred — separates task from execution)
Runnable task = () -> System.out.println("Running in: " + Thread.currentThread().getName());
Thread thread = new Thread(task);
thread.start();

The thread lifecycle: NEW → RUNNABLE → (BLOCKED | WAITING | TIMED_WAITING) → TERMINATED. The Thread.State enum and the JVM specification (§2.5) define these states.

Synchronization and the Java Memory Model

When multiple threads access shared mutable data, the Java Memory Model (JLS §17.4) guarantees visibility and ordering only when proper synchronization is used. Without synchronization, threads may see stale values or reorder instructions.

The synchronized Keyword

synchronized provides mutual exclusion and visibility guarantees. Every Java object has an intrinsic lock (monitor):

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

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

The synchronized block bounds a critical section. Only one thread at a time can execute synchronized methods on the same instance. The lock is released when the block exits (even via exception).

volatile

The volatile keyword (JLS §8.3.1.4) guarantees visibility without mutual exclusion. Writes to a volatile variable are immediately visible to all threads. Use it for flags, status indicators, and variables that are accessed by only one writer and multiple readers.

public class ShutdownFlag {
    private volatile boolean shutdown = false;

    public void shutdown() { shutdown = true; }

    public void run() {
        while (!shutdown) {
            // do work
        }
    }
---

java.util.concurrent.locks

The Lock interface provides more flexible synchronization than synchronized. ReentrantLock supports try-lock, timed lock, interruptible lock, and multiple condition variables:

ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

public void put(E item) throws InterruptedException {
    lock.lock();
    try {
        while (buffer.size() == capacity) notFull.await();
        buffer.add(item);
        notEmpty.signal();
    } finally {
        lock.unlock();
    }
---

The finally block is mandatory — never exit a Lock without unlocking. The Oracle Lock documentation describes the full API.

ReadWriteLock (and its implementation ReentrantReadWriteLock) allows concurrent reads while exclusive writes. Use it for read-mostly data structures.

Atomic Variables and CAS

The java.util.concurrent.atomic package provides lock-free, thread-safe operations using Compare-And-Swap (CAS) instructions at the hardware level. They outperform locks for simple operations:

AtomicLong counter = new AtomicLong(0);

// Equivalent to counter++ but thread-safe
long previous = counter.getAndIncrement();

// Atomic compare-and-set
boolean updated = counter.compareAndSet(10, 11);

LongAdder and LongAccumulator (Java 8+) are optimized for high-contention write scenarios (e.g., request counters).

The Executor Framework

ExecutorService abstracts thread management. It decouples task submission from execution policy.

ExecutorService executor = Executors.newFixedThreadPool(10);

// Submit a Callable and get a Future
Future<Integer> future = executor.submit(() -> expensiveComputation());

// Block until result is available
Integer result = future.get(5, TimeUnit.SECONDS);

Thread pool types:

  • newFixedThreadPool(n) — fixed pool size, unbounded queue
  • newCachedThreadPool() — dynamic sizing, 60s keep-alive
  • newSingleThreadExecutor() — single worker, serializes tasks
  • newScheduledThreadPool(n) — delayed/periodic execution

Always shut down executors:

executor.shutdown();      // reject new tasks, complete queued
boolean done = executor.awaitTermination(30, TimeUnit.SECONDS);
if (!done) executor.shutdownNow();  // force shutdown

CompletableFuture

CompletableFuture (Java 8+) enables asynchronous, non-blocking composition:

CompletableFuture.supplyAsync(() -> fetchUser(id))
    .thenCompose(user -> CompletableFuture.supplyAsync(() -> fetchOrders(user)))
    .thenApply(orders -> orders.stream()
        .filter(Order::isActive)
        .collect(Collectors.toList()))
    .exceptionally(ex -> {
        logger.error("Failed to fetch orders", ex);
        return List.of();
    })
    .thenAccept(System.out::println);

The *Async variants accept an Executor. The default fork-join pool (ForkJoinPool.commonPool()) is shared across all CompletableFuture chains.

Fork-Join Framework

The fork-join framework (Java 7) divides a task into subtasks recursively, executes them in parallel, and joins results. It uses work-stealing (idle threads steal tasks from busy threads’ queues):

public class ParallelSum extends RecursiveTask<Long> {
    private final long[] array;
    private final int lo, hi;
    private static final int THRESHOLD = 10_000;

    @Override
    protected Long compute() {
        if (hi - lo < THRESHOLD) {
            long sum = 0;
            for (int i = lo; i < hi; i++) sum += array[i];
            return sum;
        }
        int mid = (lo + hi) / 2;
        ParallelSum left = new ParallelSum(array, lo, mid);
        ParallelSum right = new ParallelSum(array, mid, hi);
        left.fork();
        long rightResult = right.compute();
        long leftResult = left.join();
        return leftResult + rightResult;
    }
---

Virtual Threads (Project Loom)

Java 21 introduced virtual threads (JEP 425) — lightweight threads managed by the JVM. Unlike platform threads, they cost only a few hundred bytes each, enabling millions of concurrent tasks:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return process(i);
        })
    );
---

Virtual threads are ideal for I/O-bound workloads. Avoid synchronized blocks inside virtual threads (they pin the carrier thread). Use ReentrantLock instead. See our Java 17–21 Features guide for more on virtual threads and structured concurrency.

FAQ

Q: What is a race condition? A: A race condition occurs when the outcome depends on the timing of thread execution. Use synchronization or atomic variables to prevent it.

Q: What is the difference between notify() and notifyAll()? A: notify() wakes one waiting thread; notifyAll() wakes all. Use notifyAll() unless you are certain only one thread can benefit — it is safer and prevents signal loss.

Q: When should I use synchronized vs ReentrantLock? A: Use synchronized for simple mutual exclusion. Use ReentrantLock when you need try-lock, timed lock, interruptible lock, or condition variables.

Q: Do virtual threads replace platform threads? A: No. Platform threads remain the default for compute-bound tasks. Virtual threads optimize I/O concurrency. Many server frameworks will transparently switch to virtual threads.

Q: What is thread starvation and how do I prevent it? A: Starvation occurs when low-priority threads never get CPU time. Use fair locks (new ReentrantLock(true)) or structured concurrency to prevent it.

Real-World Concurrency Patterns

Producer-Consumer: A classic pattern where one or more threads produce data while others consume it. Use BlockingQueue for thread-safe handoff:

BlockingQueue<Article> queue = new LinkedBlockingQueue<>(100);

// Producer
executor.submit(() -> {
    for (Article article : articles) {
        queue.put(article);  // blocks if full
    }
    queue.put(POISON_PILL);  // signal completion
---);

// Consumer
executor.submit(() -> {
    while (true) {
        Article article = queue.take();  // blocks if empty
        if (article == POISON_PILL) break;
        process(article);
    }
---);

Parallel Data Processing: Split a large task across threads and combine results. The fork-join framework handles this natively, but ExecutorService with CompletionService also works:

ExecutorService executor = Executors.newFixedThreadPool(4);
CompletionService<Result> completion = new ExecutorCompletionService<>(executor);

for (Task task : tasks) {
    completion.submit(() -> process(task));
---

for (int i = 0; i < tasks.size(); i++) {
    Future<Result> future = completion.take();
    results.add(future.get());
---
executor.shutdown();

This pattern allows processing results as they complete rather than in submission order, improving throughput.

Read-Write Locking: For read-mostly data structures:

ReadWriteLock rwLock = new ReentrantReadWriteLock();
Map<String, ConfigValue> cache = new HashMap<>();

public ConfigValue get(String key) {
    rwLock.readLock().lock();
    try {
        return cache.get(key);
    } finally {
        rwLock.readLock().unlock();
    }
---

public void put(String key, ConfigValue value) {
    rwLock.writeLock().lock();
    try {
        cache.put(key, value);
    } finally {
        rwLock.writeLock().unlock();
    }
---

Multiple readers proceed concurrently; writers obtain exclusive access. This outperforms synchronized on the entire map for read-heavy workloads.

Java 21’s structured concurrency (JEP 453) provides a structured API for managing subtask lifecycles. It ensures that if a subtask fails, sibling subtasks are cancelled automatically, preventing thread leaks and orphaned computations. Combine structured concurrency with virtual threads for a concurrency model that is both safe and scalable — the closest Java has come to Erlang or Go’s approach to concurrency.

The CompletableFuture API supports orTimeout() and completeOnTimeout() (Java 9+), which add timeouts to asynchronous operations without external scheduling. Combine with delay() and failAfter() patterns for robust microservice calls that do not hang indefinitely on unresponsive downstream services.

The Phaser class (Java 7+) provides a reusable synchronization barrier that supports dynamic party registration. Unlike CyclicBarrier, Phaser can change the number of threads between phases — useful for multi-phase parallel algorithms where work splits and merges. Each phase can execute a custom action before proceeding to the next.

Thread dump analysis tools like fastthread.io and jstack visualize thread states and lock ownership. A thread dump captured during a performance incident reveals the exact line numbers where threads are blocked, waiting, or running — invaluable for diagnosing deadlocks, livelocks, and contention hotspots.

The Exchanger class (java.util.concurrent) provides a rendezvous point for two threads to swap objects. It is useful for pipeline patterns where two threads process data in stages — one thread fills a buffer while the other drains it, and at the exchange point they swap buffers.

For deeper study, read Java Concurrency in Practice by Brian Goetz and the Oracle Concurrency Tutorial. Explore our Java Performance Tuning guide for JVM-level concurrency optimization.

Section: Java 1497 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top