从BIO到NIO:手写多线程Web服务器实战指南

发布时间:2026/10/12 15:31:42
从BIO到NIO:手写多线程Web服务器实战指南 简介这是一份面向Java后端开发者与网络编程学习者的实战型资料聚焦基于NIO的多线程Web服务器实现适合希望深入理解高并发I/O模型、HTTP协议处理与注解式框架设计的中高级读者。资源包共1个PDF文件约59KB内容围绕NIO技术、多线程调度、HTTP长连接、Cookie与Session管理及类Spring MVC注解式编程展开并给出webapp静态资源目录、server-config.properties配置项与EchoController控制器示例便于对照理解项目结构与核心实现思路。目前已有311人学习下载可作为构建高性能Web服务器、掌握NIO事件驱动与线程池协作机制的参考案例帮助读者梳理从请求解析到模板渲染的完整链路并借鉴其日志记录与过期连接清理等工程细节。1. 从 BIO 到 NIO为什么我劝你手写一遍多线程 Web 服务器很多写了三五年 Java 的人对ServerSocket的理解还停留在“accept之后丢给线程池”这一层。真到线上 QPS 一上来线程数飙到几千CPU 大量耗在上下文切换上才开始回头补 NIO 的课。我见过一个内部管理后台压测到 800 并发时 RT 从 20ms 涨到 1.2s最后定位就是 BIO 线程模型撑不住。手写一个基于 NIO 的多线程 Web 服务器不是为了造轮子而是为了把 Selector、Channel、Buffer、Reactor 这几块拼图真正拼起来。这篇笔记面向已经会写 Java、但没系统写过 NIO 服务端的后端同学从零搭一个能跑静态资源、能扛并发的迷你服务器把参数、边界和踩坑点一次讲透。2. 先把 Reactor 模型选对单线程、多线程还是主从2.1 三种 Reactor 模型的取舍逻辑NIO 服务端的核心不是 API而是事件分发结构。常见做法有三种单 Reactor 单线程一个 Selector 管 accept、read、write。代码最简单但一个慢请求会阻塞整个事件循环只适合学习或极低并发。单 Reactor 多线程一个 Selector 负责 accept 和 read读到数据后把业务处理丢给线程池。这是最实用的入门模型兼顾复杂度和吞吐。主从 Reactor 多线程mainReactor 只管 acceptsubReactor 管读写业务再丢线程池。Netty 默认就是这个结构适合高并发但代码量翻倍。我一般建议第一次手写选单 Reactor 多线程。原因很直接它把“IO 事件处理”和“业务计算”解耦了你能清楚看到瓶颈在哪又不用一上来就处理多 Selector 的线程安全问题。2.2 用 Selector 注册 Accept 事件的最小骨架先搭出事件循环的骨架不急着处理业务。下面这段代码是服务器的启动核心public class NioServer { private final Selector selector; private final ServerSocketChannel serverChannel; private final ExecutorService businessPool; public NioServer(int port, int poolSize) throws IOException { // 打开多路复用器底层在 Linux 上通常是 epoll this.selector Selector.open(); this.serverChannel ServerSocketChannel.open(); // 必须配置为非阻塞否则无法注册到 Selector this.serverChannel.configureBlocking(false); this.serverChannel.bind(new InetSocketAddress(port)); // 只注册 ACCEPT 事件表示关心新连接 this.serverChannel.register(selector, SelectionKey.OP_ACCEPT); this.businessPool Executors.newFixedThreadPool(poolSize); } public void start() throws IOException { while (true) { // 阻塞等待事件1000ms 超时用于处理其他收尾逻辑 selector.select(1000); SetSelectionKey keys selector.selectedKeys(); IteratorSelectionKey it keys.iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); // 必须手动移除否则会重复处理 if (!key.isValid()) continue; if (key.isAcceptable()) { handleAccept(key); } else if (key.isReadable()) { handleRead(key); } } } } }逻辑说明Selector.open()创建多路复用器configureBlocking(false)是硬性前提阻塞模式下注册会直接抛IllegalBlockingModeException。select(1000)返回就绪通道数量超时参数防止永久阻塞导致无法优雅退出。it.remove()是最容易漏的一步不移除的话下一轮循环还会拿到同一个 key触发重复处理甚至空指针。参数说明port是监听端口测试时建议用 8080 以上避免权限问题poolSize是业务线程池大小后面 2.4 节会讲怎么定。2.3 把读到的字节变成 HTTP 请求Accept 之后每个连接会注册OP_READ。读事件里要做三件事读数据、解析 HTTP、判断请求是否完整。private void handleRead(SelectionKey key) throws IOException { SocketChannel channel (SocketChannel) key.channel(); ByteBuffer buffer ByteBuffer.allocate(1024); int read channel.read(buffer); if (read -1) { // 对端关闭连接 key.cancel(); channel.close(); return; } buffer.flip(); byte[] data new byte[buffer.remaining()]; buffer.get(data); String request new String(data, StandardCharsets.UTF_8); // 简化判断HTTP 请求头以空行结束 if (!request.contains(\r\n\r\n)) { // 请求不完整本次不处理等下次读事件 return; } // 业务处理丢给线程池避免阻塞事件循环 businessPool.submit(() - { String response buildResponse(request); try { channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8))); } catch (IOException e) { key.cancel(); } }); }逻辑说明channel.read(buffer)返回 -1 表示对端关闭必须 cancel 并 close否则会空转。flip()把写模式切成读模式这是 Buffer 最容易搞混的地方。判断请求完整性用\r\n\r\n是简化做法真实场景要按Content-Length判断 body 是否读完。参数说明ByteBuffer.allocate(1024)是堆内内存分配快但读写要拷贝高并发下可换allocateDirect减少一次拷贝但分配慢适合长连接场景。2.4 线程池大小到底怎么定业务线程池不是越大越好。这个服务器里线程池只做业务计算和响应组装不阻塞在 IO 上所以可以用经典的N 核 * 2起步。我一般会这样配int cores Runtime.getRuntime().availableProcessors(); int poolSize cores * 2;如果业务里有数据库查询、文件读取这类阻塞操作线程池要按“阻塞系数”放大公式是核数 * (1 阻塞时间/计算时间)。但要注意线程池过大反而会让 CPU 在切换上浪费压测时观察top的sy占比超过 30% 就说明线程太多了。3. 手写 HTTP 解析与响应别小看协议细节3.1 请求行、请求头、请求体的拆分HTTP 请求文本结构固定第一行是Method Path Version之后是若干Key: Value头空行后是 body。解析时按\r\n切分即可。private HttpRequest parse(String raw) { HttpRequest req new HttpRequest(); String[] lines raw.split(\r\n); // 第一行GET /index.html HTTP/1.1 String[] firstLine lines[0].split( ); req.method firstLine[0]; req.path firstLine[1]; req.version firstLine[2]; for (int i 1; i lines.length; i) { if (lines[i].isEmpty()) break; // 空行表示头结束 int idx lines[i].indexOf(:); if (idx 0) { String name lines[i].substring(0, idx).trim().toLowerCase(); String value lines[i].substring(idx 1).trim(); req.headers.put(name, value); } } return req; }逻辑说明split(\r\n)在请求头很多时会产生大量临时字符串高并发下建议用indexOf手动扫描减少 GC 压力。头名称统一转小写因为 HTTP 头不区分大小写后面查content-length才不会漏。参数说明req.path可能带查询串比如/search?qjava路由匹配前要先按?截断。3.2 静态资源响应与 Content-Length 的坑响应必须带Content-Length否则浏览器会一直等连接关闭长连接就废了。private String buildResponse(String raw) { HttpRequest req parse(raw); String path req.path.split(\\?)[0]; if (path.equals(/)) path /index.html; File file new File(static path); if (!file.exists() || file.isDirectory()) { String body 404 Not Found; return HTTP/1.1 404 Not Found\r\n Content-Length: body.getBytes().length \r\n Content-Type: text/plain\r\n\r\n body; } try { byte[] content Files.readAllBytes(file.toPath()); String contentType guessContentType(path); return HTTP/1.1 200 OK\r\n Content-Length: content.length \r\n Content-Type: contentType \r\n\r\n new String(content, StandardCharsets.UTF_8); } catch (IOException e) { return HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n; } }逻辑说明Content-Length必须是字节长度不是字符长度。中文内容用getBytes().length才对用String.length()会少算导致浏览器截断。guessContentType按扩展名返回text/html、image/png等缺了它浏览器可能把 HTML 当纯文本显示。参数说明静态目录用相对路径static启动时工作目录要对否则 404。生产环境要防路径穿越path里出现..必须拒绝。3.3 用 curl 和 ab 验证最小可用版本写完先别急着上压测用 curl 确认协议正确# 启动服务器后验证状态行和头 curl -v http://127.0.0.1:8080/index.html # 只看响应头确认 Content-Length 存在 curl -I http://127.0.0.1:8080/index.html # 简单压测100 并发、1000 请求 ab -n 1000 -c 100 http://127.0.0.1:8080/index.html逻辑说明curl -v能看到完整请求响应确认状态行是HTTP/1.1 200。curl -I发 HEAD 请求如果你的服务器没处理 HEAD 会返回 404 或 405这是常见遗漏。ab的-c是并发数观察Requests per second和Failed requests有失败先看是不是连接被重置。参数说明ab默认用 HTTP/1.0不带Keep-Alive测的是短连接性能。要测长连接加-k。4. 避坑与排查那些让我加班到凌晨的细节4.1 空轮询导致 CPU 100%现象服务器空闲时 CPU 仍然跑满一个核top看 Java 进程持续 100%。原因JDK 在 Linux epoll 上有个经典 bugSelector 可能被唤醒但没有任何就绪事件select()返回 0循环空转。解决记录空轮询次数超过阈值就重建 Selector。int emptyCount 0; while (true) { int ready selector.select(1000); if (ready 0) { if (emptyCount 512) { rebuildSelector(); // 重建 selector把 channel 重新注册 emptyCount 0; } continue; } emptyCount 0; // ... 处理事件 }4.2 半包与粘包导致请求解析失败现象压测时偶发 400日志里请求文本被截断或者两个请求粘在一起。原因TCP 是字节流一次read不保证读到完整 HTTP 请求。请求大或网络抖动时就会半包。解决每个连接维护一个累积 Buffer每次读到的数据追加进去直到出现\r\n\r\n且 body 长度满足Content-Length才处理。简单做法是给每个SelectionKey挂一个ByteArrayOutputStream作为附件。4.3 直接 ByteBuffer 内存泄漏现象运行几小时后OutOfMemoryError: Direct buffer memory。原因allocateDirect分配的是堆外内存不受 GC 直接管理只有Cleaner在 Buffer 对象被回收时才释放。如果 Buffer 被长期持有堆外内存就涨。解决Direct Buffer 用完及时置空引用或者用池化方案复用。手写服务器里如果 QPS 不高直接用allocate堆内内存更省心。4.4 线程池拒绝策略没配请求直接丢现象高并发下部分请求无响应客户端超时。原因Executors.newFixedThreadPool用的是无界队列任务堆积不会拒绝但内存会涨如果换成有界队列又没配拒绝策略默认AbortPolicy直接抛异常连接被关闭。解决用ThreadPoolExecutor显式指定队列和拒绝策略拒绝时返回 503。new ThreadPoolExecutor( cores, cores * 2, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1024), new ThreadPoolExecutor.CallerRunsPolicy() // 让调用线程自己跑形成背压 );4.5 忘记处理 OP_WRITE 导致响应发不出去现象大文件响应只发了一部分客户端一直等。原因channel.write是非阻塞的返回的写入字节数可能小于 Buffer 剩余量。直接丢弃剩余数据就导致响应不完整。解决注册OP_WRITE把未写完的 Buffer 挂到 key 附件上下次写事件继续写写完再取消OP_WRITE。5. 进阶技巧用压测数据反推参数调优5.1 从 ab 报告里读出瓶颈跑完ab -n 10000 -c 200重点看三行Requests per second、Time per request、Failed requests。如果 RPS 上不去但 CPU 没满多半是线程池或连接数限制如果 CPU 满了但 RPS 低看sy占比高就是上下文切换太频繁降线程数Failed requests大于 0看Length还是Connect前者是响应不完整后者是 backlog 太小。5.2 backlog 和 SO_REUSEADDR 怎么设bind时第二个参数是 backlog表示已完成三次握手但还没 accept 的队列长度。默认 50 在高并发下会丢连接。serverChannel.bind(new InetSocketAddress(port), 1024);SO_REUSEADDR让服务器重启时能立刻复用端口避免Address already in use。但ServerSocketChannel默认就带这个选项不用手动设。5.3 用 jstack 定位事件循环卡顿如果服务器偶尔卡住几秒用jstack pid看主线程栈。如果停在selector.select之外的地方比如某个业务方法说明有代码在事件循环里做了阻塞操作。我自己的习惯是事件循环里只做 IO 和任务提交任何synchronized、数据库调用、文件读写都不允许出现。5.4 一个可复现的调优对照表参数初始值调优方向观察指标业务线程池核数*2阻塞多则放大队列积压、拒绝数backlog1024并发高则加大ss -lnt的 Send-QByteBuffer堆内 1KB大响应换 DirectGC 频率、堆外内存select 超时1000ms保持空轮询计数这张表是我压测时反复调的四个点每次只改一个改完跑一轮 ab记录 RPS 和失败数。别一次改多个否则出了问题不知道是哪个参数导致的。写到这里这个迷你服务器已经能跑静态资源、能扛几百并发、能通过 curl 和 ab 验证。但真正让我长记性的是第一次上线时忘了处理半包用户上传头像偶尔失败查了两天才定位到read只读了一半。从那以后我写任何 NIO 代码都会先问自己这次读到的数据完整吗希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询