Java 网络编程与 NIO 实战

系统讲解 Java 网络编程:BIO/NIO/AIO 三种模型、Socket/ServerSocket 编程、NIO Channel-Buffer-Selector 三角色、零拷贝与堆外内存、java.net.http.HttpClient、WebSocket 客户端,以及高并发 IO 模型选型。

网络编程是后端开发的必修课。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 无缝整合
虚拟线程时代阻塞式 + 虚拟线程代码简单 + 线程开销低
海量慢 IOAIO/异步回调避免线程被慢 IO 拖住
一句话:
  连接少 → BIO/虚拟线程都行
  连接多且每条连接活跃 → NIO 多路复用
  连接多但大多空闲/慢 IO → AIO 或异步回调最划算

一句话总结: 选 IO 模型看"连接数与连接活跃度":连接少用阻塞,连接多用复用,连接慢用异步——技术选型永远是围绕资源(线程)的权衡。


八、易踩的坑

坑现象对策
Channel 忘了非阻塞register 抛 IllegalBlockingModeExceptionconfigureBlocking(false)
忘记 remove selectedKeys同一个 key 反复处理迭代后立即移除
Buffer 忘记 flip读到脏数据写→读必 flip
零拷贝用错文件大文件占内核缓冲分批 transferTo
堆外内存泄漏进程内存只增不减用 Cleaner/受控释放
BIO 起海量线程OOM / CPU 飙升切 NIO 或线程池限流

九、总结

模型线程模型核心 API生产选择
BIO1 连接 1 线程ServerSocket/Socket简单场景
NIO1 线程多连接Channel/Buffer/SelectorNetty、Tomcat NIO
AIO异步回调Asynchronous*Channel海量慢 IO
虚拟线程阻塞式 + 廉价线程普通阻塞代码现代高并发首选

一句话记住:网络编程的演进史,就是"怎么用更少的线程支撑更多的连接"。看懂 BIO → NIO → 虚拟线程这条线,再看 Netty 源码和 Tomcat 配置就不会发怵了。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. Java 日志体系与工程实践完整指南
  2. Java 异常处理与防御式编程实战
  3. Java 虚拟机类加载机制与字节码深度解析