news 2026/10/16 12:08:57

多线程与线程池实战:Java多用户Socket通信服务端设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多线程与线程池实战:Java多用户Socket通信服务端设计

1. 从单用户到多用户:Socket通信的瓶颈到底在哪

很多刚接触网络编程的朋友,第一个能跑通的Demo大概率是"客户端发一句,服务端回一句"的单线程模型。代码不到五十行,跑起来看到控制台打印出对方发来的消息,成就感满满。但只要你把这个Demo稍微往真实场景推一步——比如同时开两个客户端连上去——问题立刻暴露:第二个客户端连上之后,第一个客户端就卡住了,发消息没反应,服务端好像"失忆"了一样。

这个现象背后的原因其实不复杂。服务端的accept()方法返回一个Socket对象之后,如果你直接在主线里用InputStream.read()去读数据,那么这行代码就是阻塞的。所谓阻塞,就是程序停在这一行不动了,直到有数据可读才继续往下走。在单线程模型里,主线程被第一个客户端的read()占住,根本没有机会回到accept()去接收第二个连接。于是第二个客户端虽然TCP三次握手成功了(内核层面完成了连接),但应用层没人理它,表现就是"连上了但没反应"。

这就是标题里"多用户与服务端Socket通信"要解决的核心矛盾:一个服务端进程,如何同时伺候多个客户端,且互不干扰。这个问题的解法在Java里主要有三条路——多线程、线程池、NIO多路复用。本篇聚焦前两条,因为它们最贴近"多线程"这个关键词,也是绝大多数中小型项目里最实用的方案。NIO那套Selector机制虽然更高效,但代码复杂度陡增,不适合作为入门第一站。

适合读这篇内容的人,我大致分三类:一是刚学完Java基础、想找个练手项目的学生;二是工作中需要写个内部小工具做设备数据采集的开发者;三是面试前想把这个经典题目彻底搞明白的求职者。不管你是哪一类,只要跟着把原理和代码都过一遍,再自己动手改几个参数,基本就能吃透。

提示:本文所有代码基于Java 8及以上版本,使用标准库java.net和java.util.concurrent,不需要引入任何第三方依赖,复制到IDE里就能跑。

2. 多线程方案的核心机制:一个连接一个线程

2.1 为什么"每连接一线程"是最直观的解法

回到刚才的阻塞问题。既然主线程被read()卡住了,那最朴素的想法就是:别让主线程去读数据,让它专心负责接收连接。每来一个客户端,就新起一个线程,把这个客户端的Socket交给新线程去处理读写。主线程accept()完立刻回到循环顶部,准备迎接下一个连接。这样一来,多个客户端各自有独立的线程服务,互不阻塞。

这个模型在操作系统教材里叫"Thread-per-Connection",翻译过来就是"每连接一线程"。它的优点非常明显:代码结构简单,逻辑线性,符合人类直觉。每个线程里就是一套"读-处理-写"的流程,跟单用户版本几乎一模一样,只是被包进了Runnable里。对于连接数不多(比如几十到一两百)的场景,这个方案完全够用,而且调试起来特别方便——哪个客户端出问题,看对应线程的堆栈就行。

但它的缺点同样明显。线程是操作系统里比较"重"的资源,每创建一个线程,JVM要向操作系统申请栈空间(默认1MB左右),还要做上下文切换。如果同时来一千个连接,就是一千个线程,内存直接吃掉1GB,CPU光在切换线程上就忙不过来。所以这个方案有个隐含前提:并发连接数可控。一旦连接数上量,就必须换方案。

2.2 线程的生命周期与Socket的绑定关系

这里有个容易被忽略的细节:线程和Socket是一对一绑定的,线程结束意味着这个连接的服务终止。所以在线程的run()方法里,必须用一个while循环持续读取,直到客户端主动断开(read()返回-1)或者发生异常,才能退出循环、关闭Socket、结束线程。如果你只读一次就退出,那客户端发第二条消息时就没人处理了。

