Executors and thread pools

Java · Advanced · 12 min

Executors separate what to run from how to run it, and are the standard way to do concurrent work in Java.

Creating raw threads for every task is expensive and unmanageable. An ExecutorService accepts tasks and runs them on threads it manages:

  • submit(Runnable) or submit(Callable<T>) returns a Future<T>; future.get() blocks until the result is ready and rethrows a task failure wrapped in ExecutionException.
  • invokeAll(tasks) runs a collection of callables and waits for all of them.
  • Executors.newFixedThreadPool(n) suits CPU-bound work with n near Runtime.getRuntime().availableProcessors().
  • Executors.newScheduledThreadPool(n) runs tasks after a delay or periodically.
  • Executors.newVirtualThreadPerTaskExecutor() (Java 21) is the modern choice for I/O-bound tasks — see the virtual threads lesson.

An executor must be shut down, or its non-daemon threads keep the JVM alive. Since Java 19 ExecutorService is AutoCloseable: in a try-with-resources block, close() stops accepting tasks and waits for submitted ones to finish. In older code you'll see shutdown() followed by awaitTermination(...).

Future.get() has an overload with a timeout — use it whenever a stuck task shouldn't hang the caller. cancel(true) interrupts a running task; tasks cooperate by checking Thread.currentThread().isInterrupted() or by letting blocking calls throw InterruptedException.

Handle InterruptedException properly: either propagate it or restore the flag with Thread.currentThread().interrupt(). Swallowing it breaks cancellation.

Beware unbounded queues: a fixed pool fed faster than it drains will queue tasks until memory runs out. For production servers, use ThreadPoolExecutor with a bounded queue and a rejection policy, or limit concurrency with a Semaphore.

Example

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

public class Pools {
    static long countPrimes(int from, int to) {
        long n = 0;
        for (int i = Math.max(2, from); i < to; i++) {
            boolean prime = true;
            for (int d = 2; (long) d * d <= i && prime; d++) prime = i % d != 0;
            if (prime) n++;
        }
        return n;
    }
    public static void main(String[] args) throws Exception {
        try (ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())) {
            List<Callable<Long>> chunks = List.of(() -> countPrimes(0, 500_000), () -> countPrimes(500_000, 1_000_000));
            long total = 0;
            for (Future<Long> f : pool.invokeAll(chunks)) total += f.get();
            System.out.println(total);                       // 78498
            Future<String> slow = pool.submit(() -> { Thread.sleep(5_000); return "late"; });
            try { slow.get(100, TimeUnit.MILLISECONDS); }
            catch (TimeoutException e) { slow.cancel(true); System.out.println("gave up"); }
        } // close(): waits for remaining tasks, then shuts down
    }
}

Remember

  • Submit tasks to an ExecutorService instead of creating threads by hand
  • Always shut executors down — try-with-resources does it in Java 19+
  • Use timeouts on Future.get and never swallow InterruptedException

Watch out for

  • Forgetting to shut down an executor, so the application never exits.

Check yourself

  1. A task submitted with `submit(callable)` throws an exception. What does `future.get()` do?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.oracle.com/javase/tutorial/essential/concurrency/executors.html