☰
Java聊天室实战:TCP协议、线程模型与并发容器解析
2026/10/26 15:41:35 网站建设 项目流程

做 Java 后端时间长了,隔三差五就有人问我:想练手、想搞懂网络编程,从哪个项目入门最合适?我基本都推荐同一个答案——Java语言实现网络聊天室。这东西看着简单,不就是发个消息收个消息嘛,但真动手写一遍你就会发现,它把 TCP 连接管理、线程模型、并发容器、协议设计、异常恢复这些后端吃饭的本事,全都串在一条线上了。写出来能跑,算入门;能扛住几十个人同时在线不出乱子,才算真正摸到门槛。这篇我按自己带新人和自己踩坑的经验,把整个聊天室从架构选型到协议设计、从代码骨架到线上排查问题,一节一节讲透,代码可以直接抄着改,涉及取舍的地方我会说清楚为什么这么选,新手和有基础的都能找到自己要的那部分。

1. 整体设计和选型:先想清楚边界,再动手写代码

很多人写聊天室,打开 IDE 就new ServerSocket(8888),然后while(true)里套accept(),一个下午能跑通双人对话,接着就卡住了——想加群聊,发现要维护用户表;想加私聊,发现消息里没有目标字段;加了个心跳,服务端开始莫名其妙报错。问题不在于代码写得丑,而在于一开始没想清楚这个系统到底有哪些东西是必须存在的。

1.1 从需求倒推架构,别上来就堆技术

我在动手之前会先列一张最小需求表,把“必须有的”和“以后可能加的”分开。聊天室这个项目,我个人判断的必选项是这五条:多客户端同时在线、群发消息、点对点私聊、用户上下线通知、连接异常断开后服务端不能崩。可选的是消息持久化、文件传输、历史记录拉取、WebSocket 接入。

这个划分直接决定了架构复杂度。如果只做必选项,一个基于阻塞 IO 的 BIO 模型完全够用,几百行代码,逻辑直白,调试方便,特别适合拿来理解 TCP 的本来面目。如果一上来就奔着“高并发万人同时在线”去,那你要处理的是 IO 多路复用、事件循环、零拷贝,代码量翻十倍,而你连粘包是怎么回事都没搞明白,纯粹是给自己添堵。

所以我的建议很明确:第一版就用 BIO 加多线程,把协议和业务逻辑跑通;等你确认自己理解了阻塞点在哪里,再考虑升级到 NIO。这不是妥协,这是正确的学习顺序。我见过太多人直接抄一个 Netty 的 demo,跑起来了,问他ChannelPipeline为什么这么排、ByteToMessageDecoder什么时候被调用,一句都答不上来,这不叫会。

选型上还有几个点要提前定死,否则写到一半会返工:

  • 传输层用 TCP 而不是 UDP。聊天消息对实时性的要求远低于对可靠性的要求,丢一条“我到了”比晚到 200 毫秒严重得多,TCP 的重传和有序保证正好对上。
  • 消息编码用 UTF-8 而不是平台默认编码。这条看着是小事,实际是新手第一大坑,Windows 上跑得好好的,换台机器全是乱码。
  • 不用DataOutputStream.writeUTF()直接发消息。这个方法内部有个 65535 字节的长度限制,正常聊天够用,但你哪天想发个稍长的“公告”,直接抛UTFDataFormatException,而且它把长度写在消息头里,你没法自定义消息类型。自己定协议更灵活。
  • 在线用户表用ConcurrentHashMap而不是HashMap。这个没什么可讨论的,多线程环境下的基本要求。

1.2 通信协议怎么定:给消息加个“信封”

TCP 是字节流,它不认边界。你发两次“你好”和“再见”,对端可能一次读到“你好再见”,也可能分三次读到“你”“好再”“见”。这就是粘包和拆包,处理不好就是聊天室里最诡异的一类 bug——用户 A 说的话跑到用户 B 的窗口里去了。

解决思路只有一条:在应用层自己定义消息边界。业内的主流做法有三种,我列个表对比一下,你就知道该选哪个:

方案实现方式优点缺点适用场景
定长消息每条消息固定 N 字节,不足补空格实现最简单,读取逻辑一行代码浪费带宽,长度不可变,超长要截断教学演示、固定格式指令
分隔符用\n或特殊字符结尾人类可读,调试方便消息体里不能出现分隔符,要转义文本协议、日志流
长度前缀消息头写入 body 字节数二进制安全、效率高、可扩展需要先读头再读体,实现稍复杂生产环境首选

