很多人学Java学到集合、IO、并发之后,突然就不知道该往哪个方向使力了。往前走吧,框架源码看不进去;往后退吧,感觉基础也就那样。我通常的建议是:去碰一下网络编程。这个方向能把线程、IO、异常处理、设计模式全部串起来,而且学完马上就能看到东西在跑,正反馈特别强。这篇文章就从一个最简单的Socket通信开始,一路折腾到能扛住并发请求的多线程服务器,把我自己踩过的坑和后来想明白的东西都写清楚。
先交代一下这篇文章适合谁看:Java语法已经过完一遍、知道线程怎么开但没写过完整网络程序的读者,以及那些背了一堆八股文却不知道怎么落地的人。我会把每一段代码为什么这么写、背后的TCP/IP原理是什么、线上会出什么问题都拆开讲,而不是只丢一个能跑的Demo。
1. 内容整体设计与思路拆解
1.1 为什么网络编程是Java进阶绕不开的一关
网络编程在整个Java技术栈里的位置很特殊。它不像集合框架那样天天用,也不像并发那样只在特定场景才露脸,但它是几乎所有后端技术的底座。你用的Tomcat、Netty、Dubbo,底层全是Socket那一套东西,只是披了一层又一层封装。如果你直接把Netty拿来用,遇到连接断了、消息粘包了、线程阻塞了这类问题,大概率一头雾水,因为你根本不知道下面发生了什么。
从面试的角度看,网络编程也是"八股文"重灾区。很多人能背出TCP三次握手、四次挥手的状态流转,但问他"服务端accept之后,客户端断开了,服务端怎么感知"就卡住了。这种问题背是背不出来的,必须亲手写过、调试过才有体感。
所以我给这篇文章定了一个递进式的路线:先理解Socket通信的基本模型,然后写一个能收发消息的单线程程序,再分析它的问题,接着引入多线程解决并发,最后用线程池做资源控制。每一步都对应了真实项目中会遇到的实际问题。
1.2 从Socket到多线程服务器的演进逻辑
很多教程一上来就扔一个多线程服务器的完整代码,新手看了只会复制粘贴,代码稍微变个样就废了。我坚持让读者先写一个"烂"版本——单线程、串行处理、性能极差。只有亲手写出来并压测过,才会明白为什么需要多线程,为什么需要线程池,为什么线程池参数不能随便填。
这个演进过程大概是这样的:
- 单线程服务器:同时只能服务一个客户端,其他连接全部排队等。
- 多线程服务器,每连接一线程:能同时服务多个客户端,但线程开销大,连接多了直接OOM。
- 线程池复用:限制线程数量,连接多了排队,避免资源耗尽。
第二步到第三步之间的思考特别重要。新版JDK引入了虚拟线程之后,第二步的问题被缓解了,但线程池的思路仍然值得学,因为在很多存量系统和中间件里,它还是主流方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 Socket通信的本质:两台机器之间的"电话线"
先把概念说清楚。Socket不是协议,它是操作系统提供的一个网络编程接口,是应用层和传输层之间的桥梁。你用Java写Socket程序,其实是在调用操作系统的能力,Java只是帮你封装了一套跨平台的API。
理解Socket的关键在于,把它想象成一条"电话线":
- 服务端先得装一部电话并开机(创建ServerSocket,绑定端口,监听)。
- 客户端拿起电话拨号(创建Socket,连接服务端的IP和端口)。
- 接通之后,双方通过听筒和话筒说话(InputStream读数据,OutputStream写数据)。
- 谁先挂电话(关闭连接),对方就会听到"嘟嘟嘟"(读到EOF或抛出异常)。
这里有个新手很容易懵的点:ServerSocket和Socket是两个不同的东西。ServerSocket只负责"等待电话进来",它自己不去传数据。一旦有客户端连接进来,accept()方法会返回一个新的Socket对象,这个才是真正用来和客户端聊天的通道。
用代码来表示就是这个样子:
java复制ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket socket = serverSocket.accept(); // 阻塞在这里等客户端
// 这里是新连接,但通信要用socket,不是serverSocket
}
2.2 TCP三次握手和Socket API的对应关系
我面试别人的时候,特别喜欢问一个问题:你写Socket代码的时候,三次握手发生在哪一步?很多人会愣住,因为他们从来没把八股文和代码对应起来过。
答案是这样的:当客户端执行new Socket(host, port)的时候,三次握手就已经完成了。也就是说,TCP连接在Socket对象返回之前已经建立好了。你拿到的不是一个"正在连接"的Socket,而是一个已经连接成功的Socket。
至于serverSocket.accept(),它做的是从内核的已完成连接队列里取一个连接出来。内核在三次握手完成之后,会把连接放到一个队列里,accept()只是把这个队列里的连接取出来交给应用层处理。
理解了这个对应关系,很多问题就能想通了:
- 为什么accept()会阻塞?因为队列是空的,没有连接完成。
- 为什么连接很多的时候,accept()还是有延迟?因为队列有长度限制(backlog参数),超过之后内核会丢弃SYN包。
backlog这个参数值得多说一句。很多人的代码只写了new ServerSocket(8080),没指定backlog。在Java里,这个参数默认是50。如果你预期有大量并发连接涌入,建议显式指定,比如new ServerSocket(8080, 128)。在某些Linux内核版本里,backlog的实际生效值还受到net.core.somaxconn的系统参数限制,默认是4096或者更小,线上调优的时候要留意。
2.3 流的阻塞特性:网络编程最大的坑
Java的Socket通信用的是流,而流是阻塞的。这是新手写网络程序最容易踩坑的地方,也是理解整个多线程模型的关键。
一个典型的场景:客户端发来一句话,服务端用InputStream.read()去读。如果客户端发完这句话之后不关闭连接,read()会一直阻塞在那里,因为它在等更多的数据过来。它不知道"这句话已经结束了"。
那怎么判断一条消息结束了?一般有三种方案:
- 约定好消息长度,先读固定长度的头部,头部里写了正文长度。
- 用特殊分隔符,比如文本协议里的换行符\r\n。
- 客户端关闭连接,服务端读到EOF。
最简单的入门方案是第三种,只要客户端一关连接,服务端read()就会返回-1。但这意味着"一次连接只处理一次请求",效率很低。
实际项目中,HTTP协议用的是"头部里有Content-Length + 空行分隔"的方案。你的服务器要支持多个请求复用一条连接,就得自己解析消息边界,这就涉及所谓的"粘包拆包"问题。
我在下面的实操代码里,先采用最简单的方案:客户端发完消息就关闭输出流,服务端读到EOF就认为消息结束,处理完再关闭连接。这样能跑通流程,但你要清楚,生产环境绝不会这么干。
3. 实操过程与核心环节实现
3.1 准备环境:JDK版本和基础配置
开始写代码之前,明确一下环境。我用的是JDK 17,理论上JDK 8以上的版本都能跑通下面的代码,但如果你用的是JDK 8,请确保代码里没有用到新语法。
提示:如果你用的是JDK 17但没配置好,编译时出现"源发行版 17 需要目标发行版 17"之类的报错,说明Maven或Gradle的编译插件版本不对应。最简单的方法是直接在命令行用
java和javac编译运行,绕开构建工具。
整个项目不需要任何第三方依赖,就用JDK自带的java.net和java.io包。建议新建一个干净的目录,把Server和Client分开写成两个文件,方便后面启动多个客户端测试。
3.2 第一步:写一个最朴素的单线程Socket服务器
先来看一个最小可运行的服务器。它做的事情非常简单:监听8080端口,收到客户端连接后,把客户端发来的每一行原样返回,然后关闭连接。
java复制import java.io.*;
import java.net.*;
public class EchoServerSingle {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务器启动,监听8080端口");
while (true) {
Socket socket = serverSocket.accept();
System.out.println("收到连接: " + socket.getRemoteSocketAddress());
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println("收到消息: " + line);
writer.write("echo: " + line + "\n");
writer.flush();
}
System.out.println("客户端断开: " + socket.getRemoteSocketAddress());
socket.close();
}
}
}
对应的客户端代码:
java复制import java.io.*;
import java.net.*;
public class EchoClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 8080);
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream()));
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
writer.write("hello server\n");
writer.flush();
socket.shutdownOutput(); // 关键:告诉服务端,消息发完了
String line;
while ((line = reader.readLine()) != null) {
System.out.println("收到回显: " + line);
}
socket.close();
}
}
这里有一个细节必须讲清楚:shutdownOutput()这个方法。它做的事情是发送TCP的FIN包,表示"我不会再发数据了",但允许继续接收数据。这跟close()不一样,close()是彻底关闭连接,收发都不行了。
为什么需要这一步?因为服务端的readLine()要靠EOF来判断客户端消息是否结束。如果客户端只调flush(),服务端只会收到数据,但不知道"这算不算一句话的结束"。加了shutdownOutput()之后,服务端的readLine()会返回null,所以while循环会退出,服务端就知道"客户端把话说完并且不会再说了"。
3.3 运行并观察:先手动测,再写压力测试
运行服务器,然后开多个终端窗口,分别执行客户端。第一次跑的时候,你会看到控制台输出"收到连接"、然后打印消息、打印"客户端断开",整个流程是通的。
现在做个实验:开两个客户端连上去。你会发现第二个客户端卡在new Socket("127.0.0.1", 8080)这一步,一直连不上。直到第一个客户端断开之后,第二个客户端才瞬间建立连接。
原因很简单:服务端只有一个线程,它正在处理第一个连接的readLine(),阻塞在那里,根本不可能走回accept()去接受第二个连接。不管你开多少个客户端,永远都只有一个能连上。
这就是"单线程服务器"最致命的问题:串行处理,连接之间互相阻塞。真实场景中一个客户端占用连接很久(比如做长轮询或者聊天),其他用户就永远连不上。
3.4 第二步:升级为多线程服务器,每连接一个线程
既然单线程的瓶颈在于"处理连接"和"等待新连接"挤在一个线程里,那就把它们拆开。主线程专门负责accept(),每接进来一个连接,就新建一个线程去处理。代码改动不大:
java复制import java.io.*;
import java.net.*;
public class EchoServerThread {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务器启动,监听8080端口");
while (true) {
Socket socket = serverSocket.accept();
// 每个连接交给一个独立线程处理
new Thread(new ClientHandler(socket)).start();
}
}
}
class ClientHandler implements Runnable {
private final Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
System.out.println("处理连接: " + socket.getRemoteSocketAddress()
+ " 线程: " + Thread.currentThread().getName());
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println("收到消息: " + line);
writer.write("echo: " + line + "\n");
writer.flush();
}
System.out.println("客户端断开: " + socket.getRemoteSocketAddress());
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
这个版本跑起来之后,再开两个客户端连,你会发现两个都能正常收发消息了,互不干扰。每个连接都有自己的线程,谁也不会卡住谁。
“每连接一线程”这个模式,已经是很多真实服务器的雏形了。但它的隐患也很明显:线程是昂贵的资源。
3.5 隐患分析:无限线程的代价
一个线程默认占多少内存?在Java里,线程栈大小默认是1MB(可以通过-Xss参数调整,但一般不会太小)。也就是说,1000个连接就是1GB的栈内存。
而且线程的创建和销毁本身就有开销。如果客户端连接频繁地建立和断开,服务器会陷入"疯狂创建线程、销毁线程"的循环,消耗大量CPU。
更糟的是,一旦连接数暴涨,JVM可能会直接抛出OutOfMemoryError: unable to create new native thread。这时候进程可能直接崩溃,连异常都来不及捕获。
有个比较直观的比喻:把线程想象成餐厅的服务员。单线程服务器是一个服务员要同时负责接待、点菜、传菜,忙不过来;多线程服务器是每来一个客人就新招一个服务员,客人走了服务员就离职。客人少的时候没毛病,但高峰期餐厅会被服务员淹没,运营成本爆炸。
所以,单纯引入多线程只是解决了一半问题,真正的难点在于怎么控制线程数量,让资源被复用而不是被浪费。
3.6 第三步:引入线程池,把线程变成"复用型员工"
线程池的思路很简单:提前创建好一批线程,任务来了就分配给空闲线程,执行完归还线程,线程不销毁,等着接下一个任务。
Java里直接用ThreadPoolExecutor或者它的工厂方法Executors.newFixedThreadPool()。我建议直接用ThreadPoolExecutor,因为它的参数更好控制,生产上也更规范。
改造后的服务器代码:
java复制import java.io.*;
import java.net.*;
import java.util.concurrent.*;
public class EchoServerPool {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务器启动,监听8080端口");
// 核心线程数2,最大线程数4,空闲存活30秒,任务队列容量100
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy());
while (true) {
Socket socket = serverSocket.accept();
executor.execute(new ClientHandler(socket));
}
}
}
改动只有几行,但行为的差异是巨大的:并发连接再多,真正干活的线程也不会超过4个(按这个配置)。多出来的连接会排队,而不是无限消耗系统资源。
3.7 线程池参数怎么定:核心数、最大数、队列长度
很多新手直接抄线程池代码,参数乱填,最后出了问题。我给出一个相对通用的思路:
- 核心线程数(corePoolSize):如果任务是CPU密集型的,核心线程数设为CPU核数+1就够。如果任务是IO密集型的(网络编程就是典型),核心线程数可以设大一些,比如CPU核数的2倍甚至更高,因为线程大部分时间在等待网络数据。
- 最大线程数(maxPoolSize):一般也就是核心线程数的2~4倍,设得太大就失去了线程池的意义。
- 队列长度(workQueue):设置一个合理的上限,比如100或1000,防止任务无限堆积把内存打爆。
- 拒绝策略(RejectedExecutionHandler):当队列满了、线程也到上限了,新任务怎么办?
CallerRunsPolicy的意思是"谁提交的任务谁去执行",也就是让accept()所在的线程去处理,相当于变相的背压。线上更常用的是这个策略,因为它不会丢任务,只是会拖慢任务提交速度。
生产环境里,这些参数最好做压测之后再确定。先设一个保守值跑压测,观察线程利用率、队列堆积情况,再逐步调整。不要迷信"越大越好"。
3.8 第四步(选做):用虚拟线程彻底改造服务器
如果你用的是JDK 21或更新版本,还有一个更优雅的方案:虚拟线程。虚拟线程是JVM管理的轻量级线程,数量和内存开销比平台线程小得多,可以开几万个而不崩溃。
改用虚拟线程很简单:
java复制while (true) {
Socket socket = serverSocket.accept();
Thread.ofVirtual().start(new ClientHandler(socket));
}
就这一行,就能让服务器扛着数千个并发连接而不炸。虚拟线程的底层原理是JVM捕获了阻塞操作,把线程挂起并释放底层资源,等IO就绪了再恢复执行。这对新手来说可能有点黑魔法,但它确实简化了高并发网络编程的难度。
不过我想说清楚:虚拟线程不能完全替代对线程池的理解。线上系统里大量中间件仍然在用传统线程池,而且面试中问线程池参数、拒绝策略的频率非常高。我建议两个都要会。
4. 常见问题与排查技巧实录
4.1 连接不上:Address already in use
这是一个出现频率极高的报错:
code复制java.net.BindException: Address already in use: JVM_Bind
原因通常有两种:一是端口确实被其他进程占用了,二是你上次运行的服务端没有正常关闭。服务器程序异常退出后,端口不会立刻释放,会进入TIME_WAIT状态,持续一段时间。
排查方法:在Linux上用lsof -i:8080或者netstat -tlnp | grep 8080看看是什么进程占用了端口。找到PID之后,确认是不是你自己之前没关掉的Java进程,是的话就kill掉。
如果是在Windows上,用netstat -ano | findstr 8080,然后根据PID去任务管理器里结束进程。这个错误我见的太多,99%都是"端口没释放干净"导致的。
4.2 服务端启动成功但客户端连不上
服务器启动没有报错,客户端却提示连接超时(ConnectException: Connection timed out)。这种基本可以判定是网络层面的问题。
我列一下排查的顺序:
- 先确认服务端确实在监听:
lsof -i:8080或者netstat看监听状态。 - 再确认客户端和服务端在同一网络,能互相ping通。
- 然后确认防火墙是否放行了端口。Linux上可能是iptables或者firewalld,云服务器还有安全组规则。
- 最后确认服务端绑定的是哪个地址。如果绑定了
127.0.0.1,那就只有本机能连;如果绑定了0.0.0.0,所有网卡都能连。
这里有一个很容易犯的错:本地测试的时候连接127.0.0.1没问题,放到服务器上改成绑定0.0.0.0,却忘了安全组放行。遇到"别的机器连不上,本机可以"的问题,先用nc或者telnet试一下端口通不通,能快速缩小范围。
4.3 客户端能连上,但服务端收不到数据
这个问题的典型表现是:客户端代码执行完了,服务端没有打印任何消息。原因有很多,最常见的两个:
一是客户端没有调用flush()。BufferedWriter会先写入缓冲区,只有缓冲区满了或者手动调用flush(),数据才会真正通过网络发出去。很多新手在write之后忘记flush,然后盯着服务端干等,实际数据还在客户端的内存里。
二是客户端和服务端对"消息结束"的标准不一致。客户端用write发送文本,没有换行符;服务端用readLine()读,它必须等一个换行符才会返回。于是两边都卡住。解决办法是,两边约定好协议:要么每条消息加换行符,要么用长度前缀。
4.4 服务端报Connection reset或Socket closed
这个报错通常意味着对端把连接关了,但这边的代码还在往连接上写数据,或者还在尝试读取。
举个例子:客户端发完消息之后直接close(),服务端如果还在尝试往输出流里写东西,就会收到SocketException: Connection reset。在HTTP请求的场景里,如果客户端提前断开了连接,服务器去读取请求体,也会出现这个异常。
处理方式很简单:catch住IOException,区分不同子类,连接被对端正常关闭时就不要继续读了,直接退出循环并关闭资源。别把每一次IOException都当严重错误打堆栈,正常的连接断开就应该静默处理。新手上来就e.printStackTrace(),线上日志会被搞爆炸。
4.5 OutOfMemoryError和线程耗尽
如果你还在用"每连接一线程"的方案,并且模拟大量并发连接,很可能会看到这样的报错:
code复制java.lang.OutOfMemoryError: unable to create new native thread
这基本就是线程数超过系统限制了。排查步骤:
- 用
jstack <pid>导出线程快照,看看有多少线程,都是什么状态。 - 用
ps -eLf | grep java | wc -l统计线程数。 - 查看系统的线程数上限:
ulimit -u。
解决办法就是把服务器改成线程池方案,限制线程数量。这也是我坚持让读者先写多线程版再写线程池版的原因,因为只有真正踩过OOM的坑,你才会敬畏线程池的参数设置。
4.6 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 服务端启动报Address already in use | 端口被占用 | lsof/netsatat查占用 | 杀进程或换端口 |
| 客户端连不上,超时 | 防火墙/安全组未放行 | nc -vz IP 端口测试 | 放行端口 |
| 客户端能连,服务端收不到数据 | 未flush或协议不一致 | 抓包或加日志 | flush + 约定协议 |
| 读数据时异常Connection reset | 对端已断开 | 确认对端状态 | 捕获异常正常退出循环 |
| OutOfMemoryError线程耗尽 | 线程数超限 | jstack查看线程数 | 改用线程池 |
4.7 调试网络程序的独家小技巧
我强烈建议新手学会用tcpdump或者Wireshark来看网络包。当你纠结"到底有没有发出去"、"什么时候发的FIN包"的时候,控制台日志帮不了你,抓包才是真相。
一个常用的tcpdump命令:
bash复制sudo tcpdump -i lo0 port 8080 -nn -A
这个命令会实时打印本机8080端口的所有TCP流量。-A参数会把包的内容以ASCII形式展示,你能直接看到传输的文本数据。
有一次我在调一个客户端和服务端的交互流程,逻辑上看起来完全没问题,但两端就是收不到对方的消息。后来用tcpdump一看,数据确实发到本机网卡了,但是服务端的代码在读的时候阻塞了,原来是对shutdownOutput()的行为理解错了。抓包直接揭示了问题所在,比瞎猜高效得多。
5. 写在最后的个人经验
这篇文章从单线程Socket一路走到了线程池服务器和虚拟线程。回头看,每一步都是必要的。单线程版本让我理解了阻塞的本质;多线程版本让我感受到并发的威力,也让我付出了OOM的代价;线程池版本让我明白了资源整合理的价值;虚拟线程则让我看到未来网络编程的一个新方向。
我个人在实际操作中的体会是,学网络编程不能只看教程,必须亲手把客户端和服务端跑起来,故意制造一些故障去观察现象。你可以在服务器代码里故意设置一个很短的backlog,然后用一批客户端并发连接,看看请求会被怎么拒绝;你也可以把线程池的核心线程数改到1,看看并发量上去之后延迟是怎么飙起来的。这些"刻意实验"带来的收获,远超过背一百个面试题。
如果后面你还想继续深入,建议按这个顺序走:先把HTTP协议的结构吃透,然后去读一遍Tomcat的连接器源码,再尝试用NIO或Netty重写一个简单的HTTP服务器。那时你回头再看这篇文章,会发现自己已经站在了完全不同的高度上。
最后再分享一个小技巧:多写日志。网络程序的调试难度比普通业务代码高一个量级,因为它的时序是乱的、并发是随机的。每个连接建立时打一行日志,每个消息收发时打一行日志,每条连接关闭时打一行日志。这些日志在出问题的时候,就是你唯一的线索来源。
