Why concurrency matters in Java

Most Java programs that operations teams meet are servers: a customer portal, a billing service, a message broker. They handle many requests at the same time, and each request spends most of its time waiting for a database or another service. Java has supported this from the start with threads: independent paths of execution inside one process that share its memory. The general ideas, such as concurrency versus parallelism, race conditions and deadlock, are explained in Programming Concepts. This page shows the Java tools for them.

All examples on this page are compact source files (see The JDK, javac, java and Your First Program), so the classes of java.util.concurrent are available without imports. In a normal class you would write import java.util.concurrent.*;. Every example prints its results in a fixed order after the threads have finished: the order in which threads run is never guaranteed.

Threads: start and join

A thread runs a piece of code given as a Runnable, an interface with one method run(), usually written as a lambda. The program below adds up two halves of a year's meter readings in two threads and combines the results:

void main() throws InterruptedException {
    int[] readings = {412, 388, 405, 397, 420, 431};
    long[] totals = new long[2];

    Thread first = new Thread(() -> totals[0] = sum(readings, 0, 3));
    Thread second = Thread.ofPlatform().name("second-half").start(() -> totals[1] = sum(readings, 3, 6));
    IO.println("first before start: " + first.getState());
    first.start();

    first.join();
    second.join();
    IO.println("first after join: " + first.getState());
    IO.println("First half:  " + totals[0] + " kWh");
    IO.println("Second half: " + totals[1] + " kWh");
    IO.println("Total:       " + (totals[0] + totals[1]) + " kWh");
}

long sum(int[] values, int from, int to) {
    long total = 0;
    for (int i = from; i < to; i++) total += values[i];
    return total;
}
first before start: NEW
first after join: TERMINATED
First half:  1205 kWh
Second half: 1248 kWh
Total:       2453 kWh

new Thread(runnable) creates a thread that has not started yet (state NEW); the builder Thread.ofPlatform(), added in Java 21, does the same and also lets you set a name. start() asks the JVM to run the code in a new thread and returns at once. join() waits until the thread has finished. Joining is also what makes the result safe to read: everything a thread wrote before it ended is visible to the thread that joined it. Without join(), main could print the totals before they were computed.

The two threads write to different array elements, so they never get in each other's way. Shared data that several threads change is the hard part; see Shared state and race conditions below. join() and many other waiting methods throw the checked InterruptedException, which is why main declares it (see Exceptions and Error Handling).

Platform threads

A platform thread is a Java thread that is a thin wrapper around a thread of the operating system: it holds that OS thread, with its own stack, for its whole life. Before Java 21 this was the only kind of thread. Platform threads are fully parallel, but each one costs memory and the operating system schedules them, so a server can afford hundreds or a few thousand of them, not millions. They appear as threads of the java process in tools such as top -H (see Running Java in Production).

A thread passes through a few states, which you will see again in thread dumps:

State Meaning
NEW created, start() not called yet
RUNNABLE running, or ready to run (also while it waits for network input inside the operating system)
BLOCKED waiting to enter a synchronized block
WAITING waiting without a time limit, for example in join()
TIMED_WAITING waiting with a time limit, for example in Thread.sleep()
TERMINATED finished

start() versus run()

Calling run() yourself is a plain method call: the code runs in the current thread, and no new thread is created. The name of the current thread shows the difference:

void main() throws InterruptedException {
    Thread poller = Thread.ofPlatform().name("meter-poller")
            .unstarted(() -> IO.println("running in " + Thread.currentThread().getName()));
    poller.run();    // a normal method call: runs in the current thread
    poller.start();  // starts a new thread, which calls run()
    poller.join();
}
running in main
running in meter-poller

Executors and futures

Creating a thread per task by hand quickly becomes messy: who waits for them, where do results and exceptions go, and how many run at once? The package java.util.concurrent separates what to do from how it runs. An ExecutorService is a Java interface for an object that accepts tasks and runs them on threads it manages, often a fixed-size pool of reusable threads; for each task it returns a Future through which you collect the result or the exception later.

void main() throws Exception {
    List<String> meters = List.of("E-001", "E-002", "G-001", "E-003");

    try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
        List<Future<Integer>> results = new ArrayList<>();
        for (String meter : meters) {
            results.add(pool.submit(() -> readMeter(meter)));
        }
        for (int i = 0; i < meters.size(); i++) {
            IO.println(meters.get(i) + ": " + results.get(i).get() + " kWh");
        }
    } // close() waits until every submitted task has finished
}

