Skip to content

Java 网络编程与 NIO ​

相关笔记:02 Java 核心特性(4.6 NIO 入门)、Netty

02 篇只讲了 NIO 的「核心组件 + 一个示例」,Netty 那篇直接跳到 Reactor。中间缺的这一层——五种 IO 模型、select/poll/epoll、零拷贝、半包粘包——这篇补上。

1. 从 BIO 说起:一连接一线程的死穴 ​

java
// 经典 BIO 服务端
ServerSocket server = new ServerSocket(8080);
while (true) {
    Socket socket = server.accept();          // 阻塞点 1:没人连就一直等
    new Thread(() -> {
        InputStream in = socket.getInputStream();
        byte[] buf = new byte[1024];
        int n = in.read(buf);                 // 阻塞点 2:没数据就一直等
    }).start();
}
// 经典 BIO 服务端
ServerSocket server = new ServerSocket(8080);
while (true) {
    Socket socket = server.accept();          // 阻塞点 1:没人连就一直等
    new Thread(() -> {
        InputStream in = socket.getInputStream();
        byte[] buf = new byte[1024];
        int n = in.read(buf);                 // 阻塞点 2:没数据就一直等
    }).start();
}

两个阻塞点决定了它的天花板:

问题后果
accept() 阻塞只能一个线程接连接
read() 阻塞每个连接独占一个线程,哪怕它一直不发数据
线程昂贵一个线程默认 1MB 栈 + 内核调度,几千连接就把机器拖死

C10K 问题就是这么来的:一万个连接要一万个线程。解法只有一个方向 —— 用少量线程管大量连接,这就是 IO 多路复用。

2. NIO 三件套 ​

2.1 Buffer:数据容器 ​

java
ByteBuffer buf = ByteBuffer.allocate(1024);   // 堆内
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 堆外(零拷贝/直接 IO 用)

buf.put("hello".getBytes());                  // 写模式:position 前进
buf.flip();                                   // 切换读模式:limit = position,position = 0
while (buf.hasRemaining()) buf.get();         // 读
buf.clear();                                  // 重置(数据没删,只是 position=0, limit=capacity)
ByteBuffer buf = ByteBuffer.allocate(1024);   // 堆内
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 堆外(零拷贝/直接 IO 用)

buf.put("hello".getBytes());                  // 写模式:position 前进
buf.flip();                                   // 切换读模式:limit = position,position = 0
while (buf.hasRemaining()) buf.get();         // 读
buf.clear();                                  // 重置(数据没删,只是 position=0, limit=capacity)

四个指针是 NIO 最容易被问懵的地方:

指针含义
capacity容量,分配后不变
position下一个读/写位置
limit读模式下是「最多读到哪」,写模式下等于 capacity
mark打标记,reset() 回到这里

flip() 忘写是新手第一大 bug(读出来全是 0)。

2.2 Channel:双向通道 ​

Stream 是单向的(要么读要么写),Channel 是双向的。常用四个:

  • FileChannel:文件,支持 transferTo/transferFrom(零拷贝的关键)
  • SocketChannel / ServerSocketChannel:TCP
  • DatagramChannel:UDP

Channel 必须工作在非阻塞模式(configureBlocking(false))才能交给 Selector 管理。

2.3 Selector:多路复用器 ​

一个线程轮询多个 Channel 的就绪事件:

java
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()) {
            SocketChannel sc = ((ServerSocketChannel) key.channel()).accept();
            sc.configureBlocking(false);
            sc.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            SocketChannel sc = (SocketChannel) key.channel();
            ByteBuffer buf = ByteBuffer.allocate(1024);
            int n = sc.read(buf);
            if (n == -1) { key.cancel(); sc.close(); }   // 对端关闭
            else if (n > 0) { buf.flip(); /* 处理 */ }
        }
    }
}
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()) {
            SocketChannel sc = ((ServerSocketChannel) key.channel()).accept();
            sc.configureBlocking(false);
            sc.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            SocketChannel sc = (SocketChannel) key.channel();
            ByteBuffer buf = ByteBuffer.allocate(1024);
            int n = sc.read(buf);
            if (n == -1) { key.cancel(); sc.close(); }   // 对端关闭
            else if (n > 0) { buf.flip(); /* 处理 */ }
        }
    }
}

四个事件:OP_ACCEPT(有新连接)、OP_CONNECT(连接建立)、OP_READ、OP_WRITE。

