简介:面向计算机相关专业毕业设计或课程设计的Java局域网聊天室系统完整项目,提供源代码与配套论文,涵盖客户端与服务端通信、用户界面和消息收发等典型功能,可作为选题参考与二次开发基础。资源包共239个文件,大小约14.13MB,以工程源码、头文件、编译中间文件及可执行程序为主,另含论文文档、界面图标、提示音和项目配置文件,类型覆盖开发、调试、运行与文档撰写各环节。项目内包含可直接运行的exe程序与完整源码,便于搭建演示环境、理解Socket通信原理,论文部分可为毕业设计文档提供结构参考。已有106人学习下载,适合需要快速完成课程报告或毕设项目的计算机学生参考使用,整体目录结构清晰,核心功能模块容易定位,便于直接演示或扩展修改。
1. 与其满网找能跑的源码,不如自己把 Java 局域网聊天室从零写到答辩
翻了一圈,十个“Java聊天室系统源代码”九个要么缺关键类、要么导入就报错,剩下那个界面比课程设计还丑,你敢拿它去答辩?说句实在话,“JAVA基于局域网的聊天室系统”这个选题正好卡在课程设计和毕业设计的甜区:难度不到高并发秒杀那一步,但也绝不是图书管理那样的纯增删改查。核心就四件事——Socket通信、多线程、Swing界面、消息协议,全是Java基础考点,论文也有素材写设计和测试。这篇笔记给一条能照着走完的路:先定通信模型,再写服务端和客户端,把局域网联机踩的坑一个个填平,最后说清答辩前的验证怎么做。适合正在赶Java课程设计或毕业设计、需要源代码加论文一起交差的同学。
2. 技术选型与通信模型:Socket、多线程和消息协议先定死
写代码第一步不是建类,而是把通信方式、线程模型、消息格式三件事钉死。很多同学一上来就拖两个类开始写,写到一半发现多客户端支持不了,又回头推翻重来。这个选型过程整理到论文里,正好构成系统设计章节。
2.1 为什么选 TCP Socket:UDP 和 WebSocket 对比
先想清楚“局域网聊天室”这个场景的本质:消息不能丢,顺序不能乱。文本聊天不像音视频通话,延迟几十毫秒没感觉,但一句话发出去对方没收到,或者两条消息顺序反了,体感直接就崩了。IP 报文在局域网里丢失概率很低,但“低”不意味着“没有”,不该把可靠性的赌注押在网络上。
TCP 面向连接,有确认、重传、按序到达的机制,天然匹配文本聊天。UDP 适合音视频和实时游戏同步,丢几个包无所谓,但用在聊天室上,你需要自己在应用层做消息编号和重传,工作量远超做一个聊天室本身。UDP 唯一省下的那点握手开销,在局域网场景下根本不构成差异。
至于 WebSocket,技术上能做聊天室,而且现在不少课程设计也喜欢往上靠。但它有两个麻烦:第一,Java 标准库不直接支持 WebSocket 协议,要引入 Tomcat 或 Netty 或专门客户端库;第二,课程设计提交时通常不附 IDE 和依赖包,老师换个环境要把 Maven 仓库拉一遍,离线和内网环境直接卡死。用 java.net.Socket 是零依赖方案,一个 JDK 自带,另一台机器编译就能跑,对你对老师都省事。
| 方案 | 连接模型 | 可靠性 | 依赖 | 适合这个题目吗 |
|---|---|---|---|---|
| TCP Socket | 面向连接 | 可靠字节流 | 无 | 最合适 |
| UDP Datagram | 无连接 | 需自己重传 | 无 | 不合适,工作量在这 |
| WebSocket | 面向连接 | 可靠,基于TCP | 需引入库 | 能行,但环境坑多 |
2.2 服务端线程模型:线程池比“一连接一线程”好在哪
聊天室要同时服务多个客户端,服务端 accept 到一个新连接后,必须让这个连接独立处理,不能在主线程里 readLine 阻塞住。最常见的教学写法是一个连接一个线程:
while (true) { Socket socket = serverSocket.accept(); new Thread(new ClientHandler(socket)).start(); }这段代码在课程设计十几个人联机时能跑,但有两个隐患。第一,线程创建和销毁有开销,客户端频繁登录退出时,服务端在不停地创建和回收线程,白白消耗资源。第二,如果某些连接长期不关又没有心跳,线程数只增不减,程序多跑几个小时就变得很卡。论文里写“采用线程池限制并发数,保护系统资源”,比“用多线程实现并发”强得多,老师会认为你考虑过生产环境的问题。
我一般用固定大小线程池:
ExecutorService threadPool = Executors.newFixedThreadPool(20); // 每来一个连接,交线程池处理,而不是自己 new Thread threadPool.execute(new ClientHandler(socket, this));固定 20 并发对局域网聊天室足够。如果你用 newCachedThreadPool(),空闲线程会被回收,吞吐上更灵活,但并发数不受控,不适合在这个项目里当默认选择。除了线程池,多个连接还会同时读写“在线用户”这个共享数据,这个集合必须用支持并发的容器,后面代码里我会统一用 ConcurrentHashMap。
2.3 消息协议设计:怎么定义一条消息才能不乱
很多项目死在消息格式上。用 DataInputStream/DataOutputStream 写自定义字节流,writeUTF 和 readUTF 稍微错位一次,整个流就乱了,还特别难调试。我的做法很朴素:所有消息都是一行 UTF-8 文本,字段之间用|分隔,以换行符作为消息结束。
这个方案配合 BufferedReader.readLine() 天然拆包,你不需要处理 TCP 粘包、半包问题。调试也方便,开着命令行窗口就能模拟客户端往服务端发数据。
协议定成 v1 后,文档长这样:
| 消息类型 | 发送方向 | 格式 | 说明 |
|---|---|---|---|
| 登录 | 客户端→服务端 | LOGIN|用户名 | 一个连接必须先发这行 |
| 群聊 | 客户端→服务端 | MSG|ALL|内容 | target 为 ALL 表示广播 |
| 私聊 | 客户端→服务端 | MSG|目标用户|内容 | 定向转发 |
| 系统通知 | 服务端→客户端 | SYSTEM|内容 | 上线、下线通知 |
| 心跳 | 客户端→服务端 | HEARTBEAT|用户名 | 每 30 秒发一次,保活 |
| 退出 | 客户端→服务端 | LOGOUT|用户名 | 主动下线 |
为什么协议里必须有心跳?这是我最早翻车的地方。客户端直接拔网线或关机,服务端不会收到任何挥手包,TCP 只在真正发送数据时才感知到对方已经消失。如果没有心跳包,在线用户列表里会残留一堆僵尸账号,服务端还傻傻地给死连接发广播。加心跳之后,服务端可以按“多久没收到数据”判断一个连接是否还活着。
心跳实现也不复杂:客户端开一个定时器,每 30 秒发一行 HEARTBEAT;服务端如果连续 60 秒没收到某个用户的消息,就主动断开这个连接。这 30 秒和 60 秒两个值不是随手写的,要在论文里提一句“阈值取心跳周期的两倍,避免网络抖动导致误杀”。
3. 服务端落地:连接管理、消息广播与异常退出
服务端骨架分两个类:ChatServer 负责监听端口、分配线程、维护在线用户表;ClientHandler 负责单个连接的消息循环和资源清理。分工清楚,代码和论文都好写。
3.1 服务端骨架:ServerSocket、线程池与连接账本
先看 ChatServer 完整代码,JDK 8 以上直接能编译运行:
import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ChatServer { public static final int DEFAULT_PORT = 8888; // 在线用户表:用户名 -> 连接处理器,必须支持并发 private ConcurrentHashMap<String, ClientHandler> onlineUsers = new ConcurrentHashMap<>(); private ExecutorService threadPool = Executors.newFixedThreadPool(20); public void start(int port) throws IOException { // backlog=50,accept 等待队列最多排 50 个连接 ServerSocket serverSocket = new ServerSocket(port, 50); System.out.println("聊天室服务端已启动,监听端口: " + port); while (true) { Socket socket = serverSocket.accept(); // 阻塞等待新连接 threadPool.execute(new ClientHandler(socket, this)); } } public void addUser(String username, ClientHandler handler) { onlineUsers.put(username, handler); } public void removeUser(String username) { onlineUsers.remove(username); } public ClientHandler getHandler(String username) { return onlineUsers.get(username); } public ConcurrentHashMap<String, ClientHandler> getOnlineUsers() { return onlineUsers; } public static void main(String[] args) throws IOException { int port = args.length == 1 ? Integer.parseInt(args[0]) : DEFAULT_PORT; new ChatServer().start(port); } }ServerSocket 构造函数的第二个参数是 backlog,表示内核 accept 队列最多容纳多少等待处理的连接。如果几十个客户端同时点连接,队列满了之后进来的连接会被直接拒绝。课程设计规模到不了 50,但写上去是保底,也显得你考虑过连接密度。
端口用命令行参数传进来是有意的设计。答辩现场如果 8888 被人占了,直接java ChatServer 9999换端口,不用改代码重新编译。很多同学把端口硬编码在代码里,现场换环境就手忙脚乱,这属于经验问题。
这里有个容易被忽略的坑:main 方法里 println 监听信息要在 serverSocket 创建之后,别放在创建之前。不然端口被占用时,你看到“已启动”但其实 bind 失败了。
3.2 消息分发逻辑:群聊广播与私聊转发的实现
ClientHandler 是服务端的核心处理单元。它从 socket 读取客户端发来的一行行消息,解析后调用 ChatServer 做转发。关键点有三个——登录校验、消息分发、异常清理。
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.net.Socket; import java.nio.charset.StandardCharsets; public class ClientHandler implements Runnable { private Socket socket; private ChatServer server; private BufferedReader in; private PrintWriter out; private String username; public ClientHandler(Socket socket, ChatServer server) throws IOException { this.socket = socket; this.server = server; // 统一 UTF-8,避免 Windows 下中文乱码 in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); } public void sendMessage(String message) { out.println(message); } public String getUsername() { return username; } @Override public void run() { try { // 第一条消息必须是 LOGIN,否则直接断开 String firstLine = in.readLine(); if (firstLine == null || !firstLine.startsWith("LOGIN|")) { sendMessage("SYSTEM|登录格式错误,连接即将关闭"); socket.close(); return; } username = firstLine.substring("LOGIN|".length()); server.addUser(username, this); server.broadcastSystem(username + " 加入了聊天室"); String line; while ((line = in.readLine()) != null) { if (line.startsWith("MSG|")) { String[] parts = line.split("\\|", 3); if (parts.length == 3) { server.broadcastMessage(parts[0], parts[1], parts[2]); } } else if (line.startsWith("HEARTBEAT|")) { // 心跳不需要回应,readLine 继续阻塞等待下一条就是保活 } else if (line.startsWith("LOGOUT|")) { break; } } } catch (IOException e) { System.out.println(username + " 连接异常: " + e.getMessage()); } finally { // 不管怎么退出,清理是必须的,不然线程和 socket 都会泄漏 server.removeUser(username); server.broadcastSystem(username + " 离开了聊天室"); closeQuietly(); } } private void closeQuietly() { try { if (in != null) in.close(); if (out != null) out.close(); if (socket != null) socket.close(); } catch (IOException ignored) { } } }split("\|", 3) 里第二个参数 limit=3 是一个很容易漏掉的细节。消息内容里的竖线不应该破坏解析,限定了三段之后,内容字段哪怕还有|也会原样保留。我第一次写的时候没加这个参数,别人发一条带竖线的消息,数组直接越界,服务端线程崩溃。
finally 块是这个类的保底逻辑。客户端主动退出走 LOGOUT 分支会break,最后落到 finally;客户端进程被杀、网线被拔、网络超时,会在 catch 里被捕获,最后同样落到 finally。这样无论什么情况,在线用户表里都不会残留这个用户的条目,也不会出现 socket 没关闭导致的句柄泄漏。
接下来 ChatServer 里要实现转发方法,分三种情况:系统消息广播、群聊、私聊。
public void broadcastSystem(String content) { String msg = "SYSTEM|" + content; for (ClientHandler handler : onlineUsers.values()) { try { handler.sendMessage(msg); } catch (Exception e) { removeUser(handler.getUsername()); } } } public void broadcastMessage(String sender, String target, String content) { if ("ALL".equals(target)) { String msg = "MSG|" + sender + "|" + content; for (ClientHandler handler : onlineUsers.values()) { try { handler.sendMessage(msg); } catch (Exception e) { removeUser(handler.getUsername()); } } } else { // 私聊:发给收件人,同时回显给发件人 ClientHandler receiver = onlineUsers.get(target); if (receiver != null) { String msg = "MSG|" + sender + "|" + content; receiver.sendMessage(msg); ClientHandler senderHandler = onlineUsers.get(sender); if (senderHandler != null) senderHandler.sendMessage(msg); } else { ClientHandler senderHandler = onlineUsers.get(sender); if (senderHandler != null) { senderHandler.sendMessage("SYSTEM|用户 " + target + " 不在线或不存在"); } } } }私聊为什么要给发件人也发一份?因为客户端界面上的聊天记录区只根据收到的消息渲染。发件人如果没有“自己发送也回显”这一条,消息发出之后界面毫无反应,用户会以为没发出去。与其在客户端额外维护发送记录,不如在服务端统一回显,客户端逻辑更简单。
遍历 onlineUsers.values() 时动态移除一个用户,ConcurrentHashMap 允许在遍历中安全移除,不会抛 ConcurrentModificationException。这一点是刻意选它而不是 HashMap 的原因。
3.3 服务端参数:端口、队列长度与超时设置
服务端有三个参数需要认真设置。第一个是端口,避开 3306、8080 这类常用端口,8888、6666、9000 都是课程设计常见的空闲端口。第二个是 backlog,前面代码里已经设置为 50,具体数值取决于你预期并发量,局域网几十台机器足够。第三个是超时设置,这里有个讲究。
很多同学会问“要不要调 socket.setSoTimeout”。我的做法是服务端不设读超时,改用心跳机制做软管理。原因很简单:如果 setSoTimeout 设了 30 秒,一个正常但连续 30 秒没发消息的用户会被误杀,而这 30 秒在聊天场景里非常正常。心跳是应用层的保活手段,比 TCP 层硬超时温和得多。如果你论文里非要写超时检测,建议写“心跳超时 60 秒”,而不是“Socket 读超时 30 秒”。
启动服务端后,必须验证端口真的在监听。命令行检查:
# Windows 查看 8888 端口监听状态 netstat -ano | findstr 8888 # Linux/macOS netstat -an | grep 8888正常情况下输出第一列是 0.0.0.0:8888,表示监听所有网卡。如果输出是 127.0.0.1:8888,说明你的 ServerSocket 绑定了回环地址,局域网里其他机器连不进来。原因通常是代码里用了InetAddress.getByName("127.0.0.1")去构造 ServerSocket,这个坑我在避坑章节还会讲。
4. 客户端落地:Swing 界面、收发线程与局域网联机调参
服务端跑通之后,客户端才是用户看得见的部分。客户端分两个类:ChatClient 负责网络收发,ChatClientGUI 负责界面展示和交互。两个类分开,网络逻辑和界面逻辑互不污染,写论文画模块图也容易。
4.1 客户端骨架:登录框、聊天区与在线列表
Swing 组件不需要花哨,但布局要合理。左边是聊天记录区,右边是在线用户列表,下面是输入框和发送按钮。先看骨架:
import javax.swing.*; import java.awt.*; public class ChatClientGUI extends JFrame { private JTextArea chatArea = new JTextArea(15, 40); private JTextField inputField = new JTextField(); private JButton sendButton = new JButton("发送"); private DefaultListModel<String> userListModel = new DefaultListModel<>(); private JList<String> userList = new JList<>(userListModel); public ChatClientGUI() { super("局域网聊天室"); // 只读聊天记录区,自动换行 chatArea.setEditable(false); chatArea.setLineWrap(true); setLayout(new BorderLayout()); add(new JScrollPane(chatArea), BorderLayout.CENTER); add(new JScrollPane(userList), BorderLayout.EAST); JPanel bottom = new JPanel(new BorderLayout()); bottom.add(inputField, BorderLayout.CENTER); bottom.add(sendButton, BorderLayout.EAST); add(bottom, BorderLayout.SOUTH); setSize(600, 400); setDefaultCloseOperation(EXIT_ON_CLOSE); setLocationRelativeTo(null); } }JTextField 配合 ActionListener,能实现“按回车直接发送”,这比用户每次点按钮要流畅得多。实现方式是inputField.addActionListener(e -> sendButton.doClick()),或者直接把发送逻辑写进监听器里。
聊天记录区用 setLineWrap(true) 自动换行,避免一条超长消息把水平滚动条拉出来。在线列表 JList 的模型是 DefaultListModel,因为它支持动态增删元素,并且线程可以安全地通过 SwingUtilities 更新它。
4.2 收发分线程:为什么不能把网络读写放在界面线程
Swing 界面跑在事件调度线程上,简称 EDT。所有界面刷新、按钮事件都在这个线程里排队执行。如果把 socket 的 readLine() 放在按钮监听器里,整个 EDT 会被阻塞,窗口就不能拖动、不能关闭、按钮全部卡死。这是“点完发送整个界面假死”的直接原因。
正确的做法是连接建立之后,立即开一个独立接收线程做阻塞读:
public void startReceiveThread(BufferedReader in) { Thread receiver = new Thread(() -> { String line; try { while ((line = in.readLine()) != null) { final String msg = line; // 回 EDT 更新界面,不要在接收线程直接碰 Swing 组件 SwingUtilities.invokeLater(() -> appendMessage(msg)); } } catch (IOException e) { SwingUtilities.invokeLater(() -> appendMessage("连接已断开:" + e.getMessage())); } }); receiver.setDaemon(true); receiver.start(); } private void appendMessage(String msg) { chatArea.append(msg + "\n"); // 自动滚动到底部,方便看新消息 chatArea.setCaretPosition(chatArea.getDocument().getLength()); }接收线程设为 daemon 的好处是,客户端主窗口关闭时守护线程自动结束,不会把 JVM 卡在后台。
为什么更新界面要借道 SwingUtilities.invokeLater?因为直接在线程里调用 chatArea.append 会导致两个线程同时操作 Swing 组件,属于线程安全隐患。表现要么是偶尔的重绘闪烁,要么是组件状态错乱。invokeLater 把更新动作丢回 EDT 队列,按顺序执行,这是 Swing 多线程的标准姿势。
发送本身不需要独立线程。out.println 只是把数据写进 TCP 发送缓冲区,局域网下基本不会阻塞。只有发送大文件才需要单独线程配合进度条,这个在最后一章展开。
4.3 局域网联机参数:填 IP、改端口、测试连通性
局域网联机最基础也最容易翻车的是 IP 地址。客户端不能写死 127.0.0.1,那是回环地址,只会连到自己机器。要填运行服务端那台机器的局域网 IP,Windows 上用 ipconfig 查,macOS/Linux 上用 ip addr 或 ifconfig 查。
客户端连接代码建议带超时:
public static void main(String[] args) throws Exception { String host = args.length > 0 ? args[0] : "127.0.0.1"; int port = args.length > 1 ? Integer.parseInt(args[1]) : 8888; Socket socket = new Socket(); // 3 秒连接超时,避免服务端不存在时界面傻等 socket.connect(new InetSocketAddress(host, port), 3000); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); // 连接成功后,先发登录消息 out.println("LOGIN|" + username); ... // GUI 界面初始化 }connect 带超时只解决“建立连接”这一动作的等待,与后面读消息的阻塞无关。心跳机制负责处理“连上之后对方死了”的等待问题。
跑 GUI 之前,先做两层网络验证。第一层是 ping:
ping 192.168.1.10能 ping 通说明链路层和网络层是通的,源头不是物理连接问题。第二层是端口验证:
telnet 192.168.1.10 8888连上之后如果游标一直在闪,说明端口也通。telnet 连不上,但 ping 能通,绝大多数是防火墙挡了端口。Windows 下到防火墙高级设置里加一条入站规则放行 8888,Linux 下执行sudo ufw allow 8888/tcp。这类部署步骤写进论文的“系统部署”小节,很加分。
还有一个环境坑:两台机器连同一个 WiFi,但路由器开了 AP 隔离,它们之间的流量被隔离,互相 ping 不通,代码怎么调都没用。遇到这种情况先查路由器设置,不要怀疑自己的程序。
5. 局域网环境避坑指南:连不上、乱码、卡死怎么排查
这个项目本身不难,难的是把它放到真实局域网环境里跑通。下面这些问题我几乎每个都见过,按“现象、原因、解决”三条给你列清楚,遇到可以直接对号入座。
5.1 连不上服务端:本机能连,局域网其他机器连不上
现象:在服务端本机把客户端 IP 填 127.0.0.1,一切正常;换局域网 IP 就卡在连接中,最后报 Connection timed out 或 Connection refused。
原因:核心是监听地址或防火墙。部分教程的示例代码用new ServerSocket(port, backlog, InetAddress.getByName("127.0.0.1"))创建服务端,导致 Socket 只监听回环地址,局域网其他机器自然连不进来。另一个常见原因是 Windows 防火墙默认拦截 Java 程序的入站连接,第一次运行如果没有在弹窗里点“允许访问”,后面对 8888 端口的访问全部被静默丢弃。
解决:先看服务端监听地址,用 netstat 确认:
netstat -ano | findstr 8888第一列显示 0.0.0.0:8888 才是监听所有网卡;显示 127.0.0.1:8888 就是绑定错了。修复方法是用通配地址构造 ServerSocket:
ServerSocket serverSocket = new ServerSocket(port, 50, InetAddress.getByName("0.0.0.0"));然后到防火墙加一条入站规则,放行 8888 端口或直接放行对应 Java 程序。改完先在本机 telnet 127.0.0.1 8888 验证,再去另一台机器 telnet 局域网 IP,逐步缩小范围。
5.2 中文乱码:自己机器没问题,换台电脑就变问号
现象:代码在自己机器上中文聊天正常,把服务端或客户端挪到另一台机器,中文全变成“???”或乱码。
原因:字符集不统一。Windows 简体中文环境默认编码是 GBK,Linux 和 macOS 是 UTF-8。构造流时如果不显式指定字符集,系统会按平台默认值编码,两端一对不上就乱码。控制台输出乱码不等于网络传输乱码,先分清层面再动手。
解决:所有 IO 流创建统一指定 UTF-8,这是最省心的方案。注意 PrintWriter 不能直接new PrintWriter(socket.getOutputStream())这样用,而是包一层 OutputStreamWriter 再指定编码:
BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter writer = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);如果界面显示正常,只有服务端控制台打印的中文乱码,那是控制台代码页的问题,改 Windows 控制台编码为 UTF-8 即可,网络传输本身没问题。
5.3 界面卡死:点完发送,整个窗口拖不动也关不掉
现象:客户端界面正常打开,点一下“连接”或“发送”,窗口马上无响应,系统提示“Java 程序未响应”。
原因:阻塞式网络操作写进了 ActionListener 或连接按钮的响应逻辑。Swing 的事件队列被 readLine() 阻塞,界面刷新事件排不上队,整个窗口假死。这个是 Swing 开发最有名的坑,几乎每个写网络程序的人都会撞一次。
解决:把耗时操作移出 EDT。连接动作放进 SwingWorker,读线程单独开,这是两条铁律:
SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() { @Override protected Void doInBackground() throws Exception { socket = new Socket(); socket.connect(new InetSocketAddress(host, port), 3000); initStreams(); login(); return null; } @Override protected void done() { // 连接成功或失败后回到 EDT 更新界面 } }; worker.execute();如果只是发送文本,out.println 不阻塞,不需要 SwingWorker。但如果你加了发送文件的功能,传输大文件可能让发送线程卡住,那时发送动作也必须脱离 EDT,用后台线程读取文件并写流。
5.4 服务端 Connection reset:客户端强退后线程不释放
现象:客户端直接点窗口右上角叉或杀进程,服务端控制台刷出java.net.SocketException: Connection reset,在线用户表里还能看到这个人。
原因:客户端进程被强杀,TCP 连接不会发正常的挥手包,服务端是在后续发送数据时才收到 RST 报文。如果异常分支处理不干净,这个 ClientHandler 线程和 socket 就永远挂在内存里。
解决:清理逻辑必须放 finally,前面 3.2 里的代码已经做了这件事。另外广播方法要对每个发送动作做 try-catch,一个 handler 发送失败不能拖垮整条广播链:
for (ClientHandler handler : onlineUsers.values()) { try { handler.sendMessage(msg); } catch (Exception e) { removeUser(handler.getUsername()); handler.closeQuietly(); } }真正生产级的做法是配合心跳。服务端维护每个用户最后一次心跳时间,每 30 秒扫描一次,超过 60 秒没心跳的主动断开并清理。有了这层兜底,“假死连接”不会超过一分钟就自动消失。
5.5 在线用户列表不同步:多人登录退出时列表乱跳
现象:两三台客户端同时在线,有人登录有人退出,部分客户端的在线列表出现重复用户名,或者已经退出的人还挂在那里。
原因:客户端列表只靠服务端广播的“加入/离开”消息自己加减,没有统一快照,网络一乱序,列表自然对不上。另一个可能原因是服务端用了线程不安全的集合,遍历时删除导致 ConcurrentModificationException。
解决:最简单可靠的方式是“全量快照”。服务端每次用户上线或下线后,广播一份完整用户列表:
public void broadcastUserList() { String list = String.join(",", onlineUsers.keySet()); String msg = "USERLIST|" + list; for (ClientHandler handler : onlineUsers.values()) { try { handler.sendMessage(msg); } catch (Exception e) { removeUser(handler.getUsername()); } } }客户端收到 USERLIST 前缀时,清空本地列表重新填充,不做增量维护。方法朴素,但一致性比增量同步好得多,很适合课程设计的规模。
6. 答辩前的验证与论文素材:按清单跑一遍再进教室
6.1 五分钟跑通的验证清单
别到答辩现场才第一次在两台电脑上测试。以下清单是我自己演示前必过的,可以直接打出来用:
| 测试项 | 步骤 | 预期结果 |
|---|---|---|
| 服务端启动 | 命令行 java ChatServer 8888 | 控制台输出监听端口信息 |
| 单机自测 | 客户端连 127.0.0.1,开两个窗口互聊 | 消息实时显示,在线列表两人 |
| 局域网联通 | 两台电脑连同一路由,填服务端局域网 IP | 连接成功,ping 通且 telnet 端口通 |
| 中文编码 | 两端都发一段中文 | 消息不乱码 |
| 掉线处理 | 直接结束一个客户端进程 | 服务端不崩,列表人数减少 |
| 心跳保活 | 拔掉客户端网线两分钟 | 服务端自动踢掉假连接,广播不受影响 |
这张表打印出来放到课程设计报告的测试章节,比写十行文字描述都管用。
6.2 三个加分功能与对应论文素材
想拿高分不用堆数量,把下面某一个功能做完、做完整,论文立刻厚实一截。
第一个推荐私聊。协议里已经支持MSG|目标用户|内容,客户端再加一个交互——双击在线用户列表就切换私聊对象,输入框上方显示“正在和某某私聊”。论文里画一张私聊消息转发时序图,技术深度立刻不一样。
第二个推荐发送文件。用 DataOutputStream 先传文件名长度、文件名、文件字节数,再传文件内容;接收端按固定缓冲区循环写盘。这个功能牵扯到二进制流和进度条刷新,是绝佳的论文难点素材。
第三个推荐聊天记录落盘。最简单的做法是服务端每次广播前把消息追加到一个文本文件,按日期切分。功能不复杂,但演示完“重开聊天室还能看到历史消息”这个效果,老师会认为你考虑到了用户体验。
6.3 我的一点交付习惯
我自己做这类提交,最后一天晚上永远不会去加新功能,而是做一次干净环境重建:把代码复制到一个空目录,只留 .java 文件和一份启动说明,在另一台机器上从命令行编译运行。能跑通,说明依赖完整、路径没写死;跑不通,立刻知道是谁忘了交配置文件。论文里的截图一定用自己真实运行界面,不要拿网图,老师经常对着截图现场点两下。希望这个习惯和上面的方案能帮到你,让答辩那天你心里有底。
本文还有配套的精品资源,点击获取