Skip to main content
Java · lesson 35 of 66
Open the Java page →
Java intermediate Lesson 35 of 66

Multithreading in Java

Learn Java multithreading by running it: Runnable and join, race conditions, synchronized and atomics, executors, CompletableFuture, virtual threads.

By SkillByExample · Updated

A thread is an independent path of execution inside one program. Threads share the heap, so they can cooperate on the same objects — and corrupt them if they are not coordinated. Thread scheduling is not deterministic, so every example here collects results and prints them in a fixed order after the threads finish. Each is a single file run with the JDK 21 source launcher (java File.java).

Starting a thread with a Runnable

A Runnable is the task; a Thread runs it. join() waits for the thread to finish.

public class FirstThread {
    public static void main(String[] args) throws InterruptedException {
        String[] results = new String[2];

        Runnable task0 = () -> results[0] = "computed by " + Thread.currentThread().getName();
        Runnable task1 = () -> results[1] = "computed by " + Thread.currentThread().getName();

        Thread t0 = new Thread(task0, "worker-0");
        Thread t1 = new Thread(task1, "worker-1");
        t0.start();                 // run() executes on a new thread
        t1.start();
        t0.join();                  // wait for both before reading results
        t1.join();

        for (String r : results) System.out.println(r);
        System.out.println("main ran on " + Thread.currentThread().getName());
    }
}
$ java FirstThread.java
computed by worker-0
computed by worker-1
main ran on main

start() returns immediately and the two workers run concurrently with main. Calling task0.run() instead would run the code on main — no new thread at all. join() also guarantees that the workers’ writes to results are visible to main afterwards.

The thread lifecycle

A thread moves through NEW, RUNNABLE, blocked or waiting states, and TERMINATED.

import java.util.concurrent.CountDownLatch;

public class Lifecycle {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch go = new CountDownLatch(1);
        Thread t = new Thread(() -> {
            try { go.await(); } catch (InterruptedException e) { return; }
        });

        System.out.println(t.getState());
        t.start();
        while (t.getState() != Thread.State.WAITING) Thread.onSpinWait();
        System.out.println(t.getState());   // parked inside await()
        go.countDown();
        t.join();
        System.out.println(t.getState());
    }
}
$ java Lifecycle.java
NEW
WAITING
TERMINATED

The loop waits for the observable state rather than sleeping for a guessed time. A thread blocked in await(), join() or wait() is WAITING; with a timeout it would be TIMED_WAITING, and one queuing for a synchronized lock is BLOCKED.

A race condition

count++ reads, adds and writes. Four threads doing it a million times each lose updates.

public class Race {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1_000_000; j++) count++;
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();

        System.out.println("expected 4000000");
        System.out.println("lost updates? " + (count < 4_000_000));
    }
}
$ java Race.java
expected 4000000
lost updates? true

The exact total changes from run to run — that unpredictability is the bug — so the program prints only whether updates were lost. Two threads read the same old value, both add one, and one write overwrites the other.

Fixing it: synchronized and AtomicInteger

A synchronized block lets one thread at a time through; an AtomicInteger does the read-add-write as one hardware operation.

import java.util.concurrent.atomic.AtomicInteger;

public class NoRace {
    static int locked = 0;
    static final Object LOCK = new Object();
    static final AtomicInteger atomic = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1_000_000; j++) {
                    synchronized (LOCK) { locked++; }
                    atomic.incrementAndGet();
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        System.out.println("synchronized: " + locked);
        System.out.println("atomic:       " + atomic.get());
    }
}
$ java NoRace.java
synchronized: 4000000
atomic:       4000000

Both counters are exact every run. Every thread must lock on the same object — synchronizing on a different object per thread would protect nothing. For a single counter, the atomic is simpler and usually faster.

volatile for a stop flag

volatile makes a write by one thread visible to others promptly. It suits a simple flag.

public class StopFlag {
    static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long spins = 0;
            while (running) spins++;          // re-reads the field each time
            System.out.println("worker saw running=false, spun at least once? " + (spins > 0));
        });
        worker.start();
        Thread.sleep(50);
        running = false;
        worker.join();
        System.out.println("worker state: " + worker.getState());
    }
}
$ java StopFlag.java
worker saw running=false, spun at least once? true
worker state: TERMINATED

