Chaturmind
LearnDSASystem DesignInterview PrepDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth
  • Java Interview Prep

Company

  • About
  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

👥10,044visitors

Built for engineers who want to go deep.


← Java Concurrency & Multithreading

Threads & Runnable

  • Introduction to Threads
  • ExecutorService & Thread Pools
  • Fork/Join Framework

Synchronization & Memory Model

  • synchronized and Locks
  • volatile and the Memory Model
  • CompletableFuture
  • Deadlock, Starvation, Livelock

Concurrent Collections

  • ConcurrentHashMap & CopyOnWriteArrayList
  • HashMap Concurrency Variants
  • Concurrent Utilities & Coordination
Chaturmind
← Java Concurrency & Multithreading

Threads & Runnable

  • Introduction to Threads
  • ExecutorService & Thread Pools
  • Fork/Join Framework

Synchronization & Memory Model

  • synchronized and Locks
  • volatile and the Memory Model
  • CompletableFuture
  • Deadlock, Starvation, Livelock

Concurrent Collections

  • ConcurrentHashMap & CopyOnWriteArrayList
  • HashMap Concurrency Variants
  • Concurrent Utilities & Coordination
HomeLearnJavaJava Concurrency & MultithreadingThreads & Runnable
✓ FreeIntermediate· 6 min read

ExecutorService & Thread Pools

Stop creating raw threads — use ExecutorService and thread pools correctly.

Published September 21, 2026


ExecutorService & Thread Pools

Creating a new Thread() for every task works in a demo, and fails in a server. Platform threads are expensive: each one reserves a stack (often around 1 MB), costs a system call to create, and adds scheduling work for the OS. Ten thousand simultaneous requests would mean ten thousand threads, with memory pressure, context-switch overhead, and eventually OutOfMemoryError: unable to create native thread.

A thread pool keeps a bounded set of worker threads alive and feeds them tasks from a queue. Java's ExecutorService is the standard interface for that. It separates what to run (tasks) from how it runs (the number of threads, queueing and rejection policy).

Advertisement

Basic usage

ExecutorService pool = Executors.newFixedThreadPool(8);

Future<Integer> future = pool.submit(() -> computePrice(orderId));   // Callable: returns a value
pool.execute(() -> sendAuditEvent(orderId));                          // Runnable: fire and forget

Integer price = future.get(2, TimeUnit.SECONDS);   // blocks until done, or throws TimeoutException

pool.shutdown();                                    // stop accepting new tasks; finish queued ones
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
    pool.shutdownNow();                             // interrupt running tasks
}
  • submit returns a Future: get() waits for the result. If the task threw an exception, get() rethrows it wrapped in an ExecutionException.
  • Exceptions from execute() tasks go to the thread's uncaught-exception handler. With submit(), they're captured in the Future, and silently lost if nobody ever calls get(). That's a classic source of "the job just stopped" bugs.
  • Since Java 19, ExecutorService implements AutoCloseable, so try-with-resources calls close(), which shuts down and waits for tasks.

Running many tasks together

List<Callable<Quote>> calls = suppliers.stream()
        .<Callable<Quote>>map(s -> () -> s.quote(item))
        .toList();

List<Future<Quote>> results = pool.invokeAll(calls, 3, TimeUnit.SECONDS); // all, with an overall timeout
Quote first = pool.invokeAny(calls);                                      // the first successful result

For composing asynchronous steps (then-apply, combine, handle errors), use CompletableFuture on top of an executor. See CompletableFuture.

What's inside: ThreadPoolExecutor

The Executors factory methods are shortcuts for configuring ThreadPoolExecutor:

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        8,                                   // corePoolSize: threads kept alive
        16,                                  // maximumPoolSize
        60, TimeUnit.SECONDS,                // idle time before extra threads die
        new ArrayBlockingQueue<>(500),       // BOUNDED work queue
        namedThreadFactory("pricing-"),      // readable thread names in dumps and logs
        new ThreadPoolExecutor.CallerRunsPolicy()); // what to do when full

