☰
Java Web聊天系统实战:Servlet+JSP实现高并发消息一致性
2026/10/7 13:40:42 网站建设 项目流程

简介:这是一份面向Java Web初学者与课程设计学生的完整聊天系统实战项目,基于Spring Boot + Vue前后端分离架构实现,覆盖Web开发全流程核心实践。资源包含138个文件,主体为66个Java后端类(涵盖Controller、Service、DAO、Entity、DTO、VO等分层代码)、12个Vue前端组件及配套的SCSS样式、JS逻辑与HTML模板,辅以9个Markdown文档说明和项目总文档.docx,整体压缩包仅2.08MB,轻量易部署。已有1152人学习下载,适合高校Java Web大作业参考或自学项目练手。读者可直接获得结构清晰的模块化工程:config统一管理配置,processor集成过滤器与监听器,utils封装通用工具,service严格遵循接口实现规范,vo/dto精准适配前后端数据交互,配合完整目录说明与技术文档,大幅降低理解门槛与调试成本。

1. Java Web大作业聊天系统:不是写个Servlet就叫“能跑”,而是让消息不丢、不乱、不卡在30人并发时

你交过Java Web大作业,也见过同学的“聊天系统”——页面上两个文本框加一个发送按钮,点一下,后台System.out.println("收到:" + msg),再用response.getWriter().write("ok")糊弄过去。这确实“能跑”,但真要部署到实验室服务器、让同班30人同时上线发消息、刷新页面不丢历史、换浏览器还能续上会话……立刻崩出NullPointerException、ConcurrentModificationException、HTTP 500,甚至消息发着发着就“飞”到别人窗口里去了。这不是玄学,是没处理好会话状态一致性、异步消息投递、HTTP长连接模拟、前端消息时序控制这四根骨头。本篇不讲Tomcat怎么装、IDEA怎么配JDK,只聚焦一个目标:用最轻量、最可控、最贴近教学场景的方式,在纯Java Web(Servlet + JSP + 原生JS)技术栈下,做出一个可演示、可调试、可解释每行代码作用的聊天系统。它不依赖Spring Boot自动装配,不引入WebSocket黑匣子,所有逻辑摊开在你眼皮底下——适合课程设计答辩时被老师追问“这个request.setAttribute()为什么放这里?”“如果两个用户同时发消息,synchronized锁的是谁?”,也能让你在面试时说出“我做过Web聊天,知道HttpSession和URL重写的边界在哪”。新手照着敲能跑通,熟手能一眼看出哪里该加锁、哪里该持久化、哪里是性能瓶颈。


2. 从零搭起骨架:Servlet + JSP + 内存Map实现基础会话与消息流转

2.1 为什么不用数据库?先用ConcurrentHashMap扛住课堂演示压力

教学场景下,大作业验收时间紧、环境受限(可能只有Tomcat 8 + JDK 8)、数据量极小(单次演示最多50条消息),强上MySQL反而引入连接池配置、SQL注入防护、事务隔离级别等超纲问题。真实项目当然要用DB,但大作业阶段,用线程安全的内存结构把核心流程跑通,才是验证理解的第一步。我们选ConcurrentHashMap<String, List<Message>>作为全局消息仓库:

  • Key 是房间名(如"room_1"),支持多房间;
  • Value 是该房间内按时间顺序追加的消息列表(List<Message>),用CopyOnWriteArrayList保证读多写少场景下的线程安全;
  • Message类仅含sender(用户名)、content(消息体)、timestamp(毫秒时间戳),不加任何ORM注解。

提示:别用HashMap或普通ArrayList!HashMap非线程安全,多用户并发put会触发resize死循环;普通ArrayList在遍历时被另一线程add/remove,直接抛ConcurrentModificationException——这是大作业翻车第一高频原因。

2.2 登录Servlet:用HttpSession绑定用户身份,拒绝“游客乱入”

