Skip to main content

Thread Pools, Netty, Tomcat & HikariCP

Who this guide is for

1. Thread Pools โ€” ThreadPoolExecutorโ€‹

What is a Thread Pool?โ€‹

A thread pool is a managed collection of pre-created threads that are reused to execute tasks. Instead of creating a new OS thread for every task (expensive: ~1MB stack + kernel call), the pool maintains a fixed number of threads that pick tasks from a queue.

Without a pool:
Task 1 โ†’ create Thread โ†’ run โ†’ destroy Thread
Task 2 โ†’ create Thread โ†’ run โ†’ destroy Thread
Task 3 โ†’ create Thread โ†’ run โ†’ destroy Thread
Cost: 3 ร— (1ms create + 1ms destroy) = 6ms overhead

With a pool:
Pool: [Thread-1] [Thread-2] [Thread-3] โ† pre-created, reused
Task 1 โ†’ Thread-1 picks it up โ†’ runs โ†’ Thread-1 returns to pool
Task 2 โ†’ Thread-2 picks it up โ†’ runs โ†’ Thread-2 returns to pool
Cost: 0ms overhead (threads already exist)

How ThreadPoolExecutor Works Internallyโ€‹

java.util.concurrent.ThreadPoolExecutor is the engine behind all Java thread pools. Understanding its internals prevents catastrophic production failures.

Constructor Parametersโ€‹

ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(100), // workQueue
new ThreadFactory() { ... }, // threadFactory
new CallerRunsPolicy() // rejectionHandler
);
ParameterWhat It ControlsWhy It Matters
corePoolSizeThreads that stay alive even when idleToo low โ†’ tasks queue; too high โ†’ wasted RAM
maximumPoolSizeAbsolute thread ceiling under burst loadSafety valve โ€” prevents unbounded thread creation
keepAliveTimeHow long non-core threads survive idleLets burst threads die after the spike passes
workQueueBuffer for tasks when all core threads are busyBounded = backpressure; Unbounded = OOM risk
threadFactoryCustom thread naming and daemon settingsNamed threads = readable thread dumps
rejectionHandlerWhat happens when pool AND queue are fullCallerRunsPolicy = natural backpressure

Task Submission Flow (Critical for Interviews)โ€‹

Task submitted to ThreadPoolExecutor
โ”‚
โ”œโ”€โ”€ Are there idle core threads?
โ”‚ YES โ†’ Assign task to an idle core thread
โ”‚ NO โ†“
โ”‚
โ”œโ”€โ”€ Is the work queue full?
โ”‚ NO โ†’ Add task to the queue (waits for a thread)
โ”‚ YES โ†“
โ”‚
โ”œโ”€โ”€ Is maximumPoolSize reached?
โ”‚ NO โ†’ Create a new non-core thread to handle the task
โ”‚ YES โ†“
โ”‚
โ””โ”€โ”€ Execute the RejectionPolicy
โ”œโ”€โ”€ AbortPolicy (default): throw RejectedExecutionException
โ”œโ”€โ”€ CallerRunsPolicy: run task in the caller's thread โ† backpressure
โ”œโ”€โ”€ DiscardPolicy: silently drop the task
โ””โ”€โ”€ DiscardOldestPolicy: drop oldest queued task, retry
Visual timeline:

corePoolSize=2, maxPoolSize=4, queue=3

Tasks: T1 T2 T3 T4 T5 T6 T7 T8
โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚
Core: [T1][T2] โ† Core threads handle T1, T2
Queue: [T3][T4][T5] โ† Queue absorbs T3โ€“T5
Non-core: [T6][T7] โ† Non-core threads created for T6, T7
Reject: [T8] โ† RejectionPolicy triggered
Why Executors Factory Methods Are Dangerous
Factory MethodHidden Danger
Executors.newFixedThreadPool(n)Uses unbounded LinkedBlockingQueue โ†’ tasks pile up โ†’ OOM
Executors.newCachedThreadPool()maximumPoolSize = Integer.MAX_VALUE โ†’ creates unlimited threads โ†’ OOM
Executors.newSingleThreadExecutor()Unbounded queue โ†’ same OOM risk as fixed pool