坑:OP_WRITE 几乎一直是就绪的(只要发送缓冲区有空位),无脑注册会导致 CPU 100%。只在「写不进去」时才注册它,写完立刻取消。

3. 五种 IO 模型 ​

模型阻塞点一句话
阻塞 IOread 阻塞到数据就绪且拷贝完最简单,一线程一连接
非阻塞 IOread 立即返回,没数据就空转要自己轮询,CPU 空转
IO 多路复用select/poll/epoll 阻塞,就绪后 readNIO/Netty 用的就是这个
信号驱动 IO内核发信号通知Linux 上少用
异步 IO(AIO)调用即返回,内核拷贝完再回调真正的异步,Java 的 AIO

关键区别:前四种都是「同步」的 —— 数据从内核拷贝到用户态这一步,仍然要用户线程参与(read 调用里完成)。只有 AIO 是内核拷完再通知,才是真异步。

生活比喻:点外卖。

  • 阻塞:一直站门口等骑手。
  • 非阻塞:每 30 秒下楼看一眼,没到就回去。
  • 多路复用:装个门铃,等门铃响(一次性盯多个单)。
  • 异步:跟骑手说「放门口发消息给我」,期间该干嘛干嘛。

4. select / poll / epoll ​

这是面试最爱追问的一层。

selectpollepoll
fd 上限1024(FD_SETSIZE)无上限无上限
数据结构位图数组红黑树 + 就绪链表
每次调用全量拷贝 fd 集合到内核同左只在注册时拷贝一次
就绪检测遍历所有 fd(O(n))遍历(O(n))回调,只返回就绪的(O(1))
触发模式水平触发水平触发水平触发(LT)+ 边缘触发(ET)
c
// epoll 三步
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);        // 注册,只拷一次
epoll_wait(epfd, events, MAX, -1);              // 内核直接返回就绪数组
// epoll 三步
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);        // 注册,只拷一次
epoll_wait(epfd, events, MAX, -1);              // 内核直接返回就绪数组

LT 与 ET:

  • 水平触发(LT,默认):只要缓冲区还有数据就一直通知。安全,不容易漏,但可能重复通知。
  • 边缘触发(ET):状态变化时只通知一次,必须一次把数据读干净(while (read > 0)),否则剩下的数据要等下次状态变化才通知。性能高,写法要求严。

一句话总结:select/poll 的瓶颈是「每次都要把整个 fd 集合拷进内核并遍历」;epoll 用红黑树管理注册、就绪链表 + 回调通知,把「遍历」变成「只处理就绪的」。连接数越多、活跃比例越低,epoll 优势越明显。

5. Reactor 模式:NIO 的工程化封装 ​

裸写 Selector 太容易错,所以有了 Reactor:

单 Reactor 单线程      单 Reactor 多线程        主从 Reactor
┌──────────┐          ┌──────────┐            ┌──────────┐
│ selector │          │ selector │            │ boss 组  │ ← 只管 accept
│ 业务处理 │          │ 线程池   │            └────┬─────┘
└──────────┘          └──────────┘            ┌────▼─────┐
                                              │ worker 组│ ← 读写 + 业务
                                              └──────────┘
单 Reactor 单线程      单 Reactor 多线程        主从 Reactor
┌──────────┐          ┌──────────┐            ┌──────────┐
│ selector │          │ selector │            │ boss 组  │ ← 只管 accept
│ 业务处理 │          │ 线程池   │            └────┬─────┘
└──────────┘          └──────────┘            ┌────▼─────┐
                                              │ worker 组│ ← 读写 + 业务
                                              └──────────┘
模式代表适用
单 Reactor 单线程Redis 6.0 之前极简、无阻塞业务
单 Reactor 多线程一般自研框架业务耗时,但连接数中等
主从 ReactorNetty、Nginx高并发服务端标准答案

Netty 的对应关系:bossGroup 处理 accept,workerGroup 处理读写,NioEventLoop 就是一个「Selector + 任务队列」的死循环。

6. 零拷贝 ​

6.1 传统 read + write 走了几次拷贝 ​

磁盘 → 内核缓冲区(DMA 拷贝 1 次)
内核缓冲区 → 用户缓冲区(CPU 拷贝 1 次)
用户缓冲区 → socket 缓冲区(CPU 拷贝 1 次)
socket 缓冲区 → 网卡(DMA 拷贝 1 次)
磁盘 → 内核缓冲区(DMA 拷贝 1 次)
内核缓冲区 → 用户缓冲区(CPU 拷贝 1 次)
用户缓冲区 → socket 缓冲区(CPU 拷贝 1 次)
socket 缓冲区 → 网卡(DMA 拷贝 1 次)