用户输入昵称提交表单,由LoginServlet处理。关键不在存昵称,而在把用户身份与HttpSession强绑定,后续所有操作都靠session.getAttribute("username")取身份,而非每次从request参数里解析。代码如下:

// LoginServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username").trim(); if (username == null || username.isEmpty()) { request.setAttribute("error", "昵称不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 检查昵称是否已存在(简单去重,避免同名刷屏) HttpSession session = request.getSession(true); synchronized (OnlineUsers.class) { // 全局锁,防并发注册同名 if (OnlineUsers.contains(username)) { request.setAttribute("error", "昵称已被占用,请换一个"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } OnlineUsers.add(username); // 存入静态Set } session.setAttribute("username", username); // 绑定到session session.setAttribute("joinTime", System.currentTimeMillis()); // 记录加入时间 response.sendRedirect(request.getContextPath() + "/chat.jsp?room=room_1"); // 重定向到聊天页 }

逻辑说明:

  • request.getSession(true)确保创建新session(若不存在),true表示“需要时才创建”,比getSession()更明确;
  • OnlineUsers是静态工具类,内部用ConcurrentHashMap<String, Boolean>存在线用户,contains/add方法加synchronized块,解决多用户同时登录时的竞态;
  • response.sendRedirect(...)用重定向而非forward,避免F5刷新重复提交表单——这是学生常踩的“点一次登录弹出两个欢迎页”的坑。

2.3 发送消息Servlet:原子化写入+广播通知,消息不丢的关键

SendServlet接收POST请求,将消息写入内存仓库,并触发“广播”——即通知所有已打开该房间页面的客户端拉取新消息。注意:这里没有真正的推送,而是为后续AJAX轮询打基础。代码核心段:

// SendServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(false); if (session == null || session.getAttribute("username") == null) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "未登录"); return; } String username = (String) session.getAttribute("username"); String room = request.getParameter("room"); String content = request.getParameter("content"); if (room == null || content == null || content.trim().isEmpty()) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "参数缺失"); return; } // 构建消息对象 Message msg = new Message(); msg.setSender(username); msg.setContent(content.trim()); msg.setTimestamp(System.currentTimeMillis()); // 原子化写入:先获取房间消息列表,再add(CopyOnWriteArrayList线程安全) List<Message> roomMessages = ChatRoomManager.getMessages(room); roomMessages.add(msg); // add操作本身线程安全 // 记录日志(方便调试) System.out.printf("[%s] %s: %s%n", new Date(msg.getTimestamp()), username, content.trim()); // 返回成功响应,前端JS据此清空输入框 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"status\":\"success\",\"msgId\":" + msg.getTimestamp() + "}"); }

参数说明:

  • room参数来自前端form hidden input,确保消息发到指定房间;
  • msgId用timestamp代替UUID,简化生成逻辑,且天然有序,前端可据此做消息去重(避免轮询时重复渲染);
  • ChatRoomManager.getMessages(room)是封装好的工具方法,内部用ConcurrentHashMap.computeIfAbsent()确保房间首次访问时自动初始化CopyOnWriteArrayList。

3. 让页面“活”起来:JSP动态渲染 + 原生JS轮询实现伪实时

3.1 chat.jsp:用JSTL c:forEach渲染历史消息,避免scriptlet污染

很多同学在JSP里写<% while(...) { out.print(...); } %>,导致HTML结构混乱、JS变量作用域错乱。正确做法是:后端Servlet把消息列表塞进request,JSP用JSTL标签干净渲染。关键片段:

<!-- chat.jsp --> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ page import="java.util.List" %> <% // 从ChatRoomManager预加载当前房间消息,存入request String room = request.getParameter("room"); List<Message> messages = ChatRoomManager.getMessages(room); request.setAttribute("messages", messages); request.setAttribute("room", room); %> <div id="chat-history"> <c:forEach items="${messages}" var="msg"> <div class="message ${msg.sender == username ? 'own' : 'other'}"> <span class="sender">${msg.sender}:</span> <span class="content">${msg.content}</span> <span class="time">${fn:substring(fn:formatDate(msg.timestamp, 'yyyy-MM-dd HH:mm:ss'), 0, 16)}</span> </div> </c:forEach> </div>

