一文读懂 Java NIO

如果你写过 Java 代码,大概率用过 InputStreamOutputStream 这类属于 BIO(阻塞 IO)。但当程序需要同时处理成百上千个连接(比如聊天软件、服务器)时,BIO 就会变得“力不从心”。这时候,NIO(非阻塞 IO) 就该登场了。

今天用“快递站”的例子,把 NIO 讲明白,包括它的核心组件、怎么用,以及背后的“智能调度系统”——select 和 epoll。

先搞懂:BIO 为啥“笨”?NIO 好在哪?

在讲 NIO 之前,得先知道 BIO 的问题。咱们把“IO 操作”比作“快递站送快递”,你就能秒懂:

1. BIO:一个快递员只送一个件(阻塞)

假设你开了个 BIO 快递站,规则是:

  • 来了一个快递(一个客户端连接),就派一个快递员(一个线程)专门送。
  • 快递员到了客户家门口(等待 IO 数据,比如等客户端发消息),如果客户没准备好(数据没到),快递员就站在门口等,啥也不干(线程阻塞)。

如果每天只有 10 个快递,这没问题;但要是有 1000 个快递,你就得雇 1000 个快递员——不仅要花很多钱(线程占用内存),快递员没事干时还占地方(线程阻塞浪费资源)。这就是 BIO 的致命问题:线程与连接一对一,阻塞导致资源浪费

2. NIO:一个快递员能管一堆件(非阻塞+多路复用)

NIO 快递站换了新规则:

  • 只雇几个快递员(少量线程),让他们管所有快递(所有连接)。
  • 快递员先去“调度中心”查:哪些客户已经准备好收件了(哪些连接有数据可读)?只送这些“ready”的件(只处理有数据的连接)。
  • 如果客户没准备好,快递员不傻等,转身去查下一个(线程不阻塞,去处理其他事)。

这样一来,几个快递员就能搞定上千个快递,资源省了,效率还高。这就是 NIO 的核心优势:非阻塞 + 多路复用

NIO 的 3 个核心组件:就像快递站的“三要素”

要实现上面的“智能快递站”,Java NIO 靠三个核心组件:Channel(通道)Buffer(缓冲区)Selector(选择器)。咱们还是用快递站类比:

组件 快递站类比 核心作用
Channel(通道) 客户的“家门” 数据的“进出通道”,比如客户端连接、文件读写
Buffer(缓冲区) 快递员的“快递袋” 临时存数据,解决“数据零散读写”的效率问题
Selector(选择器) 快递站的“调度中心” 监控多个 Channel,找出“有数据要处理”的通道

1. Buffer:快递员的“快递袋”(存数据用)

BIO 里读写数据是“直接读/写”(比如 read(byte[]) 直接把数据读进数组),而 NIO 里必须通过 Buffer 中转——就像快递员不能手捧零散快递,得用快递袋统一装。

Buffer 本质是一个“带状态的数组”,有三个关键属性帮你管理数据:

  • position:当前“要读写的位置”(比如快递袋里装了 3 个快递,position 就是 3)。
  • limit:最多能“读写多少数据”(比如快递袋最大装 5 个,limit 就是 5)。
  • capacity:快递袋的“总容量”(一旦创建就固定,比如 5 个位置)。

举个简单的用 Buffer 读数据的例子:

// 1. 创建一个容量为 1024 的字节缓冲区(快递袋能装 1024 个字节)
ByteBuffer buffer = ByteBuffer.allocate(1024);

// 2. 从 Channel 读数据到 Buffer(快递员从客户家把快递装进袋子)
channel.read(buffer);

// 3. 切换为“读模式”(告诉快递员:现在要从袋子里拿快递了)
buffer.flip();

// 4. 从 Buffer 里读数据(快递员拿快递)
while (buffer.hasRemaining()) { // 只要还有没读的数据
    System.out.print((char) buffer.get()); // 读一个字节
}

// 5. 清空 Buffer(快递袋用完,重置位置,准备下次装快递)
buffer.clear(); // 或者用 compact()(保留没读完的数据)

关键记住:写数据到 Buffer 后,要调用 flip() 切换到读模式;读完后用 clear() 或 compact() 重置,准备下次写

2. Channel:客户的“家门”(数据进出的通道)

Channel 是数据的“通道”,比 BIO 里的“流(Stream)”更强大:

  • 流是“单向的”(比如 InputStream 只能读,OutputStream 只能写),而 Channel 是“双向的”(既能读又能写,比如一个 Channel 既能收客户端消息,又能发消息)。
  • 流是“阻塞的”,而 Channel 可以是“非阻塞的”(关键!快递员到了家门口,客户没准备好就走,不傻等)。

Java 里常见的 Channel 类型:

  • SocketChannel:客户端连接的通道(客户家门)。
  • ServerSocketChannel:服务器监听连接的通道(快递站的“接待窗口”)。
  • FileChannel:文件读写的通道(比如读本地文件的“门”)。

举个创建非阻塞 ServerSocketChannel 的例子:

// 1. 打开服务器通道(快递站打开接待窗口)
ServerSocketChannel serverChannel = ServerSocketChannel.open();
// 2. 绑定端口(窗口挂个牌子:只接 8080 号的快递)
serverChannel.bind(new InetSocketAddress(8080));
// 3. 设置为非阻塞模式(关键!快递员接待时不傻等)
serverChannel.configureBlocking(false);

3. Selector:快递站的“调度中心”(核心中的核心)

Selector 是 NIO 实现“多路复用”的关键——它就像快递站的调度中心,能同时监控多个 Channel,告诉快递员:“这几个客户家已经准备好收件了,快去处理!”