4 次拷贝 + 4 次上下文切换(用户态/内核态来回)。真正要发文件,用户态那次完全是多余的。

6.2 三种零拷贝 ​

方式原理拷贝次数
mmap + write用户态与内核映射同一块内存,省掉「内核→用户」那次3 次
sendfile内核直接「磁盘 → socket 缓冲区」,全程不进用户态3 次(DMA 2 + CPU 1)
sendfile + DMA gather只传「文件描述符 + 偏移」,连 CPU 拷贝都省了2 次(全 DMA)

Java 里对应 FileChannel.transferTo(),底层就是 sendfile。

注意:「零拷贝」不是真的 0 次,而是减少了 CPU 拷贝和上下文切换。而且 sendfile 期间用户态拿不到数据,所以它只适合「原样转发」的场景(静态文件、MQ 的消息投递)。

6.3 Netty 的零拷贝是另一回事 ​

Netty 说的零拷贝有四层意思,容易混:

  1. 堆外直接内存:DirectByteBuffer,读写不用在 JVM 堆和内核之间再拷一次。
  2. CompositeByteBuf:把多个 Buffer 逻辑合并,不用真拷贝。
  3. slice / duplicate:共享底层数组,只换视图。
  4. FileRegion:基于 transferTo 的文件传输。

面试口径:操作系统的零拷贝指「减少内核与用户态之间的数据拷贝」;Netty 的零拷贝还包括「减少 JVM 堆内与堆外的拷贝、以及逻辑上的 Buffer 合并」。

7. 半包与粘包 ​

TCP 是字节流,没有消息边界。「粘包」不是 TCP 的 bug,是应用层没定边界。

现象原因
粘包发送方合并小包(Nagle),接收方一次读到多段
半包一次读到的数据不完整(超过缓冲区/分片)

四种解决方式:

方式说明例子
固定长度每个包定长,不够补齐简单但对变长消息浪费
长度字段包头写明 body 长度最常用(Netty LengthFieldBasedFrameDecoder)
分隔符用 \r\n 等特殊符号切HTTP、Redis 协议
自定义协议magic + version + length + body + checksum生产级 RPC

8. Java AIO 为什么没火 ​

AIO(AsynchronousSocketChannel)在 Linux 上底层是 epoll 模拟(不是真正的内核 AIO),JDK 又长期存在实现问题;而 NIO + Reactor 已经够用,加上 Netty 生态碾压,所以 AIO 在服务端几乎没人用。

口径:AIO 在 Windows 上用 IOCP 是真异步,在 Linux 上是模拟的;Java 服务端主流是 NIO + epoll + Reactor。

9. 常见坑与调优 ​

坑说明
epoll 空轮询JDK 早年的 epoll bug:没有事件也返回 0,导致 selector 空转烧 CPU。Netty 用「计数超过 512 次就重建 selector」规避
OP_WRITE 常就绪见 2.3,别常驻注册
忘记 key.remove()同一个 key 被反复处理
TCP_NODELAY默认 Nagle 算法会攒小包(延迟 40ms 级),RPC 场景务必 setTcpNoDelay(true)
SO_BACKLOGaccept 队列长度,太小会拒绝连接
半关闭shutdownOutput() 后仍可读,别忘了处理 -1
直接内存 OOM-XX:MaxDirectMemorySize 要设,Netty 的 ByteBuf 泄漏最终都是它

10. 速查表 ​

问题一句话
BIO / NIO / AIO 区别阻塞 / 多路复用 / 真异步(Linux 上是模拟)
NIO 三个核心Channel、Buffer、Selector
flip() 干什么写模式切读模式:limit = position,position = 0
select 与 epoll 差别每次全量拷贝 + 遍历 vs 注册一次 + 回调就绪链表
LT 与 ET一直通知 vs 只通知一次(必须读干净)
Reactor 三种单线程 / 多线程 / 主从(Netty)
零拷贝几次拷贝sendfile + DMA gather 后 2 次,全是 DMA
Netty 零拷贝指什么堆外内存 + CompositeByteBuf + slice + FileRegion
粘包怎么解决长度字段最常用;分隔符、定长、自定义协议
为什么 AIO 不流行Linux 上是 epoll 模拟,Netty + NIO 生态更强