Always use ThreadPoolExecutor directly with bounded queues in production.

Sizing Thread Poolsโ€‹

CPU-Bound Tasksโ€‹

Tasks that compute without waiting (sorting, encryption, JSON parsing):

Optimal threads = CPU_cores + 1

Why +1? Insurance against page faults. If one thread stalls on
a memory page fault, the extra thread keeps the CPU busy.

Example: 8-core server โ†’ 9 threads for CPU-bound work

I/O-Bound Tasksโ€‹

Tasks that spend most of their time waiting (DB queries, HTTP calls, file reads):

Optimal threads = CPU_cores ร— target_utilization ร— (1 + wait_time / compute_time)

Example:
8 cores, 70% CPU target
Average HTTP call: 200ms wait, 2ms compute
= 8 ร— 0.7 ร— (1 + 200/2) = 8 ร— 0.7 ร— 101 = 565 threads

Rule of thumb: if tasks are 99% waiting, you need ~100ร— more
threads than cores to keep the CPU busy.

Spring Boot @Async Thread Poolโ€‹

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("async-worker-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

See also: Java Concurrency โ€” Thread Pools & Executors for ThreadPoolExecutor constructor walkthrough, starvation math, and ScheduledExecutorService.


2. Tomcat โ€” Embedded Server Threadsโ€‹

What is Tomcat?โ€‹

Apache Tomcat is the default embedded servlet container in Spring Boot. It handles HTTP connections and dispatches requests to your controllers. Tomcat uses a thread-per-request model: each incoming HTTP request gets a dedicated thread from a pool.

Tomcat's Internal Architectureโ€‹

Internet
โ”‚
โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ TOMCAT โ”‚
โ”‚ โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ Connector (HTTP/1.1) โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ Acceptor Thread โ†โ”€โ”€ OS socket backlog โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ โ–ผ โ”‚ โ”‚
โ”‚ โ”‚ Poller Thread(s) โ†โ”€โ”€ NIO selector โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ (epoll/kqueue) โ”‚ โ”‚
โ”‚ โ”‚ โ–ผ โ”‚ โ”‚
โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ Worker Thread Pool โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ [T1] [T2] [T3] ... [T200] โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ max-threads=200 (default) โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚ โ–ผ โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ DispatcherServlet (Spring) โ”‚ โ”‚
โ”‚ โ”‚ โ†’ Controller โ”‚ โ”‚
โ”‚ โ”‚ โ†’ Service โ”‚ โ”‚
โ”‚ โ”‚ โ†’ Repository (JDBC โ†’ HikariCP) โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

How Tomcat Processes a Requestโ€‹

1. Acceptor Thread
- Calls ServerSocketChannel.accept()
- Accepts TCP connections from the OS accept queue
- Registers the new socket with the Poller

2. Poller Thread (NIO)
- Monitors all registered sockets via Selector (epoll/kqueue)
- Waits for data to arrive on any socket
- When data is ready, hands the socket to a Worker Thread

3. Worker Thread (from the pool)
- Reads the HTTP request
- Invokes the servlet (DispatcherServlet โ†’ your @Controller)
- Writes the HTTP response
- Returns to the pool

The worker thread is OCCUPIED for the entire request lifecycle.
If your controller makes a 2-second DB query, the thread is
blocked for 2 seconds doing nothing.

Tomcat Configurationโ€‹

server:
tomcat:
# === Worker Thread Pool ===
threads:
max: 200 # Maximum worker threads (default: 200)
min-spare: 10 # Minimum idle threads kept warm (default: 10)

# === Connection Limits ===
max-connections: 8192 # Max simultaneous connections the Poller can track (default: 8192)
accept-count: 100 # OS-level TCP backlog queue when max-connections is reached (default: 100)

# === Timeouts ===
connection-timeout: 20000 # ms to wait for first byte after TCP connect (default: 20s)

# === Keep-Alive ===
keep-alive-timeout: 20000 # ms to keep an idle connection open for reuse
max-keep-alive-requests: 100 # requests per keep-alive connection before closing

Understanding the Numbersโ€‹

max-connections (8192) โ†’ Poller can track this many sockets
โ†“
max-threads (200) โ†’ Only 200 can be actively processed
โ†“
accept-count (100) โ†’ OS queues 100 more when max-connections hit
โ†“
Beyond that โ†’ TCP RST (connection refused)

In steady state with short requests:
200 threads ร— 10ms avg response = 20,000 requests/sec throughput

With slow requests (2s average):
200 threads ร— 2000ms = 200 concurrent users max
User #201 waits in the Poller queue

Tomcat vs Jetty vs Undertowโ€‹

FeatureTomcatJettyUndertow
Default in Spring Bootโœ… YesNoNo
Threading modelThread-per-request (NIO)Thread-per-request (NIO)XNIO (non-blocking)
WebSocket supportโœ…โœ…โœ…
HTTP/2โœ…โœ…โœ…
Memory footprintMediumLowerLowest
Best forGeneral purposeLightweight appsHigh-performance, reactive

See also: Spring Boot Internals โ€” Embedded Server Architecture for how Spring Boot auto-configures the servlet container.


3. Netty โ€” Event Loop Architectureโ€‹

What is Netty?โ€‹

Netty is an asynchronous, event-driven network application framework for building high-performance protocol servers and clients. Unlike Tomcat's thread-per-request model, Netty uses a small number of event loop threads to handle thousands of connections simultaneously.

Netty is the engine behind: Spring WebFlux, gRPC-Java, Cassandra Driver, Elasticsearch transport, Vert.x, and Kafka clients.

How Netty Works Internallyโ€‹

Traditional (Tomcat): 1 Thread = 1 Connection
Thread-1 โ†’ [read][process][write] โ† blocked during I/O
Thread-2 โ†’ [read][process][write] โ† blocked during I/O
...
Thread-200 โ†’ [read][process][write]
โ†’ 200 threads = 200 concurrent connections max

Netty: 1 Thread = MANY Connections
EventLoop-1 โ†’ [read fd1][read fd5][write fd3][read fd9][write fd1]...
EventLoop-2 โ†’ [read fd2][read fd7][write fd4][read fd8][write fd6]...
...
EventLoop-8 โ†’ [read fd10][write fd12][read fd15]...
โ†’ 8 threads = 10,000+ concurrent connections

Netty Architecture (Boss-Worker Model)โ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ NETTY โ”‚
โ”‚ โ”‚
โ”‚ Boss EventLoopGroup (1 thread typically) โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ BossEventLoop โ”‚ โ”‚
โ”‚ โ”‚ Selector.select() โ†’ accept new connections โ”‚ โ”‚
โ”‚ โ”‚ Register accepted channels with Worker EventLoop โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚ โ”‚ โ”‚
โ”‚ โ–ผ (hand off new channel) โ”‚
โ”‚ โ”‚
โ”‚ Worker EventLoopGroup (N threads, default = 2 ร— CPU cores) โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ WorkerLoop-1 โ”‚ โ”‚ WorkerLoop-2 โ”‚ โ”‚ WorkerLoop-N โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ Channels: โ”‚ โ”‚ Channels: โ”‚ โ”‚ Channels: โ”‚ โ”‚
โ”‚ โ”‚ [fd1,fd5,fd9] โ”‚ โ”‚ [fd2,fd6,fd10] โ”‚ โ”‚ [fd4,fd8,fd12] โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ Event Loop: โ”‚ โ”‚ Event Loop: โ”‚ โ”‚ Event Loop: โ”‚ โ”‚
โ”‚ โ”‚ select() โ”‚ โ”‚ select() โ”‚ โ”‚ select() โ”‚ โ”‚
โ”‚ โ”‚ โ†’ read events โ”‚ โ”‚ โ†’ read events โ”‚ โ”‚ โ†’ read events โ”‚ โ”‚
โ”‚ โ”‚ โ†’ run pipeline โ”‚ โ”‚ โ†’ run pipeline โ”‚ โ”‚ โ†’ run pipeline โ”‚ โ”‚
โ”‚ โ”‚ โ†’ write events โ”‚ โ”‚ โ†’ write events โ”‚ โ”‚ โ†’ write events โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚ โ”‚
โ”‚ Each channel has a ChannelPipeline: โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ Decoder โ”‚ Encoder โ”‚ Idle โ”‚ Business Logic โ”‚ โ”‚
โ”‚ โ”‚ (bytesโ†’ โ”‚ (objectโ†’ โ”‚ Handler โ”‚ Handler โ”‚ โ”‚
โ”‚ โ”‚ object) โ”‚ bytes) โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Key Netty Conceptsโ€‹

ConceptWhat It IsAnalogy
ChannelAn open connection (socket)A phone line
EventLoopA single thread running an infinite select() loopA switchboard operator
EventLoopGroupA pool of EventLoopsThe operator team
ChannelPipelineChain of handlers processing dataAssembly line
ChannelHandlerA processing step (decode, encode, business logic)A station on the assembly line
ByteBufNetty's buffer (replaces java.nio.ByteBuffer)A smarter byte array with read/write indexes

Netty Configurationโ€‹

EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 1 thread for accepting
EventLoopGroup workerGroup = new NioEventLoopGroup(); // default: 2 ร— CPU cores

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)

