The Executor Framework and Thread Pools in Java
An executor separates submitting work from running it, so threads are reused instead of created per task.
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
Why pools exist
// One thread per request: creation cost per task, and no upper bound
for (Request request : requests) {
new Thread(() -> handle(request)).start();
}Creating a platform thread costs time and around a megabyte of stack. Ten thousand requests would create ten thousand threads and exhaust memory. A pool creates a fixed set of threads once and feeds them tasks from a queue.
ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.execute(() -> handle(request)); // Runnable, no result
Future<String> future = executor.submit(this::load); // Callable, a result
executor.shutdown(); // no new tasks accepted
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow(); // interrupt what is running
}// Java 19 onwards: ExecutorService is AutoCloseable, close() shuts down and waits
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
executor.submit(task);
}An executor must be shut down. Its threads are non daemon by default, so a program that forgets never exits.
The factory methods
| Factory | Threads | Suits |
|---|---|---|
newFixedThreadPool(n) | Exactly n | CPU bound work with a known limit |
newCachedThreadPool() | Grows as needed, idle threads expire | Many short lived tasks |
newSingleThreadExecutor() | One | Tasks that must run in order |
newScheduledThreadPool(n) | n | Delayed and repeating tasks |
newWorkStealingPool() | One per processor | Many small independent tasks |
newVirtualThreadPerTaskExecutor() | A virtual thread per task | I/O bound work, Java 21 onwards |
// A cached pool is unbounded: a burst of slow tasks can create thousands of threads
ExecutorService risky = Executors.newCachedThreadPool();Sizing a pool
int processors = Runtime.getRuntime().availableProcessors();
ExecutorService cpuBound = Executors.newFixedThreadPool(processors);
ExecutorService ioBound = Executors.newFixedThreadPool(processors * 4); // a rough startCPU bound work gains nothing from more threads than processors. I/O bound work benefits from more, because threads spend most of their time waiting. The multiplier is a starting point to be replaced by measurement.
Configuring the pool directly
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // core threads
16, // maximum threads
60, TimeUnit.SECONDS, // idle timeout above the core size
new ArrayBlockingQueue<>(1000), // a bounded queue
new ThreadPoolExecutor.CallerRunsPolicy()); // what to do when full| Rejection policy | Behaviour |
|---|---|
AbortPolicy | Throws RejectedExecutionException, the default |
CallerRunsPolicy | The submitting thread runs the task, which slows producers down |
DiscardPolicy | Silently drops the task |
DiscardOldestPolicy | Drops the oldest queued task |
Use a bounded queue. An unbounded one turns a load spike into an out of memory error, and by then there is no way to shed load gracefully.
Scheduled tasks
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
scheduler.schedule(this::sendReminder, 10, TimeUnit.SECONDS);
scheduler.scheduleAtFixedRate(this::collectMetrics, 0, 1, TimeUnit.MINUTES);
scheduler.scheduleWithFixedDelay(this::cleanUp, 0, 1, TimeUnit.MINUTES);scheduleAtFixedRate measures from each start, so a slow run causes the next to begin immediately. scheduleWithFixedDelay measures from each finish and leaves a real gap. If a repeating task throws, it is silently cancelled, so wrap the body in a try catch.
Submitting many tasks
List<Callable<String>> tasks = urls.stream()
.map(url -> (Callable<String>) () -> fetch(url))
.toList();
List<Future<String>> results = executor.invokeAll(tasks); // waits for all
String firstDone = executor.invokeAny(tasks); // waits for oneNaming threads
ThreadFactory factory = Thread.ofPlatform().name("import-", 0).factory();
ExecutorService executor = Executors.newFixedThreadPool(4, factory);Default names such as pool-1-thread-3 tell you nothing in a stack trace. Naming pools is a small change that repays itself the first time something goes wrong.
Virtual threads
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request request : requests) {
executor.submit(() -> handle(request)); // one virtual thread each
}
}Virtual threads are scheduled by the JVM onto a small set of carrier threads. When one blocks on I/O, its carrier is released to run another. Millions can exist at once, so pooling becomes unnecessary for I/O bound work: create one per task.
| Platform threads | Virtual threads | |
|---|---|---|
| Managed by | The operating system | The JVM |
| Cost each | About 1 MB of stack | A few hundred bytes, growing as needed |
| Practical count | Thousands | Millions |
| Best for | CPU bound work | I/O bound work |
| Pool them | Yes | No, one per task |
A worked example
public Map<String, Integer> countWordsInFiles(List<Path> files)
throws InterruptedException {
Map<String, Integer> totals = new ConcurrentHashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors())) {
for (Path file : files) {
executor.submit(() -> {
try (Stream<String> lines = Files.lines(file)) {
lines.flatMap(line -> Arrays.stream(line.split("[^a-zA-Z]+")))
.filter(word -> !word.isBlank())
.forEach(word -> totals.merge(word, 1, Integer::sum));
} catch (IOException e) {
logger.warning("Skipped " + file);
}
});
}
}
return totals;
}Common mistakes
- Forgetting to shut down, so the JVM never exits.
- Using an unbounded queue and turning a load spike into an out of memory error.
- Using
newCachedThreadPoolfor slow tasks and creating thousands of threads. - Ignoring the
Futurefromsubmit, which silently swallows every exception. - Letting a scheduled task throw, which cancels all future runs.
- Pooling virtual threads, which defeats their purpose.
Best practices
- Prefer an executor to creating threads by hand.
- Use a bounded queue with a deliberate rejection policy.
- Size the pool from the workload type, then measure.
- Name your pool threads.
- Always shut down, ideally with try with resources.
- Check the
Future, or exceptions vanish. - Use virtual threads for I/O bound work on Java 21 and later.
Practice
- Submit ten tasks to a pool of two threads and observe how they queue.
- Why does a task that throws inside
submitappear to succeed? - Explain the difference between
scheduleAtFixedRateandscheduleWithFixedDelay. - Choose a pool size for a CPU heavy job and for a job that mostly waits on a network, and justify each.
- Rewrite a fixed pool solution using a virtual thread per task executor.
Conclusion
An executor decouples what runs from where it runs. Choose a bounded queue and a deliberate rejection policy, size the pool for the workload, always shut down, and reach for virtual threads when the work is dominated by waiting.