4.4 IO 与 NIO 的阻塞之坑 本节摘要:BIO 模型里每个连接占用一个线程,连接数上去后线程先于带宽先于 CPU 崩掉。本节从一次网关线程数飙到四位数的事故讲起,对比流与缓冲区两种抽象、阻塞与非阻塞两种模式、多路复用的事件驱动模型,最后落到 Files 与 Path 的日常正解和 Netty 的位置。 事故现场:线程数 4000,CPU 却只有 30% 内部门户网关在大促压测中先是 RT 抖动,随后大面积超时。登录机器看监控:JVM 线程数 4000+,CPU 使用率却只有 30%——线程都在睡觉,不在干活。每个阻塞在 上的连接都拴着一个线程,而连接的另一端(客户端)迟迟不发数据。
本节摘要:BIO 模型里每个连接占用一个线程,连接数上去后线程先于带宽先于 CPU 崩掉。本节从一次网关线程数飙到四位数的事故讲起,对比流与缓冲区两种抽象、阻塞与非阻塞两种模式、多路复用的事件驱动模型,最后落到 Files 与 Path 的日常正解和 Netty 的位置。
内部门户网关在大促压测中先是 RT 抖动,随后大面积超时。登录机器看监控:JVM 线程数 4000+,CPU 使用率却只有 30%——线程都在睡觉,不在干活。每个阻塞在 read() 上的连接都拴着一个线程,而连接的另一端(客户端)迟迟不发数据。4GB 的线程栈(每线程默认 1MB)、数十万次/秒的上下文切换、调度器被拖垮,服务等于被"连接数"而非"请求量"打死了。
这就是 BIO(Blocking IO)的结构性天花板:
// 经典的 echo 服务骨架 ServerSocket server = new ServerSocket(8080); while (true) { Socket client = server.accept(); // 阻塞 等一个连接 new Thread(() -> { // 每连接一线程 try (InputStream in = client.getInputStream()) { byte[] buf = new byte[1024]; int n; while ((n = in.read(buf)) != -1) { // 阻塞 等数据 client.getOutputStream().write(buf, 0, n); } } }).start(); }
100 个连接无所谓,10000 个就是一万线程。线程是昂贵的资源(内存 + 调度成本),拿它去"等数据"是拿茅台浇花。
java.io 的流(Stream)是单向、顺序、阻塞的字节管道,读一个字节就向操作系统要一个字节(中间有缓冲也掩盖不了模型本质)。java.nio 换了抽象:Channel 是双向通道,Buffer 是数据容器,读写都发生在 buffer 与 channel 之间,配合多路复用 Selector 让一个线程盯住成千上万个连接的就绪事件。
// NIO 单线程多路复用的骨架 Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(8080)); ssc.configureBlocking(false); ssc.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); // 阻塞到任一事件就绪 Set<SelectionKey> keys = selector.selectedKeys(); for (SelectionKey key : keys) { if (key.isAcceptable()) { // 新连接 注册读事件 SocketChannel ch = ssc.accept(); ch.configureBlocking(false); ch.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 某连接有数据 处理它 SocketChannel ch = (SocketChannel) key.channel(); ByteBuffer buf = ByteBuffer.allocate(1024); int n = ch.read(buf); if (n == -1) ch.close(); else { buf.flip(); // 写模式转读模式 ch.write(buf); } } } keys.clear(); }
一个线程服务全部连接,连接数天花板从"线程数"变成"文件句柄数与内存"。Buffer 的 flip()/clear() 是模式切换的仪式:position 与 limit 三个指针决定了 buffer 处于读态还是写态,忘了 flip 是 NIO 新手 Bug 榜第一名(数据读出来全是空)。

裸 NIO 的正确性陷阱远不止 flip:半包粘包处理、事件风暴、空轮询 bug 的规避、写半包的重试……业界实践是直接用 Netty——它把多路复用封装成事件循环 + ChannelPipeline 的模型,解码器解决了粘包拆包,是 Dubbo、gRPC、MQ 客户端网络层的共同底座。用 NIO 解决容量问题 = 用 Netty,自己手写 selector 循环只出现在学习场景。
另一条新路线是虚拟线程(JDK 21 正式,JDK 25 再获增强):代码保持 BIO 的阻塞写法,JVM 把阻塞点调度走,线程模型由运行时托管。老代码几乎零改造获得"百万连接"的容量——BIO 的简单与 NIO 的容量兼得,是未来几年 IO 编程的默认答案(第 5 章线程一节会再对它展开)。
多数业务代码的 IO 是文件读写,JDK 7 以来的正解是 NIO.2 的 Path/Files,配合 try-with-resources(第 3 章):
Path p = Path.of("data", "orders.csv"); List<String> lines = Files.readAllLines(p, StandardCharsets.UTF_8); Files.writeString(p, "id,amount\n", StandardCharsets.UTF_8); try (var reader = Files.newBufferedReader(p, StandardCharsets.UTF_8)) { // 流式处理大文件 逐行读 不整载内存 } try (Stream<Path> walk = Files.walk(Path.of("logs"))) { walk.filter(x -> x.toString().endsWith(".log")).forEach(this::ship); }
注意两点:Files.lines/walk 返回的流持有文件句柄,必须 try-with-resources 包住,否则句柄泄漏直到 GC 碰巧关掉(Windows 上还锁文件);大文件严禁 readAllLines 一把梭,OOM 的账要记在第 6 章。老 File 类的缺陷(错误只返回 false、平台路径分隔符、元数据能力弱)在 Path 上全部修正,新代码不再引入。
⚠️ 常见坑:字符集不指定就依赖 JVM 默认编码。容器是 UTF-8、开发机是 GBK 时"本地好的",上线乱码——凡是
getBytes()/new String(bytes)/readAllLines,都显式传 charset。
💡 关键直觉:IO 模型决定容量天花板,业务代码决定它何时到达。线程数高而 CPU 低,先看线程栈里有多少在
read上睡觉。
jstack,阻塞在 socket read 的堆栈就是 BIO 天花板再多说一点容量规划的量化方法。"一连接一线程"到底能撑多少连接,可以用内存粗算:每线程默认栈 1MB,加上内核缓冲与调度开销,4GB 可分配内存大约支撑三四千连接——与事故里四千线程的观测完全吻合。规划时倒推:预期峰值连接数乘以单连接内存成本,超出预算就要换模型。切换到多路复用后瓶颈转移到事件循环的 CPU 与句柄上限,虚拟线程则转移到堆与调度器。模型没有免费的午餐,只有不断搬家的瓶颈——知道瓶颈会搬去哪,比记住某个模型的优点更重要。
第 4 章收束。下一章进入本册最深的深水区:并发编程事故现场。