新闻详情

新闻详情

首页 / 资讯中心 / 详情

Java NIO 多线程 Web 服务器实战:从 BIO 到事件驱动的高并发改造

发布时间:2026/9/29 1:53:24来源:尧图网络
Java NIO 多线程 Web 服务器实战:从 BIO 到事件驱动的高并发改造
简介一份基于 Java NIO 实现多线程 Web 服务器的实战型示例文档面向具备 Java 基础、正在学习网络编程或高性能 I/O 的开发者帮助理解如何用 NIO 应对高并发场景下的 I/O 瓶颈。压缩包共 1 个 PDF 文件大小约 59KB轻量便携适合快速查阅与对照实践。内容覆盖静态与动态资源获取、Cookie 和 Session 管理、HTTP 长连接、注解式控制器如 RequestMapping、RequestParam以及过期连接和会话的定时清理机制并附有项目目录结构与核心代码片段便于读者搭建环境后直接运行和调试。该资源已有 310 人浏览学习说明其在实际学习场景中具有一定参考价值尤其适合需要设计简易 Web 服务器、深入理解 NIO 多线程模型或研究 HTTP 协议底层交互的 Java 学习者。1. 让 Java 面试题里的 NIO 变成能跑的多线程 Web 服务器先抛一个反直觉的结论很多人把“Java 多线程 Web 服务器”理解成“来一个连接就 new 一个线程”结果压测到几百并发就卡死最后归咎于系统限制。真正的差异不在线程数量而在“谁在等数据”。用 Java NIO 重写一个多线程 Web 服务器本质是把“让线程等数据”改成“让事件等线程”这也是 java 面试题里最高频的追问点BIO 和 NIO 到底差在哪为什么 Netty 能撑起高并发。这份实例适合三类人准备 java 面试想亲手验证事件循环与线程池的配合刚学完 NIO 但看不懂 Netty 源码需要一个能跑通的中间态以及想把静态文件服务、HTTP 解析、Keep-Alive 这些概念落到代码上的人。接下来我会按“选型 → 骨架 → HTTP 处理 → 避坑 → 压测”完整走一遍代码可以直接复制跑。2. 老写法为什么卡在高并发BIO 与 NIO 的线程模型差异先别急着写代码把线程模型想清楚后面调参才有依据。多数人第一次写 Web 服务器用的都是 BIO主线程 accept来一个连接就开一个线程去读。这套模型在小并发下很顺但一旦连接数上来线程数量就变成了硬瓶颈。NIO 的出现不是替代“多线程”而是把“每个连接一个线程”改成“少量线程处理大量连接”这正是多线程思考时最容易绕进去的地方。2.1 一个线程等一个连接BIO 的钱都花在“等”上BIO 的核心是阻塞读。每个连接持有一个 Socket线程执行inputStream.read()时如果对方不发数据线程就挂在那里。HTTP 请求不是每时每刻都有数据在传大多数连接处于空闲状态但线程已经被占用了。一个 4 核 8 线程的机器开 200 个线程已经让 CPU 大量花在线程切换上而真正的业务计算没做多少。// BIO 典型写法每 accept 一个连接就开一个线程 Socket socket serverSocket.accept(); new Thread(() - { InputStream in socket.getInputStream(); byte[] buf new byte[1024]; while (true) { int n in.read(buf); // 线程阻塞在这里等数据 if (n -1) { break; } // 处理请求... } }).start();这段代码的问题一眼就能看出来线程的生命周期绑定在连接上而不是绑定在任务上。一个连接建立后即使 10 秒不发数据这个线程也什么都不干地等 10 秒。线程是操作系统最贵的资源之一每开一个线程默认就要分配 1MB 左右的栈空间同时线程切换还要消耗 CPU。线程池能限制线程数量但解决不了“线程被空闲连接占住”这个根本问题因为read()是阻塞的线程池里的线程一旦开始读就回不到池里。BIO 看起来代码最简单但并发一上来问题就从“业务逻辑写没写对”变成了“线程到底够不够用”。而且这个瓶颈很难通过调参绕过去调大线程池只是延迟崩溃调小了连接多了直接拒绝服务。真正要解决的是“不要用线程去等人”也就是进入 NIO 的事件模型。2.2 Selector 把“等人”变成“看板”NIO 的事件模型NIO 的核心是 Selector我习惯把它理解成一块“看板”。所有客户端连接注册到看板上你关心的不是某个连接现在有没有数据而是哪个连接有事件发生。Selector 维护三类事件OP_ACCEPT有新连接、OP_READ可读、OP_WRITE可写。一个线程调用selector.select()时它会阻塞在内核上直到这些事件中至少有一个发生然后返回就绪的事件集合。// NIO 事件模型的基本形态 Selector selector Selector.open(); ServerSocketChannel server ServerSocketChannel.open(); server.configureBlocking(false); server.bind(new InetSocketAddress(9080)); server.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(1000); // 阻塞 1 秒等事件 for (SelectionKey key : selector.selectedKeys()) { if (key.isAcceptable()) { // 新连接来了accept 后也注册到 selector } else if (key.isReadable()) { // 某个连接有数据可读把任务交给线程池 } else if (key.isWritable()) { // 某个连接可以写了把残留数据写出去 } } }这段代码最关键的一点是configureBlocking(false)。ServerSocketChannel 和 SocketChannel 都设置成非阻塞模式意味着accept()和read()调用会立即返回没有数据就返回 0不会把线程挂死。这样线程就不再是“一个连接一个”而是“一个线程管所有连接”。一个 select 线程加一个 worker 线程池就能支撑几千个连接这也是所有高性能 Java 网络库的底层逻辑。这里要澄清一个误区NIO 不等于“非阻塞就一定快”。它的优势是把“等待”集中到了一个 selector 上让线程从等待中释放出来去做真正的计算。如果你的业务里每个请求本身就是重计算NIO 并不会带来数量级提升。Web 服务器这种“读请求 → 查文件 → 写响应”的场景绝大多数时间花在 IO 上NIO 的优势才真正发挥出来。2.3 多线程放哪里Reactor 主线程与 Worker 线程池的取舍选定 NIO 之后第二个决策是线程池放哪里。常见做法是单 Reactor 线程负责select()和 acceptworker 线程池负责处理每个连接上的 IO。我一般不建议把业务逻辑直接放到 select 线程里执行因为 select 线程一旦被某个慢请求拖住所有连接的读写事件都会延迟等于把并发又降回了串行。// 线程池划分select 线程只做事件分发worker 线程做具体 IO 和业务 ExecutorService workerPool Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors() * 2 1 );这个线程池大小的经验公式适合 IO 密集型任务CPU 核数 * 2 1。Web 服务器的瓶颈通常在读写和磁盘不在 CPU 计算所以线程数可以比 CPU 核数略多让一部分线程在等待 IO 时其他线程还能继续跑。如果压测发现 CPU 没跑满但吞吐上不去先把线程池调大一点观察如果 CPU 已经 90% 以上加线程只会加剧切换。主线程和 worker 线程的职责必须分清楚。主线程 cycle 里只做三件事select()、遍历选中的 key、把事件提交给线程池。worker 线程做四件事读数据、解析请求、构造响应、写回数据。二者之间的边界是一个隐形的队列也就是线程池的阻塞队列。这里也有一个参数要考虑Executors.newFixedThreadPool默认用无界队列LinkedBlockingQueue任务积压不会拒绝但连接多到一定程度请求会排在队列里慢慢处理客户端那边表现为响应变慢。生产环境中我宁愿用有界队列加拒绝策略队列容量在 1000 到 5000 之间满了就返回 503保证服务器不会因为积压任务而内存暴涨。实现上可以把线程池定义改一行ExecutorService workerPool new ThreadPoolExecutor( 8, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(2048), new ThreadPoolExecutor.CallerRunsPolicy() );这个配置的含义是核心线程 8 个最大 32 个空闲线程 60 秒回收队列容量 2048队列满时由提交任务的线程自己执行兜底。CallerRunsPolicy看起来是“性能下降”但它能避免任务悄悄丢在队列里而且不会把服务器打崩属于性价比最高的兜底方案。3. 用 ServerSocketChannel 搭服务器骨架事件循环与线程池落地原理说清了现在开始搭骨架。第三章的目标是跑通一个“能 accept、能读、能交给线程池”的最小服务器先不做 HTTP 解析。很多教程把这个阶段和 HTTP 解析塞在一起导致新手分不清“连接处理”和“协议处理”的边界。我分开做每个阶段都能独立验证。3.1 最小可运行的主循环代码主类就叫NioWebServer包含三样东西Selector、ServerSocketChannel、线程池。这份代码可以直接复制到一个 Java 8 工程里运行不需要任何第三方依赖。import java.io.IOException; import java.net.InetSocketAddress; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class NioWebServer { private Selector selector; private ServerSocketChannel serverChannel; private ExecutorService workerPool; public void start(int port) throws IOException { selector Selector.open(); serverChannel ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.bind(new InetSocketAddress(port)); serverChannel.register(selector, SelectionKey.OP_ACCEPT); int threadCount Runtime.getRuntime().availableProcessors() * 2 1; workerPool Executors.newFixedThreadPool(threadCount); System.out.println(server started at port port); while (selector.select(1000) 0) { IteratorSelectionKey it selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); if (!key.isValid()) { continue; } if (key.isAcceptable()) { doAccept(key); } else if (key.isReadable()) { // 先取消读兴趣避免 worker 还没处理完select 又派发同一事件 key.interestOps(key.interestOps() ~SelectionKey.OP_READ); workerPool.execute(() - HttpWorker.handle(key)); } else if (key.isWritable()) { workerPool.execute(() - HttpWorker.writeBack(key)); } } } } private void doAccept(SelectionKey key) { try { ServerSocketChannel server (ServerSocketChannel) key.channel(); SocketChannel client server.accept(); if (client null) { return; } client.configureBlocking(false); SelectionKey clientKey client.register(selector, SelectionKey.OP_READ); clientKey.attach(new HttpWorker(client)); System.out.println(accepted: client.getRemoteAddress()); } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) throws IOException { new NioWebServer().start(9080); } }说明三点。第一selector.select(1000)是关键参数它让 select 最多阻塞 1 秒保证即使没有任何事件主线程也会定期醒来方便你将来在循环里加监控或清理逻辑。改成 0 就是永远阻塞改成负数会抛异常。第二处理OP_READ时先去掉读兴趣是给 worker 线程一个“独占时段”避免同一个 key 被 select 线程反复派发这一点在并发高的时候特别重要。第三doAccept里把HttpWorkerattach 到 SelectionKey 上后续读取、写出都由这个对象持有连接状态每个连接一个实例。这里的HttpWorker还不存在下一节先补一个简单版本只做“读出来打印”不做 HTTP 解析。3.2 线程池与 Buffer 参数怎么定骨架里有两个参数直接影响资源占用线程池大小和 ByteBuffer 大小。线程池在前一章已经给了经验公式这里重点说 Buffer。public class HttpWorker { private static final int BUFFER_SIZE 8192; private final SocketChannel channel; private final ByteBuffer readBuffer ByteBuffer.allocate(BUFFER_SIZE); public HttpWorker(SocketChannel channel) { this.channel channel; } public void handle() throws IOException { readBuffer.clear(); int n channel.read(readBuffer); if (n -1) { channel.close(); return; } if (n 0) { // 没有数据可读恢复读兴趣继续等待 return; } readBuffer.flip(); byte[] bytes new byte[readBuffer.remaining()]; readBuffer.get(bytes); System.out.println(new String(bytes, StandardCharsets.UTF_8)); } }Buffer 大小选择有讲究。8KB 能覆盖绝大多数 HTTP 请求头和小的 POST 体。请求头超过 8KB 时一次 read 读不完必须做累积读取这正是下一章要解决的问题。Buffer 开太小比如 1KB请求头稍微长一点就要多次读增加系统调用次数开太大比如 1MB每个连接都持有 1MB一万个连接就是 10GB内存直接爆掉。8KB 是当前网络编程里的默认安全值兼顾内存占用和系统调用次数。这段代码里最容易看错的是readBuffer.clear()的位置。每次 read 之前必须 clear把 position 归零否则数据会写进上次读过的位置。read 之后必须 flip把 position 置 0limit 置为实际读到的字节数才能从 buffer 里把数据读出来。这两个方法用错是 NIO 入门翻车率最高的点后面避坑章节会专门展开。3.3 为什么我不在事件循环里直接写业务有人会问既然读事件来了直接在 select 线程里read parse write不是更简单吗确实小请求这样做也能跑但代价是 select 线程被业务拖住。假设某个请求要读一个 100MB 的文件再写回这个过程在 select 线程里执行几十毫秒期间所有其他连接的事件都排着队select 变成了瓶颈。我一般坚持两个原则。第一select 线程里只做 accept 和事件派发所有可能耗时的 IO 都交给 worker。第二连接状态必须是每个连接独立的对象不能多个连接共享同一个 ByteBuffer。如果把 buffer 设计成静态共享两个连接同时来数据互相覆盖排查起来非常痛苦。把 readBuffer 放在 HttpWorker 实例里靠key.attach()绑定天然保证连接隔离这也算是面向对象编程 java 基本功在并发场景下的一次实际应用。第三个原因是线程切换与事件派发的平衡。每次 select 返回就绪事件worker 线程从队列里取任务执行涉及一次线程上下文切换。如果把业务放到 select 线程里省了这次切换但失去了并发能力。对 Web 服务器来说并发能力远比那一次切换成本重要。压测时你会发现一个 select 线程加四个 worker 线程吞吐量已经能超过大多数 BIO 服务器的十倍。4. HTTP 解析与响应从 ByteBuffer 到一条条请求骨架能跑通后开始写协议层。HTTP 解析是 Web 服务器里最容易出问题的地方难点不在语法而在“一条请求可能分多次到达”。先明确 HTTP 请求的格式请求行、请求头、空行、请求体。空行是\r\n\r\n它之前的部分称为 header 区之后的部分可能是 POST 体也可能什么都不剩。4.1 请求解析半包累积与请求行拆分NIO 是事件驱动的一次 read 不一定能拿到完整请求。浏览器发一个 3KB 的请求头内核可能分两个 TCP 包送过来第一次 select 只返回了 1.5KB。如果这时就去解析解析器会直接报错。所以第一步是累积读取直到拿到完整 header。public void handle(SelectionKey key) { try { readBuffer.clear(); int n channel.read(readBuffer); if (n -1) { channel.close(); return; } if (n 0) { readBuffer.flip(); byte[] tmp new byte[readBuffer.remaining()]; readBuffer.get(tmp); requestBytes.append(bytes); } // 在累积数据中寻找 header 结束标记 int headerEnd indexOf(requestBytes, \r\n\r\n); if (headerEnd 0) { // 半包恢复读兴趣等下一次事件 key.interestOps(key.interestOps() | SelectionKey.OP_READ); return; } // 到这里说明 header 已经完整接下来解析 parseAndRespond(key, headerEnd); } catch (IOException e) { try { channel.close(); } catch (IOException ex) { ex.printStackTrace(); } } }代码里的requestBytes是一个字节累积容器我用byte[]加游标实现或者直接用一个ByteArrayOutputStream每次 read 后write()进去。indexOf是对累积数据做子串匹配找到\r\n\r\n的位置。如果找不到说明一个完整请求还没到齐这次先恢复读兴趣继续等。不要在这里直接返回错误这是半包问题最常见的原因也是很多新手第一次面对“黑匣子”时最容易翻车的判断。header 完整之后把 header 区转成字符串按\r\n切行。第一行是请求行格式是METHOD SP URI SP HTTP版本。后面每一行是请求头冒号分隔字段名和值。这里有一个细节HTTP 头部字符集按规范是 ISO-8859-1而 URI 可能是 UTF-8 编码后的百分号形式。所以转字符串时不要用 UTF-8否则遇到某些特殊字符会乱。String headerText new String(requestBytes, StandardCharsets.ISO_8859_1); String[] lines headerText.substring(0, headerEnd).split(\r\n); String[] parts lines[0].split( ); String method parts[0]; String uri parts[1];URI 里的中文路径会被浏览器编码成%E4%BD%A0%E5%A5%BD解析时用java.net.URI解码而不是URLDecoder。URLDecoder会把当成空格而 URI 里的是合法字符不能变。new URI(uri).getPath()能正确按百分号编码规则解码这是很多人忽略的差异。4.2 Request/Response 封装与静态文件读取协议解析直接写在 worker 里会越来越乱我一般封装成 Request 和 Response 两个对象职责单一后面加功能也不用动主循环。public class Request { private String method; private String path; private int contentLength; public static Request parse(String headerText) { String[] lines headerText.split(\r\n); String[] parts lines[0].split( ); Request req new Request(); req.method parts[0]; try { req.path new URI(parts[1]).getPath(); } catch (Exception e) { req.path /; } for (int i 1; i lines.length; i) { if (lines[i].toLowerCase().startsWith(content-length:)) { req.contentLength Integer.parseInt(lines[i].split(:)[1].trim()); } } return req; } }注意new URI(parts[1]).getPath()对非法 URI 会抛异常所以外面包了 try-catch。如果你要支持查询字符串path 里是不包含?之后的参数的getPath()已经自动把查询串拿掉了无需手动截取。Response 的构造同样要严格。HTTP 响应分状态行、响应头、空行、消息体。状态码、Content-Type、Content-Length 三个字段必须正确尤其是 Content-Length写短了客户端会一直等写长了客户端会解析失败。public class Response { private int status 200; private String reason OK; private byte[] body new byte[0]; private String contentType text/html; charsetutf-8; public byte[] toBytes() { StringBuilder head new StringBuilder(); head.append(HTTP/1.1 ).append(status).append( ).append(reason).append(\r\n); head.append(Content-Type: ).append(contentType).append(\r\n); head.append(Content-Length: ).append(body.length).append(\r\n); head.append(Connection: keep-alive\r\n); head.append(\r\n); byte[] headBytes head.toString().getBytes(StandardCharsets.ISO_8859_1); ByteBuffer buf ByteBuffer.allocate(headBytes.length body.length); buf.put(headBytes); buf.put(body); return buf.array(); } }Content-Length 必须用body的字节长度而不是字符串长度。中文字符在 UTF-8 下占 3 个字节如果写成body.length是没问题的但如果你先getBytes()再取length顺序反了就出错。这一行也是中文网页乱码的高发区很多现实中的乱码不是编码不对而是字节长度算错了。静态文件读取放在路由方法里。安全上必须做目录边界校验这是 Web 服务器安全里最基本的一关。private static final String DOC_ROOT ./webroot; private void route(Request req, Response resp) throws IOException { if (!GET.equals(req.method()) !HEAD.equals(req.method())) { resp.setStatus(405, Method Not Allowed); return; } Path root Paths.get(DOC_ROOT).toRealPath(); Path file root.resolve(req.path().substring(1)).normalize(); if (!file.startsWith(root)) { resp.setStatus(403, Forbidden); return; } if (!Files.exists(file) || Files.isDirectory(file)) { resp.setStatus(404, Not Found); return; } resp.setBody(Files.readAllBytes(file)); resp.setContentType(application/octet-stream); }root.resolve(req.path().substring(1))把 URI 路径拼到 webroot 下normalize()消掉..和.。如果客户端请求/../etc/passwdnormalize 之后会变成/etc/passwd此时startsWith(root)返回 false直接拒绝。这就是路径穿越防护的最小实现。如果 webroot 目录里有符号链接指向外部toRealPath()才能兜底生产环境建议两种检查都做。4.3 响应写出与 Keep-Alive 的配合响应构造完成后要写回客户端。非阻塞模式下channel.write()不一定一次写完这是 NIO 服务器必须面对的现实。小页面通常一次写完大文件会写一半。我之前见过很多 demo 直接channel.write(respBuffer)就不管了压测小页面没暴露问题换一个大文件下载就卡死。处理原则很简单写不完就保存剩余数据注册 OP_WRITE等 select 事件再次触发后继续写。public void writeBack(SelectionKey key) { try { if (pendingWrite ! null) { channel.write(pendingWrite); if (pendingWrite.hasRemaining()) { // 还没写完保持 OP_WRITE 兴趣 return; } pendingWrite null; } // 写完恢复读兴趣继续处理下一条请求 key.interestOps(SelectionKey.OP_READ); key.attach(this); } catch (IOException e) { closeSilently(key); } }要注意pendingWrite是一个 ByteBuffer它的 position 在每次write()之后自动前进。写完时hasRemaining()为 false把引用置空恢复 OP_READ。如果写不完什么都不做保持 OP_WRITEselect 会在下一次可写时再次派发。不要手动把 channel 关掉那会让客户端收到一个突然断开的连接。Keep-Alive 的处理与 OP_READ 的恢复绑定在一起。HTTP 1.1 默认是 keep-alive一个连接可以连续发多个请求。所以一个请求处理完后不能关闭 channel而是恢复读兴趣继续读下一条。这要求每次解析完之后把累积的请求数据清空否则下一条请求会和上一条残留拼接在一起解析直接崩。我实现的简单策略是处理完一条就清空累积区不做 HTTP pipeline 的优化。浏览器基本上是一条请求等到响应后再发下一条pipeline 场景很少触发等真正需要时再引入总字节数限制和粘包拆分逻辑。5. NIO 服务器避坑手册五个最容易翻车的位置一个 NIO 服务器从“能跑”到“稳定跑”中间隔着一堆隐性坑。这些坑不是语法错误是事件驱动模型与 TCP 状态机相互作用的结果。下面五条是我自己踩过或帮人排查过的真实记录每条都按现象、原因、解决三个部分展开。5.1 flip/clear/compact 顺序错乱上一个请求残留现象第二次请求开始服务端解析出来的请求头里带着上一轮的数据偶尔能解析成功偶尔直接报格式错误。代码逻辑看着没问题但就是不对劲。原因ByteBuffer 的 position 和 limit 没有正确管理。读数据前调用clear()可以读完后没有flip()就直接get()读到的是旧数据或者上次没读完的数据没有compact()clear()后旧数据还在 position 之前的位置下次写入会覆盖但剩余数据没有保留。最典型的错误是在半包场景下用了clear()而不是compact()。解决读之前clear()读之后flip()这是固定套路。如果要把未读完的数据留在 buffer 里继续累积必须用compact()它的语义是把 position 到 limit 之间的未读数据搬到 buffer 头部然后把 position 设为未读数据的长度。简单场景里我更推荐用独立的累积容器比如ByteArrayOutputStream每次 read 后追加避免直接对 ByteBuffer 做原地累积理解成本和出错率都低很多。5.2 半包当完整包解析解析器拿到一半就想吐结果现象本地 curl 测试没问题用浏览器访问时偶尔出现 400 错误刷新一下又好。压测时错误率明显上升且错误请求集中在请求体较大的 POST 上。原因TCP 是流式协议没有“消息边界”。一次read()返回的字节数可能不是一个完整请求可能是半个也可能是一个半。解析器一旦在数据不完整时执行split(\r\n)并取第一行收到的就是截断的请求行。解决解析前必须先找\r\n\r\n找不到就当作半包恢复 OP_READ 继续等下一轮数据。找到之后再根据Content-Length判断请求体是否也完整。两个条件都满足才进入解析。这个判断顺序不要反过来先找空行再判断 body 长度是最稳妥的顺序因为 header 结束标记是整个请求结构里最明确的锚点。5.3 同一个 SocketChannel 被两个线程同时写现象并发压测时客户端偶尔会报Connection reset by peer服务端日志里出现java.io.IOException: 远程主机强迫关闭但单个请求单独测完全正常。原因多线程模型下读事件可能被派发到线程 AOP_WRITE 事件被派发到线程 B两个线程同时对同一个 SocketChannel 执行write()。JDK 的 SocketChannel 并发写没有内置锁两条线程交错写入TCP 数据流就错乱了。更隐蔽的是线程 A 正在处理读时线程 B 已经开始写响应导致请求和响应的边界互相穿插。解决在派发读事件时先取消 OP_READ 兴趣相当于把一个 channel 标记为“处理中”。worker 处理完并写回响应后才恢复 OP_READ。这样每个 channel 任意时刻只被一个线程持有。如果要用更细粒度的保护给每个连接加一个AtomicBoolean在进入处理逻辑时compareAndSet处理完再解锁可以防止 select 派发延迟导致的重复进入。5.4 大响应没写完就切换 OP_READ连接直接卡死现象小页面返回正常下载超过几十 MB 的文件时客户端下载进度条走到某处就不动了超时后断开。服务端日志没有任何异常。原因channel.write(outBuffer)在非阻塞模式下可能只写出一部分数据。代码如果没检查hasRemaining()直接恢复 OP_READ剩下的数据就一直留在内存里select 不再派发 OP_WRITE客户端等不到完整响应连接悬挂。解决写完后检查剩余字节。有剩余就把 buffer 保存到 worker 实例里注册 OP_WRITE等下次可写事件继续写。写完一个响应再恢复 OP_READ顺序必须是“写完成 → 恢复读”不能倒过来。文件下载这类大响应更推荐用零拷贝方式下一章会展开。5.5 静态目录穿越Web 服务器安全的边界必须亲自守现象用路径拼接直接提供服务时客户端访问/../../etc/passwd服务端原样拼到了文件路径里把系统文件读了出来。这在 Web 服务器安全里属于最底线的漏洞和版本无关纯写代码疏忽。原因HTTP URI 里的..是允许出现的不会在协议层被过滤。拼接时直接root uri就会让路径越界。有些代码只在开头判断一次uri.startsWith(/)防不住中间嵌入的连续..。解决所有静态路径统一走“规范化 前缀校验”。用Paths.get(root, uri).normalize()再startsWith(root)判断。如果要防符号链接再调用toRealPath()。这两步做完路径穿越基本堵死。顺带提醒不要依赖黑名单过滤..黑名单永远有绕过方式白名单式的前缀校验才是可靠边界。6. 压测与两个进阶优化用 ab 验证、零拷贝收尾6.1 一次能说明问题的 ab 压测服务器写好之后用ab做一轮压测。命令很简单但没有-k参数测出来的结果是“每次新建连接”的开销不带连接复用不能反映 keep-alive 服务器的真实水平。ab -k -n 10000 -c 100 http://localhost:9080/-k开启连接复用-n 10000代表总共发 10000 个请求-c 100代表同时 100 个并发连接。一轮跑完先看两个指标Requests per second是吞吐量Failed requests如果是 0再看Connection Times里的均值。如果吞吐量远低于预期先看是不是半包问题、有没有连接卡死再看线程池配置。生产级服务器里nginx 这类高性能 Web 服务器也遵循同样的事件驱动模型只是把调度器换成了 C 语言级别的 epoll这个对照能帮你理解自己写的服务器还有多少上升空间。6.2 值得做的两个优化零拷贝与 DirectBuffer 复用静态文件服务里当前实现是Files.readAllBytes()整体读入内存再写出文件一大就会占用大量堆内存。进一步优化是零拷贝用FileChannel.transferTo()让文件内容直接从内核写到 Socket 缓冲区省掉从内核复制到 JVM 堆这一步。需要注意非阻塞通道上transferTo的可用性依赖平台和 JDK 实现本地先验证一次再考虑上线。这个优化对文件下载场景的提升非常明显CPU 占用能下降一半以上。第二个优化是 Buffer 复用。线程池里的每个 worker 维护一个独立的ByteBuffer用allocateDirect()替代allocate()堆外内存减少 GC 压力。代价是分配和释放比堆内慢所以不能每次请求都新建必须复用。压测时留意 GC 日志如果频繁出现GC overhead limit exceeded多半是 Buffer 开太多或者没有复用。我自己每次改完线程模型都会先跑一轮 ab 看 Failed requests再看每秒请求数和上一版做对比。这个习惯救过我很多次——有一次改完线程池参数吞吐反而掉了 30%查了半天发现是CallerRunsPolicy把大量任务推回了 select 线程让事件循环变成了瓶颈。这类问题靠看代码很难一眼发现压测基线才是最快的定位路径。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

