上一篇【第01篇】Netty是什么——从NIO地狱到网络编程天堂的蜕变
下一篇【第03篇】手把手搭建Netty开发环境——5分钟从零到HelloWorld


摘要

Java IO模型经历了四次重大进化:JDK 1.0的BIO(阻塞IO)让服务器被连接"卡死";JDK 1.4的NIO(非阻塞IO)通过Selector实现多路复用,一个线程管成百上千连接;JDK 1.7的AIO(异步IO)让操作系统帮我们完成IO后主动通知。但NIO的API复杂难用,这正是Netty登场的原因。

本文详解Linux五种IO模型(阻塞/非阻塞/IO多路复用/信号驱动/异步),对比BIO/NIO/AIO四种Java模式的代码实现,并给出生产环境的IO模型选型建议。读完这篇,您将彻底搞懂Java网络IO的来龙去脉。


一、Linux五种IO模型——网络编程的底层逻辑

要理解Java IO,必须先理解操作系统层面的IO模型。Linux提供了五种IO模型,它们的本质区别在于:应用程序何时知道"数据准备好了"

【Linux五种IO模型对比】

模型              等待数据  数据从内核→用户空间  特点
────────────────────────────────────────────────────────────
阻塞IO(BIO)     阻塞      阻塞                一个连接一个线程,简单但浪费
非阻塞IO(NIO)   轮询      阻塞                不阻塞等待,但要不断轮询,CPU空转
IO多路复用(NIO)  select/epoll阻塞  阻塞   一个线程管多个连接,最高效
信号驱动IO         不阻塞    阻塞                较少使用,信号处理复杂
异步IO(AIO)     不阻塞    不阻塞              操作系统完成后通知,最理想

1.1 阻塞IO(Blocking IO)——最朴素的IO模型

【阻塞IO模型图解】

应用程序线程               内核
    |                      |
    |-- recvfrom() ------>|  (1) 系统调用,线程阻塞
    |                      |----> (2) 等待数据到达网卡
    |                      |----> (3) 数据从网卡拷贝到内核缓冲区
    |                      |<---- (4) 数据从内核拷贝到用户空间
    |<-- 数据返回 --------|  (5) 系统调用返回,线程恢复执行
    |                      |
   💤 阻塞等待中...       内核在忙

特点

  • 优点:编程简单,适合连接数少的场景
  • 缺点:一个连接一个线程,C10K问题(1万连接=1万线程,系统崩溃)

Java BIO代码

// 服务器端(一个连接一个线程)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
    Socket socket = serverSocket.accept(); // 💤 阻塞等待连接
    new Thread(() -> {
        try {
            InputStream in = socket.getInputStream();
            int len;
            byte[] buf = new byte[1024];
            while ((len = in.read(buf)) != -1) { // 💤 阻塞等待数据
                System.out.println(new String(buf, 0, len));
            }
        } catch (Exception e) { }
    }).start();
}

1.2 非阻塞IO(Non-blocking IO)——不断"问"内核

【非阻塞IO模型图解】

应用程序线程               内核
    |                      |
    |-- recvfrom() ------>|  (1) 系统调用,立即返回(数据未准备好返回EAGAIN)
    |<-- EAGAIN ---------|  (2) 应用程序知道数据还没好
    |  去做其他事...       |
    |-- recvfrom() ------>|  (3) 再次询问
    |<-- EAGAIN ---------|  (4) 还是没好
    |  继续做其他事...     |
    |-- recvfrom() ------>|  (5) N次询问后,数据终于准备好了!
    |<-- 数据返回 --------|  (6) 这一次真的有数据了

特点

  • 优点:不用阻塞等待,可以去干其他事
  • 缺点:要不断轮询(polling),CPU空转浪费严重

1.3 IO多路复用(IO Multiplexing)——一个线程管所有连接

【IO多路复用模型图解(select/epoll)】

应用程序线程               内核
    |                      |
    |-- select() --------->|  (1) 告诉内核:帮我监视这些连接(阻塞在此)
    |                      |----> (2) 内核监视所有连接
    |                      |----> (3) 某个连接有数据到达了!
    |<-- 有数据了! -------|  (4) select返回,告诉应用哪个连接有数据
    |                      |
    |-- recvfrom() ------>|  (5) 应用发起真正的读取(此时不会阻塞太久)
    |<-- 数据返回 --------|  (6) 读取数据

核心思想:把"监视连接"和"读取数据"两件事分开。一个线程通过select/epoll监视成百上千个连接,哪个连接有数据了才去读哪个。

select vs epoll

