Create your own
Lesson illustration

Configuring Bounded Concurrency and Graceful Shutdown with ExecutorService

Hello. In the previous lesson, you used happens-before rules to reason about what worker threads can safely observe and why unsynchronized shared state fails. Now we move from individual threads to a practical backend policy: how much background work the service is willing to accept, and how it stops that work safely.

An ExecutorService is not merely a convenience for avoiding new Thread(...). In a production service, it is an admission-control boundary. A correct configuration limits active work, limits waiting work, makes overload visible, and gives the application a predictable shutdown path. Plan for about 40 minutes.


1. What “bounded concurrency” actually means

Suppose a Spring Boot service creates thumbnails after users upload images. If every upload creates its own OS thread, a traffic spike can create thousands of threads. Each thread consumes stack memory and scheduler time; eventually the application may spend more effort context-switching than doing useful work.

A thread pool replaces unbounded thread creation with a controlled set of reusable workers. Tasks that cannot run immediately may wait in a queue.

Java ExecutorService - Part 1 - Introduction

Watch “Java ExecutorService - Part 1 - Introduction” by Defog Tech for the worker-pool model and the first-pass distinction between CPU-bound and I/O-bound work.

Watch the pool model to see tasks waiting for reusable workers rather than creating one thread per task. Then watch CPU sizing and I/O sizing. Treat the suggested sizes as starting hypotheses to validate with load tests, not universal constants.

For an executor to be truly bounded, constrain both of these quantities:

BoundWhat it limitsWhy it matters
Maximum worker threadsTasks executing concurrently in pool threadsBounds CPU scheduling overhead and concurrent pressure on dependencies.
Queue capacityTasks waiting for a workerBounds memory usage and, more importantly, bounds how long accepted tasks can sit in line.

If the pool has 4 maximum workers and a queue capacity of 100, then at most roughly 104 submitted tasks can be admitted by that executor at a time: up to 4 running and 100 waiting. Once that capacity is exhausted, the system must apply an explicit rejection policy.

That rejection is not necessarily a bug. For a request-triggered operation, rejecting quickly with an overload response is often safer than accepting unlimited work, exhausting heap memory, and causing every request to time out.

One common trap is:

ExecutorService pool = Executors.newFixedThreadPool(4);

This limits the number of worker threads, but the standard fixed-thread-pool factory uses an effectively unbounded queue. Under sustained overload, active concurrency remains 4 while queued tasks and memory consumption can continue growing. It is therefore not a complete bounded-work policy.


2. How ThreadPoolExecutor admits a task

ThreadPoolExecutor gives you direct control over thread bounds, queue capacity, and rejection behavior. Its admission order is important:

  1. While the number of workers is below corePoolSize, a submitted task starts a new worker.
  2. Once core workers exist, additional tasks are offered to the queue.
  3. If the queue is full and the current number of workers is below maximumPoolSize, the executor creates another worker for the new task.
  4. If the queue is full and the executor is already at its maximum size, the task is rejected.

Read the API’s class-level discussion before writing configurations from memory.

ThreadPoolExecutor (Java Platform SE 8 )

Read Oracle’s ThreadPoolExecutor documentation for the exact relationship among core size, maximum size, queue selection, and rejected tasks. This is the behavioral contract behind the configuration you will use.

In the class-level discussion, read the sections “Core and maximum pool sizes,” “Queuing,” and “Rejected tasks” in that order. In the first section, focus on core and maximum interaction: extra threads beyond the core size are created only after a queue offer fails. In “Queuing,” study bounded queues, especially the trade-off between queue length and worker count. Finally, in “Rejected tasks,” read AbortPolicy behavior and compare it with the other built-in policies.

This flowchart depicts how a submitted task first creates core workers, then enters a queue, then may trigger additional workers up to the maximum, and is finally rejected when both the queue and worker limit are full.

The ordering has a consequence that often surprises candidates in interviews:

maximumPoolSize does not automatically mean “create up to this many threads whenever traffic rises.”

With a large queue, the executor will usually queue work once it has core workers. It creates workers above the core size only when the queue is already full. With an unbounded queue, maximumPoolSize effectively becomes irrelevant.

A deliberate backend configuration

For a background job where you want a fixed concurrency limit and a finite backlog, make core and maximum size equal:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public final class ThumbnailWorkPool implements AutoCloseable {

    private static final int WORKERS = 4;
    private static final int QUEUE_CAPACITY = 100;

    private final ThreadPoolExecutor executor =
            new ThreadPoolExecutor(
                    WORKERS,
                    WORKERS,
                    0L,
                    TimeUnit.MILLISECONDS,
                    new ArrayBlockingQueue<>(QUEUE_CAPACITY),
                    Executors.defaultThreadFactory(),
                    new ThreadPoolExecutor.AbortPolicy()
            );

    public void submit(ThumbnailRequest request) {
        try {
            executor.execute(new GenerateThumbnailTask(request));
        } catch (RejectedExecutionException rejected) {
            throw new WorkOverloadedException(
                    "Thumbnail work queue is full", rejected);
        }
    }

    public int activeWorkers() {
        return executor.getActiveCount();
    }

    public int queuedTasks() {
        return executor.getQueue().size();
    }

    @Override
    public void close() {
        ExecutorShutdown.gracefullyStop(
                executor,
                20,
                TimeUnit.SECONDS,
                5,
                TimeUnit.SECONDS
        );
    }
}