写好游戏Prompt的8个技巧:让OpenGame生成更专业的Web游戏 2026/9/29 4:37:20

写好游戏Prompt的8个技巧:让OpenGame生成更专业的Web游戏

写好游戏Prompt的8个技巧:让OpenGame生成更专业的Web游戏 【免费下载链接】OpenGame OpenGame: Open Agentic Coding for Games 项目地址: https://gitcode.com/gh_mirrors/op/OpenGame OpenGame 是一个开源的游戏智能体框架,它能从你写的一段游戏…

阅读更多 →
OpenScreen 标注工具全解:文字、箭头与图片标注让教程视频更清晰 2026/9/29 4:37:20

OpenScreen 标注工具全解:文字、箭头与图片标注让教程视频更清晰

OpenScreen 标注工具全解:文字、箭头与图片标注让教程视频更清晰 【免费下载链接】openscreen Record your screen, ship a demo. Free and open-source, GPU-accelerated, no watermarks, no subscriptions. Windows, macOS, Linux. Actively maintained. 项目地…

阅读更多 →
WinForm 嵌入 Word/Excel 实战:COM+SetParent 源码方案 2026/9/29 4:37:14

WinForm 嵌入 Word/Excel 实战:COM+SetParent 源码方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
2026年还值得买的AI编程订阅有哪些?Awesome Coding Plan五大厂商性价比清单(含免费推荐) 2026/9/29 4:37:14

