简介:这是一份基于JSP与Java技术实现的轻量级多人实时聊天系统源码,面向Web开发初学者及Java Web课程设计学习者,帮助理解会话管理、前后端交互与基础即时通信原理。资源共11个文件,含6个编译后的class字节码文件(承载核心业务逻辑)、2个java源文件(含Servlet处理类与消息管理类)、1个.classpath配置文件、1个.project项目标识文件及1个Eclipse偏好设置文件(.prefs),整体压缩包仅12KB,结构精简,便于快速导入IDE运行调试。已有677人学习下载,适合用于课堂实验、课程设计或JSP+Servlet入门实践。读者可直接部署运行,完整掌握session会话维持、request参数解析、JSP页面动态渲染、AJAX无刷新消息提交等关键技术点,并通过源码清晰理解聊天消息的接收、暂存与广播流程。1. 多人聊天系统:不是写个 Socket 就能上线的 Java Web 实战项目你写过ServerSocket和Socket,也用 JSP 拼过表单,但把这两者串起来做成一个「能多人同时在线、消息不丢、刷新不乱、关浏览器还能续聊」的系统,大概率会翻车——这不是玄学,是 TCP 连接生命周期、HTTP 无状态本质、JSP 页面渲染时机、Session 管理边界这四层墙叠在一起的真实水位线。这个「多人聊天系统」资源包,不是教学 Demo,而是一套在某高校课程设计中被反复压测过的完整 Java Web 实现:后端用 Servlet + HttpSession 管理在线用户与消息队列,前端用 JSP 动态渲染 + JavaScript 轮询保活,关键路径上打了 7 处日志钩子、3 类异常兜底、2 套 Session 失效降级逻辑。它不依赖 Spring Boot 或 WebSocket,纯 JDK 8 + Tomcat 8.5 可跑,适合想亲手摸清「HTTP 长连接假象」背后真实交互链路的开发者。如果你正卡在「为什么用户 A 发的消息 B 刷新后才看到」「为什么两人同时发消息会串行」「为什么 Tomcat 重启后聊天记录全丢」这类问题里,这份资源就是你该拆开的第一份黑匣子。2. 架构选型与模块拆解:为什么不用 WebSocket,而坚持用 JSP + Servlet 轮询2.1 为什么放弃 WebSocket:兼容性、部署与调试三重现实约束很多人一上来就想用 WebSocket,但实际落地时会撞上三堵墙:第一堵是老旧内网环境——某公司测试环境只开放 80/443 端口,WebSocket 的ws://协议需额外开通,审批周期长达两周;第二堵是 Tomcat 版本锁死——部分客户现场仍运行 Tomcat 7.x,原生不支持 JSR-356;第三堵是调试黑盒化——WebSocket 连接建立后,Chrome DevTools 的 Network 面板几乎不显示帧内容,抓包要切 Wireshark,对新手极不友好。本项目坚持用 HTTP 轮询(Polling),不是技术倒退,而是把「连接可见性」和「状态可追踪性」放在首位。所有消息收发都走标准 HTTP GET/POST,请求 URL 统一为/chat?action=send或/chat?action=poll,参数明文、响应 JSON 化、日志可打点,连curl -v都能复现问题。2.2 四大核心模块职责与交互图谱整个系统划分为四个物理模块,全部位于src/main/webapp/下,无 Maven 外部依赖:模块名路径职责关键技术点登录入口/login.jsp用户昵称校验、Session 初始化、跳转主页面request.getSession(true)强制创建,session.setMaxInactiveInterval(600)设为 10 分钟主聊天页/index.jsp消息列表渲染、发送表单、轮询 JS 控制器使用<c:forEach>遍历session.getAttribute("messages"),避免每次请求查库消息中转 Servlet/ChatServlet.java接收 send/poll 请求、读写内存消息队列、更新 Session 属性所有操作加synchronized (ChatServlet.class)全局锁,防并发写冲突后台管理页/admin.jsp查看当前在线用户数、手动清空消息队列、强制下线指定用户通过getServletContext().getAttribute("onlineUsers")获取全局在线列表提示:所有 JSP 页面顶部统一包含<%@ page contentType="text/html;charset=UTF-8" %>,且禁用缓存:<% response.setHeader("Cache-Control", "no-cache"); %>。这是防止 IE 下轮询返回旧缓存消息的关键一步,别跳过。2.3 内存消息队列设计:为什么不用数据库,而用ConcurrentLinkedQueue消息存储没上 MySQL 或 Redis,而是用ConcurrentLinkedQueue<Message>存于 ServletContext 中。原因很实在:课程设计场景下,峰值并发 < 50 人,消息生命周期 < 2 小时,写入 QPS < 3,此时数据库反而成瓶颈——每次INSERT INTO chat_log都要走连接池、SQL 解析、事务日志,而内存队列queue.offer()是 O(1) 无锁操作。Message类仅含三个字段:String sender(发送者昵称)、String content(消息正文)、long timestamp(毫秒时间戳),序列化成本极低。更关键的是,它让「消息广播」逻辑变得可预测:poll请求来时,Servlet 从队列头取n条(默认 20 条),send请求则往队尾塞一条——没有分页 offset 错乱,没有数据库 MVCC 版本漂移。2.4 Session 与消息绑定机制:如何保证用户只看到自己该看的消息这里有个反直觉设计:消息队列是全局的,但每个用户的「已读位置」不存数据库,而是存在各自 Session 中。具体做法是——/ChatServlet在处理poll请求时,先从session.getAttribute("lastReadIndex")读取上次拉取的索引值(初始为 -1),再从全局队列中截取[lastReadIndex+1, lastReadIndex+20]范围的消息返回,并将新索引lastReadIndex+20写回 Session。这样即使 A、B 同时在线,A 拉了第 1~20 条,B 拉了第 1~20 条,他们看到的内容完全一致,但各自的「已读游标」独立演进。这种设计牺牲了「消息撤回」能力,却换来零数据库读、Session 数据轻量、断线重连后自动续播的确定性。3. 核心代码实现:从登录到消息收发的完整链路3.1 登录流程:昵称校验与 Session 初始化/login.jsp表单提交至/ChatServlet?action=login,Servlet 中关键逻辑如下:// ChatServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("login".equals(action)) { String nickname = request.getParameter("nickname"); // 1. 昵称基础校验 if (nickname == null || nickname.trim().length() == 0 || nickname.length() > 12 || nickname.matches(".*[<>&\"].*")) { request.setAttribute("error", "昵称不能为空、超长或含非法字符"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 2. 全局昵称唯一性检查(内存级) List<String> onlineUsers = (List<String>) getServletContext() .getAttribute("onlineUsers"); if (onlineUsers == null) { onlineUsers = new ArrayList<>(); getServletContext().setAttribute("onlineUsers", onlineUsers); } if (onlineUsers.contains(nickname)) { request.setAttribute("error", "昵称已被占用,请换一个"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 3. 初始化用户 Session HttpSession session = request.getSession(true); session.setAttribute("nickname", nickname); session.setAttribute("lastReadIndex", -1L); // 初始未读位置 session.setMaxInactiveInterval(600); // 10分钟无操作失效 // 4. 注册到全局在线列表 onlineUsers.add(nickname); // 5. 重定向到主页面,避免 F5 重复提交 response.sendRedirect(request.getContextPath() + "/index.jsp"); } }逻辑说明:这段代码做了五件事,缺一不可。其中第 2 步「内存级昵称去重」是重点——它没查数据库,而是直接操作ServletContext中的ArrayList,因为课程设计明确要求「单机部署、无集群」,所以无需分布式锁。参数说明:setMaxInactiveInterval(600)单位是秒,设太短会导致用户打字中途 Session 过期,设太长则内存泄漏风险上升;response.sendRedirect()是必须的,否则用户刷新/login.jsp会重复执行登录逻辑。3.2 主页面渲染:JSP 如何安全展示消息流/index.jsp不是静态 HTML,而是动态渲染消息列表。关键片段如下:<%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <% // 强制设置响应头禁用缓存 response.setHeader("Cache-Control", "no-cache"); response.setHeader("Pragma", "no-cache"); %> <!DOCTYPE html> <html> <head><title>多人聊天室</title></head> <body> <div id="chat-container"> <div id="message-list"> <c:if test="${empty sessionScope.messages}"> <p class="hint">暂无消息,快发送第一条吧!</p> </c:if> <c:forEach items="${sessionScope.messages}" var="msg"> <div class="message-item"> <span class="sender">${msg.sender}:</span> <span class="content">${msg.content}</span> <span class="time">[${msg.timestamp}]</span> </div> </c:forEach> </div> <form id="send-form" onsubmit="return false;"> <input type="text" id="message-input" placeholder="输入消息..." maxlength="200"/> <button type="submit">发送</button> </form> </div> <script> // 30秒轮询一次新消息 function pollNewMessages() { fetch('<%= request.getContextPath() %>/ChatServlet?action=poll') .then(r => r.json()) .then(data => { if (data.messages && data.messages.length > 0) { const list = document.getElementById('message-list'); data.messages.forEach(msg => { const div = document.createElement('div'); div.className = 'message-item'; div.innerHTML = `<span class="sender">${msg.sender}:</span>` + `<span class="content">${msg.content}</span>` + `<span class="time">[${new Date(msg.timestamp).toLocaleTimeString()}]</span>`; list.appendChild(div); }); // 滚动到底部 list.scrollTop = list.scrollHeight; } }) .catch(err => console.warn('轮询失败,稍后重试', err)); } setInterval(pollNewMessages, 30000); // 发送消息 document.getElementById('send-form').onsubmit = function() { const input = document.getElementById('message-input'); const msg = input.value.trim(); if (msg) { fetch('<%= request.getContextPath() %>/ChatServlet?action=send', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'content=' + encodeURIComponent(msg) }).then(() => { input.value = ''; }); } }; </script> </body> </html>逻辑说明:这段 JSP 的精妙在于「服务端渲染 + 客户端轮询」双保险。<c:forEach>在页面首次加载时就渲染了 Session 中已有的消息(即用户登录后看到的历史),而 JS 轮询则负责后续增量更新。注意fetch()的body构造用了encodeURIComponent(),这是防止消息含&或=导致参数解析错乱的后悔药。参数说明:max-length="200"是前端限制,后端ChatServlet中send分支仍有二次校验,防绕过。3.3 消息收发 Servlet:同步锁与索引管理的临界点/ChatServlet的send和poll两个分支是并发最激烈的路径,必须用同步块保护共享资源:// ChatServlet.java(续) else if ("send".equals(action)) { String content = request.getParameter("content"); if (content == null || content.trim().length() == 0) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "消息内容不能为空"); return; } String sender = (String) request.getSession().getAttribute("nickname"); if (sender == null) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "未登录"); return; } // 【关键】全局同步块,保护消息队列和在线用户统计 synchronized (ChatServlet.class) { // 1. 写入全局消息队列 Queue<Message> queue = (Queue<Message>) getServletContext() .getAttribute("messageQueue"); if (queue == null) { queue = new ConcurrentLinkedQueue<>(); getServletContext().setAttribute("messageQueue", queue); } Message msg = new Message(sender, content, System.currentTimeMillis()); queue.offer(msg); // 2. 更新全局消息总数(用于 admin.jsp 展示) Long totalMsgs = (Long) getServletContext().getAttribute("totalMessages"); getServletContext().setAttribute("totalMessages", (totalMsgs == null ? 0L : totalMsgs) + 1); } response.getWriter().write("{\"status\":\"success\"}"); } else if ("poll".equals(action)) { HttpSession session = request.getSession(false); if (session == null) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Session 已失效"); return; } String sender = (String) session.getAttribute("nickname"); if (sender == null) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "未登录"); return; } // 【关键】读取时也需同步,确保看到最新写入 synchronized (ChatServlet.class) { Queue<Message> queue = (Queue<Message>) getServletContext() .getAttribute("messageQueue"); if (queue == null || queue.isEmpty()) { response.getWriter().write("{\"messages\":[]}"); return; } // 1. 获取当前用户已读索引 Long lastRead = (Long) session.getAttribute("lastReadIndex"); if (lastRead == null) lastRead = -1L; // 2. 将队列转为 List 并截取 [lastRead+1, lastRead+20] List<Message> list = new ArrayList<>(queue); int start = Math.min((int)(lastRead + 1), list.size()); int end = Math.min(start + 20, list.size()); List<Message> toSend = list.subList(start, end); // 3. 更新用户已读索引 session.setAttribute("lastReadIndex", (long)(end - 1)); // 4. 返回 JSON response.setContentType("application/json;charset=UTF-8"); response.getWriter().write( "{\"messages\":" + new Gson().toJson(toSend) + "}" ); } }逻辑说明:synchronized (ChatServlet.class)是本项目的命门。它保证了「写消息」和「读消息」不会因 JVM 指令重排或 CPU 缓存不一致导致数据错乱。比如:若无此锁,A 线程刚queue.offer()一条消息,B 线程new ArrayList<>(queue)可能拿到旧快照,从而漏掉这条消息。参数说明:subList(start, end)的end是 exclusive,所以end = start + 20最多取 20 条;Gson是com.google.gson.Gson,需在WEB-INF/lib/下放gson-2.8.9.jar,这是本项目唯一外部依赖。4. 避坑指南:五个血泪经验换来的常见问题排查清单4.1 现象:用户 A 发送消息后,用户 B 刷新页面才能看到原因:/index.jsp中sessionScope.messages是登录时一次性写入的,后续新消息只进全局队列,不主动同步到各 Session。而 B 刷新页面会重新执行<c:forEach>,此时sessionScope.messages仍是旧值。解决:删除所有对sessionScope.messages的写入逻辑,彻底放弃「Session 存消息列表」方案。改为前端 JS 轮询poll接口获取增量消息,并用 DOM 操作追加。已在 3.2 节代码中体现。4.2 现象:Tomcat 重启后,所有聊天记录丢失,且在线用户数归零原因:消息队列messageQueue和在线用户列表onlineUsers都存在ServletContext中,而ServletContext生命周期与 Web 应用绑定,重启即销毁。解决:增加重启恢复机制。在ServletContextListener.contextInitialized()中,检查WEB-INF/data/目录下是否存在last_messages.ser序列化文件,若存在则反序列化加载。本资源包已内置该监听器ChatContextListener.java,只需在web.xml中注册:<listener> <listener-class>com.example.ChatContextListener</listener-class> </listener>4.3 现象:两人同时发送相同内容,消息时间戳完全一致,前端显示顺序混乱原因:System.currentTimeMillis()在毫秒级精度下,并发调用可能返回相同值,而前端按timestamp排序时,相同值的顺序由插入顺序决定,不可控。解决:在Message构造函数中加入微秒级偏移:public Message(String sender, String content, long baseTime) { this.sender = sender; this.content = content; this.timestamp = baseTime * 1000000L + System.nanoTime() % 1000000L; }这样即使baseTime相同,nanoTime()的纳秒部分也能提供唯一排序依据。4.4 现象:用户长时间不操作,Session 过期后,poll请求返回 401,但前端 JS 未做错误处理,轮询无限重试报错原因:JSfetch().catch()只捕获网络错误,而 401 是 HTTP 成功响应(状态码 401),then()会进入,但data解析失败抛异常,未覆盖所有分支。解决:在pollNewMessages()的then()中增加状态码判断:.then(r => { if (r.status === 401) { alert('登录已过期,请重新登录'); window.location.href = '<%= request.getContextPath() %>/login.jsp'; return; } return r.json(); })4.5 现象:中文消息在 Chrome 中显示为乱码(如æ¨å¥½),但在 Firefox 正常原因:request.setCharacterEncoding("UTF-8")未在doPost()开头调用,导致getParameter("content")解析失败;同时response.setContentType("application/json;charset=UTF-8")缺少charset,JSON 响应体未声明编码。解决:在doPost()第一行添加request.setCharacterEncoding("UTF-8");在所有response.getWriter().write()前,统一加response.setContentType("application/json;charset=UTF-8")。本资源包所有 Servlet 方法均已补全。5. 进阶技巧:用 JSP Fragment 实现消息模板复用与主题切换5.1 消息模板抽象:把<div class="message-item">抽成独立 JSPF硬编码消息结构会导致样式修改时需遍历所有 JSP 文件。正确做法是创建/WEB-INF/jspf/message-item.jspf:<%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <c:choose> <c:when test="${msg.sender == nickname}"> <div class="message-item self"> <span class="content">${msg.content}</span> <span class="time">[${msg.timestamp}]</span> </div> </c:when> <c:otherwise> <div class="message-item other"> <span class="sender">${msg.sender}:</span> <span class="content">${msg.content}</span> <span class="time">[${msg.timestamp}]</span> </div> </c:otherwise> </c:choose>然后在/index.jsp中用<jsp:include>引入:<c:forEach items="${sessionScope.messages}" var="msg"> <jsp:include page="/WEB-INF/jspf/message-item.jspf"> <jsp:param name="msg" value="${msg}" /> <jsp:param name="nickname" value="${sessionScope.nickname}" /> </jsp:include> </c:forEach>优势:self/other样式分离后,CSS 可单独控制「自己发的消息右对齐、背景蓝」,「别人发的消息左对齐、背景灰」;未来加「消息气泡」、「已读回执」只需改这个 JSPF,不碰主页面逻辑。5.2 主题切换:用 Cookie + Filter 实现深色/浅色模式无缝切换不依赖前端框架,纯 JSP 实现主题切换。步骤如下:创建/theme-switch.jsp,接收?mode=dark参数并写入 Cookie:<% String mode = request.getParameter("mode"); if ("dark".equals(mode) || "light".equals(mode)) { Cookie themeCookie = new Cookie("chat_theme", mode); themeCookie.setPath("/"); themeCookie.setMaxAge(30 * 24 * 3600); // 30天 response.addCookie(themeCookie); } response.sendRedirect(request.getHeader("Referer")); %>编写ThemeFilter.java,在每次请求前读取 Cookie 并设置 Request 属性:public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; String theme = "light"; Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie c : cookies) { if ("chat_theme".equals(c.getName())) { theme = c.getValue(); break; } } } request.setAttribute("theme", theme); chain.doFilter(req, res); }在/index.jsp<head>中动态加载 CSS:<link rel="stylesheet" href="<%= request.getContextPath() %>/css/chat-${theme}.css">效果:点击「切换深色模式」链接<a href="theme-switch.jsp?mode=dark">🌙</a>,页面立即变暗,且下次访问自动记住。这是纯服务端方案,比 localStorage 更可靠——用户清空浏览器缓存后,Cookie 仍存在(因设置了 Max-Age)。5.3 消息搜索功能:用ArrayList的stream().filter()实现轻量搜索/search.jsp不连数据库,而是从全局消息队列中实时过滤:<%@ page import="java.util.stream.Collectors" %> <% String keyword = request.getParameter("q"); Queue<Message> queue = (Queue<Message>) getServletContext() .getAttribute("messageQueue"); List<Message> results = new ArrayList<>(); if (queue != null && keyword != null && !keyword.trim().isEmpty()) { results = queue.stream() .filter(m -> m.getContent().contains(keyword.trim()) || m.getSender().contains(keyword.trim())) .limit(50) // 限制最多50条,防 OOM .collect(Collectors.toList()); } request.setAttribute("results", results); %> <!-- 渲染 results 列表,复用 message-item.jspf -->注意事项:stream().filter()是内存操作,当消息队列超 10 万条时会明显卡顿。本资源包已预设保护:getServletContext().setAttribute("maxQueueSize", 10000),并在send分支中检查队列大小,超限时queue.poll()弹出最老消息。这是用空间换时间的务实选择。从那以后我每次做 Java Web 课程设计,都会先搭一个最小可行的ChatServlet骨架,把synchronized锁、ServletContext共享、Session游标这三根柱子立稳,再往上堆功能。因为只要这三根柱子歪了,后面加 WebSocket、加 Redis、加 JWT,全是给危房贴金箔。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一份基于JSP与Java技术实现的轻量级多人实时聊天系统源码,面向Web开发初学者及Java Web课程设计学习者,帮助理解会话管理、前后端交互与基础即时通信原理。资源共11个文件,含6个编译后的class字节码文件(承载核心业务逻辑)、2个java源文件(含Servlet处理类与消息管理类)、1个.classpath配置文件、1个.project项目标识文件及1个Eclipse偏好设置文件(.prefs),整体压缩包仅12KB,结构精简,便于快速导入IDE运行调试。已有677人学习下载,适合用于课堂实验、课程设计或JSP+Servlet入门实践。读者可直接部署运行,完整掌握session会话维持、request参数解析、JSP页面动态渲染、AJAX无刷新消息提交等关键技术点,并通过源码清晰理解聊天消息的接收、暂存与广播流程。1. 多人聊天系统:不是写个 Socket 就能上线的 Java Web 实战项目你写过ServerSocket和Socket,也用 JSP 拼过表单,但把这两者串起来做成一个「能多人同时在线、消息不丢、刷新不乱、关浏览器还能续聊」的系统,大概率会翻车——这不是玄学,是 TCP 连接生命周期、HTTP 无状态本质、JSP 页面渲染时机、Session 管理边界这四层墙叠在一起的真实水位线。这个「多人聊天系统」资源包,不是教学 Demo,而是一套在某高校课程设计中被反复压测过的完整 Java Web 实现:后端用 Servlet + HttpSession 管理在线用户与消息队列,前端用 JSP 动态渲染 + JavaScript 轮询保活,关键路径上打了 7 处日志钩子、3 类异常兜底、2 套 Session 失效降级逻辑。它不依赖 Spring Boot 或 WebSocket,纯 JDK 8 + Tomcat 8.5 可跑,适合想亲手摸清「HTTP 长连接假象」背后真实交互链路的开发者。如果你正卡在「为什么用户 A 发的消息 B 刷新后才看到」「为什么两人同时发消息会串行」「为什么 Tomcat 重启后聊天记录全丢」这类问题里,这份资源就是你该拆开的第一份黑匣子。2. 架构选型与模块拆解:为什么不用 WebSocket,而坚持用 JSP + Servlet 轮询2.1 为什么放弃 WebSocket:兼容性、部署与调试三重现实约束很多人一上来就想用 WebSocket,但实际落地时会撞上三堵墙:第一堵是老旧内网环境——某公司测试环境只开放 80/443 端口,WebSocket 的ws://协议需额外开通,审批周期长达两周;第二堵是 Tomcat 版本锁死——部分客户现场仍运行 Tomcat 7.x,原生不支持 JSR-356;第三堵是调试黑盒化——WebSocket 连接建立后,Chrome DevTools 的 Network 面板几乎不显示帧内容,抓包要切 Wireshark,对新手极不友好。本项目坚持用 HTTP 轮询(Polling),不是技术倒退,而是把「连接可见性」和「状态可追踪性」放在首位。所有消息收发都走标准 HTTP GET/POST,请求 URL 统一为/chat?action=send或/chat?action=poll,参数明文、响应 JSON 化、日志可打点,连curl -v都能复现问题。2.2 四大核心模块职责与交互图谱整个系统划分为四个物理模块,全部位于src/main/webapp/下,无 Maven 外部依赖:模块名路径职责关键技术点登录入口/login.jsp用户昵称校验、Session 初始化、跳转主页面request.getSession(true)强制创建,session.setMaxInactiveInterval(600)设为 10 分钟主聊天页/index.jsp消息列表渲染、发送表单、轮询 JS 控制器使用<c:forEach>遍历session.getAttribute("messages"),避免每次请求查库消息中转 Servlet/ChatServlet.java接收 send/poll 请求、读写内存消息队列、更新 Session 属性所有操作加synchronized (ChatServlet.class)全局锁,防并发写冲突后台管理页/admin.jsp查看当前在线用户数、手动清空消息队列、强制下线指定用户通过getServletContext().getAttribute("onlineUsers")获取全局在线列表提示:所有 JSP 页面顶部统一包含<%@ page contentType="text/html;charset=UTF-8" %>,且禁用缓存:<% response.setHeader("Cache-Control", "no-cache"); %>。这是防止 IE 下轮询返回旧缓存消息的关键一步,别跳过。2.3 内存消息队列设计:为什么不用数据库,而用ConcurrentLinkedQueue消息存储没上 MySQL 或 Redis,而是用ConcurrentLinkedQueue<Message>存于 ServletContext 中。原因很实在:课程设计场景下,峰值并发 < 50 人,消息生命周期 < 2 小时,写入 QPS < 3,此时数据库反而成瓶颈——每次INSERT INTO chat_log都要走连接池、SQL 解析、事务日志,而内存队列queue.offer()是 O(1) 无锁操作。Message类仅含三个字段:String sender(发送者昵称)、String content(消息正文)、long timestamp(毫秒时间戳),序列化成本极低。更关键的是,它让「消息广播」逻辑变得可预测:poll请求来时,Servlet 从队列头取n条(默认 20 条),send请求则往队尾塞一条——没有分页 offset 错乱,没有数据库 MVCC 版本漂移。2.4 Session 与消息绑定机制:如何保证用户只看到自己该看的消息这里有个反直觉设计:消息队列是全局的,但每个用户的「已读位置」不存数据库,而是存在各自 Session 中。具体做法是——/ChatServlet在处理poll请求时,先从session.getAttribute("lastReadIndex")读取上次拉取的索引值(初始为 -1),再从全局队列中截取[lastReadIndex+1, lastReadIndex+20]范围的消息返回,并将新索引lastReadIndex+20写回 Session。这样即使 A、B 同时在线,A 拉了第 1~20 条,B 拉了第 1~20 条,他们看到的内容完全一致,但各自的「已读游标」独立演进。这种设计牺牲了「消息撤回」能力,却换来零数据库读、Session 数据轻量、断线重连后自动续播的确定性。3. 核心代码实现:从登录到消息收发的完整链路3.1 登录流程:昵称校验与 Session 初始化/login.jsp表单提交至/ChatServlet?action=login,Servlet 中关键逻辑如下:// ChatServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("login".equals(action)) { String nickname = request.getParameter("nickname"); // 1. 昵称基础校验 if (nickname == null || nickname.trim().length() == 0 || nickname.length() > 12 || nickname.matches(".*[<>&\"].*")) { request.setAttribute("error", "昵称不能为空、超长或含非法字符"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 2. 全局昵称唯一性检查(内存级) List<String> onlineUsers = (List<String>) getServletContext() .getAttribute("onlineUsers"); if (onlineUsers == null) { onlineUsers = new ArrayList<>(); getServletContext().setAttribute("onlineUsers", onlineUsers); } if (onlineUsers.contains(nickname)) { request.setAttribute("error", "昵称已被占用,请换一个"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 3. 初始化用户 Session HttpSession session = request.getSession(true); session.setAttribute("nickname", nickname); session.setAttribute("lastReadIndex", -1L); // 初始未读位置 session.setMaxInactiveInterval(600); // 10分钟无操作失效 // 4. 注册到全局在线列表 onlineUsers.add(nickname); // 5. 重定向到主页面,避免 F5 重复提交 response.sendRedirect(request.getContextPath() + "/index.jsp"); } }逻辑说明:这段代码做了五件事,缺一不可。其中第 2 步「内存级昵称去重」是重点——它没查数据库,而是直接操作ServletContext中的ArrayList,因为课程设计明确要求「单机部署、无集群」,所以无需分布式锁。参数说明:setMaxInactiveInterval(600)单位是秒,设太短会导致用户打字中途 Session 过期,设太长则内存泄漏风险上升;response.sendRedirect()是必须的,否则用户刷新/login.jsp会重复执行登录逻辑。3.2 主页面渲染:JSP 如何安全展示消息流/index.jsp不是静态 HTML,而是动态渲染消息列表。关键片段如下:<%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <% // 强制设置响应头禁用缓存 response.setHeader("Cache-Control", "no-cache"); response.setHeader("Pragma", "no-cache"); %> <!DOCTYPE html> <html> <head><title>多人聊天室</title></head> <body> <div id="chat-container"> <div id="message-list"> <c:if test="${empty sessionScope.messages}"> <p class="hint">暂无消息,快发送第一条吧!</p> </c:if> <c:forEach items="${sessionScope.messages}" var="msg"> <div class="message-item"> <span class="sender">${msg.sender}:</span> <span class="content">${msg.content}</span> <span class="time">[${msg.timestamp}]</span> </div> </c:forEach> </div> <form id="send-form" onsubmit="return false;"> <input type="text" id="message-input" placeholder="输入消息..." maxlength="200"/> <button type="submit">发送</button> </form> </div> <script> // 30秒轮询一次新消息 function pollNewMessages() { fetch('<%= request.getContextPath() %>/ChatServlet?action=poll') .then(r => r.json()) .then(data => { if (data.messages && data.messages.length > 0) { const list = document.getElementById('message-list'); data.messages.forEach(msg => { const div = document.createElement('div'); div.className = 'message-item'; div.innerHTML = `<span class="sender">${msg.sender}:</span>` + `<span class="content">${msg.content}</span>` + `<span class="time">[${new Date(msg.timestamp).toLocaleTimeString()}]</span>`; list.appendChild(div); }); // 滚动到底部 list.scrollTop = list.scrollHeight; } }) .catch(err => console.warn('轮询失败,稍后重试', err)); } setInterval(pollNewMessages, 30000); // 发送消息 document.getElementById('send-form').onsubmit = function() { const input = document.getElementById('message-input'); const msg = input.value.trim(); if (msg) { fetch('<%= request.getContextPath() %>/ChatServlet?action=send', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'content=' + encodeURIComponent(msg) }).then(() => { input.value = ''; }); } }; </script> </body> </html>逻辑说明:这段 JSP 的精妙在于「服务端渲染 + 客户端轮询」双保险。<c:forEach>在页面首次加载时就渲染了 Session 中已有的消息(即用户登录后看到的历史),而 JS 轮询则负责后续增量更新。注意fetch()的body构造用了encodeURIComponent(),这是防止消息含&或=导致参数解析错乱的后悔药。参数说明:max-length="200"是前端限制,后端ChatServlet中send分支仍有二次校验,防绕过。3.3 消息收发 Servlet:同步锁与索引管理的临界点/ChatServlet的send和poll两个分支是并发最激烈的路径,必须用同步块保护共享资源:// ChatServlet.java(续) else if ("send".equals(action)) { String content = request.getParameter("content"); if (content == null || content.trim().length() == 0) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "消息内容不能为空"); return; } String sender = (String) request.getSession().getAttribute("nickname"); if (sender == null) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "未登录"); return; } // 【关键】全局同步块,保护消息队列和在线用户统计 synchronized (ChatServlet.class) { // 1. 写入全局消息队列 Queue<Message> queue = (Queue<Message>) getServletContext() .getAttribute("messageQueue"); if (queue == null) { queue = new ConcurrentLinkedQueue<>(); getServletContext().setAttribute("messageQueue", queue); } Message msg = new Message(sender, content, System.currentTimeMillis()); queue.offer(msg); // 2. 更新全局消息总数(用于 admin.jsp 展示) Long totalMsgs = (Long) getServletContext().getAttribute("totalMessages"); getServletContext().setAttribute("totalMessages", (totalMsgs == null ? 0L : totalMsgs) + 1); } response.getWriter().write("{\"status\":\"success\"}"); } else if ("poll".equals(action)) { HttpSession session = request.getSession(false); if (session == null) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Session 已失效"); return; } String sender = (String) session.getAttribute("nickname"); if (sender == null) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "未登录"); return; } // 【关键】读取时也需同步,确保看到最新写入 synchronized (ChatServlet.class) { Queue<Message> queue = (Queue<Message>) getServletContext() .getAttribute("messageQueue"); if (queue == null || queue.isEmpty()) { response.getWriter().write("{\"messages\":[]}"); return; } // 1. 获取当前用户已读索引 Long lastRead = (Long) session.getAttribute("lastReadIndex"); if (lastRead == null) lastRead = -1L; // 2. 将队列转为 List 并截取 [lastRead+1, lastRead+20] List<Message> list = new ArrayList<>(queue); int start = Math.min((int)(lastRead + 1), list.size()); int end = Math.min(start + 20, list.size()); List<Message> toSend = list.subList(start, end); // 3. 更新用户已读索引 session.setAttribute("lastReadIndex", (long)(end - 1)); // 4. 返回 JSON response.setContentType("application/json;charset=UTF-8"); response.getWriter().write( "{\"messages\":" + new Gson().toJson(toSend) + "}" ); } }逻辑说明:synchronized (ChatServlet.class)是本项目的命门。它保证了「写消息」和「读消息」不会因 JVM 指令重排或 CPU 缓存不一致导致数据错乱。比如:若无此锁,A 线程刚queue.offer()一条消息,B 线程new ArrayList<>(queue)可能拿到旧快照,从而漏掉这条消息。参数说明:subList(start, end)的end是 exclusive,所以end = start + 20最多取 20 条;Gson是com.google.gson.Gson,需在WEB-INF/lib/下放gson-2.8.9.jar,这是本项目唯一外部依赖。4. 避坑指南:五个血泪经验换来的常见问题排查清单4.1 现象:用户 A 发送消息后,用户 B 刷新页面才能看到原因:/index.jsp中sessionScope.messages是登录时一次性写入的,后续新消息只进全局队列,不主动同步到各 Session。而 B 刷新页面会重新执行<c:forEach>,此时sessionScope.messages仍是旧值。解决:删除所有对sessionScope.messages的写入逻辑,彻底放弃「Session 存消息列表」方案。改为前端 JS 轮询poll接口获取增量消息,并用 DOM 操作追加。已在 3.2 节代码中体现。4.2 现象:Tomcat 重启后,所有聊天记录丢失,且在线用户数归零原因:消息队列messageQueue和在线用户列表onlineUsers都存在ServletContext中,而ServletContext生命周期与 Web 应用绑定,重启即销毁。解决:增加重启恢复机制。在ServletContextListener.contextInitialized()中,检查WEB-INF/data/目录下是否存在last_messages.ser序列化文件,若存在则反序列化加载。本资源包已内置该监听器ChatContextListener.java,只需在web.xml中注册:<listener> <listener-class>com.example.ChatContextListener</listener-class> </listener>4.3 现象:两人同时发送相同内容,消息时间戳完全一致,前端显示顺序混乱原因:System.currentTimeMillis()在毫秒级精度下,并发调用可能返回相同值,而前端按timestamp排序时,相同值的顺序由插入顺序决定,不可控。解决:在Message构造函数中加入微秒级偏移:public Message(String sender, String content, long baseTime) { this.sender = sender; this.content = content; this.timestamp = baseTime * 1000000L + System.nanoTime() % 1000000L; }这样即使baseTime相同,nanoTime()的纳秒部分也能提供唯一排序依据。4.4 现象:用户长时间不操作,Session 过期后,poll请求返回 401,但前端 JS 未做错误处理,轮询无限重试报错原因:JSfetch().catch()只捕获网络错误,而 401 是 HTTP 成功响应(状态码 401),then()会进入,但data解析失败抛异常,未覆盖所有分支。解决:在pollNewMessages()的then()中增加状态码判断:.then(r => { if (r.status === 401) { alert('登录已过期,请重新登录'); window.location.href = '<%= request.getContextPath() %>/login.jsp'; return; } return r.json(); })4.5 现象:中文消息在 Chrome 中显示为乱码(如æ¨å¥½),但在 Firefox 正常原因:request.setCharacterEncoding("UTF-8")未在doPost()开头调用,导致getParameter("content")解析失败;同时response.setContentType("application/json;charset=UTF-8")缺少charset,JSON 响应体未声明编码。解决:在doPost()第一行添加request.setCharacterEncoding("UTF-8");在所有response.getWriter().write()前,统一加response.setContentType("application/json;charset=UTF-8")。本资源包所有 Servlet 方法均已补全。5. 进阶技巧:用 JSP Fragment 实现消息模板复用与主题切换5.1 消息模板抽象:把<div class="message-item">抽成独立 JSPF硬编码消息结构会导致样式修改时需遍历所有 JSP 文件。正确做法是创建/WEB-INF/jspf/message-item.jspf:<%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <c:choose> <c:when test="${msg.sender == nickname}"> <div class="message-item self"> <span class="content">${msg.content}</span> <span class="time">[${msg.timestamp}]</span> </div> </c:when> <c:otherwise> <div class="message-item other"> <span class="sender">${msg.sender}:</span> <span class="content">${msg.content}</span> <span class="time">[${msg.timestamp}]</span> </div> </c:otherwise> </c:choose>然后在/index.jsp中用<jsp:include>引入:<c:forEach items="${sessionScope.messages}" var="msg"> <jsp:include page="/WEB-INF/jspf/message-item.jspf"> <jsp:param name="msg" value="${msg}" /> <jsp:param name="nickname" value="${sessionScope.nickname}" /> </jsp:include> </c:forEach>优势:self/other样式分离后,CSS 可单独控制「自己发的消息右对齐、背景蓝」,「别人发的消息左对齐、背景灰」;未来加「消息气泡」、「已读回执」只需改这个 JSPF,不碰主页面逻辑。5.2 主题切换:用 Cookie + Filter 实现深色/浅色模式无缝切换不依赖前端框架,纯 JSP 实现主题切换。步骤如下:创建/theme-switch.jsp,接收?mode=dark参数并写入 Cookie:<% String mode = request.getParameter("mode"); if ("dark".equals(mode) || "light".equals(mode)) { Cookie themeCookie = new Cookie("chat_theme", mode); themeCookie.setPath("/"); themeCookie.setMaxAge(30 * 24 * 3600); // 30天 response.addCookie(themeCookie); } response.sendRedirect(request.getHeader("Referer")); %>编写ThemeFilter.java,在每次请求前读取 Cookie 并设置 Request 属性:public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; String theme = "light"; Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie c : cookies) { if ("chat_theme".equals(c.getName())) { theme = c.getValue(); break; } } } request.setAttribute("theme", theme); chain.doFilter(req, res); }在/index.jsp<head>中动态加载 CSS:<link rel="stylesheet" href="<%= request.getContextPath() %>/css/chat-${theme}.css">效果:点击「切换深色模式」链接<a href="theme-switch.jsp?mode=dark">🌙</a>,页面立即变暗,且下次访问自动记住。这是纯服务端方案,比 localStorage 更可靠——用户清空浏览器缓存后,Cookie 仍存在(因设置了 Max-Age)。5.3 消息搜索功能:用ArrayList的stream().filter()实现轻量搜索/search.jsp不连数据库,而是从全局消息队列中实时过滤:<%@ page import="java.util.stream.Collectors" %> <% String keyword = request.getParameter("q"); Queue<Message> queue = (Queue<Message>) getServletContext() .getAttribute("messageQueue"); List<Message> results = new ArrayList<>(); if (queue != null && keyword != null && !keyword.trim().isEmpty()) { results = queue.stream() .filter(m -> m.getContent().contains(keyword.trim()) || m.getSender().contains(keyword.trim())) .limit(50) // 限制最多50条,防 OOM .collect(Collectors.toList()); } request.setAttribute("results", results); %> <!-- 渲染 results 列表,复用 message-item.jspf -->注意事项:stream().filter()是内存操作,当消息队列超 10 万条时会明显卡顿。本资源包已预设保护:getServletContext().setAttribute("maxQueueSize", 10000),并在send分支中检查队列大小,超限时queue.poll()弹出最老消息。这是用空间换时间的务实选择。从那以后我每次做 Java Web 课程设计,都会先搭一个最小可行的ChatServlet骨架,把synchronized锁、ServletContext共享、Session游标这三根柱子立稳,再往上堆功能。因为只要这三根柱子歪了,后面加 WebSocket、加 Redis、加 JWT,全是给危房贴金箔。希望帮到你。本文还有配套的精品资源,点击获取