ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java NIO多线程Web服务器实战:从BIO到Reactor模型

Java NIO多线程Web服务器实战:从BIO到Reactor模型 简介基于Java NIO实现的多线程Web服务器实例面向后端开发与网络编程学习者用于理解如何借助非阻塞I/O、多线程及HTTP协议搭建高并发服务。资源包为1个PDF文件大小约59KB内容以代码讲解形式覆盖静态资源获取、动态资源获取、Cookie与Session管理、HTTP长连接及连接和Session定时清理等关键机制并展示了EchoController、test.html、server-config.properties等文件结构与配置方式同时包含Controller、RequestMapping、RequestParam等注解式编程示例便于对照学习整体设计。已有310人学习下载适合作为课程设计、面试复习或轻量级Web服务器源码分析的入门参考。1. 用 Java NIO 写多线程 Web 服务器先搞清楚它解决什么问题先抛一个场景你在一台 2 核 4G 的小机器上用 Java 写了个 HTTP 服务BIO 模型每来一个请求就 new 一个线程去处理。连接数从 10 涨到 100 之后线程数也跟着涨到 100内存开始吃紧上下文切换让 CPU 居高不下稍微来个慢客户端整个服务就可能卡死。这时候如果面试官再追问一句“服务端怎么支撑上万连接”背后答案基本就指向同一个方向Java NIO 多线程模型。这篇笔记要讲的就是用 Java NIO 实现一个多线程 Web 服务器的完整落地过程包括线程模型设计、最小可运行实例、参数设定逻辑以及我在实际写这个标题对应方案时踩过的一堆坑。适合正在准备 Java 面试、想看 Netty 底层模型但先拿手写 NIO 练手、或者需要不依赖框架写一个轻量 HTTP 服务的后端工程师。2. 为什么是 NIO 而不是 BIO从线程模型说起2.1 BIO 的致命问题一个连接一个线程的代价传统 BIO 服务端的写法三句话就能概括ServerSocket 在 accept() 处阻塞等待连接来了连接就 new Thread 去处理处理完关闭连接。这套模型在连接数少、每个请求处理都很短的时候没有问题但一旦连接数上来每多一个连接就要多占一个线程。Java 默认线程栈是 1MB1000 个连接就是 1GB 的虚拟内存这还没算线程切换的开销。更难受的是大多数连接建立之后并不是立刻发数据很多 HTTP 长连接就是挂着不吭声但线程已经占住了什么活都没干。有人会说那就用线程池限定最大线程数不就行了问题是 BIO 里 read() 是阻塞的线程池里的线程被一个慢请求占住之后后面的请求只能排队等吞吐量直接塌方。这个套路在连接数几十的时候够用到几百上千就集体翻车。jav 面试题里关于“为什么 Netty 比 Tomcat 的 BIO 模式强”的讨论根子就在这里。2.2 NIO 三件套Channel、Buffer、Selector 如何配合Java NIO 的全称是 New I/O它和传统的 IO 最本质的区别是BIO 面向流NIO 面向通道和缓冲区。NIO 里有三个核心组件必须先把它们的关系理清楚后面代码才看得懂。第一个是 Channel通道。通道是双向的既可以读也可以写对应到这里就是 ServerSocketChannel 和 SocketChannel。它不像 InputStream 那样要单独拆一个读流和一个写流而是同一个通道上既读又写。第二个是 Buffer缓冲区。所有读写数据都必须先进 Buffer不能像 BIO 那样直接对字节流操作。Buffer 有 position、limit、capacity 三个核心属性读写切换的时候需要调用 flip()用完要 clear() 或者 compact() 重置。第三个是 Selector选择器。这是整个 NIO 模型的灵魂一个线程可以同时监控成百上千个 Channel只要把 Channel 注册到 Selector 上然后调用 select() 就能拿到当前哪些 Channel 有事件就绪。这三个组件配合起来就用一个线程替换掉了 BIO 里“一个连接一个线程”的粗暴做法。IO 多路复用的意思就是一个线程在 Selector 上等多个连接的事件到来时逐个分发处理线程不再傻等某一个连接的数据。这也是后面所有 Reactor 模型的底层基础。2.3 Reactor 多线程模型标题里的“多线程”到底落在哪光有 Selector 还不够。如果你用一个线程跑 Selector 循环然后直接在事件回调里执行业务代码这个叫单 Reactor 单线程模型。问题非常明显事件循环里任何一步处理慢了比如查数据库、算响应体、写文件所有连接都得跟着等。所以标题里这个“多线程”指的不是“每个连接一个线程”而是把事件循环和业务处理拆开。常见做法是单 Reactor 多线程模型一个线程专注跑 Selector负责 accept 新连接和读取请求数据拿到完整请求后把任务丢给工作线程池由线程池里的线程去解析、计算、生成响应再把结果写回 Channel。更高级一点的是主从 Reactor 多线程模型也就是 Netty 采用的方式mainReactor 只处理 acceptsubReactor 用一组线程处理读写事件业务再交给 worker 线程池。对一个教学性质的“单机可跑实例”来说单 Reactor 多线程是性价比最高、最好复现的落法代码结构清晰也能解释清楚多线程到底加在了哪个环节。3. 最小可用实例单线程 NIO 把 HTTP 请求跑通3.1 搭建 ServerSocketChannel 并注册到 Selector先写一个最小骨架把 NIO 服务端的“监听—注册—选择”链路搭起来。这个阶段先不做多线程只验证 NIO 本身能跑通后面再改造。import java.net.InetSocketAddress; import java.nio.channels.ServerSocketChannel; import java.nio.channels.Selector; public class NioHttpServer { public static void main(String[] args) throws Exception { // 1. 打开服务端通道绑定 8080 端口 ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8080)); // 2. 必须设为非阻塞模式这是 NIO 和 BIO 的分水岭 serverChannel.configureBlocking(false); // 3. 打开一个 Selector并把服务端通道注册进去 Selector selector Selector.open(); serverChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println(NIO server started on port 8080); // 事件循环先空着下一步再补 while (true) { selector.select(1000); } } }这里有几个必须注意的点。configureBlocking(false)一定要在register()之前调用否则会抛IllegalBlockingModeExceptionNIO 要求通道必须先切成非阻塞模式才能注册到 Selector。select(1000)表示阻塞等待最多 1 秒返回值为 0 表示超时没有就绪事件这样设计是为了让循环有机会执行其他逻辑不会死等。3.2 事件循环里识别的两类事件OP_ACCEPT 与 OP_READ有了监听通道之后核心是事件循环。Selector 会把就绪的事件放进selectedKeys()我们要遍历这组 key 并逐个判断事件类型然后分别处理。这里必须手动调用it.remove()移除已处理的事件否则同一批事件会重复出现。while (true) { int readyCount selector.select(1000); if (readyCount 0) { continue; // 没有就绪事件继续轮询 } IteratorSelectionKey iterator selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key iterator.next(); iterator.remove(); // 关键不移除会导致事件重复触发 if (key.isAcceptable()) { // 有新的连接进来接受连接并注册读事件 ServerSocketChannel server (ServerSocketChannel) key.channel(); SocketChannel client server.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 该通道有数据可读读取请求 SocketChannel client (SocketChannel) key.channel(); readRequest(client); } } }server.accept()在非阻塞模式下不会一直等有连接就返回 SocketChannel没有连接就返回 null。新连接默认是阻塞模式必须调用configureBlocking(false)让这个连接也进入非阻塞状态否则它没法被 Selector 管理。注册的时候监听OP_READ表示后续这个连接可读时会触发事件。3.3 解析 HTTP 请求并拼响应最小可用代码接下来补一个能响应 HTTP 请求的方法。这段代码只做一件事读取客户端发来的请求字节把它当成字符串拿到请求行第一行然后返回一个写死的 HTML 页面。先不设计复杂路由重点是演示完整的“读请求—写响应”流程。private static void readRequest(SocketChannel client) { ByteBuffer buffer ByteBuffer.allocate(4096); StringBuilder request new StringBuilder(); try { // 循环读把这次客户端发来的数据尽量都读出来 int len; while ((len client.read(buffer)) 0) { buffer.flip(); // 切换为读模式 byte[] bytes new byte[buffer.remaining()]; buffer.get(bytes); request.append(new String(bytes, StandardCharsets.UTF_8)); buffer.clear(); } if (len 0) { client.close(); return; } // 取第一行GET /index.html HTTP/1.1 String firstLine request.toString().split(\r\n)[0]; if (firstLine.isEmpty()) { client.close(); return; } String[] parts firstLine.split( ); String method parts[0]; String uri parts[1]; String body htmlbodyh1Hello NIO, uri uri /h1/body/html; byte[] bodyBytes body.getBytes(StandardCharsets.UTF_8); // Content-Length 必须用字节数不能用字符串长度否则中文乱码 String response HTTP/1.1 200 OK\r\n Content-Type: text/html; charsetutf-8\r\n Content-Length: bodyBytes.length \r\n Connection: close\r\n \r\n body; ByteBuffer responseBuffer ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8)); client.write(responseBuffer); client.close(); // 关闭连接客户端才能结束响应读取 } catch (IOException e) { e.printStackTrace(); } }这段代码里的一个大坑是Content-Length必须取bodyBytes.length而不是body.length()。字符串的length()是字符数如果 body 里有中文UTF-8 下一个汉字占 3 个字节按字符数返回长度会导致客户端一直等待剩余字节表现就是浏览器一直转圈。另外Connection: close和client.close()配套使用是让 HTTP/1.1 客户端知道响应结束的最简单方式。这个版本跑起来之后浏览器访问http://localhost:8080/abc能看到页面但它仍然只是单线程业务处理是串行的下一步就做多线程改造。4. 多线程改造把业务处理从 Selector 线程里拆出去4.1 线程模型设计谁碰 Channel谁做业务到这一步要明确一个核心原则Selector 线程也就是 main 方法里的主线程只负责 accept 新连接、读请求数据绝不执行耗时业务耗时任务必须丢给工作线程池。这么做是因为事件循环里面一旦有阻塞操作整个服务器的所有连接都会卡住NIO 的并发优势瞬间归零。那能不能把整个 SocketChannel 直接丢给 worker 线程去处理worker 里自己去 read 和 write可以但要注意一个并发问题Selector 并不知道这个 channel 已经交给 worker下次事件循环时如果 channel 又有数据可读Selector 还会再次触发 OP_READ同一个 channel 就会被两个线程同时读数据错乱是早晚的事。常见的解决办法是在把任务提交给线程池之前先调用key.cancel()把该 channel 从 Selector 上摘掉之后 worker 线程对这个 channel 的操作就不会再受事件循环干扰。简化版落地方案是Selector 线程负责 accept 和把连接交给 workerworker 线程负责读取请求、解析、拼响应、写回。我一般推荐先按这个写因为它最容易理解也不会出现“多个线程同时操作一个 channel”的并发怪象。4.2 用 ThreadPoolExecutor 接住 Handler最小改造下面代码展示的是改造后的核心结构定义 worker 线程池在 accept 之后把 SocketChannel 转交给 worker 处理。注意看提交任务前的key.cancel()。import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class NioHttpServer { // 线程池核心 8最大 16空闲存活 60 秒队列容量 256 private static final AtomicInteger THREAD_COUNT new AtomicInteger(1); private static final ThreadPoolExecutor WORKER_POOL new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(256), r - new Thread(r, nio-worker- THREAD_COUNT.getAndIncrement()), new ThreadPoolExecutor.CallerRunsPolicy() ); // 事件循环中的 accept 分支 if (key.isAcceptable()) { ServerSocketChannel server (ServerSocketChannel) key.channel(); SocketChannel client server.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ, client); } else if (key.isReadable()) { SocketChannel client (SocketChannel) key.channel(); key.cancel(); // 关键从 Selector 摘除防止 worker 期间再次触发读事件 WORKER_POOL.execute(() - handleRequest(client)); } }key.cancel()之后这个 channel 就不再被 Selector 监控worker 线程可以放心地对它做阻塞式读取和写入。因为已经脱离 selector 的控制worker 里可以把 SocketChannel 当成传统流来操作读的时候用一个while循环直到读不到为止。这个“先摘除再处理”的方式是单 Reactor 多线程模型里最简单也最不容易出并发问题的写法。CallerRunsPolicy是拒绝策略它的含义是队列满了之后不丢任务而是让提交任务的线程也就是 Selector 线程自己去执行这个任务代价是事件循环会卡一下属于用“短暂阻塞”换“任务不丢”。4.3 线程池参数怎么定核心线程、队列与拒绝策略这里把线程池参数的选取逻辑理一下。很多新手照抄默认参数上线之后才发现要么线程频繁创建销毁要么队列堆满直接 OOM。一个处理 HTTP 请求的 worker 线程池核心参数通常这样考虑参数我的常用值设定逻辑corePoolSizeCPU 核数 * 2大多数请求是短任务IO 和计算混合核数两倍左右能跑满 CPU 又不至于过度切换maximumPoolSize核心数的 2~4 倍应对突发流量给上限兜底keepAliveTime60 秒超过空闲时间回收线程低峰期省资源workQueueArrayBlockingQueue 容量 256~1024太小容易触发拒绝太大容易积压过期任务拒绝策略CallerRunsPolicy宁可让事件循环慢一点也不能悄悄丢掉请求任务队列建议用有界队列。无界队列比如 LinkedBlockingQueue 不设容量在流量洪峰时会让任务无限堆积内存飙升到一个不可控的程度。队列容量设成几百是一个比较稳的中间值默认情况下请求堆积 200 个以内由队列扛住超过这个数任务会直接交给 worker 线程处理线程数上限是 16。到这里“多线程”的部分就真正落地了服务端已经能支撑数百个并发连接。5. 避坑专场NIO 多线程 Web 服务器的 5 个常见问题5.1 粘包半包TCP 字节流没有消息边界现象客户端连续发多个请求时一次 read 可能读到两条请求拼在一起的数据粘包或者一个请求只读到一半半包。原因TCP 是流式协议没有消息边界应用层必须自己确定“一条请求到什么时候算结束”。解决解析请求头找到\r\n\r\n的位置再根据Content-Length计算 body 长度把缓冲区里的数据攒够一条完整请求再处理。我在第 3 章的演示代码直接按第一行解析只适合一次性小请求实际做实例要加“积累数据—判断完整性—再解析”的循环。5.2 Selector 空轮询JDK 在 Linux 上的经典 Bug现象服务器没有连接进来但某条线程 CPU 打到 100%看堆栈发现卡在selector.select()返回 0 又立刻再 select 的死循环上。原因这是早期 JDK 在 Linux 下 epoll 实现的一个已知 bugselect() 会在某些条件下提前返回 0导致事件循环变成忙等。解决统计连续空轮询次数超过阈值比如 512 次或 1024 次就主动重建 Selector把原有注册的 channel 重新 register 到新的 Selector 上。Netty 内部也有类似的规避逻辑所以这个坑不算冷门属于手写 NIO 几乎必遇的玄学问题。5.3 多线程写同一个 Channel响应错乱与连接重置现象两个请求并发处理时客户端收到的响应内容交错A 请求的 HTML 和 B 请求的 HTML 混在一起甚至报 Connect Reset。原因多个 worker 线程同时对一个 SocketChannel 执行write()ByteBuffer 里的数据被并发写乱。解决最简单的是给每个 channel 的写操作加锁保证同一时间只有一个线程在写或者设计成每次只让一个专属线程处理这个 channel 的所有读写。我在 4.2 的做法里用key.cancel()把 channel 从 Selector 摘掉配合一个 worker 全程处理从原理上规避了双线程写同一通道的问题。5.4 ByteBuffer 容量太小诡异的数据截断现象响应内容比较大的时候前一半正常后一半丢失或乱码浏览器报 ERR_INCOMPLETE_CHUNKED_CONTENT。原因ByteBuffer.allocate(4096)只分配了 4K 空间写入数据超过缓冲区容量时会溢出或者读取循环里没有正确处理flip()和clear()的切换导致某次读取其实没读到数据。解决读取数据用while ((len channel.read(buffer)) 0)循环配合一个累积用的 ByteArrayOutputStreamflip()只在准备读数据之前调用clear()只在准备继续写入之前调用顺序不能乱。这里最容易翻车我建议把 buffer 的 position、limit 打印出来跑一遍很快能理解这三个属性的含义。5.5 关闭连接时机Content-Length 与连接泄漏现象客户端请求返回后一直转圈不结束服务端连接数持续上涨最后因为文件描述符耗尽拒绝新连接。原因响应头缺失Content-Length或Connection: closeHTTP/1.1 下客户端无法判断实体什么时候结束服务端又没有主动 close连接变成了悬空资源。解决每个响应都严格带上Content-Length计算方式用 byte 数组长度不需要复用连接时明确写Connection: close并在响应后关闭通道。如果要支持 keep-alive就得实现真正的请求边界判断复杂度会高一截先把 close 模型跑稳再做。6. 验证与优化方向从跑通到压测千级并发把服务跑起来之后第一步先验证行为正确开两个终端一个跑curl -v http://localhost:8080/test能看到响应头和 HTML 正文另一个跑jstack pid看线程列表应该能看到一个main线程在跑 Selector 事件循环若干个nio-worker-x线程在池子里待命。这一步确认了多线程模型真的生效而不是只在代码里写了线程池。第二步做压力测试Mac 或 Linux 下直接用ab工具ab -n 20000 -c 200 -k http://localhost:8080/重点关注两个指标Requests per second和Failed requests。如果-c提高到 500 时失败率飙升或耗时直线上升优先检查线程池队列是否被打满、select()空轮询是否出现。压测时可以多开几个终端轮流执行jstack观察 worker 线程状态常见情况是 worker 线程全部处于 RUNNABLE那就说明任务都在正常处理如果看到大量CallerRunsPolicy触发、main 线程也卡进业务代码说明池子小了把 corePoolSize 往上调一档再压。再往后优化可以走三个方向一是把 SocketChannel 的读写改成堆外 Buffer减少 GC 压力二是大文件响应改用FileChannel.transferTo()做零拷贝不经过用户态内存三是升级为主从 Reactor 模型多线程处理 accept 和读写事件。我当年把这个实例做到第三步之后反而回来把第一版的线程模型又重构了一遍原因就是压测数据告诉我真正的瓶颈不在 Selector 而在业务处理里频繁的 ByteArrayOutputStream 拷贝。记住一条手写 NIO 服务业务处理慢是能靠线程池堆出来的但事件循环阻塞和多线程写同一通道这两类问题堆多少线程都没用。希望这篇能帮你在动手做这个 NIO 多线程 Web 服务器实例时少走几步弯路。本文还有配套的精品资源点击获取
返回列表