☰
Java NIO Web服务器实例:Selector多路复用与注解路由实现
2026/10/12 19:01:14 网站建设 项目流程

简介:这是一份面向Java后端开发者与网络编程学习者的实战型资料,聚焦基于NIO的多线程Web服务器实现,适合希望深入理解高并发I/O模型、HTTP协议处理与注解式框架设计的中级开发者参考。资源包共1个文件,为PDF格式,大小约59KB,内容围绕服务器核心模块展开,涵盖静态与动态资源获取、Cookie与Session管理、HTTP长连接及过期清理机制,并实现类似Spring MVC的注解式编程,如@RequestMapping、@RequestParam等,支持参数绑定与级联属性。同时包含日志记录方案与server-config.properties配置说明,涉及端口、连接过期时间、线程池大小等关键参数。目前已有311人学习,读者可借此掌握NIO多线程服务器的整体架构与关键实现思路,理解从请求解析到响应渲染的完整链路,为自研轻量级Web容器或深入Netty等框架打下基础。

1. 从 BIO 到 NIO:为什么这个 Java Web 服务器实例值得拆一遍

如果你写过ServerSocket的阻塞式回声服务,大概经历过这种场面:一个客户端连上来不发数据,整个accept()循环就卡死,后面所有请求排队等着。这不是代码写错了,是 BIO 模型的天花板——一个连接占一个线程,线程池一满,新连接直接拒绝。Java NIO Web 服务器要解决的就是这个问题:用 Selector 做多路复用,让少量线程管理大量连接,再配一个业务线程池处理具体请求,把 I/O 等待和业务计算解耦。

这份实例的价值在于它不是只给你一个Selector轮询的骨架,而是把静态资源、动态资源、Cookie、Session、HTTP 长连接、注解式路由、日志切分全串起来了。换句话说,它是一个能跑通完整请求生命周期的迷你 Web 容器,代码量可控,适合拿来理解「一个 HTTP 请求从字节流到 Controller 方法参数」中间到底发生了什么。适合谁看?写过 Java 但没碰过 NIO 的服务端新手,以及想自己撸一个轻量 HTTP 框架但不知道从哪下手的熟手。下面按「资源是什么 → 怎么跑起来 → 坑在哪 → 怎么改」的顺序拆。

2. 核心机制拆解:Selector 轮询、线程池与注解路由怎么串起来

2.1 Reactor 主从结构:Poller 线程和 RequestProcessor 线程池的分工

这个服务器的线程模型是典型的主从 Reactor 变体。POLLER_THREAD_COUNT控制监听读事件的线程数,每个 Poller 线程持有一个Selector,负责检测哪些SocketChannel有数据可读。检测到读事件后,不直接在 Poller 线程里做业务处理,而是把就绪的 Channel 封装成任务,丢给REQUEST_PROCESSOR_THREAD_COUNT指定的业务线程池。这样 Poller 线程只做 I/O 就绪检测,业务逻辑(解析 HTTP 报文、反射调用 Controller、渲染模板)在独立线程池里跑,互不阻塞。

为什么这么设计?如果 Poller 线程既做 I/O 检测又做业务处理,一个慢请求(比如数据库查询)就会拖住整个 Selector 轮询,其他连接的就绪事件得不到及时响应。拆开之后,Poller 保持轻量,业务线程池可以按 CPU 密集型或 I/O 密集型调参。常见做法是 Poller 线程数设为 CPU 核数,业务线程池设为 CPU 核数 × 2 起步,再根据压测调整。

配置项在server-config.properties里:

# 监听客户端读事件的线程数目 POLLER_THREAD_COUNT=2 # 处理具体请求的线程池的大小 REQUEST_PROCESSOR_THREAD_COUNT=4

这两个值不是拍脑袋定的。Poller 线程数超过 CPU 核数没有意义,因为 Selector 的select()是阻塞调用,多出来的线程只会增加上下文切换开销。业务线程池的大小取决于请求处理里有没有阻塞操作——如果 Controller 里只是内存计算和模板渲染,4 个线程在 4 核机器上够用;如果有文件 I/O 或远程调用,适当放大。

2.2 注解路由的扫描与参数绑定:从@RequestMapping到方法入参

注解式编程是这个实例比较有意思的部分。@Controller标记的类会被扫描,类上的@RequestMapping和方法上的@RequestMapping拼成完整路径。扫描方式是通过遍历 classpath 下的目录实现的,所以项目不支持打包成 jar——一旦打成 jar,目录遍历就失效了,必须以 class 文件形式发布。这是第一个需要记住的约束。

参数绑定支持三种来源:@RequestParam从查询字符串或表单体取值,@RequestHeader从请求头取值,@CookieValue从 Cookie 取值。方法参数可以是基本类型、String,也可以是自定义对象,支持级联属性。看这个例子:

