Backend Development 28 min read

Master Java Thread Pools: Boost Performance and Avoid Resource Pitfalls

This article explains why creating a thread for each task is inefficient, introduces thread pools as a solution, compares execution times with code examples, details ThreadPoolExecutor's core interfaces, constructors, execution flow, rejection policies, state transitions, and provides practical usage patterns and best‑practice recommendations for Java backend development.

macrozheng
macrozheng
macrozheng
Master Java Thread Pools: Boost Performance and Avoid Resource Pitfalls

01 Background Introduction

Although Java provides extensive support for thread creation, interruption, waiting, notification, destruction, and synchronization, creating and destroying threads frequently consumes significant time and resources from the operating system perspective.

When many tasks need to be processed simultaneously, a naive model creates one thread per task, which works for a few tasks but quickly leads to problems as the number of tasks grows:

Thread count becomes uncontrollable, making unified management impossible.

System overhead becomes huge; excessive thread creation can exhaust resources and even cause the system to crash.

Using a pool of threads to execute many tasks—known as a thread pool —solves these issues.

02 Thread Pool Overview

A thread pool maintains a set of reusable threads. Idle threads wait for tasks; when a new task arrives, an idle thread is assigned. If all threads are busy, the task is queued, a new thread may be created, or the task may be rejected.

The advantages are clear:

Resources become controllable, preventing resource exhaustion.

Resource consumption is lower because threads are reused, reducing creation and destruction overhead.

Execution efficiency improves, as tasks can start immediately without waiting for thread creation.

For a practical open‑source example, the mall project is a SpringBoot3 + Vue e‑commerce system (GitHub ★60K) that demonstrates these concepts.

Below is a simple comparison:

<code>/**
 * One task per thread
 */
public static void main(String[] args) {
    long startTime = System.currentTimeMillis();
    final Random random = new Random();
    List<Integer> list = new CopyOnWriteArrayList<>();
    // 20,000 threads, each adding a random number
    for (int i = 0; i < 20000; i++) {
        new Thread(() -> list.add(random.nextInt(100))).start();
    }
    while (list.size() < 20000) {}
    System.out.println("One task per thread time: " + (System.currentTimeMillis() - startTime) + "ms");
}
/**
 * Using a thread pool (4 threads)
 */
public static void main(String[] args) {
    long startTime = System.currentTimeMillis();
    final Random random = new Random();
    List<Integer> list = new CopyOnWriteArrayList<>();
    ThreadPoolExecutor executor = new ThreadPoolExecutor(4, 4, 60, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(20000));
    for (int i = 0; i < 20000; i++) {
        executor.submit(() -> list.add(random.nextInt(100)));
    }
    while (list.size() < 20000) {}
    System.out.println("Thread pool time: " + (System.currentTimeMillis() - startTime) + "ms");
    executor.shutdown();
}
</code>

Results:

<code>One task per thread time: 3073ms
---------------------------
Thread pool time: 578ms
</code>

The dramatic speedup demonstrates why thread pools are essential.

02.1 Thread Pool Core Interfaces

In Java, the top‑level interface is

Executor

. Key implementations include:

Executor

: abstracts task execution from thread creation.

ExecutorService

: extends

Executor</b> with lifecycle management methods such as shutdown and task status queries.</li><li><code>ThreadPoolExecutor

: the core class that actually creates and manages the pool.

ScheduledThreadPoolExecutor

: supports timed and periodic task execution.

2.1 ThreadPoolExecutor Constructor

The constructor takes seven parameters; the most important are corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, and handler. Example core source:

<code>public ThreadPoolExecutor(int corePoolSize,
                           int maximumPoolSize,
                           long keepAliveTime,
                           TimeUnit unit,
                           BlockingQueue<Runnable> workQueue,
                           ThreadFactory threadFactory,
                           RejectedExecutionHandler handler) { ... }
</code>

Parameter meanings:

corePoolSize: number of core threads that stay alive.

maximumPoolSize: maximum allowed threads.

keepAliveTime: idle thread survival time (effective only when thread count exceeds corePoolSize).

unit: time unit for keepAliveTime.

