摘要:本文从Socket 系统调用说起,简单说明了网络I/O过程。介绍了Tomcat中最常用的两种实现NioEndpoint、Nio2Endpoint,并对工作线程池调优做了探讨。
本文中源码来自tomcat 9.0.x版本,地址github.com/apache/tomc…
一 从Socket Read 说起
Linux 系统中 Socket Read 系统调用,涉及从用户态→内核态的切换、内核态的核心处理逻辑、数据拷贝与返回三个核心阶段。先来看看几个概念:
- 用户态 / 内核态:Linux 进程分为两种运行级别,用户态(低权限,应用程序运行)、内核态(高权限,操作系统内核运行),系统调用是用户态进入内核态的唯一合法方式。
- Socket 本质:Linux 中 Socket 是一种文件描述符(fd),所有网络 I/O 最终都映射为文件操作
- 阻塞 / 非阻塞:Socket 默认是阻塞的,若接收缓冲区无数据,
read()会阻塞进程;非阻塞模式下则直接返回。
Tomcat作为Web容器来处理网络请求,一项核心工作就是与操作系统进行网络I/O交互。 它将如何减少上下文切换、提升并发处理能力?这就需要了解Tomcat的两种常用网络I/O模型:NIO(NioEndpoint)和AIO (Nio2Endpoint)。
二 NioEndpoint
EndPoint 组件的主要工作就是处理 I/O,而 NioEndpoint 利用 java.nio包实现了多路复用 I/O 模型,封装了ServerSocketChannel、SocketChannel和Selector等类。是Tomcat 默认使用的I/O模型。
NioEndpoint 基于 主从Reactor多线程模型 设计。核心组件分为三层:
Acceptor (主 Reactor) → Poller (从 Reactor) → Worker 线程池 (业务处理)
NioEndpoint的start方法做了这几件事:
- 创建worker线程池
- 创建LimitLatch,用于限制并发连接数;
- 创建并启动 poller thread;
- 创建并启动Acceptor线程
2.1 Acceptor
Acceptor是单线程阻塞式 Accept的连接接收器,它实现了Runnable接口。主要功能包括:
- Acceptor 是一个独立线程(实现 Runnable),在 while 循环中不断调用 endpoint.serverSocketAccept()接收新连接;
- 在 NioEndpoint 中,ServerSocketChannel 被配置为阻塞模式即serverSock.configureBlocking(true),所以 accept 调用是阻塞的;
- 接收到连接后调用 endpoint.setSocketOptions(socket) 将 socket 交给 Poller;
- 通过 endpoint.countUpOrAwaitConnection(),使用 LimitLatch 控制最大连接数(默认 8192),达到上限时 Acceptor 线程阻塞等待。
在NioEndpoint#initServerSocket中,设置了ServerSocket的accept()为阻塞模式。
public class Acceptor<U> implements Runnable {
// 持有endpoint
private final AbstractEndpoint<?,U> endpoint;
// 简化代码
@Override
public void run() {
try {
// Loop until we receive a shutdown command
while (!stopCalled) {
if (stopCalled) {
break;
}
state = AcceptorState.RUNNING;
try {
U socket;
try {
// 阻塞式接收连接,返回SocketChannel
socket = endpoint.serverSocketAccept();
} catch (Exception e) {
endpoint.countDownConnection();
}
// 注册
if (!endpoint.setSocketOptions(socket)) {
endpoint.closeSocket(socket);
}
} catch (Throwable t) {
// 省略
}
}
} finally {
stopLatch.countDown();
}
state = AcceptorState.ENDED;
}
核心流程就是:
while (!stopCalled) {
countUpOrAwaitConnection(); // 连接数限流
socket = endpoint.serverSocketAccept(); // 阻塞等待新连接
endpoint.setSocketOptions(socket); // 注册到 Poller
}
2.2 Poller
Poller是NioEndpoint的一个内部类,是NIO 同步非阻塞模型 的核心体现,对应 Java NIO 的 Selector 机制:
- 注册阶段:setSocketOptions 中将 SocketChannel 配置为 非阻塞(socket.configureBlocking(false)),然后调用 poller.register()将 socket 以 OP_REGISTER 事件加入事件队列
- 轮询阶段(Poller.run):
-
- 先处理事件队列 events()将新注册的 socket 通过 sc.register(getSelector(), SelectionKey.OP_READ) 注册到 Selector,
- 调用 selector.select(selectorTimeout) 或 selector.selectNow() 进行 IO 多路复用
- 遍历就绪的 SelectionKey,对可读/可写事件调用 processSocket() 提交到 Worker 线程池
当向events队列添加事件时,会让阻塞的selector.select()立即返回,从而及时消费消息。
// 简化代码
public void run() {
// Loop until destroy() is called
while (true) {
boolean hasEvents = false;
try {
if (!close) {
hasEvents = events();
if (wakeupCounter.getAndSet(-1) > 0) {
keyCount = selector.selectNow();
} else {
keyCount = selector.select(selectorTimeout);
}
wakeupCounter.set(0);
}
} catch (Throwable x) {
continue;
}
Iterator<SelectionKey> iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null;
while (iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
iterator.remove();
NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
if (socketWrapper != null) {
// 提交给worker线程池,处理读写事件
processKey(sk, socketWrapper);
}
}
}
getStopLatch().countDown();
}
2.3 流程图
三 Nio2Endpoint
Nio2Endpoint是异步非阻塞 AIO 模型,与 NioEndpoint 有本质区别:
- 没有 Poller:不需要 Selector 轮询,IO 操作完成后由 OS 回调 CompletionHandler
- Nio2Acceptor:继承 Acceptor 并实现 CompletionHandler<AsynchronousSocketChannel, Void>
- 调用 serverSock.accept(null, this) 发起异步 accept
- 连接到达时 OS 回调 completed() 方法,在回调中直接处理连接并再次发起 accept
- 读写操作:通过 readCompletionHandler 和 writeCompletionHandler 实现异步回调
- 非阻塞读:getSocket().read(to, timeout, unit, to, readCompletionHandler)
- 非阻塞写:getSocket().write(buffer, timeout, unit, buffer, writeCompletionHandler)
四 两种模型对比
| 特性 | NioEndpoint (NIO) | Nio2Endpoint (AIO) |
|---|---|---|
| IO 模型 | 同步非阻塞 + IO 多路复用 | 异步非阻塞 |
| Accept | 阻塞式(ServerSocketChannel blocking) | 异步回调(CompletionHandler) |
| IO 就绪检测 | Selector 轮询(Poller 线程) | OS 内核通知(无需 Poller) |
| 数据读写 | 应用主动读写(就绪后由 Worker 执行) | OS 完成后回调通知 |
| 线程模型 | Acceptor(1) + Poller(1) + Worker Pool | Acceptor(异步) + Worker Pool |
五 Tomcat线程调优
Tomcat 运行时关键指标有:吞吐量、响应时间、错误数、线程池、CPU 以及 JVM 内存。
- 前三个指标是业务指标,需要Tomcat 吞吐量要大、响应时间要短,并且错误数要少。
- 后三个指标与系统资源有关,当某项到达瓶颈时,就会影响业务指标,比如线程池中线程数不足会影响吞
吐量和响应时间;当内存不足时会触发频繁地 GC,耗费 CPU,导致业务指标恶化。
我们最常使用的调优措施,就是优化Worker Pool线程池(在NioEndpoint中Acceptor、Poller都是单线程,无法调整) 。
在NioEndpoint、Nio2Endpoint启动时,都会调用createExecutor()初始化线程池;其中配置参数包括:
| 参数 | 描述 |
|---|---|
| threadPriority | (int) 线程优先级,默认是 5 (Thread.NORM_PRIORITY) |
| daemon | (boolean) 是否 daemon 线程,默认为 true |
| maxThreads | (int) 最大线程数,默认是 200 |
| minSpareThreads | (int) 最小线程数,默认是 25 |
| maxIdleTime | (int) 线程最大空闲时间,超过这个时间线程就会回收,直到线程数剩下 minSpareThreads 个,默认值是60秒 |
| maxQueueSize | (int) 线程池中任务队列的最大长度,默认是 Integer.MAX_VALUE |
| prestartminSpareThreads | (boolean) 是否在线程池启动时就创建 minSpareThreads 个线程,默认为 false |
最核心参数是maxThreads,它并非越大越好,过大或过小都有问题:
- 设置的过小,Tomcat会发生线程饥饿,请求将在队列中排队等待,使得响应时间变长;
- 设置的过大,上下文切换开销剧增、内存占用高,反而拖慢整体性能。
maxThreads合理取值的目标,应该是让 CPU 利用率接近 xx%(如80%),同时避免频繁上下文切换。需要根据 CPU 核心数、 请求类型、请求平均耗时、 目标吞吐量 等综合考量。
通用计算方法:系统中并发数 ≈ QPS × 平均响应时间(秒)
比如,目标 QPS=1000,平均请求耗时=200ms,1000 × 0.2 = 200即maxThreads,可以设为 200~240(留 20% 冗余)
此外,maxThreads不能孤立设置,需配合以下参数。
| 参数 | 建议值 | 作用 |
|---|---|---|
minSpareThreads | maxThreads × 20% ~ 30% | 保持核心线程,避免频繁创建 / 销毁线程 |
maxQueueSize | 1000~5000 | 适当让请求排队等候,避免直接拒绝服务 |
connectionTimeout | 20000~30000(ms) | 控制连接等待超时,防止慢请求占住线程 |