02 · java.util.concurrent¶
Manually creating and joining Thread objects gets unwieldy fast. The
java.util.concurrent package provides higher-level building blocks — thread
pools, futures, and composable asynchronous pipelines — for managing
concurrent work.
Thread pools with ExecutorService¶
An ExecutorService manages a pool of reusable worker threads so you don't pay
the cost of creating a new OS thread per task:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorDemo {
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(3); // 3 worker threads
for (int i = 1; i <= 5; i++) {
int taskId = i;
pool.submit(() -> {
System.out.println("Task " + taskId + " running on " + Thread.currentThread().getName());
});
}
pool.shutdown(); // stop accepting new tasks, let submitted ones finish
pool.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS);
System.out.println("All tasks complete");
}
}
Five tasks share only three threads — once a thread finishes a task, the pool reassigns it to the next queued task.
Submitting Callable tasks and using Future¶
Runnable can't return a value or throw a checked exception. Callable<T>
can — and submitting one gives you back a Future<T> representing the
result, which may not be ready yet.
import java.util.concurrent.*;
public class CallableFutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 1000; i++) sum += i;
return sum;
};
Future<Integer> future = pool.submit(sumTask);
System.out.println("Doing other work while the task runs...");
int result = future.get(); // blocks until the result is ready
System.out.println("Sum: " + result); // Sum: 500500
pool.shutdown();
}
}
future.get() blocks the calling thread until the task completes (an
overload also accepts a timeout: future.get(2, TimeUnit.SECONDS), throwing
TimeoutException if it's too slow). If the task threw an exception,
get() wraps it in an ExecutionException.
Shutting down executors properly¶
An ExecutorService's threads keep the JVM alive, so forgetting to shut one
down is a common resource leak.
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
// submit tasks...
} finally {
pool.shutdown(); // reject new tasks, finish queued ones
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
pool.shutdownNow(); // force-cancel anything still running
}
}
Since Java 19, ExecutorService implements AutoCloseable, so try-with-resources
works too:
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
pool.submit(() -> System.out.println("Task running"));
} // close() calls shutdown() + waits for tasks automatically
CompletableFuture basics¶
CompletableFuture represents an asynchronous computation you can chain,
combine, and react to without ever blocking manually.
import java.util.concurrent.CompletableFuture;
public class CompletableFutureDemo {
public static void main(String[] args) {
CompletableFuture<Integer> future = CompletableFuture
.supplyAsync(() -> { // runs on the common ForkJoinPool
System.out.println("Fetching a number...");
return 21;
})
.thenApply(n -> n * 2); // transform the result once available
future.thenAccept(result -> System.out.println("Final result: " + result));
future.join(); // block main() so the async chain finishes before exit
// Fetching a number...
// Final result: 42
}
}
Combining two async computations¶
CompletableFuture<Integer> priceFuture = CompletableFuture.supplyAsync(() -> 100);
CompletableFuture<Double> taxRateFuture = CompletableFuture.supplyAsync(() -> 0.08);
CompletableFuture<Double> totalFuture = priceFuture.thenCombine(
taxRateFuture,
(price, taxRate) -> price + (price * taxRate)
);
System.out.println("Total: " + totalFuture.join()); // Total: 108.0
Handling exceptions in a CompletableFuture chain¶
CompletableFuture<Integer> risky = CompletableFuture.supplyAsync(() -> {
if (true) {
throw new RuntimeException("boom");
}
return 42;
});
CompletableFuture<Integer> recovered = risky.exceptionally(ex -> {
System.out.println("Recovered from: " + ex.getMessage());
return -1; // fallback value
});
System.out.println(recovered.join());
// Recovered from: java.lang.RuntimeException: boom
// -1
handle((result, ex) -> ...) is similar but runs whether or not an exception
occurred, letting you inspect both possibilities in one place.
A quick look ahead: virtual threads¶
Java 21 introduced virtual threads — extremely lightweight threads managed by the JVM instead of the OS, letting you write simple blocking-style code (one thread per task, even millions of tasks) without the overhead of platform threads:
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
pool.submit(() -> {
// each task gets its own cheap virtual thread
});
}
}
This module only scratches the surface — structured concurrency, virtual thread pinning, and scaling patterns are covered in depth in Level 4.
| Type | Purpose |
|---|---|
ExecutorService |
Manages a pool of worker threads |
Runnable |
Task with no return value |
Callable<T> |
Task that returns a value (or throws a checked exception) |
Future<T> |
Handle to a result that may not be ready yet |
CompletableFuture<T> |
Composable, non-blocking asynchronous pipeline |
| Virtual thread | Lightweight, JVM-managed thread (Java 21+) |
How It Actually Works¶
ConcurrentHashMap (Java 8+) achieves high-concurrency reads/writes
with no global lock: each bucket's head node is updated via CAS
(compare-and-swap) for the common no-collision insert case, and only
falls back to a synchronized block scoped to that single bin when a
real collision occurs — contention on bucket A never blocks a thread
touching bucket B, unlike a Hashtable or externally-synchronized
HashMap, which serialize on one lock for the whole table.
AtomicInteger and friends aren't locks at all — they wrap a volatile
field and use Unsafe/VarHandle compare-and-swap intrinsics that
map directly to a single CPU instruction (LOCK CMPXCHG on x86). CAS
loops ("read, compute, try to swap, retry on failure") avoid ever
blocking a thread, but they can still livelock under extreme contention
because every failed attempt means real, wasted work, not a queued wait.
ExecutorService thread pools reuse OS threads instead of paying
creation/teardown cost per task; a bounded queue plus a
RejectedExecutionHandler is what actually happens when submission
outpaces worker capacity — the pool doesn't magically grow past its max
size, it queues, then rejects, and understanding which of those three
states you're in is the real skill in tuning one.
Exercise¶
Using an ExecutorService with a fixed pool of 4 threads, submit 8 Callable<Integer>
tasks that each compute the square of their index (0-7). Collect all 8
Future<Integer> objects in a list, then loop over them calling get() to
print each result in order. Afterward, rewrite the same computation using
CompletableFuture.supplyAsync for each task and CompletableFuture.allOf
to wait for all of them before printing a "done" message.