int readMeter(String meterId) throws InterruptedException {
    Thread.sleep(50); // stands in for a slow network call
    return switch (meterId) {
        case "E-001" -> 412;
        case "E-002" -> 388;
        case "G-001" -> 97;
        default -> 405;
    };
}
E-001: 412 kWh
E-002: 388 kWh
G-001: 97 kWh
E-003: 405 kWh

The pool has two threads, so two meters are read at the same time and the other two tasks wait in the pool's queue. submit() accepts a Callable, a task that returns a value and may throw a checked exception; the lambda () -> readMeter(meter) is one. Future.get() blocks until that task's result is available. Because the program walks through the futures in the order of the list, the output is always in that order, whichever task finished first.

Since Java 19 an ExecutorService is AutoCloseable, so try-with-resources shuts it down and waits for the remaining tasks. In older code you see the same done by hand with shutdown() and awaitTermination(…). A pool that is never shut down keeps its threads alive, which can stop a program from exiting.

The factory methods of Executors you meet most:

Factory method What you get
newFixedThreadPool(n) n platform threads; extra tasks wait in an unbounded queue
newSingleThreadExecutor() one thread: tasks run one after another, in order
newCachedThreadPool() creates threads as needed and reuses idle ones; no upper limit
newScheduledThreadPool(n) runs tasks after a delay or at a fixed rate
newVirtualThreadPerTaskExecutor() a new virtual thread for every task (see below)

Running a batch with invokeAll

invokeAll submits a whole list of tasks and returns when all of them are done, with the futures in the same order as the tasks:

void main() throws Exception {
    List<Callable<String>> checks = List.of(
            () -> "billing-db: up",
            () -> "meter-gateway: up",
            () -> "portal: degraded");
    try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
        for (Future<String> f : pool.invokeAll(checks)) {
            IO.println(f.get());
        }
    }
}
billing-db: up
meter-gateway: up
portal: degraded

Exceptions in tasks

An exception inside a task does not appear where it happened; the pool catches it and stores it in the future. get() then throws an ExecutionException whose cause is the original exception:

void main() throws InterruptedException {
    try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
        Future<Integer> ok = pool.submit(() -> Integer.parseInt("412"));
        Future<Integer> broken = pool.submit(() -> Integer.parseInt("41x"));

        for (Future<Integer> f : List.of(ok, broken)) {
            try {
                IO.println("reading: " + f.get());
            } catch (ExecutionException e) {
                IO.println("task failed: " + e.getCause());
            }
        }
    }
}
reading: 412
task failed: java.lang.NumberFormatException: For input string: "41x"

If nobody handles it, the stack trace starts with the wrapper, and the real problem is in the message after the colon (and in the Caused by: section further down):

void main() throws Exception {
    try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
        Future<Integer> broken = pool.submit(() -> Integer.parseInt("41x"));
        IO.println("reading: " + broken.get());
    }
}
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.NumberFormatException: For input string: "41x"

Chaining work with CompletableFuture

A plain Future can only be waited for. A CompletableFuture is a Java future that you can also chain: you describe what should happen with the result when it arrives (transform it, combine it with another result, recover from an error), and every step runs as soon as its input is ready. It plays the role of a JavaScript promise or a C# Task.

void main() {
    try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
        CompletableFuture<BigDecimal> usage =
                CompletableFuture.supplyAsync(() -> new BigDecimal("412"), pool);     // kWh
        CompletableFuture<BigDecimal> tariff =
                CompletableFuture.supplyAsync(() -> new BigDecimal("0.27"), pool);    // euro per kWh

        CompletableFuture<String> bill = usage
                .thenCombine(tariff, BigDecimal::multiply)          // when both are done
                .thenApply(amount -> amount.setScale(2, RoundingMode.HALF_UP))
                .thenApply(amount -> "C-10042 owes EUR " + amount);

        IO.println(bill.join());
    }
}
C-10042 owes EUR 111.24

supplyAsync starts a task on the given executor; without one it uses a shared pool, the ForkJoinPool.commonPool(), which is meant for short computations, not for slow calls. thenApply transforms a result, thenCombine joins two independent results, and join() waits for the end of the chain, like get() but without checked exceptions. allOf(…) waits for a whole group and anyOf(…) for the first one.

Errors travel down the chain and skip the steps that need a value. exceptionally turns an error into a fallback value. The error arrives wrapped in a CompletionException, which is also what join() throws when nobody recovered:

