Don't create a thread per task — submit tasks to an ExecutorService, which reuses a pool of threads and gives you Futures for the results.
Why a pool?
submit, Future and invokeAll
import java.util.List;
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(3);
try {
Future<Integer> f = pool.submit(() -> 6 * 7); // Callable<Integer>
System.out.println("answer = " + f.get()); // blocks until done
List<Callable<String>> tasks = List.of(() -> "a".repeat(3), () -> "b".repeat(2), () -> "c");
for (Future<String> r : pool.invokeAll(tasks)) { // results in task order
System.out.println(r.get());
}
Future<?> g = pool.submit(() -> { }); // Runnable
System.out.println("runnable result = " + g.get());
Future<Integer> bad = pool.submit(() -> 1 / 0);
try {
bad.get();
} catch (ExecutionException e) { // the task's exception is wrapped
System.out.println("cause: " + e.getCause());
}
} finally {
pool.shutdown(); // stop accepting, finish queued
System.out.println("terminated = " + pool.awaitTermination(1, TimeUnit.SECONDS));
}
}
}answer = 42
aaa
bb
c
runnable result = null
cause: java.lang.ArithmeticException: / by zero
terminated = trueFuture method | Purpose |
|---|---|
get() / get(t, unit) | Wait for the result; throws ExecutionException (task failed), TimeoutException, InterruptedException |
isDone() | Completed normally, exceptionally or cancelled |
cancel(mayInterrupt) | Try to cancel; true interrupts a running task |
resultNow() / state() | Java 19+: read a completed result without checked exceptions |
Executors factory methods
| Factory | Pool | Watch out |
|---|---|---|
newFixedThreadPool(n) | n threads, unbounded LinkedBlockingQueue | Queue can grow until OutOfMemoryError |
newCachedThreadPool() | 0…∞ threads, idle ones die after 60 s, SynchronousQueue | Unbounded threads under load |
newSingleThreadExecutor() | 1 thread, tasks run sequentially in order | Same unbounded queue |
newScheduledThreadPool(n) | Delayed / periodic tasks (schedule, scheduleAtFixedRate) | Replaces java.util.Timer |
newWorkStealingPool() | ForkJoinPool with one queue per worker | Good for many small CPU tasks |
newVirtualThreadPerTaskExecutor() | Java 21: one virtual thread per task | For blocking I/O workloads |
Shutting down
shutdown()No new tasks accepted; already submitted ones still run.
awaitTermination(t, unit)Block until all tasks finish or the timeout elapses; returns
trueif terminated.shutdownNow()Interrupt running tasks and return the ones never started (tasks must respond to interrupts).
Since Java 19 ExecutorService is AutoCloseable: close() calls shutdown() and waits for every task, so try-with-resources is the tidiest pattern.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Main {
public static void main(String[] args) throws Exception {
List<Future<Integer>> squares = new ArrayList<>();
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
for (int i = 1; i <= 5; i++) {
int n = i;
squares.add(pool.submit(() -> n * n));
}
} // close(): shutdown + wait for all tasks
int total = 0;
for (Future<Integer> f : squares) total += f.resultNow();
System.out.println("sum of squares = " + total);
}
}sum of squares = 55ThreadPoolExecutor: the real knobs
| Parameter | Meaning |
|---|---|
corePoolSize | Threads kept alive even when idle |
maximumPoolSize | Upper bound — extra threads are created only when the queue is full |
keepAliveTime | How long threads above core may stay idle |
workQueue | Where waiting tasks go: bounded ArrayBlockingQueue recommended |
threadFactory | Name threads (order-worker-1), set daemon/priority |
handler | Rejection policy when both queue and max threads are full |
task arrives → threads < core? new thread → else queue it → queue full? thread up to max → else reject
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws InterruptedException {
CountDownLatch gate = new CountDownLatch(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS, // exactly one thread
new ArrayBlockingQueue<>(1), // room for one waiting task
new ThreadPoolExecutor.AbortPolicy()); // default: throw when full
Runnable blocker = () -> {
try { gate.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
pool.execute(blocker); // runs on the only thread
pool.execute(blocker); // waits in the queue
try {
pool.execute(blocker); // no thread, no queue space
} catch (RejectedExecutionException e) {
System.out.println("3rd task rejected");
}
System.out.println("pool size = " + pool.getPoolSize() + ", queued = " + pool.getQueue().size());
gate.countDown();
pool.shutdown();
pool.awaitTermination(1, TimeUnit.SECONDS);
System.out.println("completed = " + pool.getCompletedTaskCount());
}
}3rd task rejected
pool size = 1, queued = 1
completed = 2| Rejection policy | Behaviour |
|---|---|
AbortPolicy (default) | Throw RejectedExecutionException |
CallerRunsPolicy | Run the task on the submitting thread — natural back-pressure |
DiscardPolicy | Silently drop the new task |
DiscardOldestPolicy | Drop the oldest queued task, retry the new one |
How many threads?
threads ≈ cores × (1 + wait time / compute time)
CPU-bound (hashing, parsing, math): about
Runtime.getRuntime().availableProcessors()threads — more only adds context switching.I/O-bound (HTTP calls, DB queries): many more, since threads mostly wait. On Java 21 prefer virtual threads here.
Use separate pools for separate kinds of work, so a slow dependency can't starve everything (bulkheads).
submit() vs execute()?
Why is Executors.newFixedThreadPool considered risky in production?
When does a ThreadPoolExecutor create threads beyond corePoolSize?
shutdown() vs shutdownNow()?