简介:这份Java网络聊天室项目源码面向学习Java网络编程、Socket通信、多线程与数据库应用的初中级开发者,可作为课程设计、毕业设计或综合实训的参考案例。压缩包共46个文件,约199KB,包含7个java源文件、20个class编译文件及jpg界面截图、txt说明、db数据库等,便于读者对照源码、运行结果和说明理解整体实现。已有195人学习下载,说明该案例具备一定的参考价值。内容围绕客户端与服务器端通信展开,涵盖Socket连接与消息广播、多线程处理并发连接、用户注册登录与数据库校验、聊天记录存储以及多人聊天在线状态管理等关键环节,并保留调试与异常处理思路。读者可据此梳理聊天室从连接建立到消息分发的完整流程,学习网络通信、并发控制和数据持久化的组织方式,适合作为动手实践与查漏补缺的素材。
1. 需求先立住:为什么一个 Java 网络聊天室值得认真做
很多人做 Java 网络聊天室的第一反应,是打开教程抄一段ServerSocket+accept()的代码,跑通「服务端收一行、客户端显示一行」就交差。真到答辩现场被问一句「两个客户端同时发消息会怎么样」,程序当场卡死——这就是单线程阻塞模型的宿命。网络通信、多线程、数据库这三件事凑在一起,才构成一个能拿得出手的方案:Socket 决定消息怎么在网络里跑,多线程决定成百上千的连接怎么互不拖累,数据库决定聊天记录、用户账号、离线消息这些「有状态」的东西存在哪。这套方案适合三类人:正在做 Java 课程设计需要一套完整结构的学生、准备毕设想找一个可扩展题目的同学,以及想把 BIO/NIO 模型吃透的初级后端。下面按需求、设计、实现、避坑、验收的顺序,把整套东西拆开讲。
2. 总体架构设计:三层职责划分与消息流转模型
2.1 从「一个 accept 循环」升级到三层结构
单文件版的聊天室最典型的结构是:main方法里一个while(true)不断accept(),拿到 Socket 后直接在当前线程读数据。它只能服务一个人,第二个人连上后必须等第一个人断开。合格的方案要做的第一件事,是把「接入」「连接管理」「业务处理」三件事拆开。
我一般把服务端切成三层:
- 接入层:负责监听端口、接受连接、把 Socket 交给连接管理器。这一层只做搬运,不做业务。
- 连接管理层:维护一个在线客户端集合(线程安全),负责连接的上线与下线、心跳检测、消息广播的遍历。
- 业务层:负责登录鉴权、私聊路由、群聊分发、消息落库、离线消息补发。
这样分层以后,每个类的职责足够窄。接入层改端口不影响业务,业务层换存储不影响通信。客户端那边同理,拆成「网络收发」「界面刷新」「本地缓存」三块,UI 线程绝对不能直接读 Socket,否则界面会跟着堵塞。
2.2 消息协议设计:一条消息到底长什么样
裸 Socket 传的是字节流,TCP 只保证字节顺序,不保证「一次 read 就是一条完整消息」。如果客户端发hello,服务端read到的可能是hel,下一次才是lo。这是新手最容易翻车的地方之一,也是必须自己定协议的原因。
我采用的方案是「长度前缀 + JSON 载荷」,即每条消息先写 4 个字节的 int(表示 body 长度),再写 body 的 UTF-8 字节。body 用 JSON,字段固定:
{ "type": "PRIVATE", "from": "1001", "to": "1002", "content": "在吗", "timestamp": 1710000000000, "token": "登录后下发的会话凭证" }type枚举我通常定这几个:LOGIN、LOGOUT、PUBLIC(群聊)、PRIVATE(私聊)、HEARTBEAT(心跳)、HISTORY_REQ(拉历史记录)、HISTORY_RESP、ERROR。字段token只在登录后携带,用于服务端校验身份,避免客户端伪造from。
为什么不用 Java 原生序列化?因为它把类结构绑死在两端,客户端换个版本就InvalidClassException,而且体积大、不可读、有安全风险。JSON 加长度前缀实现简单、跨语言、抓包可读,调试成本低得多。
2.3 线程模型选型:BIO + 线程池,还是 NIO + Selector
这是整个方案里最需要想清楚的一次选型。三种常见做法:
| 模型 | 实现复杂度 | 单机承载连接 | 适用场景 |
|---|---|---|---|
| 单线程 BIO | 极低 | 1 | 教学演示,不能交付 |
| BIO + 线程池 | 低 | 数百到一千左右 | 课程设计、毕设首选 |
| NIO + Selector | 高 | 数千以上 | 想拿高分或做进阶 |
课程设计和毕设,我建议直接上BIO + 线程池。理由是:逻辑直观,每个连接一个线程,调试时断点打得明白;线程池控制住线程总数,不至于一千个连接就一千个线程把内存吃穿;代码量在可控范围内,答辩时讲得清楚。NIO 的Selector加ByteBuffer的写法,OP_READ半包处理这些细节,没有足够时间打磨反而容易出隐性 bug。
线程池用两个:一个负责处理新连接(或者主线程 accept 后直接 submit),一个负责处理已连接客户端的数据读写。核心参数按「连接数上限 × 1.2」估,队列用有界队列,拒绝策略用CallerRunsPolicy,压力大时让 accept 线程自己跑任务,天然形成反压,比直接抛异常把连接丢掉要好。
提示:线程数量不是越多越好。一个连接一个线程时,每个线程默认栈约 1MB,几百个连接就是几百 MB 虚拟内存。线程池的核心数要按实际压力测,别照抄网上「200 个线程」的参数。
2.4 数据库设计:账号、消息、在线状态三张核心表
聊天室一旦要记录历史消息和用户账号,就必须有库。我用 MySQL,三张主表加一张关系表:
-- 用户表 CREATE TABLE t_user ( user_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT 'SHA-256 加盐后的密文', nickname VARCHAR(32) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 消息表:群聊 to_id 为 0,私聊存接收方 id CREATE TABLE t_message ( msg_id BIGINT PRIMARY KEY AUTO_INCREMENT, from_id BIGINT NOT NULL, to_id BIGINT NOT NULL DEFAULT 0, msg_type TINYINT NOT NULL COMMENT '1群聊 2私聊', content VARCHAR(1000) NOT NULL, send_time DATETIME(3) NOT NULL, INDEX idx_to_time (to_id, send_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 离线消息表:用户不在线时先落库,上线后拉取 CREATE TABLE t_offline ( id BIGINT PRIMARY KEY AUTO_INCREMENT, to_id BIGINT NOT NULL, msg_id BIGINT NOT NULL, delivered TINYINT DEFAULT 0, INDEX idx_to_delivered (to_id, delivered) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;t_message上的idx_to_time联合索引是为「拉取某人和某人的最近 N 条聊天记录」这个查询准备的,走to_id过滤再按send_time倒序,避免全表扫。离线消息单独建表而不是在消息表加标记位,原因是离线消息的读写频率远高于普通历史消息,单独拆表可以让热点表和冷数据表互不干扰,清理也更方便——投递成功后直接按id批量物理删除,不会让主消息表膨胀。
3. 服务端核心实现:连接管理、消息路由与线程安全
3.1 连接注册中心:用一个并发容器管住所有 Socket
所有在线连接必须有一个统一的注册中心。我的写法是维护一个ConcurrentHashMap<Long, ClientSession>,key 是用户 id,value 封装了 Socket、输入输出流、最后心跳时间和用户基本信息。
public class SessionManager { // 用户 id -> 会话,线程安全 private static final ConcurrentHashMap<Long, ClientSession> SESSIONS = new ConcurrentHashMap<>(); public static void online(ClientSession session) { ClientSession old = SESSIONS.put(session.getUserId(), session); if (old != null) { // 同一账号重复登录:踢掉旧连接,避免一个账号两处在线导致消息错乱 old.close(); } } public static void offline(Long userId) { SESSIONS.remove(userId); } public static void broadcast(String json) { for (ClientSession s : SESSIONS.values()) { s.send(json); // send 内部加锁,见下文 } } }这里三个设计点值得说明。第一,用ConcurrentHashMap而不是HashMap,因为广播时遍历和对新连接的写入是并发发生的,普通HashMap遍历中被打断会抛ConcurrentModificationException。第二,put的返回值处理重复登录,这是实际项目里一定会遇到的问题:用户手机和电脑同时登录,必须明确规则,我选择「后登录踢前一个」。第三,broadcast遍历时如果某个连接写失败,不能中断整个广播,send方法内部要 catch 掉异常并触发该连接下线,否则一个人掉线全场收不到消息。
ClientSession.send()必须加锁,因为群聊广播和多线程私聊转发可能同时对同一个 Socket 的OutputStream写入。两个线程同时写,字节流会交错,客户端解出来的 JSON 直接坏掉。解决办法是给每个 session 一个synchronized或ReentrantLock,把「写长度 + 写 body + flush」作为一个原子块。
3.2 消息分发器:一个 switch 撑起全部业务分支
连接读线程收到一条完整消息后,交给分发器按type路由。它是一个典型的分发结构,代码不复杂,但每个分支的边界条件必须写全。
public void dispatch(ClientSession session, Message msg) { switch (msg.getType()) { case LOGIN: // 校验用户名密码,成功则注册会话并补发离线消息 if (userService.auth(msg.getUsername(), msg.getPassword())) { session.bindUser(userService.findByName(msg.getUsername())); SessionManager.online(session); offlineService.pushOffline(session); } else { session.send(Message.error("账号或密码错误")); } break; case PUBLIC: // 群聊:先落库再广播,保证历史记录不丢 long msgId = messageService.save(session.getUserId(), 0L, 1, msg.getContent()); SessionManager.broadcast(Message.publicMsg(session.getUserId(), msg.getContent(), msgId)); break; case PRIVATE: // 私聊:在线直接转发,离线落离线表 long pid = messageService.save(session.getUserId(), msg.getToId(), 2, msg.getContent()); ClientSession target = SessionManager.get(msg.getToId()); if (target != null) { target.send(Message.privateMsg(session.getUserId(), msg.getContent(), pid)); } else { offlineService.save(msg.getToId(), pid); } break; case HEARTBEAT: session.touch(); // 刷新最后活跃时间 break; case HISTORY_REQ: session.send(messageService.queryHistory(session.getUserId(), msg.getToId(), msg.getLimit())); break; default: session.send(Message.error("不支持的消息类型")); } }顺序上有个关键取舍:先落库再转发,而不是先转发再异步落库。前者在服务端崩溃时会丢最新几条消息,但不会出现「对方收到了、历史记录里没有」的不一致;后者吞吐略高,却可能让两个人看到的记录不一样。聊天室的场景里一致性优先,我选前者。如果将来量大了,再改成先写消息队列、由消费端落库并广播,那是另一种架构了。
HISTORY_REQ的limit参数必须在服务端做上限校验,比如最多 100 条,不能让客户端传 100000 把数据库拖死。
3.3 心跳与断线检测:怎么知道对面已经悄悄走了
TCP 连接的「断开」有两类:一类是客户端正常调close(),服务端read返回 -1,能感知;另一类是客户端断电、拔网线、进程被杀,服务端完全不知道,线程会一直阻塞在read上,连接泄漏。这就是聊天室必须做心跳的原因。
我的做法是:客户端每 30 秒发一条HEARTBEAT,服务端touch()更新lastActiveTime;服务端起一个单线程的定时任务,每 60 秒扫一遍SESSIONS,把now - lastActiveTime > 90s的会话强制关闭并从注册中心移除。
ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor(); cleaner.scheduleAtFixedRate(() -> { long now = System.currentTimeMillis(); for (ClientSession s : SessionManager.all()) { if (now - s.getLastActive() > 90_000L) { s.close(); // close 内部会触发注销 SessionManager.offline(s.getUserId()); } } }, 60, 60, TimeUnit.SECONDS);三个阈值的关系要理清:心跳间隔 30 秒 < 扫描周期 60 秒 < 超时阈值 90 秒。如果超时阈值比扫描周期还小,会出现「刚判超时,下一条心跳就到了」的误杀;如果心跳间隔接近超时阈值,网络稍抖就掉线。建议心跳间隔取超时阈值的 1/3。
注意:
close()里要同时关闭 Socket 和从注册中心移除,并且要幂等。定时任务和读线程可能同时调用它,重复关闭不能抛异常。
3.4 离线消息投递:上线那一刻补齐所有欠账
离线消息的处理分写入和投递两段。写入发生在私聊时对方不在线;投递发生在用户登录成功后,由offlineService.pushOffline(session)完成。
实现上要注意两点。一是投递顺序必须按id升序,否则用户看到的消息时间线是乱的。二是投递完成后要标记delivered = 1或者直接删除,不能重复推。我倾向「先查询未投递列表 → 逐条发送 → 发送成功后批量更新或删除」,中间某条发送失败就停下,下次登录重试,保证至少一次送达。客户端那边要做消息去重,用msg_id判重,防止重试导致的重复显示。
4. 客户端与通信可靠性:UI 与 Socket 的边界怎么划
4.1 收发分离:UI 线程永远不碰 Socket
客户端如果用 Swing 或 JavaFX,最大的坑是「在按钮点击事件里直接read()」。网络一卡,整个界面白屏无响应,用户以为程序死了。正确做法是两条线程或两个线程池:
- 发送线程池:界面触发发送后,把消息丢进队列,由后台线程真正写 Socket。
- 接收线程:一个独立的
while循环持续阻塞读,读满一条完整消息就通过SwingUtilities.invokeLater()或Platform.runLater()交回 UI 线程刷新。
// 接收线程:只负责读和派发,不直接改界面 public void receiveLoop() { try (DataInputStream in = new DataInputStream(socket.getInputStream())) { while (running) { int len = in.readInt(); // 先读 4 字节长度 if (len <= 0 || len > 1024 * 1024) { throw new IOException("非法长度: " + len); } byte[] body = new byte[len]; in.readFully(body); // 必须 readFully,一次 read 可能读不全 Message msg = JSON.parseObject(new String(body, StandardCharsets.UTF_8), Message.class); SwingUtilities.invokeLater(() -> ui.onMessage(msg)); // 切回 UI 线程 } } catch (IOException e) { SwingUtilities.invokeLater(() -> ui.onDisconnected(e.getMessage())); } }readFully和read的区别值得单独说:read(byte[])返回值可能小于数组长度,只读了一半就返回,剩下的字节还在内核缓冲区。用readFully才会阻塞到读满为止。这个细节不做对,消息内容稍微长一点就会解析失败,而且报错位置很随机,极难复现。长度上限校验也是必须的,防止对端发一个Integer.MAX_VALUE让你new byte[]直接OutOfMemoryError。
4.2 断线重连:一个带退避的重连状态机
真实网络会断。客户端要有重连机制,但不能「一断就死循环重连」,那会把服务端打挂。我一般用指数退避:首次 1 秒,之后 2、4、8 秒,上限 30 秒,重连成功后重置。
private void reconnectLoop() { int delay = 1; while (!connected && retry < MAX_RETRY) { try { Thread.sleep(delay * 1000L); connect(); // 内部重新建 Socket、发 LOGIN delay = 1; // 成功则重置退避 } catch (Exception e) { retry++; delay = Math.min(delay * 2, 30); // 指数退避,封顶 30 秒 } } }重连成功后要重新发LOGIN,并且主动拉一次离线消息和历史记录,把断线期间的消息补齐。如果服务端会话是按 token 维持的,重连时带上原 token 可以避免重复登录被踢。
4.3 消息顺序与去重:客户端也要有兜底
服务端保证了写入顺序,但网络传输、重连补发都可能让客户端收到重复消息或乱序消息。客户端的做法是维护一个最近收到的msg_id集合(可以用LinkedHashSet保留最近 500 个),收到消息先判重再展示,同时按send_time和msg_id做一次稳定排序后再渲染消息列表。这套兜底在演示时往往看不出来,但真实网络环境下能避免大量「同一条消息出现两次」的提问。
5. 常见问题排查:这个项目最容易翻车的五个点
5.1 广播时抛 ConcurrentModificationException
现象:群聊消息一发,服务端日志里偶尔冒ConcurrentModificationException,部分人收不到消息。
原因:广播用普通HashMap遍历,遍历过程中有新用户上线执行了put,或者有人掉线执行了remove,集合结构被修改。
解决:换成ConcurrentHashMap,遍历时用它的弱一致性迭代器;或者在广播前把values()复制成一个ArrayList再遍历,代价是一次小内存分配,但绝对安全。
5.2 客户端收到的 JSON 解析失败,且时好时坏
现象:短消息正常,稍长的中文消息偶尔报 JSON 解析异常,重启后又能跑。
原因:两个问题叠加。一是用了read(byte[])而不是readFully,半包被当成完整消息;二是中文 UTF-8 占 3 字节,长度按字符算而不是按字节算,长度前缀和实际 body 对不上。
解决:长度前缀统一按byte[]的字节数计算,body.getBytes(StandardCharsets.UTF_8).length;读取用DataInputStream.readFully。写完这两条,这类问题基本绝迹。
5.3 关闭窗口后服务端线程不释放,连接数越测越多
现象:反复开关客户端,服务端线程数只增不减,最后OutOfMemoryError: unable to create new native thread。
原因:客户端直接关窗口没调socket.close(),服务端读线程一直阻塞在read上;服务端也没有心跳超时清理。
解决:客户端加窗口关闭监听,close()前先发LOGOUT;服务端心跳清理任务必须实现,别嫌麻烦跳过。压测时用jstack看线程栈,阻塞在socketRead0的就是没释放的。
5.4 数据库连接在并发下被打满
现象:几个人同时发消息,服务端报Too many connections,或者响应突然变慢几秒。
原因:每次发消息都DriverManager.getConnection()新建连接,用完不关或关得慢;没有连接池。
解决:引入连接池(HikariCP 或 Druid),最大连接数按「并发写消息的线程数 + 余量」配,比如 10~20。所有 DAO 用 try-with-resources 保证释放。另外消息落库不要放在持有 session 锁的代码块里,否则一次慢 SQL 会卡住整个广播。
5.5 同一账号两处登录导致消息错乱
现象:A 在两端登录,B 发私聊给 A,只有一端收到,或者两端都收到但历史记录只有一条。
原因:注册中心 key 是用户 id,后登录的put覆盖了前一个,但前一个连接的读线程还在跑,仍会往旧 Socket 写。
解决:put时检查旧值,旧连接主动close()并置一个kicked标记,让旧连接的读循环检测到标记后退出。同时在协议层明确规则:后登录踢前一个,并在被踢端弹一个提示。
6. 验收与答辩:怎么证明这套聊天室真的能用
功能验收不要只演示「两个窗口互发一句你好」,那只能证明最理想路径通了。我通常准备一张验收清单,逐条跑:
| 验收项 | 操作方式 | 通过标准 |
|---|---|---|
| 多客户端并发 | 同时开 20 个客户端登录 | 全部在线,群聊消息无丢失、无重复 |
| 私聊在线转发 | A 私聊 B,B 在线 | B 实时收到,双方历史记录一致 |
| 私聊离线补发 | B 离线时 A 私聊,B 再登录 | 登录后自动收到,顺序正确 |
| 断线重连 | 手动断网 10 秒再恢复 | 自动重连,补齐断线期间消息 |
| 心跳清理 | 强杀一个客户端进程 | 90 秒内服务端移除该会话 |
| 重复登录 | 同账号两端登录 | 旧端被踢,新端正常 |
| 压力粗测 | 用脚本模拟 100 连接持续发消息 | 服务端线程数稳定,无 OOM |
答辩时最容易被追问的三个问题,提前准备好答案:为什么选 BIO 不选 NIO(回答:连接规模在千级以内,BIO 加线程池代码可维护性更高,NIO 的复杂度在这个规模下是负收益);消息为什么先落库再转发(回答:一致性优先,避免两端记录不一致);心跳为什么是 30 秒(回答:取超时阈值的 1/3,兼顾及时性和误判率)。把这三条讲清楚,基本就能说明你做过取舍、不是抄的。
还有个演示技巧:提前用脚本灌 500 条历史消息和一个离线用户,现场演示「登录即补发」的效果,比空库演示有说服力得多。压测数据也提前跑好,截图存着,现场只跑小规模,避免网络抖动翻车。
这套东西我从第一次写聊天室把服务端写成单线程、答辩时被问得哑口无言之后,就形成了一个习惯:任何网络程序,先把「连接生命周期」画出来——怎么建、怎么判活、怎么断、断了怎么补——这四个问题答不上来,代码写得再漂亮也是纸糊的。希望这套结构能帮到你,少走我当年那段弯路。
本文还有配套的精品资源,点击获取