// TCP backlog โ€” OS-level queue for pending connections
.option(ChannelOption.SO_BACKLOG, 1024)

// Child channel options (per-connection settings)
.childOption(ChannelOption.TCP_NODELAY, true) // Disable Nagle's algorithm
.childOption(ChannelOption.SO_KEEPALIVE, true) // Enable TCP keep-alive
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) // Pooled memory

.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new HttpServerCodec()) // HTTP encode/decode
.addLast(new HttpObjectAggregator(65536)) // Aggregate HTTP chunks
.addLast(new IdleStateHandler(60, 30, 0)) // Detect idle connections
.addLast(new MyBusinessHandler()); // Your logic
}
});
The Golden Rule of Netty

NEVER block an EventLoop thread. If your handler does blocking I/O (JDBC query, synchronous HTTP call, Thread.sleep()), you block that EventLoop โ€” and ALL channels assigned to it are frozen.

// โŒ NEVER DO THIS in a ChannelHandler
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// This blocks the EventLoop โ†’ freezes hundreds of connections
String result = jdbcTemplate.queryForObject("SELECT ...", String.class);
ctx.writeAndFlush(result);
}

// โœ… Offload blocking work to a separate thread pool
private final EventExecutorGroup blockingGroup =
new DefaultEventExecutorGroup(16); // dedicated pool for blocking ops

