What Is Java ExecutorService?

A Java ExecutorService (java.util.concurrent.ExecutorService) manages a pool of threads and runs the tasks you hand it, returning a Future for each one so you can collect the result later.

It exists because creating threads directly does not scale. Every new Thread() costs memory and scheduling overhead, nothing limits how many you create, and once started a thread offers no way to retrieve what it computed. An ExecutorService separates what work needs doing from which thread does it: you submit tasks, the pool decides when and where they run.

ExecutorService pool = Executors.newFixedThreadPool(8);

Future<Report> result = pool.submit(() -> buildReport(month));

Executor, ExecutorService, and ThreadPoolExecutor

These names get used interchangeably in conversation and mean quite different things.

TypeWhat it is
ExecutorAn interface with a single method, execute(Runnable). It says only that a task will be run somewhere.
ExecutorServiceAn interface extending Executor, adding result-bearing submission (submit, invokeAll, invokeAny) and lifecycle control (shutdown, awaitTermination).
ScheduledExecutorServiceAn interface extending ExecutorService with delayed and periodic execution.
ThreadPoolExecutorA concrete class implementing ExecutorService — the actual pool, with tunable sizes, queue, and rejection policy.

Executors is separate from all of them: a utility class of static factory methods that build preconfigured instances. So Executors (plural, a factory) produces an ExecutorService (an interface) which is usually a ThreadPoolExecutor (the implementation) underneath.

The practical distinction between Executor and ExecutorService is that Executor gives you no handle on the work. You cannot get the result, cannot cancel it, and cannot tell when it has finished. ExecutorService is the one you almost always want.

Creating an ExecutorService

The Executors factory covers the common shapes:

// n threads, tasks queue up behind them
ExecutorService fixed = Executors.newFixedThreadPool(8);

// threads created on demand, idle ones reclaimed after 60 seconds
ExecutorService cached = Executors.newCachedThreadPool();

// one thread, tasks run strictly in order
ExecutorService single = Executors.newSingleThreadExecutor();

// work-stealing pool backed by ForkJoinPool (Java 8+)
ExecutorService stealing = Executors.newWorkStealingPool();

// a fresh virtual thread per task, no pooling (Java 21+)
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();

Two of these carry a hazard worth knowing before you reach for them. newFixedThreadPool queues work in an unbounded LinkedBlockingQueue, so if tasks arrive faster than the pool drains them, the queue grows until the heap runs out. newCachedThreadPool has the mirror-image problem: it hands each task straight to a thread through a SynchronousQueue that holds nothing, and its maximum pool size is Integer.MAX_VALUE, so a burst of slow tasks can spawn thousands of threads.

Neither fails loudly. Both degrade into an out-of-memory error or thread exhaustion well after the actual mistake. That is why production code often skips the factory and constructs the pool directly, where every limit is explicit:

ExecutorService pool = new ThreadPoolExecutor(
        4, 16,                              // core and maximum pool size
        60L, TimeUnit.SECONDS,              // idle keep-alive for non-core threads
        new ArrayBlockingQueue<>(500),      // bounded queue
        new ThreadPoolExecutor.CallerRunsPolicy());

The bounded queue means the pool can reject work, and the rejection policy decides what happens then. CallerRunsPolicy runs the task on the submitting thread, which slows the producer down instead of dropping the task — a crude but effective backpressure valve.

One behaviour here surprises almost everyone: threads beyond the core size are only created once the queue is full. With the settings above, the pool runs on four threads until 500 tasks are backed up, and only then grows toward sixteen. A large queue paired with a large maximum quietly means the extra threads may never appear.

How Many Threads?

There is no universal number, but the two ends of the range are well understood. CPU-bound work saturates at roughly the number of available cores, so Runtime.getRuntime().availableProcessors() is a sensible starting point — beyond that, more threads simply add context switching. I/O-bound work spends most of its time waiting, so the useful pool is larger, bounded in practice by whatever resource sits at the other end rather than by the CPU.

Treat any formula as a starting hypothesis and measure. And note that on Java 21 the question can often be sidestepped entirely, as the virtual threads section below explains.

submit() vs execute()

Both hand a task to the pool. The difference is what comes back, and what happens when the task fails.

execute(Runnable)submit(Runnable or Callable)
ReturnsvoidFuture<T>
AcceptsRunnable onlyRunnable or Callable<T>
On exceptionPropagates to the thread's UncaughtExceptionHandlerCaptured inside the Future
// fire and forget; a failure surfaces through the uncaught handler
pool.execute(() -> sendMetrics());

// returns a handle; a failure is stored, not thrown
Future<Report> future = pool.submit(() -> buildReport(month));
Report report = future.get();   // throws ExecutionException if the task failed