没有 Selector 时,你得开多个线程监控多个 Channel;有了 Selector,一个线程就能监控所有 Channel,这就是“多路复用”的精髓。

Selector 怎么用?分 4 步:
  1. 创建 Selector:调度中心开张。
  2. Channel 注册到 Selector:告诉调度中心“要监控这个客户家”,并指定要监控的“事件类型”。
  3. Selector 等待事件:调度中心坐等“有客户准备好”(线程会阻塞在这里,但这是“一个线程等所有连接”,比 BIO 高效)。
  4. 处理就绪的 Channel:调度中心找出“有事件的客户家”,快递员去处理。
关键:要监控的 4 种事件类型

Selector 不会监控所有情况,只会盯你指定的“事件”,Java 里用 SelectionKey 表示:

  • OP_READ:Channel 里有数据可以读(客户把快递放门口了,能拿了)。
  • OP_WRITE:Channel 可以写数据(客户在家,能送快递了)。
  • OP_ACCEPT:ServerSocketChannel 有新的客户端连接(新客户来快递站寄件了)。
  • OP_CONNECT:SocketChannel 成功连接到服务器(客户家到快递站的路通了)。
举个 Selector 完整例子(服务器处理连接):
// 1. 创建 Selector(调度中心)
Selector selector = Selector.open();

// 2. 打开服务器通道,绑定端口,设为非阻塞
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);

// 3. 把服务器通道注册到 Selector,监控“接受连接”事件(OP_ACCEPT)
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

// 4. 循环等待事件(调度中心持续工作)
while (true) {
    // 等待有事件发生(没事件就阻塞,相当于调度中心歇着,不浪费资源)
    int readyChannels = selector.select();
    if (readyChannels == 0) continue; // 没事件就继续等

    // 5. 找出所有“有事件的通道”(拿到所有就绪的 SelectionKey)
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

    while (keyIterator.hasNext()) {
        SelectionKey key = keyIterator.next();

        // 6. 处理不同类型的事件
        if (key.isAcceptable()) {
            // 事件 1:有新客户端连接(新客户来寄件)
            ServerSocketChannel server = (ServerSocketChannel) key.channel();
            // 接受连接,得到客户端通道
            SocketChannel clientChannel = server.accept();
            // 客户端通道设为非阻塞(关键!)
            clientChannel.configureBlocking(false);
            // 把客户端通道注册到 Selector,监控“读数据”事件(OP_READ)
            clientChannel.register(selector, SelectionKey.OP_READ);

        } else if (key.isReadable()) {
            // 事件 2:客户端通道有数据可读(客户把快递放门口了)
            SocketChannel clientChannel = (SocketChannel) key.channel();
            // 用 Buffer 读数据
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            int bytesRead = clientChannel.read(buffer);
            if (bytesRead == -1) {
                // 客户端断开连接,取消注册
                key.cancel();
                clientChannel.close();
                continue;
            }
            // 读数据并打印
            buffer.flip();
            System.out.println("收到客户端消息:" + new String(buffer.array(), 0, bytesRead));
            buffer.clear();
        }

        // 7. 处理完事件,移除这个 Key(避免重复处理)
        keyIterator.remove();
    }
}

这段代码的核心:一个线程(while 循环)通过 Selector 监控所有连接,有新连接就接受,有数据就读,全程不阻塞(除了 selector.select(),但这是“一个线程等所有”,效率极高)

关键原理:select 和 epoll 是啥?

你可能会问:Selector 这么厉害,它是怎么“监控多个 Channel”的?背后靠的是操作系统提供的“多路复用 IO 模型”,最常见的就是 selectepoll(还有 poll,和 select 类似)。

咱们还是用快递站类比,把“操作系统”比作“城市快递管理局”,Selector 是“快递站调度中心”,Channel 是“客户家”:

1. 老方案:select/poll(调度中心挨家挨户问)

select/poll 的工作方式很“笨”:

  • 快递站调度中心(Selector)要监控 1000 个客户家(Channel),就把这 1000 个地址列成一个表,交给城市管理局(操作系统)。
  • 城市管理局拿着表,挨家挨户敲门问:“你家有快递吗?”(遍历所有文件描述符,检查是否就绪)。
  • 不管有没有客户准备好,都要把 1000 家问一遍,然后把“有快递”的家告诉调度中心。
问题很明显:
  • 效率低:监控的客户越多(连接数越多),挨家挨户问的时间越长,哪怕只有 1 家有快递,也要问 1000 家。
  • 有上限:select 最多只能监控 1024 个客户家(操作系统限制),poll 虽然没上限,但遍历效率还是低。

2. 新方案:epoll(客户主动打电话给调度中心)

epoll 是 Linux 下的改进方案,聪明多了:

  • 快递站调度中心(Selector)先跟城市管理局说:“我要监控这 1000 个客户家,他们有快递了就直接打电话告诉我。”(把 Channel 注册到 epoll 实例,设置“回调”)。
  • 客户家(Channel)有快递了(数据就绪),就主动打给城市管理局,城市管理局再把这个消息记下来。
  • 调度中心问城市管理局:“有客户有快递吗?”,城市管理局直接把“有快递的客户列表”拿出来,不用遍历。
优势直接碾压:
  • 效率高:不管监控多少客户,只要有就绪的,直接拿列表,不用遍历(时间复杂度从 O(n) 变成 O(1))。
  • 无上限:能监控的连接数理论上没限制(只受内存影响)。
  • 非阻塞:epoll 支持“边缘触发”(ET)和“水平触发”(LT),可以更灵活地处理数据(比如 ET 模式下,只通知一次就绪,避免重复处理)。

更多推荐