如果你写过几年Java,或者刚开始往服务端方向走,一定绕不开这样一个问题:两个进程之间到底是怎么"通话"的?我第一次认真接触Socket是在实习时,导师让我给内部工具写一个简单的消息推送服务端。我照着教程用ServerSocket写了一个能接收一条消息的Demo,本地一测能通,当时还挺高兴。结果第二天连第二个客户端,直接把我给看懵了——新客户端连进来之后没有任何反应,服务端也不报错,就像卡住了一样。后来我才明白,问题不是出在"网络",而是出在我对Socket模型的理解上:accept()和readLine()都是阻塞的,一个客户端不断开,服务端永远等不到下一个accept()。
这篇文章不打算堆OSI七层模型的概念,而是从一次真实的"卡死现场"出发,带着你用纯Java从Socket最基础的API开始,一步一步写一个能同时处理大量客户端连接的多线程服务器。你会弄清楚Socket到底是什么、accept()为什么阻塞、线程池参数怎么定,以及为什么有了多线程之后还会遇到拆包粘包这类新麻烦。适合刚学完Java基础、准备做服务端开发的同学,也适合那些平时只用框架写业务、没搞懂底层网络模型的人。
1. 先搞明白Socket到底是什么:别急着写代码
1.1 用一个"打电话"的类比理解Socket
很多人一开始接触Socket都会被术语劝退:连接、套接字、端口、握手、会话……其实你把Socket当成一个电话系统,瞬间就通透了。
- IP地址就是对方的电话号码,告诉你找哪台机器。
- 端口就是分机号,告诉你在这台机器上找哪个进程。一台服务器可以有好多服务,它们都靠"IP+端口"区分。
- TCP三次握手就是拨号到对方接起电话的过程。
- 连接建立之后,两端各自拿到一个Socket对象,就像两个人各拿一部话筒,你说话我能听到,我说话你能听到,数据双向流动。
用这个类比去理解服务端代码会轻松很多。ServerSocket就是前台接线员坐席,它的accept()方法相当于"等待电话进来";一旦有来电,accept()返回一个Socket,就相当于你接起电话开始通话。
同样的道理可以解释一个常见报错:BindException: Address already in use,或者是你在一些框架日志里看到的only one usage of each socket address。你可以理解成"这个分机号已经被别人占了",两个进程不能同时绑定同一个IP+端口。
理解了这层,再看Java的API就顺理成章了。Socket这个词本身也确实有"插头、插座"的含义——一端插在客户端,一端插在服务端,中间由TCP管道连通。
1.2 网络分层里必须知道的两个点:TCP和端口
我不打算把计算机网络教材翻出来,但有两点必须弄清楚,否则后面你排查问题会一头雾水。
第一,TCP是什么。TCP是面向连接的、可靠的、基于字节流的传输协议。"可靠"意味着消息有确认、超时重传、排序、流量控制;"基于字节流"则意味着TCP不保证你发一次就恰好被对端读一次,它只保证字节顺序和完整性。这直接引出了后面要讲的"拆包粘包"问题——为什么你用readLine()读一条消息可能读到半条,也可能一次读到好几条。
第二,端口号是16位无符号整数,范围0到65535。一台主机上可以同时监听多个端口,但要保证IP+端口唯一。进程A绑定了0.0.0.0:8080,进程B再想绑定同一个端口就会失败。排查端口冲突时,记住后面这个套路:先用netstat或lsof找到占用进程,再决定是杀掉旧进程还是换端口。
1.3 一个能"跑通"但很鸡肋的最小Demo
先把最简单的代码写出来,亲眼看到数据通,再讨论改造。这个例子包含一个服务端和一个客户端,服务端收到一行文本后回复一句,然后退出。
// 服务端 public class SingleServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); System.out.println("服务端已启动,监听端口: 8080"); Socket socket = serverSocket.accept(); System.out.println("收到连接: " + socket.getRemoteSocketAddress()); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); String line = in.readLine(); System.out.println("客户端说: " + line); out.println("你好客户端,我收到了: " + line); socket.close(); serverSocket.close(); } }// 客户端 public class SimpleClient { public static void main(String[] args) throws IOException { Socket socket = new Socket("127.0.0.1", 8080); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); out.println("hello server"); String response = in.readLine(); System.out.println("服务端回复: " + response); socket.close(); } }你先运行服务端,再运行客户端,会看到两边互相打印消息。这里有几个细节值得注意:
PrintWriter的第二个参数true表示自动flush,所以println()后数据会立刻发出去,否则可能要等到缓冲区满才发送。readLine()会一直阻塞,直到读到\n或\r\n,或者流关闭。- 这段代码的发收模式是"一问一答":客户端发一行,服务端读一行、回一行。只要有一端没发换行符,另一端就会一直等着。
这个Demo最大的问题是:服务端只处理一个连接,处理完就退出。你连第二个客户端的时候,操作系统层面TCP连接可能已经建立了,但服务端进程已经没有再去调用accept(),第二个客户端自然得不到任何回应。
2. 踩坑现场:为什么"能跑通"的Demo连不了第二个客户端
2.1 现象复现:三个终端看现场
别光看我描述,你自己花三分钟复现一次,印象会非常深。
- 终端1:启动
SingleServer。 - 终端2:启动
SimpleClient,正常收发。 - 终端3:再次启动
SimpleClient,程序就停在new Socket("127.0.0.1", 8080)这一步,什么也不打印,也不报错。
更典型的一种场景是:启动一个客户端后不退出,保持连接占着,然后再开第二个客户端。这第二个客户端的连接请求会一直在等待队列里躺着。用jstack看服务端线程,你会发现主线程停在socketAccept方法上。
为什么?因为服务端主线程的代码顺序是:先accept()拿到第一个连接,然后进入readLine()等消息。只要第一个客户端还连着、还没发消息,readLine()就不会返回,代码永远不会执行到第二个accept()。
2.2 accept()和readLine()为什么是"卡住"的
这是Java网络编程最核心的一个理解点:阻塞I/O。
ServerSocket.accept()在没有客户端连接进来时,线程会挂起,直到内核通知"有连接完成了三次握手"才返回。BufferedReader.readLine()同理,在内核缓冲区里没有读到一行数据时,线程也会挂起。对操作系统来说,这种"挂起"并不浪费CPU,但浪费的是"你只能处理这一个事情"的能力。
用一个更生活化的例子:你是公司前台唯一的接线员。你正在听第一个客户电话里滔滔不绝地说话,这时第二个客户打进来,电话铃一直在响,但你腾不出手去接。你不是不响应,而是被当前通话阻塞了。
这就是为什么单线程服务器无法同时服务多个客户端。不是Java的问题,是"阻塞+串行"这种组合注定了只能排队处理。
2.3 内核的等待队列:第二个连接其实"连上了"但没被接走
很多人会误解"第二个客户端卡住"是连接没建立成功。实际上TCP三次握手大概率已经完成了,连接进入了内核为监听Socket维护的accept queue(等待被accept()取走)。如果你的代码迟迟不调accept(),队列会越积越多;队列满了之后,新的连接请求就可能被内核丢弃,或者客户端看到连接超时。
这里可以引出一个被很多人忽略的参数:ServerSocket(int port, int backlog)。第二个参数backlog就是内核允许排队的连接数量上限。默认值因操作系统而异,通常是50或128。如果你的业务是短连接且并发量很大,适当调大backlog可以降低握手失败率,但它并不能解决"服务端来不及处理"的根因。
2.4 最朴素的修复思路:加while循环,为什么还是不行
很多有经验一点的初学者会想:那我用while(true)包住accept(),每次收到连接就处理,然后进入下一轮accept()不就行了?
while (true) { Socket socket = serverSocket.accept(); BufferedReader in = ...; PrintWriter out = ...; String line = in.readLine(); out.println("收到: " + line); }这种写法能解决"短连接、一问一答"的场景:每个客户端连上来,发一行消息,收一条回复,然后断开;服务端处理完这个,再accept()下一个。
但一旦客户端是长连接,比如聊天软件、游戏服务器、消息推送,客户端连上后可能几秒甚至几分钟不发数据,readLine()就会阻塞在第一个客户端上,后面排队的客户端又全部没人管。结论是:阻塞串行处理的方式,只适合单次交互立即结束的极简场景。只要协议是长连接,就必须让"接受连接"和"处理连接"解耦,给每个连接分配独立的执行资源。
这就是多线程服务器的动机。
3. 多线程服务器的第一版实现:线程池是标配,不是进阶
3.1 为什么要用线程池,而不是每连接一个new Thread
最直接的多线程玩法是:accept()返回一个Socket后,立刻new Thread(() -> handleClient(socket)).start()。这段代码确实能让多个客户端同时连接,但它有两个非常现实的问题。
第一,线程创建和销毁的代价高。每次都要向操作系统申请栈空间、注册进调度器,用完又要回收。如果客户端连接是高频短连接,开销很可观。
第二,线程数量不可控。每连接一线程意味着线程数随连接数线性增长。来1000个连接就有1000个线程,来10000个连接就有10000个线程。线程太多时,CPU时间片大量浪费在线程切换上,甚至可能把内存耗尽。这也是我不建议在for循环里频繁new Thread的原因——如果你真的需要并发,线程池几乎是更好的选择。
线程池的本质是复用固定数量的线程,让所有连接共享这些执行资源。连接多了,任务在队列里排队;线程资源不够,策略来兜底。多线程服务器要做的不是"连接越多线程越多",而是"在有限线程资源内尽量高效地服务所有连接"。
3.2 线程池参数怎么定:别再用Executors默认了
很多教程喜欢用Executors.newFixedThreadPool(10),做Demo完全够,但我不建议你在服务器项目里直接照抄。原因是newFixedThreadPool的队列是无界的LinkedBlockingQueue,如果任务积压太多,队列会内存暴涨;newCachedThreadPool则可能无限创建线程。学习阶段最好直接接触ThreadPoolExecutor,把参数含义搞清楚。
ThreadPoolExecutor pool = new ThreadPoolExecutor( 8, // corePoolSize 核心线程数 32, // maximumPoolSize 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new ArrayBlockingQueue<>(128), // 有界任务队列 r -> { Thread t = new Thread(r); t.setName("client-worker"); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() );几个参数怎么理解:
- corePoolSize:平时常驻线程数。网络服务器属于I/O密集,线程适当多一些没坏处,8到16是常见的起步值。
- maximumPoolSize:线程数上限。当队列满了,线程池才会创建额外的线程,直到这个上限。
- workQueue:排队的任务队列。有界队列更安全,128或256是经验起步值,具体要看你的内存和连接量。
- 拒绝策略:队列满且线程数到上限时的处理方式。
AbortPolicy默认会抛异常;CallerRunsPolicy会由调用者线程直接执行任务,天然限流,适合服务器不想直接拒绝请求的场景。
核心思想是:先在队列里排队,排不下再扩线程,线程到上限还有任务来就触发拒绝策略。这能防止系统在突发流量下被打垮。
3.3 一个完整可运行的多线程EchoServer
下面这个例子同时服务多个客户端,每个客户端可以反复发消息,服务端原样回复;发quit时断开连接。
public class ThreadPoolEchoServer { private static final int PORT = 8080; public static void main(String[] args) throws IOException { ThreadPoolExecutor pool = new ThreadPoolExecutor( 8, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(128), r -> { Thread t = new Thread(r); t.setName("client-worker"); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() ); try (ServerSocket serverSocket = new ServerSocket(PORT)) { System.out.println("服务器启动成功,端口: " + PORT); while (true) { Socket socket = serverSocket.accept(); pool.execute(() -> handleClient(socket)); } } } private static void handleClient(Socket socket) { System.out.println("新连接: " + socket.getRemoteSocketAddress() + ", 处理线程: " + Thread.currentThread().getName()); try { BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); String line; while ((line = in.readLine()) != null) { System.out.println("[" + socket.getRemoteSocketAddress() + "] -> " + line); if ("quit".equalsIgnoreCase(line)) { out.println("bye"); break; } out.println("echo: " + line); } } catch (IOException e) { System.out.println("连接异常: " + e.getMessage()); } finally { closeQuietly(socket); } } private static void closeQuietly(Socket socket) { try { socket.close(); } catch (IOException ignored) { // 忽略关闭异常 } } }测试方法很简单:
# 终端1启动服务端 java ThreadPoolEchoServer # 终端2 telnet 127.0.0.1 8080 # 终端3 nc 127.0.0.1 8080两个终端同时连,服务端都能一一回复。你还能在服务端日志里看到不同连接由不同线程名的线程处理,这就是"多线程服务器"最直观的样子。
3.4 处理客户端时最常见的两个失误
第一,忘了处理循环里的阻塞读。如果你只读一次客户端消息就返回,连接过期后线程还一直被占着。上面代码用while ((line = in.readLine()) != null),只要客户端不断开,线程就一直服务它;客户端断开后,readLine()返回null,循环退出,finally里关闭Socket,线程归还给线程池。
第二,把socket.close()写在catch里而不是finally里。如果out.println或in.readLine抛异常,连接就没被正常关闭,会造成连接泄漏。每次都坚持在finally里close,这是个好习惯。
4. 多线程解决了并发,又带出了三个新麻烦
4.1 消息边界:为什么生产环境不推荐裸readLine
readLine()看起来很好用,但它本质是把"换行符"当成了消息边界。这在简单文本协议里可以工作,放到生产环境则有三个问题。
第一,如果业务消息里本身就包含换行符,拆包就错了。比如你要传输一段JSON,格式化后带很多换行,用readLine()读出来的只是一行,而不是一条完整消息。
第二,TCP是字节流,readLine()读取时如果一条消息还没发完整,比如客户端发了hel,网络就中断了,服务端会一直卡在readLine()上,等不到那最后的lo\n。
第三,当客户端发消息很快、发送缓冲合并时,一次readLine()可能读出来的是两条消息拼在一起的"粘包";也可能读出来一条消息被拦腰截断的"半包"。
所以业界设计网络协议时,通常有三种方案:
- 定长消息:约定每条消息固定N字节,不够补位。适合字段固定、长度固定的场景。
- 分隔符协议:约定以某个特殊字符作为边界,比如HTTP的
\r\n\r\n头部分隔。 - 长度前缀:最通用,开头4字节表示消息体长度,再跟真正的消息体。无论做IM还是RPC都推荐这种方式。
学习阶段用readLine()理解原理没问题,但你要清楚它的局限性。真要写生产级服务器,建议直接用Protobuf配合Netty,或者至少自己封装一个长度字段的编解码器。
4.2 连接管理:服务器主动推送时,Socket引用放哪?
EchoServer只做了"收到消息再回复",但如果服务器要主动推送消息给某个客户端(比如通知另一个玩家上线),就得保存所有客户端连接。常见做法是维护一个ConcurrentHashMap:
private static final Map<String, Socket> clients = new ConcurrentHashMap<>(); public static void addClient(Socket socket) { clients.put(socket.getRemoteSocketAddress().toString(), socket); } public static void removeClient(Socket socket) { clients.remove(socket.getRemoteSocketAddress().toString()); }这段代码的注意点:
- 必须用
ConcurrentHashMap,不能用HashMap。因为多个工作线程都在往map里put/remove,HashMap在并发写时可能造成死循环(Java 8之前)或数据错乱。 - 连接关闭时记得remove,否则这个Socket对象一直被map引用,无法被GC回收,就是典型的内存泄漏。
- 如果要给所有客户端广播消息,遍历map逐个写。但注意每个客户端的输出流不能被两个线程同时写,否则消息会交错。简单做法是按连接粒度加锁,或者用一个线程池/队列把写操作串行化。
很多初学者一上来就想搞"广播",其实第一步是把连接存好、删对。否则上线通知写着写着就出现内存泄漏或消息错乱。
4.3 资源回收:close不是小事
Socket和文件流一样,用完必须关。而且关闭顺序有讲究:直接调用socket.close()是最稳妥的,它会关闭输入流和输出流,并发送FIN给对端,触发TCP四次挥手。如果你只关闭inputStream或outputStream,在不同平台上行为有差异,有的会连带关闭Socket,有的不会,很容易踩坑。
我在实际项目里还会做两件事:
- 把Socket引用放进finally里统一close,尽量不写多个手动关闭点。
- 对读到的异常做区分。比如客户端正常断开时,
readLine()返回null;如果是连接被重置,会抛SocketException: Connection reset。这两类都不算严重的业务异常,打日志要注意级别,别把正常踢下线打成ERROR刷屏。
4.4 端口绑定的经典报错与排查套路
写服务器程序,几乎每个人都会遇到BindException: Address already in use。它的本质是bind阶段发现IP:端口已被占用,无论是Java、Node、Python还是Go,底层都是同一个EADDRINUSE。排查套路是固定的:
在Linux上:
netstat -anp | grep 8080 ss -lnpt | grep 8080 lsof -i :8080在Windows上:
netstat -ano | findstr 8080 taskkill /F /PID 进程号很多时候是你上次启动的Java进程没被停掉。注意,服务端重启时,如果旧的连接还处于TIME_WAIT状态,某些场景下绑定时会受影响。ServerSocket.setReuseAddress(true)可以在服务端重启后更快地重用端口,但要在bind之前设置。这个选项主要影响TIME_WAIT状态,不是万能的。
顺便说一句,日常开发里MySQL连接时遇到的ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock',也属于Socket连接失败的典型场景,不过它是Unix Domain Socket,走的是文件系统路径而不是IP+端口,别和TCP Socket搞混。
4.5 线程池别只用来干活:命名、监控、拒绝策略
在生产环境里,线程池跑起来之后你会面临一个很现实的问题:不知道系统里有多少线程在跑,不知道谁创建了它们。所以从第一天起就给工作线程命名,这个习惯会救你很多次。上面代码里t.setName("client-worker")就是这么回事。等出问题时,你jstack一看线程名,立刻知道是哪个业务线程占着资源。
有条件的话,可以把线程池的关键指标打监控:活跃线程数、队列大小、完成任务数、拒绝次数。这些东西在流量高峰时比什么监控都直观。
5. 验证这个服务器的真实能力:压测与边界测试
5.1 用telnet和nc先做冒烟测试
代码写完先别急着上压测,先用工具连一次,确认基本功能没问题。
# 启动服务端 java ThreadPoolEchoServer # 终端A telnet 127.0.0.1 8080 # 输入 hello 回车,看到 echo: hello # 输入 quit 回车,看到 bye,连接断开 # 终端B nc 127.0.0.1 8080这一步能验证accept、readLine、println、close这一整条链路是通的。
5.2 写一个多线程客户端做并发验证
想验证服务器能同时处理多个客户端,最简单的方式是写一个并发客户端,一口气开几十个连接同时发消息。用CountDownLatch等所有线程结束,最后看成功数量。
public class MockClients { public static void main(String[] args) throws InterruptedException { int total = 50; CountDownLatch latch = new CountDownLatch(total); ExecutorService pool = Executors.newFixedThreadPool(20); for (int i = 0; i < total; i++) { int id = i; pool.execute(() -> { try (Socket socket = new Socket("127.0.0.1", 8080); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) { out.println("msg-" + id); String resp = in.readLine(); System.out.println("客户端 " + id + " 收到: " + resp); } catch (IOException e) { System.err.println("客户端 " + id + " 失败: " + e.getMessage()); } finally { latch.countDown(); } }); } latch.await(10, TimeUnit.SECONDS); pool.shutdownNow(); System.out.println("压测结束"); } }跑这个程序时,注意观察服务端的线程池状态,可以用jstack <pid>看工作线程数。正常情况下,50个连接不会同时耗尽32个线程,因为每个任务占一个线程直到连接关闭,如果你改成每个客户端连接后长时间挂起,线程数就会涨上去。
5.3 每连接一线程的极限在哪:C10K问题的入口
多线程服务器的实现到这一步,"能处理并发连接"这个目标已经达成。但它有明显的天花板:当连接数到达上万级别时,每连接占用一个线程的代价就绷不住了。你算算:一个线程默认栈空间1MB,一万个连接就算线程不干活也可能吃掉几个GB内存,再加上上下文切换开销,服务器会在连接量还很高时先把自己拖垮。这就是经典的C10K问题。
怎么解决?方向是事件驱动和多路复用,也就是Java NIO里的Selector机制:一个线程可以同时监听成千上万个Channel的事件,只有真正有数据可读时才触发处理。理解了阻塞I/O的瓶颈,你就明白为什么Netty会诞生,为什么现在主流RPC框架全都基于Netty。
5.4 下一步学习路线:别急着扣Netty
我的建议是先把本文这个线程池版本玩熟,做一些有真实感的改造,让底层概念长进肌肉里:
- 改造一:让它支持客户端之间互相发消息(需要维护客户端集合加广播)。
- 改造二:加入心跳机制,超过一段时间没收到消息就踢掉连接。
- 改造三:限制单IP最大连接数,防止某个客户端把所有线程池资源吃满。
- 改造四:把消息格式从纯文本换成"长度前缀+字节数组",亲身体会一次拆包粘包。
这些项目任何一个都值得写几百行代码。等你把它们全踩一遍,再去看NIO和Netty,会觉得豁然开朗。
我个人在实际项目里很少直接基于裸ServerSocket写生产服务器,但每当我需要排查连接异常、调线程池参数、设计通信协议时,当初手写这个多线程EchoServer打下的底子都会帮上大忙。网络编程最怕的就是"框架把细节都隐藏了",遇到诡异问题不知从何下手。从Socket到多线程服务器这条路,走通了,后面学什么都快。