void main() {
    CompletableFuture<Integer> reading = CompletableFuture
            .supplyAsync(() -> Integer.parseInt("41x"))
            .exceptionally(error -> {
                IO.println("fallback because of " + error.getCause().getClass().getSimpleName());
                return 0;
            });
    IO.println("reading: " + reading.join());

    CompletableFuture<Integer> failed = CompletableFuture.supplyAsync(() -> Integer.parseInt("41x"));
    failed.join();
}
fallback because of NumberFormatException
reading: 0
Exception in thread "main" java.util.concurrent.CompletionException: java.lang.NumberFormatException: For input string: "41x"

Long chains of thenApply, thenCompose and exceptionally are hard to read and to debug: a stack trace shows pool threads, not the code that started the chain. With virtual threads, the next section, plain sequential code that simply waits is often the clearer choice.

Virtual threads

A virtual thread is a lightweight Java thread, final since Java 21, that the JVM schedules itself: it runs on an ordinary platform thread, its carrier, only while it is doing work, and when it waits for input or output the JVM parks it and the carrier runs another virtual thread. A waiting virtual thread costs a little heap memory and no operating system thread, so a program can have hundreds of thousands of them.

Left: three platform threads, each permanently tied to its own operating system thread. Right: eight virtual threads; two are mounted on the two carrier threads, which are platform threads with their own operating system threads, and six are waiting without using any operating system thread. Platform threads thread thread thread OS thread OS thread OS thread one OS thread each, for life Virtual threads v v v v v v v v carrier carrier OS thread OS thread waiting virtual threads use no OS thread
Platform threads keep their operating system thread even while they wait. Virtual threads borrow a carrier thread only while they compute; the highlighted ones are running, the others are waiting.

The easiest way to use them is an executor that starts a new virtual thread for every task. Here 10,000 simulated meter polls each wait 200 milliseconds for an answer:

void main() throws Exception {
    List<Future<Integer>> polls = new ArrayList<>();
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int meter = 1; meter <= 10_000; meter++) {
            int id = meter;
            polls.add(executor.submit(() -> pollMeter(id)));
        }
    } // waits for all 10,000 virtual threads

    long total = 0;
    for (Future<Integer> poll : polls) total += poll.get();
    IO.println("Meters polled: " + polls.size());
    IO.println("Total usage:   " + total + " kWh");
}

int pollMeter(int id) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(200));   // waiting for the meter's reply
    return 300 + id % 50;
}
Meters polled: 10000
Total usage:   3245000 kWh

Because all 10,000 waits overlap, the program finishes in roughly the time of one poll plus overhead (on the small test machine for this site, the whole run took under two seconds). With a fixed pool of 100 platform threads the same work would take at least 10,000 / 100 × 0.2 s = 20 seconds. The code inside the task is ordinary blocking code: Thread.sleep here, in practice a JDBC query or an HttpClient.send. The JDK's blocking operations recognize that they run in a virtual thread and park it instead of blocking the carrier.

You can also start a single virtual thread with the builder Thread.ofVirtual(). Virtual threads are always daemon threads: they do not keep the JVM alive, so wait for them with join() or an executor.

void main() throws InterruptedException {
    Thread virtual = Thread.ofVirtual().name("reader-1")
            .start(() -> IO.println("reader-1 virtual: " + Thread.currentThread().isVirtual()));
    virtual.join();

    Thread platform = Thread.ofPlatform().name("reader-2")
            .start(() -> IO.println("reader-2 virtual: " + Thread.currentThread().isVirtual()));
    platform.join();

    IO.println("main daemon? " + Thread.currentThread().isDaemon() + ", virtual daemon? " + virtual.isDaemon());
}
reader-1 virtual: true
reader-2 virtual: false
main daemon? false, virtual daemon? true

When virtual threads help

  • They help with waiting, not with computing. A virtual thread is not faster than a platform thread. It lets many tasks wait at the same time, which is what servers that call databases and other services do. Ten CPU-heavy calculations still run only as fast as the number of cores allows.
  • Do not pool them. A pool exists to reuse expensive threads; virtual threads are cheap, so create one per task and let it end.
  • Limit the resource, not the threads. If a database accepts only 20 connections, 10,000 virtual threads will queue for them. Use a connection pool or a Semaphore to cap how many tasks use a scarce resource at once (example below).
  • Pinning. Older material warns that a virtual thread waiting inside a synchronized block stays pinned to its carrier. That was fixed in JDK 24 (JEP 491); on Java 25 synchronized is fine in virtual threads. Waiting inside native code can still pin.
