ExecutorService and thread pool: choosing the right type
1. Overview
Creating new Thread() for each task is easy to write, but hard to control when load increases. ExecutorService solves this by reusing threads and queuing tasks. The Executors class provides many different pool types, and choosing the wrong one can cause OutOfMemoryError or hang the application.
This article goes through each pool type, how ThreadPoolExecutor handles tasks internally, how to choose pool size and how to configure it in Spring Boot 3.x. After reading, you’ll know which pool to use for CPU-bound tasks, I/O-bound tasks, or scheduled tasks.
2. ExecutorService basics
The following example uses a simple pool to run a task with a return value:
import java.util.concurrent.*;
public class OrderReportApp {
public static void main(String[] args) throws Exception {
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
Future<Integer> total = executor.submit(() -> countOrders("2026-10"));
System.out.println("Total orders: " + total.get());
}
}
static int countOrders(String month) {
return 128; // simulate database query
}
}
Total orders: 128
The code works as follows:
submit()takes aCallableand returns aFuture<Integer>immediately. The task runs on one of the pool’s threads.total.get()blocks the current thread until there’s a result.- Since Java 19,
ExecutorServiceimplementsAutoCloseable. When exiting thetryblock,close()callsshutdown()and waits for all tasks to finish.
Note: If you don’t close the executor, non-daemon threads will keep the JVM alive. The
mainprogram won’t exit.
3. Pool types in Executors
3.1. newFixedThreadPool(n)
This pool has exactly n threads. Extra tasks are put into an unbounded LinkedBlockingQueue.
ExecutorService invoiceExecutor = Executors.newFixedThreadPool(8);
This type is suitable when you want to limit the number of tasks running concurrently, for example CPU-bound tasks. The risk lies in the queue. If the producer pushes tasks faster than they’re processed, the queue grows until heap runs out.
3.2. newCachedThreadPool()
This pool has no fixed threads. It uses a SynchronousQueue, meaning there’s no real queue. Each new task is given to an idle thread if available, otherwise the pool creates a new thread. Idle threads are terminated after 60 seconds.
ExecutorService notificationExecutor = Executors.newCachedThreadPool();
This pool is good for many short tasks with low, uneven load. When load spikes, it can create thousands of platform threads. Each thread takes about 1 MB of stack by default, so the application can easily hit OutOfMemoryError: unable to create native thread.
3.3. newSingleThreadExecutor()
This pool has only one thread, and tasks run sequentially in the exact order they were submitted.
ExecutorService auditLogExecutor = Executors.newSingleThreadExecutor();
auditLogExecutor.submit(() -> System.out.println("Order #1 created"));
auditLogExecutor.submit(() -> System.out.println("Order #1 paid"));
Order #1 created
Order #1 paid
The two log lines always print in the correct order. This type is useful when you need to log, write to files, or update state in order without locks. If the thread dies due to an exception, the pool creates a new thread to replace it. Its queue is also unbounded.
3.4. newScheduledThreadPool(n)
This type is for scheduled or repeating tasks:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
() -> System.out.println("Syncing inventory at " + java.time.LocalTime.now()),
0, 5, TimeUnit.SECONDS);
scheduleAtFixedRate runs the task every 5 seconds, measured from the start of the previous run. In contrast, scheduleWithFixedDelay measures 5 seconds from when the previous run ended. You should choose scheduleWithFixedDelay when task execution time is variable.
Note: If a scheduled task throws an exception, all subsequent runs are silently cancelled with no logging. Always wrap task code in
try/catch.
3.5. newWorkStealingPool()
This method returns a ForkJoinPool with a number of threads equal to the number of CPUs. Each thread has its own queue. Idle threads will “steal” work from other threads’ queues.
ExecutorService pricingExecutor = Executors.newWorkStealingPool();
This type is good for CPU-bound tasks that can be recursively subdivided, like parallel computation over large datasets. Pool threads are daemon threads, and the pool doesn’t guarantee execution order.
3.6. newVirtualThreadPerTaskExecutor() (Java 21)
With this type, each task runs on a new virtual thread. Virtual threads are very cheap, so you can create hundreds of thousands.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int customerId = i;
executor.submit(() -> {
Thread.sleep(100); // simulate HTTP call to CustomerService
return customerId;
});
}
}
The code above runs 10,000 tasks blocking for 100 ms and completes in a few hundred milliseconds. If you use a fixed pool of 10 threads, the same work takes about 100 seconds.
This is the right default for I/O-bound tasks like HTTP calls, database queries, or file reads. You need to remember three things:
- Don’t pool virtual threads. They’re created to be used once and discarded.
- To limit load on external resources, use
Semaphore. For example, if the database only handles 20 connections, don’t rely on pool size to limit it. - In Java 21,
synchronizedblocks with blocking operations will “pin” the virtual thread to a carrier thread. Then virtual thread benefits disappear. PreferReentrantLockin such sections.
Example of limiting concurrent queries with Semaphore:
Semaphore dbPermits = new Semaphore(20);
executor.submit(() -> {
dbPermits.acquire();
try {
return orderRepository.findByCustomerId(customerId);
} finally {
dbPermits.release();
}
});
4. Configuring ThreadPoolExecutor manually
The factory methods in Executors usually hide a dangerous parameter: an unbounded queue or unlimited thread count. For production systems, you should create ThreadPoolExecutor yourself to control everything:
ThreadFactory factory = Thread.ofPlatform().name("payment-", 0).factory();
ThreadPoolExecutor paymentExecutor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAlive for threads above core
new ArrayBlockingQueue<>(100), // bounded queue
factory, // naming: payment-0, payment-1...
new ThreadPoolExecutor.CallerRunsPolicy());
When a new task arrives, ThreadPoolExecutor handles it in this exact order:
- If thread count is less than
corePoolSize, the pool creates a new thread. - Otherwise, the pool puts the task in the queue.
- If the queue is full and thread count is less than
maximumPoolSize, the pool creates another thread. - If both limits are reached, the pool calls
RejectedExecutionHandler.
The consequence is that maximumPoolSize only takes effect when the queue is full. If you use an unbounded queue, the pool will never exceed corePoolSize. This is a very common source of confusion.
Java provides four built-in rejection policies:
| Policy | Behavior |
|---|---|
AbortPolicy (default) |
Throws RejectedExecutionException |
CallerRunsPolicy |
The thread calling execute() runs the task itself, helping to slow down the producer (back-pressure) |
DiscardPolicy |
Discards the task silently |
DiscardOldestPolicy |
Discards the oldest task in the queue and retries |
Setting thread names via ThreadFactory makes thread dumps and logs much more readable. Comparing payment-3 with pool-7-thread-3 shows the difference immediately.
5. Choosing the right type
5.1. Pool size
For CPU-bound tasks, the thread count should be approximately the number of cores:
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuExecutor = Executors.newFixedThreadPool(cores);
More threads than cores just increases context switching overhead.
For I/O-bound tasks on platform threads, you can use an approximation formula:
thread count ≈ number of cores × (1 + wait time / compute time)
Example: machine has 8 cores, each request waits 90 ms for I/O and computes for 10 ms. Result is 8 × (1 + 9) = 80 threads. This is just a starting point. You need to measure actual performance with load testing.
5.2. Decision table
| Problem | Choice |
|---|---|
| Heavy CPU computation | newFixedThreadPool(cores) or ForkJoinPool |
| Calling APIs, querying DB at scale | newVirtualThreadPerTaskExecutor() + Semaphore |
| Need sequential order | newSingleThreadExecutor() |
| Scheduled tasks | newScheduledThreadPool(n) |
| Production, need back-pressure | ThreadPoolExecutor with bounded queue |
| Spiky load, no limits | Avoid newCachedThreadPool() |
6. Thread pool in Spring Boot 3.x
First, you need the Maven dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Then declare a dedicated executor for sending emails:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
return executor;
}
}
@Service
public class EmailService {
@Async("emailExecutor")
public void sendOrderConfirmation(String customerEmail) {
System.out.println(Thread.currentThread().getName() + " sending email to " + customerEmail);
}
}
email-1 sending email to an.nguyen@example.com
A few important points:
queueCapacitydefaults toInteger.MAX_VALUEforThreadPoolTaskExecutor. If you don’t set this value,maxPoolSizebecomes meaningless, as explained in section 4.setWaitForTasksToCompleteOnShutdown(true)ensures emails being sent aren’t cut off when the application shuts down.- If you want to use virtual threads for
@Async, Tomcat, and@Scheduled, you just need to add one config line:
spring.threads.virtual.enabled=true
7. Testing pool behavior
The following test confirms the exact execution order described in section 4: core threads first, then queue, then extra threads, finally rejection.
import org.junit.jupiter.api.Test;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class PaymentExecutorTest {
@Test
void shouldRejectWhenThreadsAndQueueAreFull() throws Exception {
CountDownLatch release = new CountDownLatch(1);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 2, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.AbortPolicy());
Runnable blockingPayment = () -> {
try {
release.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
try {
executor.execute(blockingPayment); // 1: create core thread
executor.execute(blockingPayment); // 2: into queue
executor.execute(blockingPayment); // 3: queue full -> create thread 2
assertThat(executor.getPoolSize()).isEqualTo(2);
assertThat(executor.getQueue()).hasSize(1);
assertThatThrownBy(() -> executor.execute(blockingPayment))
.isInstanceOf(RejectedExecutionException.class);
} finally {
release.countDown();
executor.shutdown();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
}
CountDownLatch keeps all tasks in a blocking state, so the pool’s state remains static when you inspect it. The fourth task has no room, so AbortPolicy throws an exception. The finally block releases the latch and closes the pool, so the test doesn’t leave behind stray threads.
8. Common mistakes
- Using
Executors.newFixedThreadPoolwith uncontrolled producers. An unbounded queue will grow and cause OOM. UseArrayBlockingQueuewith a limit. - Creating a new pool for each request. Pools must be shared, for example declared as a bean or
static finalfield. Creating and forgetting to close leaks threads. - Swallowing exceptions. With
submit(), exceptions are held in theFuture. If you don’t callget(), you’ll never see the error. Callget()or catch errors inside the task. - Deadlock from tasks waiting for subtasks on the same small pool. Example: pool has 2 threads, both parent tasks call
get()on child tasks sitting in the queue. No thread left to run the child tasks, so the whole system hangs. - Putting heavy
ThreadLocalinto virtual threads. You can have millions of virtual threads, each holding a copy, so memory grows very quickly. - Calling
shutdownNow()when tasks don’t handle interruption. The task will keep running. Long-running tasks should checkThread.currentThread().isInterrupted().
Note: Don’t use
ExecutorServicefor complex multi-step workflows with dependencies. That case should useCompletableFuture, which can still run on your configured executor.
9. Conclusion
Each thread pool type solves a specific problem:
- Fixed pool or
ForkJoinPoolfor CPU-bound tasks. - Virtual threads with
Semaphorefor I/O-bound tasks on Java 21. - Single thread executor when order matters.
- Scheduled pool for periodic tasks.
In production, configure ThreadPoolExecutor or ThreadPoolTaskExecutor yourself with a bounded queue, clear rejection policy, and recognizable thread names. Finally, validate pool size with load testing rather than just formulas.