Choosing the right concurrency tool

Multithreading · lesson 38 of 38 · 4 min read

A short decision path from the task you have to the executor, lock or queue that fits it.

Open this lesson in the learning hub

Key points

  • CPU-bound work: a fixed pool sized near the core count, or a parallel stream. More threads than cores only adds switching.
  • Blocking I/O on Java 21: one virtual thread per task. There is no pool size to tune, and plain blocking code stays readable.
  • Several results to combine: CompletableFuture, or StructuredTaskScope when the subtasks must fail together.
  • Shared state: an atomic for one field, a concurrent collection for a map, a lock only when two fields must change together.
  • Handing work between stages: a bounded BlockingQueue. The bound is your back-pressure and your overload alarm.

Example

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    static final int TASKS = 200;

    public static void main(String[] args) throws Exception {
        System.out.println(TASKS + " tasks that each wait 10 ms (pure I/O shape)");
        System.out.println("fixed pool of 4    : " + run(Executors.newFixedThreadPool(4)) + " ms");
        System.out.println("fixed pool of 64   : " + run(Executors.newFixedThreadPool(64)) + " ms");
        System.out.println("virtual per task   : " + run(Executors.newVirtualThreadPerTaskExecutor()) + " ms");
        System.out.println();
        System.out.println("waiting work scales with the thread count, so use virtual threads");
        System.out.println("CPU work does not: there a fixed pool of " + Runtime.getRuntime().availableProcessors() + " is the ceiling");
    }

    static long run(ExecutorService pool) {
        AtomicInteger done = new AtomicInteger();
        long start = System.nanoTime();
        try (pool) {                                   // close() waits for every task
            for (int i = 0; i < TASKS; i++) {
                pool.submit(() -> { Thread.sleep(10); return done.incrementAndGet(); });
            }
        }
        return (System.nanoTime() - start) / 1_000_000;
    }
}

Pick the smallest tool that fits: atomic, then concurrent collection, then lock.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Multithreading course, and every lesson in it is listed on the Multithreading contents page.