Java ExecutorService and Thread Pool Types
Table of Contents + β
In the last lesson you learned about Java thread pools and executors: a small team of reusable threads runs many tasks. Now letβs get practical. There are several pool types, and each one fits a different job. Pick the wrong one and your program slows down, or runs out of memory.
π€ The problem: one pool does not fit every job
A hammer is great for a nail but wrong for a screw. Thread pools are the same. A pool that is perfect for a steady stream of work is dangerous for a sudden flood of slow tasks.
So the real skill is matching the pool to the work. Letβs start with the manager you talk to, then walk through each pool type with code and output.
π§© The ExecutorService interface
ExecutorService is the interface you hold and talk to. You do not care what pool sits behind it. You hand it tasks and it runs them. The same few methods work no matter which pool you chose.
The methods you will actually use:
execute(Runnable)runs a task and returns nothing.submit(...)runs a task and hands you back aFuture, a handle to the result.shutdown()andshutdownNow()stop the pool.awaitTermination(...)waits for all tasks to finish.
The key habit: declare the variable as ExecutorService, not the concrete pool class. Then you can swap the pool type later by changing one line, and the rest of your code never notices.
import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;
// the variable type is the interface, not the pool classExecutorService pool = Executors.newFixedThreadPool(3);To switch to a cached pool, you only change the right-hand side.
π The Executors factory methods
You never build a pool with new. You call a static method on the Executors class, which returns a ready-made ExecutorService. Each gives a pool with a different personality. Letβs meet them one at a time.
newFixedThreadPool(n)
This keeps exactly n threads alive for the whole life of the pool. Extra tasks wait in a queue. This is the safe default, because you set a hard limit and can never accidentally start ten thousand threads.
This example runs six tasks on a pool of two threads, so you can watch two workers share all the work.
import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;
public class Main { public static void main(String[] args) { ExecutorService pool = Executors.newFixedThreadPool(2);
for (int i = 1; i <= 6; i++) { int taskId = i; pool.execute(() -> { System.out.println("Task " + taskId + " ran on " + Thread.currentThread().getName()); }); } pool.shutdown(); }}Two threads grab the first two tasks. The other four wait in the queue. As a thread finishes, it grabs the next. Two workers handle all six jobs by reusing themselves.
The output shows only two thread names for six tasks. That repetition is the reuse you are paying for. The order may shift from run to run, because the two threads race for the next task.
Output (order may vary)
Task 1 ran on pool-1-thread-1Task 2 ran on pool-1-thread-2Task 3 ran on pool-1-thread-1Task 4 ran on pool-1-thread-2Task 5 ran on pool-1-thread-1Task 6 ran on pool-1-thread-2Use a fixed pool when the work is steady and you want a firm cap on threads. That covers most everyday situations.
newCachedThreadPool()
A cached pool makes a new thread whenever every current thread is busy, and retires a thread that sits idle for about a minute. So it grows and shrinks with demand. It is great for lots of short tasks, because a free thread is reused right away and new ones appear only when needed.
This example fires three quick tasks at a cached pool.
import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;
public class Main { public static void main(String[] args) { ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 1; i <= 3; i++) { int taskId = i; pool.execute(() -> System.out.println("Quick task " + taskId + " on " + Thread.currentThread().getName())); } pool.shutdown(); }}Because the tasks are short and arrive together, the pool may start a thread for each. There is no fixed limit on the names you see.
Output (thread names and order may vary)
Quick task 1 on pool-1-thread-1Quick task 2 on pool-1-thread-2Quick task 3 on pool-1-thread-3The catch is the missing limit. A cached pool has no cap on threads. A flood of slow tasks keeps making threads until the machine struggles. So use it only when tasks are genuinely short.
newSingleThreadExecutor()
This is a pool of one. Tasks run strictly one at a time, in submit order. Use it when order matters, or when two tasks must never touch the same data at once.
This example submits three tasks and they come out in order, every run.
import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;
public class Main { public static void main(String[] args) { ExecutorService single = Executors.newSingleThreadExecutor();
single.execute(() -> System.out.println("Step 1")); single.execute(() -> System.out.println("Step 2")); single.execute(() -> System.out.println("Step 3"));
single.shutdown(); }}One worker, so there is no race. The steps print in the order you wrote them, every run.
Output (always in this order)
Step 1Step 2Step 3This predictability is the point. When a later step depends on an earlier one, a single-thread executor keeps things safe.
newScheduledThreadPool(n)
A scheduled pool runs tasks later, or again and again on a timer. It hands you a ScheduledExecutorService with time-based methods. We use it fully below.
newVirtualThreadPerTaskExecutor()
One line for modern Java. On Java 21 and newer, Executors.newVirtualThreadPerTaskExecutor() gives each task its own virtual thread, which is so cheap that one per task is fine even for thousands of tasks that mostly wait on input or output.
π οΈ execute vs submit
You hand a task to the pool in one of two ways:
execute(Runnable)fires off a task and gives nothing back. Use it for fire-and-forget work.submit(...)accepts aRunnableorCallableand hands you aFuture, a handle to wait for the task or grab its result.
This example shows both. The first task is fire-and-forget. The second returns a value we read from the Future.
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 { ExecutorService pool = Executors.newFixedThreadPool(2);
// fire-and-forget, nothing comes back pool.execute(() -> System.out.println("Logged the visit"));
// submit returns a Future we can read later Future<Integer> result = pool.submit(() -> 2 + 3); System.out.println("Sum from the task: " + result.get());
pool.shutdown(); }}The execute call just runs its task. The submit call returns a Future, and result.get() waits for the task and gives us its value.
Output
Logged the visitSum from the task: 5The rule of thumb: no result needed, use execute. Need a result or want to check if the task finished, use submit. We go deep on Future next lesson.
β° Scheduling with ScheduledExecutorService
Sometimes you want a task to run after a delay, or to repeat on a timer, like checking a folder every ten seconds. That is what ScheduledExecutorService is for. You create it with Executors.newScheduledThreadPool(n).
It adds two methods worth knowing:
schedule(task, delay, unit)runs the task once, after the delay.scheduleAtFixedRate(task, initialDelay, period, unit)runs the task again and again, everyperiodunits.
This example runs one task after a two-second delay.
import java.util.concurrent.Executors;import java.util.concurrent.ScheduledExecutorService;import java.util.concurrent.TimeUnit;
public class Main { public static void main(String[] args) throws Exception { ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
System.out.println("Scheduling a task..."); scheduler.schedule( () -> System.out.println("Ran after 2 seconds"), 2, TimeUnit.SECONDS);
scheduler.shutdown(); }}The program prints the first line right away, then waits two seconds and prints the second.
Output
Scheduling a task...Ran after 2 secondsNow a repeating task. This one runs every second. We let it tick a few times, then stop the scheduler.
import java.util.concurrent.Executors;import java.util.concurrent.ScheduledExecutorService;import java.util.concurrent.TimeUnit;import java.util.concurrent.atomic.AtomicInteger;
public class Main { public static void main(String[] args) throws Exception { ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
AtomicInteger count = new AtomicInteger();
scheduler.scheduleAtFixedRate(() -> { int n = count.incrementAndGet(); System.out.println("Tick " + n); }, 0, 1, TimeUnit.SECONDS);
// let it run for about 3 seconds, then stop Thread.sleep(3500); scheduler.shutdown(); }}The task starts with no initial delay, then repeats every second. We sleep main for about three and a half seconds so the timer fires a few times, then shut the scheduler down.
Output
Tick 1Tick 2Tick 3Tick 4A repeating scheduled task never stops on its own. You must shut the scheduler down, or it ticks forever and the program never ends.
π Lifecycle and proper shutdown
A pool starts running, you submit work, then you must stop it. Skipping that last step is the most common executor bug. Pool threads stay alive waiting for work that never comes, and a live thread keeps the program from exiting, so it just hangs.
You end a pool with one of two methods.
shutdown()is the polite one. It stops accepting new tasks, lets everything already submitted finish, then ends the threads. This is what you want almost every time.shutdownNow()is the firm one. It tries to stop right away. Queued tasks that have not started are dropped, and running tasks get interrupted. Use it only when you must stop fast.
After shutdown(), call awaitTermination(...) to block until the pool finishes, or a time limit runs out. This is the safe pattern you will see in real code.
import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.TimeUnit;
ExecutorService pool = Executors.newFixedThreadPool(3);// ... submit tasks here ...
pool.shutdown(); // stop taking new work, finish what is queuedtry { // wait up to 60 seconds for the tasks to drain if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { pool.shutdownNow(); // still not done, so force it }} catch (InterruptedException e) { pool.shutdownNow(); Thread.currentThread().interrupt();}Ask politely with shutdown(), wait a reasonable time with awaitTermination(), and only force a stop with shutdownNow() if the wait runs out.
Always shut down the pool
A poolβs threads stay alive waiting for work, so the program will not end on its own. Always call shutdown() when you finish submitting tasks. Forgetting this makes the program hang.
π― Choosing a pool size
For a fixed pool, how many threads? It depends on what the tasks do:
- Tasks that mostly use the processor, like math or sorting, want a size near the number of cores. More threads than cores just fight over the same processors.
- Tasks that mostly wait, like a network call or a file read, can go higher, because each thread spends most of its time idle.
The cleanest start is to read the core count at runtime instead of hard-coding a number.
int cores = Runtime.getRuntime().availableProcessors();ExecutorService pool = Executors.newFixedThreadPool(cores);Then measure under real load and adjust. A good size is the one your own numbers point to, not a guess.
β οΈ Common Mistakes
A few pool slip-ups trip up almost everyone at least once.
Never shutting down. Without shutdown(), the poolβs threads stay alive and the program hangs forever.
// β no shutdown, the program never exitsExecutorService pool = Executors.newFixedThreadPool(2);pool.execute(() -> System.out.println("done"));
// β
shut it down when finishedExecutorService pool = Executors.newFixedThreadPool(2);pool.execute(() -> System.out.println("done"));pool.shutdown();Picking the wrong pool type or size. A pool of thousands of threads throws away the whole benefit, and a single-thread pool when you wanted parallel work makes everything slow and serial.
// β a giant pool can swamp the machineExecutorService pool = Executors.newFixedThreadPool(5000);
// β
size it to the cores and the kind of workint cores = Runtime.getRuntime().availableProcessors();ExecutorService pool = Executors.newFixedThreadPool(cores);Using an unbounded cached pool for slow work. A cached pool has no cap on threads. Feed it slow tasks and it keeps making threads until the machine struggles.
// β slow tasks on a cached pool can create endless threadsExecutorService pool = Executors.newCachedThreadPool();for (int i = 0; i < 100000; i++) { pool.execute(() -> slowNetworkCall()); // each one ties up a thread}
// β
a fixed pool caps the thread count for slow workExecutorService pool = Executors.newFixedThreadPool(20);for (int i = 0; i < 100000; i++) { pool.execute(() -> slowNetworkCall());}β Best Practices
Habits that keep pool code clean and safe.
- Hold the interface, not the pool class. Declare the variable as
ExecutorServiceso you can swap pool types with one line. - Match the pool to the work. Fixed for steady work, single for ordered tasks, cached for many short ones, scheduled for timers.
- Always shut down. Call
shutdown()when done, and useawaitTermination()when you need to wait for the work to finish. - Cap threads for slow tasks. Prefer a fixed pool over a cached one whenever tasks can run long.
- Size fixed pools deliberately. Tie the count to your cores and the kind of work, then measure and adjust.
- Always stop scheduled tasks. A repeating task ticks forever until you shut the scheduler down.
π§© What Youβve Learned
Nicely done. Letβs recap the pool types and how to use them.
- β
ExecutorServiceis the interface you talk to; hold the interface type so you can swap pools easily. - β
The
Executorsfactory builds the pools: fixed, cached, single-thread, scheduled, and virtual-thread-per-task on modern Java. - β
Use
executefor fire-and-forget work andsubmitwhen you want aFutureback. - β
A
ScheduledExecutorServiceruns tasks after a delay withscheduleor on a timer withscheduleAtFixedRate. - β
Always
shutdown()the pool, optionally withawaitTermination(), or the program may never end.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why declare the variable as ExecutorService instead of the concrete pool class?
Why: Holding the interface type lets you change the pool type on the right-hand side without touching the rest of your code.
- 2
Which pool has no built-in cap on the number of threads?
Why: A cached pool keeps making new threads when all current ones are busy, so it has no fixed limit and is risky for slow tasks.
- 3
Which method runs a task again and again on a timer?
Why: scheduleAtFixedRate on a ScheduledExecutorService repeats the task every period until you stop the scheduler.
- 4
What does shutdown() do compared to shutdownNow()?
Why: shutdown() is the polite stop that lets submitted tasks finish, while shutdownNow() tries to stop right away and drops queued tasks.
π Whatβs Next?
You have seen submit return a Future, but we only touched it lightly. Often you want a task to return a result you can wait for and read. For that, Java gives you Callable and Future. Letβs dig into them next.