ch.pipeline().addLast(blockingGroup, new MyBlockingHandler());

See also: Socket Programming & I/O Models for epoll, the Reactor pattern, and how Netty uses them under the hood.


4. HikariCP โ€” Database Connection Poolingโ€‹

What is HikariCP?โ€‹

HikariCP is the fastest JVM connection pool and the default in Spring Boot 2.x+. It manages a cache of pre-opened, pre-authenticated database connections that threads borrow and return โ€” eliminating the 10โ€“100ms overhead of establishing a new connection per request.

How HikariCP Works Internallyโ€‹

HikariCP Internals
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ โ”‚
โ”‚ ConcurrentBag (lock-free data structure) โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ Thread-local list โ†’ try borrow from own list โ”‚ โ”‚
โ”‚ โ”‚ โ†“ miss โ”‚ โ”‚
โ”‚ โ”‚ Shared list โ†’ CAS-based steal from shared โ”‚ โ”‚
โ”‚ โ”‚ โ†“ miss โ”‚ โ”‚
โ”‚ โ”‚ Handoff queue โ†’ wait with park/unpark โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚ โ”‚
โ”‚ HouseKeeper Thread (every 30s): โ”‚
โ”‚ โ”œโ”€โ”€ Evict idle connections beyond minimum-idle โ”‚
โ”‚ โ”œโ”€โ”€ Retire connections older than max-lifetime โ”‚
โ”‚ โ””โ”€โ”€ Create new connections if below minimum-idle โ”‚
โ”‚ โ”‚
โ”‚ Connection validation: โ”‚
โ”‚ โ”œโ”€โ”€ On borrow: Connection.isValid(timeout) โ”‚
โ”‚ โ”œโ”€โ”€ Keepalive: periodic ping (keepalive-time) โ”‚
โ”‚ โ””โ”€โ”€ Max-lifetime: recycle after age limit โ”‚
โ”‚ โ”‚
โ”‚ Metrics (Micrometer integration): โ”‚
โ”‚ โ”œโ”€โ”€ hikaricp.connections.active โ”‚
โ”‚ โ”œโ”€โ”€ hikaricp.connections.idle โ”‚
โ”‚ โ”œโ”€โ”€ hikaricp.connections.pending โ”‚
โ”‚ โ”œโ”€โ”€ hikaricp.connections.timeout โ”‚
โ”‚ โ””โ”€โ”€ hikaricp.connections.usage (histogram) โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The ConcurrentBag โ€” Why HikariCP Is So Fastโ€‹

