IO 是区分「会写代码」和「懂系统」的分水岭。同样的功能,用 BIO 还是 NIO、有没有零拷贝、缓冲区多大,性能能差一个数量级。
一、三种 IO 模型,一张图说清
二、NIO 三件套:Buffer、Channel、Selector
- Buffer:本质是块内存 + 四个指针(
position/limit/capacity/mark)。读写切换必须flip(),读完要clear()或compact()。忘了flip()是初学 NIO 最常见的 bug —— 写完不 flip,读出来是空的。 - Channel:双向的(BIO 的 Stream 是单向的),可以和 Buffer 交互,支持
transferTo/transferFrom(底层就是sendfile零拷贝)。 - Selector:一个线程监听多个 Channel 的就绪事件(OP_ACCEPT / OP_CONNECT / OP_READ / OP_WRITE)。
最小可用的 Selector 骨架:
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(); // 阻塞到有事件就绪
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // ★ 必须手动移除,否则下次重复处理
if (key.isAcceptable()) { /* accept 并注册 OP_READ */ }
if (key.isReadable()) { /* 读,注意处理半包粘包 */ }
}
}
it.remove() 这一行是 NIO 编程的经典陷阱:selectedKeys() 返回的是同一个集合,不移除的话已处理的 key 会一直留在里面,导致 CPU 空转 100%。
三、文件读写的正确姿势
| 场景 | 推荐做法 | 别这么干 |
|---|---|---|
| 读文本文件 | Files.readAllLines(path, StandardCharsets.UTF_8) |
不指定字符集(用平台默认,Windows 上是 GBK) |
| 大文件逐行处理 | Files.lines(path).forEach()(流式) |
readAllLines 一次性加载(撑爆内存) |
| 拷贝文件 | Files.copy(src, dst, REPLACE_EXISTING) |
自己写 byte[] 循环 |
| 大文件传输 | FileChannel.transferTo() |
用户态中转 |
| 随机读写 | RandomAccessFile / MappedByteBuffer |
反复 seek + read |
一定要显式指定字符集,这是最容易埋雷的地方:
// 错:用平台默认编码,Windows 开发机 GBK,Linux 服务器 UTF-8,必乱码
new String(bytes);
// 对
new String(bytes, StandardCharsets.UTF_8);
四、零拷贝的两个入口
FileChannel.transferTo / transferFrom:对应sendfile,适合文件→网络(静态资源服务、日志下载)。MappedByteBuffer(mmap):把文件映射到虚拟内存,适合大文件随机读(RocketMQ 的 CommitLog 就是这么干的)。注意 mmap 的内存不受-Xmx限制,释放依赖 GC,map太频繁会触发OutOfMemoryError: Map failed。
五、序列化与 IO 的选择
| 方式 | 特点 | 场景 |
|---|---|---|
| JSON(Jackson / Fastjson2) | 可读、跨语言、体积大 | HTTP 接口 |
| Protobuf | 二进制、体积小、需要 schema | RPC、MQ 消息、跨语言 |
| Kryo | 快、只限 Java | 内部缓存序列化 |
| JDK Serializable | 慢、体积大、有安全漏洞 | 不要用 |
JDK 原生序列化现在基本是安全红线:反序列化漏洞(如 CommonsCollections 利用链)能直接 RCE。接口和 MQ 上一律用 JSON 或 Protobuf。
六、生产问题:Too many open files
脱敏自某电商 SaaS 商城的一次线上事故。
现象:高峰期接口偶发 502,日志里零星出现 java.io.IOException: Too many open files,重启后消失,过几小时又出现。
排查命令:
ulimit -n # 看进程级限制(常见默认 1024)
lsof -p <pid> | wc -l # 当前进程打开了多少 fd
lsof -p <pid> | awk '{print $9}' | sort | uniq -c | sort -rn | head # 按文件类型聚合
cat /proc/<pid>/limits | grep files # 确认实际生效的限制
结果:lsof 里堆积了大量状态为 CLOSE_WAIT 的 socket。这是被动关闭方没有正确关闭连接的典型症状 —— 我们调用下游 HTTP 接口用的工具类,在异常分支里没有关闭 response。
修法:
- 所有 IO 资源一律
try-with-resources,异常分支也不会漏。 - HTTP 客户端统一走连接池(Apache HttpClient / OkHttp),并设置
evictIdleConnections定期回收。 ulimit -n调到 65535(/etc/security/limits.conf),并给连接池设置明确上限。- 加监控:fd 使用率超过 70% 告警。
结论:IO 资源泄漏是慢性病,不像 OOM 那样立刻爆,而是随着运行时间慢慢吃掉 fd。这类问题只能靠监控曲线发现,靠代码规范根治。
七、记住这几条
- 流一定要关,用
try-with-resources。 - 指定字符集,永远不要依赖平台默认。
- 大文件用流式,不要一次性读进内存。
- NIO 的
selectedKeys要remove(),Buffer读写要flip()。 - 生产上真正写 NIO 的机会不多 —— 直接用 Netty,它已经把半包粘包、内存池、优雅关闭都处理好了。但原理要懂,否则 Netty 的参数你也不知道该怎么调。
一起聊聊