Java Nio

2026-07-23
8 min read

Java NIO 的核心优势是用一个线程通过 Selector 管理成千上万个连接(非阻塞多路复用),同时借助零拷贝(transferTo)和直接缓冲区大幅提升 I/O 吞吐量,适合高并发场景。

BIO 的痛点

在 BIO 模型里,一个连接对应一个线程。线程阻塞在 read() 上干等数据,什么都做不了:

客户端A ──→ Thread-1 ──→ read() 阻塞等待数据
客户端B ──→ Thread-2 ──→ read() 阻塞等待数据
客户端C ──→ Thread-3 ──→ read() 阻塞等待数据

每个线程占 ~1MB 栈内存,一万个连接出去 10GB 内存,而且大部分时间都在空转。NIO 的思路很简单:不要让线程等数据,让一个线程去管多个连接,哪个连接有数据了就去处理哪个。

线程和文件描述符

线程是 CPU 调度的最小单位,IO 密集场景下它大部分时间都在等数据,真正干活的时间很少。

文件描述符(fd)是操作系统给每个打开的文件、Socket 分配的一个整数编号。比如 fd=4 是某个 Socket 连接,fd=5 是另一个。进程能打开的 fd 数量有限,默认 1024,可以 ulimit 调大。

BIO 里每个 fd 都要配一个线程去 read() 阻塞等待。NIO 里一个线程可以同时盯着成千上万个 fd。

epoll

epoll 是 Linux 2.6 引入的 IO 多路复用机制,Java NIO 的 Selector 在 Linux 上底层就是 epoll。

epoll 本质上是个“就绪事件收集器”——你把一堆 fd 注册进去,有数据到达时它告诉你哪些 fd 就绪了,不用你自己遍历所有 fd 去查。它不关心这些 fd 是 HTTP、Redis 还是 MySQL,只知道哪个能读、哪个能写。

有个容易搞错的地方:epoll 唤醒的是等待 epoll_wait() 的线程,不是 fd。fd 只是个整数,根本不具备“被唤醒”的概念。实际流程是:fd 就绪 → 内核把 fd 放进 Ready List → 唤醒等待 epoll_wait() 的线程 → epoll_wait 返回 Ready List 里的 fd → JVM 根据注册时的映射关系找到对应的 SelectionKey 和 Channel。

epoll 在内核里维护两个集合:Interest List(你要监听的)和 Ready List(现在有事件的)。两个不是一回事。epoll_wait 直接从 Ready List 取,不需要遍历 Interest List,所以是 O(1)。

epoll_wait 一次可能返回多个就绪的 fd,Java 这边一次 select() 可能拿到好几个 SelectionKey,一次处理完,不是一个个来。

register() 干了两件事:把 fd 加入 epoll 的 Interest List,同时建立 fd → SocketChannel → SelectionKey 的映射。为什么要映射?epoll_wait 只返回整数 11、12,JVM 不知道 11 对应哪个 Java 对象,得靠注册时建立的映射关系把 11 翻译成对应的 Channel。

select / poll / epoll 对比:

机制 原理 复杂度 fd 数量限制
select 每次都把整个 fd 集合传给内核,内核遍历 O(n) 最多 1024
poll 同 select,用链表存 fd,无数量限制 O(n) 无上限,但越慢
epoll 内核维护 Interest/Ready List,只返回就绪 O(1) 十万级无压力

select/poll 是应用主动问内核“谁有数据?”,epoll 是内核主动告诉应用“这几个有数据”,而且不用把全部 fd 在用户态和内核态之间来回拷贝。

LT 和 ET 的区别:LT(水平触发)是只要 fd 缓冲区还有数据没读完,每次 epoll_wait 都会返回这个 fd,默认模式。ET(边缘触发)只在 fd 状态从“没数据”变成“有数据”时通知一次,必须循环 read 直到 EAGAIN 把数据读完。ET 效率更高,Nginx 默认用 ET。

NIO 三大组件

Channel 是双向数据传输通道,对应操作系统里的 fd。FileChannel 读写文件,SocketChannel 做 TCP 客户端,ServerSocketChannel 做 TCP 服务端,DatagramChannel 走 UDP。

Buffer 是数据容器,所有读写都经过 Buffer。ByteBuffer 最常用,position、limit、capacity 三个指针控制读写边界。

Selector 就是前面的 epoll 在 Java 侧的封装,一个 Selector 可以管成千上万个 Channel。

NIO 代码示例

