简介:这是面向高校或培训机构的Java课程设计资源,适合正在学习网络编程与桌面应用开发的学生参考。项目实现了一个简易多人聊天系统,覆盖用户注册登录、创建与加入聊天室、实时文本收发等核心功能,并兼顾安全传输与数据存储思路,能够帮助读者快速理解客户端/服务器架构下的聊天应用实现方式。压缩包共16个文件,以Java源码为主,包含5个java源文件及对应的class编译文件,另附Eclipse工程配置、项目属性及聊天配置等文件,整体仅28KB,结构紧凑便于导入开发环境。目前已有97人学习下载。对初学者而言,可以借此对照源码学习Socket通信、多线程消息分发、Swing界面构建以及数据库连接等关键知识点,同时通过配置文件快速还原工程结构,是理解Java网络编程流程的一份轻量级实践样本。
1. 从课程设计到可运行聊天室:这个 Java 多人聊天系统到底解决了什么
某高校的网络编程课交作业的前一晚,最常见的题目就是基于 JAVA 的简易多人聊天系统。名字叫简易,其实该碰的东西一样没少:一个 ServerSocket 挂在指定端口,若干个 Socket 客户端连上来,服务端把消息广播给所有人,客户端再用 Swing 做个能打字、能看到别人说话的窗口。它是网络编程几大核心的浓缩:TCP 连接怎么建立、多线程怎么处理并发、网络 IO 和界面刷新怎么不互相卡死。适合刚学完 Java 基础、想拿完整项目练手的学生,也适合实训周需要快速搭一个能演示项目的开发者。下文按我自己的拆解顺序,把架构、转发、踩坑和验证一路过完。
2. 并发模型与 TCP 选型:一个 ServerSocket 怎么扛住十几个客户端
2.1 为什么选 Socket 而不是 DatagramSocket
聊天室场景有一个硬要求:消息不能丢、顺序不能乱。A 先说“你好”,B 后说“在吗”,C 那边显示的顺序必须是“你好”在前。
TCP 的字节流天然保证这一点,服务端只需要按顺序 readLine,客户端按顺序 readLine,顺序就不会乱。UDP 的 DatagramSocket 是数据报,不保证到达顺序,也不保证不丢,真要拿它做群聊,得自己实现确认重传、序列号排序、超时重发,等于把一个完整的 TCP 重新造一遍。课程设计阶段这么干,不是炫技,是给自己挖坑。
| 对比项 | TCP (Socket) | UDP (DatagramSocket) |
|---|---|---|
| 可靠性 | 有确认重传,保证顺序 | 不保证,可能丢包乱序 |
| 连接状态 | 服务端能感知断开 | 无连接状态,只能靠超时猜 |
| 服务端复杂度 | 需要维护每个连接 | 一个 socket 收包即可 |
| 适合本项目 | 群聊要求不丢不乱 | 语音、游戏同步类场景 |
答辩时如果被问“为什么不用 UDP”,这个对比表就够回答了:聊天系统的正确性优先,TCP 把复杂部分包掉了。
2.2 accept 主循环与线程池参数
服务端的核心是一个死循环:server.accept() 阻塞在那里,等一个新连接进来,然后把连接交给一个工作线程去处理。
ExecutorService pool = Executors.newFixedThreadPool(10); ServerSocket server = new ServerSocket(8080); while (true) { Socket socket = server.accept(); // 阻塞,直到有客户端连进来 pool.execute(() -> handleClient(socket)); // 新连接交给池中的一个线程 }先看两个参数:8080 是监听端口,可以任意换,但要注意避开 8080、3306、5432 这类已经被 Tomcat、MySQL、PostgreSQL 常用的端口,我一般习惯用 8787 这样不常撞车的端口,后面避坑章会细说。线程池固定 10 个线程,含义是同一时刻最多只有 10 个连接在被处理,第 11 个连接会进入队列排队。教室实训二三十个节点这种规模,10 是够用的;如果目标改成线上几百人,固定线程池就不合适了,得换 NIO 或 Netty 那一套。
提示:newFixedThreadPool 的队列是无限队列,如果连续来几百个连接,任务排队会吃掉大量内存。教学场景不需要纠结,但注释里建议写清“最多同时服务 10 个连接”。
2.3 数据流按行切分:println 与 readLine 的配对
TCP 是字节流,它不知道“消息”在哪里结束。最简单的做法是约定一个边界:每个消息以换行符结尾。
// 写端 PrintWriter out = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); out.println("这是一条完整消息"); // 自动追加换行并 flush // 读端 BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), "UTF-8")); String line = in.readLine(); // 读到一个以换行结尾的完整行写端用了 true 表示自动 flush,println 后数据立刻发出去,不会积在缓冲区里。读端用 readLine 一次消费一行,正好和 println 配对。常见错误是写端按行写、读端却用 read(byte[]) 按字节读,结果一次读到的可能是半个消息,也可能是好几条消息挤在一起,这种时好时坏的问题最难排查。字符集我固定传 "UTF-8",两侧保持一致,Windows 默认 GBK 的坑就绕开了。
3. 把转发逻辑跑通:在线表、广播循环与客户端收发
3.1 广播循环:ConcurrentHashMap + checkError 双保险
服务端需要记住所有在线客户端的输出流,来一条消息就逐个转发。在线表我用 ConcurrentHashMap 而不是 HashMap,原因很直接:多个线程同时在往里放、往外取,HashMap 在并发修改时会抛 ConcurrentModificationException,而 ConcurrentHashMap 是并发安全的。
private static final Map<String, PrintWriter> clients = new ConcurrentHashMap<>(); private static void broadcast(String from, String content) { String msg = from + ": " + content; List<String> dead = new ArrayList<>(); for (Map.Entry<String, PrintWriter> entry : clients.entrySet()) { PrintWriter w = entry.getValue(); w.println(msg); w.flush(); if (w.checkError()) { dead.add(entry.getKey()); // 写不进去,说明这个连接已经断了 } } // 统一移除坏连接,而不是在遍历时直接 remove for (String nick : dead) { clients.remove(nick); } }这里有两个关键设计。第一,不直接在 for 循环里 remove,因为一边遍历一边删容易出语义混乱,宁可先把坏连接名收进 dead 列表,循环结束后再做移除。第二,不用捕获异常的方式判断断连,因为 PrintWriter 内部把 IOException 吞掉了,写失败不会抛异常,只能靠 checkError 拿到错误状态。每次广播后检查一次,坏连接会被自动清理,不会越积越多。
3.2 昵称注册与重名处理
客户端连上来后第一件事是报昵称,服务端把昵称和输出流绑定。这里有个边界要处理:昵称重复。
String nick = in.readLine(); if (nick == null) { return; // 客户端什么都没发就关了 } if (clients.containsKey(nick)) { out.println("SYSTEM|昵称已被占用,换一个再来"); return; } clients.put(nick, out);containsKey 和 put 之间存在一个时间窗,两个线程同时抢注同一个昵称时可能双双通过检查,这是并发界面的典型竞态。想让实现更严谨,把 put 换成 putIfAbsent:
PrintWriter old = clients.putIfAbsent(nick, out); if (old != null) { out.println("SYSTEM|昵称已被占用,换一个再来"); return; }putIfAbsent 是原子的,如果返回非空,说明有人先注册了这个名字。
3.3 Swing 客户端:读消息必须单独开线程
客户端不能把网络读取塞在界面线程里。Swing 的组件刷新只能由事件分发线程(EDT)执行,而 readLine 是阻塞的,如果直接写在主线程里,窗口一打开就卡死。
private void startReadThread() { new Thread(() -> { String line; try { while ((line = in.readLine()) != null) { final String msg = line; // 跨线程更新组件,必须交给 EDT SwingUtilities.invokeLater(() -> chatArea.append(msg + "\n")); } } catch (IOException e) { SwingUtilities.invokeLater(() -> chatArea.append("连接已断开\n")); } }).start(); }invokeLater 的作用是把“更新聊天区”这个动作排到 EDT 的事件队列里,等 EDT 有空了再执行,这样网络线程只管读,界面线程只管画,互不阻塞。这条规则是 Swing 程序的底线,违反它几乎必卡。
3.4 发送消息:按钮与回车共用入口
发送侧相对简单,按钮监听和回车键监听都调同一个 send 方法:
sendBtn.addActionListener(e -> sendMsg()); inputField.addActionListener(e -> sendMsg()); // 输入框回车也能发 private void sendMsg() { String text = inputField.getText().trim(); if (text.isEmpty()) { return; // 空消息不发 } out.println(text); inputField.setText(""); }注意 PrintWriter 的方法内部有同步,跨线程调用是安全的,发送侧不需要额外加锁。trim 掉前后空格可以避免用户手滑按出一个空格当消息发出去。
4. 避坑排查:端口占用、粘包错乱与界面假死
4.1 第二次启动报 Address already in use
现象:服务端改了点代码,重启,控制台抛出 java.net.BindException: Address already in use,监听端口起不来。
原因:上一次运行的进程没有完全退出。在 IDE 里点停止,有时只是断开了调试器,JVM 进程还留在后台;还有一种情况是程序里没有调用 server.close(),ServerSocket 处于 TIME_WAIT 状态,端口短时间内不被释放。
解决:开发期先用命令确认端口被谁占了。Windows 下在命令行执行 netstat -ano | findstr 8787,能看进程 PID,再用 taskkill /pid /f 干掉旧进程;Linux 下用 lsof -i:8787 查,kill -9 结束它。代码层面,给服务端加一个 shutdownHook 做兜底:
Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { if (server != null) server.close(); } catch (IOException ignored) {} }));从那以后我每次跑服务端之前,先看一眼端口是干净的再启动,这个习惯省了非常多时间。
4.2 消息粘成一团:println 和 readLine 没配对
现象:客户端界面上,两条消息粘成一行,比如“alice: 你好 bob: 在吗”,或者一条消息被截成两半,时好时坏。
原因:写端用了 PrintWriter.println,但读端没用 BufferedReader.readLine,而是用 read(byte[]) 按字节读缓冲,一次 read 返回的字节数不固定,可能一次读半条,也可能一次读两条。
解决:两端统一按行消费。写端必须 println,读端必须 readLine。如果有人从旧代码复制了一段字节读取逻辑,直接替换掉。排查的时候,在服务端收到消息后先打印一次原文,看是否能按行切分,能快速定位是哪一端破坏了约定。
4.3 客户端窗口转圈假死
现象:连上服务端,窗口正常显示,但发两条消息后整个界面无响应,转圈,点哪都没反应。
原因:网络读取循环被放到了事件线程中执行。最典型的是在初始化方法里直接写了 while ((line = in.readLine()) != null) 然后更新 chatArea,readLine 阻塞时整个 EDT 全部停摆,按钮、输入框、重绘全部失效。
解决:网络读取必须单独建线程,而且更新组件时必须用 SwingUtilities.invokeLater 转回 EDT,不能在线程里直接调用 chatArea.append。这里没有例外,别图省事。
4.4 一个用户断开,其他用户跟着遭殃
现象:某个客户端被强杀或拔网线,服务端控制台疯狂刷 Connection reset,其他客户端也收不到新消息了。
原因:这个项目里有两个连锁问题。第一,广播循环时向已断开的连接写数据,写失败后没有清理,坏连接一直留在在线表里;第二,如果在线表用的是 HashMap,多个线程同时在广播和移除条目,遍历时触发 ConcurrentModificationException,异常一抛,广播线程直接死掉。
解决:在线表换 ConcurrentHashMap,广播时写完立刻 checkError,把坏连接收进 dead 列表统一移除。这样断线的连接最多影响自己,不会把整个广播循环拖垮。拔线测试是必做的,答辩时老师一定会问“某个客户端掉线了,服务端崩不崩”。
4.5 中文乱码:Windows 下全是问号
现象:服务端在 Windows 上跑,客户端也是 Windows,互相发的英文正常,中文显示成一串问号或乱码。
原因:两端默认字符集不一致。Windows 控制台默认 GBK,而代码里 new InputStreamReader(inputStream) 没指定字符集,用的是系统默认值;一旦有一端改了 UTF-8,另一端还按 GBK 解码,中文就乱了。
解决:所有字符流转接点统一写成 new InputStreamReader(socket.getInputStream(), "UTF-8"),输出同理。编译时 IDE 里把项目编码设为 UTF-8,避免 .java 文件本身存成 GBK。这个小问题看着不起眼,经常是“代码照着敲了一遍但就是乱码”,排查半小时最后发现是字符集没统一。
5. 消息协议与心跳:把“能聊”升级成“聊得稳”
5.1 为什么要定义消息格式
到第 3 章为止,消息还是“昵称 + 冒号 + 内容”的自由文本。自由文本能聊,但服务端没法区分这条消息是普通聊天、私聊还是系统通知,也没法做“在线列表”这类功能。想让系统再往前走一步,就得把消息格式从“一段话”变成“结构化的一条记录”。
我习惯用竖线分隔的纯文本协议,简单、肉眼能读、调试方便。每条消息有四个字段:
| 字段 | 含义 | 示例 |
|---|---|---|
| type | 消息类型 | CHAT / PRIVATE / SYSTEM / PING |
| from | 发送方昵称 | alice |
| target | 接收方昵称(公开聊天为空) | bob |
| payload | 消息正文 | 你好,bob |
对应的一条私聊消息长这样:PRIVATE|alice|bob|这条消息只有 bob 能看到。公开聊天是 CHAT|alice||大家好,target 字段留空。
5.2 协议解析:split 里的正则坑
解析竖线分隔的消息,代码要小心一个坑:split 的参数是正则表达式,而竖线在正则里是“或”的意思,必须转义。
String[] parts = line.split("\\|", -1); if (parts.length < 3) { return; // 格式不对,直接丢弃 } String type = parts[0]; String from = parts[1]; String target = parts.length > 2 ? parts[2] : ""; String payload = parts.length > 3 ? parts[3] : ""; switch (type) { case "CHAT": broadcast(from, payload); break; case "PRIVATE": sendPrivate(from, target, payload); break; case "PING": lastActive.put(from, System.currentTimeMillis()); break; default: // 未知类型,忽略或打日志 }split 的第二个参数 -1 表示保留尾部空字段,否则 "CHAT|alice||你好" 这种 payload 前有空字段的分隔数据,尾部的空 target 会被丢掉,字段位置全错。协议字段越少越好,这个四字段模型能覆盖公开聊天、私聊、系统通知、心跳四类消息,课程设计够用。
5.3 心跳与超时踢人:区别“静默”和“掉线”
TCP 断开时服务端通常会感知到,但有一种情况是感知不到的:客户端所在网络断开,或者程序直接卡死,连接没有正常关闭,服务端这边还守着一条半死的连接。
解决办法是心跳。客户端每隔 20 秒发一条 PING|alice,服务端收到后更新这个用户的 lastActive 时间;服务端另起一个调度线程,每 30 秒扫一次在线表,超过 90 秒没有活动记录的连接,判定为掉线,强制移除。
private static final Map<String, Long> lastActive = new ConcurrentHashMap<>(); private static final long TIMEOUT = 90_000L; // 90 秒无心跳视为离线 ScheduledExecutorService sweeper = Executors.newSingleThreadScheduledExecutor(); sweeper.scheduleAtFixedRate(() -> { long now = System.currentTimeMillis(); for (Map.Entry<String, Long> entry : lastActive.entrySet()) { if (now - entry.getValue() > TIMEOUT) { String nick = entry.getKey(); clients.remove(nick); broadcast("SYSTEM", nick + " 因长时间无响应被断开"); lastActive.remove(nick); } } }, 30, 30, TimeUnit.SECONDS);三个参数要联动理解:20 秒发一次心跳,30 秒扫一次,90 秒超时。最坏情况下,一条死连接要 20 秒心跳停止 + 最多 30 秒扫描延迟,加起来约 50 秒被踢掉,不会出现“人早跑了还占着在线表”的情况。调度线程命名为 sweeper,起到的是哨兵角色,它不处理业务消息,只负责清理。
5.4 加了协议才算“能答辩”
很多课程设计做完 3.3 章就停了,能连、能聊、能断开,演示没问题。但答辩老师通常会追问:你如何区分聊天消息和系统通知?如何实现私聊?如何处理离线用户?这三个问题没有协议设计根本答不上来。加了消息类型字段后,回答路径非常清晰:CHAT 走广播,PRIVATE 走单发,PING 负责保活,SYSTEM 来自服务端。整个系统从“一个字符串反复横跳”变成了“一套可扩展的消息框架”,后续想加图片传输、加 emoji、加敏感词过滤,都只是往协议里加字段的事。
6. 最后一层验证:telnet 冒充客户端,把广播与断线行为过一遍
改完消息协议后,我最常做的一件事不是打开 GUI,而是先起一个 telnet 窗口,手动把报文一行行敲进去。这样能直接看到服务端对每条消息的响应,绕过 Swing 界面,问题定位特别快。
# 终端 1:启动服务端 java -cp out ChatServer # 终端 2:模拟第一个客户端 alice,连接后输入昵称 telnet 127.0.0.1 8787 alice # 终端 3:模拟第二个客户端 bob telnet 127.0.0.1 8787 bob # 回到终端 2,发一条公开聊天,注意格式 CHAT|alice||你好 # 终端 3 应该立刻看到 alice 的消息telnet 的界面没有回显,输入时看不见自己敲了什么,回车后消息才发出去,这是正常现象。验证要点有三条:第一条,终端 3 能看到 alice 发的公开消息,说明广播链路通;第二条,敲一条格式错误的报文如 HELLO|abc,服务端不崩,说明解析防御有效;第三条,直接把终端 2 关掉,终端 3 还能正常收发,说明断线清理没有拖垮全局。
之后再看服务端日志,把连接建立、昵称注册、消息广播、断线移除几个节点按时间顺序过一遍,基本就能确认系统状态。如果开了心跳功能,可以等 90 秒左右看那个静默连接的清理日志,对比心跳参数是否按预期工作。
从那以后我每次改协议,都先开一个 telnet 窗口手动发一遍报文,再打开 GUI 对照,两步一比对,省下来的调试时间不是一点半点。日志里看到格式错误、断线清理、超时踢人这三类记录都在,我才会去动界面层。希望帮到你。
本文还有配套的精品资源,点击获取