HikariCP's secret weapon is ConcurrentBag, a lock-free data structure:

Step 1: Thread-Local Borrow (fastest path โ€” no contention)
Thread A previously returned Connection C1
Thread A requests a connection
โ†’ ConcurrentBag checks Thread A's thread-local list
โ†’ C1 is there! Borrow it in ~250 nanoseconds

Step 2: Shared List Steal (fast โ€” CAS operation)
Thread B requests a connection (first time, no thread-local history)
โ†’ Check shared connection list
โ†’ CAS (compare-and-swap) to claim an idle connection
โ†’ ~500 nanoseconds

Step 3: Handoff Queue (slowest โ€” waits for a return)
All connections are borrowed
Thread C requests a connection
โ†’ Parks the thread (waits up to connection-timeout)
โ†’ When any thread returns a connection, C is unparked
โ†’ If timeout expires โ†’ SQLTransientConnectionException

HikariCP Configuration (Production-Ready)โ€‹

spring:
datasource:
url: jdbc:postgresql://db-host:5432/mydb
username: ${DB_USER}
password: ${DB_PASSWORD}
hikari:
# === Pool Size ===
maximum-pool-size: 20 # Total connections (active + idle)
minimum-idle: 20 # Fixed-size pool (no dynamic churn)

# === Timeouts ===
connection-timeout: 3000 # 3s โ€” fail fast, don't queue for 30s
idle-timeout: 600000 # 10min โ€” only matters if min-idle < max
max-lifetime: 1800000 # 30min โ€” recycle before DB kills them
validation-timeout: 3000 # 3s โ€” how long to test a connection

# === Health ===
keepalive-time: 30000 # Ping idle connections every 30s
leak-detection-threshold: 5000 # Warn if connection held > 5s

# === Identity ===
pool-name: HikariPool-Orders

Pool Sizing Formulaโ€‹

connections = (CPU_cores ร— 2) + effective_spindle_count

Where:
CPU_cores = physical cores on the DATABASE server
effective_spindle_count = 1 for SSD, disk count for RAID

Examples:
4-core DB, SSD: (4 ร— 2) + 1 = 9 โ†’ set to 10
8-core DB, SSD: (8 ร— 2) + 1 = 17 โ†’ set to 20

Divide by app instances:
20 total connections, 4 pods โ†’ 5 per pod
Fixed-Size Pool is Best for Production

Set minimum-idle = maximum-pool-size. A dynamic pool that shrinks during quiet periods means cold-start latency during the next traffic spike (new connections take 10โ€“100ms each).

See also: Database Connection Pooling for the complete guide on pool starvation, failure modes, PgBouncer, RDS Proxy, and anti-patterns.


5. How They All Relateโ€‹

The Full Request Flowโ€‹

Understanding how thread pools, Tomcat, Netty, and HikariCP interact in a single HTTP request is the key to diagnosing performance issues.

