☰
Java JSP多人聊天系统:Servlet轮询与并发管理实战解析
2026/10/11 1:52:10 网站建设 项目流程

简介:基于JSP与Servlet的多人实时聊天系统源码包,面向Web开发学习者与Java初学者,可作为课程设计或毕业设计的起步模板。资源覆盖JSP内置对象、Servlet请求处理、AJAX无刷新交互等关键环节,能帮助读者理解用户会话保持、服务端消息转发与在线广播机制的完整链路;其中JSP负责动态页面生成,Servlet承担消息接收与转发,分层思路清晰。压缩包共11个文件,包含2个Java源文件、6个class编译文件以及classpath、prefs、project等工程配置,便于对照源码与编译产物排查环境或部署问题;整体仅12KB,结构紧凑。已有676人学习下载。通过研读项目,可掌握session、request在实际聊天场景中的用法,了解借助队列或数据库保存消息并推送给在线用户的服务端设计思路,还可延伸实践WebSocket实时通信与前端展示优化,适合动手改造和二次开发。

1. Java JSP 多人聊天系统:先搞清楚广播链路,再谈界面美化

一套 Java JSP 多人聊天系统,表面上是个带登录框、消息列表和在线名单的网页,真正考验技术的部分从来不是 JSP 标签怎么写,而是消息怎么从一个浏览器传到另一个浏览器。很多初学者在拿到这类源码包后,第一反应是先把界面跑起来,看到能发消息就以为完事了,结果一打开两个浏览器同时发言,要么消息丢、要么在线列表错乱,甚至直接把 Tomcat 卡死。这套系统适合两类人:一类是做课程设计需要快速跑通完整业务流程的学生,另一类是刚接触 Servlet 会话管理、想弄明白多客户端状态同步的开发者。它能解决的核心问题,是把“多用户在线状态”和“消息广播”这两个最容易出错的模块,从底层设计上理顺。

2. 技术选型与核心链路:为什么这套系统的重量在 Servlet 而不是 JSP

在拆这份 JSP 聊天室源码之前,需要先想清楚一个基本事实:JSP 本质是服务端模板,浏览器看到的是渲染后的 HTML,它并不具备主动推送消息的能力。所以聊天室的消息通知,必须依靠服务端和浏览器之间的某种“反复拉取”或“长连接”机制。这套源码采用的是最经典、对新手最友好的方案:浏览器定时轮询 + Servlet 处理请求 + JSP 渲染页面。选这个方案的原因很直接——它不需要额外引入 WebSocket 依赖,Tomcat 版本兼容性风险小,课程设计和本地调试都能跑得动。

2.1 消息模型与会话管理:ConcurrentHashMap 支撑的用户中心

多人聊天系统的第一道设计题,是“如何记录谁在线”。常见做法是用一个全局的 Map 来维护在线用户名和对应的 HttpSession,而这份源码更进一步,把这个 Map 封装成一个单例的 ChatRoom 类,避免多个 Servlet 之间各自维护一份数据导致的不一致。下面这段是用户管理和消息存储的核心骨架:

