Stop creating raw threads — use ExecutorService and thread pools correctly.
Published September 21, 2026
Creating a new Thread() for every task works in a demo, and fails in a server. Platform threads are expensive: each one reserves a stack (often around 1 MB), costs a system call to create, and adds scheduling work for the OS. Ten thousand simultaneous requests would mean ten thousand threads, with memory pressure, context-switch overhead, and eventually OutOfMemoryError: unable to create native thread.
A thread pool keeps a bounded set of worker threads alive and feeds them tasks from a queue. Java's ExecutorService is the standard interface for that. It separates what to run (tasks) from how it runs (the number of threads, queueing and rejection policy).
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<Integer> future = pool.submit(() -> computePrice(orderId)); // Callable: returns a value
pool.execute(() -> sendAuditEvent(orderId)); // Runnable: fire and forget
Integer price = future.get(2, TimeUnit.SECONDS); // blocks until done, or throws TimeoutException
pool.shutdown(); // stop accepting new tasks; finish queued ones
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt running tasks
}
submit returns a Future: get() waits for the result. If the task threw an exception, get() rethrows it wrapped in an ExecutionException.execute() tasks go to the thread's uncaught-exception handler. With submit(), they're captured in the Future, and silently lost if nobody ever calls get(). That's a classic source of "the job just stopped" bugs.ExecutorService implements AutoCloseable, so try-with-resources calls close(), which shuts down and waits for tasks.List<Callable<Quote>> calls = suppliers.stream()
.<Callable<Quote>>map(s -> () -> s.quote(item))
.toList();
List<Future<Quote>> results = pool.invokeAll(calls, 3, TimeUnit.SECONDS); // all, with an overall timeout
Quote first = pool.invokeAny(calls); // the first successful result
For composing asynchronous steps (then-apply, combine, handle errors), use CompletableFuture on top of an executor. See CompletableFuture.
ThreadPoolExecutorThe Executors factory methods are shortcuts for configuring ThreadPoolExecutor:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // corePoolSize: threads kept alive
16, // maximumPoolSize
60, TimeUnit.SECONDS, // idle time before extra threads die
new ArrayBlockingQueue<>(500), // BOUNDED work queue
namedThreadFactory("pricing-"), // readable thread names in dumps and logs
new ThreadPoolExecutor.CallerRunsPolicy()); // what to do when full
A new task is handled in this order:
Note the counter-intuitive consequence: with an unbounded queue, step 3 never happens, so maximumPoolSize is ignored.
Rejection policies:
AbortPolicy (the default) throws RejectedExecutionException.CallerRunsPolicy runs the task on the submitting thread. That naturally slows producers down, which is a simple form of backpressure.DiscardPolicy and DiscardOldestPolicy silently drop tasks. Use them only for work that's truly optional.Executors shortcuts can be dangerous| Factory | Configuration | Risk |
|---|---|---|
newFixedThreadPool(n) | n threads, unbounded LinkedBlockingQueue | A slow downstream makes the queue grow without limit, until memory runs out |
newCachedThreadPool() | 0..Integer.MAX_VALUE threads, no queue | A burst creates thousands of threads |
newSingleThreadExecutor() | 1 thread, unbounded queue | Same unbounded-queue risk, but guarantees ordering |
newScheduledThreadPool(n) | Delayed and periodic tasks | A periodic task that throws is silently cancelled unless you catch exceptions inside it |
For production services, build ThreadPoolExecutor explicitly, with a bounded queue, named threads, and a deliberate rejection policy.
cores × (1 + wait time / compute time).Since Java 21, blocking I/O-heavy tasks can often use virtual threads instead of a sized pool: Executors.newVirtualThreadPerTaskExecutor(). See Virtual Threads.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
scheduler.scheduleAtFixedRate(() -> {
try { refreshExchangeRates(); }
catch (Exception e) { log.error("rate refresh failed", e); } // otherwise future runs stop silently
}, 0, 5, TimeUnit.MINUTES);
scheduleAtFixedRate aims for a steady rate. If a run overruns, the next one starts immediately after it; runs never overlap.scheduleWithFixedDelay waits a fixed delay after each run finishes.Pool threads are non-daemon by default, so a pool that's never shut down can keep the JVM running. In Spring applications, define executors as beans (for example ThreadPoolTaskExecutor), so the container shuts them down cleanly. In plain code, call shutdown() and awaitTermination() in a shutdown hook or finally block. Tasks should respond to interruption, so that shutdownNow() can actually stop them.
Q: What's the difference between execute() and submit()?
A: execute(Runnable) returns nothing, and exceptions go to the uncaught-exception handler. submit() accepts a Runnable or Callable and returns a Future, which captures the result or exception. Unless someone calls get(), the exception is never seen.
Q: Why is an unbounded queue risky? A: If tasks arrive faster than they complete, the queue grows until memory runs out, and latency grows without limit in the meantime. A bounded queue with a rejection policy fails fast, or applies backpressure instead.
Q: How many threads should a pool have? A: About the number of cores for CPU-bound tasks. For I/O-bound tasks, scale by the ratio of wait to compute time, capped by downstream capacity such as database connections. Then measure and adjust.
Q: What's the difference between shutdown() and shutdownNow()?
A: shutdown() stops accepting tasks but finishes queued and running ones. shutdownNow() also interrupts running tasks and returns the queued ones that never started. Use shutdown() with awaitTermination(), falling back to shutdownNow().
Q: Why did my scheduled task stop running? A: A periodic task that throws an exception is cancelled, and later runs are suppressed without any log message. Catch and log exceptions inside the scheduled task.