我见过不少初学者写的代码是这样的:在run()里读一次数据,打印出来,然后方法就结束了。结果客户端发第二条消息石沉大海。原因就是线程已经跑完销毁了,Socket虽然还开着,但没人读它。正确的做法是:

public void run() { try (BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream()))) { String line; // 循环读取,直到对方关闭连接返回null while ((line = reader.readLine()) != null) { System.out.println("收到来自 " + socket.getRemoteSocketAddress() + " 的消息: " + line); // 这里可以做业务处理,然后回写响应 } } catch (IOException e) { System.out.println("连接异常: " + e.getMessage()); } finally { // 无论正常还是异常,都要关闭Socket释放资源 try { socket.close(); } catch (IOException ignored) {} } }

注意try-with-resources的用法,它保证reader在退出时自动关闭。而finally块里关闭Socket是必须的,否则文件描述符会泄漏,跑久了服务端会报"Too many open files"。

2.3 主线程的accept循环该怎么写

主线程的职责非常单一:坐在ServerSocket上等连接,来了就派发。写法上有个讲究——accept()本身也会抛IOException,而且ServerSocket关闭时accept()会抛异常退出。所以循环要能优雅处理关闭信号。

ServerSocket serverSocket = new ServerSocket(8888); System.out.println("服务端启动,监听 8888 端口"); while (!Thread.currentThread().isInterrupted()) { try { Socket client = serverSocket.accept(); System.out.println("新客户端接入: " + client.getRemoteSocketAddress()); // 每来一个连接,起一个新线程 new Thread(new ClientHandler(client)).start(); } catch (IOException e) { // 如果是主动关闭导致的异常,跳出循环 if (serverSocket.isClosed()) break; e.printStackTrace(); } }

这里用Thread.currentThread().isInterrupted()作为循环条件,是为了支持外部通过中断信号来停止服务。实际项目里更常见的做法是用一个volatile boolean running标志位,效果类似。关键点是:accept循环里不要做任何耗时操作,派发完立刻回到accept,否则新连接会排队等待。

3. 从裸线程到线程池:什么时候该升级

3.1 裸线程方案的三个致命场景

"每连接一线程"在小规模下没问题,但下面三种场景会让它原形毕露。

第一种是连接数突增。比如你写了个内部工具,本来只给三五个同事用,结果某天被推广到整个部门,五十个人同时连上来。五十个线程还好,但如果这是个面向公网的测试服务,被扫描器扫到,瞬间几百个连接打进来,线程数飙升,内存和CPU都可能被拖垮。

第二种是短连接高频请求。有些客户端的设计是"发一条消息就断开,下次再重连"。这种模式下,服务端会不停地创建线程、销毁线程。创建线程本身是有开销的(虽然比进程小得多),高频创建销毁会让CPU大量时间花在调度上,而不是处理业务。

第三种是线程数不可控。裸线程方案没有任何上限,来多少连接就起多少线程。万一遇到恶意连接(只连不发数据,占着线程不放),线程数会无限增长,最终OOM。这在生产环境是绝对不能接受的。

3.2 线程池的引入与参数计算

线程池的核心思想是复用线程。预先创建一批线程放在池子里,来了任务就从池子里拿一个空闲线程去执行,执行完线程不销毁,归还池子等待下一个任务。这样就避免了频繁创建销毁的开销,同时能通过参数控制最大并发数。

Java里用ThreadPoolExecutor来创建线程池,核心参数有四个需要你理解透彻:

参数含义设置建议
corePoolSize核心线程数,即使空闲也保留根据CPU核数和IO等待比例定
maximumPoolSize最大线程数一般设为核心数的2-4倍
keepAliveTime非核心线程空闲存活时间60秒左右
workQueue任务队列有界队列,防止无限堆积

对于Socket通信这种IO密集型任务(线程大部分时间在等网络数据,CPU占用低),核心线程数可以设得比CPU核数大。一个经验公式是:核心线程数 = CPU核数 / (1 - 阻塞系数),阻塞系数对IO密集型任务通常取0.8到0.9。假设你的机器是4核,阻塞系数0.9,那核心线程数大约是40。当然这只是理论值,实际要根据压测结果调整。

int cpuCores = Runtime.getRuntime().availableProcessors(); int coreSize = cpuCores * 2; // 保守起步,后续压测调整 int maxSize = cpuCores * 4; ThreadPoolExecutor pool = new ThreadPoolExecutor( coreSize, maxSize, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(200), // 有界队列,最多积压200个任务 new ThreadFactory() { private final AtomicInteger counter = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "socket-worker-" + counter.getAndIncrement()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.AbortPolicy() // 队列满时拒绝,抛异常 );

注意这里用了有界队列加AbortPolicy。很多人图省事用Executors.newCachedThreadPool(),那个用的是无界队列(SynchronousQueue配合无限大的max),任务堆积时会无限创建线程,反而更危险。生产环境一定要手动new ThreadPoolExecutor,把参数捏在自己手里。

3.3 拒绝策略的选择与背压传递

当线程池和队列都满了,新来的连接怎么办?AbortPolicy会直接抛RejectedExecutionException。在Socket场景里,这个异常发生在accept()之后、任务提交时。此时连接已经建立,但没线程处理。合理的做法是捕获这个异常,给客户端回一条"服务繁忙"的消息,然后关闭连接,而不是让它悬着。

try { pool.execute(new ClientHandler(client)); } catch (RejectedExecutionException e) { // 服务过载,礼貌拒绝 try (PrintWriter out = new PrintWriter(client.getOutputStream(), true)) { out.println("SERVER_BUSY"); } client.close(); }

这就是所谓的背压传递——服务端处理不过来时,把压力反馈给客户端,让客户端知道该退避或重试,而不是默默堆积直到崩溃。这个细节很多教程不讲,但实际项目里非常关键。

4. 共享资源的并发陷阱:多线程下最容易翻车的地方

4.1 客户端注册表的线程安全问题

多用户场景下,服务端通常需要维护一份"当前在线客户端"的列表,方便做广播或者定向推送。这个列表就是典型的共享可变状态,多线程同时读写必然出问题。

假设你用ArrayList<Socket>存客户端,一个线程在遍历列表发广播,另一个线程正好往列表里加新客户端,ArrayList会抛ConcurrentModificationException。更隐蔽的是,即使不抛异常,遍历时可能漏掉刚加进来的元素,或者读到不一致的状态。

正确的做法是用ConcurrentHashMap或者CopyOnWriteArrayList。前者适合频繁增删、偶尔遍历的场景;后者适合读多写少的场景,每次写操作复制整个数组,代价高但读操作无锁。对于在线客户端列表,增删(上下线)和遍历(广播)都算频繁,我一般用ConcurrentHashMap<String, ClientSession>,key用客户端ID,value封装Socket和输出流。

private final ConcurrentHashMap<String, ClientSession> onlineClients = new ConcurrentHashMap<>(); // 上线 onlineClients.put(sessionId, session); // 下线 onlineClients.remove(sessionId); // 广播 for (ClientSession s : onlineClients.values()) { s.send(message); }

4.2 输出流的并发写问题

比集合更隐蔽的坑是同一个Socket的输出流被多个线程同时写。比如客户端A发来消息,服务端要广播给B、C、D。如果每个接收线程都直接往B的输出流写,而B自己也在处理自己的业务往同一个流写,两个线程的字节就会交错,客户端收到的数据就乱了。

解决办法是给每个客户端的写操作加锁,或者更优雅地,给每个客户端配一个独立的发送队列,由专门的发送线程串行写出。前者简单,后者性能更好。简单方案:

public class ClientSession { private final Socket socket; private final PrintWriter out; private final Object writeLock = new Object(); public void send(String msg) { synchronized (writeLock) { out.println(msg); } } }

synchronized锁的是writeLock对象,保证同一时刻只有一个线程能写这个客户端的流。锁的粒度要小,不要锁整个Session,否则读操作也会被阻塞。

4.3 优雅关闭:别让线程泄漏

服务端关闭时,如果只是简单调用System.exit(),那些正在阻塞在read()上的工作线程不会被通知,Socket也不会关闭。虽然进程退出后操作系统会回收资源,但在Web容器或者需要热重启的场景下,这就成了问题。

优雅关闭的步骤是:先关闭ServerSocket停止接收新连接,然后遍历所有在线客户端,逐个关闭Socket(关闭Socket会让阻塞的read()抛SocketException,从而唤醒工作线程),最后关闭线程池。

public void shutdown() { try { serverSocket.close(); // 停止accept } catch (IOException ignored) {} // 关闭所有客户端连接,唤醒阻塞的读线程 for (ClientSession s : onlineClients.values()) { s.close(); } pool.shutdown(); // 不再接受新任务 try { if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { pool.shutdownNow(); // 超时强制关闭 } } catch (InterruptedException e) { pool.shutdownNow(); Thread.currentThread().interrupt(); } }

awaitTermination给5秒缓冲,让正在处理的任务跑完。如果5秒还没结束,shutdownNow()会中断线程。注意shutdownNow()只是发中断信号,如果任务不响应中断(比如阻塞在不可中断的IO上),还是关不掉。所以前面关闭Socket这一步很关键,它能让IO阻塞提前返回。

5. 协议设计:让多用户通信真正可用

5.1 为什么必须定义消息边界

TCP是字节流协议,它不保证你发一次write,对方就read一次。你发"Hello"和"World"两条消息,对方可能一次读到"HelloWorld",也可能分三次读到"Hel"、"loW"、"orld"。这就是所谓的粘包/拆包问题。

单用户Demo里通常用readLine()配合换行符来划分消息,因为BufferedReader.readLine()会一直读到换行符才返回。这在简单场景下够用,但有个限制:消息内容里不能包含换行符,而且如果对方发了个超长消息不带换行,readLine()会一直等,内存可能被撑爆。

更健壮的协议是长度前缀:每条消息前面用固定字节数(比如4字节int)标明消息体的长度,接收方先读4字节拿到长度N,再精确读N字节。这样无论消息内容是什么,都能正确切分。

// 发送:先写长度,再写内容 public void sendMessage(OutputStream out, byte[] body) throws IOException { DataOutputStream dos = new DataOutputStream(out); dos.writeInt(body.length); // 4字节长度前缀 dos.write(body); dos.flush(); } // 接收:先读长度,再读内容 public byte[] readMessage(InputStream in) throws IOException { DataInputStream dis = new DataInputStream(in); int length = dis.readInt(); // 阻塞直到读满4字节 if (length < 0 || length > MAX_MSG_SIZE) { throw new IOException("非法消息长度: " + length); } byte[] body = new byte[length]; dis.readFully(body); // 保证读满length字节 return body; }

readFully是关键,它会循环读取直到填满数组,避免只读了一半就返回。MAX_MSG_SIZE做上限校验,防止对方发个Integer.MAX_VALUE把内存撑爆。

5.2 心跳机制:识别僵尸连接

TCP连接有个特点:如果一方断电或者网线拔了,另一方不会立刻知道,可能过很久(取决于系统配置)才超时。这期间服务端会一直保留这个"僵尸连接"的线程和资源。对于多用户服务端,僵尸连接积累多了就是灾难。

解决办法是心跳:客户端每隔一段时间(比如30秒)发一个特殊的心跳包,服务端收到就更新该客户端的"最后活跃时间"。服务端再起一个定时任务,扫描所有客户端,如果某个客户端超过90秒没活跃,就判定为僵尸,主动关闭。

// 心跳检测定时任务 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { long now = System.currentTimeMillis(); for (ClientSession s : onlineClients.values()) { if (now - s.getLastActiveTime() > 90_000) { System.out.println("心跳超时,关闭: " + s.getId()); s.close(); onlineClients.remove(s.getId()); } } }, 30, 30, TimeUnit.SECONDS);

心跳包本身可以是一个约定的特殊消息(比如长度前缀为0,或者内容为特定字符串),服务端识别到就只更新活跃时间,不当作业务消息处理。

5.3 消息编解码与业务分发

有了消息边界,接下来就是编解码。实际项目里消息通常不是纯文本,而是结构化的,比如JSON或者自定义二进制格式。我一般推荐用JSON,可读性好,调试方便,Java里用Jackson或Gson几行就能搞定。

// 消息体用JSON public class Message { private String type; // 消息类型:LOGIN, CHAT, LOGOUT private String from; private String content; // getter/setter省略 } // 收到字节后反序列化 Message msg = objectMapper.readValue(body, Message.class); // 根据type分发到不同处理器 switch (msg.getType()) { case "LOGIN": handleLogin(session, msg); break; case "CHAT": handleChat(session, msg); break; case "LOGOUT": handleLogout(session); break; default: session.send("未知消息类型"); }

这种类型分发的模式让代码结构清晰,新增消息类型只要加一个case和对应handler,符合开闭原则。注意handler里如果要做耗时操作(比如查数据库),不要在主接收线程里做,应该再丢给业务线程池,避免阻塞消息接收。

6. 实测中的性能拐点与调优经验

6.1 压测方法与关键指标

光看代码不够,得实际压一压才知道方案能扛多少。我常用的压测方法是写一个简单的客户端程序,用线程池模拟N个并发连接,每个连接每秒发M条消息,跑5分钟,观察服务端的表现。

关键指标有三个:吞吐量(每秒处理多少消息)、延迟(消息从发出到收到响应的耗时)、资源占用(CPU、内存、线程数)。用jconsole或者jvisualvm连上服务端进程就能看到线程数和堆内存变化。

我实测过一组数据,在一台4核8G的机器上,用"每连接一线程"方案,连接数从100加到500时,吞吐量基本线性增长;到800左右开始平缓;超过1000后,吞吐量不升反降,同时CPU的sy(系统态)占比飙升,说明大量时间花在了线程上下文切换上。这就是性能拐点。

换成线程池方案(核心40、最大80、队列200),同样硬件下能稳定支撑到1500连接左右,吞吐量比裸线程高约30%,且内存占用更平稳。再往上就得考虑NIO了,但那是另一个话题。

6.2 常见异常与排查思路

多线程Socket编程里,异常是常态,关键是要能快速定位。下面这张表是我踩坑总结出来的常见异常对照:

异常信息可能原因排查方向
Connection reset客户端强制关闭,服务端还在写捕获异常,清理该客户端资源
SocketException: Socket closed服务端主动关闭后线程还在读检查关闭逻辑,用标志位控制
Too many open filesSocket未关闭,文件描述符泄漏检查finally块是否关闭Socket
OutOfMemoryError: unable to create new native thread线程数超过系统限制换线程池,控制最大线程数
java.io.IOException: Broken pipe对端已关闭,本端继续写写之前检查连接状态,捕获异常

排查时我习惯先看线程dump(jstack),看看有多少线程、都卡在哪。如果大量线程卡在SocketInputStream.read,那是正常的等待;如果卡在某个锁上,那就是并发问题;如果线程数异常多,那就是泄漏。

6.3 几个能立刻用上的调优技巧

第一个技巧是设置Socket的TCP_NODELAY。默认情况下TCP会攒一批小数据再发(Nagle算法),这在实时通信里会引入延迟。对于聊天、指令控制这类场景,应该关掉:

socket.setTcpNoDelay(true);

第二个技巧是合理设置SO_TIMEOUT。给read()设一个超时时间,避免线程无限期阻塞:

socket.setSoTimeout(60_000); // 60秒没数据就抛SocketTimeoutException

这样即使心跳机制失效,线程也能定期醒来检查状态。注意超时抛异常后连接不一定坏了,可以捕获后继续循环读。

第三个技巧是用缓冲流包装原始流。直接对InputStream做小字节读性能很差,每次read都是一次系统调用。用BufferedInputStream包一层,一次读一大块到内存,后续从内存读,性能能提升好几倍。

BufferedInputStream bis = new BufferedInputStream(socket.getInputStream(), 8192);

第四个技巧是控制日志输出。多线程下如果每个消息都System.out.println,控制台的同步锁会成为瓶颈。生产环境应该用异步日志框架,或者至少把日志级别调高,只记录关键事件。

7. 方案选型:多线程、线程池还是NIO

7.1 三种方案的适用边界

聊到这里,有必要把三种方案摆在一起对比一下,方便你根据实际场景做选择。

维度每连接一线程线程池NIO多路复用
代码复杂度低中高
最大连接数几百一千到几千数万
内存占用高(每线程1MB栈)中低
调试难度低中高
适用场景内部工具、教学中小型服务高并发网关

选型的核心判断依据是并发连接数量级。一百以内,裸线程最省心;一千以内,线程池足够;上万,必须NIO。不要为了"先进"而过度设计,我见过太多项目一上来就上Netty,结果业务逻辑没几行,全在跟框架的异步回调较劲,维护成本极高。

7.2 线程池方案的隐藏成本

线程池虽然比裸线程好,但也不是银弹。它有几个隐藏成本需要心里有数。

一是任务队列的延迟。当所有核心线程都在忙,新任务进队列排队。如果队列里积压了很多任务,排在后面的任务要等很久才被执行,表现为客户端感觉"服务端反应慢"。这时候要么加大核心线程数,要么缩短单个任务的处理时间。

二是线程池的监控盲区。ThreadPoolExecutor提供了getActiveCount()、getQueue().size()等方法,但这些数据要主动采集。建议接一个定时任务,每隔几秒打印一次线程池状态,方便观察趋势。如果活跃线程数长期等于最大值、队列长期非空,说明该扩容了。

三是异常吞噬。execute()提交的任务如果抛了未捕获异常,线程会销毁并重新创建,异常信息可能只打到标准错误。建议在run()方法最外层包一层try-catch,把异常记到日志里,别让它悄悄溜走。

7.3 什么情况下该考虑NIO

如果你的场景满足下面任意一条,就该认真考虑NIO了:连接数稳定超过五千;需要支撑大量长连接但消息量稀疏(比如物联网设备上报);对内存占用极其敏感(比如容器环境限制严格)。

NIO的核心是一个线程管理多个连接,通过Selector监听所有Channel的事件,有数据才处理,没数据就等着。这样线程数和连接数解耦,一个线程能管几千个连接。代价是编程模型从"阻塞顺序"变成了"事件驱动",代码要围绕SelectionKey和回调来组织,心智负担大不少。

不过话说回来,对于本篇标题"多线程实现多用户Socket通信"这个命题,线程池方案已经能覆盖90%的实际需求。把线程池玩透,比半吊子地堆NIO要靠谱得多。

8. 一套可直接复用的服务端骨架

8.1 整体结构设计

把前面所有要点串起来,我给出一套经过实际项目验证的服务端骨架。它的结构分四层:接入层(ServerSocket + accept循环)、调度层(线程池)、会话层(ClientSession管理连接状态)、业务层(消息处理器)。

public class MultiClientServer { private final int port; private ServerSocket serverSocket; private ThreadPoolExecutor workerPool; private final ConcurrentHashMap<String, ClientSession> sessions = new ConcurrentHashMap<>(); private volatile boolean running = true; public MultiClientServer(int port) { this.port = port; } public void start() throws IOException { serverSocket = new ServerSocket(port); int cores = Runtime.getRuntime().availableProcessors(); workerPool = new ThreadPoolExecutor( cores * 2, cores * 4, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(500), new NamedThreadFactory("socket-worker"), new ThreadPoolExecutor.AbortPolicy() ); System.out.println("服务端已启动,端口: " + port); while (running) { try { Socket client = serverSocket.accept(); client.setTcpNoDelay(true); client.setSoTimeout(120_000); ClientSession session = new ClientSession(client, this); workerPool.execute(session); } catch (IOException e) { if (!running) break; System.err.println("accept异常: " + e.getMessage()); } } } public void register(String id, ClientSession s) { sessions.put(id, s); } public void unregister(String id) { sessions.remove(id); } public void broadcast(String msg, String excludeId) { sessions.forEach((id, s) -> { if (!id.equals(excludeId)) s.send(msg); }); } public void shutdown() { running = false; try { serverSocket.close(); } catch (IOException ignored) {} sessions.values().forEach(ClientSession::close); workerPool.shutdown(); } }

ClientSession实现Runnable,在run()里做消息读取、解码、分发。每个Session持有自己的writeLock保证写安全,持有lastActiveTime供心跳检测使用。

8.2 关键代码的注意事项

这套骨架里有几个地方值得单独强调。

第一,accept()之后立刻设置setTcpNoDelay和setSoTimeout,这两个参数要在任何读写之前设置,否则不生效。

第二,ClientSession的run()方法里,读取循环要用while (running && !socket.isClosed())作为条件,配合SO_TIMEOUT抛出的SocketTimeoutException做定期检查。捕获超时异常后不要退出循环,继续读即可。

第三,broadcast方法遍历ConcurrentHashMap是弱一致性的,遍历过程中如果有客户端上下线,可能看不到最新状态,但不会抛异常。对于广播场景这完全可以接受。

第四,shutdown的顺序不能乱:先置running=false,再关ServerSocket(让accept抛异常退出),再关所有客户端(唤醒读线程),最后关线程池。顺序错了可能导致线程卡死。

8.3 客户端测试代码

服务端写好了,得有个客户端来验证。下面这个简易客户端可以开多个实例,模拟多用户:

public class TestClient { public static void main(String[] args) throws Exception { Socket socket = new Socket("127.0.0.1", 8888); socket.setTcpNoDelay(true); // 读线程 new Thread(() -> { try (BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream()))) { String line; while ((line = in.readLine()) != null) { System.out.println("[收到] " + line); } } catch (IOException e) { System.out.println("连接断开"); } }).start(); // 主线程负责发送 BufferedReader console = new BufferedReader(new InputStreamReader(System.in)); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); String input; while ((input = console.readLine()) != null) { out.println(input); } } }

开三个终端跑这个客户端,在任意一个里输入消息,另外两个应该能收到广播(前提是服务端实现了广播逻辑)。这样就能直观验证多用户并发通信是否正常。

9. 几个我踩过的坑和对应解法

9.1 客户端断开后线程没退出

早期我写的ClientSession里,读取循环用的是while (true),结果客户端断开后,read()返回-1,但循环没退出,线程一直空转。后来改成while ((line = reader.readLine()) != null),读到null就退出。但这里有个坑:readLine()在连接正常但暂时没数据时会阻塞,只有对方关闭连接才返回null。所以这个写法是对的,但前提是用了BufferedReader。如果直接用InputStream.read(),返回-1表示结束,返回0表示读到了0字节(一般不会),要区分清楚。

9.2 线程池满了之后连接被静默丢弃

有一次压测发现,当连接数超过线程池容量后,新连接虽然TCP握手成功,但客户端发消息没任何响应,也不报错。排查后发现是AbortPolicy抛的RejectedExecutionException被accept循环的catch (IOException)漏掉了——RejectedExecutionException是RuntimeException,不是IOException,所以没被捕获,直接导致accept线程崩溃退出。修复方法是单独捕获RejectedExecutionException,给客户端回拒绝消息。这个坑很隐蔽,因为异常类型不匹配,编译器不会提醒。

9.3 广播时的并发修改异常

用ArrayList存客户端列表时,广播遍历过程中如果有客户端下线并调用remove,立刻抛ConcurrentModificationException。换成CopyOnWriteArrayList后问题消失,但要注意它的写操作是复制整个数组,如果客户端频繁上下线,性能会下降。最终我选了ConcurrentHashMap,兼顾了并发安全和性能。

9.4 消息乱序问题

多线程环境下,如果多个线程同时往同一个客户端的输出流写,消息会交错。我最初的广播实现是每个接收线程直接调用目标客户端的send,结果客户端收到的消息偶尔是乱的。加上writeLock同步后解决。这个问题的本质是输出流不是线程安全的,任何多线程写同一流的场景都要加锁。

10. 写在最后的一点个人体会

这套多线程Socket通信的方案,我从学生时代的第一版Demo,到工作后用在设备数据采集工具上,前后改过七八个版本。最大的感受是:并发编程的难点从来不在语法,而在对共享状态和资源生命周期的管理。线程怎么起、怎么停、怎么复用,Socket什么时候关、谁来关、关了之后阻塞的线程怎么办,这些才是真正花时间的地方。

如果你刚开始学,我的建议是先把"每连接一线程"的版本彻底跑通,理解阻塞和线程绑定的关系;然后手动改成线程池,观察线程数变化;最后再考虑加心跳、加协议、加优雅关闭。每一步都自己动手改一遍,比看十篇教程都管用。遇到异常别慌,先看线程dump,再看Socket状态,大部分问题都能定位。

这套骨架代码我放在内部工具里跑了两年多,支撑过几百个设备的同时接入,稳定性没问题。当然,如果你的场景连接数真的上了万,那还是老老实实上NIO或者成熟的网络框架,别跟线程池死磕。工具选对了,事半功倍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 12:03:53

subagent-driven-development - spec-reviewer-prompt

规格合规审查者提示词模板 在派遣规格合规审查子代理时使用此模板。 目的&#xff1a; 验证实现者构建了所要求的内容&#xff08;不多也不少&#xff09; Task tool (general-purpose):description: "审查任务 N 的规格合规性"prompt: |你正在审查一个实现是否与其规…

作者头像 李华
网站建设 2026/10/16 12:00:59

I2C从机设计硬核指南:地址匹配、状态机与寄存器级调试

简介&#xff1a;本资源是一份基于Verilog语言的I2C从设备&#xff08;I2Cslave&#xff09;完整FPGA实现方案&#xff0c;面向数字电路设计初学者、EDA课程学习者及嵌入式硬件开发工程师&#xff0c;聚焦I2C协议底层时序建模与可综合逻辑设计。内容涵盖I2C状态机控制、SCL/SDA…

作者头像 李华
网站建设 2026/10/16 11:56:52

P3分布参数拟合与KS检验:Python水文频率分析全流程

做水文频率分析的人&#xff0c;十有八九绕不开P3分布。年最大降雨量要拟合P3&#xff0c;设计洪水要拟合P3&#xff0c;水质浓度、枯水流量也常常用它。这几年我前前后后帮几个项目做过P3分布参数拟合、KS检验与累计概率密度画图的整套流程&#xff0c;踩过不少坑&#xff0c;…

作者头像 李华
网站建设 2026/10/16 11:56:38

手写简易Tomcat:深入理解Web容器核心架构与HTTP处理原理

1. 为什么“手写一个简易版的Tomcat”不是炫技&#xff0c;而是理解Web容器本质的必经之路你有没有在调试一个Spring Boot应用时&#xff0c;看到控制台第一行输出Tomcat started on port(s): 8080就直接点开浏览器&#xff1f;有没有在配置文件里反复修改server.port、server.…

作者头像 李华