特性 select poll epoll(Linux特有)
最大连接数 1024(硬编码) 无限制 无限制
性能 O(N),随连接数下降 O(N) O(1),只返回活跃连接
数据结构 位图(bitmap) 数组 红黑树 + 就绪链表

为什么Netty在Linux上用Epoll传输层比NIO快? 因为JDK NIO在Linux上用的是epoll,但JDK的实现有额外开销;Netty的Epoll传输层直接调用Linux原生epoll API,减少了JDK层的开销,性能提升20-30%。

1.4 异步IO(Asynchronous IO)——最理想的IO模型

【异步IO模型图解】

应用程序线程               内核
    |                      |
    |-- aio_read() ------>|  (1) 告诉内核:数据好了叫我(立即返回,不阻塞)
    |<-- 立即返回 --------|  (2) 应用程序可以去干其他事
    |                      |
    |  做其他事情...       |----> (3) 数据到达网卡
    |                      |----> (4) 数据拷贝到用户空间
    |                      |
    |<-- 信号/回调 -------|  (5) 内核主动通知应用:数据已经好了!
    |                      |

特点

  • 优点:真正异步,应用完全不阻塞
  • 缺点:Linux对AIO支持不完善,Java AIO在实际生产中使用较少

二、Java IO模型的四次进化

Java的IO模型随着JDK版本不断进化,每一次进化都是为了解决上一代的痛点。

【Java IO进化时间线】

JDK 1.0 (1996)   JDK 1.4 (2002)    JDK 1.7 (2011)
    |                   |                    |
    v                   v                    v
   BIO                NIO                  AIO
(阻塞IO)          (非阻塞IO)          (异步IO)
    |                   |                    |
    v                   v                    v
 一个连接            多路复用             回调函数
 一个线程            Selector            CompletionHandler

2.1 第一代:BIO(Blocking IO)——一个连接一个线程

// BIO时间服务器(传统阻塞模式)
public class TimeServerBIO {
    public static void main(String[] args) throws Exception {
        ServerSocket server = new ServerSocket(8080);
        System.out.println("BIO服务器启动,监听8080...");
        while (true) {
            Socket socket = server.accept(); // 💤 阻塞,等待客户端连接
            System.out.println("客户端连接:" + socket.getRemoteSocketAddress());
            // 每个连接开一个新线程处理(这就是C10K问题的根源)
            new Thread(() -> handleSocket(socket)).start();
        }
    }

    private static void handleSocket(Socket socket) {
        try (InputStream in = socket.getInputStream();
             OutputStream out = socket.getOutputStream()) {
            byte[] buf = new byte[1024];
            int len = in.read(buf); // 💤 阻塞,等待客户端发数据
            if (len > 0) {
                String msg = new String(buf, 0, len);
                System.out.println("收到:" + msg);
                out.write(("时间:" + new Date() + "\n").getBytes());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

BIO的致命缺陷

  • 连接数 = 线程数,1万连接 = 1万线程
  • 线程切换开销巨大(上下文切换)
  • 线程栈占用内存(默认1MB/线程),内存撑不住

2.2 第二代:伪异步IO——用线程池"缓一缓"

// 伪异步IO(用线程池限制线程数量)
public class TimeServerPseudoAsync {
    public static void main(String[] args) throws Exception {
        ServerSocket server = new ServerSocket(8080);
        // 用线程池代替"一个连接一个线程"
        ExecutorService pool = Executors.newFixedThreadPool(10);
        System.out.println("伪异步IO服务器启动...");
        while (true) {
            Socket socket = server.accept(); // 💤 还是阻塞
            pool.execute(() -> handleSocket(socket)); // 交给线程池
        }
    }
}

伪异步IO的评价

  • 优点:控制了线程数量,不会无限创建线程
  • 缺点:并没有解决阻塞问题!如果10个连接都在等待数据,第11个连接就无法被处理(线程池满了)

2.3 第三代:NIO(Non-blocking IO)——多路复用的王者

// NIO时间服务器(非阻塞多路复用模式)
public class TimeServerNIO {
    public static void main(String[] args) throws Exception {
        Selector selector = Selector.open();
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false); // 🔑 设置为非阻塞模式
        ssc.bind(new InetSocketAddress(8080));
        ssc.register(selector, SelectionKey.OP_ACCEPT); // 注册"接受连接"事件

        System.out.println("NIO服务器启动,监听8080...");
        while (true) {
            selector.select(); // 💤 阻塞,但阻塞在Selector上(不是阻塞在某个连接上!)
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> it = keys.iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();
                if (key.isAcceptable()) {
                    // 有新连接进来
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    sc.register(selector, SelectionKey.OP_READ);
                    System.out.println("客户端连接:" + sc.getRemoteAddress());
                } else if (key.isReadable()) {
                    // 有连接发来数据
                    SocketChannel sc = (SocketChannel) key.channel();
                    ByteBuffer buf = ByteBuffer.allocate(1024);
                    int len = sc.read(buf); // 非阻塞读取
                    if (len > 0) {
                        buf.flip();
                        System.out.println("收到:" + new String(buf.array(), 0, len));
                        sc.write(ByteBuffer.wrap(("时间:" + new Date() + "\n").getBytes()));
                    }
                }
            }
        }
    }
}

NIO的核心思想

  • 一个Selector线程监视所有连接
  • 只有真正有数据的连接才会被处理
  • 完美解决C10K问题!

但NIO的痛点

  • API复杂(ByteBuffer的flip/compact让人头秃)
  • 要自己处理TCP粘包/拆包
  • 要自己实现协议(HTTP/WebSocket等)
  • JDK的epoll Bug(空轮询导致CPU 100%)

2.4 第四代:AIO(Asynchronous IO)——回调函数的世界

// AIO时间服务器(真正的异步IO)
public class TimeServerAIO {
    public static void main(String[] args) throws Exception {
        AsynchronousServerSocketChannel server = 
            AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(8080));
        System.out.println("AIO服务器启动,监听8080...");
        
        server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
            @Override
            public void completed(AsynchronousSocketSocketChannel client, Void attachment) {
                server.accept(null, this); // 继续接受下一个连接
                ByteBuffer buf = ByteBuffer.allocate(1024);
                client.read(buf, buf, new CompletionHandler<Integer, ByteBuffer>() {
                    @Override
                    public void completed(Integer result, ByteBuffer buf) {
                        buf.flip();
                        System.out.println("收到:" + new String(buf.array(), 0, result));
                        client.write(ByteBuffer.wrap(("时间:" + new Date() + "\n").getBytes()));
                    }
                    @Override
                    public void failed(Throwable exc, ByteBuffer buf) {
                        exc.printStackTrace();
                    }
                });
            }
            @Override
            public void failed(Throwable exc, Void attachment) {
                exc.printStackTrace();
            }
        });
        