That last row is the trap. An exception thrown inside a submited task does not reach any handler, does not appear in your logs, and does not stop anything. It is packaged into the Future and waits there:

pool.submit(() -> { throw new IllegalStateException("boom"); });
// Silence. The exception lives in a Future nobody holds a reference to.

If you submit fire-and-forget work, either use execute, or keep the Future and check it. Tasks that swallow their own failures are one of the most common sources of "the job just stopped running and nobody noticed."

invokeAll and invokeAny

For bulk work, ExecutorService offers two blocking helpers.

invokeAll submits a collection of tasks and waits for every one to finish. When it returns, each Future in the list is already complete, so get() will not block.

List<Callable<Quote>> tasks = List.of(
        () -> quote("AAPL"), () -> quote("MSFT"), () -> quote("GOOG"));

List<Future<Quote>> results = pool.invokeAll(tasks);

for (Future<Quote> f : results) {
    render(f.get());
}

invokeAny is the opposite: it returns the result of the first task to succeed and cancels the rest. It suits redundant lookups against several mirrors, where any one answer will do. If every task fails, it throws ExecutionException.

Both accept a timeout overload, and both block the calling thread — which is fine in a batch job and wrong inside a request handler.

Scheduling with ScheduledExecutorService

ScheduledExecutorService extends ExecutorService with delayed and repeating execution, obtained from Executors.newScheduledThreadPool(n).

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

// once, after a delay
scheduler.schedule(() -> expireSessions(), 10, TimeUnit.MINUTES);

// every 60 seconds, measured start-to-start
scheduler.scheduleAtFixedRate(() -> publishMetrics(), 0, 60, TimeUnit.SECONDS);

// 60 seconds after each run finishes
scheduler.scheduleWithFixedDelay(() -> compact(), 0, 60, TimeUnit.SECONDS);

The distinction between the last two matters more than it looks. scheduleAtFixedRate measures the interval between successive starts, so if a run overruns its period, later runs begin late and follow one another with no gap — they are never allowed to overlap. scheduleWithFixedDelay measures from the end of one run to the start of the next, which keeps a fixed gap regardless of how long the work takes. Use fixed delay unless you specifically need a steady cadence.

One sharp edge: if a repeating task throws an uncaught exception, the schedule is cancelled silently and never runs again. Wrap the body in a try/catch if the job needs to survive its own failures.

Shutting It Down Properly

A platform-thread pool created by the Executors factory uses non-daemon threads, and non-daemon threads keep the JVM alive. Forget to shut one down and your application will not exit; it simply hangs after main returns, with no error to explain why. (The virtual-thread executor is the exception — virtual threads are always daemon threads, so it will not hold the JVM open. It still needs closing, to wait for the tasks themselves.)

There are three lifecycle methods and they do different things:

  • shutdown() stops the pool accepting new work but lets everything already submitted finish. It returns immediately without waiting.
  • shutdownNow() attempts to stop running tasks by interrupting them, skips anything still queued, and returns the list of tasks that never started.
  • awaitTermination(timeout, unit) blocks until the pool has finished terminating, the timeout expires, or the caller is interrupted.

The pattern the Javadoc recommends combines all three — ask nicely, then insist:

pool.shutdown();
try {
    if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
        pool.shutdownNow();
        if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
            log.error("Pool did not terminate");
        }
    }
} catch (InterruptedException e) {
    pool.shutdownNow();
    Thread.currentThread().interrupt();
}

Note that shutdownNow interrupts threads; it cannot force a task to stop. A task that never checks Thread.currentThread().isInterrupted() and never calls a blocking method will run to completion regardless. Cooperative cancellation is the task's responsibility.

Since Java 19, ExecutorService extends AutoCloseable, and close() calls shutdown() then waits for termination. For a pool whose lifetime matches a block of code, try-with-resources replaces the whole ceremony:

try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
    for (Request r : batch) {
        pool.submit(() -> handle(r));
    }
}   // close() blocks here until every task has finished

One caveat: this only makes sense for short-lived pools. A long-lived application-wide executor wrapped in try-with-resources will be closed the moment the block exits, which is almost never what you want.

ExecutorService vs CompletableFuture

They are complements, not competitors. An ExecutorService supplies and manages threads; a CompletableFuture describes what happens to a result once it exists.

The reason to prefer CompletableFuture.supplyAsync(task, pool) over pool.submit(task) is the return type. submit() gives you a plain Future, which can only be blocked on. supplyAsync with your executor gives the same thread-pool control plus a result you can chain, combine, and attach error handling to.

ExecutorService vs ForkJoinPool

ForkJoinPool is itself an ExecutorService, so this is a comparison of implementations rather than of interfaces.