void main() throws Exception {
    Semaphore gatewaySlots = new Semaphore(3);   // the meter gateway accepts 3 calls at a time
    AtomicInteger busy = new AtomicInteger();
    AtomicInteger maxBusy = new AtomicInteger();

    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 100; i++) {
            executor.submit(() -> {
                gatewaySlots.acquire();
                try {
                    maxBusy.accumulateAndGet(busy.incrementAndGet(), Math::max);
                    Thread.sleep(10);   // the call to the gateway
                } finally {
                    busy.decrementAndGet();
                    gatewaySlots.release();
                }
                return null;
            });
        }
    }
    IO.println("Calls made: 100");
    IO.println("More than 3 at the same time: " + (maxBusy.get() > 3));
}
Calls made: 100
More than 3 at the same time: false

Shared state and race conditions

Problems start when several threads change the same data. The statement processed++ looks like one step, but it is three: read the value, add one, write it back. When two threads read the same old value, one of the two increments is lost. This program lets four threads count 100,000 tickets each:

int processed = 0;   // shared by all threads

void main() throws InterruptedException {
    List<Thread> workers = new ArrayList<>();
    for (int w = 0; w < 4; w++) {
        workers.add(Thread.ofPlatform().start(() -> {
            for (int i = 0; i < 100_000; i++) processed++;   // read, add 1, write back
        }));
    }
    for (Thread t : workers) t.join();
    IO.println("Expected 400000, got " + processed);
}
Expected 400000, got 365779
Expected 400000, got 400000
Expected 400000, got 314164

