Java 与 JVM 列表全部文章
Java 与 JVM
技术笔记›Java 与 JVM第 2 / 6 篇

Java IO 与 NIO 笔记:BIO/NIO/AIO 区别、零拷贝、以及文件与网络读写的正确姿势

从 BIO、NIO、AIO 三种模型讲到 Selector、Buffer、Channel 与零拷贝原理,配上文件读写、大文件传输、Socket 编程的实用代码和命令,最后落到 Linux 文件描述符耗尽这类真实生产问题的排查。

更多

IO 是区分「会写代码」和「懂系统」的分水岭。同样的功能,用 BIO 还是 NIO、有没有零拷贝、缓冲区多大,性能能差一个数量级。

一、三种 IO 模型,一张图说清

BIO(同步阻塞) 线程 read() 阻塞等待 一连接一线程 连接多时线程爆炸,上下文切换吃掉 CPU NIO(同步非阻塞多路复用) Selector 轮询就绪事件 一线程管上万连接 Netty / Redis / Kafka 都基于此 AIO(异步非阻塞) 回调通知,读完了再叫你 Linux 下实现不成熟 实际项目极少使用 零拷贝:数据不再在内核态和用户态之间来回拷贝 传统:磁盘→内核缓冲→用户缓冲→Socket缓冲→网卡(4 次拷贝) 4 次上下文切换 sendfile:磁盘→内核缓冲→网卡(2 次拷贝) 2 次上下文切换,Kafka 快的秘密之一

二、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);

四、零拷贝的两个入口

  1. FileChannel.transferTo / transferFrom:对应 sendfile,适合文件→网络(静态资源服务、日志下载)。
  2. 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。这类问题只能靠监控曲线发现,靠代码规范根治。

七、记住这几条

  1. 流一定要关,用 try-with-resources。
  2. 指定字符集,永远不要依赖平台默认。
  3. 大文件用流式,不要一次性读进内存。
  4. NIO 的 selectedKeys 要 remove(),Buffer 读写要 flip()。
  5. 生产上真正写 NIO 的机会不多 —— 直接用 Netty,它已经把半包粘包、内存池、优雅关闭都处理好了。但原理要懂,否则 Netty 的参数你也不知道该怎么调。
— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人