⚡ Advanced Concurrency · Advanced

ExecutorService & thread pools in Java

Fixed, cached, scheduled pools; submit vs execute; shutdown.

🧩 The mysteryHiring a new worker for every single task, then firing them when it's done: that's new Thread() per task. A thread pool keeps a team on staff and hands them a to-do list.

Submitting vs running

An ExecutorService separates submitting a task from running it. A pool of reusable threads takes tasks from a queue. You skip the cost of creating a thread per task, and you cap how many tasks run at once.

ExecutorService pool =
    Executors.newFixedThreadPool(4);
pool.submit(() -> fetch("a"));
pool.submit(() -> fetch("b"));
pool.shutdown();

Pick a pool

newFixedThreadPool(n): n threads sharing an unbounded queue. newCachedThreadPool(): grows on demand and reuses idle threads. newScheduledThreadPool(n): runs tasks after a delay or periodically. newVirtualThreadPerTaskExecutor(): a fresh virtual thread for every task.

execute() vs submit()

✗ Silent failure
pool.submit(() -> {
    throw new NullPointerException();
});
// nobody calls get()

submit() returns a Future and stores the exception in it. It's rethrown (wrapped) only by get(). Never call get(), and the error never shows up in your logs.

✓ Visible failure
pool.execute(() -> {
    throw new NullPointerException();
});

execute() is fire-and-forget: no Future, so the exception reaches the thread's uncaught-exception handler and gets printed. Or keep the Future from submit() and call get().

Shutting down

shutdown() stops accepting new tasks, but queued and running tasks still complete. shutdownNow() also interrupts running tasks and returns the queued ones. Since Java 19, ExecutorService is AutoCloseable: try-with-resources waits for all tasks, then shuts the pool down.

var pool = Executors.newFixedThreadPool(4);
try (pool) {
    pool.submit(() -> fetch("a"));
    pool.submit(() -> fetch("b"));
} // waits for both, then shuts down
🔮 Predict it

Your turn

Four increments are submitted to a 2-thread pool inside try-with-resources. What prints?

var done = new AtomicInteger();
var pool = Executors.newFixedThreadPool(2);
try (pool) {
    for (int i = 0; i < 4; i++)
        pool.submit(done::incrementAndGet);
}
System.out.println(done.get());
  1. 4
  2. 2
  3. A value from 0 to 4
Show the answer

Always 4. Leaving the try block calls close(), which shuts the pool down and waits for every submitted task to finish. AtomicInteger loses no increments, and they're all visible to main.

⚠️ The trap

The JVM that never exits

A batch job submits its jobs, prints "queued", every job completes... and the JVM never exits. Fixed-pool workers are non-daemon threads that wait forever for more work. Call shutdown() when you're done submitting, or use try-with-resources. (Calling System.gc() or switching to execute() won't help.)

💼 In the real world

Pools everywhere

Web servers handle each request on a pooled thread (Tomcat's default maximum is 200). Schedulers run cron-like jobs on scheduled pools. Choose by workload: fixed pools for bounded CPU work, cached for bursts of short tasks, scheduled for timers, virtual threads for massive blocking I/O.

Key takeaways

  1. execute(Runnable) is fire-and-forget; submit() returns a Future
  2. Exceptions in submit() tasks are stored in the Future, not printed
  3. shutdown() rejects new tasks; shutdownNow() also interrupts workers
  4. try (var ex = ...) { } waits for tasks, then shuts down
🤯 Did you know?

close() on an ExecutorService is essentially shutdown() plus waiting for termination, and if the waiting thread is interrupted, it switches to shutdownNow().

Practice questions

What does this print?

var done = new AtomicInteger();
try (var pool = Executors.newFixedThreadPool(3)) {
    for (int i = 0; i < 5; i++) {
        pool.submit(() -> done.incrementAndGet());
    }
}
System.out.println(done.get());
  1. 5
  2. 3
  3. 0
  4. A value from 0 to 5
Check your answer

5. close() (called by try-with-resources) shuts the pool down and waits for every submitted task to finish, so all 5 increments are done and visible.

A task submitted with pool.submit(task) throws NullPointerException, but nothing ever appears in the logs. Why?

  1. submit() captured the exception in the returned Future, and nobody called get()
  2. Thread pools suppress all NullPointerExceptions
  3. The worker thread died silently and was never replaced
  4. Exceptions are only logged after shutdown()
Check your answer

submit() captured the exception in the returned Future, and nobody called get(). With submit(), the exception becomes the Future's result and is rethrown (wrapped) only by get(). execute() would let it reach the thread's uncaught-exception handler instead.

Next: tasks that hand back an answer. Callable and Future: how to get a result from another thread, and what happens when that thread fails.