
简介这是一份面向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_COUNT2 # 处理具体请求的线程池的大小 REQUEST_PROCESSOR_THREAD_COUNT4这两个值不是拍脑袋定的。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?pageSize10pageNum1 HTTP/1.1 RequestMapping(/page) String page(RequestParam(valuepageSize, defaultValue10) Integer pageSize, Integer pageNum) { // pageSize 从请求参数取缺省值 10 // pageNum 没有注解按参数名匹配 return page.html; }RequestParam的defaultValue在参数缺失时生效value指定请求参数名不写则用方法参数名。对于对象参数框架会按属性名逐级匹配/** * POST /login HTTP/1.1 * user.nameadminuser.passwdadminuser.data.valok */ 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_TIME30000 # 清理过期连接的周期单位毫秒 CONNECTION_CLEANING_CYCLE30000 # Session 过期时间单位毫秒 SESSION_EXPIRY_TIME30000 # 清理过期 Session 的周期单位毫秒 SESSION_CLEANING_CYCLE30000长连接的实现依赖 HTTP/1.1 的Connection: keep-alive头。服务器在响应里带上Connection: keep-alive和Keep-Alive: timeout30客户端在超时前可以复用同一个 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_PATHE:\\Linux 下写成/var/log/webserver即可。这个日志切分方式比较原始没有按天归档文件多了需要自己写清理脚本。3. 从零跑起来目录结构、配置项与第一个 Controller3.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.jarwebapp必须在 classpath 下因为服务器启动时会用ClassLoader.getResource(webapp)定位静态资源根目录。如果放在项目根目录但没加入 classpath运行时会报 404。3.2 server-config.properties 逐项说明与调参建议配置文件必须放在 classpath 下名称固定为server-config.properties。逐项说明配置项含义默认建议值调参注意PORT监听端口80非 root 用户改用 8080LOG_FILE_STORAGE_PATH日志存储路径项目 logs 目录Windows 反斜杠要转义CONNECTION_EXPIRY_TIME连接过期时间(ms)30000太短导致频繁重连太长占资源CONNECTION_CLEANING_CYCLE清理周期(ms)30000建议为过期时间的 1/2 到 1/3SESSION_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 langen head titleTest/title /head body pEcho: ${request.msg}/p pLast Accessed Time: ${request.lastAccessedTime}/p pimg srcimg/girl.jpg altgirl width320 height480//p /body /html访问http://localhost:8080/echo?msghello页面会显示Echo: hello和 Session 最后访问时间。img/girl.jpg是静态资源由服务器的静态资源处理器直接返回不走 Controller。注意模板里的${request.msg}对应request.setAttribute(msg, ...)${request.lastAccessedTime}同理。如果属性名写错模板里会原样输出${request.msg}而不是报错这是正则模板的一个坑。3.4 验证静态资源、动态路由和长连接是否生效启动后按顺序验证静态资源浏览器访问http://localhost:8080/img/girl.jpg能显示图片说明静态资源根路径配置正确。动态路由访问/echo?msgtest页面显示Echo: test说明注解扫描和参数绑定生效。Session连续刷新/echoLast Accessed Time应该更新说明 Session 在复用。长连接用curl -v看响应头有没有Connection: keep-alive有则长连接开启。日志检查LOG_FILE_STORAGE_PATH下是否生成了server-0.log和access-0.logaccess 日志里应该有刚才的请求记录。如果第 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: timeout30要和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_PATHE:\logs中的\l被 Properties 加载器当成 Unicode 转义序列解析。解决Windows 路径写成E:\\logs或E:/logs。Linux 下用/var/log/webserver没有这个问题。如果路径里有空格不需要加引号Properties 会保留空格。5. 进阶改造把注解路由和模板引擎换成可扩展实现这个实例的注解路由和模板引擎都是「够用但不够灵活」的实现。注解扫描依赖目录遍历模板只支持属性替换。如果想把它改造成能用于小工具项目的轻量框架有两个方向可以动手。第一个方向是替换注解扫描逻辑。把File遍历改成ClassLoader.getResources()配合JarURLConnection解析就能支持 jar 包部署。核心代码大概是这样EnumerationURL 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(); EnumerationJarEntry 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→长连接→日志」的检查清单少一步都可能在上线后收到连接重置的报警。希望帮到你。本文还有配套的精品资源点击获取