Java Nio
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 堆内存完全不碰。