尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
多线程与线程池实战: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左右还要做上下文切换。如果同时来一千个连接就是一千个线程内存直接吃掉1GBCPU光在切换线程上就忙不过来。所以这个方案有个隐含前提并发连接数可控。一旦连接数上量就必须换方案。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 客户端注册表的线程安全问题多用户场景下服务端通常需要维护一份当前在线客户端的列表方便做广播或者定向推送。这个列表就是典型的共享可变状态多线程同时读写必然出问题。假设你用ArrayListSocket存客户端一个线程在遍历列表发广播另一个线程正好往列表里加新客户端ArrayList会抛ConcurrentModificationException。更隐蔽的是即使不抛异常遍历时可能漏掉刚加进来的元素或者读到不一致的状态。正确的做法是用ConcurrentHashMap或者CopyOnWriteArrayList。前者适合频繁增删、偶尔遍历的场景后者适合读多写少的场景每次写操作复制整个数组代价高但读操作无锁。对于在线客户端列表增删上下线和遍历广播都算频繁我一般用ConcurrentHashMapString, ClientSessionkey用客户端IDvalue封装Socket和输出流。private final ConcurrentHashMapString, 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块是否关闭SocketOutOfMemoryError: unable to create new native thread线程数超过系统限制换线程池控制最大线程数java.io.IOException: Broken pipe对端已关闭本端继续写写之前检查连接状态捕获异常排查时我习惯先看线程dumpjstack看看有多少线程、都卡在哪。如果大量线程卡在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. 方案选型多线程、线程池还是NIO7.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 ConcurrentHashMapString, 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的顺序不能乱先置runningfalse再关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或者成熟的网络框架别跟线程池死磕。工具选对了事半功倍。
RELATED

相关推荐

eCLIP-Seq技术全解析:从UMI去重到Input对照的RBP结合位点分析方法

eCLIP-Seq技术全解析:从UMI去重到Input对照的RBP结合位点分析方法

文献积累系列写到第7篇,这次想好好拆一下eCLIP-Seq。eCLIP-Seq不是一个“CLIP的增强版”就能带过的概念,它把实验流程、测序策略和分析范式一起改了,并且靠一套几十个RNA结合蛋白(RBP)上的可重复实验证明了这套流程可以…

📅 2026/10/16 12:04:05
pvbrowser开源SCADA组态软件:架构、开发与避坑指南

pvbrowser开源SCADA组态软件:架构、开发与避坑指南

简介:pvbrowser是一款基于QT库构建的开源SCADA组态软件,面向工业自动化、能源管理、楼宇监控等场景,适合从事实时监控与数据采集的开发人员、运维工程师以及SCADA初学者。资源包为ZIP压缩包,大小27.65MB,下载页未标注文…

📅 2026/10/16 12:04:05
Pytorch语义分割入门:CamVid数据集读取与实战避坑指南

Pytorch语义分割入门:CamVid数据集读取与实战避坑指南

很多做语义分割的朋友,一开始总喜欢直接拿几十GB的大规模驾驶数据集练手,结果光在数据下载和加载上就耗掉大半热情。我个人始终觉得,CamVid数据集才是把整套 Pytorch 分割流程跑通的最佳起点。它规模适中、标注统一、类别覆盖了典型的道路场景…

📅 2026/10/16 12:04:05
MORE NEWS

更多资讯

📰

K8s部署Redis生产级实战:持久化、高可用与Helm一站式避坑

聊到k8s安装redis,很多人第一反应是“拉个镜像、起个Deployment不就完事了”。真上手操作你会发现,光是把Pod跑起来只算幼儿园水平,数据不丢、重启不挂、主从能自动切换才是真正的考验。去年某项目上线前,我们所有服务共用一个Red…

📰

QCustomPlot瀑布图绘制详解:从QCPColorMap到实时滚动实现

简介:基于Qt与QCustomPlot的瀑布图实现demo,主要面向需要在Qt界面中展示数据变化过程的C开发者。demo通过随机数生成数据,完整演示了初始化绘图控件、配置坐标轴与刻度、创建QCPGraph对象、绘制矩形及连接线、调用replot刷新视图等关键流程&a…

📰

公共文化场馆携程评论爬取实战:反爬应对与文旅数据应用

1. 项目概述:为什么一个城市图书馆的评论数据值得被系统性采集?“广州图书馆——携程评论爬取”这个标题乍看有点违和——图书馆不是政务服务平台或公共文化场馆吗?怎么会出现在携程这样的旅游预订平台上?但正是这种“反常识”的组…

📰

用Go实现GitLab pre-receive钩子:强制规范commit message

简介:这是一份采用 Go 语言编写的 GitLab pre-receive(预接收)钩子示例资源,面向 GitLab 管理员、DevOps 工程师及对服务端钩子机制感兴趣的开发者。它演示了如何在代码推送前拦截提交,通过检查最新提交消息是否包含指…

📰

PySOT评估必装pysot-toolkit:Ubuntu 16.04编译安装与评测原理详解

1. 为什么必须单独安装 pysot-toolkit:PySOT 主库与评估工具的职责边界 很多人在跑通 PySOT 训练流程后,会自然地认为“模型训出来了,接下来直接 eval 就行”——结果在终端敲下 python tools/test.py 时,十有八九会遇到 Modul…

📰

福克斯刷机避坑:ELM327改AS-Built配置与备份恢复全攻略

简介:面向福特福克斯车主与汽车电子爱好者的电子控制单元刷机工具包,以ELM327诊断接口为硬件基础,可读取车辆电脑数据、分析参数并修改固件。压缩包共33个文件,集成FoCCCus主程序、19个vbf车型固件、12个动态链接库及1个xml配置说…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