This configuration has a simple operational story:

  • At most 4 thumbnail jobs run in executor workers at once.
  • At most 100 more jobs wait in memory.
  • When all workers are busy and the queue is full, AbortPolicy throws RejectedExecutionException in the submitting thread.
  • The transport layer can translate WorkOverloadedException into an appropriate overload response, commonly an HTTP 503 for synchronous request-triggered work.
  • The worker count cannot silently expand under load.

execute is appropriate here because the caller is not expecting a return value from each thumbnail task. If you use submit, task failures are captured in a Future; they become visible only if something later observes that future. In either case, saturation and shutdown can reject submissions.

The prior lesson’s memory-visibility ideas still apply. The ExecutorService contract guarantees that actions before task submission happen-before actions inside the submitted task. That safely publishes the initial task state. It does not make it safe to keep mutating a shared object after handing it to a task. Prefer immutable task inputs such as a record, an ID, or a copied command object.


3. Pool size, queue size, and rejection are one design decision

There is no globally correct pool size. The right configuration depends on the work and on the dependencies that work consumes.

For CPU-bound work, such as image transforms, hashing, or model-feature calculations, begin near the CPU capacity allocated to the process:

int cpuWorkers = Runtime.getRuntime().availableProcessors();

Treat that as a starting point. A container may share CPU with other workloads, and real code may be partly blocked on memory, I/O, or downstream calls. Measure throughput, latency, and CPU saturation before increasing it.

For I/O-bound work, such as calling a remote service or database, workers spend time waiting. A larger pool may be justified, but it must still respect downstream limits:

  • If the database connection pool has 20 connections, configuring 200 database-task workers does not create 200 useful concurrent database operations.
  • If an HTTP dependency is slow, more waiting workers can increase memory usage and queue delay without improving completed work.
  • Each blocking remote call needs timeouts; otherwise shutdown and overload recovery become unpredictable.

A useful default is to use separate executors for separate resource classes. For example, do not let slow report generation consume the same worker pool used for payment-related jobs. This is a practical bulkhead: one overloaded class of work cannot consume every worker allocated to another.

Choose rejection behavior deliberately

The queue and pool limits determine when rejection occurs. The RejectedExecutionHandler determines what happens next.

PolicyBehavior at saturationTypical use and risk
AbortPolicyThrows RejectedExecutionExceptionStrong default for request-driven backend work. The caller can return an explicit overload error and emit a metric.
CallerRunsPolicyThe submitting thread runs the taskCan provide backpressure, but may block a request thread, servlet thread, scheduler, or event-loop thread. Use only when that trade-off is intentional.
DiscardPolicySilently drops the taskDangerous for commands, payments, notifications, or anything requiring a record of loss.
DiscardOldestPolicyRemoves the oldest queued task and retries the new oneSuitable only for explicitly “latest value wins” work, such as stale UI refreshes; it can violate ordering expectations.

For the thumbnail example, AbortPolicy is usually easier to reason about. The work is either accepted within a known bound or clearly refused. Silent drops would make a user upload appear successful while its thumbnail never appears.

The pool should also be observable. At minimum, export or log:

  • active worker count;
  • pool size;
  • queue size and remaining queue capacity;
  • completed task count;
  • rejection count;
  • task execution duration and queue-wait duration, if you wrap tasks to measure them.

A queue that stays near capacity is not merely “busy.” It indicates that arrival rate is repeatedly exceeding completion rate, which usually means you need load shedding, a capacity change, a dependency fix, or a different asynchronous workflow.


4. Graceful shutdown is a two-phase protocol

Creating an executor is only half of lifecycle management. An application that forgets to shut down executors can retain threads and prevent clean process termination. More subtly, calling shutdown() without waiting does not ensure jobs have finished before the application exits.

Read the ExecutorService lifecycle contract and Oracle’s shutdown helper.

ExecutorService (Java SE 21 & JDK 21)

Read Oracle’s ExecutorService documentation for the difference between orderly shutdown, forced shutdown, termination, and interruption handling.

At the top of the interface documentation, read the lifecycle contract. Then find the shutdownAndAwaitTermination example immediately below the try-with-resources example and read the entire helper method. Within it, use the two waiting phases to locate the fallback from orderly shutdown to forced shutdown. Finish by reading the “Memory consistency effects” paragraph and the method details for shutdown, shutdownNow, and awaitTermination.

The executor lifecycle has four relevant states:

StateNew submissionsQueued tasksRunning tasks
RunningAccepted if capacity permitsMay waitContinue
After shutdown()RejectedAllowed to runAllowed to finish
After shutdownNow()RejectedReturned to the caller as never-started tasksInterruption is requested; stopping is best effort
TerminatedRejectedNoneNone

