网络编程是后端开发的必修课。Java 从 BIO(阻塞)、NIO(非阻塞 + 多路复用)演进到 AIO(异步)与虚拟线程时代,理解"线程和 IO 如何配合"比记住 API 更重要。本文从三种模型讲起,落到 Channel-Buffer-Selector 的核心用法与高并发选型。
一、三种 IO 模型对比
| 模型 | 核心 | 线程占用 | 典型代表 | 适用 |
|---|---|---|---|---|
| BIO | 一个连接一个线程,阻塞读 | 高 | ServerSocket | 连接数少 |
| NIO | 多路复用 + 非阻塞,一个线程管多连接 | 低 | Selector + Channel | 高并发 |
| AIO | 系统异步通知回调 | 低 | AsynchronousServerSocketChannel | 大量慢 IO |
BIO 的痛点:
每个 Socket 占一个线程,阻塞在 read 上。
1000 连接 = 1000 线程,上下文切换开销巨大。
解决方向:让一个线程同时"看管"多个连接的 IO 事件 —— 这就是 NIO。
1.1 NIO 的核心思想
NIO 三件套:
Channel(通道) —— 连接的抽象,读写都通过 Channel
Buffer(缓冲区) —— 数据存的中转站(ByteBuffer 等)
Selector(选择器) —— 一个线程注册多个 Channel,轮询"谁就绪了"
一句话总结: BIO 是"一连接一线程"的阻塞模型,NIO 是"一线程管多连接"的多路复用模型——用更少的线程支撑更多的连接,是高并发 IO 的基石。
二、BIO 编程:理解阻塞的代价
// 服务端:每来一个连接起一个线程(示意,勿直接用于生产)
try (ServerSocket server = new ServerSocket(8080)) {
while (true) {
Socket socket = server.accept(); // 阻塞等待连接
new Thread(() -> handle(socket)).start(); // 每个连接一个线程
}
}
BIO 的问题在 accept 和 read 都会阻塞当前线程。简单的解决办法是线程池,但线程池大小仍受连接数限制;真正的出路是 NIO 的非阻塞 + 多路复用。
一句话总结: BIO 编程最简单但线程成本最高;生产环境要么限流使用、要么直接切 NIO/虚拟线程。
三、NIO 编程:Channel 与 Buffer
3.1 读写流程
// 客户端:SocketChannel 连接
SocketChannel channel = SocketChannel.open(new InetSocketAddress("localhost", 8080));
ByteBuffer buf = ByteBuffer.allocate(1024);
buf.put("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n".getBytes());
buf.flip(); // 写模式 → 读模式(position=0, limit=写到的位置)
channel.write(buf); // 把 buffer 内容发给服务器
buf.clear(); // 回到写模式
int n = channel.read(buf); // 读取响应
buf.flip();
System.out.println(new String(buf.array(), 0, buf.limit()));
3.2 Buffer 的四个位置属性
position:下一个读写的位置
limit: 可读/可写的上限
capacity:缓冲区容量
mark: 标记,可 reset 回退
常用切换:
flip() :写→读(limit=position, position=0)
clear() :读→写(position=0, limit=capacity)
compact():读→写并保留未读完数据
一句话总结: NIO 的数据不是直接进出 Channel,而是先到 ByteBuffer 再转换;搞清楚 position/limit/flip 就是 NIO 编程的第一关,这也是 NIO 比 BIO 难的原因。
四、Selector:多路复用核心
4.1 一个线程管理多个连接
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false); // 必须非阻塞才能注册
ssc.bind(new InetSocketAddress(8080));
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 = ssc.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ); // 注册读事件
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(1024);
sc.read(buf);
}
}
}
4.2 就绪事件
| 事件 | 常量 | 含义 |
|---|---|---|
| 可读 | OP_READ | 有数据可读 |
| 可写 | OP_WRITE | 可写数据 |
| 可连接 | OP_CONNECT | 客户端连接完成 |
| 可接受 | OP_ACCEPT | 服务端有新连接 |
一句话总结: Selector 把"一个线程轮询多个连接的 IO 就绪状态"做到了系统级(epoll 底层)——线程数从"连接数"降为"1",这就是 Netty、Tomcat NIO 的底层真相。
五、零拷贝与堆外内存
5.1 零拷贝
传统 IO 四次拷贝 + 四次上下文切换:
磁盘 → 内核缓冲 → 用户缓冲 → 内核 socket 缓冲 → 网卡
零拷贝(transferTo):
磁盘 → 内核缓冲 → 网卡 (sendfile 由内核直接搬运,减少用户态拷贝)
// FileChannel.transferTo 实现零拷贝发送文件
FileChannel file = FileChannel.open(Paths.get("big.bin"));
SocketChannel socket = ...;
long pos = 0;
long len = file.size();
while (pos < len) {
pos += file.transferTo(pos, len - pos, socket); // 内核态直接搬
}
5.2 堆外内存(DirectBuffer)
ByteBuffer heap = ByteBuffer.allocate(1024); // 堆内:有 GC,拷贝到内核要经堆外中转
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 堆外:免中转,但需手动释放
堆外内存适合大块、长生命周期的数据(网络/文件),代价是 JVM 不直接管理、用 sun.misc.Cleaner 或 Unsafe 释放,内存泄漏更难排查。
一句话总结: 零拷贝靠
transferTo让内核直接搬运数据,堆外内存用 DirectBuffer 减少 JVM↔内核的复制中转——两个手段都指向同一个目标:减少数据复制次数。
六、java.net.http.HttpClient
JDK 11+ 内置的 java.net.http.HttpClient 是替代第三方 HTTP 客户端的现代选择:支持 HTTP/2、异步、WebSocket。
// 异步 GET 请求
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.version(HttpClient.Version.HTTP_2)
.build();
CompletableFuture<HttpResponse<String>> future = client
.sendAsync(
HttpRequest.newBuilder(URI.create("https://api.example.com/users"))
.GET().build(),
HttpResponse.BodyHandlers.ofString());
future.thenAccept(resp -> System.out.println(resp.statusCode() + resp.body()));
// 同步调用
HttpResponse<String> resp = client.send(
HttpRequest.newBuilder(URI.create("https://example.com")).GET().build(),
HttpResponse.BodyHandlers.ofString());
一句话总结: JDK 原生 HttpClient 支持 HTTP/2 + 异步 + WebSocket,功能足够覆盖大部分调用场景,减少外部依赖的现代选择。
七、高并发 IO 模型选型
| 场景 | 推荐 | 理由 |
|---|---|---|
| 传统同步 Web 服务 | Servlet 阻塞 + 线程池 | 简单成熟,配合虚拟线程 |
| 高并发长连接 | Netty(基于 NIO/Epoll) | 事件驱动、零拷贝、背压 |
| 响应式全栈 | Spring WebFlux | 与 Reactor 无缝整合 |
| 虚拟线程时代 | 阻塞式 + 虚拟线程 | 代码简单 + 线程开销低 |
| 海量慢 IO | AIO/异步回调 | 避免线程被慢 IO 拖住 |
一句话:
连接少 → BIO/虚拟线程都行
连接多且每条连接活跃 → NIO 多路复用
连接多但大多空闲/慢 IO → AIO 或异步回调最划算
一句话总结: 选 IO 模型看"连接数与连接活跃度":连接少用阻塞,连接多用复用,连接慢用异步——技术选型永远是围绕资源(线程)的权衡。
八、易踩的坑
| 坑 | 现象 | 对策 |
|---|---|---|
| Channel 忘了非阻塞 | register 抛 IllegalBlockingModeException | configureBlocking(false) |
| 忘记 remove selectedKeys | 同一个 key 反复处理 | 迭代后立即移除 |
| Buffer 忘记 flip | 读到脏数据 | 写→读必 flip |
| 零拷贝用错文件 | 大文件占内核缓冲 | 分批 transferTo |
| 堆外内存泄漏 | 进程内存只增不减 | 用 Cleaner/受控释放 |
| BIO 起海量线程 | OOM / CPU 飙升 | 切 NIO 或线程池限流 |
九、总结
| 模型 | 线程模型 | 核心 API | 生产选择 |
|---|---|---|---|
| BIO | 1 连接 1 线程 | ServerSocket/Socket | 简单场景 |
| NIO | 1 线程多连接 | Channel/Buffer/Selector | Netty、Tomcat NIO |
| AIO | 异步回调 | Asynchronous*Channel | 海量慢 IO |
| 虚拟线程 | 阻塞式 + 廉价线程 | 普通阻塞代码 | 现代高并发首选 |
一句话记住:网络编程的演进史,就是"怎么用更少的线程支撑更多的连接"。看懂 BIO → NIO → 虚拟线程这条线,再看 Netty 源码和 Tomcat 配置就不会发怵了。
延伸阅读
- Spring WebFlux 响应式编程与 Reactor 全解析 — Reactor 背压与 NIO 底层的结合
- Java 性能优化:从代码到 JVM 的全链路调优 — IO 与网络性能优化专题
- Java 并发编程与 JUC 包完全指南 — CompletableFuture 与异步 IO 的编排
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。