HTTP Request arrives
โ”‚
โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ TOMCAT โ”‚
โ”‚ Acceptor โ†’ Poller โ†’ Worker Pool โ”‚
โ”‚ Thread "http-nio-8080-exec-42" โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚ โ”‚
โ–ผ โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ Spring DispatcherServlet โ”‚ โ”‚
โ”‚ @Controller โ†’ @Service โ”‚ โ”‚
โ”‚ โ”‚ โ”‚
โ”‚ orderService.getOrder(42) โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ–ผ โ”‚ โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚
โ”‚ โ”‚ HikariCP โ”‚ โ”‚ Tomcat worker thread is โ”‚
โ”‚ โ”‚ Borrow connection โ”‚ โ”‚ BLOCKED during the entire โ”‚
โ”‚ โ”‚ โ†’ Execute SQL query โ”‚ โ”‚ DB query + response write โ”‚
โ”‚ โ”‚ โ†’ Return connection โ”‚ โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚
โ”‚ โ”‚ โ”‚
โ”‚ Build response โ†’ return โ”‚ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚ โ”‚
โ–ผ โ”‚
Response sent back โ† โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”€ โ”˜
Thread "http-nio-8080-exec-42" returns to Tomcat's pool

Key insight: The Tomcat worker thread is occupied for the entire request lifecycle โ€” including time spent waiting for the DB.

The Relationship Diagramโ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ YOUR APPLICATION โ”‚
โ”‚ โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ HTTP Server โ”‚ โ”‚ Database Access โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€ Tomcat โ”€โ”€โ”€โ”€โ”€โ”โ”‚ โ”‚ โ”Œโ”€โ”€โ”€ HikariCP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ Thread Pool โ”‚โ”‚ โ”‚ โ”‚ Connection Pool โ”‚โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ (200 workers) โ”‚โ”‚ โ”‚ โ”‚ (20 connections) โ”‚โ”‚ โ”‚
โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚ โ”‚
โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€ Netty โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ EventLoopGroup โ”‚โ”‚ โ”‚ โ”Œโ”€โ”€โ”€ R2DBC Pool โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ (8 event loops) โ”‚โ”‚ โ”‚ โ”‚ Reactive connections โ”‚โ”‚ โ”‚
โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚ โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚
โ”‚ โ”‚ Application Thread Pools โ”‚โ”‚
โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚โ”‚
โ”‚ โ”‚ โ”‚ @Async pool โ”‚ โ”‚ @Scheduled pool โ”‚ โ”‚โ”‚
โ”‚ โ”‚ โ”‚ (business logic)โ”‚ โ”‚ (cron/timer tasks) โ”‚ โ”‚โ”‚
โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚โ”‚
โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚โ”‚
โ”‚ โ”‚ โ”‚ ForkJoinPool โ”‚ โ”‚ Virtual Thread Executor โ”‚ โ”‚โ”‚
โ”‚ โ”‚ โ”‚ (parallelStream)โ”‚ โ”‚ (Java 21+) โ”‚ โ”‚โ”‚
โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Concurrency Model Comparisonโ€‹

AspectTomcat (BIO/NIO)NettyVirtual Threads
ModelThread-per-requestEvent loopVirtual-thread-per-request
Threads needed~200 for 200 concurrent~8 for 10,000+ concurrentMillions possible
Blocking I/OBlocks a worker threadโŒ Must never blockโœ… Safe โ€” unmounts from carrier
Code styleSimple imperativeCallback/reactiveSimple imperative
Memory per connection~1MB (thread stack)~1KB (channel state)~1KB (heap continuation)
Spring integrationSpring MVCSpring WebFluxSpring MVC (3.2+)
DB accessJDBC + HikariCPR2DBC (reactive)JDBC + HikariCP
Best forTraditional CRUD APIsHigh-connection serversI/O-heavy APIs on Java 21+

6. Production Sizing Guideโ€‹

The Bottleneck Chainโ€‹

Internet โ†’ Load Balancer โ†’ Tomcat Threads โ†’ HikariCP Connections โ†’ Database CPU
โ†‘ โ†‘ โ†‘
Bottleneck 1 Bottleneck 2 Bottleneck 3

If Tomcat has 200 threads but HikariCP has 10 connections:
โ†’ 190 threads will queue waiting for a connection
โ†’ If those threads also serve other endpoints, the ENTIRE API stalls

If HikariCP has 200 connections but the DB only has 8 CPU cores:
โ†’ 192 queries queue in the DB waiting for CPU
โ†’ Query latency spikes โ†’ connection hold time increases โ†’ pool exhaustion