        // 主线程不退出(AIO是回调模式,主线程退出程序就结束了)
        Thread.currentThread().join();
    }
}

AIO的评价

  • 优点:真正异步,编程模型更简洁
  • 缺点:Linux对AIO支持不完善;Windows的IOCP实现较好,但服务端很少用Windows;实际生产中很少使用AIO

为什么生产环境很少用AIO? 因为NIO的多路复用模型已经足够高效(epoll的O(1)复杂度),AIO带来的性能提升有限,反而增加了编程复杂度(回调函数嵌套)。所以,Netty、Dubbo、Elasticsearch等框架都选择NIO,而不是AIO。


三、Java NIO三大核心组件详解

Java NIO提供了三大核心组件:Channel(通道)Buffer(缓冲区)Selector(选择器)。理解这三大组件,就理解了Java NIO的半壁江山。

【Java NIO三大核心组件关系图】

+---------------------+       +---------------------+
|     Selector        |       |     Channel         |
|   (选择器/多路     |<------|   (通道/连接)      |
|    复用器)         |       |                     |
|                     |       |  ServerSocketChannel |
|  监视多个Channel    |       |  SocketChannel      |
|  的IO事件           |       |  DatagramChannel    |
+----------+----------+       +----------+----------+
           |                         |
           | 注册(register)          | read/write
           v                         v
+----------+----------+       +---------------------+
|  SelectionKey       |       |     Buffer          |
|   (事件类型)       |       |   (缓冲区)         |
|                     |       |                     |
|  OP_ACCEPT: 接受连接 |       |  HeapByteBuffer    |
|  OP_CONNECT: 连接完成|       |  DirectByteBuffer  |
|  OP_READ: 可读      |       |                     |
|  OP_WRITE: 可写     |       |  数据的中转站        |
+---------------------+       +---------------------+

3.1 Channel(通道)——数据的"高速公路"

Channel是Java NIO中的数据通道,类似于BIO中的Socket,但有重要区别:

特性 BIO的Socket NIO的Channel
方向 输入/输出分开(InputStream/OutputStream) 双向(可读可写)
阻塞 阻塞 可设置为非阻塞
数据 直接读写 通过Buffer读写

常用Channel

  • ServerSocketChannel:服务器通道,用于监听新连接(对应OP_ACCEPT)
  • SocketChannel:客户端/服务端连接通道,用于读写数据(对应OP_READ/OP_WRITE)
  • DatagramChannel:UDP通道

3.2 Buffer(缓冲区)——数据的"中转站"

