Java聊天室实战:Swing+Socket+多线程实现群聊私聊与用户列表
简介面向高校学生与Java初学者的聊天室完整工程资源提供一套可直接运行的桌面应用源码实现了带完整界面的群聊、私聊与在线用户列表功能。项目基于Socket编程与多线程机制搭配Swing构建交互窗口覆盖网络通信、事件驱动、集合框架和异常处理等关键知识点代码注释清晰便于梳理整体调用流程。压缩包共42个文件以14个java源文件与15个class编译文件为主另含project、classpath等工程配置和说明文档整体仅46KB结构精简适合导入IDE直接学习。已有7431人浏览学习适合作为Java课程设计或网络编程练手项目。通过研读源码可掌握ServerSocket连接管理、多客户端并发处理、消息收发流程与用户状态维护等实用技巧同时项目中的私聊和群聊逻辑也能为后续扩展即时通讯功能提供可参考的代码骨架。无论是课程设计、毕业设计还是个人技术提升都能从中获得完整的网络编程实战经验。1. 这个Java聊天室能干什么群聊、私聊、用户列表三个硬需求先讲透一个带完整界面的 Java 聊天室听起来不算难真正跑起来才发现坑全藏在细节里群聊消息怎么广播、私聊消息怎么精准投递、用户列表什么时候刷新、窗口关闭时连接怎么正确释放。这套资源是一个用 Swing 写界面、Socket 加多线程做通信的完整聊天室源码包界面完整群聊、私聊、用户列表三项功能全部封装好了。适合正在学 Java 网络编程、需要课程设计源码、或者想搞明白 Swing 和网络线程如何协作的开发者和学生。拿到手导入 IDE 就能跑通先看到效果再逐行读源码比从课本开始推演要直观得多。2. 把通信模型先立住Socket 线程模型与消息协议的设计取舍2.1 服务端与客户端的分工谁负责转发谁负责渲染聊天室要先分清楚两方职责。服务端跑在局域网内任意一台机器上负责的事情有三件接受所有客户端的 Socket 连接维护一张“在线用户名到输出流”的映射表把收到的消息按规则转发到对应目标。它不负责显示窗口也不负责把消息渲染成界面元素这类项目里服务端的输出就是控制台日志加上一张不断增删的在线表。客户端负责的事情刚好反过来把用户敲进输入框的内容装成一条符合协议约定的消息发出去把读线程收到的消息解析出来更新聊天记录区、刷新右侧用户列表。界面能不能用、消息显示顺序对不对都在客户端这一侧解决。这样分工的直接收益是排查问题能快速定位。群聊能收到、私聊收不到问题多半出在服务端的转发逻辑而不是界面上服务端日志显示已经转发、但窗口里没显示问题就出在客户端的读线程或者消息解析上。我一般接到这类源码第一件事就是看服务端有没有把每条消息打印出来再决定往哪个方向查。主流程上有两种典型的服务端写法一种是用一个ServerSocket绑定固定端口在死循环里accept()每来一个连接开一个新线程另一种是上 NIO 的非阻塞模型。这套资源用的是前一种理由很直白代码量最小、理解成本最低、调试时能用断点直接跟进去看每个连接的状态。代价是并发量有限但对一个几十人同时在线就能满足课程设计或局域网演示的聊天室来说绰绰有余。要特别强调的是多线程的必要性。socket.getInputStream().read()是阻塞的如果服务端只有一个主线程在轮询所有连接一旦某个客户端不发消息主线程就会卡在read()上其他客户端全都连不进来这就是“只连了第一个客户端后面全都连不上”的典型症状。每连接一线程的模型让每个客户端独占一条执行路径互相不阻塞是入门阶段正确的技术选型。2.2 消息协议上下线、群聊、私聊和列表复用一条文本消息服务端和客户端之间要说“人话”就得有协议。这套资源没引入 JSON、XML 这类依赖用的是字段分隔符协议每条完整消息以换行符\n结尾作为边界消息内部用竖线|分隔字段第一个字段固定为消息类型。这种协议在入门项目里很常见抓包时一眼就能看懂内容。项目里实际用到的类型共五种LOGIN|用户名 MSG|group|all|文本内容 MSG|private|目标用户名|文本内容 LOGOUT|用户名 LIST|用户名1,用户名2,用户名3LOGIN是客户端建立连接后发送的第一条消息服务端把用户名写进在线表MSG|group是群聊消息服务端收到后广播给所有在线客户端MSG|private是私聊消息服务端根据目标用户名从在线表找到对应输出流只做定向发送LOGOUT是客户端主动退出时通知服务端LIST是服务端主动推送给客户端的在线用户列表登录、退出、断开三种时机都会触发。协议设计的核心是类型字段放第一位后面字段数量随类型而定。服务端解析时先split(\\|)取parts[0]判断类型再按对应规则取后续字段。字段顺序一旦约定好就不能随意改动因为客户端和服务端是两段独立代码各自写死了parts[1]、parts[2]的语义前端改了后端没跟着改就会出现“私聊消息发过去对方收不到”这种半通半断的故障。这里有个非常容易踩的底层细节网络传输是流式的接收方一次read()读到的可能只是消息的半个片段也可能是好几条消息黏在一起。用BufferedReader.readLine()时按行切分的行为已经封装好了它会自己等待完整的一行所以协议必须约定“每条消息以换行符结尾”发送端不要自己在消息内容里再拼一个换行符否则消息会被从中间拆开接收端解析时就会出现莫名其妙的截断。如果不用readLine()而是自己读InputStream那就要在代码里维护一个字符串缓冲区不断把读到的字节追加进去每次遇到\n才取出一段完整消息去解析这个缓冲逻辑是网络编程里会反复遇到的通用套路。2.3 为什么界面用 Swing 而不是 JavaFX课程设计场景下的现实选择聊天室界面在大多数同类资源的实现里都由 Swing 承担。Swing 的代码样式偏老、布局也没有现在前端那么好看但在课程设计和入门练手这个场景下有四个不可替代的好处。第一JDK 自带框架不需要额外引入依赖和配置模块javac编译后直接能跑对还不熟悉 Maven、Gradle 的同学最友好。第二BorderLayout、JScrollPane、JList这几个组件的组合方式固定聊天窗口这种“上方聊天记录、下方输入框、右侧列表”的三分布局十分钟就能拼完。第三相关资料多遇到问题能快速找到参考这个优势在学习阶段比技术先进性重要得多。第四Swing 对线程的要求虽然严格但容易理解所有界面更新必须在 EDT 上遵守这一条就不会出现卡界面或者绘制错乱。JavaFX 不是不能用但它的生命周期管理、样式文件和线程模型比 Swing 更重对“要把一个带网络通信的窗口程序做出来”的场景来说付出的学习成本和项目复杂度都更高。我一般建议把 Swing 作为入门选项等把 Socket、多线程、协议这些核心概念都吃透了再换 JavaFX 重构界面那才是合理的技术演进。Swing 这里还有个容易被忽略的细节界面组件不能在工作线程里直接改。读线程收到消息后如果直接textArea.append()大多数情况下不会立刻报错但会出现界面刷新不及时、绘制闪烁极端情况下偶发ConcurrentModificationException而且这类 bug 不能稳定复现排查起来非常费时间。正确习惯是工作线程只做数据读取和解析真正改界面文本、更新列表模型的逻辑统一用SwingUtilities.invokeLater()提交到 EDT 去执行。这个习惯在项目里从一开始就该养成不然写完后半段时翻回来改会非常痛苦。3. 服务端拆解端口监听、客户端线程与消息转发路由3.1 ServerSocket 主流程一个 accept 循环撑起所有连接服务端入口是整份资源里最短的一段代码但承载的职责并不少绑定端口、开启监听、循环接受连接、为新连接创建处理线程。先看主流程。public class ChatServer { // 在线客户端表key 为用户名value 是对应客户端的输出流 private static final MapString, PrintWriter clients new ConcurrentHashMap(); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8888); System.out.println(ChatServer started on port 8888); while (!serverSocket.isClosed()) { Socket socket serverSocket.accept(); System.out.println(New client connected: socket.getRemoteSocketAddress()); new Thread(new ClientHandler(socket)).start(); } serverSocket.close(); } }这段代码有三个关键参数需要理解。第一端口 8888 是写死的端口取值范围是 0 到 655351024 以下通常需要系统权限常见开发用途端口有 8080、8888、9090 这些选一个没被占用的就行。如果项目跑完一次再启动时报Address already in use多半是上一个进程没有真正退出。第二clients必须用ConcurrentHashMap而不是普通HashMap。原因是每个客户端线程都会往这张表里put输出流、remove下线用户多个线程同时写一张非并发安全的 Map会出现不可预期的数据错乱或者ConcurrentModificationException而且属于偶现问题不好复现、不好排查。ConcurrentHashMap的读写是线程安全的网络程序里维护这种公共状态是首选。第三accept()是阻塞等待连接的每返回一个Socket直接丢给新线程然后主线程回到accept()继续等。如果这里改成“先处理完这个连接再 accept”后面来的连接就会全部排队变成串行处理。3.2 ClientHandler登录注册、消息解析与转发路由ClientHandler是服务端持有一个客户端连接的工作线程。它的时序是读取登录消息并注册用户广播最新用户列表进入readLine()消息循环循环结束或异常时清理用户并再次广播列表。核心实现如下。public class ClientHandler implements Runnable { private final Socket socket; private PrintWriter out; private String username; public ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try { BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); out new PrintWriter(socket.getOutputStream(), true); String firstLine in.readLine(); if (firstLine null || !firstLine.startsWith(LOGIN|)) { socket.close(); return; } username firstLine.split(\\|)[1]; // 重复用户名检查避免私聊分发目标混乱 if (ChatServer.clients.containsKey(username)) { out.println(SYS|用户名已被占用请换一个再试); socket.close(); return; } ChatServer.clients.put(username, out); broadcastUserList(); String line; while ((line in.readLine()) ! null) { handleMessage(line); } } catch (IOException e) { e.printStackTrace(); } finally { ChatServer.clients.remove(username); broadcastUserList(); try { socket.close(); } catch (IOException ignored) {} } } private void handleMessage(String line) { String[] parts line.split(\\|); if (parts.length 2) return; String type parts[0]; if (MSG.equals(type) group.equals(parts[1])) { for (PrintWriter writer : ChatServer.clients.values()) { writer.println(line); } } else if (MSG.equals(type) private.equals(parts[1])) { String target parts[2]; PrintWriter writer ChatServer.clients.get(target); if (writer ! null) { writer.println(line); out.println(line); // 回显给发送者自己 } else { out.println(SYS|用户 target 不在线); } } else if (LOGOUT.equals(type)) { ChatServer.clients.remove(username); broadcastUserList(); } } }几个值得说明的点。登录后的第一条消息必须是LOGIN|用户名。readLine()会阻塞到收到一个完整的换行字段为止所以客户端连接建立后要立即发送登录消息如果迟迟不发服务端就一直挂在这条readLine()上这个连接的线程也不会进入后续的消息循环。parts[1]用来区分群聊和私聊parts[2]在私聊时是目标用户名。这是前面约定的协议格式不能随意换位置。代码里对parts.length 2的保护是防呆的如果split(\\|)只切出一个字段parts[1]就会抛ArrayIndexOutOfBoundsException整个线程直接崩掉。私聊回显是容易漏的一步。服务端把消息转给目标用户后如果不给发送者也回一份发送者自己的聊天窗口就不会显示他刚说的话体验上像是消息没发出去。因此代码里writer.println(line)之后紧跟着out.println(line)这个out是发送者自己的输出流。字符集统一用 UTF-8 是个隐藏前提。InputStreamReader构造时如果不指定字符集会用 JDK 所在平台的默认编码中文 Windows 上通常是 GBK而客户端很可能按 UTF-8 编码后发送两边一对不上就是中文乱码。所以两端都要显式写StandardCharsets.UTF_8这一点必须写死在代码里不能靠环境碰运气。3.3 用户列表广播的时机登录、退出与连接断开三个触发点用户列表是项目第三项核心功能服务端负责生成和广播。所谓广播就是遍历clients表把包含所有用户名的LIST消息发给每一个客户端。private void broadcastUserList() { String userList String.join(,, ChatServer.clients.keySet()); for (PrintWriter writer : ChatServer.clients.values()) { writer.println(LIST| userList); } }列表会在三个时机刷新新用户登录成功、老用户主动发送LOGOUT、连接因异常或直接关窗口而断开finally块里的清理路径。第三点特别容易被忽略。当客户端直接关闭窗口而没有走“退出登录”按钮时服务端其实无法立刻感知。进程关闭时 Socket 断开服务端的readLine()会返回null或抛SocketException执行流落到finally块里此时才触发移除用户和广播列表。这段清理必须写在finally而不能写在try的正常流程里因为它的触发时机不可控唯一确定的保障就是finally。如果清理逻辑没写对在线列表里会长期残留已经断开的用户私聊发过去也会被静默丢弃这就是“幽灵用户”。列表广播用的String.join(,, ...)会把用户名拼成user1,user2,user3的形式。如果用户名本身含有逗号或者竖线这里就会出现协议碰撞所以比较稳妥的约束是客户端登录时限制昵称里不能包含|和,这也属于协议边界的一部分。这个限制如果源码里原本没写可以自己加在客户端登录对话框上。4. 客户端拆解Swing 界面三块布局与读线程更新机制4.1 界面分区与组件选择聊天区、输入框、用户列表的静态布局客户端的界面目标很明确上方是聊天记录下方是输入区右侧是用户列表。用BorderLayout就能把三个区域拼到同一个窗口里不需要自定义布局管理器。聊天记录区用只读的JTextArea包在JScrollPane里输入区由JTextField和JButton横向排列用户列表用JListString并支持单选选中后即为私聊目标。public class ChatClient extends JFrame { private JTextArea chatArea new JTextArea(); private JTextField inputField new JTextField(); private JButton sendButton new JButton(发送); private JListString userList new JList(); private PrintWriter out; private String currentUsername; public ChatClient() { super(Java 聊天室); chatArea.setEditable(false); JScrollPane chatScroll new JScrollPane(chatArea); JScrollPane userScroll new JScrollPane(userList); userScroll.setPreferredSize(new Dimension(150, 0)); add(chatScroll, BorderLayout.CENTER); add(userScroll, BorderLayout.EAST); JPanel bottom new JPanel(new BorderLayout()); bottom.add(inputField, BorderLayout.CENTER); bottom.add(sendButton, BorderLayout.EAST); add(bottom, BorderLayout.SOUTH); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(700, 500); setVisible(true); } }组件选择上有个细节JTextArea必须设为setEditable(false)它只是消息展示容器用户不能直接在里面打字JList默认支持单选正好符合选中一个人即私聊对象的交互输入框和按钮没有固定比例窗口大小变化时BorderLayout会自动分配这也是选这个布局管理器而不是null布局的原因——手写坐标在窗口拉伸时会乱成一团。登录框环节承担了一个协议职责用户名必须能唯一标识一个客户端。如果两个客户端用同一个昵称登录后续私聊消息分发到clients.get(target)时会命中后登录的那个输出流前一个用户实际被顶下线。所以客户端的登录框最好做一次非空校验和长度限制比如 2 到 12 个字符服务端收到LOGIN后再做一次重复名检查并回SYS错误消息双保险才能避免这个隐患。4.2 读线程与 EDT为什么必须用 invokeLater 更新界面客户端最核心的线程模型是主线程负责界面和发送一个单独的读线程负责从 Socket 接收数据。读线程从连接建立后立即启动直到连接关闭为止。核心代码如下。private void startReadThread() { new Thread(() - { try { BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); String line; while ((line in.readLine()) ! null) { final String message line; SwingUtilities.invokeLater(() - handleMessage(message)); } } catch (IOException e) { SwingUtilities.invokeLater(this::onDisconnected); } }).start(); }handleMessage里的更新操作包括追加聊天记录、解析LIST类型的在线用户列表并重置JList的模型。全部通过invokeLater排队到 EDT 上执行读线程本身不直接触碰任何界面组件。写这段代码时要注意三点。第一readLine()在连接正常时是阻塞的读线程用new Thread(...).start()的目的只是拿到一条可独立运行的执行路径。第二捕获到异常和readLine()返回null是两条不同的退出路径前者是连接异常中断后者是服务端正常关闭输出流代码里都要走到onDisconnected把界面改成“连接已断开”状态并禁用发送按钮否则用户还以为自己还连着。第三invokeLater是异步排队不保证消息按严格顺序绘制但同一段时间内读线程只会解析一条消息再排队一条顺序是稳定的不会出现消息互相穿插的乱序。提示invokeLater只是把任务排队到 EDT不保证同步执行。在这个项目里读线程是唯一的消息来源所以排队顺序和读取顺序一致不会出现串话。为什么不直接在读线程里chatArea.append()语法允许但 Swing 组件的线程安全约束决定了不该这么做。界面绘制、事件分发都在 EDT 上如果另一个线程同时修改文本轻则显示不同步、滚动位置异常重则抛出ConcurrentModificationException导致整个窗口失去响应。用invokeLater的成本非常低长期收益却很高。4.3 发送逻辑输入框内容如何包装成协议消息发送动作比看起来要简单很多完整链路是读取文本框内容、判断当前有没有选中的私聊对象、按协议格式拼出消息、通过PrintWriter写进 Socket 输出流。这个过程不该有任何网络等待发送完成后立即把输入框清空焦点也留在输入框里方便连续聊天。下面是这个方法的常规实现。private void sendMessage() { String content inputField.getText().trim(); if (content.isEmpty()) { return; } String target userList.getSelectedValue(); if (target ! null !target.equals(currentUsername)) { out.println(MSG|private| target | content); } else { out.println(MSG|group|all| content); } inputField.setText(); }这段代码里有两个交互约定需要理解。第一个约定用户列表选中了除自己以外的用户时发送的是私聊没选中任何人或选中了自己时发送的是群聊。这个规则够用但有个体验包袱用户可能之前选中过一个人之后想发群聊却忘了取消选中发出的就变成私聊。改进做法是在界面加两个单选按钮或者加一个取消私聊选择的小按钮代码量不大拿到源码后可以自己扩展。第二个约定消息里的|字段分隔符是协议保留字符。如果用户输入“今天|明天有空吗”按上面的拼法服务端split(\\|)后内容段会被截断。稳妥的处理是发送前统一把内容里的|替换成全角竖线接收端展示时再替换回来。这个过滤属于协议边界的一部分如果源码里没做自己加上就行。另外out是PrintWriter构造时第二个参数传true表示自动 flush。如果没有自动 flushprintln()的内容会积在缓冲区只有缓冲区满了或手动调用flush()才会真正写到网络里表现为消息发出去对方收不到、自己还正常。这也是一个必须确认的初始化参数。5. 常见问题与避坑私聊丢失、界面卡死、中文乱码的五条真实记录这一章是把这个项目翻来覆去跑过以后总结出的高频故障。每一条都按“现象、原因、解决”的路径记录拿到源码如果复现了其中某个问题直接对照着查最快。5.1 现象私聊消息发出去对方没收到自己窗口也是空白这条最吓人因为它半通半断群聊正常自己发送后界面也有反应但对方完全没收到。查过服务端日志会发现MSG|private的消息确实进来了但clients.get(target)返回了null。原因通常有三个一是目标用户名的字段顺序取错了parts[2]取了群聊标志而不是用户名目标永远是all之类的固定字符串二是大小写不一致服务端在线表里存的是A同学客户端选中的列表项却是a同学get()精确匹配失败三是重复用户名检查没做两个同名用户先后登录时后登录的会把先登录的输出流覆盖掉先登录的用户实际已经隐形。解决路径分三步先在handleMessage的私聊分支打印一条System.out.println(private to: target)确认目标值再检查登录时用户名校验和客户端发送时是否做了trim()最后在登录检查里加上containsKey(username)的重复判断拒绝重复名登录。5.2 现象点击发送按钮后整个窗口卡死转圈响应任何操作这个故障的定位非常明确Swing 的 EDT 被网络 I/O 阻塞了。常见写法是发送按钮的监听器里做了一件多余的事发送消息后立刻调用socket.getInputStream().read()去等服务端回包比如等SYS消息。这个read()是阻塞的会一直卡住 EDT于是鼠标事件、重绘事件全部排队界面看起来就是死透了。原因在于发送按钮的回调执行在 EDT 上网络阻塞式读不能出现在这条线程上。解决方法是把读回复的动作全部交给读线程发送回调里只做out.println()然后立即返回。如果确实需要等服务端确认某个动作比如重复用户名检查可以在登录阶段用回调或监听机制异步等待结果但不要在 EDT 上同步阻塞。检查代码时重点看发送按钮的ActionListener里有没有readLine()、read()、Thread.sleep()这类调用有就全部移出。5.3 现象聊天内容里的中文变成问号或乱码乱码问题的根源几乎都是字符集不一致但具体到出错位置有三种。第一种是客户端编码 UTF-8、服务端解码用平台默认的 GBK或反过来。解决方法是两端InputStreamReader和OutputStreamWriter都显式指定StandardCharsets.UTF_8不要依赖系统默认字符集。第二种是字符串里混入了换行符println()发送时会把内容里自带的换行当成消息边界接收方readLine()读到半条消息就开始解析导致后续所有消息错位表现为这次乱码、下次错乱。解决方法是发送前对内容里的换行符做过滤或替换。第三种是PrintWriter构造函数忘了开 autoFlush半条消息在缓冲区里憋着下一次写才一起发出接收端读到的组合顺序就会错乱。解决方法是new PrintWriter(outputStream, true)第二个参数固定传true。5.4 现象服务端重启时报 Address already in use这个属于典型的端口回收问题。现象是服务端代码改完重启时在new ServerSocket(8888)这一行就抛BindException。原因通常是上一次的服务端进程没有真正关闭或者 Socket 关闭后端口处于TIME_WAIT状态还没被系统回收。特别是在 Windows 上直接关掉 IDE 运行窗口时子进程经常没被杀干净。解决第一确认所有socket.close()都在finally里执行确保连接释放第二在服务端启动时调用serverSocket.setReuseAddress(true)允许端口在TIME_WAIT状态下被重新绑定第三启动前用netstat -ano | findstr 8888Windows或lsof -i :8888Linux/macOS查一下端口占用把残留进程结束。5.5 现象用户列表显示有人在线但发消息对方不收这个坑的根源多半是幽灵用户。客户端直接关窗口时没有走退出逻辑服务端只在finally里清理如果清理逻辑缺失或者没有广播LIST在线列表里就会残留这个用户。另一种可能原因是网络断线时readLine()抛异常后线程没有走正确的退出路径直接退出了但没把用户从clients表移除。解决方法是看服务端的finally块是否完整移除用户后是否调用了broadcastUserList()。更稳妥的做法是给每个连接加一个心跳检测客户端每 5 秒发一条PING消息服务端如果超过 10 秒没收到该客户端的任何数据就强制关闭这个连接并清理在线表。心跳不会给聊天功能带来额外负担但对防止在线列表失真很有效展开写法放到最后一章。6. 验证与进阶双客户端实测、心跳扩展与 jar 打包拿到源码后第一件事不是读代码而是先把它跑起来。我一般会开三个进程一个服务端、两个客户端。客户端 A 登录名取A同学客户端 B 取B同学然后按下面这组步骤做一次完整实测。第一步A 在用户列表里选中 B输入一条私聊消息并发送确认 B 的窗口里能收到且 A 自己的窗口里也有回显。这验证了私聊的发送、转发、回显三条链路。第二步B 不选任何人直接发一条群聊消息确认 A 的窗口里能收到。第三步关掉 B 的窗口回到 A 的界面确认右侧用户列表里B同学消失。第四步把 B 重新登录进来A 的列表里再次出现B同学并且此时发私聊仍然正常。这四步走完群聊、私聊、用户列表三项核心功能就都验证到了。接下来可以自己动手加两个扩展。第一个是心跳保活。客户端加一个定时任务每 5 秒发一次PING服务端在ClientHandler里记录lastActiveTime每次收到任何消息都更新它再起一个定时检查线程超过 10 秒未活跃的连接直接关闭并清理在线表。这个扩展能解决大多数幽灵用户问题代码量不超过 20 行。第二个是内容转义。在客户端发送前把内容里的|替换成全角竖线展示时还原可以避免用户聊天内容里带竖线导致消息截断的隐性 bug。最后是打包交付。在src目录下先执行javac -encoding UTF-8 -d out $(find . -name *.java)编译再执行jar cfe chatroom.jar 主类名 -C out .生成可执行 jar。服务端和客户端分别打两个包或者打包成一个再带参数区分启动类。没有 Maven 环境也能完成关键点是编译时指定-encoding UTF-8否则中文字面量在 Windows 上会变成乱码。我在这类项目的排查上最深的教训是网络程序的功能验证不能只看自己点了一下没报错必须开两个客户端、分角色、反复断线重连实测而且每次改完协议都强制自己把服务端和客户端的字段解析部分重新对一遍。从那以后我写带界面的网络程序都会先检查读线程、EDT 更新和finally清理这三件事再做功能联调。希望这份源码和这篇文章能帮你少走几步弯路。本文还有配套的精品资源点击获取