Sizing Checklistโ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Component โ”‚ Formula / Rule โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Tomcat max-threads โ”‚ Start at 200 (default). Tune down if CPU โ”‚
โ”‚ โ”‚ context-switching dominates. โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ HikariCP pool-size โ”‚ (DB_CPU ร— 2) + 1, divided by # app pods โ”‚
โ”‚ โ”‚ Example: 8-core DB, 4 pods โ†’ 5 per pod โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Netty workers โ”‚ 2 ร— CPU cores (default). Rarely needs โ”‚
โ”‚ โ”‚ adjustment. โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ @Async pool โ”‚ CPU-bound: cores + 1 โ”‚
โ”‚ โ”‚ I/O-bound: cores ร— (1 + wait/compute) โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ ForkJoinPool โ”‚ Defaults to CPU cores. Only for CPU-bound โ”‚
โ”‚ โ”‚ parallel work (parallel streams). โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Virtual Threads โ”‚ Don't pool them. Unlimited. Use Semaphore โ”‚
โ”‚ โ”‚ to guard downstream resources. โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Mismatch Deadlockโ€‹

A critical failure mode when thread pool and connection pool sizes don't match:

Scenario:
Tomcat max-threads = 200
HikariCP maximum-pool-size = 10
connection-timeout = 30s (default)

Step 1: 200 requests arrive simultaneously
Step 2: Threads 1โ€“10 borrow all 10 connections
Step 3: Threads 11โ€“200 queue for connections (up to 30s each)
Step 4: Thread 1 (holding C1) makes an internal REST call to /api/helper
Step 5: /api/helper request arrives โ†’ needs a connection too
Step 6: All connections are held โ†’ /api/helper waits 30s
Step 7: Thread 1 waits for /api/helper โ†’ can't release C1
Step 8: DEADLOCK โ€” nobody makes progress

Fix:
1. Set connection-timeout to 3s (fail fast)
2. Use separate pools for internal sub-requests
3. Ensure pool size โ‰ฅ max threads that need concurrent connections

7. Troubleshooting & Common Failuresโ€‹

Symptoms โ†’ Diagnosis โ†’ Fixโ€‹

SymptomLikely CauseDiagnosisFix
Response times spike under loadPool starvation (thread or connection)Check hikaricp.connections.pending > 0 or high thread countReduce connection-timeout, fix slow queries
SQLTransientConnectionExceptionAll connections borrowed, timeout expiredhikaricp.connections.timeout counter increasingIncrease pool size OR fix connection hold time
RejectedExecutionExceptionThread pool + queue both fullThread dump shows all threads busyIncrease queue or threads; fix slow handlers
CPU at 100% with no useful workToo many threads โ†’ context switchingvmstat shows high cs (context switch) rateReduce thread count
Memory growing (OOM)Unbounded queue or thread countHeap dump shows many task objects or threadsUse bounded queues; explicit ThreadPoolExecutor
Tomcat stops accepting requestsAll 200 worker threads blockedThread dump shows all threads in WAITING on HikariCPFix connection leak; reduce connection-timeout
Netty EventLoop blockedBlocking call in a ChannelHandlerSlow channel handlers, increasing event loop latencyOffload blocking work to separate executor

Essential Metrics to Monitorโ€‹

# Spring Boot Actuator + Micrometer

# Tomcat thread pool
tomcat.threads.current # Current thread count
tomcat.threads.busy # Threads actively processing requests
tomcat.threads.config.max # Maximum configured threads

# HikariCP connection pool
hikaricp.connections.active # Connections currently in use
hikaricp.connections.idle # Connections sitting idle
hikaricp.connections.pending # Threads waiting for a connection โ† ALERT if > 0
hikaricp.connections.timeout # Connection borrow timeouts (cumulative)
hikaricp.connections.usage # Connection hold time histogram

# JVM threads
jvm.threads.live # Total live threads
jvm.threads.peak # Peak thread count since startup
jvm.threads.daemon # Daemon threads

Thread Dump Analysisโ€‹

# Get a thread dump of your Java process
jstack <pid> > threaddump.txt

# What to look for:
# 1. Many threads in WAITING state on HikariCP
"http-nio-8080-exec-42" WAITING
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:162)
โ†’ Pool starvation โ€” all connections are borrowed

