
简介这份资源是面向Java后端开发者与网络编程学习者的NIO多线程Web服务器实例以PDF形式呈现适合希望深入理解高性能I/O模型、并发处理与轻量级Web框架原理的中高级读者。压缩包内共1个PDF文件约59KB内容围绕完整项目展开涵盖静态与动态资源获取、Cookie与Session管理、HTTP长连接及过期清理机制并实现类似Spring MVC的注解式编程如RequestMapping、RequestParam、RequestHeader、CookieValue等支持参数绑定与级联属性传递。项目还包含基于java.util.logging的日志记录、server-config.properties配置项说明以及EchoController演示代码可帮助读者掌握从连接监听到请求分发的整体流程。目前已有310人学习适合作为NIO网络编程与简易Web服务器实现的参考案例。1. Java NIO 多线程 Web 服务器从 BIO 的痛点到 Reactor 的落地很多 Java 开发工程师面试时被问到「手写一个 Web 服务器」第一反应是ServerSocket加线程池accept 一个连接就丢给线程处理。这套 BIO 模型在几十个并发下没问题一旦连接数上千线程栈内存和上下文切换开销直接把机器拖垮。Java NIO 的多线程 Web 服务器实例核心就是用少量线程管理大量连接把「一个连接一个线程」换成「一个事件循环管一批连接」。它解决的是高并发下资源利用率的问题适合想搞懂 Netty 底层原理、准备 java 多线程和高并发方向面试、或者需要自己实现轻量 HTTP 服务的从业者。下面从模型选型一路写到可运行的代码和踩坑记录。2. 为什么 BIO 撑不住NIO 多线程模型选型与线程分工2.1 从阻塞 accept 到 Selector 就绪通知BIO 的accept()和read()都是阻塞调用线程发起系统调用后一直挂起直到有数据才返回。这意味着一个线程同一时刻只能盯一个连接。NIO 把这两个动作拆开Selector负责向操作系统查询哪些 Channel 已经就绪线程只在有事件时才去处理处理完立刻回到select()等待下一批事件。一个线程就能轮询成百上千个 Channel。这个转变的关键在于「就绪通知」替代了「阻塞等待」。操作系统底层用 epollLinux或 kqueuemacOS维护就绪列表select()返回的是已经可读或可写的 Channel 集合线程不需要遍历所有连接去试探。理解这一点后面线程怎么分工就顺了。2.2 Reactor 模式单 Reactor 多线程的线程分工常见做法是单 Reactor 多线程模型。一个Selector线程专门负责accept新连接和监听所有已注册 Channel 的读写事件我们叫它 Reactor 线程。当某个 Channel 可读时Reactor 线程不直接处理业务而是把读到的数据封装成一个任务丢给后面的业务线程池。业务线程池处理完请求、生成响应后再把写事件注册回 Reactor 线程由它统一负责写回客户端。这样分工的好处是Reactor 线程只做事件分发逻辑极轻不会被业务阻塞业务线程池可以按 CPU 核数配置处理 HTTP 解析、路由、响应生成这些耗时操作。连接数再多Reactor 线程数量不变内存和上下文切换都可控。2.3 线程数怎么定Reactor 单线程与业务池参数Reactor 线程通常就一个因为select()本身很快瓶颈不在它。如果压测发现单个 Reactor 的select循环成为瓶颈比如 QPS 超过几万可以升级成主从 Reactor主 Reactor 只负责 accept从 Reactor 负责读写事件但这是后话先跑通单 Reactor。业务线程池大小按任务类型定。如果任务是 CPU 密集的比如 JSON 序列化、模板渲染线程数设为 CPU 核数加一如果涉及磁盘 IO 或数据库查询可以适当放大到核数的两到四倍。我一般先用Runtime.getRuntime().availableProcessors() * 2起步压测时看线程池队列积压情况再调。队列不要用无界队列否则请求堆积时内存会爆用有界队列加拒绝策略让过载快速失败。3. 手写核心Selector 事件循环与 HTTP 请求解析3.1 最小可运行的 NIO 服务器骨架先搭一个能接受连接、读取数据、回写响应的骨架。这段代码不处理 HTTP 协议细节只验证 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; public class NioServer { private Selector selector; private ServerSocketChannel serverChannel; private volatile boolean running true; public void start(int port) throws IOException { selector Selector.open(); serverChannel ServerSocketChannel.open(); serverChannel.configureBlocking(false); // 必须非阻塞才能注册到 Selector serverChannel.socket().bind(new InetSocketAddress(port)); // 注册 accept 事件关注新连接 serverChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println(Server started on port port); while (running) { // select 阻塞直到有事件就绪返回就绪的 key 数量 int readyCount selector.select(); if (readyCount 0) continue; SetSelectionKey keys selector.selectedKeys(); IteratorSelectionKey it keys.iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); // 必须手动移除否则下次循环重复处理 try { if (key.isAcceptable()) { handleAccept(key); } else if (key.isReadable()) { handleRead(key); } } catch (IOException e) { key.cancel(); key.channel().close(); } } } } private void handleAccept(SelectionKey key) throws IOException { ServerSocketChannel server (ServerSocketChannel) key.channel(); SocketChannel client server.accept(); client.configureBlocking(false); // 新连接注册读事件附加一个缓冲区 client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024)); System.out.println(Accepted: client.getRemoteAddress()); } private void handleRead(SelectionKey key) throws IOException { SocketChannel client (SocketChannel) key.channel(); ByteBuffer buffer (ByteBuffer) key.attachment(); buffer.clear(); int bytesRead client.read(buffer); if (bytesRead -1) { // 客户端关闭 key.cancel(); client.close(); return; } buffer.flip(); byte[] data new byte[buffer.remaining()]; buffer.get(data); String request new String(data); System.out.println(Received: request); // 简单回写真实场景要解析 HTTP 后构造响应 String response HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!; client.write(ByteBuffer.wrap(response.getBytes())); } public static void main(String[] args) throws IOException { new NioServer().start(8080); } }selector.select()是阻塞的返回就绪事件数量。selectedKeys()返回的是就绪 key 集合处理完必须it.remove()否则下一轮循环还会拿到同一个 key导致重复处理甚至空指针。handleAccept里新连接注册OP_READ时附加了一个ByteBuffer作为附件这样每个连接有独立的读缓冲区避免共享缓冲区导致数据错乱。handleRead里read返回 -1 表示对端关闭必须 cancel 并 close否则会一直触发读事件形成空转。3.2 HTTP 请求解析状态机与 Content-Length 处理上面的骨架只读了原始字节真实 HTTP 请求需要解析请求行、请求头、请求体。TCP 是流式的一次read可能只读到半个请求也可能读到两个请求粘在一起。常见做法是用状态机逐行解析遇到\r\n\r\n表示头部结束再根据Content-Length或Transfer-Encoding决定是否继续读 body。public class HttpRequestParser { // 解析状态请求行 - 请求头 - 请求体 private enum State { REQUEST_LINE, HEADERS, BODY, DONE } private State state State.REQUEST_LINE; private StringBuilder lineBuffer new StringBuilder(); private MapString, String headers new HashMap(); private String method, path, version; private int contentLength 0; private byte[] body; // 逐字节喂入返回 true 表示请求解析完成 public boolean parse(ByteBuffer buffer) { while (buffer.hasRemaining()) { byte b buffer.get(); if (state State.BODY) { // body 按 contentLength 读取 int remaining buffer.remaining() 1; int toRead Math.min(remaining, contentLength - bodyRead); // 简化处理实际要把已读字节和剩余字节拼起来 return true; } lineBuffer.append((char) b); if (b \n) { String line lineBuffer.toString().trim(); lineBuffer.setLength(0); if (state State.REQUEST_LINE) { String[] parts line.split( ); method parts[0]; path parts[1]; version parts[2]; state State.HEADERS; } else if (state State.HEADERS) { if (line.isEmpty()) { // 空行表示头部结束 contentLength Integer.parseInt( headers.getOrDefault(Content-Length, 0)); if (contentLength 0) { state State.BODY; body new byte[contentLength]; } else { state State.DONE; return true; } } else { int idx line.indexOf(:); headers.put(line.substring(0, idx).trim(), line.substring(idx 1).trim()); } } } } return state State.DONE; } }这段代码省略了 body 拼接的完整逻辑但展示了核心思路用状态机区分请求行、头部、body 三个阶段。Content-Length是解析 body 长度的关键如果请求头里没有它且方法是 POST说明客户端用了 chunked 编码需要另外处理Transfer-Encoding: chunked。实际项目中解析器要能处理半包一次read的数据可能不完整解析器要保留中间状态等下次read继续喂入。3.3 业务线程池接入把解析任务从 Reactor 卸下来Reactor 线程里做 HTTP 解析和业务处理会阻塞事件循环必须把任务丢给业务线程池。改造handleRead读到数据后不直接处理而是提交一个任务任务里完成解析、路由、生成响应然后把响应写回。import java.util.concurrent.*; public class NioServerWithPool { private final ExecutorService businessPool new ThreadPoolExecutor( 4, // 核心线程数 Runtime.getRuntime().availableProcessors() * 2, // 最大线程数 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(1000), // 有界队列防止内存溢出 new ThreadPoolExecutor.CallerRunsPolicy() // 过载时由 Reactor 线程自己执行 ); private void handleRead(SelectionKey key) throws IOException { SocketChannel client (SocketChannel) key.channel(); ByteBuffer buffer (ByteBuffer) key.attachment(); buffer.clear(); int bytesRead client.read(buffer); if (bytesRead -1) { key.cancel(); client.close(); return; } buffer.flip(); byte[] data new byte[buffer.remaining()]; buffer.get(data); // 提交给业务线程池Reactor 线程立即返回继续 select businessPool.execute(() - { try { String request new String(data); String response route(request); // 业务处理 // 写回也要注册到 Reactor这里简化直接写 client.write(ByteBuffer.wrap(response.getBytes())); } catch (IOException e) { e.printStackTrace(); } }); } private String route(String request) { // 解析请求行简单路由 if (request.startsWith(GET /hello)) { return HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!; } return HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nNot Found; } }线程池用有界队列加CallerRunsPolicy当队列满时由提交任务的 Reactor 线程自己执行形成背压避免任务无限堆积。businessPool.execute提交后 Reactor 线程立刻回到select()不等待业务完成。注意client.write在业务线程里调用如果写缓冲区满会阻塞业务线程更严谨的做法是把写事件注册回 Reactor由 Reactor 负责写。这里为了代码简洁直接写生产环境要改成事件驱动写。4. 避坑与排查NIO 服务器最容易翻车的五个地方4.1 空轮询导致 CPU 100%现象服务器空闲时 CPU 占用率飙到 100%top看到 Java 进程持续跑满一个核。原因JDK 在 Linux 上的 epoll 实现存在空轮询 bugselector.select()有时会立即返回 0 但没有任何就绪事件循环空转。解决记录select()返回 0 的次数连续超过阈值比如 512 次就重建 Selector把原 Channel 重新注册到新 Selector 上。Netty 就是这么干的自己写的话加个计数器。4.2 忘记 remove 导致重复处理现象客户端发一次请求服务器处理了多次日志重复打印。原因selector.selectedKeys()返回的集合不会自动清除处理完的 key 必须手动it.remove()。解决在迭代器循环里拿到 key 后第一件事就是it.remove()再处理事件。注意不能在 for-each 里 remove必须用 Iterator。4.3 ByteBuffer 未 flip 导致读到脏数据现象读到的请求内容包含上一次的残留数据或者remaining()为 0。原因ByteBuffer写完后没有flip()就调用get()position 还在末尾读不到数据或者clear()后没重置 limit。解决记住口诀「写前 clear读前 flip」。每次read前buffer.clear()read后buffer.flip()再取数据。附件缓冲区每个连接独立不要共享。4.4 业务线程直接写 Channel 导致响应错乱现象高并发下客户端收到错乱的响应A 请求的响应发给了 B 连接。原因多个业务线程同时往同一个 Channel 写或者写操作和 Reactor 的读操作并发修改 Channel 状态。解决写操作统一交给 Reactor 线程业务线程只负责生成响应字节通过队列把写任务投递给 Reactor。如果必须多线程写对 Channel 加锁但性能会下降。4.5 连接不关闭导致文件描述符耗尽现象运行一段时间后报Too many open files新连接无法建立。原因客户端关闭后没有正确 cancel key 和 close channel或者异常路径下漏了关闭。解决在handleRead里判断read返回 -1 时关闭在 catch 块里也要关闭。用try-finally保证异常时释放资源。同时检查ulimit -n生产环境调到 65535 以上。5. 压测验证与进阶用 ab 和 wrk 摸清服务器边界写完服务器不能凭感觉说「性能不错」得用数据说话。ab和wrk是两个常用压测工具ab简单wrk能压出更高 QPS。先用ab跑一轮基础压测# -n 总请求数-c 并发数-k 保持长连接 ab -n 10000 -c 100 -k http://127.0.0.1:8080/hello重点看三个指标Requests per second是吞吐量Time per request是平均延迟Failed requests是失败数。如果失败数不为 0先查服务器日志有没有异常再查连接是否被提前关闭。再用wrk压更高并发# -t 线程数-c 连接数-d 持续时间 wrk -t4 -c500 -d30s http://127.0.0.1:8080/hellowrk的Latency分布比ab更细能看到 P99 延迟。如果 P99 远高于平均值说明有长尾请求通常是业务线程池队列积压或者 GC 停顿导致。压测时用jstack看线程状态如果大量线程处于BLOCKED说明锁竞争严重用jstat -gc看 GC 频率如果 Full GC 频繁检查是不是每次请求都创建大对象。我一般会把 Reactor 线程和业务线程分开命名jstack里一眼就能区分。调优方向有几个Reactor 线程数从 1 加到 2 看 QPS 是否提升业务线程池队列从 1000 调到 5000 看失败率是否下降ByteBuffer从堆内换成直接内存allocateDirect减少一次拷贝。每次只改一个参数记录 QPS 和延迟变化找到瓶颈再动下一个。最后说个习惯我写 NIO 服务器时会在select()循环里加一个计数器每秒打印一次就绪事件数和处理耗时。这个日志在排查空轮询和性能瓶颈时比任何 profiling 工具都直接。希望帮到你。本文还有配套的精品资源点击获取