2026年还值得买的AI编程订阅有哪些?Awesome Coding Plan五大厂商性价比清单(含免费推荐)

2026年还值得买的AI编程订阅有哪些?Awesome Coding Plan五大厂商性价比清单(含免费推荐) 【免费下载链接】awesome-coding-plan 各厂家 Coding Plan 实际价值对比 项目地址: https://gitcode.com/gh_mirrors/aw/awesome-coding-plan 本…

阅读更多 →
华为交换机批量清除接口配置的工程实践与避坑指南 2026/9/29 4:37:07

华为交换机批量清除接口配置的工程实践与避坑指南

1. 项目概述:为什么批量清除接口配置是华为交换机运维的“高频刚需”在实际网络运维中,我几乎每周都会遇到这类场景:新接手一批二手S5720交换机,设备里残留着前任工程师留下的VLAN、ACL、QoS策略和错误的Trunk配置;或者…

阅读更多 →
AI智能体与多AI协作实战:从训练方法到工具选型 2026/9/29 4:37:01

AI智能体与多AI协作实战:从训练方法到工具选型

今天打开各种群,发现讨论最多的还是智能体、编程辅助和各种“AI副业”的消息。其实这类信息每天都有,但真正值得记录的,往往是那些能落地、能改变工作方式的小细节。我干脆把今天看到、试到手的东西整理成一份日报式的清单,聊聊几…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