ExecutorService, Callable, and Future — Efficient Concurrency in Java
Part 11 of 13 in Mastering Java Network Programming
When a Java server handles network requests, every incoming connection needs work to be done. The naive approach — one Thread per request — works until the connection count climbs past what the OS can manage. Method calls avoid that problem entirely, but they run sequentially and don’t scale. ExecutorService sits between them: it lets you submit tasks for concurrent execution without paying a fresh Thread-creation cost each time.
This post walks through two demonstrations.
First, a side-by-side timing comparison of three strategies:
- Method calls — the single-threaded baseline where every unit of work runs in sequence on one thread.
- Raw Thread — creating and destroying a new Thread for each task (the classic “one thread per request” pattern).
- ExecutorService — submitting to a fixed-size pool that reuses its workers across tasks.
The code splits 1,000 units of work into batches and measures how long each strategy takes:
static long benchmarkMethodCalls(int tasks, int workSize) {
long start = System.nanoTime();
for (int i = 0; i < tasks; i++) {
doWork(workSize);
}
return System.nanoTime() - start;
}
static long benchmarkRawThreads(int tasks, int workSize) throws Exception {
long start = System.nanoTime();
Thread[] threads = new Thread[tasks];
for (int i = 0; i < tasks; i++) {
final int idx = i;
threads[i] = new Thread(() -> doWork(workSize));
threads[i].start();
}
for (Thread t : threads) { t.join(); }
return System.nanoTime() - start;
}
static long benchmarkExecutor(int tasks, int workSize) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
long start = System.nanoTime();
for (int i = 0; i < tasks; i++) {
pool.submit(() -> doWork(workSize));
}
pool.shutdown();
pool.awaitTermination(60, TimeUnit.SECONDS);
return System.nanoTime() - start;
}
Here’s the output from a single run on this machine (1,000 tasks × 500,000 iterations of work each):
On this run the numbers were:
- Direct method call: ~128 ms (single-threaded baseline)
- New Thread each time: ~40 ms
- ExecutorService: ~16 ms
Both multi-threaded approaches beat single-threaded work because they execute in parallel across available cores. The difference between “new Thread” and ExecutorService is the per-task overhead: every new Thread() allocates a fresh OS thread stack and registers it with the kernel, which takes microseconds — negligible for a single task, but substantial when you are creating 1,000 of them in quick succession. ExecutorService reuses its worker threads, so the cost is amortized across all submitted tasks.
The second demonstration shows how to use ExecutorService beyond raw parallelism: getting results back from parallel work and bounding concurrency as a shared-resource gate.
Demo 1 splits a computation into four Callable tasks submitted to a pool of four threads. Each task returns a Long result via Future.get():
List<Future<Long>> futures = new ArrayList<>();
for (int i = 0; i < parts; i++) {
long s = i * partSize + 1;
long e = (i == parts - 1) ? n : (i + 1) * partSize;
futures.add(executor.submit(new SumTask(s, e)));
}
long totalSum = 0;
for (Future<Long> f : futures) {
totalSum += f.get();
}
The sum of squares of the first million integers was computed in parallel and matched the expected value exactly — Future.get() blocks until each task completes, guaranteeing that no result is missed or double-counted.
Demo 2 shows bounded concurrency: 100 tasks are submitted to a pool of only three threads. Tasks queue up and execute as workers become available:
ExecutorService executor = Executors.newFixedThreadPool(3);
AtomicInteger sharedCounter = new AtomicInteger(0);
for (int i = 0; i < totalTasks; i++) {
futures.add(executor.submit(new CountingTask(i, sharedCounter)));
}
Because only three threads run concurrently, the writes to the shared counter are serialized across time. AtomicInteger ensures those writes don’t corrupt — incrementAndGet() is an atomic compare-and-swap operation, not a read-modify-write sequence that races.
The output confirmed all 100 tasks completed with a global counter of exactly 100 (matching the number of submitted tasks).
Takeaway: Thread pools are a finite resource, like a connection pool. Use ExecutorService to reuse workers across tasks instead of creating threads on demand; use Callable/Future when parallel work needs to return results; and configure the pool size based on whether your workload is CPU-bound (roughly N_cores + 1) or I/O-bound (often higher, since threads spend most of their time waiting).