先看个最简单的 Channel + Buffer 文件读写:

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NioFileRead {
    public static void main(String[] args) throws Exception {
        RandomAccessFile file = new RandomAccessFile("test.txt", "rw");
        FileChannel channel = file.getChannel();
        ByteBuffer buf = ByteBuffer.allocate(48);

        int bytesRead = channel.read(buf);
        while (bytesRead != -1) {
            buf.flip();          // 切换为读模式
            while (buf.hasRemaining()) {
                System.out.print((char) buf.get());
            }
            buf.clear();         // 切换为写模式
            bytesRead = channel.read(buf);
        }
        file.close();
    }
}

Buffer 的状态是靠 flip/clear 来回切的:写完后 flip 一下变成读模式,读完 clear 一下切回写模式。

再看 Selector 多路复用的服务端,这是 NIO 的核心用法:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class NioServer {
    private Selector selector;
    private ExecutorService workerPool = Executors.newFixedThreadPool(8);

    public NioServer(int port) throws IOException {
        selector = Selector.open();                       // 底层 epoll_create
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(port));
        serverChannel.configureBlocking(false);            // 必须非阻塞
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
    }

    public void start() throws IOException {
        while (true) {
            selector.select();                             // 底层 epoll_wait
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> it = keys.iterator();

            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();                               // 必须 remove

                if (key.isAcceptable()) {
                    handleAccept(key);
                } else if (key.isReadable()) {
                    // 先取消读事件,防止重复触发
                    key.interestOps(key.interestOps() & ~SelectionKey.OP_READ);
                    workerPool.execute(() -> handleRead(key));
                }
            }
        }
    }

    private void handleAccept(SelectionKey key) throws IOException {
        ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
        SocketChannel sc = ssc.accept();
        sc.configureBlocking(false);
        sc.register(selector, SelectionKey.OP_READ);
        System.out.println("新连接: " + sc.getRemoteAddress());
    }

    private void handleRead(SelectionKey key) {
        try {
            SocketChannel sc = (SocketChannel) key.channel();
            ByteBuffer buf = ByteBuffer.allocate(1024);
            int len = sc.read(buf);
            if (len == -1) { sc.close(); return; }
            buf.flip();
            byte[] data = new byte[buf.remaining()];
            buf.get(data);
            System.out.println("收到: " + new String(data));

            String response = "ECHO: " + new String(data);
            ByteBuffer respBuf = ByteBuffer.wrap(response.getBytes());
            while (respBuf.hasRemaining()) sc.write(respBuf);

            key.interestOps(key.interestOps() | SelectionKey.OP_READ);
            selector.wakeup();
        } catch (IOException e) {
            try { key.channel().close(); } catch (IOException ignored) {}
        }
    }

    public static void main(String[] args) throws IOException {
        new NioServer(8080).start();
    }
}

几个要点:Channel 必须 configureBlocking(false),不然 Selector 管不了;每次迭代必须 remove SelectionKey;读事件处理前先取消 OP_READ,防止一个事件被反复触发;业务逻辑丢给 Worker 线程池,Selector 线程不阻塞。

客户端很简单:

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class NioClient {
    public static void main(String[] args) throws Exception {
        SocketChannel client = SocketChannel.open();
        client.connect(new InetSocketAddress("localhost", 8080));
        client.write(ByteBuffer.wrap("Hello NIO".getBytes()));

        ByteBuffer respBuf = ByteBuffer.allocate(1024);
        client.read(respBuf);
        respBuf.flip();
        byte[] data = new byte[respBuf.remaining()];
        respBuf.get(data);
        System.out.println("响应: " + new String(data));
        client.close();
    }
}

线程与连接,是两回事

BIO 里线程和连接是 1:1 绑定的,有多少连接就得开多少线程。NIO 打破了这种绑定——一个 Selector 线程管理一堆连接,只有少量 Worker 线程处理业务:

            ┌──────────────┐
            │   Selector   │  ← 1 个线程,底层 epoll
            └──────┬───────┘
    ┌──────────────┼──────────────┐
    │ fd=4 可读    │ fd=7 可读    │ fd=12 可写
    ▼              ▼              ▼
┌────────┐    ┌────────┐    ┌────────┐
│Worker-1│    │Worker-2│    │Worker-3│  ← 少量线程,只处理业务
└────────┘    └────────┘    └────────┘
  BIO NIO
