简介:一款基于Java的实时聊天室项目,界面完整,提供群聊、私聊与在线用户列表功能,适合正在学习Java网络编程、多线程及GUI开发的初学者,也可作为课程设计与毕业设计的参考原型。压缩包内含42个文件,其中14个java源码与15个class编译文件构成主体,另配project、classpath、txt等工程配置文件,压缩后仅46KB,轻量且结构清晰。已有7431人浏览学习,属于同类项目中热度较高的实战案例。项目通过ServerSocket与Socket建立客户端-服务器通信,利用多线程并行处理用户收发消息,使用Swing组件绘制聊天窗口、输入框与用户列表,并在交互中处理事件监听与状态更新。此外还涉及消息序列化传输、集合框架维护在线用户、异常处理等知识点,能帮助学习者在真实场景中理解Socket通信与多线程协作,快速搭建并扩展自己的聊天室应用。
1. 为什么你的Java聊天室总差口气:群聊、私聊和用户列表才是完整的分水岭
我第一次写完Java聊天室,最得意的不是能发消息,而是用户列表真的“会动”——有人进来自动多一行,有人退出自动消失,双击任意一个名字就能弹出私聊窗口。翻过不少同类源码后会发现,能跑通群聊的示例很多,但界面完整、私聊路由和用户列表同步全都到位的反而是少数,大多数人卡在消息协议那一层,后面的功能全堆在一串if里,越写越乱。这篇把一个整理过很多次的落地方案拆开讲:消息协议怎么定,服务端和客户端各负责什么,群聊、私聊、用户列表三条主线怎么串起来,以及那些最容易翻车的边界问题。适合拿它做课程设计、毕业设计,或者想弄懂Socket多线程和Swing协作关系的入门者。
2. 先画清楚三条主线:群聊广播、私聊路由与用户列表同步
2.1 为什么用“一个客户端一条线程”的模型,而不上NIO
聊天室是一个典型的C/S模型:服务端负责维护所有客户端的连接,客户端只跟服务端通信。标题里的三个功能——群聊、私聊、用户列表——在服务端分别对应三种操作:群聊是一次广播,私聊是一次定向转发,用户列表是一次状态同步。它们都依赖服务端维护的一份“当前在线用户”名单,所以数据结构和消息协议是必须先想清楚的两件事。
线程模型的选择上,常见做法是一个客户端对应一条线程,配合固定大小的线程池,也就是BIO。理由很简单:聊天室这种场景,一个服务端同时撑几十个客户端是常态,但让它去扛几百上千个长连接,那是正经IM产品要考虑的事,课程设计和内部工具没必要用NIO/AIO把代码复杂度拉高。我用Executors.newFixedThreadPool(50),连接数超过50时,多余的客户端会在Accept队列里排队,日志里能看到拒绝策略触发,至少能提前知道服务端的瓶颈在哪。
很多入门者在这里会犯第一个错:每accept一个连接就直接new Thread去跑,线程数没有上限。单个连接看不出问题,一旦客户端反复断开重连,JVM里会堆出大量僵尸线程和TIME_WAIT连接,服务端莫名其妙变慢,重启也没用。线程池加有界队列是更稳的组合,开发期可以把线程数设小一点,比如10,方便用日志观察每个连接的建立和释放。
服务端的启动流程很固定:创建ServerSocket、绑定端口、循环accept,每拿到一个Socket就封装成任务丢进线程池。端口选择上,开发期用8888这类好记的端口,部署到服务器时换到1024以上的其他端口,避免和系统服务冲突。
2.2 消息协议:用JSON统一格式,type字段决定路由
消息协议是聊天室的骨架,也是最多人偷懒的地方。直接拿“昵称:内容”这种字符串拼消息,群聊还能凑合,私聊一加就露馅——你得在内容里区分昵称、目标和正文,还得约定分隔符,万一用户输的内容里恰好包含分隔符,整条消息解析就崩了。我一般直接用JSON,解析虽然比手工切字符串慢一点,但字段清晰、扩展方便。后面加心跳、加离线消息、加消息ID,都只需要在JSON里加字段,不用改传输逻辑。
把消息统一成四个核心字段。type表示消息类型,服务端按它分发;from是发送者昵称;to是私聊目标昵称,群聊时留空;content是正文。另外加一个time字段用于界面展示。下面这张表列出常用的消息类型:
| type | 方向 | 作用 |
|---|---|---|
| login | 客户端到服务端 | 登录,携带昵称 |
| logout | 客户端到服务端 | 主动下线 |
| group | 客户端到服务端,服务端广播 | 群聊消息 |
| private | 客户端到服务端,服务端转发 | 私聊消息 |
| user_list | 服务端到客户端 | 同步在线用户列表 |
| system | 服务端到客户端 | 系统通知,如用户上下线、目标不在线 |
下面是一条登录消息、一条私聊消息和一条群聊消息的示例,字段顺序无所谓,但键名必须全局统一:
{"type":"login","from":"A同学","time":"2025-01-01 12:00:00"} {"type":"private","from":"A同学","to":"B同学","content":"在吗","time":"2025-01-01 12:01:00"} {"type":"group","from":"A同学","content":"大家好","time":"2025-01-01 12:02:00"}为什么不直接用Java对象序列化?两个原因:一是聊天室客户端未必只有Java一种实现,JSON跨语言、可读性强,服务端打印日志排查问题时,一行JSON能直接看出是哪条消息出了问题,序列化对象则是一堆不可读的字节;二是流式传输上,JSON字符串天然按行分隔,配合BufferedWriter写一行、BufferedReader读一行,应用层协议就是“一行一条消息”,粘包问题基本不会出现。
这里有个细节值得强调:用readLine接收消息,意味着消息内容里不能出现换行。聊天正文里如果允许用户输入换行,发送前要替换成转义字符或统一去掉;私聊窗口的多行输入框同理。这个问题不处理,用户粘贴一段带换行的文字,服务端会一次读到两行,第一行解析失败,第二行甚至会被当成伪消息处理,属于典型的界面完整度杀手。
2.3 用户列表同步:全量拉取加增量广播
用户列表是界面里最容易做烂的模块。它有两种同步方式,全量和增量,实际项目要把两者配合使用,而不是只选一种。
全量拉取的场景是登录。新客户端连上服务端后,服务端要把当前所有在线用户一次性发给它,否则新用户打开界面就只能看到自己,其他人的状态全靠后续消息去猜。增量广播的场景是上下线。任何一个用户登录或离线,服务端都要向所有在线用户广播一条user_list消息,界面收到后对列表做一次全量刷新。这里用全量刷新实现,是因为在线人数少时,重绘一次列表的开销远小于维护增删状态的复杂度。
很多半成品聊天室只做了群聊和私聊,用户列表要么写死,要么登录时拉一次就再也不更新。后果是有人下线了,他还在列表里,双击名字打开私聊窗口,发出去的消息石沉大海。这类问题不影响编译和运行,但演示时被问一句“这个人已经下线了为什么还显示”,基本就露馅了。第4章会给客户端刷新用户列表的具体代码,这里先把协议层的设计对齐:登录时先全量后增量,上下线时广播增量,客户端无脑全量刷新。
3. 服务端落地:在线用户表、线程池与消息分发
3.1 服务端骨架:ServerSocket + 线程池 + 在线用户表
服务端核心是一张“在线用户表”。我用ConcurrentHashMap<String, PrintWriter>,key是昵称,value是发送消息用的PrintWriter。选ConcurrentHashMap而不是HashMap,是因为多线程同时登录、离线时,普通HashMap在并发扩容场景下会出并发问题,轻则丢数据,重则导致CPU飙升,属于那种不压测发现不了、压测才发现的黑匣子。这个类的读写非常频繁,几乎每条消息都要查一遍,所以还要避免在遍历时做耗时操作,比如把广播逻辑放在锁外处理。
服务端主框架代码:
public class ChatServer { // 在线用户表:昵称 -> 发送消息的出口 private static final ConcurrentHashMap<String, PrintWriter> onlineUsers = new ConcurrentHashMap<>(); private static final ExecutorService pool = Executors.newFixedThreadPool(50); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8888); System.out.println("聊天室服务端已启动,端口 8888"); while (true) { Socket socket = serverSocket.accept(); pool.execute(new ClientHandler(socket)); } } }逻辑说明:accept阻塞等待新连接,每来一个客户端就创建一个ClientHandler任务提交到线程池,每个任务对应一个Socket连接的生命周期。onlineUsers保存昵称到PrintWriter的映射,PrintWriter是服务端向这个客户端发送消息的唯一出口,后面群聊广播和私聊转发都靠它。参数说明:端口8888是本地开发常用值,部署到服务器时要换端口并注意防火墙;线程池固定50,超过上限的新连接会停留在Accept队列排队,极端情况下客户端会感觉“连上了但没人理”,这个行为要靠日志观察,不能靠猜。
ClientHandler内部的关键是初始化流程。把socket包装成BufferedReader和PrintWriter,PrintWriter开启autoFlush,然后进入读消息循环。这里把读循环和消息分发分开,读循环只负责取出一行JSON,分发方法负责解析并处理:
public class ClientHandler implements Runnable { private Socket socket; private PrintWriter out; private BufferedReader in; private String nickname; public ClientHandler(Socket socket) { this.socket = socket; } public void run() { try { in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); String line; while ((line = in.readLine()) != null) { handleMessage(line); } } catch (IOException e) { e.printStackTrace(); } finally { // 离线清理 if (nickname != null) { onlineUsers.remove(nickname); broadcastUserList(); } closeQuietly(socket); } } }逻辑说明:run的finally块是保证用户列表不残留脏数据的关键。无论正常下线还是连接异常断开,finally都会把昵称从在线用户表移除,并向所有在线用户广播一次用户列表刷新。nickname是在登录成功后才赋值的,用来判断这个连接是否已经完成登录。这里有个小陷阱:如果客户端连上后不发login就断开,nickname为null,remove就不会执行,避免把别人同名用户的记录误删。
3.2 登录处理:先全量同步,再增量广播
当收到type为login的消息时,服务端要做三件事:把昵称和out写入onlineUsers;给这个新用户单独发送一份全量在线列表;向所有在线用户广播一份新的在线列表。注意顺序,前两步有先后关系:必须先put,再给新用户发全量列表,否则新用户收到的列表里不包含自己。
登录处理代码:
private void handleLogin(String json) { nickname = parseNickname(json); // 重名检查 if (onlineUsers.containsKey(nickname)) { out.println(buildSystemMsg("昵称已被占用")); return; } onlineUsers.put(nickname, out); sendFullUserList(nickname); // 只发给新用户 broadcastUserList(); // 广播给所有人 }逻辑说明:重名检查放在put之前,通过out直接回一条system消息,客户端收到后提示“昵称已被占用”。这个检查和第5章会提到的重名坑相互呼应。sendFullUserList和broadcastUserList内部都会构建一条user_list消息,消息体是当前在线昵称的集合;两者的区别只是发送目标不同,一个是单发,一个是群发。
user_list消息的内容就是在线用户表的所有key。这里要注意序列化顺序:ConcurrentHashMap的遍历不保证顺序一致,如果客户端每次刷新列表都乱序,体验会很差。我一般会在发user_list前把昵称收集到一个ArrayList里做一次排序,再放进JSON。排序字段用昵称的字符串自然序就行,主要目的是让列表在多次刷新之间保持稳定。
3.3 群聊广播与私聊路由:一对多与一对一的两种分发
群聊和私聊的处理都在消息分发方法里。分发方法先解析type字段,再按类型走到不同分支。群聊是遍历onlineUsers,把消息转发给所有人,但通常不转发给发送者本人——客户端的聊天区已经在本地把自己发送的内容显示出来了,再收一遍会重复显示。私聊是根据to字段去onlineUsers里找目标昵称,找到就只发给目标。
群聊与私聊的分发代码:
private void handleMessage(String json) { String type = parseType(json); switch (type) { case "login": handleLogin(json); break; case "group": broadcastGroupMessage(json); break; case "private": handlePrivateMessage(json); break; case "logout": // 触发 finally 清理即可 break; default: System.out.println("未知消息类型: " + json); } } private void broadcastGroupMessage(String json) { String from = parseNickname(json); for (Map.Entry<String, PrintWriter> entry : onlineUsers.entrySet()) { if (!entry.getKey().equals(from)) { entry.getValue().println(json); } } } private void handlePrivateMessage(String json) { String from = parseNickname(json); String to = parseTarget(json); PrintWriter target = onlineUsers.get(to); if (target != null) { target.println(json); // 回显给发送者自己,私聊窗口才能看到 onlineUsers.get(from).println(json); } else { onlineUsers.get(from).println(buildSystemMsg("用户 " + to + " 不在线")); } }逻辑说明:群聊广播过滤掉发送者,是因为客户端已经自己显示了发出的群聊消息;私聊转发给目标后,还要把这条消息也发给发送者一份,这样发送者的私聊窗口才能看到完整的会话记录。很多人漏掉这一步,表现为“私聊发出去了,目标收到了,自己窗口没反应”。私聊目标不在线时,回一条system消息而不是静默丢弃,用户体验上是“有反馈”和“进黑洞”的区别。
参数说明:parseType、parseNickname、parseTarget这几个解析方法基于同一个JSON解析工具。有两点要注意:一是解析失败的兜底,JSON格式不对时不要抛异常中断连接,而是打印日志并忽略这条消息,因为一条坏消息不该把整个连接搞崩;二是聊天正文里出现JSON特殊字符的问题,发送端要把双引号和反斜杠做转义,否则解析出的字段会错位,这个问题和第5章的乱码一样属于高频坑。
3.4 离线清理与用户列表广播,放在finally更稳
离线清理的逻辑放在ClientHandler的finally块里,而不是放在logout消息的处理分支里。原因是:客户端正常退出时会发logout,但强制关窗口、断网、进程被杀这些情况,服务端可能根本收不到logout,只会发现readLine返回null或抛IOException。如果清理逻辑只在logout分支里做,异常退出的用户就会永远留在在线用户表里,直到服务端重启。
所以我在run方法里把清理统一放在finally:从onlineUsers移除当前昵称,广播用户列表,最后关闭socket。这也是第5.3节“用户下线后列表不更新”的根治方案。logout消息仍然保留,它存在的意义是让客户端主动告知“我要下线了”,服务端可以提前做一些资源清理,但不依赖它来维持用户列表的正确性。
4. 客户端界面落地:主窗口、私聊窗口与用户列表刷新
4.1 主窗口布局:左侧用户列表,右侧聊天区
界面完整度首先看布局。主窗口我的做法是BorderLayout:左侧放一个JScrollPane包裹的JList,宽度150,负责在线用户展示;右侧用垂直分割,上面是聊天区JTextPane,下面是输入区,输入区由JTextField和JButton组成。聊天区用JTextPane而不是JTextArea,是因为私聊、群聊、系统消息可以用不同颜色展示,这对区分信息来源很有帮助。
初始化代码:
JFrame frame = new JFrame("Java聊天室"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setSize(600, 450); frame.setLayout(new BorderLayout()); JList<String> userList = new JList<>(); DefaultListModel<String> userModel = new DefaultListModel<>(); userList.setModel(userModel); userList.setPreferredSize(new Dimension(150, 400)); JTextPane chatPane = new JTextPane(); chatPane.setEditable(false); JScrollPane chatScroll = new JScrollPane(chatPane); JTextField inputField = new JTextField(); JButton sendButton = new JButton("发送"); JPanel inputPanel = new JPanel(new BorderLayout()); inputPanel.add(inputField, BorderLayout.CENTER); inputPanel.add(sendButton, BorderLayout.EAST); frame.add(new JScrollPane(userList), BorderLayout.WEST); frame.add(chatScroll, BorderLayout.CENTER); frame.add(inputPanel, BorderLayout.SOUTH);逻辑说明:userModel是JList的数据源,后面收到的user_list消息直接操作userModel,clear后重新addElement,JList会自动重绘。JTextPane设置为不可编辑,聊天内容统一通过appendMessage方法追加;JTextField监听回车键,和点击“发送”按钮走同一个发送方法,这是桌面端聊天工具的标准习惯。参数说明:窗口尺寸600x450在开发期够用,私聊窗口可以设小一点,比如480x360。
窗口关闭行为要注意:frame的EXIT_ON_CLOSE会直接结束JVM进程,但客户端在退出前应该向服务端发一条logout消息。做法是重写windowClosing事件,先发送logout再退出,而不是依赖服务端读流时发现异常。正常流程和异常流程都覆盖到,服务端的finally清理才有意义。
4.2 连接服务端与登录:登录对话框先行
客户端启动后先弹登录对话框,收集昵称、服务器地址和端口,然后建立连接、发送login消息,最后启动读消息线程。读消息线程必须放在后台子线程里,不能放到Swing事件线程,否则读不到消息时界面会一直无响应;所有UI更新再从子线程里通过invokeLater切回EDT,详见第5.1节。
连接与登录代码:
socket = new Socket(serverAddress, 8888); out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); sendMessage("login", nickname, "", ""); new Thread(this::readLoop).start();逻辑说明:PrintWriter第二个参数true是autoFlush,每次println后立即发送,不用手动flush;InputStreamReader和OutputStreamWriter都显式指定UTF-8,与服务端保持一致,中文才不乱码。readLoop是一个while循环,readLine读到一行就调用dispatchMessage交给消息分发方法;返回null表示连接已断开,循环退出,界面提示“连接已断开”。
登录这一步还有一个细节:连接成功和登录成功是两回事。先建立socket再发login,服务端在login分支里会校验重名。收到system类型的“昵称已被占用”时,客户端要提示用户换昵称,并关闭socket,不能继续进入主界面。这个顺序处理不对,就会出现“连接成功但没登录成功,依然打开了主窗口”的尴尬状态。
4.3 群聊消息:发送、接收与本地回显
群聊发送就是把输入框内容包装成JSON,通过out发送,同时本地追加一条自己的消息。接收群聊消息时,根据type判断来源,不同消息用不同颜色展示:群聊默认黑色,私聊用蓝色,系统消息用灰色。这里用一个appendMessage方法统一处理,核心是操作JTextPane的StyledDocument,而不是用setText覆盖。
群聊发送与消息展示代码:
private void sendGroupMessage() { String text = inputField.getText().trim(); if (text.isEmpty()) { return; } String json = buildJson("group", nickname, "", text); out.println(json); appendMessage("我", text, Color.BLACK); inputField.setText(""); } private void appendMessage(String sender, String content, Color color) { SwingUtilities.invokeLater(() -> { try { StyledDocument doc = chatPane.getStyledDocument(); doc.insertString(doc.getLength(), String.format("%s: %s%n", sender, content), createStyle(color)); chatPane.setCaretPosition(doc.getLength()); } catch (BadLocationException e) { e.printStackTrace(); } }); }逻辑说明:sendGroupMessage在发送群聊消息后,本地把自己这条消息追加到聊天区,这是必要的——服务端广播群聊时会过滤掉发送者,不本地回显的话发送者看不到自己的发言。appendMessage里用StyledDocument.insertString而不是setText,是为了保留历史消息,同时更新滚动条位置,让最新消息始终可见。参数说明:createStyle根据颜色参数生成一个新的Style,私聊和群聊的颜色区分就在这个方法的调用处决定;insertString的位置参数用doc.getLength()表示追加到末尾。
接收消息的分发逻辑里要严格区分群聊和私聊:群聊消息直接走appendMessage,显示在公共聊天区;私聊消息则要看目标是不是当前登录用户,是的话除了显示在主聊天区,还要转给对应的私聊窗口。这部分在4.4里一起说清楚。
4.4 私聊窗口:双击用户列表打开、独立会话展示
私聊窗口是一个独立的JFrame,包含自己的一块消息区和一个输入框。双击左侧用户列表的某项时,打开或激活对应目标用户的私聊窗口;窗口标题用“与XX私聊”,窗口内部保存对方昵称。我一般用一个HashMap<String, PrivateWindow>维护已打开的私聊窗口,避免重复创建。
双击打开私聊窗口:
userList.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { String target = userList.getSelectedValue(); if (target == null || target.equals(nickname)) { return; } openPrivateWindow(target); } } }); private void openPrivateWindow(String target) { PrivateWindow window = privateWindows.get(target); if (window == null) { window = new PrivateWindow(target); privateWindows.put(target, window); } window.toFront(); }逻辑说明:双击判定是getClickCount()==2,target为空说明点到了空白处,target等于自己昵称时不允许和自己私聊。privateWindows缓存已打开的窗口,已存在就直接置前,不存在才新建,避免同一个对象打开十几个窗口。私聊窗口的发送逻辑和主窗口类似,只是buildJson时会填上to字段:目标昵称,接收到服务端转发回来的私聊消息也要追加显示到当前窗口。
客户端收到私聊消息时,要根据from判断来源:如果from不是自己,说明是别人发来的私聊,找到对应私聊窗口并追加显示;如果from是自己,说明是服务端回显的私聊消息,也要追加到对应窗口。这个对称逻辑很多人会漏,导致私聊会话里看不到自己发过的内容,体验上像“我只说了一句话,对方回了一串,中间缺了上下文”。
4.5 用户列表刷新:收到user_list后重绘JList
用户列表的刷新逻辑比较直接:收到type为user_list的JSON后,解析出在线昵称数组,通过invokeLater更新userModel。具体做法是clear后重新addElement,这样JList的选中状态和滚动条会由Swing自行处理,比直接setListData更稳定。
刷新代码:
private void refreshUserList(List<String> users) { SwingUtilities.invokeLater(() -> { userModel.clear(); for (String name : users) { userModel.addElement(name); } }); }逻辑说明:clear和addElement都在invokeLater里执行,保证不跨线程操作Swing组件。收到的user_list消息可能是登录时的全量列表,也可能是上下线时的增量广播结果。客户端不区分这两种情况,统一全量刷新,因为在几十人规模下重绘一次JList的开销可以忽略,而代码逻辑因此简单很多。这是典型的“以空间换时间”思路,也是界面完整项目里值得坚持的取舍。
5. 踩坑记录:聊天室最常见的5个翻车现场
聊天室的坑不像算法题那样抽象,它们都在运行时才会暴露,而且经常是组合出现。下面这几条是我自己调试中踩过的,按从“必现”到“偶发”的顺序排,每一条都是现象、原因、解决三段。
5.1 界面卡死:在子线程里直接更新Swing组件
现象:运行后聊天窗口无响应,标题栏出现“Not Responding”,点击任何按钮都没反应,几分钟后系统提示强制关闭。
原因:读消息的后台线程收到消息后,直接调用了chatPane.insertString或者userModel.addElement。Swing组件不是线程安全的,所有UI更新必须在事件分发线程中执行;在子线程直接操作组件,轻则显示异常,重则整个EDT被阻塞,界面假死。排查时在堆栈里能看到类似“Thread-x”持有组件锁、“AWT-EventQueue”一直等待的迹象。
解决:所有跨线程更新UI的地方,统一用SwingUtilities.invokeLater包一层,例如前面appendMessage和refreshUserList的实现。invokeLater不会立即执行,它把任务排到EDT队列后面,同一条消息连续多次调用也没问题,Swing会按顺序执行。要记住的边界是:只有UI更新需要切线程,网络IO、JSON解析这些重活放在后台线程里做反而更合理。
5.2 用户重名:后登录的顶掉先登录的
现象:两个客户端用同一个昵称登录,后登录的用户一进来,先登录用户的所有群聊消息和私聊消息都收不到了,在线用户列表里只剩下一个人。
原因:ConcurrentHashMap的key直接用昵称,put操作会把同key的旧值覆盖。我的第一版连重名判断都没有,后登录者覆盖先登录者,先登录者虽然socket还活着,但已经不在在线用户表里了,自然收不到任何转发消息。
解决:在handleLogin里先做containsKey检查,存在就拒绝登录并回一条system消息,客户端收到后提示“昵称已被占用”并关闭连接。更严谨的方案是给每个用户分配唯一ID,内部用ID做key,界面上显示昵称;但课程设计阶段做重名拒绝就够了,唯一ID方案可以放进第6章的进阶方向里。注意拒绝时要先把消息发出去再关闭socket,顺序反了客户端会收到一个空的连接关闭。
5.3 用户下线后列表不更新:清理逻辑放错了位置
现象:一个客户端直接关掉窗口,其他所有客户端的用户列表里他还在,双击他发私聊,消息石沉大海,也没有“不在线”提示。
原因:客户端退出时没有发送logout消息,服务端的消息分发里也就没有执行移除操作。真正的关键问题是清理逻辑只存在于logout分支,而异常断网、强制关窗口、进程被杀这些情况下,服务端根本收不到logout,readLine只会返回null或者抛异常。如果清理只靠logout,这些非正常退出的用户就会一直留在在线用户表里。
解决:把离线清理统一放在ClientHandler的finally块中,无论正常退出还是异常断开,都执行onlineUsers.remove和broadcastUserList。logout消息仍然可以保留,但只做提前告知,不承担维护用户列表正确性的责任。广播要在remove之后执行,否则广播出来的列表里还包含刚离线的用户。
5.4 中文乱码:两端编码各说各话
现象:A同学发“你好”,B同学看到“浣犲ソ”;有时同一台机器上跑两端没问题,换一台机器就乱码。
原因:Socket流默认使用JVM的平台编码,Windows中文系统是GBK,Linux通常是UTF-8。服务端和客户端只要有一边没显式指定编码,就可能乱码,而且这种乱码不总是出现,取决于双方JVM的默认编码,排查起来非常“玄学”。另一个隐蔽来源是String.getBytes()不带Charset参数,直接用了平台默认编码。
解决:在服务端和客户端的Socket流初始化处,统一指定UTF-8,InputStreamReader和OutputStreamWriter的构造参数都写死“UTF-8”。同时约定buildJson里对特殊字符做转义,双引号、反斜杠、换行都要处理,尤其是换行:readLine协议下,消息正文里不能包含换行符,否则一条消息会被拆成两行,第二行解析直接失败。
5.5 端口占用:服务端重启报BindException
现象:改完代码重启服务端,控制台报Address already in use,但上一个进程明明已经关掉了。
原因:Java进程虽然退出,但操作系统可能还有TIME_WAIT状态的socket占用端口;或者上一个服务端进程没有真正结束。这是我开发时遇到频率最高的启动期问题,浪费的时间比业务bug还多。
解决:ServerSocket创建后调用setReuseAddress(true),允许端口重用;重启前确认旧进程已被杀掉,Windows下用netstat -ano查端口对应的PID,再按PID结束进程;如果没开setReuseAddress,即使进程退出了,TIME_WAIT状态也可能让端口短时间无法绑定。这一条是开发期的经验总结,不属于业务逻辑,但能省下不少磕绊时间。
6. 进阶:心跳检测、离线消息与消息样式
基础功能跑通后,想让这个项目再上一个台阶,我建议优先做三件事:心跳检测、离线消息和消息样式。它们分别对应系统稳定性、消息可靠性和界面可用性,刚好把聊天室从“能跑”推向“能演示”。
第一是心跳检测。目前的实现里,客户端正常退出能清理,但断网、休眠、强杀进程这些情况,服务端的清理依赖finally,而finally依赖readLine返回null或抛异常——网络异常时这个触发往往有延迟。可以约定客户端每30秒发一条ping消息,服务端记录每个用户最后一次心跳时间,再开一个定时任务,把超过90秒没有心跳的用户踢下线并广播用户列表刷新。这样用户列表的准确性不再依赖连接状态,而是依赖活跃状态。
private void checkHeartbeat() { long now = System.currentTimeMillis(); for (String name : onlineUsers.keySet()) { Long last = lastHeartbeat.get(name); if (last != null && now - last > 90000) { kickUser(name); } } }逻辑说明:lastHeartbeat是另一个ConcurrentHashMap,记录昵称到最后心跳时间的映射。客户端发ping时更新它,定时任务周期性地扫描并踢掉超时用户。注意踢人不能只移除map,还要关闭对应socket并广播用户列表,否则对方的连接还挂在服务端。心跳间隔和超时阈值根据场景调:聊天室可以宽松到30秒/90秒,局域网即时演示可以更短,比如10秒/30秒,体验上更敏捷。
第二是离线消息。私聊目标不在线时,当前处理是回一句“用户不在线”,消息本身被丢弃。如果想让消息不丢,就给每个用户维护一个离线队列,用ConcurrentHashMap<String, List<String>>保存,登录时把队列里的消息逐条补发,再清空队列。队列要限制上限,比如100条,防止长期离线的用户积压大量消息。群聊离线消息一般不做,因为群聊强调实时性,补发历史反而造成刷屏,私聊才有补发的价值。
第三是消息样式。现在聊天区已经用颜色区分群聊、私聊和系统消息,还可以继续加:时间戳显示在消息前、系统提示居中、自己的消息靠右、别人的消息靠左。这些用JTextPane的ParagraphAttributes都能实现,改动集中在appendMessage一个方法里,每加一种样式都只是往styled document里塞一段带样式的文本。界面的“完整感”往往就是这些细节堆出来的。
我自己的习惯是,每次跑通一个新功能,先不急着加下一个,而是把正常流程和异常流程各跑一遍,重点看服务端日志和客户端界面的表现是否一致。聊天室这种项目,正常链路大家都能写出来,拉开差距的永远是断线、重连、重名、消息丢失这些边界情况。希望这篇能帮你在这些坑上少花点时间。
本文还有配套的精品资源,点击获取