// GET /page?pageSize=10&pageNum=1 HTTP/1.1 @RequestMapping("/page") String page(@RequestParam(value="pageSize", defaultValue="10") Integer pageSize, Integer pageNum) { // pageSize 从请求参数取,缺省值 10 // pageNum 没有注解,按参数名匹配 return "page.html"; }

@RequestParam的defaultValue在参数缺失时生效,value指定请求参数名,不写则用方法参数名。对于对象参数,框架会按属性名逐级匹配:

/** * POST /login HTTP/1.1 * user.name=admin&user.passwd=admin&user.data.val=ok */ @RequestMapping("/login", method = HttpMethod.POST) String login(User user) { // User 类:String name; String passwd; Data data; // Data 类:String val; // 请求参数 user.name 绑定到 user.getName() // user.data.val 绑定到 user.getData().getVal() return "login.html"; }

级联绑定的实现逻辑是:遇到user.data.val这样的参数名,先按.拆分,逐级查找对象的 setter 方法,中间对象如果为 null 则自动实例化。这个机制在表单提交场景下很省事,但要注意循环引用和类型转换失败的处理——如果val声明为 Integer 但请求传了非数字字符串,框架会抛异常还是设默认值,取决于具体实现,建议在 Controller 里做防御性判断。

2.3 Session 与 HTTP 长连接的生命周期管理

Session 和长连接都有过期时间,配置项如下:

# 连接过期时间,单位毫秒 CONNECTION_EXPIRY_TIME=30000 # 清理过期连接的周期,单位毫秒 CONNECTION_CLEANING_CYCLE=30000 # Session 过期时间,单位毫秒 SESSION_EXPIRY_TIME=30000 # 清理过期 Session 的周期,单位毫秒 SESSION_CLEANING_CYCLE=30000

长连接的实现依赖 HTTP/1.1 的Connection: keep-alive头。服务器在响应里带上Connection: keep-alive和Keep-Alive: timeout=30,客户端在超时前可以复用同一个 TCP 连接发多个请求。服务器侧维护一个连接集合,每次读写更新最后访问时间,后台定时任务按CONNECTION_CLEANING_CYCLE周期扫描,把超过CONNECTION_EXPIRY_TIME没活动的连接关掉。

Session 的管理类似:每个 Session 有lastAccessedTime,定时清理线程按SESSION_CLEANING_CYCLE周期检查,超过SESSION_EXPIRY_TIME的 Session 被移除。HttpSession.getLastAccessedTime()返回的是毫秒时间戳,在 Controller 里可以这样用:

@RequestMapping("/echo") public String echo(HttpRequest request, @RequestParam(value = "msg", defaultValue = "输入为空") String msg) { LocalDateTime localDateTime = LocalDateTime.ofEpochSecond( request.getSession().getLastAccessedTime() / 1000, 0, ZoneOffset.ofHours(8)); request.setAttribute("lastAccessedTime", localDateTime.format(formatter)); request.setAttribute("msg", msg); return "test.html"; }

这里把 Session 的最后访问时间格式化后塞进 request 域,模板里用${request.lastAccessedTime}取出来。注意getLastAccessedTime()返回的是毫秒,ofEpochSecond需要秒,所以除以 1000。时区用ZoneOffset.ofHours(8)硬编码,生产环境应该从配置读。

2.4 模板渲染与日志切分:正则模板和 server-n.log / access-n.log

模板引擎用正则实现,只支持从 request 和 session 域取属性,语法是${request.user.id}和${session.xxx}。渲染逻辑是:读取模板文件内容,用正则匹配${...}占位符,解析出作用域和属性路径,反射取值后替换。不支持条件判断和循环,所以复杂页面得靠 Controller 预先拼好数据。

日志用java.util.logging内置记录器,自定义了格式,分成两类:server-n.log记录服务器运行日志(启动、关闭、异常),access-n.log记录 HTTP 请求日志(方法、路径、状态码、耗时)。n是文件序号,按大小或时间滚动。日志存储路径由LOG_FILE_STORAGE_PATH配置,注意 Windows 下路径要转义反斜杠:

LOG_FILE_STORAGE_PATH=E:\\

Linux 下写成/var/log/webserver即可。这个日志切分方式比较原始,没有按天归档,文件多了需要自己写清理脚本。

3. 从零跑起来:目录结构、配置项与第一个 Controller

3.1 环境准备与目录约定

需要 JDK 8 及以上,没有第三方依赖,java.util.logging、java.nio、反射都是 JDK 自带的。把 release 里的 jar 包导入项目,然后在 classpath 下建一个webapp目录,作为静态资源根路径。目录结构大概是这样:

project-root/ ├── src/ │ └── com/test/EchoController.java ├── resources/ │ ├── server-config.properties │ └── webapp/ │ ├── test.html │ └── img/girl.jpg └── lib/ └── webserver.jar

webapp必须在 classpath 下,因为服务器启动时会用ClassLoader.getResource("webapp")定位静态资源根目录。如果放在项目根目录但没加入 classpath,运行时会报 404。

3.2 server-config.properties 逐项说明与调参建议

配置文件必须放在 classpath 下,名称固定为server-config.properties。逐项说明:

配置项含义默认建议值调参注意
PORT监听端口80非 root 用户改用 8080
LOG_FILE_STORAGE_PATH日志存储路径项目 logs 目录Windows 反斜杠要转义
CONNECTION_EXPIRY_TIME连接过期时间(ms)30000太短导致频繁重连,太长占资源
CONNECTION_CLEANING_CYCLE清理周期(ms)30000建议为过期时间的 1/2 到 1/3
SESSION_EXPIRY_TIMESession 过期时间(ms)30000按业务会话时长调整
SESSION_CLEANING_CYCLESession 清理周期(ms)30000同上
POLLER_THREAD_COUNTPoller 线程数CPU 核数超过核数无收益
REQUEST_PROCESSOR_THREAD_COUNT业务线程池大小CPU 核数 × 2有阻塞操作时放大

一个容易翻车的点:CONNECTION_EXPIRY_TIME和CONNECTION_CLEANING_CYCLE设成一样大时,连接可能在刚过期就被清理,也可能多活一个周期,行为不确定。建议清理周期小于过期时间,比如过期 30 秒、清理 10 秒一次。

3.3 写一个带 Session 和模板渲染的 Controller

启动入口是BootStrap.run(),在 main 方法里调用:

package com.test; import com.webserver.BootStrap; import com.webserver.annotation.Controller; import com.webserver.annotation.RequestMapping; import com.webserver.annotation.RequestParam; import com.webserver.http.HttpRequest; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; @Controller public class EchoController { private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @RequestMapping("/echo") public String echo(HttpRequest request, @RequestParam(value = "msg", defaultValue = "输入为空") String msg) { LocalDateTime localDateTime = LocalDateTime.ofEpochSecond( request.getSession().getLastAccessedTime() / 1000, 0, ZoneOffset.ofHours(8)); request.setAttribute("lastAccessedTime", localDateTime.format(formatter)); request.setAttribute("msg", msg); return "test.html"; } public static void main(String[] args) { BootStrap.run(); } }

对应的test.html放在webapp目录下:

<!DOCTYPE html> <html lang="en"> <head> <title>Test</title> </head> <body> <p>Echo: ${request.msg}</p> <p>Last Accessed Time: ${request.lastAccessedTime}</p> <p><img src="img/girl.jpg" alt="girl" width="320" height="480"/></p> </body> </html>

访问http://localhost:8080/echo?msg=hello,页面会显示Echo: hello和 Session 最后访问时间。img/girl.jpg是静态资源,由服务器的静态资源处理器直接返回,不走 Controller。注意模板里的${request.msg}对应request.setAttribute("msg", ...),${request.lastAccessedTime}同理。如果属性名写错,模板里会原样输出${request.msg}而不是报错,这是正则模板的一个坑。

3.4 验证静态资源、动态路由和长连接是否生效

启动后按顺序验证:

  1. 静态资源:浏览器访问http://localhost:8080/img/girl.jpg,能显示图片说明静态资源根路径配置正确。
  2. 动态路由:访问/echo?msg=test,页面显示Echo: test,说明注解扫描和参数绑定生效。
  3. Session:连续刷新/echo,Last Accessed Time应该更新,说明 Session 在复用。
  4. 长连接:用curl -v看响应头有没有Connection: keep-alive,有则长连接开启。
  5. 日志:检查LOG_FILE_STORAGE_PATH下是否生成了server-0.log和access-0.log,access 日志里应该有刚才的请求记录。

如果第 1 步就 404,先检查webapp目录是否在 classpath 下;如果第 2 步 404,检查 Controller 类是否被@Controller标记、是否在扫描路径下。

4. 避坑与排查:五个真实会遇到的翻车点

4.1 打包成 jar 后注解扫描失效

现象:本地跑得好好的,打成 jar 部署后所有@RequestMapping路径都 404,日志里没有任何 Controller 注册记录。

原因:扫描控制器是通过遍历 classpath 下的目录实现的,用的是FileAPI 而不是ClassLoader.getResources()配合 jar 协议解析。jar 包里的类文件不是以独立文件形式存在的,目录遍历拿不到。

解决:这个项目明确不支持打包,必须以 class 文件形式发布。部署时把classes目录和依赖 jar 一起放到 classpath 下,用java -cp "classes:lib/*" com.test.EchoController启动。如果非要打包,得自己改扫描逻辑,用JarFile遍历 entry。

4.2 模板属性名写错导致占位符原样输出

现象:页面显示${request.msg}而不是实际值,但 Controller 里确实setAttribute了。

原因:正则模板只替换能匹配到的占位符,匹配不到就原样保留。属性名大小写不一致、作用域写错(request写成req)、属性路径多了一层少了一层,都会导致匹配失败。

解决:在 Controller 里setAttribute之后打一行日志确认 key 名,模板里严格按${request.属性名}写。级联属性如${request.user.name}要求user对象有getName()方法,且user本身不为 null。

4.3 长连接过期时间与清理周期配置不当

现象:客户端偶尔收到连接被重置,或者连接池里堆积大量空闲连接不释放。

原因:CONNECTION_EXPIRY_TIME和CONNECTION_CLEANING_CYCLE设成一样大时,清理线程的执行时机和连接过期判断之间存在竞态。另外如果CONNECTION_EXPIRY_TIME小于客户端预期的 keep-alive 超时,客户端会复用已被服务器关闭的连接。

解决:清理周期设为过期时间的 1/3 到 1/2,比如过期 30 秒、清理 10 秒一次。响应头里的Keep-Alive: timeout=30要和CONNECTION_EXPIRY_TIME一致,避免客户端误判。

4.4 业务线程池过小导致请求排队超时

现象:压测时 QPS 上不去,access 日志里请求耗时很长,但 CPU 利用率不高。

原因:REQUEST_PROCESSOR_THREAD_COUNT设得太小,请求在队列里排队。如果 Controller 里有文件读写或远程调用,线程被阻塞,新请求进不来。

解决:先确认 Controller 里有没有阻塞操作。纯内存计算的话,线程池设为 CPU 核数 × 2 够用;有阻塞操作时按阻塞时间与计算时间的比例放大。同时观察 access 日志里的耗时分布,如果 P99 远大于 P50,说明排队严重。

4.5 Windows 下日志路径反斜杠未转义

现象:启动时报IllegalArgumentException: Malformed \uxxxx encoding,或者日志文件生成到了奇怪的位置。

原因:server-config.properties里LOG_FILE_STORAGE_PATH=E:\logs中的\l被 Properties 加载器当成 Unicode 转义序列解析。

解决:Windows 路径写成E:\\logs或E:/logs。Linux 下用/var/log/webserver没有这个问题。如果路径里有空格,不需要加引号,Properties 会保留空格。

5. 进阶改造:把注解路由和模板引擎换成可扩展实现

这个实例的注解路由和模板引擎都是「够用但不够灵活」的实现。注解扫描依赖目录遍历,模板只支持属性替换。如果想把它改造成能用于小工具项目的轻量框架,有两个方向可以动手。

第一个方向是替换注解扫描逻辑。把File遍历改成ClassLoader.getResources("")配合JarURLConnection解析,就能支持 jar 包部署。核心代码大概是这样:

Enumeration<URL> resources = classLoader.getResources(packagePath); while (resources.hasMoreElements()) { URL url = resources.nextElement(); if ("file".equals(url.getProtocol())) { // 目录扫描,同现有逻辑 } else if ("jar".equals(url.getProtocol())) { JarURLConnection conn = (JarURLConnection) url.openConnection(); JarFile jarFile = conn.getJarFile(); Enumeration<JarEntry> entries = jarFile.entries(); while (entries.hasMoreElements()) { JarEntry entry = entries.nextElement(); if (entry.getName().endsWith(".class")) { // 加载类并检查 @Controller } } } }

这段代码的关键是区分file和jar两种协议,分别用目录遍历和 JarEntry 遍历。加载类时用Class.forName(className, false, classLoader),第二个参数false表示不初始化,避免静态代码块提前执行。

第二个方向是给模板引擎加条件判断。现有正则只匹配${...},可以扩展成支持${if request.user != null}...${end}的块级语法。实现思路是先用正则匹配块级标签,递归渲染内部内容,再把结果替换回原位置。这个改动量不大,但能让模板处理简单的登录态判断。

验证改造是否成功,我一般会跑三个用例:一个纯静态资源请求、一个带查询参数的动态请求、一个带 Session 的登录后请求。三个都通过,再压测 100 并发看线程池和连接池有没有泄漏。从那以后我每次改这类网络框架,都强制走一遍「静态→动态→Session→长连接→日志」的检查清单,少一步都可能在上线后收到连接重置的报警。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询