逻辑说明:

  • ${msg.sender == username ? 'own' : 'other'}利用EL表达式做CSS类名切换,实现“自己发的消息右对齐、背景色不同”;
  • fn:formatDate需在page指令中声明<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>,避免手动new SimpleDateFormat线程不安全;
  • 所有动态内容均由EL表达式驱动,无scriptlet,便于老师检查代码规范性。

3.2 前端JS轮询:用setInterval + fetch实现“准实时”,避开WebSocket复杂度

教学环境常禁用WebSocket(防火墙策略、Tomcat默认未启用ws协议),而<meta http-equiv="refresh">太粗暴。我们用原生JSfetch+setInterval实现轻量轮询,关键在于控制频率、处理重复、优雅降级:

// chat.jsp 中的 script 标签内 let lastMsgId = 0; // 记录最后收到的消息ID(即timestamp) const POLLING_INTERVAL = 3000; // 3秒轮询一次,平衡实时性与服务器压力 function pollNewMessages() { const room = "${room}"; fetch('PollServlet?room=' + encodeURIComponent(room) + '&lastId=' + lastMsgId) .then(response => { if (!response.ok) throw new Error('HTTP ' + response.status); return response.json(); }) .then(data => { if (data.messages && data.messages.length > 0) { const newMsgs = data.messages.filter(msg => msg.timestamp > lastMsgId); newMsgs.forEach(renderMessage); // 渲染新消息 lastMsgId = Math.max(lastMsgId, ...newMsgs.map(m => m.timestamp)); } }) .catch(err => { console.warn('轮询失败,稍后重试:', err.message); // 失败时不中断轮询,避免页面“假死” }); } // 页面加载完成后启动轮询 document.addEventListener('DOMContentLoaded', () => { pollNewMessages(); // 首次立即拉取 setInterval(pollNewMessages, POLLING_INTERVAL); }); function renderMessage(msg) { const historyDiv = document.getElementById('chat-history'); const msgDiv = document.createElement('div'); msgDiv.className = 'message ' + (msg.sender === '${username}' ? 'own' : 'other'); msgDiv.innerHTML = ` <span class="sender">${msg.sender}:</span> <span class="content">${escapeHtml(msg.content)}</span> <span class="time">${new Date(msg.timestamp).toLocaleTimeString()}</span> `; historyDiv.appendChild(msgDiv); historyDiv.scrollTop = historyDiv.scrollHeight; // 滚动到底部 } // 简单HTML转义,防XSS(教学项目最低防护) function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }

参数说明:

  • lastMsgId初始为0,每次成功拉取后更新为最大timestamp,确保不重复渲染旧消息;
  • encodeURIComponent(room)防止房间名含特殊字符(如room#test)导致URL解析错误;
  • fetch替代XMLHttpRequest,语法简洁,Promise链清晰;
  • catch中仅console.warn不中断setInterval,避免网络抖动导致聊天界面“卡住”。

3.3 PollServlet:按lastId精准返回增量消息,减少带宽浪费

轮询的价值全在PollServlet的实现质量。它必须:

  1. 根据room参数定位消息列表;
  2. 过滤出timestamp > lastId的消息;
  3. 限制返回条数(防恶意请求拖垮内存)。代码精简版:
// PollServlet.java protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String room = request.getParameter("room"); long lastId = Long.parseLong(request.getParameter("lastId")); List<Message> allMessages = ChatRoomManager.getMessages(room); // 只取lastId之后的最新10条(教学够用,防爆) List<Message> newMessages = allMessages.stream() .filter(msg -> msg.getTimestamp() > lastId) .limit(10) .collect(Collectors.toList()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(new ObjectMapper().writeValueAsString( Map.of("messages", newMessages) )); }

逻辑说明:

  • limit(10)是硬性保护,即使房间有1000条消息,也只返回10条增量,避免JSON过大阻塞HTTP响应;
  • ObjectMapper来自Jackson库(需在pom.xml添加com.fasterxml.jackson.core:jackson-databind),比手拼JSON字符串健壮;
  • stream().filter().limit()在内存中完成,无DB查询开销,符合教学轻量原则。

4. 避坑指南:30人并发测试时,90%的翻车都发生在这5个地方

4.1 现象:用户A发消息,用户B页面显示“undefined: hello”

原因:JSP中${msg.sender}取值时,msg对象的sender字段为null。常见于Message类未提供public getter方法,或getter命名不规范(如getSenderName()而非getSender()),导致JSTL EL无法反射调用。
解决:严格遵循JavaBean规范,Message类必须有public String getSender() { return sender; },且字段名与getter前缀完全匹配(sender→getSender)。

4.2 现象:两人同时发“1”、“2”,页面显示顺序为“2”、“1”(时间倒置)

原因:System.currentTimeMillis()在毫秒级精度下,高并发时可能返回相同值,导致timestamp相等,stream().filter()按插入顺序而非时间顺序返回。
解决:在Message构造时,若检测到timestamp与上一条相同,则timestamp++(微调):

private static long lastTimestamp = 0; public Message() { long now = System.currentTimeMillis(); this.timestamp = Math.max(now, lastTimestamp + 1); lastTimestamp = this.timestamp; }

4.3 现象:刷新chat.jsp页面后,新消息不再出现,或历史消息消失

原因:ChatRoomManager.getMessages(room)每次调用都新建CopyOnWriteArrayList,而非复用全局实例。根源在ConcurrentHashMap.computeIfAbsent()使用不当,例如写成map.getOrDefault(room, new CopyOnWriteArrayList<>()),后者每次调用都新建对象。
解决:必须用computeIfAbsent确保单例:

public static List<Message> getMessages(String room) { return ROOMS.computeIfAbsent(room, k -> new CopyOnWriteArrayList<>()); }

4.4 现象:Tomcat启动报错java.lang.ClassNotFoundException: com.fasterxml.jackson.databind.ObjectMapper

原因:未将Jackson依赖加入WEB-INF/lib,或Maven依赖scope写成test。
解决:确认pom.xml中依赖为compile(默认):

<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> <!-- 选Tomcat 8兼容的2.x版本 --> </dependency>

并执行mvn clean package,检查生成的WAR包WEB-INF/lib/下是否存在jackson-databind-*.jar。

4.5 现象:登录后跳转chat.jsp,页面显示javax.servlet.ServletException: java.lang.NullPointerException

原因:chat.jsp中${username}取值失败,因LoginServlet未将username存入session,或存入了但key名不一致(如存成"user"而非"username")。
解决:统一约定session attribute key为"username",并在chat.jsp顶部用<% String username = (String) session.getAttribute("username"); %>显式获取,再用<c:if test="${not empty username}">包裹主体内容,避免EL表达式空指针。


5. 从“能跑”到“像样”:加3个功能让答辩老师眼前一亮

5.1 在线用户列表:用ServletContext共享状态,实时显示谁在聊

HttpSession只绑定单个用户,要跨session统计在线人数,必须用ServletContext——它在整个Web应用生命周期内唯一。我们在LoginServlet登录成功后增加:

ServletContext context = getServletContext(); Set<String> onlineUsers = (Set<String>) context.getAttribute("onlineUsers"); if (onlineUsers == null) { onlineUsers = ConcurrentHashMap.newKeySet(); // 线程安全的Set context.setAttribute("onlineUsers", onlineUsers); } onlineUsers.add(username); // 添加当前用户

对应地,在LogoutServlet中移除:

onlineUsers.remove(username); if (onlineUsers.isEmpty()) { context.removeAttribute("onlineUsers"); // 空了就清理 }

前端chat.jsp通过AJAX定时拉取/UserListServlet(返回JSON数组),用<ul id="user-list"></ul>动态渲染。亮点:老师问“你怎么知道谁在线?”,你能指着ServletContext说:“它就像整个聊天室的公告板,所有Servlet都能读写”。

5.2 消息撤回功能:加个“撤回”按钮,后端用timestamp标记逻辑删除

不删物理数据(破坏历史),而是加isDeleted字段。SendServlet发消息时默认false;RecallServlet收到撤回请求后,遍历roomMessages找到timestamp匹配的消息,设isDeleted=true。PollServlet返回时过滤掉isDeleted==true的消息,chat.jsp渲染时对已撤回消息显示“[此消息已被撤回]”。价值:展示你理解“软删除”设计模式,且代码改动仅3处(Message类加字段、SendServlet初始化、PollServlet过滤)。

5.3 敏感词过滤:用HashSet+contains实现O(1)拦截,防课堂尴尬

建SensitiveWords.java工具类,静态块加载敏感词:

public class SensitiveWords { private static final Set<String> WORDS = ConcurrentHashMap.newKeySet(); static { Collections.addAll(WORDS, "xxx", "yyy", "zzz"); // 替换为实际词库 } public static boolean contains(String text) { return WORDS.stream().anyMatch(text::contains); } }

在SendServlet中插入:

if (SensitiveWords.contains(content)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "包含敏感词,禁止发送"); return; }

答辩话术:“我加了基础敏感词过滤,用ConcurrentHashMap保证高并发下查询不锁表,词库可从文件动态加载——这比正则匹配快得多,也比调用外部API更可控。”

注意:此处“敏感词”仅指教学场景中需规避的词汇(如特定人名、不雅缩写),不涉及任何违规内容。实现方案完全基于内存HashSet,无外部依赖,符合教学安全要求。


6. 验证与压测:用Apache Bench跑出30并发下的真实表现

6.1 本地验证三步法:确保每个环节可观察

  1. 日志验证:启动Tomcat,观察控制台输出格式为[2024-06-15 14:22:33] 张三: 你好,确认SendServlet成功写入;
  2. 网络面板验证:Chrome DevTools → Network,查看PollServlet请求返回的JSON是否含"messages": [...],且lastId参数被正确传递;
  3. 并发模拟验证:开3个隐身窗口,分别登录“张三”、“李四”、“王五”,互相发消息,观察各自页面是否只显示本房间消息、时间戳是否递增、无错乱。

6.2 Apache Bench压测:量化你的系统承载力

别信“应该能扛住”,用ab命令实测。假设你的应用部署在localhost:8080/chat,执行:

# 模拟30个用户,共发送1000次请求(登录+发消息混合) ab -n 1000 -c 30 'http://localhost:8080/your-app/LoginServlet?username=test1' ab -n 1000 -c 30 'http://localhost:8080/your-app/SendServlet?room=room_1&content=hello'

关键看报告中的Requests per second(QPS)和Time per request(平均延迟)。教学环境预期:QPS ≥ 50,平均延迟 ≤ 200ms。若QPS骤降至5以下,大概率是CopyOnWriteArrayList在大量写入时add操作引发数组复制开销——此时应提醒老师:“当前设计侧重教学可读性,生产环境会改用Redis Stream或Kafka”。

6.3 一个血泪经验:永远在finally块里释放资源

曾有个学生在PollServlet里用ObjectMapper序列化后,忘了response.getWriter().flush(),导致部分请求卡在“等待响应”状态,最终Tomcat线程池耗尽。正确写法:

PrintWriter out = response.getWriter(); try { out.write(jsonString); } finally { out.flush(); // 强制刷出缓冲区 // out.close() 不要调用!由容器管理 }

教训:Servlet容器负责关闭PrintWriter,你只需flush()确保数据发出。这个细节在教材里常被忽略,却是线上故障的隐形推手。

希望帮到你。

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

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

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

立即咨询