资讯动态

Java Web多人聊天系统:Servlet+JSP轮询实战

发布时间:2026/10/20 7:33:13 来源:尧图企业网站定制
简介这是一份基于JSP与Java技术实现的轻量级多人实时聊天系统源码面向Web开发初学者及Java Web课程设计学习者帮助理解会话管理、前后端交互与基础即时通信原理。资源共11个文件含6个编译后的class字节码文件承载核心业务逻辑、2个java源文件含Servlet处理类与消息管理类、1个.classpath配置文件、1个.project项目标识文件及1个Eclipse偏好设置文件.prefs整体压缩包仅12KB结构精简便于快速导入IDE运行调试。已有677人学习下载适合用于课堂实验、课程设计或JSPServlet入门实践。读者可直接部署运行完整掌握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?actionsend或/chat?actionpoll参数明文、响应 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 contentTypetext/html;charsetUTF-8 %且禁用缓存% response.setHeader(Cache-Control, no-cache); %。这是防止 IE 下轮询返回旧缓存消息的关键一步别跳过。2.3 内存消息队列设计为什么不用数据库而用ConcurrentLinkedQueue消息存储没上 MySQL 或 Redis而是用ConcurrentLinkedQueueMessage存于 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再从全局队列中截取[lastReadIndex1, lastReadIndex20]范围的消息返回并将新索引lastReadIndex20写回 Session。这样即使 A、B 同时在线A 拉了第 1~20 条B 拉了第 1~20 条他们看到的内容完全一致但各自的「已读游标」独立演进。这种设计牺牲了「消息撤回」能力却换来零数据库读、Session 数据轻量、断线重连后自动续播的确定性。3. 核心代码实现从登录到消息收发的完整链路3.1 登录流程昵称校验与 Session 初始化/login.jsp表单提交至/ChatServlet?actionloginServlet 中关键逻辑如下// 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. 全局昵称唯一性检查内存级 ListString onlineUsers (ListString) 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 contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % // 强制设置响应头禁用缓存 response.setHeader(Cache-Control, no-cache); response.setHeader(Pragma, no-cache); % !DOCTYPE html html headtitle多人聊天室/title/head body div idchat-container div idmessage-list c:if test${empty sessionScope.messages} p classhint暂无消息快发送第一条吧/p /c:if c:forEach items${sessionScope.messages} varmsg div classmessage-item span classsender${msg.sender}/span span classcontent${msg.content}/span span classtime[${msg.timestamp}]/span /div /c:forEach /div form idsend-form onsubmitreturn false; input typetext idmessage-input placeholder输入消息... maxlength200/ button typesubmit发送/button /form /div script // 30秒轮询一次新消息 function pollNewMessages() { fetch(% request.getContextPath() %/ChatServlet?actionpoll) .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 classsender${msg.sender}/span span classcontent${msg.content}/span span classtime[${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?actionsend, { 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-length200是前端限制后端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. 写入全局消息队列 QueueMessage queue (QueueMessage) 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) { QueueMessage queue (QueueMessage) 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 并截取 [lastRead1, lastRead20] ListMessage list new ArrayList(queue); int start Math.min((int)(lastRead 1), list.size()); int end Math.min(start 20, list.size()); ListMessage toSend list.subList(start, end); // 3. 更新用户已读索引 session.setAttribute(lastReadIndex, (long)(end - 1)); // 4. 返回 JSON response.setContentType(application/json;charsetUTF-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-classcom.example.ChatContextListener/listener-class /listener4.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 成功响应状态码 401then()会进入但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;charsetUTF-8)缺少charsetJSON 响应体未声明编码。解决在doPost()第一行添加request.setCharacterEncoding(UTF-8)在所有response.getWriter().write()前统一加response.setContentType(application/json;charsetUTF-8)。本资源包所有 Servlet 方法均已补全。5. 进阶技巧用 JSP Fragment 实现消息模板复用与主题切换5.1 消息模板抽象把div classmessage-item抽成独立 JSPF硬编码消息结构会导致样式修改时需遍历所有 JSP 文件。正确做法是创建/WEB-INF/jspf/message-item.jspf% page contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:choose c:when test${msg.sender nickname} div classmessage-item self span classcontent${msg.content}/span span classtime[${msg.timestamp}]/span /div /c:when c:otherwise div classmessage-item other span classsender${msg.sender}/span span classcontent${msg.content}/span span classtime[${msg.timestamp}]/span /div /c:otherwise /c:choose然后在/index.jsp中用jsp:include引入c:forEach items${sessionScope.messages} varmsg jsp:include page/WEB-INF/jspf/message-item.jspf jsp:param namemsg value${msg} / jsp:param namenickname value${sessionScope.nickname} / /jsp:include /c:forEach优势self/other样式分离后CSS 可单独控制「自己发的消息右对齐、背景蓝」「别人发的消息左对齐、背景灰」未来加「消息气泡」、「已读回执」只需改这个 JSPF不碰主页面逻辑。5.2 主题切换用 Cookie Filter 实现深色/浅色模式无缝切换不依赖前端框架纯 JSP 实现主题切换。步骤如下创建/theme-switch.jsp接收?modedark参数并写入 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.jsphead中动态加载 CSSlink relstylesheet href% request.getContextPath() %/css/chat-${theme}.css效果点击「切换深色模式」链接a hreftheme-switch.jsp?modedark/a页面立即变暗且下次访问自动记住。这是纯服务端方案比 localStorage 更可靠——用户清空浏览器缓存后Cookie 仍存在因设置了 Max-Age。5.3 消息搜索功能用ArrayList的stream().filter()实现轻量搜索/search.jsp不连数据库而是从全局消息队列中实时过滤% page importjava.util.stream.Collectors % % String keyword request.getParameter(q); QueueMessage queue (QueueMessage) getServletContext() .getAttribute(messageQueue); ListMessage 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全是给危房贴金箔。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