ExecutorService & CompletableFuture
Thread pools, Callable and Future, shutting down cleanly, async pipelines and virtual-thread executors.
Creating a new Thread for every task is wasteful and hard to manage: there's no limit on how many threads run, no easy way to get results back, and exceptions vanish. The java.util.concurrent package offers higher-level tools. ExecutorService manages pools of threads for you, Future represents a pending result, and CompletableFuture lets you build asynchronous pipelines. Finally, Java 21's virtual threads make "one thread per task" cheap again.
ExecutorService: submit tasks, not threads#
Possible output (task order and thread assignment vary):
Six tasks ran on just three threads: each worker takes the next task from the pool's queue when it's free. You describe what to run; the executor decides where and when.
Common executors
Runtime.getRuntime().availableProcessors() tells you the number of cores.
Shutting down
shutdown(): stop accepting tasks, let queued ones finish.awaitTermination(timeout): wait for that to happen.shutdownNow(): interrupt running tasks and return the ones still queued.- Since Java 19,
ExecutorServiceisAutoCloseable:try (var pool = Executors.newFixedThreadPool(4)) { ... }shuts down and waits automatically at the end of the block.
Callable and Future: getting results back#
A Callable<V> is like a Runnable that returns a value (and may throw checked exceptions). Submitting one gives you a Future<V>, a handle to the result that will exist later:
future.get()waits for the result.get(2, TimeUnit.SECONDS)gives up with aTimeoutException.- An exception thrown inside the task is wrapped in an
ExecutionException; the original isgetCause(). invokeAllreturns futures in the same order as the tasks;invokeAnyreturns the first successful result.future.cancel(true)interrupts a running task.
The weakness of Future: you can only block and wait. You can't say "when this finishes, do that".
Scheduled tasks#
CompletableFuture: asynchronous pipelines#
CompletableFuture<T> (Java 8+) is a Future you can chain and combine without blocking, much like promises in JavaScript:
Key methods:
Fan-out with allOf and handling errors
Without a handler, an exception inside a stage propagates down the chain, and join() throws a CompletionException wrapping it.
By default,
supplyAsyncuses the sharedForkJoinPool.commonPool(), sized for CPU work. For blocking I/O (HTTP, database), pass your own executor as the second argument, as above, or use virtual threads.
Virtual threads (Java 21)#
A traditional platform thread maps one-to-one onto an operating-system thread: it is expensive to create and has a large stack, so a server can run only a few thousand of them. Virtual threads are lightweight threads managed by the JVM. When a virtual thread blocks on I/O or sleep, the JVM parks it and reuses the underlying OS thread (a carrier) for other work. You can run millions of them.
With a fixed pool of 100 platform threads, the same work would take about 100 seconds. Guidelines:
- Use virtual threads for I/O-bound tasks: web requests, database calls, file and network access. Spring Boot 3.2+ can serve every request on a virtual thread with
spring.threads.virtual.enabled=true. - They don't make CPU-bound work faster; use a fixed pool sized to your cores for that.
- Don't pool virtual threads; create one per task.
- Keep using normal synchronisation tools. In Java 21, blocking while inside a
synchronizedblock pins the carrier thread, so preferReentrantLockaround long blocking operations (this limitation was removed in Java 24).
Choosing the right tool#
Common mistakes#
- Forgetting to shut down executors.
- Calling
future.get()immediately after submitting, which makes the work effectively sequential. - Ignoring exceptions inside tasks: a failed
submit(Runnable)task fails silently unless you callget(). - Running blocking I/O in the common fork/join pool.
- Unbounded
newCachedThreadPoolunder heavy load, which can create thousands of platform threads. - Pooling virtual threads, or expecting them to speed up CPU-bound code.
What's next#
Threads, stacks, heaps and pools all live inside the JVM. Next we look under the hood: JVM memory and garbage collection.
Check your understanding
Quick quiz
1.What is the difference between
RunnableandCallable<V>?2.What happens if you never call
shutdown()on an ExecutorService created withExecutors.newFixedThreadPool(4)?3.Which CompletableFuture method combines the results of two independent futures?
Finished reading?
Mark this lesson complete to track your progress.