A conventional ThreadPoolExecutor feeds every worker from one shared queue. That is the right shape for independent tasks, especially tasks that block on I/O. ForkJoinPool gives each worker its own deque and lets idle workers steal from the tail of another's, which suits recursive divide-and-conquer work where tasks spawn subtasks — the RecursiveTask and RecursiveAction model. Its common pool also backs parallel streams and the default async execution of CompletableFuture.

The rule of thumb: fork/join for CPU-bound work that splits, a plain pool for independent tasks that wait.

ExecutorService and Virtual Threads

Java 21 changed the calculus. Executors.newVirtualThreadPerTaskExecutor() returns an ExecutorService that creates a brand new virtual thread for every task rather than reusing a pool.

That inversion is the point. Pooling exists because platform threads are expensive; virtual threads are cheap enough that pooling them is counterproductive, and pool sizing stops being a tuning lever at all. For workloads dominated by blocking I/O, this often removes the sizing question entirely.

It does not make pools obsolete. Virtual threads do not add CPU, so genuinely compute-bound work still wants a bounded platform pool sized to the cores available. And a bounded pool remains a useful way to limit concurrency against a resource that cannot take unlimited load — a database with a connection cap, or a rate-limited API.

Distributed Task Execution with Valkey and Redis

Every executor described so far is confined to one JVM. Submit a task and it runs on a thread in that process; if the process dies, queued work dies with it. Once an application runs on more than one node, that becomes a real limitation — you cannot submit work from one instance and have another pick it up.

Redisson addresses this with RExecutorService, which implements java.util.concurrent.ExecutorService over Valkey or Redis. The interface is the one this page has just described; the pool spans machines.

RedissonClient redisson = Redisson.create(config);
RExecutorService executor = redisson.getExecutorService("myExecutor");

RExecutorFuture<Long> future = executor.submit(new SumTask());
Long total = future.get();

Tasks and their results are serialized with the configured codec and held in request and response queues. Workers registered on any node poll from the head of that queue in submission order, so adding capacity is a matter of starting another worker:

executor.registerWorkers(WorkerOptions.defaults().workers(4));

Each task receives a globally unique 128-bit id, and executor.cancelTask(taskId) cancels it across the cluster rather than only locally. Cancellation is still cooperative, exactly as it is with a local pool — the task has to notice:

public class SumTask implements Callable<Long> {

    @RInject
    private RedissonClient redisson;

    @Override
    public Long call() {
        RMap<String, Integer> map = redisson.getMap("myMap");
        long total = 0;
        for (Integer value : map.values()) {
            if (Thread.currentThread().isInterrupted()) {
                return null;    // the task was cancelled
            }
            total += value;
        }
        return total;
    }
}

The @RInject annotation supplies the RedissonClient to the task once it lands on a worker, so a task can operate on the same distributed data structures it was dispatched over.

Management operations have asynchronous counterparts that return an RFuture, which is a CompletionStage and therefore composes with the CompletableFuture toolkit:

executor.cancelTaskAsync(taskId)
        .thenAccept(cancelled -> log.info("cancelled: {}", cancelled));

For scheduled work, RScheduledExecutorService implements ScheduledExecutorService and adds cron support in the Quartz format, which is useful when a periodic job must run once across the cluster rather than once per instance:

RScheduledExecutorService scheduler = redisson.getExecutorService("maintenance");

// CleanupTask implements Runnable
scheduler.schedule(new CleanupTask(), CronSchedule.dailyAtHourAndMinute(3, 0));

Full details are in the distributed services documentation.

Frequently Asked Questions

What Is the Use of ExecutorService in Java?

It manages a pool of reusable threads so your code submits tasks instead of creating threads. That caps concurrency, removes per-task thread creation cost, and returns a Future for each task so results and failures can be collected.

Is It Necessary to Shut Down Executor Services?

Yes, for any pool you create yourself. Platform-thread pools from the Executors factory use non-daemon threads, which keep the JVM running after main returns — the application appears to hang. Call shutdown() followed by awaitTermination(), or use try-with-resources on Java 19 and later. Virtual-thread executors will not hold the JVM open, but should still be closed so their tasks are allowed to finish.

What Is the Difference Between Executor and ExecutorService?

Executor declares one method, execute(Runnable), and gives you no handle on the submitted work. ExecutorService extends it with submit(), which returns a Future you can wait on or cancel, plus bulk submission and shutdown control. In practice ExecutorService is the interface you use.

What Is an Executor Service?

It is an interface, java.util.concurrent.ExecutorService, not a class. It describes something that accepts tasks and runs them on threads it owns. The concrete object behind it is usually a ThreadPoolExecutor, a ForkJoinPool, or on Java 21 a virtual-thread-per-task executor — all built through the Executors factory.