3.2 IO 模型演进:BIO、NIO 与线程池 本节摘要:三种 IO 模型的全部差异可以压缩成一句话——谁来等数据。BIO 派一个线程死等,NIO 由轮询器统一守望、数据到了才叫线程,NIO2 与 APR 是这一思想在不同底层的实现。本节用一张对比矩阵、一次线程数实测和一道容量算术,把 acceptCount、maxConnections、maxThreads 三道闸门的配合关系讲透,并给出连接器层面的调参方法论。 3.1 认清了连接器的岗位,本节讲这些岗位的"工作制度"——IO 模型决定了线程怎么花、连接怎么等。这里的结论直接服务第 7 章的调优实战,是全书参数讨论密度最高的一节。 分水岭:谁来等数据 网络 IO 里最冤的活是"等"。
本节摘要:三种 IO 模型的全部差异可以压缩成一句话——谁来等数据。BIO 派一个线程死等,NIO 由轮询器统一守望、数据到了才叫线程,NIO2 与 APR 是这一思想在不同底层的实现。本节用一张对比矩阵、一次线程数实测和一道容量算术,把 acceptCount、maxConnections、maxThreads 三道闸门的配合关系讲透,并给出连接器层面的调参方法论。
3.1 认清了连接器的岗位,本节讲这些岗位的"工作制度"——IO 模型决定了线程怎么花、连接怎么等。这里的结论直接服务第 7 章的调优实战,是全书参数讨论密度最高的一节。
网络 IO 里最冤的活是"等"。客户端把请求头发到一半去喝口水,服务器这边读不到数据,总得有人守着。三种模型的差别就在守的方式。
BIO(阻塞 IO)的制度最笨:一条连接从进门到关闭,独占一个工作线程,线程在读不到数据时就阻塞干等。200 条慢连接就是 200 个线程在岗打盹——线程是 JVM 里最贵的资源之一,栈内存按 MB 计,上下文切换按微秒计,BIO 的并发天花板因此卡在几百连接的量级。Tomcat 7 及更早的默认就是它,那个年代"Tomcat 扛不住大并发"的口碑由此而来。
NIO(非阻塞 IO)换了制度:3.1 的 Poller 拿着 selector 统一守望成百上千条连接,谁的数据到了才把谁交给工作线程,线程只干"数据在手"的活。连接数与线程数解耦,一台普通服务器扛几万条空闲连接不再费力。Tomcat 8 起默认 NIO,老口碑从此翻案。NIO2(JDK 7 的异步 IO)与 APR(本地库实现)是同一思想的不同落地:前者把等待也交给操作系统回调,后者调用本地代码贴近内核,性能差距在日常负载下并不显著,选型看运维习惯而非 benchmark 数字。

空口对比不过瘾,做一次小实验。先配一个 BIO 连接器(Tomcat 9 还保留着实现类),再压 300 条并发连接,观察线程行为。
<!-- server.xml:临时启用 BIO 以便对照(10.x 已移除请用 9 做实验) --> <Connector port="8081" protocol="org.apache.coyote.http11.Http11Protocol" maxThreads="600"/> <!-- 对照组:NIO --> <Connector port="8082" protocol="org.apache.coyote.http11.Http11NioProtocol" maxThreads="200" maxConnections="8192"/>
# 用连接保持工具制造 300 条空闲连接 分别打两个端口 # 观察两边工作线程数量差异 pid=$(ps -ef | grep catalina | grep -v grep | awk '{print $2}') jstack $pid | grep -c 'http-bio-8081-exec' jstack $pid | grep -c 'http-nio-8082-exec'
298 10
同样的 300 条连接,BIO 侧起了近 300 个 exec 线程(打盹也在岗),NIO 侧只用 10 个常备线程(minSpareThreads 的值)守望。这就是"谁来等数据"的代价差异,比任何文字描述都直观。实验后记得删掉 BIO 连接器。
acceptCount、maxConnections、maxThreads 三道闸门从外到内串联,任何一道都可能成为瓶颈,配置它们要用同一套容量算术,而不是各抄各的"最佳实践"。
第一道:maxConnections 是城内容量,NIO 默认 8192,含义是同一时刻最多接纳这么多条连接,超出后新连接在操作系统队列里排队。第二道:acceptCount 是门外队列,默认 100,城内满了才用得上它。第三道:maxThreads 是办事窗口,默认 200,决定同时在处理的请求数。
一道算术把三者串起来:假设平均每个请求业务耗时 50 毫秒(含数据库与下游调用),单个线程每秒可完成 20 个请求,200 线程的理论吞吐是每秒 4000 请求。如果压测目标只有每秒 500,那 maxThreads 留 50 就够,多出来的线程只是占内存;反过来,若目标是每秒 8000,加线程之前先问下游数据库能不能扛——窗口开得再多,仓库发货跟不上,队伍只会排到城里。线程数的上限由下游最慢的一环决定,这是调参第一守则。
排队的代价也要算。城内满、门外排队的连接,客户端感受是连接建立慢;窗口满、任务排队的请求,感受是已连接但响应慢。前者调 maxConnections,后者调 maxThreads 或优化业务耗时——3.1 的"变慢与被拒"二分法在此落地成参数动作。
💡 关键直觉:三道闸门的默认值(8192、100、200)对多数应用是够用的。真正要按容量算术重算的场景是:请求耗时长(拉高线程需求)、长连接多(拉高连接容量)、下游有硬上限(限制线程数)。没有压测数据就别动它们,调参迷信比默认值危险。
六个连接器参数一张表收齐,最后一列写清"什么时候才该动它"——没有对应的观测信号,就保持默认。
| 参数 | 默认值 | 作用岗位 | 动它的前提信号 |
|---|---|---|---|
| maxThreads | 200 | 工作线程池 | 线程全忙且下游还有余量 |
| minSpareThreads | 10 | 工作线程池 | 突发流量下首波请求排队 |
| maxConnections | 8192 | 城内容量 | 连接数曲线触顶且出现被拒 |
| acceptCount | 100 | 门外等待队列 | 峰值期连接被拒但城内容量未满 |
| connectionTimeout | 20000 毫秒 | 空闲回收 | 大量慢客户端长期占线 |
| keepAliveTimeout | 同连接超时 | 长连接空闲 | 空闲长连接数巨大 |
最后强调一次联动:这些参数不独立生效。maxThreads 提到 400,4.3 的连接池 maxTotal 还是 50,多出来的线程全在等连接——白忙。改任何一个参数前,把它上下游的闸门一起过一遍,这正是"参数有住址"在连接器一段的具体化。
连接器会接客了,下一节把它接到真实网络:多端口、HTTPS 证书、以及那个叫 AJP 的内网专线。