Multithreading

August 11, 20265 min readUpdated 8/20/2026

Threads let a program do several things at once. They also introduce a class of bug that is intermittent, unreproducible and dependent on timing — which is why the most useful advice about concurrency is to use the highest-level tool that solves your problem.

Starting a thread

class Demo {
    void run() throws InterruptedException {
        Thread thread = new Thread(() -> System.out.println("on another thread"));
        thread.start();                      // start(), never run()
        thread.join();                       // wait for it to finish

        // Java 21: a virtual thread, and the same API
        Thread virtual = Thread.ofVirtual().start(() -> System.out.println("virtual"));
        virtual.join();
    }
}

start() creates a thread and runs the task on it. run() just calls the method on the current thread — a mistake that produces code which looks threaded, works perfectly, and is entirely sequential.

You will rarely do that

Managing threads by hand is the assembly language of concurrency. An ExecutorService handles creation, reuse and shutdown:

class Demo {
    void run() throws Exception {
        try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
            Future<Integer> future = pool.submit(() -> 21 * 2);
            System.out.println(future.get());        // 42, blocking until ready

            List<Callable<String>> tasks = List.of(
                    () -> "one", () -> "two", () -> "three");
            for (Future<String> f : pool.invokeAll(tasks)) {
                System.out.println(f.get());
            }
        }   // AutoCloseable since Java 19 — waits for tasks, then shuts down
    }
}

Sizing the pool: roughly the number of cores for CPU-bound work, and much larger for I/O-bound work — or, on Java 21, virtual threads, which remove the question entirely.

The actual problem: shared mutable state

class Counter {
    private int count = 0;

    void increment() {
        count++;            // NOT atomic: read, add, write — three steps
    }

    int get() { return count; }
}

class Demo {
    void run() throws Exception {
        Counter counter = new Counter();
        try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
            for (int i = 0; i < 10_000; i++) {
                pool.submit(counter::increment);
            }
        }
        System.out.println(counter.get());   // almost never 10000
    }
}

count++ looks like one operation and is three. Two threads can read the same value, both add one, and both write back — one increment vanishes. This is a race condition, and it is the defining hazard of shared state.

Four ways to fix it, best first

1. Do not share mutable state. The only fix that scales. Give each thread its own data and combine results at the end — which is what a stream collector does:

class Demo {
    void run() {
        long count = IntStream.range(0, 10_000).parallel().filter(n -> n % 2 == 0).count();
        System.out.println(count);           // 5000, always — nothing is shared
    }
}

2. Use an atomic or a concurrent collection. Correct, fast, and no locking to get wrong:

class Demo {
    void run() throws Exception {
        AtomicInteger counter = new AtomicInteger();
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
            for (int i = 0; i < 10_000; i++) {
                pool.submit(() -> {
                    counter.incrementAndGet();
                    map.merge("hits", 1, Integer::sum);
                });
            }
        }
        System.out.println(counter.get());   // 10000, reliably
        System.out.println(map.get("hits")); // 10000
    }
}

3. Synchronise. A lock lets one thread at a time into a block:

class SafeCounter {
    private int count = 0;
    private final Object lock = new Object();

    void increment() {
        synchronized (lock) {                // a private lock, not `this`
            count++;
        }
    }

    int get() {
        synchronized (lock) {                // reads need it too — see below
            return count;
        }
    }
}

4. volatile, for visibility only. Worth understanding because it is routinely misused:

class Worker {
    private volatile boolean running = true;   // guarantees other threads SEE the change

    void stop() { running = false; }

    void loop() {
        while (running) {
            // without volatile this loop may never see stop() and spin forever
        }
    }
}

volatile guarantees visibility, not atomicity. A volatile int still breaks under ++. Use it for a flag one thread writes and others read, and nothing else.

Visibility, and why reads need locking too

The counter-intuitive part. Without synchronisation, one thread's write may never become visible to another — the JVM and CPU are allowed to cache values in registers and reorder instructions. That is why get() above is synchronised even though reading an int is atomic: atomicity is not the issue, visibility is.

Deadlock

class Demo {
    private final Object a = new Object();
    private final Object b = new Object();

    void first() {
        synchronized (a) {
            synchronized (b) { }             // thread 1: a then b
        }
    }

    void second() {
        synchronized (b) {
            synchronized (a) { }             // thread 2: b then a — deadlock
        }
    }
}

Two threads each hold what the other wants, and neither ever proceeds. The fix is a consistent lock ordering everywhere, or holding one lock at a time. When it happens in production, jstack names the threads and the locks — see Debugging.

Interruption, done properly

One idiom worth getting right, because getting it wrong silently breaks cancellation. Catching InterruptedException clears the thread's interrupt flag, so code further up the stack can no longer tell that cancellation was requested:

class Demo {
    // Wrong: the interrupt is swallowed and the caller never learns of it
    void bad() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            // nothing
        }
    }

    // Right: restore the flag before moving on
    void good() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted", e);
        }
    }
}

Either rethrow the exception or restore the flag — never neither. This is the concurrency-specific form of the swallowed exception, and it is why a task sometimes refuses to stop when its executor is shut down.

The rules

  • Prefer immutability. An object that cannot change is automatically thread-safe.
  • Prefer the highest-level tool — a parallel stream, an executor, a concurrent collection — over threads and locks.
  • Keep synchronised blocks small, and never do I/O inside one.
  • Document thread-safety on any class meant to be shared. If it is not safe, say so.

Next

Regex is next — pattern matching on text, and how to keep it readable.