The important distinctions are:

  • shutdown() is an orderly shutdown request. It stops new submissions but lets queued and already-running tasks proceed.
  • shutdownNow() prevents waiting tasks from starting and interrupts running workers. It does not forcibly kill a thread.
  • awaitTermination(...) waits for the executor to reach the terminated state.
  • If a task ignores interruption forever, shutdownNow() cannot guarantee termination.

A reusable shutdown helper makes the desired policy explicit:

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

public final class ExecutorShutdown {

    private ExecutorShutdown() {
    }

    public static void gracefullyStop(
            ExecutorService executor,
            long gracefulTimeout,
            TimeUnit gracefulUnit,
            long forcedTimeout,
            TimeUnit forcedUnit
    ) {
        executor.shutdown();

        try {
            boolean terminated = executor.awaitTermination(
                    gracefulTimeout, gracefulUnit);

            if (!terminated) {
                List<Runnable> neverStarted = executor.shutdownNow();

                // Log or account for neverStarted according to business policy.
                // Do not silently assume these tasks completed.

                terminated = executor.awaitTermination(
                        forcedTimeout, forcedUnit);

                if (!terminated) {
                    System.err.println("Executor did not terminate");
                }
            }
        } catch (InterruptedException interrupted) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

This code follows a deliberate policy:

  1. Request an orderly shutdown.
  2. Wait a bounded amount of time for accepted work to finish.
  3. Escalate only if necessary.
  4. Wait again for tasks to respond to interruption.
  5. If the shutdown-coordinating thread itself is interrupted, request cancellation and restore its interrupt status.

Restoring the interrupt status is not ceremony. Higher-level code may need to detect that interruption and stop its own work. Catching InterruptedException and continuing normally loses that cancellation signal.

Your tasks must cooperate

A shutdown policy works only when tasks are written to cooperate with cancellation. In particular:

  • Do not swallow InterruptedException.
  • If a blocking method throws InterruptedException, usually restore the interrupt flag and return after any essential cleanup.
  • Use read, connect, and request timeouts for database and HTTP calls. Interrupts alone may not promptly stop every library call.
  • Make side-effecting tasks idempotent where possible. A task may partially complete before shutdown begins.

For a task that blocks in an interruptible operation, the basic pattern is:

public void run() {
    try {
        performInterruptibleWork();
    } catch (InterruptedException interrupted) {
        Thread.currentThread().interrupt();
        return;
    }
}

The correct cleanup behavior depends on the domain. A task that owns a temporary file should clean it up. A task that might have sent an external command should record enough state to determine whether retrying is safe. Those are application-level guarantees; an executor cannot invent them for you.

In Spring Boot, make the executor a lifecycle-managed bean or close an owning component from a shutdown hook such as @PreDestroy. Also ensure the service stops accepting new upstream work during its broader shutdown sequence. There is always a race between a final request arriving and the executor closing, so submission code must handle RejectedExecutionException even during normal deployment termination.


5. Interview explanation and implementation checklist

For a backend interview, avoid saying only, “I would use a fixed thread pool.” Explain the resource policy:

“I would use a ThreadPoolExecutor with a finite maximum worker count and a bounded ArrayBlockingQueue. That bounds concurrent execution and queued memory. On saturation, I would use AbortPolicy and translate rejection into an explicit overload outcome rather than silently dropping work. During shutdown, I would stop new submissions with shutdown(), wait for a bounded grace period, then call shutdownNow() only as a fallback, with tasks designed to respect interruption and external-call timeouts.”

Before considering an executor configuration complete, verify:

  • Workload: Is it CPU-bound, I/O-bound, or mixed?
  • Concurrency: What maximum simultaneous work can the service and its dependencies safely support?
  • Backlog: How many waiting tasks are acceptable before latency becomes useless?
  • Overload behavior: Does rejection return an error, apply controlled backpressure, or persist work elsewhere?
  • Isolation: Could one slow workload starve another workload sharing this pool?
  • Shutdown: Does the owner call shutdown() and awaitTermination()?
  • Cancellation: Do tasks honor interruption and use dependency timeouts?
  • Observability: Can operators see queue pressure and rejections before the service fails?

Key takeaways

  • Bounded concurrency requires a finite worker limit and a finite queue capacity.
  • Use ThreadPoolExecutor directly when you need explicit queue and rejection behavior; a fixed thread pool with an unbounded queue does not fully bound admitted work.
  • corePoolSize, maximumPoolSize, and queue type interact: additional non-core workers are created only after the queue cannot accept a task.
  • For request-triggered backend work, AbortPolicy is often the clearest overload behavior because it makes rejection explicit.
  • shutdown() rejects new tasks while allowing accepted work to finish; shutdownNow() is a best-effort escalation based on interruption.
  • A graceful shutdown needs awaitTermination() and tasks that cooperate with interruption.
  • Monitor queue occupancy and rejection count: sustained queue pressure is a capacity or dependency signal, not merely a normal busy state.

Next, you will use CompletableFuture to compose asynchronous operations and handle their failure paths. The bounded executor from this lesson will provide the controlled execution environment for that asynchronous work.

Can't find a good explanation? Sign up and we'll make it for you

Sign up