Buffer是NIO中数据读写的中转站。所有的数据都通过Buffer来处理。

Buffer的核心属性

【ByteBuffer内部结构图解】

        position    limit       capacity
           |         |            |
           v         v            v
+----------+---------+------------+
| 已读/已写 | 未读数据 | 空闲区域    |
+----------+---------+------------+
0          position   limit       capacity

- capacity: 缓冲区的总容量(创建时指定,不可变)
- position: 当前读写位置(写模式下=已写字节数;读模式下=已读字节数)
- limit: 读写限制(写模式下=capacity;读模式下=已写字节数)

Buffer的四大操作

// 1. 写入数据到Buffer
ByteBuffer buf = ByteBuffer.allocate(1024);
buf.put("Hello".getBytes());

// 2. 切换为读模式(flip是关键!)
buf.flip(); // position=0, limit=position(5)

// 3. 从Buffer读取数据
byte[] dst = new byte[buf.remaining()];
buf.get(dst);
System.out.println(new String(dst)); // 输出:Hello

// 4. 清空Buffer(准备下一次写入)
buf.clear(); // position=0, limit=capacity

NIO编程第一大坑:忘记调用flip(),导致读不到数据!Netty的ByteBuf通过读写两个指针完美解决了这个问题。

3.3 Selector(选择器)——NIO的"大脑"

Selector是Java NIO多路复用的核心。一个Selector可以监视多个Channel的IO事件。

Selector的使用流程

// 1. 创建Selector
Selector selector = Selector.open();

// 2. 创建Channel并设置为非阻塞
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));

// 3. 将Channel注册到Selector,并指定要监听的事件
ssc.register(selector, SelectionKey.OP_ACCEPT);

// 4. 循环调用select()(阻塞,直到有事件到达)
while (true) {
    selector.select(); // 阻塞,直到有Channel就绪
    
    // 5. 获取就绪的SelectionKey
    Set<SelectionKey> keys = selector.selectedKeys();
    
    // 6. 处理每个就绪的Channel
    for (SelectionKey key : keys) {
        if (key.isAcceptable()) { /* 处理新连接 */ }
        if (key.isReadable()) { /* 处理读事件 */ }
        if (key.isWritable()) { /* 处理写事件 */ }
    }
    
    // 7. 清空已处理的key(重要!)
    keys.clear();
}

四、Java NIO的"九九八十一难"

虽然Java NIO功能强大,但在实际使用中存在大量"坑"。这也是为什么我们需要Netty。

【Java NIO常见坑点一览】

坑点                      表现                       Netty的解决方案
────────────────────────────────────────────────────────────────────
epoll空轮询Bug     CPU 100%,Selector死循环  重建Selector(rebuildSelector)
ByteBuffer的flip   经常忘记调用flip()          ByteBuf有独立的读写指针
TCP粘包/拆包       数据不完整                  自带多种Decoder解决
直接内存泄漏        OOM                        Buffer池化 + 引用计数
跨平台兼容性        Linux/Windows行为不一致     Epoll/KQueue原生传输层
没有HTTP/WS支持    要自己实现协议              内置HttpServerCodec等

五、如何选择合适的IO模型?

不同场景适合不同的IO模型,以下是选型建议:

【Java IO模型选型指南】

场景                          推荐模型        理由
────────────────────────────────────────────────────────────
连接数少(<1000)           BIO/NIO        简单够用,没必要上Netty
连接数多(>10000)           Netty(NIO)     多路复用,一个线程管所有连接
低延迟、高吞吐               Netty(NIO)     零拷贝+内存池,性能极致
HTTP/WebSocket服务           Netty           内置协议支持,开箱即用
简单客户端程序                NIO/AIO         直接用JDK NIO也够用

总结

  1. Linux五种IO模型:阻塞IO → 非阻塞IO → IO多路复用 → 信号驱动IO → 异步IO,效率依次提升
  2. Java IO四次进化:BIO(一个连接一个线程)→ 伪异步IO(线程池缓冲)→ NIO(多路复用)→ AIO(异步回调)
  3. Java NIO三大核心:Channel(通道)、Buffer(缓冲区)、Selector(多路复用器)
  4. NIO的痛点:API复杂、TCP粘包要自己处理、epoll空轮询Bug、跨平台兼容性问题
  5. 为什么需要Netty:Netty在JDK NIO之上提供了生产级的封装,解决了NIO的所有痛点,是Java网络编程的最优选择

上一篇【第01篇】Netty是什么——从NIO地狱到网络编程天堂的蜕变
下一篇【第03篇】手把手搭建Netty开发环境——5分钟从零到HelloWorld


更多推荐