Executors and Thread Pools

advanced30 min

The problem with new Thread()

Creating a thread is expensive — it allocates a stack and asks the operating system for a scheduling resource. Doing it per task wastes most of the time on setup:

for (int i = 0; i < 1000; i++) {
    new Thread(() -> handle(i)).start();     // 1000 threads — bad
}

Beyond the cost, nothing limits how many exist at once. A thousand threads competing for a handful of processor cores mostly switch between each other rather than doing work, and memory use climbs sharply.

An executor separates what to run from how it is run. You submit tasks; a small pool of reusable threads works through them.

import java.util.concurrent.*;

ExecutorService pool = Executors.newFixedThreadPool(4);

for (int i = 0; i < 1000; i++) {
    final int id = i;
    pool.submit(() -> handle(id));
}

pool.shutdown();

Four threads handle all thousand tasks, reusing themselves as each finishes.

Choosing a pool

Factory methodBehaviourUse when
newFixedThreadPool(n)Exactly n threads; extra tasks queueThe usual default
newSingleThreadExecutor()One thread, tasks run in orderTasks must not overlap
newCachedThreadPool()Grows without bound, reuses idle threadsMany short-lived tasks; risky under load
newScheduledThreadPool(n)Runs tasks after a delay or repeatedlyPeriodic work
newVirtualThreadPerTaskExecutor()A lightweight virtual thread per taskMany tasks that mostly wait (Java 21+)

For work that keeps the processor busy, a fixed pool sized near the core count is a sensible starting point:

int cores = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(cores);

For work that mostly waits — network calls, file reads — more threads than cores is fine, since most are idle.

Be careful with cached pools

newCachedThreadPool() creates a new thread whenever no idle one is available, with no upper limit. Submit tasks faster than they complete and it will happily create thousands, which is the problem you were trying to avoid.

Prefer a fixed pool unless you know the task rate is bounded.

Getting results back

Runnable returns nothing. Callable<T> returns a value, and submit hands you a Future<T> to collect it:

ExecutorService pool = Executors.newFixedThreadPool(2);

Future<Integer> future = pool.submit(() -> {
    Thread.sleep(500);
    return 42;
});

doSomethingElse();                    // runs while the task computes

int result = future.get();            // blocks until the value is ready

get() waits for completion. Two exceptions to handle:

try {
    int result = future.get();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} catch (ExecutionException e) {
    Throwable cause = e.getCause();   // the exception the task actually threw
}

ExecutionException wraps whatever the task threw. Calling getCause() is how you see the real problem — a detail worth knowing, because the wrapper alone tells you almost nothing.

Use the timeout form to avoid waiting forever:

int result = future.get(2, TimeUnit.SECONDS);   // throws TimeoutException

Exceptions in submitted tasks are swallowed

A task submitted with submit() that throws does not print a stack trace. The exception is stored in the Future, and if you never call get(), you never learn about it.

This makes silently-failing background work a common and confusing bug. Either call get(), or wrap the task body in a try/catch that logs.

execute() behaves differently — an exception there does surface on the default handler.

Running many tasks

invokeAll submits a batch and waits for all of them:

List<Callable<Integer>> tasks = List.of(
    () -> compute(1),
    () -> compute(2),
    () -> compute(3)
);

List<Future<Integer>> results = pool.invokeAll(tasks);   // blocks until all done

for (Future<Integer> f : results) {
    System.out.println(f.get());
}

invokeAny returns as soon as one succeeds and cancels the rest — useful when several approaches could answer the same question.

Scheduled work

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

// once, after a delay
scheduler.schedule(() -> System.out.println("later"), 5, TimeUnit.SECONDS);

// repeatedly, every 100 ms
scheduler.scheduleAtFixedRate(this::pollSensor, 0, 100, TimeUnit.MILLISECONDS);

scheduleAtFixedRate targets a fixed start-to-start interval; scheduleWithFixedDelay waits a fixed gap after each run finishes. If a task can overrun its period, the second is usually what you want.

Note that if a repeating task throws, the repetition stops silently. Wrap the body in a try/catch if it must keep running.

Shutting down

An executor’s threads are non-daemon by default, so the program will not exit while a pool is alive.

pool.shutdown();                       // no new tasks; finish what is queued

try {
    if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
        pool.shutdownNow();            // interrupt what is still running
    }
} catch (InterruptedException e) {
    pool.shutdownNow();
    Thread.currentThread().interrupt();
}
MethodEffect
shutdown()Rejects new tasks, completes queued ones
shutdownNow()Also interrupts running tasks, returns those never started
awaitTermination(t, unit)Waits up to a timeout for completion
isShutdown() / isTerminated()Whether shutdown began / finished

shutdownNow only interrupts — a task ignoring interruption keeps going. Long-running loops should check Thread.currentThread().isInterrupted() and exit when set.

CompletableFuture

For chaining work without blocking on get():

CompletableFuture
    .supplyAsync(() -> fetchReading())
    .thenApply(reading -> reading * 2)
    .thenAccept(result -> System.out.println(result))
    .exceptionally(error -> { error.printStackTrace(); return null; });

Each stage runs after the previous one completes, without a thread sitting idle waiting. Worth knowing exists; a fixed pool with Future covers most needs.

Common mistakes

  • Never calling shutdown(), so the program never exits.
  • Ignoring the Future, hiding exceptions entirely.
  • get() immediately after submit(), which is just a slower sequential call.
  • Not unwrapping ExecutionException, losing the real cause.
  • An unbounded cached pool under sustained load.
  • A repeating scheduled task that throws, stopping silently.
  • Assuming shutdownNow() stops everything. It interrupts; tasks must cooperate.

Practice

  1. Submit 20 tasks that each sleep briefly to a fixed pool of 4, printing which thread ran each. Note that only four names appear.
  2. Submit a Callable returning a number and collect it with get().
  3. Submit a task that throws, then confirm nothing is printed until you call get().
  4. Use invokeAll to run three computations and sum the results.
  5. Schedule a task to run every 200 ms, let it run five times, then shut down cleanly.
Hints
  1. Thread.currentThread().getName().
  2. Future<Integer> and handle both exceptions.
  3. This is the swallowed-exception trap — worth seeing once.
  4. invokeAll returns futures in the same order as the input.
  5. scheduleAtFixedRate, then shutdown() and awaitTermination.

Next

Related