Stop creating raw threads. Learn how to size and manage Java thread pools correctly, avoid Future.get() traps, and use CompletableFuture for everything new.
Creating raw Thread objects in application code is almost always wrong. A new thread costs roughly 512KB of stack memory by default, thread creation is not free, and nothing stops the JVM from spawning ten thousand of them if your code is called concurrently. Thread pools exist to put a hard cap on that resource consumption and to amortise thread creation cost across many tasks. Yet the default pool types are often misused and pool sizing is frequently guessed rather than calculated.
This post covers the ExecutorService API, how to choose the right pool type, how to size it correctly, and why you should prefer CompletableFuture over raw Future for anything written today.
Executors provides four factory methods. Know when each is appropriate.
newFixedThreadPool(n) — a pool of exactly n threads backed by an unbounded LinkedBlockingQueue. Tasks queue up if all threads are busy. Good for CPU-bound work where n is close to the core count.
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
newCachedThreadPool() — creates threads on demand and reuses idle ones; threads terminate after 60 seconds of inactivity. No queue — tasks execute immediately. Good for many short-lived tasks with unpredictable arrival rates. Bad for CPU-bound work because it will spawn as many threads as there are concurrent tasks, and bad for anything that needs a bounded thread count.
newScheduledThreadPool(n) — supports delayed and periodic execution via schedule(), scheduleAtFixedRate(), and scheduleWithFixedDelay(). Use this instead of Timer (which is single-threaded and suppresses exceptions).
newSingleThreadExecutor() — serialises tasks onto a single thread. Useful for maintaining ordering guarantees without synchronized blocks. The thread is replaced if it terminates due to an exception.
Wrong pool size is one of the most common performance problems in Java services.
CPU-bound tasks (computation, no blocking): pool size ≈ number of available cores. Adding more threads than cores means context switching overhead with no throughput gain.
int cpuBound = Runtime.getRuntime().availableProcessors();
I/O-bound tasks (database, HTTP calls, file access): threads spend most of their time waiting. You can safely run far more threads than cores. Use Little’s Law as a starting point:
threads = target_throughput × average_latency
If you want 200 req/s and each request takes 50ms, you need 200 × 0.05 = 10 threads at steady state. Add headroom for burst (×1.5 to ×2). Measure and adjust — this is a starting point, not a final answer.
Mixed workloads: separate pools for CPU-bound and I/O-bound tasks. Sharing a pool means a burst of I/O work starves CPU work and vice versa.
Runnable returns nothing and swallows checked exceptions. Callable returns a result and can throw checked exceptions — it’s almost always what you want:
Callable<PriceQuote> quoteTask = () -> pricingService.getQuote(instrumentId);
Future<PriceQuote> future = pool.submit(quoteTask);
Future.get() is a blocking call. If you submit ten tasks and call get() on each in sequence, you’re effectively running them serially from the calling thread’s perspective:
// Wrong — blocks on each task before submitting the next
for (String id : ids) {
Future<Quote> f = pool.submit(() -> fetch(id));
results.add(f.get()); // blocks here
}
The correct approach is to submit all tasks first, then collect results:
List<Future<Quote>> futures = ids.stream()
.map(id -> pool.submit(() -> fetch(id)))
.toList();
List<Quote> results = new ArrayList<>();
for (Future<Quote> f : futures) {
results.add(f.get(5, TimeUnit.SECONDS)); // timeout is essential
}
Always call get(timeout, unit), never the no-arg get(). An uncapped get() will block the calling thread forever if the task hangs. The checked TimeoutException forces you to decide what to do when the task takes too long.
ExecutionException wraps any exception the task threw. You must unwrap it to get the cause:
try {
Quote q = future.get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
throw new QuoteFetchException("Quote fetch failed", e.getCause());
}
CompletableFuture (Java 8+) fixes the Future API’s deficiencies. It composes asynchronously, handles errors in the pipeline, and does not require blocking to retrieve results:
CompletableFuture<Quote> quoteFuture = CompletableFuture
.supplyAsync(() -> pricingService.getQuote(id), pool)
.thenApplyAsync(quote -> enricher.enrich(quote), pool)
.exceptionally(ex -> Quote.fallback(id));
Combining multiple independent async operations:
CompletableFuture<String> priceF = CompletableFuture.supplyAsync(() -> fetchPrice(id), pool);
CompletableFuture<String> volumeF = CompletableFuture.supplyAsync(() -> fetchVolume(id), pool);
CompletableFuture<MarketData> combined = priceF.thenCombine(
volumeF,
(price, volume) -> new MarketData(id, price, volume)
);
MarketData data = combined.get(10, TimeUnit.SECONDS);
Both tasks run concurrently. thenCombine completes when both are done and combines their results. No manual coordination required.
Default thread names are pool-1-thread-1, pool-1-thread-2, etc. In a busy application with multiple pools this is useless for debugging. Name your threads:
ExecutorService pool = Executors.newFixedThreadPool(
8,
new ThreadFactoryBuilder()
.setNameFormat("quote-fetcher-%d")
.setUncaughtExceptionHandler((t, e) ->
log.error("Uncaught exception in thread {}", t.getName(), e))
.build()
);
ThreadFactoryBuilder is from Guava. If you’d rather not add that dependency, implement ThreadFactory directly — it’s a single-method interface.
ExecutorService does not shut down with the JVM unless you tell it to. Register a shutdown hook or wire it into your application lifecycle:
@PreDestroy
public void shutdown() {
pool.shutdown(); // stop accepting new tasks
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // cancel running tasks
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
log.error("Pool did not terminate cleanly");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
shutdown() drains the queue gracefully. shutdownNow() interrupts running tasks and returns the unstarted ones — use it as a last resort or for immediate shutdown scenarios.
newFixedThreadPool for CPU-bound, newCachedThreadPool only for truly short-lived I/O tasks, newScheduledThreadPool instead of Timer.Future.get() — always include a timeout.CompletableFuture over Future for any new code.If you’re working on a Java application where throughput and latency matter and want a second opinion on your concurrency design, get in touch.