Without volatile, the JIT may hoist the read out of the loop and the worker can spin forever. volatile fixes visibility but not atomicity: volatile int count would still lose updates in Race.

ExecutorService and Future

An executor owns a pool of threads. submit takes a Callable and returns a Future for its result.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class Pool {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(3);
        List<Future<Long>> futures = new ArrayList<>();
        for (int n = 1; n <= 5; n++) {
            final int limit = n * 1_000_000;
            futures.add(pool.submit(() -> {
                long sum = 0;
                for (int i = 1; i <= limit; i++) sum += i;
                return sum;
            }));
        }
        for (int i = 0; i < futures.size(); i++) {
            System.out.println("task " + (i + 1) + " -> " + futures.get(i).get());
        }
        pool.shutdown();
        System.out.println("terminated: " + pool.awaitTermination(5, TimeUnit.SECONDS));
    }
}
$ java Pool.java
task 1 -> 500000500000
task 2 -> 2000001000000
task 3 -> 4500001500000
task 4 -> 8000002000000
task 5 -> 12500002500000
terminated: true

Five tasks share three threads; the rest wait in the pool’s queue. Reading the futures in submission order keeps the output stable no matter which task finished first. get() blocks until that result is ready and rethrows any task exception wrapped in ExecutionException.

CompletableFuture pipelines

CompletableFuture chains asynchronous steps without blocking between them.

import java.util.concurrent.CompletableFuture;

public class Pipeline {
    static int fetchPrice(String item) { return item.length() * 100; }

    public static void main(String[] args) {
        CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> fetchPrice("book"));
        CompletableFuture<Double> rate = CompletableFuture.supplyAsync(() -> 1.2);

        CompletableFuture<String> total = price
            .thenCombine(rate, (p, r) -> p * r)            // when both are done
            .thenApply(t -> String.format("total %.2f", t));

        CompletableFuture<Integer> failing = CompletableFuture
            .supplyAsync(() -> 10 / 0)
            .exceptionally(ex -> -1);                      // recover with a default

        System.out.println(total.join());
        System.out.println("failing -> " + failing.join());
    }
}
$ java Pipeline.java
total 480.00
failing -> -1

supplyAsync runs on the common ForkJoinPool. thenCombine waits for two independent futures, thenApply transforms a result, and exceptionally turns a failure into a value. join() is the one blocking call, at the end.

Thread-safe collections

ConcurrentHashMap.merge updates a key atomically, so threads can count into one shared map.

import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.*;

public class WordCount {
    public static void main(String[] args) throws InterruptedException {
        String[] docs = {"a b a", "b c", "a c c", "b b"};
        Map<String, Integer> counts = new ConcurrentHashMap<>();
        ExecutorService pool = Executors.newFixedThreadPool(4);
        for (String doc : docs) {
            pool.submit(() -> {
                for (String w : doc.split(" ")) counts.merge(w, 1, Integer::sum);
            });
        }
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
        System.out.println(new TreeMap<>(counts));
    }
}
$ java WordCount.java
{a=3, b=4, c=3}

A plain HashMap here could lose counts or corrupt its internal table. Copying into a TreeMap gives a sorted, stable print.

Virtual threads (Java 21)

Virtual threads are cheap enough to create one per task, even ten thousand blocking tasks.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class Virtual {
    public static void main(String[] args) throws Exception {
        long start = System.nanoTime();
        List<Future<Integer>> results = new ArrayList<>();
        try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                final int n = i;
                results.add(exec.submit(() -> {
                    Thread.sleep(100);             // blocking call: frees the carrier
                    return n % 2;
                }));
            }
        }                                          // close() waits for all tasks
        int odd = 0;
        for (Future<Integer> f : results) odd += f.get();
        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.println("odd tasks: " + odd);
        System.out.println("finished in under 10 s? " + (ms < 10_000));
    }
}
$ java Virtual.java
odd tasks: 5000
finished in under 10 s? true

Ten thousand tasks that each sleep 100 ms finish in seconds rather than 1,000, because a sleeping virtual thread is unmounted from its carrier platform thread. On a fixed pool of a few platform threads the same work would take minutes.

