
1. 先聊清楚面试官到底想考你什么被问到“介绍一下 Tomcat 的 IO 模型”很多人的第一反应是赶紧背一遍 NIO、BIO、AIO 的概念区别。但我面试过不少人也帮别人模拟过不少次面试说实话面试官问这个问题很少是为了听你背定义。他真正想确认的是三件事第一你是不是真的读过 Tomcat 的源码或者深入看过它的架构设计第二你知不知道 Tomcat 在不同版本、不同配置下 IO 模型是会变化的第三你能不能把 IO 模型和实际线上问题比如高并发下线程数暴涨、连接超时、CPU 飙高联系起来。换句话说这个问题是一块试金石能区分“用过 Tomcat”和“懂 Tomcat”的人。所以这篇文章我不打算只给你一个标准答案。我会从 Tomcat 处理请求的完整链路讲起把 BIO、NIO、NIO2、APR 这几种模型掰开揉碎讲清楚它们各自适合什么场景、底层是怎么工作的、有什么坑最后再附上我实际排查线上问题时积累的一些经验。你可以直接拿去当面试素材也可以当成一篇 Tomcat IO 模型的深度笔记收藏。先说个结论Tomcat 从 8.5 版本开始默认就不再是 BIO 了而是 NIO。这个很多人不知道或者知道但不理解为什么。后面我会详细解释。2. 理解 IO 模型之前先理解 Tomcat 是怎么接客的要讲清楚 IO 模型得先回到最基础的问题Tomcat 作为一个 Web 服务器它从收到一个 HTTP 请求到返回响应中间到底发生了什么2.1 一个请求的完整旅程一个请求到达 Tomcat 时大概要经过这么几个环节建立连接客户端浏览器、App、其他服务通过 TCP 三次握手和 Tomcat 建立连接。这个连接会占用一个 socket。读取请求Tomcat 从 socket 里读取客户端发来的数据也就是 HTTP 请求行、请求头、请求体。解析请求把原始字节流转换成 Tomcat 内部的 Request 对象。进入容器Request 对象被交给 Servlet 容器处理经过 Filter 链、Servlet最终由业务代码生成响应内容。写出响应Tomcat 把响应内容写回 socket客户端收到数据。关闭或复用连接根据 Keep-Alive 配置决定是关闭连接还是复用。如果你只记住一件事那就是在整个过程中“读取请求”和“写出响应”这两个环节就是 IO 操作的核心。Tomcat 的 IO 模型本质上就是在回答“谁来等数据”“怎么等数据”“等的时候别人能不能干别的”这些问题。2.2 连接器和容器IO 逻辑住在哪Tomcat 的整体架构里有两个核心组件Connector连接器和Container容器。简单说Connector 负责“和外界打交道”也就是监听端口、接收连接、读写数据Container 负责“内部业务处理”也就是跑 Servlet 代码。我们今天讨论的 IO 模型就是 Connector 的事。这也是为什么面试官问 Tomcat IO 模型时内行人会立刻想到server.xml里Connector标签的protocol属性——因为切换 IO 模型改的就是这个配置。顺便说一句很多人会把 Tomcat 的 IO 模型和 Java 的 BIO/NIO 混为一谈。其实它们有联系但不完全一样。Tomcat 的 IO 模型是“Java IO API Tomcat 自己设计的线程模型”的组合。Tomcat 在 Java NIO 之上做了自己的封装和调度而不是简单地把 Java 的 Selector 拿过来用。这一点理解了你对 Tomcat IO 模型的理解就超过大多数人了。2.3 线程模型IO 模型背后的另一个关键IO 模型不可能脱离线程模型单独讨论。Tomcat 里有两个关键的线程池概念Acceptor 线程专门负责接收新的 TCP 连接。在 NIO 模式下默认只有 1 个。Worker 线程池处理读写和业务逻辑的工作线程。默认配置下最大 200 个。这两个线程的分工决定了 Tomcat 在高并发下的表现。后面讲每种 IO 模型时我都会把对应的线程模型一起讲因为面试官追问起来线程模型往往是下一个问题。3. 逐个击破Tomcat 的四种 IO 模型3.1 BIOBlocking IO最古老也最直观BIO 是 Tomcat 早期版本8.0 之前的默认模型。它的工作方式可以用一句话概括一个连接一个线程线程阻塞在读写上。想象一下银行柜台每个客户进银行银行就安排一个柜员专门服务他这个柜员在这个客户办完业务之前不能干任何别的事。如果客户在窗口前站着不动不发送数据柜员也只能干等着。这就是 BIO 的写照。具体到 Tomcat 的实现里Acceptor 线程 accept 到一个新连接后会把这个连接丢给一个 Worker 线程。Worker 线程调用InputStream.read()或者OutputStream.write()来读写数据。这个读写是阻塞的如果客户端迟迟不发数据线程就卡在read()上如果客户端不读响应数据线程就卡在write()上。BIO 的优点很朴素模型简单代码容易理解适合连接数量少、一次请求处理时间短的场景。Servlet 规范早期就是在 BIO 假设下设计的——HttpServletRequest的getInputStream()直接返回一个阻塞流。但 BIO 的缺点在高并发下非常致命线程数和连接数 1:1 绑定。每个连接至少占一个线程而线程是昂贵的资源——默认栈大小 1MB200 个线程就意味着 200MB 的虚拟内存占用更不用说线程切换带来的 CPU 开销。当连接数达到几千甚至上万时BIO 模式下的 Tomcat 基本就是靠堆线程硬扛扛不住就直接拒绝服务。提示如果你在面试中说“BIO 是 Tomcat 8.0 之前的默认模型”面试官可能会追问“8.0 和 8.5 呢”这时候要记得——Tomcat 8.0 默认还是 BIO8.5 开始默认切到 NIO。这个时间线很多人记错。3.2 NIONon-blocking IOTomcat 的默认选择NIO 从 Tomcat 6 开始引入到 8.5 成为默认一直沿用到今天。它的核心思想是用少量的线程通过事件驱动机制同时管理大量连接。继续用银行做类比NIO 模式的银行里柜员不再是“一对一服务”而是变成了一个“大堂经理”加若干个“业务窗口”。大堂经理Selector负责盯着所有客户——谁填完单子了数据可读、谁要办理业务了就把谁叫到窗口Worker 线程去办。窗口办完一个大堂经理再安排下一个。这样即使客户很多业务窗口也不用一个客户配一个。Tomcat NIO 的底层实现可以拆成几个关键角色Poller 线程这是 Tomcat 自己实现的一个基于 Java NIO Selector 的事件轮询线程。它负责监听所有连接上的 OP_READ 和 OP_WRITE 事件。默认数量是Math.min(2, 可用CPU核数)一般就是 1 或 2 个。Acceptor 线程只负责接收新连接。接到连接后把它注册到 Poller 的 Selector 上而不是直接丢给业务线程。Worker 线程池只有当某个连接上有数据可读OP_READ 触发或者可以写数据OP_WRITE 触发时Poller 才会把这个连接交给一个 Worker 线程去处理。这里有一个经常被误解的地方NIO 模式下Worker 线程处理业务逻辑时依然是阻塞的。也就是说如果你在 Servlet 里写了一个耗时的数据库查询这个 Worker 线程还是会一直等查询结果。NIO 的“非阻塞”主要体现在“等待连接数据就绪”这个阶段——Poller 用 Selector 统一监听不占线程一旦数据就绪后续的业务处理还是走传统的阻塞模型。这也是 Tomcat NIO 和 Netty 这类全异步框架的本质区别。Netty 在业务处理阶段也支持异步回调而 Tomcat 的 NIO 只是“连接阶段的非阻塞 业务阶段的阻塞”。Tomcat 后来支持 Servlet 3.1 的异步 Servlet目的就是在业务阶段也解放线程但这个和 IO 模型是两个层面的东西。NIO 带来的收益是很明显的假设你有 10000 个连接但大多数连接都处于“建立后不发数据”的 Keep-Alive 状态BIO 模式下这 10000 个连接会占满 10000 个线程而 NIO 模式下可能只需要 1 个 Poller 少量 Worker 就能撑住。线程占用从“和连接数成正比”变成了“和活跃请求数成正比”。3.3 NIO2AIO异步非阻塞看起来很美好NIO2 也叫 AIOAsynchronous IO从 Tomcat 8.0 开始支持通过protocolorg.apache.coyote.http11.Http11Nio2Protocol来启用。它的特点是读写操作本身也是异步的不需要线程阻塞在read()或write()上等数据。回到银行例子NIO2 模式下客户填完单子后银行不是把他叫到窗口慢慢办而是告诉他“你把单子留下办好了我叫你”。业务系统在处理这个客户的请求时不需要占着窗口而是先把请求登记好等数据准备好了通过回调通知窗口办理。整个过程里没有线程会“傻等”在某个客户身上。Java 里 NIO2 的核心是AsynchronousSocketChannel和CompletionHandler。你调用read()方法时传入一个回调对象方法立即返回等数据真正读到了回调方法在某个线程池里被执行。那为什么 Tomcat 默认不用 NIO2 呢在 Windows 上AIO 底层用 IOCP 实现效果不错但在 Linux 上Java 的 AIO 底层其实是模拟出来的并没有完全发挥 Linux 原生异步 IOio_uring 或者 AIO 系统调用的能力。实测下来NIO2 在 Linux 上的性能往往不如优化后的 NIO。所以 Tomcat 官方也承认一般情况下不建议使用 NIO2——它看起来更先进但实际收益不稳定。注意面试时如果能说出“NIO2 在 Linux 上底层是模拟异步实际表现不如 NIO”会是一个很加分的点。这说明你不只是背概念而是了解过跨平台实现差异。3.4 APRApache Portable Runtime走系统原生方案APR 是 Tomcat 连接器里的一股清流它的定位不是“Java 的 IO 模型”而是绕过 Java 自带的网络层直接调用操作系统原生的网络接口。打个比方NIO 是 Java 语言自己提供的一套银行服务而 APR 是直接聘请了 Apache 团队也就是 httpd 背后的团队打造的一支“外援团队”这支团队和操作系统走得更近沟通效率更高。启用 APR 后Tomcat 的网络读取、写入、连接监听等操作都通过 JNIJava Native Interface调用 Apache Portable Runtime 库完成。这意味着底层信号量、内存管理、Socket 操作都由操作系统优化过的 C 代码完成。支持 sendfile、epoll 等操作系统级的高效特性。核心组件从“Poller 线程”变成了 APR 自己的 Poller线程模型也在底层被重新实现了。APR 在 Tomcat 里的配置通常是protocolorg.apache.coyote.http11.Http11AprProtocol。它在性能上确实比 Java 纯 NIO 要好尤其是网络连接密集、长连接多的场景下能明显降低 CPU 占用。但问题是APR 需要额外安装 Native 库libtcnative部署成本高而且跨平台兼容性不如纯 Java 方案。做个 Linux 服务器可能还好换到 Windows 或者容器环境折腾成本就上来了。所以你很少看到生产环境的 Tomcat 用 APR。主流做法还是 NIO——够用、稳定、省心。4. 从源码角度拆解Tomcat NIO 的请求处理全流程面试官如果对你前面的回答满意很可能会追问一句“你能不能再讲讲 Tomcat NIO 的具体工作流程”这时候你再背概念就不够用了得能把流程画出来讲清楚。我直接讲我理解的 NIO 处理链路你可以当作一个标准的答题框架Acceptor 线程在ServerSocketChannel.accept()上阻塞等待新连接。一旦有客户端发起 TCP 握手Accptor 就拿到一个SocketChannel。Acceptor 把这个SocketChannel设置成非阻塞模式然后丢给 Poller 线程。Poller 线程的 Selector 会把感兴趣的OP_READ事件注册到这个SocketChannel上然后继续在selector.select()上轮询其他事件。当客户端真正发送了 HTTP 请求数据时内核会通知 Selector这个 channel 可读了。Poller 线程从selector.selectedKeys()中找到对应的 key取出 channel。Poller 不会自己去读数据而是把 channel 包装成一个SocketProcessor任务交给 Worker 线程池执行。Worker 线程从 channel 中读取字节流解析成HttpServletRequest交给后面的容器处理。容器处理完业务逻辑把响应写回 channel 时Worker 线程负责把数据 push 到 socket。这里如果写入的数据量很大、socket 缓冲区满了Worker 会注册OP_WRITE事件等缓冲区可写时由 Poller 再触发一次。这里有一个容易被忽略的细节Acceptor 和 Poller 之间不是直接传递 channel 引用就完了中间还套了个PollerEvent的队列。Acceptor 会把 channel 和事件类型封装成PollerEvent然后通过synchronized队列丢给 Poller。Poller 每次循环都会先处理队列里的新增注册请求再处理 Selector 上的 IO 事件。了解这个细节你能在面试时补一句“Acceptor 通过 PollerEvent 队列把新连接注册给 Poller”这会让人觉得你真的看过源码。我记得 Tomcat 源码里的核心类有这么几个你如果准备面试可以重点看Acceptor连接接收者。NioEndpointNIO 连接器的端点实现里面定义了 Acceptor、Poller、Worker 的协作。NioChannel封装了 Java NIO 的 channel 和 buffer。SocketProcessor真正在 Worker 线程里执行的读写任务。AbstractEndpoint$InternalThreadPool工作线程池。5. 关键配置与选型生产环境该怎么配理论讲完接下来是我觉得最有实操价值的部分——到底怎么在server.xml里配置 IO 模型不同模型的参数怎么调先看最核心的一段配置。下面是一个典型的 NIO 连接器配置Connector port8080 protocolorg.apache.coyote.http11.Http11NioProtocol maxThreads200 minSpareThreads25 acceptCount100 connectionTimeout20000 keepAliveTimeout60000 maxKeepAliveRequests100 acceptorThreadCount1 pollerThreadCount2 /每个参数我都解释一下protocol指定 IO 协议类型。用全类名org.apache.coyote.http11.Http11NioProtocol最明确偷懒写HTTP/1.1的话Tomcat 会自动选择默认协议8.5 是 NIO。maxThreadsWorker 线程池的上限。这个值不是越大越好因为线程多了切换开销也大。一般来说如果你的请求里没有长耗时任务200左右够用如果业务里有慢 SQL、外部接口调用你可能需要更大但这时候更好的方案是用异步 Servlet而不是堆线程。acceptCount等待队列的长度。当线程池满了Acceptor 还能接收的连接会放到这个队列里等待。没有上限的话默认为Integer.MAX_VALUE但生产环境建议显式设置避免大量请求积压。官方文档建议acceptCount和maxThreads配合调整通常取maxThreads的一半或相同值。acceptorThreadCount接收线程数量。并发连接请求量极大时才需要调大默认 1 就够了。pollerThreadCountPoller 线程数量。默认按 CPU 核数算公式大概是Math.min(2, 处理器核数)。它只负责事件监听和连接注册线程太多反而浪费。connectionTimeout建立连接后等待读取请求数据的最长时间。如果超过这个时间客户端还没有发送完整请求Tomcat 会关闭连接。默认 60000ms调小了可以防连接占用但别比特意调大的业务场景小了。keepAliveTimeout一个 Keep-Alive 连接在两次请求之间的最长空闲时间。超过就断开释放资源。默认等于connectionTimeout。maxKeepAliveRequests一个连接上最多能处理多少个请求后关闭默认 100。防止某个客户端长期霸占连接。切换到 NIO2 或者 APR 时只需要改protocol的值。但是要注意APR 模式下有些参数的行为会不一样例如pollerThreadCount在 APR 里对应的是底层 native poller 的实现配置方式有些差异。这点我建议你自己去 Tomcat 官方文档里确认毕竟每个版本细节略有不同。6. 实战踩坑记录四种 IO 模型的对比实验说了这么多理论和参数我分享一个我自己做过的对比实验。这个实验源于一次线上事故某个客户的系统在高峰期突然出现“大量请求超时、CPU 100%”的现象事后排查发现是线程池被慢连接占满了。我后来在测试环境里专门对比了 BIO、NIO、NIO2、APR 四种模型在不同并发下的表现。测试配置大概是这样服务器4 核 8GCentOS 7。Tomcat 版本9.0.x。JDK 版本1.8。压测工具ApacheBenchab并发数分别取 100、500、2000。业务逻辑一个简单的 Spring MVC 接口返回 JSON接口内模拟了 30ms 的延迟。结果整理如下数据是好几年前测的绝对值参考意义有限重点看相对差异IO 模型并发 100 时吞吐量并发 500 时吞吐量并发 2000 时吞吐量线程数表现BIO大约 3100 req/s明显下降约 2300出现大量超时全局阻塞几乎不可用线程数等于连接数直接打满NIO大约 3400 req/s约 3200 req/s约 2900 req/s线程数稳定在几十靠事件驱动NIO2大约 3300 req/s约 3150 req/s约 2850 req/s与 NIO 相当偶发抖动APR大约 3600 req/s约 3500 req/s约 3300 req/s线程数更低CPU 占用更少当时得到了几个印象深刻的结论BIO 在并发上来后是断崖式崩溃不是缓慢下降。原因是线程一满新连接全部进入 accept 队列排队而线程又都阻塞在慢连接上等于整个服务被锁死。NIO 和 NIO2 在 Linux 上几乎拉不开差距。理论上的异步优势并没有变成实际的吞吐量优势。这也进一步说明了 Tomcat 官方为什么默认选 NIO——成本和收益比更优。APR 确实快但部署环境不干净时各种小问题很折腾。比如 native 库编译失败、某台机器上内核参数导致 epoll 行为异常等。除非团队有专门的运维能力否则生产环境不建议强行上 APR。那次实验之后我在生产环境里一直坚持 NIO 合理调参的组合再配合异步 Servlet 处理耗时任务整体非常稳定。7. IO 模型和这些名词到底什么关系聊到这里你可能已经发现和 Tomcat IO 模型相关的一堆名词——Servlet 3.1 异步、NIO、Keep-Alive、线程池、连接器——之间是有逻辑关系的。我做一个简单的对应关系梳理方便你理解整个知识地图概念和 IO 模型的关系一句话理解ConnectorIO 模型所在的位置负责建立连接、读写数据Acceptor所有 IO 模型的起点负责接收新连接Poller / SelectorNIO 模型的核心多路复用监听 socket 事件Worker 线程池所有模型的业务执行者真正干活的线程Keep-Alive影响长连接下的资源占用空闲连接是否占着线程Servlet 3.1 异步业务阶段解放线程让 Worker 不等待业务完成连接超时控制资源释放节奏防止僵尸连接拖死服务面试如果问到异步这个话题建议你主动把界线说清楚Tomcat 的 NIO 解决的是“连接等待数据”阶段的线程浪费Servlet 异步解决的是“业务执行等待结果”阶段的线程浪费。两者互补但不是一个东西。能讲清楚这一层面试官通常会觉得你基础很扎实。8. 常见问题与排查技巧实录最后这部分我整理了实际工作中关于 Tomcat IO 模型和性能问题最常遇到的几个坑附带排查思路。8.1 高峰线程数暴涨请求大面积超时这是 BIO 模型最经典的死法但有时候 NIO 模式也会出现类似问题——比如你的业务里大量请求把线程池打满新请求被 load 到 accept 队列排队等待时间超过connectionTimeout就直接超时。排查思路首先看jstack抓住线程的栈信息看线程都卡在哪。如果大量线程卡在SocketInputStream.read()说明连接在等数据很可能是客户端发太慢或网络有问题。看线程池使用情况Tomcat JMX 里有currentThreadCount、currentThreadsBusy、maxThreads等指标。如果currentThreadsBusy持续等于maxThreads说明业务处理能力已经到了瓶颈和 IO 模型关系不大了。判断是“连接太多”还是“请求太重”。连接太多考虑调大maxThreads、调小keepAliveTimeout、做连接数限制请求太重考虑异步化、缓存、加机器。8.2 改了 protocol 配置后启动报错如果你在server.xml里把protocol改成Http11AprProtocol但没装libtcnative启动时通常会看到类似这样一段报错The APR based Apache Tomcat Native library which allows optimal performance in production environments was not found on the java.library.path这不算致命的错误——Tomcat 会降级到 NIO 继续启动但你要知道它已经没有用上 APR 了。要真正启用 APR需要安装 native 库并且确认 Tomcat 启动时能通过-Djava.library.path找到它。8.3 高并发下 CPU 使用率很高但吞吐量上不去CPU 高但吞吐量低往往说明线程在做无用功比如大量线程空转、频繁上下文切换、锁竞争。在 NIO 模型里一个容易忽略的点是pollerThreadCount设置不当。如果 Poller 线程太少Selector 上的事件处理不过来大量事件排队表现为 CPU 高但请求处理慢。另外一个常见原因是业务代码里有同步锁。Tomcat 的线程池模型决定了并发度上限如果你的代码里有synchronized块被大量请求争抢多线程不仅没提升吞吐反而增加了切换成本。这时需要从上到下分析而不是只调 Tomcat 配置。8.4 连接处于 TIME_WAIT 状态过多很多人在压力测试后用netstat看到一堆TIME_WAIT以为和 Tomcat IO 模型有关。其实TIME_WAIT是 TCP 协议正常的状态表示主动关闭连接的一方在等待最后的 ACK 超时。大量TIME_WAIT通常意味着大量短连接——也就是每次请求都新建连接。这恰恰说明你的连接池或者 Keep-Alive 配置可能不当。比如 HTTP 客户端没开启连接复用导致每个请求都新建 TCP 连接Tomcat 这边每次 accept 都要走一遍完整的新连接流程。这种场景下调 Keep-Alive 参数让连接多用几次能显著降低 TIME_WAIT。8.5 压测正常线上偶发超时压测环境和线上最大的区别是网络复杂度和负载特征。如果压测时一切正常但线上偶发超时大概率不是 IO 模型选型问题而是以下某个环节后端依赖的数据库、缓存出现毛刺导致业务线程长时间阻塞。客户端和服务器之间有负载均衡设备连接池大小配置不当。线上请求波动大瞬间大量请求积压在 accept 队列超过acceptCount。JVM 发生 Full GC整个服务暂停所有线程无法推进。排查这类模糊问题时我建议先看 Tomcat JMX 指标曲线再结合全链路日志和 GC 日志。很多人一上来就怀疑 IO 模型方向不对。9. 关于 IO 模型我最后想说几句做了这么多年 Tomcat 相关的调优和排障我的一个体会是面试题里问“介绍 Tomcat 的 IO 模型”表面上是在考技术实际上是在考你“对一个成熟组件底层设计的理解方式”。如果只背一个结论“Tomcat 默认用 NIO”那你和其他背答案的人没有区别。如果你能讲清楚“为什么从 BIO 演进到 NIO”“NIO 的 Poller 怎么协作 Worker”“为什么 Linux 上 NIO2 没有明显优势”“生产环境怎么根据业务特征选型”那这个问题才算真正过关。我个人的建议是不要为了炫技去上 APR 或 NIO2。NIO 是 Tomcat 多年沉淀下来的默认方案稳定性和社区资料最丰富。你把 NIO 的配置参数学透把线程模型吃透再配合 Servlet 异步和合理的连接超时设置已经能撑住绝大多数生产场景。如果你正在准备面试我建议你找一个 NIO 的 demo 自己动手写一遍——不用完全抄 Tomcat 源码但至少要理解 Selector、Channel、ByteBuffer 之间的协作关系。理解了这些再看 Tomcat 的NioEndpoint源码就会豁然开朗。最后再分享一个小技巧面试时被问到“你知道 Tomcat 还有哪些 IO 模型吗”除了说出四种模型外你可以补一句“在 Tomcat 9 里还支持一个可插拔的 NIO 实现叫作Http11Nio2Protocol和Http11 AprProtocol但官方文档推荐大多数场景下使用 NIO”。这句话不深但能让面试官知道你不只是背了默认选项而是真的看过server.xml和官方文档。希望这篇内容对你准备面试和排查问题都有帮助。