public class ChatRoom { // 单例,保证整个 web 应用共享同一个聊天室状态 private static final ChatRoom INSTANCE = new ChatRoom(); // 在线用户表:key 为用户名,value 为会话对象 private final Map<String, HttpSession> onlineUsers = new ConcurrentHashMap<>(); // 消息列表:用 CopyOnWriteArrayList 避免遍历时并发修改异常 private final List<ChatMessage> messages = new CopyOnWriteArrayList<>(); // 自增消息序号,客户端靠它做增量拉取 private volatile int lastMessageId = 0; private ChatRoom() {} public static ChatRoom getInstance() { return INSTANCE; } // 用户上线,如果用户名已存在则返回 false public boolean join(String username, HttpSession session) { if (onlineUsers.containsKey(username)) { return false; } onlineUsers.put(username, session); return true; } public void leave(String username) { onlineUsers.remove(username); } public List<String> getOnlineUsers() { return new ArrayList<>(onlineUsers.keySet()); } public synchronized int publish(String username, String content) { int id = ++lastMessageId; messages.add(new ChatMessage(id, username, content, System.currentTimeMillis())); return id; } // 客户端传过来上一次拿到的消息 id,只需要返回比它新的消息 public List<ChatMessage> messagesAfter(int lastId) { List<ChatMessage> result = new ArrayList<>(); for (ChatMessage msg : messages) { if (msg.getId() > lastId) { result.add(msg); } } return result; } }

这里的逻辑有几个关键点。ConcurrentHashMap 代替普通 HashMap,是因为多个客户端同时请求登录时,在线用户表的 put 和 remove 会发生在不同线程里,普通 Map 在扩容时可能把线程直接卡死。消息列表用 CopyOnWriteArrayList 也是一样的并发考虑——广播消息时会有线程在往列表里加消息,同时另外的线程正在遍历列表准备渲染,如果使用普通 ArrayList 几乎必然抛 ConcurrentModificationException。还有一个很实用的设计是 lastMessageId 自增字段,客户端每次轮询时带上自己已经收到的最大消息 id,服务端只返回比这个大且比当前小的消息,这样既不用每次全量拉取历史,也避免了重复渲染同一批消息。

2.2 增量轮询接口:PollServlet 该怎么设计响应格式

有了 ChatRoom 作为数据中心,接下来要有一个专门处理客户端轮询请求的 Servlet。这个接口的职责是接收客户端传来的 lastId 参数,返回新消息和当前在线用户列表。源码里的 PollServlet 大致长这样:

@WebServlet("/poll") public class PollServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = (String) request.getSession().getAttribute("username"); if (username == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } int lastId = 0; String lastIdParam = request.getParameter("lastId"); if (lastIdParam != null && !lastIdParam.isEmpty()) { try { lastId = Integer.parseInt(lastIdParam); } catch (NumberFormatException e) { lastId = 0; } } ChatRoom chatRoom = ChatRoom.getInstance(); List<ChatMessage> newMessages = chatRoom.messagesAfter(lastId); List<String> onlineUsers = chatRoom.getOnlineUsers(); // 组装成 JSON 字符串,直接在 Servlet 里输出,不经过 JSP StringBuilder json = new StringBuilder(); json.append("{\"users\":["); for (int i = 0; i < onlineUsers.size(); i++) { if (i > 0) json.append(","); json.append("\"").append(onlineUsers.get(i)).append("\""); } json.append("],\"messages\":["); for (int i = 0; i < newMessages.size(); i++) { ChatMessage msg = newMessages.get(i); if (i > 0) json.append(","); json.append("{\"id\":").append(msg.getId()) .append(",\"from\":\"").append(msg.getFrom()) .append("\",\"content\":\"").append(msg.getContent()) .append("\",\"time\":").append(msg.getTimestamp()) .append("}"); } json.append("]}"); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(json.toString()); } }

这里要特别说明一下为什么轮询接口用纯 Servlet 输出 JSON,而不把数据丢给 JSP 去渲染。聊天室前端的消息区和在线名单更新频率很高,如果用 JSP 去套 HTML 模板,每次轮询都要重新渲染整个页面片段,服务端和浏览器都白白消耗 CPU。直接输出 JSON 让前端 JavaScript 拼接 DOM,是目前这类轻量级聊天室最主流也最省资源的写法。另一个值得关注的细节是 lastId 的默认值设为 0,这样可以保证用户刚进聊天室时,如果之前没有人发言,接口返回的 messages 数组是空的,前端不用做额外的空值判断。

3. 环境搭建与项目骨架:把代码包跑起来之前的三个准备动作

拿到这套源码之后,先别急着往 Tomcat 里扔。从多年经验看,七成以上的启动失败都出在环境不匹配或目录结构不完整上。这份源码是按传统 Dynamic Web Project 结构组织的,也就是 WebContent 目录下放 JSP,src 目录下放 Java 类,编译产物由 Eclipse 或 IDEA 自动打到 WEB-INF/classes 下。建议直接用 Eclipse EE 版本导入,IDEA 也可以,但要注意设置 Web Facet 和 Artifact 导出方式。

3.1 运行环境清单与 Tomcat 版本选择

这套基于 JSP 和 Servlet 的聊天系统,对环境要求并不苛刻。JDK 8 或 11 都能跑,Servlet 容器建议用 Tomcat 8.5 或 9.0,这两个版本对 Servlet 3.0/3.1 规范的支持非常稳定。有一个容易被忽略的点:如果用 JDK 11 配 Tomcat 8.5 之前的老版本,可能会因为 Tomcat 内部使用了一些被移除的 Java EE 模块而启动失败,所以最省事的组合是 JDK 8 + Tomcat 8.5。下面是一个典型的配置参考表:

组件推荐版本说明
JDK1.8 或 111.8 最稳,课程设计场景优先
Tomcat8.5 / 9.09.0 对 Servlet 4.0 支持更好
编码UTF-8页面、Servlet、数据库连接统一
浏览器任意现代浏览器建议 Chrome 系,F12 方便看轮询请求

导入源码后,要检查项目的 Java Build Path 里是否已经引入了 Tomcat 运行时库。如果没有引入,JSP 会编译失败,控制台报的错通常是一堆 javax.servlet 无法解析。遇到这种情况,只需要在 Eclipse 的 Targeted Runtimes 里勾选 Tomcat 版本,IDEA 则在 Project Structure 里配置 Web Facet 对应的 Tomcat 即可。

3.2 Web.xml 配置与欢迎页入口

现代 Servlet 项目使用注解 @WebServlet 就能完成路由注册,不需要在 web.xml 里逐个写 servlet-mapping。但 web.xml 文件本身仍然要保留,它承担着配置欢迎页面和全局编码过滤器的任务。下面是源码中 web.xml 的核心片段:

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <display-name>MultiUserChat</display-name> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> <filter> <filter-name>EncodingFilter</filter-name> <filter-class>com.demo.chat.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>EncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <session-config> <session-timeout>30</session-timeout> </session-config> </web-app>

这里面有个非常关键的设计:编码过滤器拦截了所有请求。如果不加这个过滤器,登录时输入中文用户名、聊天时发中文消息,非常容易在请求解析阶段就出现乱码。过滤器内部只需要在 doFilter 里调用 request.setCharacterEncoding("UTF-8"),并且让 chain.doFilter 继续往下走。session-timeout 设为 30 分钟,这是为了防止用户挂着页面不操作导致会话被容器提前回收,但也不能设太长,否则服务端会积累大量已经废弃的 HttpSession 对象,这一点在后文避坑章节会详细展开。

3.3 登录页与会话初始化:用户名重复检测放在服务端

登录页本身是一张简单的 JSP 表单,提交到 LoginServlet 后,执行 ChatRoom.join 判断用户名是否已存在。很多初学者会把用户名重复检测放在前端用 JavaScript 做,这是靠不住的——浏览器 A 和浏览器 B 同时提交同一个用户名,两个前端页面各自检测时在线列表里都没有这个名字,结果双双通过校验。所以必须把判断放到 ChatRoom 的 containsKey 里,这是唯一可靠的位置。登录成功后,要同时做两件事:把用户名写入 session,并且把 ChatRoom 里的在线用户列表刷新一遍。

4. 核心功能实现细节:从消息发送到广播渲染的完整链路

聊天室的主页面是 chat.jsp,它承担着三个任务:展示消息记录、展示在线用户、提供输入框。这三个任务分别对应前端的三个渲染区域,而它们的数据来源都在 PollServlet 的 JSON 响应里。当用户在输入框里敲了一句话点发送,浏览器会向 SendServlet 发一个 POST 请求,SendServlet 调用 ChatRoom.publish 写入消息,然后轮到 poll 机制把这条消息推给所有在线的浏览器。

4.1 发送消息的 Servlet 实现

@WebServlet("/send") public class SendServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = (String) request.getSession().getAttribute("username"); if (username == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("not login"); return; } String content = request.getParameter("content"); if (content == null) { content = ""; } content = content.trim(); if (content.length() == 0) { response.getWriter().write("empty"); return; } // 限制单条消息长度,防止恶意刷屏拖垮服务端 if (content.length() > 500) { content = content.substring(0, 500); } ChatRoom.getInstance().publish(username, content); response.getWriter().write("ok"); } }

这段代码看起来简单,但有三个边界处理值得学习。第一,username 从 session 里取,而不是信任前端传来的参数,这能防止一个用户伪造另一个用户的身份发言。第二,消息必须先 trim 再判断长度,否则用户发一串空格也会被当成有效消息存进去。第三,设置了 500 字的长度上限,这是服务端最基本的自我保护——如果没有这个限制,一个浏览器可以不停地发超大文本,把 ChatRoom 里的 List 撑爆,最终拖垮整个 JVM。发送成功或失败都通过简单的字符串返回,前端拿到后清空输入框或提示错误。

4.2 前端轮询逻辑:setInterval 与增量渲染

let lastMessageId = 0; // 轮询函数:每 2 秒拉取一次新消息和在线用户 function poll() { fetch('poll?lastId=' + lastMessageId, { method: 'GET', credentials: 'same-origin' }) .then(response => { if (response.status === 401) { // 会话过期,跳转到登录页 window.location.href = 'login.jsp'; return null; } return response.json(); }) .then(data => { if (!data) return; // 渲染在线用户列表 const userBox = document.getElementById('onlineUsers'); userBox.innerHTML = ''; data.users.forEach(user => { const div = document.createElement('div'); div.textContent = user; div.className = 'user-item'; userBox.appendChild(div); }); // 增量渲染新消息 const msgBox = document.getElementById('messageList'); data.messages.forEach(msg => { const div = document.createElement('div'); div.className = 'msg-row'; div.innerHTML = '<span class="msg-from">' + msg.from + ':</span> ' + '<span class="msg-content"></span>'; // 使用 textContent 设置消息内容,防止 XSS div.querySelector('.msg-content').textContent = msg.content; msgBox.appendChild(div); // 更新本地记录的最大消息 id if (msg.id > lastMessageId) { lastMessageId = msg.id; } }); // 滚动到底部 msgBox.scrollTop = msgBox.scrollHeight; }) .catch(error => { console.error('poll error:', error); }); } // 页面加载后立即拉一次,然后每 2 秒轮询 window.addEventListener('DOMContentLoaded', () => { poll(); setInterval(poll, 2000); });

这是整条广播链路的最后一段。两个最容易出错的地方:一是渲染消息内容时必须使用 textContent 而不是直接拼 innerHTML,否则用户发一条<img src=x onerror=alert(1)>就能在别人浏览器里执行脚本,这就是典型的 XSS 攻击;二是 lastMessageId 的更新时机,必须在消息渲染完成之后再做赋值,否则万一渲染抛异常,客户端会把没显示出来的消息当成已读,下次轮询就永远看不到那条消息了。这里的 fetch 用 credentials: 'same-origin',确保轮询请求能带上当前会话的 Cookie,否则服务端 session.getAttribute 会一直拿到 null。

4.3 用户退出的处理路径

退出聊天室不能只靠关闭浏览器。用户直接关标签页时,浏览器并不会主动通知服务端,所以需要一种兜底策略。常见的做法是提供一个退出按钮,点击后跳转到 LogoutServlet,Servlet 里先调用 ChatRoom.leave,再调用 session.invalidate。代码路径很简单,但要注意执行顺序——如果先 invalidate 再从 session 里取用户名,会抛出 IllegalStateException。另一个兜底是 session-timeout 到期后,SessionListener 的 sessionDestroyed 回调里也要调用 ChatRoom.leave,保证容器回收会话时能同步清理在线名单。

5. 多人聊天系统的避坑排查:并发、乱码与轮询压力的四个重灾区

这套系统在单用户单浏览器场景下几乎不会出问题,一旦模拟多人并发,各种隐藏的坑就浮出水面。下面这四条是拆这套代码时最容易撞上的,每一条都曾经让使用者反复查日志。

5.1 现象:两个浏览器同时发言,偶发 500 错误,后台报 ConcurrentModificationException

原因:消息列表在广播的同时被另一个线程修改,遍历和写入同时发生。如果代码里用的是 ArrayList 而不是 CopyOnWriteArrayList,这个问题几乎 100% 会出现。 解决:换成 CopyOnWriteArrayList,或者把遍历放在 synchronized 块里。但如果用 synchronized 包住整个广播过程,会拖慢响应速度,所以更推荐前者。

5.2 现象:登录后聊天室页面中文全部乱码,数据库里存的内容正常

原因:Tomcat 8.5 之后 GET 请求参数的编码默认不是 UTF-8,而是 ISO-8859-1,轮询请求里带的 lastId 虽然不受影响,但登录时的用户名参数已经变成乱码。另外 JSP 页面本身如果没有设置 pageEncoding,也可能导致响应乱码。 解决:web.xml 的编码过滤器覆盖 POST 请求,GET 请求则需要在 Tomcat 的 conf/server.xml 中给 Connector 加 URIEncoding="UTF-8",或者把参数重新编码处理。更稳妥的做法是前端表单统一用 POST 提交。

5.3 现象:页面开着不动几个小时后,再点发送按钮,被踢回登录页

原因:session-timeout 设的是 30 分钟,但页面里轮询请求每 2 秒发起一次,按道理会不断刷新 session 的活跃时间。问题出在很多人把轮询写成了只在页面加载后执行一次 setInterval,但中间某个请求报错后没有重新调度,导致后续轮询停止,会话自然过期。 解决:在 poll 的 catch 分支里保留 setInterval 继续运行,或者在每次请求后判断 response.status,如果是 401 就主动跳转登录页。另一个做法是把轮询间隔缩短到 2 秒以内,让 Tomcat 认为会话一直活跃。

5.4 现象:几十个用户同时在线,服务端 CPU 飙升,轮询请求全部堆积

原因:每 2 秒一次轮询,100 个用户就有每秒 50 个请求打到 Tomcat。如果每个请求都要扫描整个消息列表,即使没有新消息,CPU 也很容易被打满。这里的问题既是设计问题也是参数问题。 解决:给 PollServlet 加一个轻量判断,如果 lastId 等于 ChatRoom 当前 lastMessageId,直接返回空 JSON,跳过遍历。同时把前端轮询间隔从 1 秒调整为 2 到 3 秒,并加入随机抖动,避免所有浏览器整齐划一地同时发起请求造成流量尖峰。

6. 用模拟脚本做端到端验证:账号并发上线与消息时序检测

聊天室功能写完,光靠人工开几个浏览器点来点去验证是不够的。我习惯用一段简单的 Python 脚本模拟多个客户端同时登录、发言、退出,重点观察三个指标:登录是否全部成功、消息广播是否到齐、在线用户列表是否精确增减。这段脚本不需要引入任何第三方库,Python 3 自带的 urllib 和 http.cookiejar 就够用:

# -*- coding: utf-8 -*- # 模拟多个客户端并发登录聊天室 import threading import time import urllib.request import http.cookiejar import json BASE_URL = "http://localhost:8080/chat" def client_session(username): # 每个客户端独立的 cookie 容器,模拟不同浏览器 cj = http.cookiejar.CookieJar() opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) # 1. 模拟登录 login_data = urllib.parse.urlencode({"username": username}).encode("utf-8") req = urllib.request.Request(BASE_URL + "/login", data=login_data) resp = opener.open(req) if resp.status != 200: print(username, "login failed") return # 2. 发送一条消息 msg_data = urllib.parse.urlencode({"content": username + " say hello"}).encode("utf-8") req = urllib.request.Request(BASE_URL + "/send", data=msg_data) resp = opener.open(req) print(username, "send result:", resp.read().decode("utf-8")) # 3. 轮询一次,检查能否看到自己的消息 req = urllib.request.Request(BASE_URL + "/poll?lastId=0") resp = opener.open(req) data = json.loads(resp.read().decode("utf-8")) print(username, "poll messages:", len(data["messages"])) # 开 5 个线程模拟 5 个用户同时操作 threads = [] for i in range(5): t = threading.Thread(target=client_session, args=("user" + str(i),)) threads.append(t) t.start() for t in threads: t.join()

脚本跑完,重点看最后一个用户的 poll 返回里 messages 长度是不是 5。如果不是,说明广播链路上有消息丢失,问题大概率出在 ChatRoom.messagesAfter 的遍历逻辑或者 PollServlet 的 JSON 序列化上。这个脚本还能顺手验证登录重复检测——把两个线程的 username 改成一样,应该其中一个返回 login failed,如果两个都成功,说明 ChatRoom.join 的判断写错了位置。

从那以后,我每次拿到一套 JSP 聊天室代码,都会先做三件事:检查 Map 是否用并发版、检查轮询接口是否支持增量查询、检查前端渲染是否用了 textContent。这三条过完,再高的并发也不至于翻车。希望这篇文章能帮你少踩几个坑,尽快把这套系统跑起来。

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

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

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

立即咨询