Java局域网聊天程序课设:Socket协议与多线程并发避坑 简介这是一份面向高校计算机网络课程设计场景的局域网聊天程序完整文档对应软件工程等专业学生的课设选题与实现参考。内容围绕基于P2P思想的点对点数据交换展开说明如何实现局域网内任意多点间的消息共享与交换并给出用户注册登录、聊天、文件传输、好友管理等模块的设计思路涉及Windows、Visual Studio 2010与C#开发环境以及客户端、服务器端和数据库的整体架构。压缩包共1个doc文件约161KB篇幅精炼适合直接查阅或作为课设说明书模板。文档包含需求分析、总体设计、详细设计、系统实现编码及运行结果、总结与参考文献等章节还覆盖软件层次模型、数据流程图和功能函数说明可帮助读者梳理Socket编程与应用协议设计流程完成从模块划分到编码落地的完整课设。目前已有562人学习。1. 一份 2010 年的 Java 课设代码协议设计比界面更值得抄局域网聊天程序大概是计算机网络课程设计里出镜率最高的题目十个交上来的有九个界面能点、逻辑跑不通。我最近把一份完整的老课设翻出来重跑服务器端两百来行、客户端六十来行、一堆静态工具方法丢到现在的 JDK 上照样能连通。真正让它跑起来的不是 Swing 那套窗体而是服务器里那张 IP 到昵称的哈希表外加一套用冒号和美元符号拼出来的文本协议谁上线、谁改名、谁被踢、谁跟谁私聊全靠一行行带前缀的字符串在 Socket 上走。它适合正在赶课设、想要一份能演示能答辩的完整骨架的人也适合想重温阻塞 IO 加多线程这套老模型的人。下面的拆解按「模型怎么立、服务器怎么写、客户端怎么连、哪里会翻车」的顺序走。2. 先把模型立住转发式 P2P 的三种线程与五类消息2.1 为什么题目写着 P2P代码却是服务器转发课程设计的题目叫「点对点数据交换」但这份实现里没有任何一台机器直连另一台所有消息都先到服务器再由服务器分发。这个选择不是偷懒是被局域网环境逼出来的。同学之间的机器 IP 由 DHCP 分配重启一次就变写死地址的直连方案第二天就失效UDP 广播发现只在同一广播域有效跨网段直接哑掉纯 P2P 要求每个客户端同时当服务端和客户端两端都得占端口、都得处理并发接入一个宿舍的机器上还常常被安全软件拦。更关键的是验收环节必须要有的那几个动作——管理员踢人、强制改昵称、群发公告——在无中心的拓扑里根本没有执行者。转发式的代价也很清楚。服务器是单点它挂掉整个聊天室就没了所有消息都过中心服务器带宽是天花板消息在服务器上是明文可读的课设场景无所谓换成真实产品这就是隐私问题。所以这套架构的正确定位是控制面集中在线状态、权限、路由表数据面保留端到端语义私聊只投递给目标规模上限大概几十到一两百连接再往上就得考虑连接池、异步 IO 或者直接上现成的消息中间件。我一般在动手写代码前先把这张表列出来因为它决定了后面所有类的职责边界角色职责不该做的事服务器 accept 线程接受连接、去重、登记在线表不解析聊天内容服务器 Service 线程每连接一个读该连接的行、解析前缀、路由不直接操作界面控件服务器状态容器在线 Socket 表、IP 与昵称映射、Service 表不持有业务判断客户端监听线程读服务器下发的行、分类处理不阻塞界面线程客户端发送队列缓存待发消息、串行写出不在按钮回调里直接写流2.2 行协议五类消息与前缀约定TCP 是字节流没有消息边界。这份代码的做法非常朴素所有消息以换行结尾用println发、用readLine收一条消息就是一行。行的内部再用前缀区分类型用$SPECIAL$这种几乎不可能出现在正文里的字符串当字段分隔符。原稿里贴出的SPECIAL:分支和NICKNAME:分支就是这个思路我把Service和Listener里没贴全的解析部分按同一套前缀补齐整理成下面这张表。前缀线上格式方向服务器动作LOGIN:LOGIN:昵称客户端到服务器登记或修改昵称刷新在线表NICKNAME:NICKNAME:张三 李四 王五服务器到全部客户端全量在线昵称空格分隔无前缀今天下午三点集合客户端到服务器到全部前面拼上「昵称」再群发SPECIAL:SPECIAL:目标昵称$SPECIAL$正文客户端到服务器到目标只投递给目标那一个 Socket系统消息服务器直接生成的纯文本服务器到全部或单个上线提示、改名通知、踢人通知这张表有三条硬约束写在文档里比写在注释里管用昵称里不能有空格否则NICKNAME:那行用StringTokenizer切出来就多一个假用户昵称里不能含$SPECIAL$否则私聊字段会错位任何消息体里不能有换行否则一条消息会被拆成两条接收端解析直接崩。这三条在课设答辩时被问到「你的协议怎么保证健壮性」就是标准答案的入口——不是保证是约束正式产品里会换成 JSON 或者「长度前缀 载荷」的二进制帧。顺带说一个原稿的语义瑕疵私聊投递到目标端之后服务器转发时拼的前缀是「系统管理员 悄悄对你说」实际上说话人应该是发起私聊的那个用户。改起来就一行把Service里的当前昵称拼进去即可。这类小地方不影响跑通但演示的时候一眼就被看出来。2.3 三种线程与四份共享状态整个系统里的线程数量是可以数清楚的。服务器侧有一个 accept 线程Server extends Thread的run方法每条客户端连接再起一个Service线程客户端侧有一个Listener线程负责读外加一个轮询线程负责写。也就是说一台服务器连了 20 个人就有 1 20 个服务端线程每个客户端各有 2 个线程。线程一多共享状态就成了雷区。原稿里有四份onLineUsers在线 Socket 列表、ip2nicknameIP 与昵称的Hashtable、allServiceService 线程列表、还有两个java.awt.List控件充当的界面数据容器。前三个会被多个 Service 线程同时读写第四个会被 Service 线程和 Swing 事件线程同时碰。Hashtable本身的方法级同步能挡住并发读写但挡不住「遍历的同时另一个线程在改」ArrayList更糟一边sendInfoToAll遍历一边有人掉线执行remove轻则漏发重则IndexOutOfBoundsException或者ConcurrentModificationException。我一般的处理是容器换成ConcurrentHashMap和CopyOnWriteArrayList遍历前先new ArrayList(容器)做快照凡是「先查再改」的复合操作比如判断 IP 是否已存在然后插入统一套synchronized块。这些改动不难难的是你得先意识到它是个问题——大部分课设代码在单人测试时永远不会暴露一到多人同时上下线就原形毕露。3. 服务器落地端口监听、在线表维护与消息路由3.1 端口监听与 accept 循环的三个退出条件服务器的入口只有一个ServerSocket。构造时绑定端口run方法里死循环accept每拿到一个Socket就做三件事取对端 IP、查重、登记并启动该连接专属的Service线程。这段逻辑原稿写得基本对但有一个能把整个服务打瘫的细节——查重命中时用了break。public class Server extends Thread { private final ServerSocket server; // 监听套接字端口由构造参数传入 private final List onLineUsersList; // 服务器界面上的在线列表 private final List chatContentList; // 服务器界面上的聊天记录 private final ListSocket onLineUsers new CopyOnWriteArrayList(); private final MapString, String ip2nickname new ConcurrentHashMap(); private final ListService allService new CopyOnWriteArrayList(); private volatile boolean isClosed false; // 让 accept 线程能看到关闭信号 public Server(int port, List users, List content) throws IOException { this.server new ServerSocket(port, 50); // backlog 50课设规模够用 this.onLineUsersList users; this.chatContentList content; this.start(); } Override public void run() { while (!isClosed) { Socket client; try { client server.accept(); // 阻塞直到有新连接或 server 被 close } catch (IOException e) { if (isClosed) break; // destroy() 关掉 server 引发的异常属正常退出 continue; // 其他 IO 异常不能结束监听 } String ip client.getInetAddress().getHostAddress(); if (ip2nickname.containsKey(ip)) { // 同一 IP 只允许一个连接 ChatTookit.sendInfo(client, 已经有客户端从该 ip 地址连接服务器本次连接将退出); try { client.close(); } catch (IOException ignore) { } continue; // 关键continue不是 break } onLineUsers.add(client); ip2nickname.put(ip, ip); // 未登录前先用 IP 当昵称占位 onLineUsersList.add(ip); chatContentList.add(ip 来了); ChatTookit.sendInfoToAll(onLineUsers, ChatTookit.getAllNickname(ip2nickname)); ChatTookit.sendInfoToAll(onLineUsers, ip 来了); Service service new Service(client, this); // 每连接一个读线程 allService.add(service); } } }ServerSocket(port, 50)的第二个参数是 backlog也就是内核里等待 accept 的连接队列长度课设几十个人同时连完全够如果演示时出现「连接被拒绝」而服务器日志又没有任何输出先怀疑 backlog 太小或者端口已经被别的进程占了。isClosed加了volatile是因为它由界面线程写、accept 线程读不加的话 JIT 优化后可能永远读到旧值关不掉服务器——这种问题在单人测试里几乎不会出现因为你关窗口的时候进程都退出了。真正致命的是那个break。它的语义是「跳出 while 循环」也就是一旦有人从同一个 IP 重复连接同一台机器开第二个客户端、或者宿舍共用一个出口服务器不会拒绝这一个连接而是整个 accept 线程结束之后再没有任何人能连上界面上看着还在跑实际已经死了。这个 bug 的排查难度在于它是静默的没有异常、没有日志只是「突然连不上了」。改成continue并顺手close掉多余的那个 Socket问题消失。3.2 IP 与昵称映射表的四类操作ip2nickname这张表是整个服务器的路由核心所有定向投递都要靠它把「昵称」翻译成「IP」再靠 IP 找到「Socket」。围绕它其实只有四类操作值得单独抽成一个工具类而不是散落在各个方法里。public final class ChatTookit { // 1. 全量昵称串给 NICKNAME: 前缀用空格分隔 public static String getAllNickname(MapString, String ip2nickname) { StringBuilder sb new StringBuilder(NICKNAME:); for (String nick : ip2nickname.values()) { sb.append(nick).append( ); // 昵称含空格会破坏协议登录时就要校验 } return sb.toString(); } // 2. 昵称反查 IP私聊投递前必须先做这一步 public static String getIP(MapString, String ip2nickname, String nickname) { if (nickname ! null nickname.contains()) { // 兼容「昵称ip」的显示格式 nickname nickname.substring(0, nickname.indexOf()); } for (Map.EntryString, String e : ip2nickname.entrySet()) { if (e.getValue().equals(nickname)) { return e.getKey(); } } return null; // 找不到就是目标已下线调用方必须判空 } // 3. 根据 IP 找 Socket遍历在线列表比对地址 public static Socket getSocketByIP(ListSocket onLineUsers, String ip) { for (Socket s : onLineUsers) { if (s.getInetAddress().getHostAddress().equals(ip)) { return s; } } return null; } // 4. 刷新界面在线列表客户端传昵称串服务器传映射表 public static void updateOnLineUsersList(List uiList, String allNickname) { uiList.removeAll(); StringTokenizer st new StringTokenizer(allNickname); while (st.hasMoreTokens()) { uiList.add(st.nextToken()); } } }四个方法里getIP的反查是 O(n) 遍历几十个人无所谓几百人就有感知了要做优化就再维护一张反查表代价是两张表必须原子地一起更新反而更容易出 bug。所以我倾向保留遍历把这个复杂度当成可接受的边界写在文档里。getIP返回null是所有调用方必须处理的路径。原稿在私聊和踢人两处都是直接拿返回值去查 Socket目标刚好在这一瞬间下线就会 NPE然后这个 Service 线程静默死掉表现是「某个用户从此发消息没反应」。这类空指针不看堆栈很难猜所以我在每个return null前面加日志出问题先看日志时间戳。updateOnLineUsersList用了StringTokenizer而不是split原因是它对连续空格的容忍度更好但前提仍然是昵称本身不含空格。在线列表的刷新策略是「全量重建」先removeAll再逐个add。这个做法简单可靠代价是用户数多的时候界面会闪一下而且每次上下线都要重新组装一整串昵称、走一轮广播。课设规模完全够用真实产品会改成增量 diff。3.3 群发实现与 PrintStream 的 flush 陷阱群发的代码短到只有五行但它是这份代码里最容易出玄学问题的地方。public static void sendInfoToAll(ListSocket onLineUsers, String info) { for (Socket c : onLineUsers) { try { // 第二个参数 true 表示 println 之后自动 flush不传就会攒在缓冲区里 PrintStream ps new PrintStream(c.getOutputStream(), true, UTF-8); ps.println(info); } catch (IOException e) { // 单个客户端写失败不能拖垮整轮广播记下来交给清理逻辑处理 System.err.println(发送失败: c.getInetAddress() - e.getMessage()); } } }new PrintStream(OutputStream)这个构造器创建的流默认不会自动 flush。而PrintStream内部有一层BufferedOutputStream默认缓冲区 8KB所以println只是把数据写进了缓冲区——除非缓冲区正好满否则对面什么都收不到。这就是那个经典现象本机测试一切正常因为消息量大、缓冲很快就满了换个环境或者发一条短消息对面要等十几秒才显示甚至一直不显示。修复就是构造时把 autoFlush 打开同时显式指定字符集。autoFlush 的触发条件只包括println、printf和写入换行符普通write并不会触发所以这个参数和「用println按行发」是配套的。另外每次群发都new一个PrintStream包同一个getOutputStream()也不是好习惯正规做法是在Service里给每条连接缓存一个PrintStream群发时直接取既省对象也避免多次包装同一个流。3.4 Service 线程把一条消息路由到正确的人Service是真正干活的地方它读自己那条连接上的行按前缀分派。原稿没贴出这一段的完整实现我按ChatTookit里那些方法的用法把它补全成能跑的样子。public class Service extends Thread { private final Socket socket; private final Server server; private volatile String nickname; // 可能被管理员的改名操作修改 public Service(Socket socket, Server server) { this.socket socket; this.server server; this.nickname socket.getInetAddress().getHostAddress(); this.start(); } Override public void run() { try (BufferedReader br new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8))) { String line; while ((line br.readLine()) ! null) { // readLine 返回 null 表示对端已关闭 if (line.startsWith(LOGIN:)) { server.changeNickname(nickname, line.substring(6).trim()); } else if (line.startsWith(SPECIAL:)) { String body line.substring(8); int sep body.indexOf($SPECIAL$); if (sep 0) { // 分隔符不存在或位置非法则丢弃 String to body.substring(0, sep); String content body.substring(sep 9); server.sendPrivate(nickname, to, content); } } else { server.broadcast(nickname line); } } } catch (IOException e) { // 客户端强制退出会走到这里属于正常现象不必弹窗 } finally { server.removeClient(this); // 断开清理必须放 finally } } }三个技术点值得展开。第一readLine返回null是判断对端关闭的标准方式比捕获异常靠异常类型判断更准确也比isConnected()靠谱——isConnected()反映的是本地 Socket 是否曾经连接过对端拔网线它照样返回true。第二清理逻辑必须放在finally里否则一旦解析过程中抛出运行时异常这个客户端就会永远留在在线表里界面上显示在线、发消息却没人收俗称幽灵用户。第三nickname声明成volatile是因为管理员改名是另一个线程的操作Service必须能看到新值。removeClient要做的事情有五件从allService移除自己、从onLineUsers移除 Socket、从ip2nickname按 IP 移除映射、广播下线通知、刷新在线列表。这五步的顺序有讲究——先摘路由表再广播能保证广播过程中别人发来的私聊不会再命中这个已经死掉的连接反过来做的话会出现「已下线但还能收到最后一次私聊」的诡异现象。4. 客户端落地登录握手、监听线程与 Swing 线程边界4.1 连接与登录握手客户端启动只做两件事用new Socket(ip, port)连上服务器然后起监听线程。真正的「登录」是连上之后主动发的那一行。public class Client extends Thread { private final Socket client; private final BlockingQueueString outbox new LinkedBlockingQueue(); // 界面线程只往队列里塞 private volatile boolean isClosed false; public Client(String ip, int port, String nickname, List userList, List contentList) throws IOException { this.client new Socket(ip, port); // 默认超时是无限的演示前建议加 connect 超时 PrintStream ps new PrintStream(client.getOutputStream(), true, UTF-8); ps.println(LOGIN: nickname); // 先报昵称服务器才能把 IP 换成名字 new Listener(client, userList, contentList).start(); this.start(); } Override public void run() { try (PrintStream ps new PrintStream(client.getOutputStream(), true, UTF-8)) { while (!isClosed) { String msg outbox.poll(200, TimeUnit.MILLISECONDS); // 200ms 一轮兼顾延迟和 CPU if (msg ! null) { ps.println(msg); } } } catch (Exception e) { // 连接断开交给界面提示 } } public void send(String s) { outbox.offer(s); } // 供按钮回调调用立即返回 }new Socket(ip, port)在不指定超时的前提下如果目标 IP 存在但没进程监听会一直等到系统默认的 TCP 超时几十秒界面上就是「点了连接没反应」。想让它快速失败就用Socket的无参构造加connect(new InetSocketAddress(ip, port), 3000)3 秒连不上直接抛异常给用户看。原稿用一个String word字段加 100ms 轮询来发送逻辑上是通的但只能暂存一条消息用户手速快一点连发两句第一句就会被第二句覆盖丢得毫无痕迹。换成LinkedBlockingQueue之后界面线程offer立即返回不卡界面发送线程poll(200, TimeUnit.MILLISECONDS)有消息就发、没消息就等既不空转也不丢消息。这是我在所有类似的「界面线程写、网络线程读」场景里都会用的一招比sleep(100)轮询共享变量的写法稳定得多。4.2 Listener 的解析分支与界面刷新客户端的监听线程是整条链路的终点它把服务器下发的一行行文本翻译成界面上的动作。public class Listener extends Thread { private final BufferedReader br; private final List userList; private final List contentList; public Listener(Socket socket, List userList, List contentList) throws IOException { this.br new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); // 编码必须两端一致 this.userList userList; this.contentList contentList; this.setDaemon(true); // 主窗口关闭后不阻止进程退出 this.start(); } Override public void run() { try { String line; while ((line br.readLine()) ! null) { if (line.startsWith(NICKNAME:)) { final String names line.substring(9); SwingUtilities.invokeLater(() - ChatTookit.updateOnLineUsersList(userList, names)); } else { final String text line; SwingUtilities.invokeLater(() - { contentList.add(text); contentList.select(contentList.getItemCount() - 1); // 自动滚到最新一条 }); } } } catch (IOException e) { // 服务器关闭或网络中断静默退出由界面提示重连 } } }NICKNAME:前缀的偏移量是 9NICKNAME:刚好 9 个字符这个数字写错一位在线列表就会显示成「ICKNAME:张三」这种鬼东西而且因为不报错、只是显示怪异很容易被当成界面 bug 查半天。同样SPECIAL:的偏移是 8LOGIN:的偏移是 6$SPECIAL$的长度是 9这四个数字建议直接定义成常量别在代码里散着写裸数字。setDaemon(true)是个细节但有用监听线程是死循环读如果它不是守护线程用户关掉主窗口后 JVM 仍然会因为「还有非守护线程在跑」而拒绝退出任务管理器里留一堆僵尸进程。课设演示时连续开关几次客户端最后发现机器卡了多半就是这个原因。4.3 Swing 的线程边界所有控件操作都得回到事件线程AWT 和 Swing 的控件不是线程安全的规则只有一条除事件分发线程EDT之外任何线程都不能直接调用控件方法。而Listener恰恰是一个普通线程它读到的每一条消息都得跨过这条边界。原稿是直接在Listener里调用chatContentList.add(...)的。在单人测试下这段代码能跑因为 Swing 的部分实现内部做了同步但它是未定义行为——典型症状是界面偶尔不刷新、列表项显示成空白、快速滚动时抛ArrayIndexOutOfBoundsException而堆栈指向 Swing 内部。这类问题最烦人的地方在于不可复现改一行代码就换了表现。正确的做法就是上面代码里的SwingUtilities.invokeLater把「改控件」这个动作打包成一个Runnable丢进 EDT 的队列让 EDT 自己按顺序执行。代价是多了一层对象创建和队列调度收益是彻底摆脱这类玄学问题。我的习惯是只要一个方法里出现了控件变量名就默认它必须在 EDT 上执行不管当前在哪个线程——写成invokeLater永远不会错最差只是多绕一圈。还有一处是原稿里重量级和轻量级控件混用界面用了java.awt.List这种重量级组件同时又弹JOptionPane这种轻量级对话框。在部分 JDK 和系统主题下重量级组件会盖在轻量级弹窗上面表现为弹窗被列表挡住一半。要么全用 Swing 的JList配DefaultListModel要么接受这个瑕疵但别在弹窗上放关键信息。5. 联调避坑五个能把课设卡死一整晚的现场5.1 同一台机器开第二个客户端直接被踢现象想在本机开两个客户端互发消息做测试第一个正常第二个连上就弹「已经有客户端从该 ip 地址连接服务器本次连接将退出」根本没法自测。原因服务器用客户端 IP 作为在线表的唯一键ip2nickname.containsKey(ip)这个判断本意是防重复登录但同一台机器上所有客户端对外都是127.0.0.1或者同一个内网地址于是第二个必然被判重。解决把在线表的键从 IP 换成「连接建立时自增的会话号」IP 只作为属性存着。这样同一台机器能开任意多个客户端联调时一个人就能演完整个聊天室。改完之后getSocketByIP也要改成getSocketBySessionId别只改一半。如果不想动结构临时方案是拿两台机器测但答辩现场往往只有一台机器所以建议直接改。5.2 服务器接受过一个新连接之后就再也连不上了现象第一次启动服务器A 能连上A 断开后 B 再连一直卡住服务器界面没有任何反应也不报错。原因accept循环里那个break。原稿在「同一 IP 已存在」的分支里写了break一旦触发就跳出了整个while(!isClosed)循环accept 线程直接结束但ServerSocket还开着、界面还活着所以外面看起来一切正常。解决break改成continue并且把多余的 Socket 关掉。排查这类「静默失效」有个通用手法在accept成功和进入下一轮循环的地方各打一行带时间戳的日志只要日志停了就说明循环被跳出了比盯着界面猜快得多。5.3 消息发出去了对面要等十几秒才显示现象本机测的时候一切正常换成两台机器A 发一句「在吗」B 那边十几秒后才蹦出来或者连着发好几句一起蹦出来。原因PrintStream的缓冲区。new PrintStream(socket.getOutputStream())不带 autoFlush数据攒在 8KB 的缓冲区里得等满了或者流关闭才真正写出去。本机测试时消息密集、缓冲区很快填满所以「看起来正常」这个假象极具欺骗性。解决构造时打开 autoFlush 并指定编码new PrintStream(out, true, UTF-8)。同时把「每条连接一个 PrintStream」缓存起来复用别在群发循环里反复 new。如果改了还是延迟检查一下是不是用了write而不是println——autoFlush 只对println、printf和写换行符生效。5.4 中文昵称变成问号聊天内容变成一串方块现象英文一切正常起个中文昵称别人看到的是???或者乱码方块聊天内容里的中文偶尔正常偶尔乱。原因InputStreamReader和PrintStream的构造器不传字符集时用的是平台默认编码。Windows 中文版是 GBKLinux 和 macOS 多半是 UTF-8两端不一致就必然乱码。同一台机器上因为收发用的是同一个默认编码所以自测发现不了。解决所有跨进程的字符流构造器一律显式传UTF-8包括客户端和服务器的InputStreamReader、PrintStream一个都不能漏。排查时用file.encoding打印一下两端 JVM 的默认编码两边不一样就基本可以确诊。另外注意String.getBytes()无参版本也是平台默认编码需要跨端传输的地方别用它。5.5 有人退出或管理员踢人时服务器抛下标越界现象几个人同时上下线服务器控制台偶尔蹦出IndexOutOfBoundsException或者ConcurrentModificationException然后某个用户就收不到消息了。原因ArrayList被多个 Service 线程同时读写。sendInfoToAll正在用索引遍历onLineUsers另一个线程执行了remove后面的元素整体前移循环变量就越界了。ConcurrentModificationException则是增强 for 循环里检测到modCount变化时抛的。解决在线 Socket 列表换成CopyOnWriteArrayList映射表换成ConcurrentHashMap广播前先复制一份快照再遍历。这么改的代价是每次写操作都复制整个数组用户数上百之后有性能损耗但课设规模下完全无感。真正的原则是任何可能被两个以上线程碰的容器默认就用并发版本别等到报错再回头改。6. 再往前一步把 IP 做 key 换成会话号加心跳与三开验证改造成会话号其实只有几行但收益立竿见影——单机三开、五开随便测答辩演示不用求人借电脑。private static final AtomicInteger SEQ new AtomicInteger(); // accept 到新连接时 String sessionId s SEQ.incrementAndGet(); MapString, ClientInfo online new ConcurrentHashMap(); online.put(sessionId, new ClientInfo(socket, ip, ip)); // 会话号 - (socket, ip, nickname)ClientInfo就是一个持有三个字段的小类昵称可改IP 和会话号不变。所有路由方法从「按 IP 找」改成「按会话号找」NICKNAME:广播时取nickname字段SPECIAL:私聊时先按昵称反查会话号再取 Socket。原来的getSocketByIP可以直接删掉或者保留但只用于日志。心跳解决的是另一个问题客户端被强杀或者拔网线服务端的readLine不会立刻返回null因为 TCP 连接在协议层面还「活着」要好几分钟才超时。这段时间里这个用户会一直显示在线别人发私聊过去石沉大海。我在客户端每 5 秒发一行PINGService 收到就更新lastActive时间戳服务器再起一个守护线程每 10 秒扫一遍在线表超过 30 秒没动静的主动断开并走正常的清理流程。// 服务器端的清理线程三个数字按演示节奏调别照抄生产环境的量级 scheduler.scheduleAtFixedRate(() - { long now System.currentTimeMillis(); for (Map.EntryString, ClientInfo e : online.entrySet()) { if (now - e.getValue().lastActive 30_000) { closeAndRemove(e.getKey()); // 内部会广播下线并刷新列表 } } }, 10, 10, TimeUnit.SECONDS);验证环节我固定走三步。第一步单机三开A、B、C 全部上线看三端的在线列表是否都显示三个名字顺序是否一致。第二步 A 给 B 发私聊C 的窗口里绝不能出现这条内容同时 B 显示的是「A 悄悄对你说」而不是「系统管理员」。第三步管理员把 B 踢掉检查三件事B 收到提示并断开、A 和 C 的在线列表立即少一个人、服务器界面的 IP 与昵称对照表也同步少一行。这三步全过功能层面基本就稳了。打包演示再补一句血泪经验javac编译时用-encoding UTF-8否则源码里的中文在 GBK 环境下编译出来就是乱码而且报错位置会飘到行尾看着像语法错误。代码里用到List的地方尤其要看清java.awt.List和java.util.List的导入IDE 自动补全经常选错编译期不报错、运行期行为诡异。从那以后我每次交课设前都强制走一遍三步验证三开看列表、私聊看隔离、踢人看清理。少一步就等着答辩现场翻车。希望帮到你。本文还有配套的精品资源点击获取