A new task is handled in this order:

  1. If fewer than core threads are running, start a new thread.
  2. Otherwise, put the task in the queue.
  3. If the queue is full, start extra threads, up to maximum.
  4. If that's also at its limit, apply the rejection policy.

Note the counter-intuitive consequence: with an unbounded queue, step 3 never happens, so maximumPoolSize is ignored.

Rejection policies:

  • AbortPolicy (the default) throws RejectedExecutionException.
  • CallerRunsPolicy runs the task on the submitting thread. That naturally slows producers down, which is a simple form of backpressure.
  • DiscardPolicy and DiscardOldestPolicy silently drop tasks. Use them only for work that's truly optional.

Why the Executors shortcuts can be dangerous

FactoryConfigurationRisk
newFixedThreadPool(n)n threads, unbounded LinkedBlockingQueueA slow downstream makes the queue grow without limit, until memory runs out
newCachedThreadPool()0..Integer.MAX_VALUE threads, no queueA burst creates thousands of threads
newSingleThreadExecutor()1 thread, unbounded queueSame unbounded-queue risk, but guarantees ordering
newScheduledThreadPool(n)Delayed and periodic tasksA periodic task that throws is silently cancelled unless you catch exceptions inside it

For production services, build ThreadPoolExecutor explicitly, with a bounded queue, named threads, and a deliberate rejection policy.

Sizing a pool

  • CPU-bound work (computation, compression): about the number of cores. More threads only add context switching.
  • I/O-bound work (HTTP calls, database queries): threads spend most of their time waiting, so you need more. A useful estimate is cores × (1 + wait time / compute time).
  • Real limits usually come from downstream. A pool of 200 threads calling a database with a 20-connection pool just queues on connections. Size pools to match the dependencies they call.
  • Keep separate pools for separate kinds of work (bulkheads), so a slow dependency can't consume every thread in the application.

Since Java 21, blocking I/O-heavy tasks can often use virtual threads instead of a sized pool: Executors.newVirtualThreadPerTaskExecutor(). See Virtual Threads.

Scheduling

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
scheduler.scheduleAtFixedRate(() -> {
    try { refreshExchangeRates(); }
    catch (Exception e) { log.error("rate refresh failed", e); }   // otherwise future runs stop silently
}, 0, 5, TimeUnit.MINUTES);
  • scheduleAtFixedRate aims for a steady rate. If a run overruns, the next one starts immediately after it; runs never overlap.
  • scheduleWithFixedDelay waits a fixed delay after each run finishes.

Shutting down properly

Pool threads are non-daemon by default, so a pool that's never shut down can keep the JVM running. In Spring applications, define executors as beans (for example ThreadPoolTaskExecutor), so the container shuts them down cleanly. In plain code, call shutdown() and awaitTermination() in a shutdown hook or finally block. Tasks should respond to interruption, so that shutdownNow() can actually stop them.

Follow-up questions this topic invites — and their answers

Q: What's the difference between execute() and submit()? A: execute(Runnable) returns nothing, and exceptions go to the uncaught-exception handler. submit() accepts a Runnable or Callable and returns a Future, which captures the result or exception. Unless someone calls get(), the exception is never seen.

Q: Why is an unbounded queue risky? A: If tasks arrive faster than they complete, the queue grows until memory runs out, and latency grows without limit in the meantime. A bounded queue with a rejection policy fails fast, or applies backpressure instead.

Q: How many threads should a pool have? A: About the number of cores for CPU-bound tasks. For I/O-bound tasks, scale by the ratio of wait to compute time, capped by downstream capacity such as database connections. Then measure and adjust.

Q: What's the difference between shutdown() and shutdownNow()? A: shutdown() stops accepting tasks but finishes queued and running ones. shutdownNow() also interrupts running tasks and returns the queued ones that never started. Use shutdown() with awaitTermination(), falling back to shutdownNow().

Q: Why did my scheduled task stop running? A: A periodic task that throws an exception is cancelled, and later runs are suppressed without any log message. Catch and log exceptions inside the scheduled task.

Advertisement

Previous

Introduction to Threads

Next

Fork/Join Framework

AI Tutor

Lesson: ExecutorService & Thread Pools

Quick actions

AI responses can be inaccurate. Verify critical information.