This is a race condition, and the second run shows why it is so dangerous: sometimes the result is right. A test can pass a hundred times and the bug still appears in production under load. (This example is not run by the site's checker, because its output differs on every run.) There is a second, subtler problem: without synchronization, Java does not even promise that one thread ever sees a value another thread wrote. The Java Memory Model only guarantees visibility through synchronization actions such as locks, volatile fields, starting and joining threads, and the concurrent classes below.

synchronized

synchronized is the Java keyword that makes a method or block a critical section: a thread must hold the object's built-in lock (its monitor) to run it, so only one thread at a time runs synchronized code on the same object, and everything written inside is visible to the next thread that takes the lock.

void main() throws InterruptedException {
    TicketCounter counter = new TicketCounter();
    List<Thread> workers = new ArrayList<>();
    for (int w = 0; w < 4; w++) {
        workers.add(Thread.ofPlatform().start(() -> {
            for (int i = 0; i < 100_000; i++) counter.increment();
        }));
    }
    for (Thread t : workers) t.join();
    IO.println("Processed: " + counter.get());
}

class TicketCounter {
    private int processed = 0;

    synchronized void increment() { processed++; }   // one thread at a time per object

    synchronized int get() { return processed; }
}
Processed: 400000

A synchronized method locks this; a block synchronized (lock) { … } locks any object you choose. Note that get() is synchronized too: reading needs the lock for visibility, not only writing. Threads that wait for the lock are in the state BLOCKED. Two threads that each hold one lock and wait for the other's are in a deadlock; a thread dump reports it (see Running Java in Production). The usual prevention is to always take locks in the same order. The class ReentrantLock in java.util.concurrent.locks does the same as synchronized with extra options, such as waiting for a lock with a time limit.

Atomic variables

For a single counter or flag, a lock is more than you need. The classes in java.util.concurrent.atomic, such as AtomicInteger, AtomicLong and AtomicReference, do read-modify-write operations as one indivisible step using special processor instructions. The next example also uses a concurrent map, explained in the next section:

void main() throws InterruptedException {
    AtomicInteger processed = new AtomicInteger();
    ConcurrentHashMap<String, Integer> perTeam = new ConcurrentHashMap<>();
    List<String> teams = List.of("NOC", "Billing", "Service desk", "NOC");

    List<Thread> workers = new ArrayList<>();
    for (String team : teams) {
        workers.add(Thread.ofVirtual().start(() -> {
            for (int i = 0; i < 25_000; i++) {
                processed.incrementAndGet();
                perTeam.merge(team, 1, Integer::sum);   // atomic read-modify-write per key
            }
        }));
    }
    for (Thread t : workers) t.join();

    IO.println("Processed: " + processed.get());
    IO.println("Per team:  " + new TreeMap<>(perTeam));
}
Processed: 100000
Per team:  {Billing=25000, NOC=50000, Service desk=25000}

The map is copied into a TreeMap only to print it in a fixed, sorted order; a hash map's own order is not defined (see Arrays and Collections). Under heavy contention, LongAdder counts faster than AtomicLong.

Concurrent collections

The ordinary collections, ArrayList and HashMap, must not be changed by several threads at once: updates get lost, and in the worst case the internal structure breaks. java.util.concurrent has thread-safe versions.

A ConcurrentHashMap is a thread-safe Java hash map that many threads can read and update at the same time without one global lock; its compound methods, such as merge, compute and computeIfAbsent, update one key atomically. Use them instead of "check, then act" code: if (!map.containsKey(k)) map.put(k, v) is two separate steps, and another thread can slip in between them, even on a concurrent map.

Class Use it for
ConcurrentHashMap shared lookup tables, counters per key, caches
CopyOnWriteArrayList lists that are read very often and changed rarely, such as a list of listeners
ArrayBlockingQueue, LinkedBlockingQueue handing work from producer threads to consumer threads
ConcurrentLinkedQueue a non-blocking queue shared by many threads

A BlockingQueue connects threads that produce work with threads that consume it. put waits while the queue is full and take waits while it is empty, so a fast producer cannot run far ahead of a slow consumer:

void main() throws InterruptedException {
    BlockingQueue<String> queue = new ArrayBlockingQueue<>(2);   // holds at most 2 items

    Thread producer = Thread.ofPlatform().start(() -> {
        try {
            for (String reading : List.of("E-001;412", "E-002;388", "G-001;97", "STOP")) {
                queue.put(reading);   // waits while the queue is full
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });

    String item;
    while (!(item = queue.take()).equals("STOP")) {   // waits while the queue is empty
        IO.println("stored " + item);
    }
    producer.join();
    IO.println("done");
}
stored E-001;412
stored E-002;388
stored G-001;97
done

The special value "STOP" tells the consumer that no more work will come; such a marker is often called a poison pill. The legacy classes Vector and Hashtable are synchronized too, but they lock the whole collection for every call and are not used in new code.

Stopping work: interrupts and timeouts

Java cannot kill a thread from the outside. Thread.stop() was unsafe; on Java 25 it always throws UnsupportedOperationException, and JDK 26 removed it. Instead, you interrupt a thread: a polite request that sets a flag and wakes the thread from sleep, wait, join or a blocking queue with an InterruptedException. The thread decides how to stop cleanly:

void main() throws Exception {
    CountDownLatch started = new CountDownLatch(1);
    Thread export = Thread.ofPlatform().name("report-export").start(() -> {
        started.countDown();
        try {
            Thread.sleep(Duration.ofMinutes(10));      // a very slow export
            IO.println("export finished");
        } catch (InterruptedException e) {
            IO.println("export interrupted, cleaning up");
        }
    });

    started.await();        // wait until the export is running
    export.interrupt();     // ask it to stop
    export.join();
    IO.println("state: " + export.getState());
}
export interrupted, cleaning up
state: TERMINATED

For a task in an executor, the same works through its future: get with a time limit throws a TimeoutException when the result is late, and cancel(true) interrupts the thread that runs the task. Anything that talks to another system needs such a time limit, otherwise one hanging service can tie up all your threads.

void main() throws Exception {
    try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
        Future<String> report = pool.submit(() -> {
            Thread.sleep(Duration.ofMinutes(10));
            return "report ready";
        });
        try {
            IO.println(report.get(100, TimeUnit.MILLISECONDS));
        } catch (TimeoutException e) {
            IO.println("no answer within 100 ms, canceling");
            report.cancel(true);   // true = interrupt the thread running it
        }
        IO.println("canceled: " + report.isCancelled());
    }
}
no answer within 100 ms, canceling
canceled: true

Concurrency in practice

In real applications you mostly use concurrency through a framework: a web server handles each request on its own thread (in recent versions of Spring Boot and other frameworks this can be a virtual thread, switched on in configuration), and a connection pool limits database access. What you write yourself is usually small: a handful of parallel calls with an executor, a shared cache in a ConcurrentHashMap, a scheduled job. A short checklist:

  • Prefer executors and futures over raw threads, and virtual threads for tasks that wait.
  • Keep shared, changing state small, and protect what remains with one clear mechanism: a lock, an atomic, or a concurrent collection.
  • Give every pool and thread a meaningful name; it is what you see in logs and thread dumps.
  • Put a time limit on every call to another system, and handle InterruptedException properly.
  • Make sure exceptions in tasks are collected or logged, never lost in an unread future.

When a concurrent program misbehaves in production (it hangs, slows down or uses all CPUs), the first tool is a thread dump: see Running Java in Production.