线程 1 连接 = 1 线程 N 线程管理 M 连接
fd 每个线程阻塞一个 fd Selector 监控所有
内存 N × 1MB 固定少量线程
切换 频繁上下文切换 只切就绪事件

什么是零拷贝

传统 IO 里,哪怕只是把文件发给客户端,数据也要经过 JVM 堆内存兜一圈:

磁盘 → 内核缓冲区 → 用户缓冲区(Java堆) → Socket缓冲区 → 网卡
       DMA          CPU拷贝              CPU拷贝          DMA

4 次拷贝、4 次上下文切换。文件越大,堆内存占用越多,GC 压力越大。

零拷贝的思路是让数据直接在内核态完成传输,别进用户态。Java NIO 提供了两种方式:FileChannel.transferTo(底层调 sendfile,直接从磁盘到 Socket)和 MappedByteBuffer(底层调 mmap,文件映射到虚拟内存,按需加载,不占堆)。

零拷贝的好处很实在:CPU 拷贝从 2 次降到 0 次,上下文切换从 4 次降到 2 次,JVM 堆内存不碰,大文件不会 GC 或 OOM。实测大文件传输吞吐量能提 30%~50%。

但 sendfile 有个硬伤:它只能走 文件 → Socket 这个方向,也就是下载。Socket → 文件(上传)这个方向它不支持。

上传:Spring Boot 为什么不行

上传要的是 Socket → 文件,sendfile 方向反了。Linux 其实有 splice 这个系统调用能干 Socket → 文件的零拷贝,但 Java 没暴露这个 API,Servlet 规范也没支持。

更麻烦的是 Servlet 容器本身。HTTP 的 Header 和 Body 在同一个 TCP 流里,Tomcat 必须先读到 Header 才知道 Content-Length 是多少、是不是 chunked 编码。这个解析过程数据已经进了 JVM 内存,回不去了。

所以结论很直接:只要是走 Servlet API 的 Spring Boot 应用,上传的数据必然经过 JVM 堆,零拷贝免谈。

Nginx 为什么可以

Nginx 的工作方式不一样。它直接处理 TCP 流,可以读到 Header 后先暂停,发个子请求到 Spring 做鉴权,鉴权过了自己再开始收 Body:

浏览器                      Nginx                       Spring
  │                           │                           │
  │  POST /upload/movie.mp4   │                           │
  │  Authorization: Bearer xx │                           │
  │ ─────────────────────────→│                           │
  │                           │  GET /auth (只发Header)   │
  │                           │ ─────────────────────────→│
  │                           │                           │ 检查 JWT/权限/配额
  │                           │  200 OK                   │
  │                           │ ←─────────────────────────│
  │                           │                           │
  │  开始接收 Body            │                           │
  │ ←─────────────────────────→ 边收边写磁盘              │

Nginx 配置就几行:

location /upload {
    auth_request /auth;
    client_body_temp_path /data/upload/tmp;
    proxy_pass http://file-storage;
}

关键是 Nginx 不需要把整个文件加载到内存,边收边写磁盘,用适中的缓冲区就行。虽然严格来说不是内核级零拷贝,但效果等于“Nginx 直连磁盘”,Java 全程只收到一个鉴权请求(几 KB 的 Header)。

其他方案还包括预签名 URL(Spring 生成签名 URL,客户端直传 OSS/S3)、Nginx upload_pass(Nginx 写临时文件后通知 Spring 路径)。

下载:Spring Boot 怎么做零拷贝

下载是文件 → Socket,sendfile 天然支持。Spring Boot 里直接返回 FileSystemResource 就行,底层 ResourceHttpMessageConverter 在 Tomcat NIO / Undertow 下会自动调 transferTo → sendfile:

import org.springframework.core.io.FileSystemResource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.File;

@RestController
@RequestMapping("/api/files")
public class FileDownloadController {

    @GetMapping("/download/{filename}")
    public ResponseEntity<FileSystemResource> download(@PathVariable String filename) {
        File file = new File("/data/files/" + filename);
        if (!file.exists()) {
            return ResponseEntity.notFound().build();
        }
        FileSystemResource resource = new FileSystemResource(file);
        return ResponseEntity.ok()
                .header("Content-Disposition",
                        "attachment; filename=\"" + filename + "\"")
                .contentLength(file.length())
                .body(resource);
    }
}

就这么简单。背后是 sendfile:磁盘 → Socket,2 次 DMA 拷贝、0 次 CPU 拷贝,JVM 堆内存完全不碰。