Java I/O:阻塞、Reactor 与虚拟线程的等待成本
隔了十一年回头看,《Java 并发:锁、AQS 与 CAS 的边界》里把锁、AQS 和 CAS 放在一起比过一遍。那里面的等待发生在内存里:线程 park 住,醒来时条件已经变了,等的是一个状态。这篇换一种等待——一个请求进来,先查数据库,再调下游服务,两行业务代码之间绝大部分墙钟时间都花在等网络上。
等网络不产生错误,但它要占地方。手边几本资料讲这段时的口径还停在十几年前:一个线程 1MB 栈,一万个连接就把内存吃光,所以要用 NIO。方向没错,可是数字、平台默认值和 JDK 版本都已经对不上了。这篇笔记按现在的实现把这笔账重算一遍:一个请求在等的时候到底有什么被占住,换一种组织方式以后被占住的东西换成了什么,以及为什么换完之后超时和限流还是一个都不能省。
阻塞调用写起来直接,等待也有成本
用平台线程做阻塞 I/O(BIO,Blocking I/O),代码可以顺着请求过程往下写:取连接、发 SQL、读结果、序列化、发下游。数据没就绪时线程停在 socketRead0 上,这段时间里被占住的东西有几样。
- 一个平台线程。它属于某个线程池,而池子是有上限的。Tomcat 的
maxThreads默认 200,用满之后第 201 个连接只能待在 accept 队列里,客户端看到的是连上了却迟迟没有响应。 - 线程栈的地址空间。这个数字随平台和 JDK 版本变,不该认定每个线程总是固定占 1MB:笔者的 macOS 上跑
java -XX:+PrintFlagsFinal -version,ThreadStackSize打出来是 2048(单位 KB),-Xss可以覆盖它。更要紧的是这笔空间是预留的:一个停在 socket read 上的线程碰过的栈页通常只有几十 KB,物理页按实际用量提交。拿它算内存占用会高估,拿它算「还开得出多少线程」才是对的。 - 一个文件描述符(fd,file descriptor),以及它对应的一条连接。
还有第四样,它跟线程模型没有关系:数据库连接池里的一条连接。等待期间它同样被这个请求独占着,而池子的大小和 I/O 用哪种模型完全无关。这一点后面还会回来。
这些线程在 jstack 里的样子值得单独提一句:它们大多显示为 RUNNABLE,栈顶停在 socketRead0 这类 native 方法上。它们不是卡在锁上的那种 WAITING,只看线程状态统计很容易看漏——RUNNABLE 有 180 个,不代表这 180 个都在烧 CPU。
accept 队列和线程池是两个不同的上限,混起来会找错方向。连接已经完成三次握手、排在队列里等着被应用取走时,客户端看到的是连上了但没响应;连 accept 队列都满了,新的 SYN 会被直接丢掉,客户端看到的是连接超时。前者的入口是线程池配置,后者的入口是 backlog 和内核参数。
线程池满了以后怎么办,取决于队列类型。原生的 ThreadPoolExecutor 配无界队列时,核心线程满了任务会先进队列而不是先开新线程,于是 maximumPoolSize 几乎永远到不了;换成有界队列,线程数才会被推到上限,再满则触发拒绝策略。这两种配置的差别不在数字大小上,在「满了之后请求去哪儿」上。
「一个连接一个线程」是常见设计,不是阻塞 I/O 的强制规则。线程池可以调小,超出的请求排队或者直接拒绝,只是队没有边界时问题不会消失——它从线程数量换成排队延迟和堆内存:排队的每个请求还各自攥着一条连接和一份请求体。
要不要动它,先看两个数:jstack 里有多少线程卡在网络读上,以及线程池队列长度是不是和 p99 延迟一起往上走。阻塞等待的服务 CPU 往往很闲,用 CPU 使用率判断这件事几乎没有信息量。
Selector 让少量线程看住大量连接
NIO 起初是 New I/O 的缩写,后来更多人按 Non-blocking I/O 理解,两个说法都不够准确:java.nio 包里既有阻塞能力也有非阻塞能力,能否非阻塞取决于通道类型。能注册到 Selector(多路复用器)的只有 SelectableChannel 的子类——SocketChannel、ServerSocketChannel、DatagramChannel 可以,FileChannel 不行,它连 configureBlocking() 都没有,因为文件 I/O 在多数系统上并不存在「就绪」这个概念。
机制本身不复杂。selector.select() 是一次系统调用,问内核「这批 fd 里哪些现在有事」;Linux 上 JDK 走 epoll,macOS 上走 kqueue。返回之后由应用决定处理谁、怎么处理。线程不再陪着某一个连接等,而是陪着一批连接等。骨架大概长这样:
Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (running) {
selector.select(); // 停在这里,等任意一条连接有事
var it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
if (key.isAcceptable()) {
// 接受连接,注册 OP_READ
} else if (key.isReadable()) {
// read() 一次,可能只拿到半条消息
}
it.remove(); // 不删,下一轮 select() 还会把它带回来
}
}
selectedKeys() 交回的是一个累积集合,处理完必须自己 iterator.remove()。漏掉这一行,下一次 select() 返回时同一个 key 还在里面,事件被重复处理——这个 bug 在低流量下几乎看不出来,压测时才冒头。
注册之后,关注的事件集合也还能改:一条连接在凑齐一条完整消息之前,可以先把 OP_READ 摘掉,等已读到的部分处理完再加回来。interestOps 是应用主动调节就绪通知的开关——关注什么、什么时候关注,都由这一侧决定。
这里还有个流传很广的说法要纠正:Selector 最多只能管 1024 个连接。1024 是 select(2) 这个系统调用的 FD_SETSIZE,而 JDK 的 Selector 在 Linux 上根本没走 select。epoll 没有这个上限,先撞上的通常是进程的 fd 上限:ulimit -n 在很多发行版上默认就是 1024,现象看着像,原因完全不同。
就绪也不等于读完了。read() 交回的是「这一次拿到了多少字节」:0 表示此刻没有数据可读,-1 表示对端关闭、到达流末尾,中间的任何正数都可能只是一条消息的一部分。TCP 是字节流,消息边界只能由应用协议定义——长度前缀、固定分隔符或者定长。半包和粘包不是 NIO 引入的毛病,在阻塞流里一次 read() 同样不保证读到一条完整的业务消息,只是阻塞模型会替你继续等下去,把问题藏了起来。
这也是为什么 Reactor 实现里每条连接都得挂一个自己的状态对象:一个读缓冲、当前解到哪一步、长度字段收齐没有。NIO 省下的是线程,没省下状态——状态只是从线程栈搬到了堆上,而且从此由应用自己管生命周期。
Selector 能告诉你哪些连接可以动,却没规定谁去动它们。这正是 Reactor 要回答的。
Reactor 的三种形态,差别在谁做重活
Reactor 这个模式的经典出处是 Doug Lea 2003 年的 Scalable IO in Java,它是一份模式分类,不是框架规范。名字的来历也不复杂:应用不再主动轮询「有数据了吗」,事件到了反过来驱动处理,一环推一环。三种形态共享同一条纪律——处理事件的线程绝不能阻塞,否则它负责的那批连接一起停摆;区别只在这条纪律由几个线程、分几层来守。
单 Reactor 单线程:一个线程包办 accept、read、decode 和业务处理。连接数少、每次处理都快的时候它最省事,配置中心的长轮询、内部管理端的小接口都够用,代码里不用加锁,因为压根只有一个线程。它的失效方式同样干脆:业务处理里出现一次慢查询,所有连接一起等——包括那些本来只是来读一个开关值的。
单 Reactor 多线程:Reactor 线程只管 accept 和读写事件,把 decode 与业务处理丢给工作线程池。连接数可以做得很大,代价是「就绪 → 派发」这一段仍然是单线程,事件量一上来 Reactor 线程先饱和。业务落到多个线程上以后,共享状态也要自己加锁,并发那篇里的问题换了个地方又回来了。
主从 Reactor:mainReactor 只负责 accept,把新连接注册到某个 subReactor 上;每个 subReactor 自带一个 Selector 和一个线程,管住自己那批连接的读写。连接被摊到多个事件循环上,单个循环的压力就下来了。Netty 的服务端就是这第三种形态的产品化版本。
把三种形态排成一条进化路线是常见的简化。实际上第一种今天依然大量活在网关和代理里,第二种是绝大多数 Java 业务框架的默认样子,第三种是通信框架的标配——这三者各自适用的场景至今都还在,排成接力棒反而是失真。
Netty 的 EventLoop:一个线程、一个 Selector、一个队列
EventLoop 底下就三样东西:一条线程、一个 Selector、一个任务队列。NioEventLoop.run() 的循环是 select → 处理就绪的 key → 跑队列里攒下的任务,如此往复。线程数有默认值,new NioEventLoopGroup() 取 max(1, CPU 核数 × 2);服务端示例里给 Boss 组的一般写成 new NioEventLoopGroup(1),因为 accept 本身很轻。这些是默认值,不是必须遵守的规则。
真正值得记住的是分配方式:一条新连接按 round-robin 注册到 worker 组里的某一个 EventLoop,之后这条连接的所有事件都在那一个线程里跑,直到连接关闭。所以是「一批连接共用一个 EventLoop」,不是一对一。这带来一个很大的好处——handler 里不用加锁,因为根本没有第二个线程碰这些状态,Netty 等于把线程封闭(thread confinement)直接送给了业务代码。
线程封闭还有一个不太直观的推论:一个 ChannelHandler 实例默认只能挂到一条连接上。假如写了个带计数器的 handler 想复用到所有连接,Netty 在第二条连接注册时就会直接抛异常;除非标上 @Sharable,而标了之后必须自己保证它没有可变状态。这条规则不是在为难人,它是在替使用者守住「这条连接只有一个线程碰」这个前提。
任务队列解决的是跨线程投递。业务线程想把一件事交给某条连接去做,不能直接动手——那条连接的状态只能由它的 EventLoop 线程碰。于是任务被塞进队列,同时唤醒停在 select() 上的那个线程;后者返回后先跑队列,再处理 I/O 事件。Netty 里所有「从别的线程安全地影响某条连接」的能力,都建立在这条队列上。
代价是对称的:handler 里的一次阻塞会拖住这个 EventLoop 上的所有连接。JDBC 查询、Thread.sleep、同步 HTTP 调用都属于这一类。如果你在事件循环线程里对自己发起的写调用 await(),Netty 会直接抛出 BlockingOperationException——那不是洁癖,那是一个必然的死锁。
正确的做法是把阻塞处理挪出去:给 pipeline 挂一个 DefaultEventExecutorGroup,让耗时 handler 在另一组线程里执行;或者在业务层用线程池接住。ioRatio 调的是 I/O 与任务各占多少时间,默认值 50 表示各占一半预算,调大能让队列里的任务跑得更久,代价是同一循环上的 I/O 事件要多等一会儿。它是个需要看数据的旋钮,不是让阻塞变便宜的手段。
换框架也就换了一批要自己管的东西。NIO 的 ByteBuffer 由 GC 回收,Netty 的 ByteBuf 是池化加引用计数的:少一次内存拷贝和一部分 GC 压力,代价是漏掉 release() 就是内存泄漏——-Dio.netty.leakDetection.level 这个参数存在就是为了这个。
Linux 上还有一层可选项:Netty 的 native transport(EpollEventLoopGroup)绕开 JDK 的 Selector 直接调 epoll,能用上 JDK 没暴露的 socket 选项。代价是多一份平台相关的依赖,跨平台部署时要留个心。
换个角度看,Netty 的做法是承认「一条线程同一时刻只能伺候一条连接」,于是把会阻塞的那部分尽量挪出去。虚拟线程反过来问:如果线程便宜到几乎不用在乎,这一圈还绕不绕。
虚拟线程把等待变便宜,但没让它消失
虚拟线程(virtual thread)从 JDK 21 起正式提供,设计见 JEP 444。它和平台线程的关系是:JVM 把大量虚拟线程调度到少量载体线程(carrier thread)上执行,载体线程本身就是操作系统的平台线程。默认调度器是一个专用 ForkJoinPool,并行度默认等于 CPU 核数,可以用 jdk.virtualThreadScheduler.parallelism 调;jdk.virtualThreadScheduler.maxPoolSize 默认 256,管的是出现 pin 时的补偿上限。
创建方式有两种:Thread.ofVirtual().start(task),或者 Executors.newVirtualThreadPerTaskExecutor()——后者每提交一个任务就开一条新虚拟线程,没有池,也不需要池。
虚拟线程在阻塞点卸载(unmount):栈被搬到堆上,载体线程立刻去跑下一个虚拟线程,等 I/O 就绪再挂载回来。于是「一个等待占一条线程栈」这笔账被换掉了——等待期间占的是堆上一块按需增长的栈数据,而不是预留的 1MB 地址空间。业务代码仍然是同步顺序写法,不必手工拆成回调,这可能才是它最大的价值。
它的调度是协作式的,只有走到 JVM 认识的阻塞点才会卸载。一段纯计算的死循环放在虚拟线程里,载着它的那条载体线程同样会被占满——虚拟线程解决的是等待,不是算力。
问题出在卸载不了的时候,这个现象叫 pinning(钉住):虚拟线程停下来,载体线程跟着一起停。JDK 21 里最常见的一种是在 synchronized 块内阻塞——monitor 的持有关系绑在载体线程上,JVM 没法安全地把栈换出去。Object.wait() 要求持有 monitor,属于同一类。存量代码里 synchronized 遍地都是,所以这个限制在实践中相当扎手。JDK 24 的 JEP 491 把这一条改掉了,monitor 的所有权不再绑在载体线程上,在 synchronized 里阻塞不再 pin,Oracle 的 JDK 24 迁移指南里有这条记录。但 native 帧是另一类,JEP 491 没有覆盖它:栈上一旦有 JNI 调用,JVM 就没法安全地搬走。所以「同步块里不能阻塞」不是所有版本都成立;反过来说,升级到 24 也不代表所有原生调用路径都没了限制。
pin 的后果可以量化。载体线程数默认就是 CPU 核数,假设是 8 核,那就是 8 条载体线程。只要有 8 个虚拟线程各自卡在一个 synchronized 包着的慢调用里,调度器就一条可用载体线程都没有了——剩下的虚拟线程全在排队,现象和线程池打满一模一样,但 jstack 里能看到的平台线程数很少,反而更容易误判成「不是线程的问题」。
诊断有两个口子:JDK 21 上可以用 -Djdk.tracePinnedThreads=full 把 pin 住的调用栈打出来,或者开着 JFR 的 jdk.VirtualThreadPinned 事件长期观察。到了 JDK 24 之后,这两个口子主要用来盯 native 那一类 pin。
ThreadLocal 在这个模型下也要重新审视。它原本是「一条线程一份、随线程销毁」的便宜存储,而平台线程是池化的、数量有限,副本数量有天然上限。虚拟线程可以同时存在几十万条,每条都拷一份 ThreadLocal 里的用户上下文,占的就是实打实的堆内存。ScopedValue 这类作用域化的方案要解决的正是这件事:作用域是代码块而不是线程,出了作用域即失效,更贴合「一次请求一份上下文」这种用法;它现在还停在预览上,所以短期内能做的仍然是控制 ThreadLocal 的副本数量。
有一件事不要做:把虚拟线程再池化成固定数量的 worker,用来「限制并发」。那等于手工把调度器拆掉,再实现一个更差的平台线程池。要限制的是稀缺资源,那就用连接池、Semaphore 这类明确的机制去限。
换线程模型,换不掉下游的容量
现在可以结账了。
三种组织方式改变的只是「等待时占住什么」:阻塞 I/O 占一个平台线程,Reactor 占一个连接状态对象,虚拟线程占堆上一块栈数据。它们不会改变数据库的处理能力,也不能省掉超时、背压和并发限制。
把数字代进去看看。假定一次数据库查询 20ms,连接池按 HikariCP 的默认值配 10 条连接,这一层每秒能过的查询就是 10 × (1000 / 20) = 500 次。把工作线程从 200 提到 2000,或者换成十万个虚拟线程,500 这个数一点都不会动——多出来的等待只是从线程池队列前面挪到了连接池队列前面。虚拟线程换掉的是排队的位置,不是队的长度。
同一个 500 换个算法也一样。按 Little 定律,要在一个平均耗时 20ms 的操作上撑住每秒 500 次,系统里同时存在的请求数必须正好是 500 × 0.02 = 10。连接池那 10 条连接就是这个 10。线程数不在这个等式里——它是排队的地方,不是容量的来源。
超时这一层还容易配歪。HikariCP 的 connectionTimeout 默认 30 秒,意思是拿不到连接最多等 30 秒;可业务侧对外的超时往往是 2 秒。这两个数放在一起,请求会在第 2 秒被上游放弃,而它还在池的队列里继续排到第 30 秒,占着一个将来没人要的位置。池的等待时间必须小于调用方的超时预算,否则排队等来的全是浪费。
下游同理。对方给的 QPS 是合同不是建议;虚拟线程能让本服务轻松发出比过去多几个数量级的请求,也就意味着上游的一次抖动可以被放大成对下游的一次压测。
所以真正要写进代码的还是那几样:每个外部调用带超时,聚合入口有并发上限(Semaphore 或者连接池的大小),队列有界且满了会拒绝。背压(backpressure)在这里的意思很朴素——让等待可见,并且让拒绝发生在自己这一侧,而不是原样转发给下游。
换模型之后最危险的动作是顺手把并发上限调大。阻塞模型里线程数本身就是个粗粒度的限流器,换成虚拟线程等于把它拆了;不补一个有界的信号量或者熔断,第一次下游抖动就能被放大成一次雪崩。
怎么选:先看瓶颈在哪一层
判断顺序可以固定下来。
- 服务已经跑在事件驱动框架上(Netty、WebFlux),运行稳定,慢在数据库慢查询或某个下游接口——该修的是那条查询,重写成虚拟线程大概率没有命中原因。
- 以顺序调用为主的服务,
jstack里大半线程停在 socket read,线程池上限是扩展瓶颈——虚拟线程值得试,在相同负载下验证的成本并不高。 - 长连接数量极大(推送、IM、订阅类),瓶颈在 fd 和每连接的内存开销——事件驱动仍然是现实选择,虚拟线程不会让一条连接变得更便宜。
验证时能拿到的数比吞吐更有指向性:线程状态分布看 jstack,pin 的情况看 JFR 的 jdk.VirtualThreadPinned,等待到底压在哪一层,看连接池的活跃连接数和等待连接的线程数。这几个指标能直接回答「换了模型有没有命中原因」,而峰值 QPS 不能。
除此之外,超时是否按期结束、取消能不能沿着调用链传播、下游有没有被放大流量打满、高峰过去之后资源是否释放,这几条里任何一条不合格,方案就不算通过。模型选对了只完成了一部分,请求从进入到结束的整个生命周期仍然要由应用来管。
这个系列里隔着好几年写的另外两篇,讲的是内存那一侧:一次 Full GC 排查记的是从告警到引用链的顺序,CMS、G1、ZGC谈的是收集器之间怎么选。至于 JDK 24 之后 native 调用那一类 pin 还剩多少实际影响,笔者的判断还停在「应该不多了」的层面,没在真实负载上量过,先记一笔在这里。