聊天室我毫无悬念选长度前缀。具体帧结构我自己习惯这么定:

+--------+--------+--------+--------+----------+----------------+ | 总长度(4字节 int) | 类型(1字节) | 载荷(变长 UTF-8) | +--------+--------+--------+--------+----------+----------------+

总长度字段记录的是“类型 + 载荷”的字节数,接收方先readInt()拿到长度,再readFully()精确读这么多字节,一条消息就完整了,一条不多一条不少。类型字段用来区分登录、群聊、私聊、心跳、下线通知这些不同语义,载荷就是实际内容。

这里有个细节我得强调:长度字段一定要用DataInputStream.readInt()/DataOutputStream.writeInt()来读写。这两个方法固定使用大端序(Big-Endian),跟平台无关。如果你图省事自己用移位拼字节,一不小心写成小端,在自己机器上跑得欢,换台机器就全乱。Java 的这套 API 已经帮你把字节序问题解决了,没必要重新发明。

另外载荷部分我之所以不直接放字符串,而是先转成byte[]再写,是为了让长度字段和实际写入的字节数严格一致。中文在 UTF-8 下一个字占 3 个字节,你要是拿String.length()当长度去算,必错。

1.3 线程模型:每个连接一个线程,什么时候会翻车

BIO 模型最直观的写法是:主线程accept()拿到一个Socket,就丢给一个新线程去处理这个连接的读写。这个模型的优点是代码极其简单,读逻辑就是线性的read()循环;缺点是线程数和连接数一比一绑定。

线程不是免费的。每个 Java 线程默认栈大小是 1MB(可以用-Xss调小,比如-Xss256k),1000 个连接就是 1GB 内存被栈吃掉,还没算线程上下文切换的开销。所以这个模型有个明确的天花板,大概几百个连接量级,超过之后性能断崖式下跌。

但注意,这个天花板对学习项目来说完全不是问题。我们做聊天室练习,目标不是扛一万人,而是理解连接、读写、并发这三件事怎么配合。所以我第一版就用“一连接一线程”,但做了两点改良:

第一,不用new Thread()裸创建,用一个带名字和数量上限的线程池。裸创建线程出了问题你连是哪个连接崩的都定位不了,线程池配合线程名前缀(比如client-handler-),日志里一眼就能看出是哪个客户端。这里有个坑:不能直接用Executors.newFixedThreadPool(n),因为处理连接的线程会长期阻塞在read()上,固定大小的池子很快就被占满,后面的连接全部排队等死。要么用newCachedThreadPool()(空闲线程自动回收,但上限不受控),要么自己new ThreadPoolExecutor(0, 200, 60L, TimeUnit.SECONDS, new SynchronousQueue<>())把上限卡住。

第二,把“读”和“写”分开考虑。第一版为了简单,写操作直接在读线程里做(收到消息就遍历在线表往别人的输出流里写),但这埋了个雷:如果某个客户端网络很慢,你往它的OutputStream写数据会阻塞,而这个阻塞发生在广播者自己的线程里,等于一个慢用户拖住了所有人。所以后面我在第 3 节会讲怎么用“每连接一个发送队列 + 独立写线程”把这个坑填上。

2. 核心细节解析:连接、协议、用户表、心跳四件事

架构想清楚了,接下来就是四个必须写对的核心部件。这四块任何一块写歪,整个聊天室就会出现那种“能跑但说不清为什么”的玄学 bug。

2.1 服务端连接的建立与生命周期管理

服务端启动的第一步是ServerSocket:

ServerSocket serverSocket = new ServerSocket(); serverSocket.setReuseAddress(true); // 允许端口快速复用,避免重启报 Address already in use serverSocket.bind(new InetSocketAddress(8888));

setReuseAddress(true)这行很多人会漏。它的作用是让TIME_WAIT状态的端口能被重新绑定。你调代码的时候改一行重启一次,不设这个参数,大概率会遇到BindException: Address already in use,然后你以为是端口被别的程序占了,其实是你自己上一次进程的残留连接还在等超时。默认的TIME_WAIT在某些系统上是 60 秒,够你抓狂的了。

主循环长这样:

while (!serverSocket.isClosed()) { Socket socket = serverSocket.accept(); // 阻塞,直到有连接进来 socket.setTcpNoDelay(true); // 禁用 Nagle 算法,小消息立刻发出 pool.execute(new ClientHandler(socket)); }

关于setTcpNoDelay(true),值得展开说一下。TCP 协议默认开启了 Nagle 算法,它会把多个小数据包合并成一个再发,目的是减少网络上的小包数量。这个策略在批量传输文件时非常好,但对聊天室这种“一句话一句话发”的场景是灾难——你可能发出去了,但对端要等几十甚至上百毫秒才收到,因为操作系统在等更多数据凑一包。禁用之后每次写就立刻发送,延迟能明显降下来,代价是网络上包会多一点。聊天室这种场景消息量小、延迟敏感,毫无疑问要关掉。

accept()之后我在ClientHandler的run()里做的事情顺序很固定:

  1. 先初始化输入输出流,并且立刻包装一层缓冲(BufferedInputStream/BufferedOutputStream);
  2. 进入读循环,按帧协议解出一条完整消息;
  3. 分发给业务处理逻辑(登录、群聊、私聊、心跳);
  4. 发生IOException或读到null时,视为连接断开,进入清理流程。

清理流程是新手最容易忽略、也最容易出 bug 的地方。要清理的东西有三样:从在线用户表里移除、关闭Socket、通知其他人“某某下线了”。这三步必须在finally块里保证执行,而且要做幂等处理——因为客户端正常退出和网络异常断开是两个入口,都可能导致清理逻辑被触发两次,第二次再去移除就会引发空指针或者重复广播下线通知。

我自己的做法是给每个ClientHandler加一个AtomicBoolean closed标志,close()方法第一行就if (!closed.compareAndSet(false, true)) return;,保证清理逻辑只跑一遍。这个套路不复杂,但能省掉一大半诡异问题。

2.2 消息帧的编解码实现

协议设计的价值在于编码解码能不能写得干净。我把它封装成一个工具类,收发两边共用,避免两边实现细节不一致导致“我发的你解不出来”。

写消息的代码如下:

public static void writeMessage(DataOutputStream out, byte type, String payload) throws IOException { byte[] data = payload.getBytes(StandardCharsets.UTF_8); synchronized (out) { // 同一个输出流的写必须互斥 out.writeInt(data.length + 1); // 总长度 = 类型(1) + 载荷 out.writeByte(type); // 消息类型 out.write(data); // 载荷 out.flush(); // 缓冲流必须 flush,否则消息卡在缓冲里 } }

这段代码有三个点必须理解到位,否则会出现“偶尔丢消息”这种最难查的问题。

第一是那个synchronized (out)。服务端在广播消息时,是多个线程同时往同一个客户端的输出流里写。DataOutputStream内部是没有任何同步保护的,两个线程同时执行writeInt和write,字节就可能交错在一起,接收方解析出来的长度字段是个毫无意义的数字,然后readFully读不到那么多字节就抛异常或者卡死。这个锁必须加在输出流对象上,保证一条消息的字节是连续写入的。

第二是flush()。我前面说了要包一层BufferedOutputStream提升性能,但缓冲流的特性是攒够一定量才真正写到网络。你不 flush,消息就卡在本地缓冲区里,表现出来的现象就是“服务端日志都打印了,客户端就是收不到”,非常迷惑。所以每次写完必须 flush。这一条我建议直接在工具方法里做掉,不要指望调用方记得。

第三是data.length + 1这个+1。代表类型字段占的那 1 个字节。这个偏移量极容易写错,写错了接收方要么多读一个字节导致后面的消息整体错位,要么少读一个字节导致类型字段丢失。我的建议是把这个+1抽成一个常量HEADER_TYPE_LEN = 1,别到处硬编码。

读消息这边要用readFully,不能只调read:

public static Message readMessage(DataInputStream in) throws IOException { int totalLen; try { totalLen = in.readInt(); // 读不到 4 字节会抛 EOFException,正好用来判断断开 } catch (EOFException e) { return null; // 对端正常关闭 } if (totalLen < 1 || totalLen > 1024 * 1024) { throw new IOException("非法消息长度: " + totalLen); } byte type = in.readByte(); byte[] payload = new byte[totalLen - 1]; in.readFully(payload); // 关键:必须读满 return new Message(type, new String(payload, StandardCharsets.UTF_8)); }

readFully和read的区别是:read(byte[])返回的是“本次实际读到的字节数”,可能比数组长度小,剩下的还没到;readFully会一直阻塞到把数组填满为止,除非流结束。半包问题就是靠这一个方法解决的。很多人不知道有这个 API,自己写while循环拼字节,容易写成死循环,非常危险。

另外我加了一个长度上限校验1024 * 1024。不加的话,恶意或者异常的客户端发一个长度字段是Integer.MAX_VALUE的伪包,服务端立刻按这个长度去new byte[],直接OutOfMemoryError整个服务端陪葬。这个校验在真实项目里属于必做项,属于最基本的一道防线。

2.3 在线用户表的选型与并发访问

在线用户表是整个聊天室的“通讯录”,所有广播和私聊都要查它。它的结构选择直接决定了并发下会不会出问题。

我从简单到复杂做了几次迭代,最后定在ConcurrentHashMap<String, ClientHandler>:

  • 一开始用HashMap,两个人测试没问题,五个人测试就开始出现ConcurrentModificationException,因为广播时在遍历,同时有人登录在往里面 put;
  • 换成Collections.synchronizedMap(new HashMap<>()),遍历时必须手动synchronized包住整个循环,而那意味着整个广播期间其它线程全部阻塞,登录和私聊都得排队;
  • 最后用ConcurrentHashMap,put 和 remove 天然线程安全,遍历时用弱一致性迭代器,不会抛异常。

但ConcurrentHashMap不是万能药。它保证的是单个操作的原子性,不保证复合操作的原子性。举两个我实际踩过的例子:

例子一:先检查再放入。有人想防止重名,写成if (!users.containsKey(name)) users.put(name, handler);。这两步之间是存在时间窗口的,两个同名的客户端几乎同时连进来,都通过了containsKey检查,然后都 put 进去,后一个把前一个覆盖掉,前一个的用户从此“人间蒸发”,收不到任何消息,也发不出去——因为他已经不在表里了。正确写法是用putIfAbsent(name, handler),返回非 null 就说明名字被占了。

例子二:遍历时发消息。广播时我习惯先把 key 集合的快照拿出来:new ArrayList<>(users.keySet()),然后再遍历发。如果你直接对着users.values()遍历并在循环里调用发送方法,一旦发送方法内部因为异常把某个客户端从表里移除了,迭代器的行为就不好预测了。取快照这个动作开销很小,但能换来确定的行为,非常划算。

还有一点,用户表的key 用什么。用昵称当 key 是最直观的,但昵称可以被改,改名就成了“先删再加”,中间有一瞬间这个人不在表里。所以我倾向于给每个连接分配一个不变的sessionId(比如 UUID),昵称作为ClientHandler的一个属性存在,ConcurrentHashMap<String, ClientHandler>的 key 用 sessionId,另外维护一个ConcurrentHashMap<String, String>做昵称到 sessionId 的映射。这一层拆分开销不大,但后面加改名、加好友、加离线消息都会轻松很多。

2.4 心跳机制与超时清理

TCP 连接有个“看起来还活着,其实已经死了”的状态。典型场景:手机用户切到飞行模式、笔记本合盖休眠、Wi-Fi 突然断开。这时候服务端这侧的 socket 并不会立刻收到 FIN 包,read()就一直在那儿阻塞着,你以为他还连着,实际上他早就收不到消息了。这种“僵尸连接”积累多了,内存泄漏、广播无效、用户表脏乱,全是麻烦。

解决办法就是应用层心跳。注意不是 TCP 的SO_KEEPALIVE,那个机制的默认探测间隔是两个小时,对聊天室来说跟没有一样;虽然可以调整系统参数,但那是操作系统级别的设置,跟你这个应用没关系,也不可控。

具体做法:

  • 客户端:起一个定时任务,每隔 15 秒往服务端发一条TYPE_HEARTBEAT的空消息;
  • 服务端:每收到一条消息(不管是聊天还是心跳),更新ClientHandler.lastActiveAt = System.currentTimeMillis();
  • 服务端:再起一个单线程的定时任务,每 30 秒扫一遍在线用户表,谁的lastActiveAt超过 45 秒没更新,就判定为死连接,主动close()并从表里移除。

这三个数字(15 / 30 / 45)之间的关系是有讲究的,不是随便写的。判定超时的阈值必须大于两个心跳周期,否则网络稍微抖一下、一条心跳延迟了,你就会误杀一个活着的用户。我选 15 秒发心跳、45 秒判死,等于允许连续丢两到三次心跳,兼顾了及时性和稳定性。如果你的客户端在移动网络下用,还可以再放宽一点,比如 20 秒发、60 秒判死。

扫描线程用ScheduledExecutorService就够了:

Scheduler.scheduleAtFixedRate(() -> { long now = System.currentTimeMillis(); for (ClientHandler h : users.values()) { if (now - h.getLastActiveAt() > 45_000L) { log.info("心跳超时,关闭连接: {}", h.getNickname()); h.close(); // close 内部会从 users 表里移除自己 } } }, 30, 30, TimeUnit.SECONDS);

注意这里遍历users.values()并在循环里调用close(),而close()内部会users.remove(...)。ConcurrentHashMap的迭代器是弱一致性的,remove 不会导致异常,遍历会正常结束,但可能看不到刚被移除的元素。这个行为对我们的场景完全可接受。如果换成普通HashMap,这里就是ConcurrentModificationException的经典现场。

3. 实操过程:从零把聊天室跑起来

前面讲的都是“应该怎么做”,这一节我把完整可用的代码骨架摆出来,按顺序照着敲,半小时能跑通群聊、私聊和上下线通知。

3.1 消息类型定义与公共协议常量

先把协议里所有魔法数字集中定义,后面所有代码都引用常量,不写裸数字:

public final class Protocol { public static final byte TYPE_LOGIN = 0x01; // 客户端 -> 服务端:登录,载荷为昵称 public static final byte TYPE_LOGIN_OK = 0x02; // 服务端 -> 客户端:登录成功 public static final byte TYPE_LOGIN_FAIL= 0x03; // 服务端 -> 客户端:昵称被占用 public static final byte TYPE_CHAT = 0x04; // 群聊消息 public static final byte TYPE_PRIVATE = 0x05; // 私聊,载荷格式 "目标昵称\u0000内容" public static final byte TYPE_SYSTEM = 0x06; // 系统通知:上线、下线 public static final byte TYPE_HEARTBEAT = 0x07; // 心跳 public static final byte TYPE_LOGOUT = 0x08; // 主动退出 public static final int MAX_FRAME_LEN = 1024 * 1024; public static final int HEARTBEAT_INTERVAL_MS = 15_000; public static final int HEARTBEAT_TIMEOUT_MS = 45_000; }

私聊载荷我用\u0000作为昵称和内容的分隔符。为什么不用逗号或者冒号?因为用户聊天内容里完全可能出现逗号冒号,一分割就错位了。\u0000(空字符)在正常键盘输入里基本不会出现,安全性高得多。当然更规范的做法是把私聊做成两个字段的二进制结构,但对这个练习项目来说,一个几乎不可能冲突的分隔符已经够了。

3.2 服务端主类与连接处理

主类负责启动、注册、扫描:

public class ChatServer { private final Map<String, ClientHandler> users = new ConcurrentHashMap<>(); private final ExecutorService workerPool = new ThreadPoolExecutor( 0, 200, 60L, TimeUnit.SECONDS, new SynchronousQueue<>(), r -> { Thread t = new Thread(r, "client-handler-" + System.nanoTime()); t.setDaemon(true); return t; }); public void start() throws IOException { ServerSocket ss = new ServerSocket(); ss.setReuseAddress(true); ss.bind(new InetSocketAddress(8888)); startHeartbeatScanner(); while (!ss.isClosed()) { Socket s = ss.accept(); s.setTcpNoDelay(true); workerPool.execute(new ClientHandler(s, this)); } } public boolean register(String nick, ClientHandler h) { return users.putIfAbsent(nick, h) == null; } public void unregister(String nick) { if (nick != null) users.remove(nick); } public void broadcast(byte type, String text, ClientHandler except) { Message m = new Message(type, text); for (ClientHandler h : users.values()) { // 弱一致性遍历,安全 if (h != except) h.send(m); // send 内部只入队,不阻塞 } } public ClientHandler findByNick(String nick) { return users.get(nick); } }

这里broadcast里的h.send(m)是我特意设计成非阻塞的,后面 3.4 会讲细节。

ClientHandler是这个项目里承载逻辑最多的类,核心结构如下:

public class ClientHandler implements Runnable { private final Socket socket; private final ChatServer server; private final DataInputStream in; private final DataOutputStream out; private final BlockingQueue<Message> sendQueue = new LinkedBlockingQueue<>(1000); private volatile long lastActiveAt = System.currentTimeMillis(); private final AtomicBoolean closed = new AtomicBoolean(false); private volatile String nickname; @Override public void run() { try { startSender(); handshake(); // 等客户端发登录帧 Message msg; while ((msg = Protocol.readMessage(in)) != null) { lastActiveAt = System.currentTimeMillis(); dispatch(msg); } } catch (IOException e) { log.info("连接异常结束: {}", nickname); } finally { close(); } } private void dispatch(Message msg) throws IOException { switch (msg.getType()) { case Protocol.TYPE_CHAT: server.broadcast(Protocol.TYPE_CHAT, nickname + ": " + msg.getPayload(), this); break; case Protocol.TYPE_PRIVATE: String[] parts = msg.getPayload().split("\u0000", 2); if (parts.length == 2) { ClientHandler target = server.findByNick(parts[0]); if (target != null) { target.send(new Message(Protocol.TYPE_PRIVATE, "[私聊] " + nickname + ": " + parts[1])); } else { send(new Message(Protocol.TYPE_SYSTEM, "用户 " + parts[0] + " 不在线")); } } break; case Protocol.TYPE_HEARTBEAT: break; // 只需要更新 lastActiveAt,已在循环里做了 case Protocol.TYPE_LOGOUT: close(); break; default: log.warn("未知消息类型: {}", msg.getType()); } } }

注意dispatch里的一个细节:群聊消息我传了this作为except参数,也就是不回显给发送者自己。有人会问为什么不回显,回显了客户端也能收到自己发的消息,体验不是更统一吗?这取决于你的客户端怎么做。如果客户端在发送时自己先把消息显示在窗口里(这叫乐观渲染),服务端再回显一次,你就会看到每条消息出现两遍。所以要么客户端不回显、完全依赖服务端,要么服务端不回显、客户端自己显示,只能二选一,一定不能两边都做。这是新手做聊天室最常见的“UI 上消息重复”问题的根因。

3.3 客户端实现与自动重连

客户端最简单可以用Scanner读控制台输入,这里我给出能直接跑的结构:

public class ChatClient { public static void main(String[] args) throws Exception { Socket socket = new Socket("127.0.0.1", 8888); socket.setTcpNoDelay(true); DataInputStream in = new DataInputStream(new BufferedInputStream(socket.getInputStream())); DataOutputStream out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream())); // 1. 单独线程收消息,不阻塞主线程输入 Thread reader = new Thread(() -> { try { Message m; while ((m = Protocol.readMessage(in)) != null) { render(m); } } catch (IOException ignored) { System.out.println("与服务端的连接已断开"); } }, "receiver"); reader.setDaemon(true); reader.start(); // 2. 心跳线程 Scheduler.scheduleAtFixedRate(() -> { try { Protocol.writeMessage(out, Protocol.TYPE_HEARTBEAT, ""); } catch (IOException e) { System.out.println("心跳发送失败,连接可能已断开"); } }, 15, 15, TimeUnit.SECONDS); // 3. 主线程处理控制台输入 Scanner sc = new Scanner(System.in); System.out.print("输入昵称: "); Protocol.writeMessage(out, Protocol.TYPE_LOGIN, sc.nextLine()); while (sc.hasNextLine()) { String line = sc.nextLine().trim(); if (line.isEmpty()) continue; if (line.startsWith("@") && line.contains(" ")) { int sp = line.indexOf(' '); Protocol.writeMessage(out, Protocol.TYPE_PRIVATE, line.substring(1, sp) + "\u0000" + line.substring(sp + 1)); } else { Protocol.writeMessage(out, Protocol.TYPE_CHAT, line); } } } }

客户端必须用至少两个线程,这个不能省。主线程阻塞在Scanner.nextLine()上等用户输入,如果你让同一个线程去read()服务端消息,那用户不打字的时候,收到的消息就全堆在 socket 缓冲区里没人处理,表现出来就是你得随便敲个回车才能看到别人说的话。这是行不通的。收消息必须独立成线程。

关于重连,我这里没有写自动重连,是因为对控制台客户端来说意义不大——连接断了你就退出去重开就好。但如果客户端是 GUI 或者要长期挂着跑,重连逻辑就必须有。我的经验是指数退避:

重连次数等待时间说明
第 1 次1 秒大概率是网络瞬断,很快能恢复
第 2 次2 秒
第 3 次4 秒
第 4 次8 秒
第 5 次及以后30 秒封顶持续重试,但别把服务端打爆

如果固定 1 秒重试,服务端重启的那十秒钟里,200 个客户端会发起 2000 次连接请求;退避之后压力平摊,服务端起来能正常接纳。重连成功之后还要重新登录,因为服务端已经把你原来的 session 清掉了,这是个容易漏的步骤。

3.4 发送队列与慢客户端隔离

前面留了个扣子,现在解开。ClientHandler.send()的实现是这样的:

public void send(Message m) { if (closed.get()) return; if (!sendQueue.offer(m)) { // 队列满了 log.warn("发送队列积压,断开慢客户端: {}", nickname); close(); // 主动踢掉,保护其他用户 } } private void startSender() { Thread t = new Thread(() -> { try { while (!closed.get()) { Message m = sendQueue.take(); // 队列空时阻塞,不消耗 CPU Protocol.writeMessage(out, m.getType(), m.getPayload()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (IOException e) { close(); } }, "sender-" + socket.getPort()); t.setDaemon(true); t.start(); }

这么设计解决了什么问题?广播者和接收者解耦了。A 发一条消息,broadcast只是把消息分别塞进 B、C、D 的队列里,然后立刻返回,A 的线程不会因为 D 的网络卡顿而阻塞。B、C、D 各自的发送线程按自己的速度从队列里取消息往外发。整个系统里,快用户不会被慢用户拖累。

那个队列长度1000是经验值,含义是“我最多容忍你积压 1000 条消息不处理”。超过这个量说明这个连接要么已经死了,要么对方在做别的事情根本不看消息,继续往里塞只会把服务端内存吃光。直接断开是最负责任的做法——对系统负责,也对其他用户负责。这个策略业内叫“慢客户端隔离”,是消息类系统里的标准做法。

4. 常见问题与排查实录

聊天室这个项目代码量不大,但坑非常集中。我把这些年被问过最多的、以及我自己踩过的整理成一张速查表,遇到问题先对表。

4.1 典型故障与解决思路速查

现象最可能的原因排查手段解决
只能单人测试,第二个人连不上服务端只accept()了一次就进入读循环看主循环是不是写在while外面把accept()放进while(true),每个连接丢给线程池
收到的中文全是乱码编解码字符集不一致,或用了平台默认编码打印两端的file.encoding收发两端统一StandardCharsets.UTF_8
消息偶尔串包、错位长度字段和实际写入字节数不一致在编解码处打日志比对长度长度按byte[]的length算,不要按String.length()算
客户端长时间收不到消息,敲回车才收到收消息和读控制台输入在同一个线程看客户端是不是只有一个线程收消息独立线程
广播时抛ConcurrentModificationException用了HashMap且在遍历时增删看用户表类型换ConcurrentHashMap,遍历取快照
服务端日志显示已发送,客户端收不到缓冲流没有flush()抓包看有没有实际发出字节每次写完调用flush()
内存持续上涨不下降断开的连接没有从用户表移除打印用户表大小随时间变化在finally里做幂等清理
服务端偶发OutOfMemoryError恶意长度字段导致超大数组分配记录收到的长度字段加长度上限校验
重启服务端报端口被占用TIME_WAIT残留netstat看端口状态setReuseAddress(true)

4.2 几个值得单独说的排查过程

第一个是“消息重复显示”。有位同学说他的聊天室每条消息都显示两遍,一开始怀疑是广播逻辑写重复了。我让他抓包看,发现服务端只发了一次,问题在客户端:他既在发送时System.out.println了一遍,又收到了服务端的回显。这就回到我前面说的那个原则,回显责任要单边承担。判断方法很简单:让两个人对话,如果只有自己发的消息重复、别人发的正常,那就是客户端本地回显和服务端回显撞车了。

第二个是“连接数一多就崩”。现象是同时在线到 30 个人左右,服务端开始变慢,到 50 个人直接卡死。这种情况我第一件事是看线程数,用jstack一抓,发现线程数接近 500,而且大量线程状态是BLOCKED。原因是他用了Executors.newFixedThreadPool(50)处理连接,但同时还有一个synchronized大锁包住了整个dispatch方法,所有线程在抢同一把锁,吞吐量直接归零。这个问题的教训不是“别用锁”,而是“锁的粒度必须跟它的必要性匹配”。广播时需要保护的是单个输出流,不是整个业务逻辑。

第三个是“用户明明在线,别人却发不出私聊”。查了半天发现是昵称前后有空格。客户端输入昵称时用了scanner.next()之后又读了行,混用了next()和nextLine(),导致昵称后面跟了个\r。这种问题在 Windows 上尤其常见,因为换行是\r\n两个字符。我的处理习惯是:所有用户输入统一trim(),并且明确只使用nextLine(),绝不混用。

第四个是“服务端 CPU 跑满”。一个已经断开的连接,读写线程还在while循环里疯狂空转——因为他的循环写成了while (socket.isConnected())里面套in.read(),但read()返回 -1 之后没做处理,直接继续读,读取失败又不抛异常(因为流没关),于是死循环。正确做法是read()返回 -1 就 break 出循环,进清理逻辑。

提醒:所有和 socket 相关的循环,退出条件只有三个来源——读到 -1、捕获到IOException、主动调用close()。任何“连接是否还活着”的判断,都不要依赖isConnected()或isClosed(),这两个方法反映的是本地对象的状态,不是网络对端的状态,用它做循环条件必然出问题。

4.3 几条不太好写进文档的心得

日志一定要打,而且要打对地方。我见过太多人的日志只有一句e.printStackTrace(),出了问题完全不知道是哪个用户、哪个消息类型、在哪个阶段出的事。我自己的习惯是每条连接在建立时打一行INFO 连接建立 port=xxx,登录成功后打INFO 用户上线 nick=xxx 当前在线=N,异常时打WARN并带上nickname和消息类型。这三类日志加起来,99% 的问题看一眼就知道了。

还有一点,测试的时候一定要用两个以上的真实终端,而不是同一个 IDE 里起两个 main。同一个进程里起两个客户端,某些资源是共享的,比如静态变量、系统属性,可能出现“单进程测试正常,多机器连就崩”的假象。有条件的话,让一个客户端跑在另一台机器上,用局域网 IP 连过来,这一步能提前暴露一大堆配置问题。

5. 后续扩展:这个骨架还能往哪长

代码跑通之后,大多数人的第一反应是“接下来做点什么”。我给几条我自己走过的、投入产出比比较高的方向,按难度递增排。

5.1 从 BIO 迁移到 NIO 的正确姿势

如果你已经摸清了 BIO 的阻塞点在哪里,下一步可以试试 NIO。但我要提醒一句:不要为了学 NIO 而扔掉现有代码重写。更靠谱的做法是先理解 NIO 解决的是什么问题——它把“一个连接一个线程”变成了“一个线程管多个连接”,靠的是Selector事件驱动,只有真正有数据可读的连接才会被处理。代价是编程模型从“线性代码”变成了“状态机”,一条消息可能要分好几次read才能凑齐,中间状态得自己存。

如果只是想体验一下,可以基于Selector写一个最简单的回声服务,能跑起来理解channel、buffer、selector三件套就够了,不必真的把聊天室改过去。真要做生产级的东西,直接上成熟的网络框架,别自己造轮子——那些框架把粘包处理、空闲检测、编解码器都做好了,你专注在业务逻辑上就行。

5.2 消息持久化与历史记录

现在的聊天记录全在内存里,服务端一停就没了。想加持久化,最简单的做法是每收到一条群聊消息就追加一行到文件,格式用时间戳|昵称|内容,用\n分隔。读取历史记录时按时间倒序取最后 50 条推给新登录的用户。

但这里有个细节:文件写入也要走队列。如果直接在广播逻辑里同步写文件,磁盘 IO 的抖动会拖慢整个广播流程,跟之前说的慢客户端问题是一个道理。开一个单线程的写入队列,业务线程只管往队列里丢,写文件的事情交给它慢慢做。这一步做对了,你其实已经摸到了“生产者-消费者”模式的实践版。

再往深了走,可以考虑上一套嵌入式数据库,把消息按会话分表存,支持分页查询。但这已经超出聊天室练习的范畴了,属于独立的存储话题。

5.3 压测:怎么知道你的聊天室到底能扛多少

不要靠“我感觉挺快的”来判断性能。写个简单的压测客户端,循环创建 N 个连接,每个连接每秒钟发一条消息,跑 10 分钟,同时用jvisualvm或者在启动参数里加-Xlog:gc观察内存和 GC 情况。

我的实测经验是,一个写得规规矩矩的 BIO 聊天室,在普通开发机上,同时在线 200 到 300 人、每秒几百条消息是能稳住的。超过这个量,你会先看到线程数飙升,然后是 GC 频率变高,最后是响应变慢。这个数字本身不重要,重要的是你通过这个过程知道了瓶颈在哪一层——是线程数、是内存、还是锁竞争。知道了瓶颈,才知道下一步该优化什么。盲目优化是最浪费时间的。

如果想再进一步,可以在服务端加一个统计接口,返回当前在线人数、累计消息数、平均消息处理耗时。把这些指标暴露出来,压测的时候看着数字变化,比对着日志猜要靠谱得多。

最后分享一个我自己的习惯:每次改完代码,不要只测正常流程,一定要测三个异常场景——客户端直接关窗口(模拟异常断开)、把网线拔了或者禁用网卡(模拟网络中断)、服务端 Ctrl+C 强杀。这三个场景如果服务端都能正常清理、不崩溃、重启后能正常接受新连接,那这个聊天室在稳定性上就算过关了。我见过太多能跑通正常流程但一遇异常就满地狼藉的项目,区别就在这里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询