Practice

  1. Make Race correct by changing the counter to a java.util.concurrent.atomic.LongAdder, which scales better than AtomicInteger under heavy contention.
Solution
import java.util.concurrent.atomic.LongAdder;

public class Adder {
    static final LongAdder count = new LongAdder();

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1_000_000; j++) count.increment();
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        System.out.println(count.sum());
    }
}
$ java Adder.java
4000000

LongAdder keeps per-thread cells and sums them on demand, so threads rarely contend on one memory location.

  1. Use invokeAll to run three Callables that return "A", "B" and "C" on a two-thread pool, and print the results joined in order.
Solution
import java.util.List;
import java.util.concurrent.*;

public class InvokeAll {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        List<Callable<String>> tasks = List.of(() -> "A", () -> "B", () -> "C");
        StringBuilder sb = new StringBuilder();
        for (Future<String> f : pool.invokeAll(tasks)) sb.append(f.get());
        pool.shutdown();
        System.out.println(sb);
    }
}
$ java InvokeAll.java
ABC

invokeAll blocks until every task is done and returns the futures in the same order as the input list.

  1. Start a thread that sleeps for 10 seconds, interrupt it from main, and have it print that it was interrupted. Print its state after join.
Solution
public class Interrupt {
    public static void main(String[] args) throws InterruptedException {
        Thread sleeper = new Thread(() -> {
            try {
                Thread.sleep(10_000);
                System.out.println("woke normally");
            } catch (InterruptedException e) {
                System.out.println("interrupted while sleeping");
            }
        });
        sleeper.start();
        while (sleeper.getState() != Thread.State.TIMED_WAITING) Thread.onSpinWait();
        sleeper.interrupt();
        sleeper.join();
        System.out.println(sleeper.getState());
    }
}
$ java Interrupt.java
interrupted while sleeping
TERMINATED

Interruption is cooperative: blocking methods such as sleep respond by throwing InterruptedException, and the thread decides how to wind down.

  1. Chain a CompletableFuture that parses "42" to an int, doubles it, and prints the result with thenAccept, then make the input "x" and recover with handle.
Solution
import java.util.concurrent.CompletableFuture;

public class Chain {
    static CompletableFuture<Void> run(String input) {
        return CompletableFuture.supplyAsync(() -> Integer.parseInt(input))
            .thenApply(n -> n * 2)
            .handle((n, ex) -> ex == null ? "result " + n : "failed: " + ex.getCause().getClass().getSimpleName())
            .thenAccept(System.out::println);
    }

    public static void main(String[] args) {
        run("42").join();
        run("x").join();
    }
}
$ java Chain.java
result 84
failed: NumberFormatException

handle sees either the value or the exception (wrapped in CompletionException), so it can recover in both cases with one function.

Frequently Asked Questions

Should I implement Runnable or extend Thread?
Implement Runnable (or Callable) almost always. Extending Thread uses up your one superclass and ties the task to a specific way of running it. A Runnable can be handed to a plain Thread, an ExecutorService or a virtual thread without changing the task's code.
What is a race condition?
A race condition is a bug whose result depends on how threads happen to interleave. count++ is a read, an add and a write, so two threads can both read 100 and both write 101, losing an update. Fix it with synchronized, a Lock, or an atomic class such as AtomicInteger.
What is the difference between synchronized and volatile?
synchronized gives mutual exclusion, so only one thread runs the block at a time, and it also makes writes visible to the next thread that takes the lock. volatile only guarantees visibility of a single variable's latest value. It does not make compound actions like count++ atomic.
Why use an ExecutorService instead of creating threads directly?
An ExecutorService reuses a fixed set of threads, queues tasks when they are all busy, returns Futures for results and exceptions, and shuts down cleanly. Creating a platform thread per task is expensive and unbounded. For many blocking tasks on Java 21, Executors.newVirtualThreadPerTaskExecutor() gives cheap per-task threads.
What are virtual threads?
Virtual threads, final in Java 21, are lightweight threads managed by the JVM rather than the operating system. Millions can exist at once, and a virtual thread that blocks on I/O or sleep frees its carrier thread for others. They suit request-per-thread servers; they do not make CPU-bound work faster.