# 2. Many threads BLOCKED on synchronized
"http-nio-8080-exec-15" BLOCKED
at com.example.LegacyService.criticalSection(LegacyService.java:42)
โ†’ Lock contention โ€” single synchronized method is a bottleneck

# 3. Deadlock detected
"Found one Java-level deadlock:"
โ†’ Thread A holds Lock 1, waits for Lock 2
โ†’ Thread B holds Lock 2, waits for Lock 1

8. Interview Questionsโ€‹

Q: Explain the relationship between Tomcat's thread pool and HikariCP's connection pool.โ€‹

A: Tomcat's thread pool handles HTTP requests โ€” each request gets a worker thread. When that request needs the database, the worker thread borrows a connection from HikariCP. The Tomcat thread is blocked until the DB query completes and the connection is returned. If HikariCP has fewer connections than Tomcat has threads (common: 200 threads vs 10โ€“20 connections), excess threads queue. This is fine for fast queries (under 5ms) but dangerous for slow queries โ€” threads pile up waiting, leading to pool starvation and cascading timeouts.

Q: Why does Netty need far fewer threads than Tomcat?โ€‹

A: Tomcat uses thread-per-request: each thread blocks during I/O. With 200 threads, you handle 200 concurrent requests max. Netty uses the Reactor pattern: a few EventLoop threads multiplex thousands of connections via epoll/kqueue. When data isn't ready on a socket, the EventLoop serves another channel instead of blocking. This means 8 threads can handle 10,000+ concurrent connections โ€” but you must never block an EventLoop thread, or all its channels freeze.

Q: What happens when you enable virtual threads in Spring Boot 3.2+?โ€‹

A: Setting spring.threads.virtual.enabled=true makes Tomcat use virtual threads instead of platform threads for request handling. Each request gets its own virtual thread (not from a fixed pool). When the virtual thread blocks on I/O (JDBC query, HTTP call), it unmounts from the carrier thread โ€” the carrier is free to run other virtual threads. This gives Tomcat-like simplicity (blocking code) with Netty-like efficiency (threads aren't wasted during I/O). The new bottleneck shifts from threads to connection pools โ€” you must size HikariCP and use Semaphores to prevent 100K virtual threads from overwhelming the database.

Q: How would you size a HikariCP pool for a 4-pod deployment against an 8-core RDS instance?โ€‹

A: Use the formula: connections = (CPU_cores ร— 2) + 1 = 17. Round to 20 total connections. With 4 pods: 20 / 4 = 5 connections per pod. Set maximum-pool-size: 5 and minimum-idle: 5 (fixed pool). If you scale to 8 pods without adjusting, you'd get 40 total connections โ€” overloading the DB. Either reduce per-pod pool size or add PgBouncer/RDS Proxy as a connection multiplexer.

Q: Why is Executors.newFixedThreadPool() considered dangerous?โ€‹

A: It uses an unbounded LinkedBlockingQueue. If tasks arrive faster than threads can process them, the queue grows without limit โ€” consuming heap memory until OutOfMemoryError. In production, always use ThreadPoolExecutor directly with a bounded ArrayBlockingQueue and a rejection policy like CallerRunsPolicy for backpressure.

Q: How do you diagnose pool starvation?โ€‹

A: Monitor hikaricp.connections.pending (threads waiting for connections) and hikaricp.connections.timeout (failed borrows). Take a thread dump โ€” if many threads are in WAITING state at HikariPool.getConnection(), the pool is starved. Root causes: slow queries (N+1, missing indexes), connections held during non-DB work (@Transactional wrapping HTTP calls), or pool too small for the workload. Fix the query first; increase pool size only as a last resort.


๐Ÿ”— Cross-Referencesโ€‹

TopicLink
ThreadPoolExecutor & Fork/Join detailsJava Concurrency
Virtual Threads deep diveVirtual Threads (Project Loom)
HikariCP anti-patterns & PgBouncerDatabase Connection Pooling
Netty, epoll, and the Reactor patternSocket Programming & I/O Models
Tomcat embedded server internalsSpring Boot Internals
JVM memory & thread stacksJVM Memory Architecture
Spring Boot server tuningSpring Boot Advanced