workQueue: queue that holds pending tasks.

threadFactory: creates new threads.

handler: strategy for rejected tasks.

2.2 Execution Flow

When a task is submitted, the pool follows these steps:

If the current thread count is less than corePoolSize, a new thread is created immediately.

Otherwise, the task is offered to the workQueue.

If the queue is full, the pool tries to create a new thread (up to maximumPoolSize).

If both the queue and thread count are saturated, the configured

RejectedExecutionHandler

is invoked.

Key methods:

execute(Runnable)

: core task submission method without a return value.

submit(Runnable/Callable)

: submits a task and returns a

Future

for result retrieval; internally it still calls

execute

.

2.3 Rejection Policies

AbortPolicy

: throws

RejectedExecutionException

(default).

DiscardPolicy

: silently discards the task.

DiscardOldestPolicy

: removes the oldest queued task and retries.

CallerRunsPolicy

: runs the task in the calling thread if the pool is shut down.

2.4 ThreadPoolExecutor States

The pool has five states:

RUNNING

: accepts new tasks and processes queued ones.

SHUTDOWN

: stops accepting new tasks but processes queued tasks.

STOP

: stops all processing and discards queued tasks.

TIDYING

: all tasks finished, awaiting termination.

TERMINATED

: final state after termination.

Transition examples:

Calling

shutdown()

moves from RUNNING to SHUTDOWN.

Calling

shutdownNow()

moves to STOP.

When both thread count and queue are empty, the pool moves to TIDYING, then to TERMINATED after

terminated()

completes.

03 Thread Pool Applications

Typical usage steps:

<code>// 1. Create a fixed‑size pool (4 threads, 15‑second keep‑alive, queue capacity 1000)
ThreadPoolExecutor executor = new ThreadPoolExecutor(
        4, 4, 15, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1000),
        Executors.defaultThreadFactory(),
        new ThreadPoolExecutor.AbortPolicy());

// 2. Submit tasks
executor.submit(task1);
executor.submit(task2);
// ...

// 3. Shut down when done
executor.shutdown();
</code>

Both

execute()

and

submit()

can be used;

submit()

additionally returns a

Future

for result retrieval.

Factory Methods Summary

Executors.newSingleThreadExecutor()

: core=1, max=1, unbounded

LinkedBlockingQueue

.

Executors.newFixedThreadPool(n)

: core=n, max=n, unbounded

LinkedBlockingQueue

.

Executors.newCachedThreadPool()

: core=0, max=Integer.MAX_VALUE, 60‑second keep‑alive,

SynchronousQueue

(creates threads on demand).

Executors.newScheduledThreadPool(core)

: core as specified, max=Integer.MAX_VALUE, uses

DelayedWorkQueue

for timed tasks.

Because the default factories use unbounded queues or unlimited thread counts, they can cause OOM in high‑concurrency scenarios. It is recommended to create pools directly via

ThreadPoolExecutor

with explicit limits.

Custom Thread Factories

Giving threads meaningful names helps debugging. Example using Guava’s

ThreadFactoryBuilder

:

<code>ThreadFactory threadFactory = new ThreadFactoryBuilder()
        .setNameFormat("order-%d")
        .setDaemon(true)
        .build();
</code>

Or implement your own:

<code>public final class NamingThreadFactory implements ThreadFactory {
    private final AtomicInteger threadNum = new AtomicInteger();
    private final ThreadFactory delegate;
    private final String name;
    public NamingThreadFactory(ThreadFactory delegate, String name) {
        this.delegate = delegate;
        this.name = name;
    }
    @Override
    public Thread newThread(Runnable r) {
        Thread t = delegate.newThread(r);
        t.setName(name + "-" + threadNum.incrementAndGet());
        return t;
    }
}
</code>

Thread Count Guidelines

CPU‑bound tasks: set thread count to N (CPU cores) + 1.

I/O‑bound tasks: set thread count to 2N.

Determine the type by checking whether the task performs network/file I/O (I/O‑bound) or pure computation (CPU‑bound).

Javabackend developmentconcurrencythread poolExecutorServiceThreadPoolExecutor
macrozheng
Written by

macrozheng

Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.