1. 从单用户到多用户:Socket通信的架构演进思路
做过网络编程的朋友应该都有这个体会:刚接触Socket的时候,写一个客户端连服务端、发消息、收消息的Demo,几十行代码就能跑通,成就感满满。但一旦把场景换成“多个客户端同时连同一个服务端”,问题就全冒出来了——第二个客户端连不上、消息发串了、某个客户端断开后服务端直接崩了。这些坑我当年一个不落地踩过。
这个项目的核心目标很明确:用Java的多线程机制,实现一个服务端能够同时处理多个客户端的Socket连接与消息收发。说白了,就是做一个最基础的“多用户聊天服务端”的骨架。它解决的核心问题是并发连接管理——服务端不能只盯着一个客户端,而是要有能力同时服务多个用户,每个用户的连接互不干扰。
适合谁来参考?如果你已经会写最简单的Socket单连接Demo,但对多线程怎么和网络IO配合还没有清晰的认识,那这篇内容就是为你准备的。如果你是有一定经验的开发者,想回顾一下多线程Socket的经典模型和常见坑点,也能从中找到一些实用的细节。我会从架构设计思路讲起,然后拆解核心代码,再补充实操中容易翻车的地方,最后给出一份问题排查速查表。
整个方案基于Java原生的java.net包和java.util.concurrent包,不依赖任何第三方框架。为什么不用Netty?因为理解原生多线程Socket模型是理解Netty等框架的基础,先把底层逻辑搞清楚了,上层框架用起来才不心虚。
2. 整体架构设计与线程模型选型
2.1 为什么是“一连接一线程”模型
多用户Socket通信的服务端,最经典的模型就是一连接一线程(Thread-Per-Connection)。服务端在主线程里循环调用ServerSocket.accept(),每接收到一个客户端连接,就创建一个新的线程专门负责这个连接的读写操作。
这个模型的好处非常直观:每个客户端的IO操作在独立线程里执行,InputStream.read()阻塞的时候不会影响其他客户端。代码结构简单,逻辑清晰,调试方便。对于连接数在几百以内的场景,完全够用。
当然它也有明显的局限。每个线程都要占用一定的内存(默认线程栈大小约512KB到1MB),如果同时有几千个连接,线程数量爆炸,内存和CPU调度都会吃不消。这种时候就需要考虑NIO或者Netty的Reactor模型了。但那是另一个话题,先把基础模型吃透。
提示:线程池虽然可以复用线程,但在“一连接一线程”模型里要慎用。因为每个连接的生命周期是不确定的,如果线程池满了,新连接就没法处理。用
Executors.newCachedThreadPool()相对合适,但也要注意线程数量的上限控制。
2.2 服务端整体结构拆解
服务端的结构可以分成三层来理解:
- 监听层:
ServerSocket绑定端口,主线程阻塞在accept()方法上,等待客户端连接。 - 连接管理层:每接受一个连接,创建一个
ClientHandler任务,提交给线程或线程池执行。同时用一个线程安全的集合(比如ConcurrentHashMap)保存所有活跃客户端的输出流,方便后续做消息广播。 - 通信层:每个
ClientHandler负责读取对应客户端的消息,解析后决定是回发给单个客户端还是广播给所有客户端。
客户端的结构就简单多了:建立Socket连接,启动一个独立线程专门负责接收服务端发来的消息(因为接收是阻塞的),主线程负责读取用户键盘输入并发送给服务端。
2.3 关键选型对比:直接new Thread还是线程池
| 对比维度 | 直接new Thread | 线程池(CachedThreadPool) |
|---|---|---|
| 代码简洁度 | 简单直接 | 需要额外管理线程池生命周期 |
| 线程复用 | 不复用,每个连接一个线程 | 空闲线程可复用 |
| 资源控制 | 无上限,连接多了容易OOM | 可设置上限,但需合理配置 |
| 适用场景 | 连接数少、学习演示 | 连接数中等、需要一定控制 |
| 关闭处理 | 线程结束后自动回收 | 需要正确shutdown线程池 |
我个人的建议是:学习和演示用直接new Thread就够了,代码一目了然。如果是稍微正式一点的项目,用Executors.newCachedThreadPool(),并在服务端关闭时调用shutdownNow()。但要注意,CachedThreadPool的最大线程数是Integer.MAX_VALUE,本质上也没有上限,只是复用了空闲线程而已。真要控制并发量,得用ThreadPoolExecutor手动指定核心线程数、最大线程数和拒绝策略。
2.4 消息协议的前期约定
多用户通信最容易出问题的地方就是消息边界。TCP是字节流协议,没有消息边界的概念。如果客户端连续发送两条消息,服务端一次read()可能把两条都读出来,也可能只读到半条。
所以必须约定一个简单的应用层协议。最常见的有两种方案:
- 按行读取:每条消息以换行符
\n结尾,服务端用BufferedReader.readLine()读取。简单好用,适合文本聊天场景。 - 长度前缀:消息前几个字节表示消息体的长度,服务端先读长度再读内容。更通用,但实现稍复杂。
这个项目里我用按行读取的方案,因为实现简单,调试方便,用telnet或nc就能直接测试。但要注意,readLine()遇到客户端异常断开时可能抛异常,需要妥善捕获。
3. 核心细节解析与实操要点
3.1 ServerSocket的正确创建与参数设置
创建ServerSocket的时候,有一个参数经常被忽略但非常重要——backlog。它表示等待队列的最大长度,也就是当服务端还没来得及accept()时,操作系统可以暂存的连接请求数量。
ServerSocket serverSocket = new ServerSocket(8888, 50);这里的50就是backlog。如果不指定,默认值通常是50(不同操作系统可能不同)。当并发连接请求超过这个值时,新的连接会被拒绝。对于多用户场景,建议根据预期并发量适当调大,比如设为100或200。
另一个关键点是setReuseAddress(true)。这个选项允许服务端重启后立即绑定同一端口,而不用等待操作系统的TIME_WAIT状态结束。开发调试阶段非常有用,否则每次重启服务端都要等几十秒才能重新绑定端口。
ServerSocket serverSocket = new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(8888), 100);注意:
setReuseAddress必须在bind之前调用才生效。如果直接用构造方法创建ServerSocket,它内部已经完成了bind,再调用就没用了。
3.2 客户端连接的管理与线程安全集合
服务端需要维护一个所有在线客户端的列表,用于消息广播。这个列表必须是线程安全的,因为多个ClientHandler线程可能同时读写它。
我推荐用ConcurrentHashMap,key可以用客户端的唯一标识(比如用户名或自增ID),value是对应的PrintWriter或BufferedWriter输出流。为什么不用Collections.synchronizedList?因为ConcurrentHashMap的并发性能更好,而且遍历的时候不需要额外加锁(弱一致性迭代器)。
private static final ConcurrentHashMap<String, PrintWriter> onlineClients = new ConcurrentHashMap<>();每个客户端连接成功后,把自己的输出流注册进去;断开时,从map中移除。广播消息的时候,遍历map中的所有输出流,逐个写入。
这里有一个容易踩的坑:遍历过程中如果有客户端断开,map的结构会发生变化。ConcurrentHashMap的迭代器是弱一致的,不会抛ConcurrentModificationException,但可能读不到最新的状态。所以广播的时候要对每个输出流的写入操作做异常捕获,一旦写入失败就说明该客户端已断开,顺手把它从map里移除。
3.3 输入流读取的阻塞问题与线程分工
InputStream.read()是阻塞方法。如果服务端只有一个线程,它阻塞在读取某个客户端消息的时候,就没法接受新连接,也没法读取其他客户端的消息。这就是为什么必须为每个连接分配独立线程。
但即使每个连接一个线程,还有一个细节要注意:同一个Socket的输入流和输出流可以在不同线程里操作吗?答案是可以的。TCP是全双工的,输入和输出互不干扰。所以服务端可以在一个线程里循环读取客户端消息,同时另一个线程(或同一个线程的不同阶段)向客户端写入消息。
不过在这个项目里,服务端的ClientHandler线程既负责读也负责写(广播时由其他线程触发写入)。这里就涉及到一个并发写入的问题:多个线程可能同时向同一个PrintWriter写入数据。PrintWriter本身不是线程安全的,所以需要对写入操作加同步。
synchronized (clientWriter) { clientWriter.println(message); }或者用BufferedWriter配合显式的flush(),同样需要同步。更优雅的方案是每个客户端维护一个消息队列,用一个专门的发送线程从队列里取消息写入,但那样代码复杂度会上升不少。对于这个项目,直接加synchronized就够了。
3.4 客户端接收线程的设计
客户端需要两个线程:一个主线程负责读取键盘输入并发送,另一个线程负责接收服务端消息并打印。接收线程是一个死循环,不断调用readLine(),直到读到null(服务端关闭连接)或抛出异常。
Thread receiveThread = new Thread(() -> { try { String msg; while ((msg = reader.readLine()) != null) { System.out.println(msg); } } catch (IOException e) { System.out.println("与服务端的连接已断开"); } }); receiveThread.setDaemon(true); receiveThread.start();把接收线程设为守护线程(setDaemon(true))是个好习惯。这样当主线程退出时,接收线程会自动结束,不会阻止JVM退出。否则主线程结束了,接收线程还阻塞在readLine()上,程序就挂在那里退不出来。
3.5 字符编码的统一约定
Socket通信底层传输的是字节,InputStreamReader和OutputStreamWriter负责字节到字符的转换。如果不指定编码,会使用平台默认编码,在Windows上通常是GBK,在Linux上通常是UTF-8。跨平台通信时就会出现中文乱码。
解决方案很明确:两端都显式指定UTF-8编码。
// 服务端 BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter writer = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); // 客户端同样处理PrintWriter的构造方法第二个参数设为true表示自动flush,每次调用println后会自动刷新缓冲区。这个很重要,否则消息可能留在缓冲区里发不出去,导致对方一直收不到。
4. 完整实操过程与核心代码实现
4.1 服务端主线程:监听与连接分发
服务端的入口逻辑很清晰:创建ServerSocket,死循环accept(),每来一个连接就启动一个处理线程。
public class MultiClientServer { private static final int PORT = 8888; private static final ConcurrentHashMap<String, PrintWriter> onlineClients = new ConcurrentHashMap<>(); private static final AtomicInteger clientCounter = new AtomicInteger(0); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(PORT), 100); System.out.println("服务端已启动,监听端口:" + PORT); ExecutorService threadPool = Executors.newCachedThreadPool(); while (true) { Socket clientSocket = serverSocket.accept(); String clientId = "用户" + clientCounter.incrementAndGet(); System.out.println(clientId + " 已连接,地址:" + clientSocket.getRemoteSocketAddress()); threadPool.execute(new ClientHandler(clientSocket, clientId)); } } public static void broadcast(String message, String senderId) { onlineClients.forEach((id, writer) -> { if (!id.equals(senderId)) { synchronized (writer) { writer.println(message); } } }); } public static void addClient(String id, PrintWriter writer) { onlineClients.put(id, writer); } public static void removeClient(String id) { onlineClients.remove(id); } }这段代码里有几个设计决策值得说明。用AtomicInteger生成客户端ID,保证多线程环境下ID不重复。用ExecutorService管理线程,比直接new Thread更可控。broadcast方法里排除了发送者自己,这样发送者不会收到自己发的消息(当然也可以选择发给自己,看产品需求)。
4.2 ClientHandler:每个客户端的独立处理逻辑
ClientHandler实现了Runnable接口,每个实例负责一个客户端的完整生命周期。
public class ClientHandler implements Runnable { private final Socket socket; private final String clientId; private PrintWriter writer; public ClientHandler(Socket socket, String clientId) { this.socket = socket; this.clientId = clientId; } @Override public void run() { try { BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); writer = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); MultiClientServer.addClient(clientId, writer); writer.println("欢迎 " + clientId + " 加入聊天室!"); MultiClientServer.broadcast(clientId + " 加入了聊天室", clientId); String message; while ((message = reader.readLine()) != null) { if (message.equalsIgnoreCase("quit")) { break; } String fullMessage = clientId + ":" + message; System.out.println(fullMessage); MultiClientServer.broadcast(fullMessage, clientId); } } catch (IOException e) { System.out.println(clientId + " 连接异常:" + e.getMessage()); } finally { MultiClientServer.removeClient(clientId); MultiClientServer.broadcast(clientId + " 离开了聊天室", clientId); try { socket.close(); } catch (IOException ignored) { } System.out.println(clientId + " 已断开"); } } }finally块是整段代码里最不能省的部分。不管客户端是正常发送quit退出,还是网络异常断开,还是服务端主动关闭,finally块都会执行,确保客户端从在线列表中移除,并通知其他人。如果不做这个清理,在线列表里会残留已经断开的客户端,广播的时候就会不断抛异常。
4.3 客户端实现:双线程协作
客户端需要同时做两件事:读键盘输入发送给服务端,接收服务端消息打印到控制台。用两个线程分别处理。
public class ChatClient { public static void main(String[] args) throws IOException { Socket socket = new Socket("127.0.0.1", 8888); System.out.println("已连接到服务端"); BufferedReader serverReader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter serverWriter = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); BufferedReader keyboardReader = new BufferedReader( new InputStreamReader(System.in, StandardCharsets.UTF_8)); Thread receiveThread = new Thread(() -> { try { String msg; while ((msg = serverReader.readLine()) != null) { System.out.println(msg); } } catch (IOException e) { System.out.println("连接已断开"); } }); receiveThread.setDaemon(true); receiveThread.start(); String input; while ((input = keyboardReader.readLine()) != null) { serverWriter.println(input); if (input.equalsIgnoreCase("quit")) { break; } } socket.close(); System.out.println("已退出聊天室"); } }客户端的接收线程设为守护线程,主线程负责发送。当用户输入quit时,主线程退出循环,关闭Socket,守护线程随之结束。
4.4 测试验证:多客户端同时连接
测试的时候,我一般会开三个终端窗口。第一个运行服务端,第二和第三个分别运行客户端。然后依次在客户端里输入消息,观察其他客户端是否能收到广播。
也可以用telnet或nc来模拟客户端,省去启动Java客户端的过程:
# Linux/Mac下用nc测试 nc 127.0.0.1 8888连接成功后直接输入文字按回车,就能看到服务端的广播消息。这种方式特别适合快速验证服务端逻辑是否正确。
提示:Windows自带的telnet客户端默认可能没启用,需要在“启用或关闭Windows功能”里手动勾选。用nc的话,Windows下可以用ncat替代。
测试的时候要重点验证几个场景:多个客户端同时发送消息、某个客户端突然断开(直接关窗口)、客户端发送空消息、客户端发送超长消息。这些边界情况最能暴露代码里的问题。
5. 常见问题与排查技巧实录
5.1 连接被拒绝:Connection refused
这是最常见的问题,原因通常有三种:服务端没启动、端口号写错了、防火墙拦截了。
排查顺序是这样的:先确认服务端程序是否在运行,控制台有没有打印“服务端已启动”。然后检查客户端连接的端口和服务端监听的端口是否一致。如果都没问题,再看防火墙设置。Linux下可以用netstat -tlnp | grep 8888查看端口是否处于LISTEN状态。
还有一个容易被忽略的情况:服务端绑定的是0.0.0.0还是127.0.0.1。如果绑定的是127.0.0.1,只有本机能连接;局域网内其他机器要连接的话,需要绑定0.0.0.0或者具体的网卡地址。
5.2 消息发送后对方收不到
这个问题八成是缓冲区没刷新。PrintWriter默认不会自动flush,除非在构造方法里传了true,或者手动调用flush()。如果用的是BufferedWriter,更是必须手动flush。
另一个可能的原因是消息没有以换行符结尾。服务端用readLine()读取,如果客户端发送的消息没有\n,readLine()会一直阻塞等待换行符。PrintWriter.println()会自动加换行符,但print()不会。
5.3 客户端断开后服务端抛异常
客户端直接关闭窗口时,服务端的readLine()会抛出SocketException: Connection reset。这是正常现象,不需要惊慌,但必须捕获处理。在catch块里做好清理工作,从在线列表中移除该客户端,并通知其他人。
如果服务端在广播时向一个已断开的客户端写入数据,PrintWriter不会抛异常(它内部吞掉了IOException),但可以通过checkError()方法检查是否出错。更可靠的做法是在广播的循环里对每个写入操作做try-catch,一旦出错就立即从map中移除该客户端。
5.4 中文乱码
乱码的根源是两端编码不一致。解决方案就是前面说的,两端都显式指定StandardCharsets.UTF_8。注意InputStreamReader和OutputStreamWriter都要指定,只指定一端没用。
如果已经指定了UTF-8还是乱码,检查一下IDE的控制台编码设置。有些IDE默认用GBK显示控制台输出,即使程序内部用的是UTF-8,打印出来也会乱码。这种情况调整IDE设置即可,不是程序的问题。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Connection refused | 服务端未启动/端口错误/防火墙 | netstat查看端口监听状态 | 启动服务端、核对端口、开放防火墙 |
| 消息收不到 | 缓冲区未刷新/缺少换行符 | 检查flush和println | 自动flush或手动flush,用println |
| 客户端断开抛异常 | 正常现象 | 查看异常类型 | catch捕获,finally清理资源 |
| 中文乱码 | 编码不一致 | 检查两端编码设置 | 统一使用UTF-8 |
| 第二个客户端连不上 | backlog太小/服务端阻塞 | 检查accept循环 | 调大backlog,确保accept不被阻塞 |
| 广播时抛异常 | 向已断开客户端写入 | 检查在线列表 | 写入时try-catch,出错即移除 |
5.6 几个实操中总结的避坑技巧
第一个技巧:服务端关闭时先关闭ServerSocket,再关闭线程池。如果顺序反了,线程池关闭后accept循环还在跑,会抛RejectedExecutionException。正确的关闭顺序是:关闭ServerSocket(停止接受新连接),然后shutdownNow线程池(中断所有处理线程),最后等待线程池终止。
第二个技巧:给Socket设置超时时间。socket.setSoTimeout(30000)可以让read()在30秒没有数据时抛出SocketTimeoutException,避免线程永远阻塞。但聊天场景下通常不需要,因为用户可能长时间不说话。这个要根据具体业务决定。
第三个技巧:用try-with-resources管理Socket和流。虽然Socket实现了Closeable接口,但在多线程场景下,流的关闭顺序和时机需要仔细考虑。我一般还是在finally块里手动关闭,确保关闭顺序正确(先关流,再关Socket)。
第四个技巧:日志输出要带线程名。多线程环境下,日志混在一起很难排查问题。在创建线程的时候给线程起个有意义的名字,比如"client-" + clientId,这样日志里就能清楚看到是哪个客户端的操作。
Thread handlerThread = new Thread(new ClientHandler(socket, clientId), "client-" + clientId);这个习惯在调试并发问题的时候能省下大量时间。我当初排查一个消息串号的问题,就是因为日志里没有线程标识,花了半天才定位到是两个客户端的输出流搞混了。
5.7 性能优化的几个方向
当连接数上升到几百的时候,可以开始考虑一些优化。比如把PrintWriter换成BufferedWriter配合手动flush,减少IO次数。或者引入消息队列,每个客户端一个发送队列,用一个专门的发送线程批量写入。
再往上走,就是NIO的Selector模型了。一个线程用Selector管理多个Channel,通过事件驱动的方式处理读写。但NIO的编程复杂度比多线程模型高不少,代码可读性也差很多。我的建议是:连接数在500以内,多线程模型完全够用;超过500再考虑NIO或Netty。
还有一个容易忽略的点是线程栈大小。每个线程默认栈大小在512KB到1MB之间,1000个线程就是500MB到1GB的内存。可以通过-Xss参数调小线程栈,比如-Xss256k,但太小可能导致StackOverflowError。这个需要根据实际业务逻辑的调用深度来权衡。
5.8 从多线程Socket到NIO的过渡思路
如果你已经熟练掌握了多线程Socket模型,想进一步学习NIO,我建议的过渡路径是这样的:先用多线程模型实现一个完整的聊天室,然后把服务端的连接处理部分改成NIO的Selector模式,客户端保持不变。这样你能直观地对比两种模型的差异,理解NIO为什么能用更少的线程处理更多的连接。
NIO的核心是Selector、Channel和Buffer三个组件。Selector负责监听多个Channel上的事件,Channel代表一个连接,Buffer是数据读写的载体。服务端用一个线程轮询Selector,有事件就处理,没有就继续轮询。这样就避免了为每个连接创建一个线程。
但NIO的坑也不少:半包粘包问题需要自己处理,ByteBuffer的读写模式切换容易搞混,Selector的空轮询会导致CPU飙升。这些问题在多线程模型里都不存在,因为readLine()帮你处理了消息边界,阻塞IO也不会空轮询。所以到底用哪种模型,还是要看具体场景和团队的技术储备。
我个人在实际操作中的体会是,多线程Socket模型虽然“老”,但它是理解网络编程的最佳起点。把这里的线程安全、资源管理、异常处理都搞明白了,再去学NIO或者Netty,会发现很多概念都是相通的。反过来,如果连多线程Socket都没写明白就直接上Netty,遇到问题往往连排查的方向都找不到。