Java TCP网络编程:从底层原理到高并发Socket实战,一次讲透
1. 为什么我建议每个Java开发者都要吃透TCP Socket编程
说实话,我接触过的很多Java开发者,CURD写得很溜,Spring Boot服务搭得一气呵成,但一碰到Socket编程就露怯。不是不会用ServerSocket和Socket这两个类,而是遇到TLS握手失败、连接被重置、粘包拆包混乱、高并发下连接数上不去这类问题就抓瞎。归根结底,是TCP协议底层那套机制没吃透,光知道调API是不够的。
TCP/IP协议族是互联网的骨架,而TCP(Transmission Control Protocol,传输控制协议)又是其中最核心的传输层协议。Java作为服务端开发的主力语言,无论是在企业级应用、中间件开发,还是在大数据、微服务领域,只要是跨进程通信,底层几乎都逃不开TCP。你写HTTP接口只是坐在TCP的肩膀上,而做Socket编程就是直接跟TCP打交道——两者的难度和视野完全不是一个量级。
这篇文章不打算从OSI七层模型开始念经,而是以一个完整、可运行的Java Socket实战项目为主线,从TCP为什么“可靠”讲起,到三次握手、四次挥手、粘包拆包、多线程并发处理、连接保活与超时控制,再到高频面试题和常见异常排查,一条线全部给你捋清楚。
适合的人群很明确:
- 准备面试Java后端岗,需要系统梳理TCP和网络编程知识的人
- 工作中要写RPC框架、网关、消息推送、自定义协议通信的Java工程师
- 对
Socket停留在“知道但不会用”,想动手跑通一个完整项目的学生或转行者
别担心,我会把晦涩的协议机制掰开揉碎,用大白话加实战代码带你过一遍。
2. 先搞清楚一件事:TCP凭什么被称为“可靠传输”
2.1 什么叫“可靠”?TCP到底在可靠什么
很多初学者会把“可靠传输”理解成“数据不会丢”。这个说法不准确,更严谨地讲,TCP的可靠是指:在不可靠的IP网络之上,通过一系列机制,保证数据能按序、不重复、不丢失地到达对端,并且对端能正确接收。
IP层是“尽力而为”的,它只管把数据包从A点送到B点,至于中途有没有丢包、有没有乱序、有没有重复,IP一概不管。就像你寄快递,快递公司只保证“出发了”,不保证“一定到”。TCP干的事情就是在快递之上加了一层“签收确认”制度——每个包裹都要回执,没收到回执就重发,收到的包裹还要按编号排好序再交给上层。
具体来说,TCP的可靠性靠这几个机制撑起来:
- 确认应答(ACK):接收方收到数据后,必须给发送方回一个ACK确认包,告诉它“我收到了第几号字节”。发送方如果在超时时间内没收到ACK,就认为数据丢了,触发重传。
- 超时重传(Retransmission):发送方为每个发送的报文段启动一个计时器(RTO,Retransmission Timeout),计时器到期没等到ACK,就重新发送这段数据。RTO不是固定死的,而是根据网络往返时间(RTT)动态计算,网络越慢,等待越久。
- 序号与确认号(Sequence Number & Acknowledgment Number):TCP把要发送的字节流从0开始编号,每个报文段携带首字节的序号。接收方按序号排序、去重,并反馈“下一个期望收到的字节序号”。这正是TCP能“按序”交付、去重、断点续传的根基。
- 流量控制(Flow Control):通过滑动窗口机制,接收方告诉发送方“我的接收缓冲区还能装多少字节”,防止发送方太猛,把接收方缓冲区打爆。
- 拥塞控制(Congestion Control):发送方根据网络的拥塞程度动态调整发送速率,慢启动、拥塞避免、快重传、快恢复,这四件套就是干这个的。
2.2 三次握手:建立一个连接为什么刚刚好要“三次”
TCP建立连接的三次握手,几乎是Java面试里必问的送分题,但很多人只背了“SYN、SYN+ACK、ACK”这个流程,没搞懂为什么必须是三次。
画个简图帮助理解:
客户端 --> 服务端:SYN(seq=x,我打算建立连接,我的初始序号是x)
服务端 --> 客户端:SYN+ACK(seq=y,ack=x+1,我收到了你的SYN,我的初始序号是y)
客户端 --> 服务端:ACK(seq=x+1,ack=y+1,我收到了你的SYN+ACK,连接正式建立)
核心原因有两个:
双向确认彼此的收发能力。第一次握手,服务端确认了“客户端的发送能力”和“自己的接收能力”;第二次握手,客户端确认了“服务端的发送能力”和“自己的接收能力”,同时还确认了“自己刚才发的SYN服务端确实收到了”;第三次握手,服务端确认“客户端收到了自己的SYN+ACK”。三次握手是让双方都确信“你发的我能收到,我发的你也能收到”的最少次数。两次不够,因为服务端无法确认客户端的接收能力;四次多余,因为第三次ACK本身就够了。
防止历史重复连接初始化造成混乱。这是RFC 793里强调的重要原因。假如客户端发出的第一个SYN因为网络拥堵,在链路上滞留了很久,客户端等不及触发了超时重传,又发了一个新的SYN。如果只有两次握手,服务端收到第一个老SYN后就会直接建立连接、分配资源,但客户端其实已经不想用这个连接了,于是服务端这边挂着一条死连接,白白浪费资源。三次握手可以让客户端通过判断
ack号是否正确来中止历史连接(发送RST),保证连接建立的唯一性。
2.3 四次挥手:断开一个连接为什么还要“四次”
断开连接比建立连接多一次,因为TCP连接是全双工的,两边可以同时收发数据。断开的时候,每一方的“发送能力”都要单独关闭并确认一次。
客户端 --> 服务端:FIN(我的数据发完了,申请关闭发送通道)
服务端 --> 客户端:ACK(收到你的FIN,但我可能还有数据要发)
服务端 --> 客户端:FIN(我的数据也发完了,关闭这条通道)
客户端 --> 服务端:ACK(收到,确认关闭)
注意:服务端的ACK和FIN经常被合并成一次发送(延迟ACK机制),所以在抓包时你可能只看到三个包,但逻辑上仍是四次挥手。
挥手中最容易被问到的坑是TIME_WAIT状态。主动关闭方(通常是客户端)在发出最后那个ACK之后,不会立刻进入CLOSED,而是进入TIME_WAIT状态,要等**2MSL(Maximum Segment Lifetime,报文最大生存时间)**才真正关闭。为什么?一是怕最后一个ACK丢了,对端会重发FIN,自己还在的话能及时补发ACK;二是让网络中这个连接的所有“幽灵数据包”全部过期消失,避免串扰到后续使用同一端口的新连接。MSL在Linux系统上一般默认30秒或60秒,所以TIME_WAIT持续60秒到120秒很常见。高并发短连接场景下,你要是在服务器上执行ss -tan看到大量TIME_WAIT连接,别慌,这是主动关闭方正常的状态,不是连接泄漏。
2.4 重传、滑动窗口与拥塞控制:TCP的“三大救火队员”
我简单说下这几个机制在实战中的意义,因为Java层排查网络问题时经常要和它们打交道。
超时重传解决丢包问题,但重传也有策略。早期TCP是全部重传,效率低;后来有了快速重传:发送方连续收到3个重复的ACK,就立刻重发没被确认的数据,不用等RTO超时。再后来出现了SACK(Selective Acknowledgment,选择性确认),接收方在ACK里附带自己收到的不连续数据块的边界,发送方只需重传真正丢失的段,效率大幅提升。Linux内核从2.6开始默认开启SACK,Java开发者虽然不用直接操作它,但理解它能帮你读懂tcpdump抓包重传标记。
滑动窗口是流量控制的关键。简单说,接收方在TCP头的Window字段里通告自己的剩余缓冲区大小,发送方严格按照窗口大小决定一次能发多少数据。窗口为0时,发送方停手,并定期发窗口探测包。Java层面的Socket#setReceiveBufferSize、Socket#setSendBufferSize设置的就是内核缓冲区大小,直接影响窗口通告值。我做高吞吐服务时的一个经验是:如果单条TCP连接上要跑大文件传输或大量小消息,一定要把收发缓冲区调大(例如调到1MB以上),否则性能会被窗口卡死。
拥塞控制则是“根据网络状况自限速”。慢启动阶段发送量按指数增长,撞到阈值(ssthresh)后转入线性增长,一旦丢包就减半。Java层没法直接干预拥塞控制算法(这是内核的事),但可以设置TCP_NODELAY关闭Nagle算法(下文会细说),减少小包延迟。
3. Java Socket编程实战:从零搭建一个可用的TCP通信程序
协议机制再好,最终还是要落在API上。Java的Socket编程核心类就两个:java.net.ServerSocket(服务端)和**java.net.Socket**(客户端)。我先把骨架代码给你,再用一个完整例子讲透每个关键点。
3.1 服务端骨架:ServerSocket的三大周期性工作
服务端编程有一个固定套路:绑定端口->循环accept->处理连接。代码如下:
import java.io.*; import java.net.*; public class TcpEchoServer { public static void main(String[] args) throws IOException { int port = 9090; // 1. 创建ServerSocket并绑定端口 try (ServerSocket serverSocket = new ServerSocket(port)) { System.out.println("[Server] listening on port " + port); // 2. 循环接收客户端连接 while (true) { Socket clientSocket = serverSocket.accept(); // 阻塞等待 System.out.println("[Server] client connected: " + clientSocket.getRemoteSocketAddress()); // 3. 处理该连接(先简单单线程处理) handleClient(clientSocket); } } } private static void handleClient(Socket socket) { try (socket; BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) { String line; // 逐行读取客户端发来的数据 while ((line = in.readLine()) != null) { System.out.println("[Server] received: " + line); out.println("Echo: " + line); // 原样回显 } } catch (IOException e) { System.err.println("[Server] connection error: " + e.getMessage()); } } }几个关键点你务必注意:
accept()是阻塞方法,没有客户端连接进来时,线程就挂在这里等。这是个极其重要的心智模型:一个线程一次只能处理一个连接(如果串行处理的话)。上面的代码虽然能跑,但同一时刻只能服务一个客户端,第二个连上来必须等第一个断开。真实项目里绝对不能这么干,3.3节我会给你多线程方案。try (socket; ...)是Java 7的try-with-resources语法,会自动关闭Socket和流,省得手动关。关闭顺序建议:先关内层流,再关Socket,但用try-with-resources就不用操心了。new PrintWriter(out, true)的第二个参数autoFlush设为true,表示每次println后自动flush缓冲区,否则数据可能一直囤在缓冲区里发不出去。这是新手最容易踩的坑之一。
3.2 客户端骨架:连接三步走
import java.io.*; import java.net.*; public class TcpEchoClient { public static void main(String[] args) throws IOException { String host = "127.0.0.1"; int port = 9090; // 1. 创建Socket并连接服务器(这一步内部就会进行TCP三次握手) try (Socket socket = new Socket(host, port); BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); BufferedReader console = new BufferedReader(new InputStreamReader(System.in))) { System.out.println("[Client] connected to " + socket.getRemoteSocketAddress()); String userInput; while ((userInput = console.readLine()) != null) { out.println(userInput); System.out.println("[Client] server response: " + in.readLine()); } } } }启动流程:先跑TcpEchoServer,再跑TcpEchoClient,然后在客户端控制台输入任意字符串,回车,就能看到服务端打印received: xxx,同时客户端打印server response: Echo: xxx。一个最简单的TCP回显程序就跑通了。
注意new Socket(host, port)这个构造方法内部做的动作比你想象的要多:
- 解析域名(如果是域名的话),得到IP;
- 在内核里创建一个Socket文件描述符;
- 发起TCP三次握手——注意这个构造方法是阻塞式的!如果对端ip不可达、端口没监听、防火墙丢包,这里会一直阻塞直到系统超时(通常几十秒到几分钟)。
想要更好的超时控制,不要用这个便捷构造方法,应该像下面这样:
Socket socket = new Socket(); // 连接超时时间设置为3秒 socket.connect(new InetSocketAddress(host, port), 3000); // 读超时时间设置为5秒 socket.setSoTimeout(5000);connect(addr, timeout)里的timeout是连接超时,单位毫秒,超过就抛SocketTimeoutException;setSoTimeout是读超时,作用于InputStream.read(),一旦超过指定时间没有数据可读,会抛出SocketTimeoutException。这两个超时是网络编程里最重要的两个参数,高可用系统必须设置,否则一个网络抖动就能让你的线程卡死在读操作上。
3.3 升级到多线程:让一个服务端同时处理N个客户端
前面说了串行处理是玩具代码,生产环境至少要能并发处理多个客户端。最简单、最经典、最不容易出错的方式就是每来一个连接就丢给一个线程:
public class TcpMultiThreadServer { public static void main(String[] args) throws IOException { int port = 9090; ExecutorService pool = Executors.newFixedThreadPool(8); // 用线程池 try (ServerSocket serverSocket = new ServerSocket(port)) { System.out.println("[Server] multi-thread listening on " + port); while (true) { Socket clientSocket = serverSocket.accept(); System.out.println("[Server] accept client: " + clientSocket.getRemoteSocketAddress()); // 直接丢给线程池处理,主线程立刻回去继续accept pool.submit(() -> handleClient(clientSocket)); } } } private static void handleClient(Socket socket) { try (socket; BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) { String line; while ((line = in.readLine()) != null) { out.println("Echo: " + line); } } catch (IOException e) { System.err.println("[Server] handler error: " + e.getMessage()); } } }这里是我在实际项目中总结的几条硬经验:
- 绝不要裸new Thread处理连接。短连接还好,长连接下并发几千个客户端你就知道什么叫线程爆炸——每个线程默认栈大小1MB,2000个线程就是2GB内存。请一律使用线程池,而且线程池的核心线程数和最大线程数要根据业务的IO密集程度来定,不要照抄网上的“最佳实践”。
- 上面用的是
newFixedThreadPool(8),它基于无界队列,如果连接处理速度跟不上客户端连入速度,任务会在队列里积压。真实高并发场景建议改用ThreadPoolExecutor自定义队列和拒绝策略,避免OOM。 - 长连接场景下,线程会被长期占住。
in.readLine()阻塞等待客户端发数据,客户端不发,线程就一直挂着。所以线程池的最大线程数必须大于“预估同时在线客户端数”的上限,否则新连接无法被处理。
3.4 BIO、NIO、AIO:你该选哪种模型
上面这种“一个连接一个线程”的写法叫BIO(Blocking IO,同步阻塞IO),Java中的传统Socket编程几乎都是BIO。它的缺点是线程数和连接数强绑定,连接多了线程就多,线程切换开销巨大。
与之对应的是NIO(Non-blocking IO,同步非阻塞IO),Java里的java.nio.channels.SocketChannel、ServerSocketChannel配合Selector使用。一个线程可以管理成千上万个连接,核心是事件驱动——Selector帮你监听哪些Channel有读事件、写事件、连接事件,然后只去处理有事件的Channel。Netty就是构建在NIO之上的王者框架,通信层几乎必选它。
那还有没有AIO(Async IO,异步非阻塞IO)?有的,Java 7推出了AsynchronousSocketChannel,但实际使用体验不佳,Netty官方都说AIO相比NIO没有明显优势甚至更差,不建议使用。所以我给你一句掏心窝的话:中小项目直接BIO+线程池完全够用;项目中大规模高并发通信,直接上Netty(NIO),别自己裸写Selector,坑太多。
4. 深入核心:Java Socket的可靠性细节与网络编程四大痛点
协议说了可靠,但我在日常开发和排查问题中遇到的“不可靠”情况多如牛毛。搞懂下面四个痛点,你的Java网络编程水平会立刻上一个台阶。
4.1 粘包与拆包:TCP“流”的本质决定了必须自己定协议
这是Java Socket面试和实战中最高频的坑,没有之一。TCP是面向字节流的协议,它不保证你发的每一条消息边界——你out.write("hello")再out.write("world"),对端可能一次性读到一个"helloworld";反过来你一次write一个超大的包,对端可能要读多次才能读全。这就是粘包和拆包。
为什么会有这种现象?TCP底层是按MTU(最大传输单元,通常1500字节)大小来切分数据段的,应用层一次write的数据可能被切成多个TCP段发出;而接收端内核缓冲区可能同时积压了多个TCP段的数据,应用层的read()则一次性取走所有可用数据。就导致了:
- 粘包:两条或多条应用消息被合并成一条读出来(发送频率高、消息体小时尤其常见);
- 拆包/半包:一条应用消息被拆成了多次读取(消息体超过MTU时几乎必然发生)。
解决办法只有一个,在应用层定义消息边界。常见方案有三种:
- 固定长度:每条消息固定N字节,不足补零。简单,但浪费带宽,灵活性差。
- 特殊分隔符:每条消息以
\n或\r\n结尾,读端按分隔符切分。实现简单,但消息内容里不能出现和分隔符冲突的字节,适合简单文本协议(我们前面的Echo例子用的就是这种)。 - 长度字段前置(LengthField):消息头固定几个字节存消息体长度,服务端先读长度,再读相应字节。这是最通用、最推荐的方式,主流RPC框架(Dubbo、gRPC)都采用这种思路。
给你一个长度字段方案的代码片段:
// 发送方:先写4字节的消息长度,再写消息体 ByteBuffer buffer = ByteBuffer.allocate(4 + messageBytes.length); buffer.putInt(messageBytes.length); buffer.put(messageBytes); buffer.flip(); socketChannel.write(buffer); // 注意:write未必一次写完,需要循环 // 接收方:先读4字节长度,再循环读满整个消息体 InputStream in = socket.getInputStream(); byte[] lenBuf = new byte[4]; readFully(in, lenBuf); int msgLen = ByteBuffer.wrap(lenBuf).getInt(); byte[] msgBuf = new byte[msgLen]; readFully(in, msgBuf); // 按msgLen循环读,直到读满为止readFully是我所有TCP通信代码里必写的一个工具方法,作用就是把指定长度的字节读满,避免半包导致的数据截断。生产环境我建议直接使用Netty,它自带的LengthFieldBasedFrameDecoder就是专门解决粘包拆包的,两行配置就搞定,不用自己手搓。
4.2 半关闭:告诉对端“我还有话说”还是“我已说完”
TCP支持半关闭(half-close):一方关闭发送通道,表示“我的数据发完了”,但对端仍可继续给自己发数据。Java里通过Socket#shutdownOutput()和Socket#shutdownInput()实现。
一个经典场景:客户端向服务端发送一个请求,服务端返回一个响应。请求和响应可能都很大,需要分多次发送。如果客户端什么都不说就直接close(),服务端就分不清“客户端是真的发完了数据”还是“客户端断开了连接”,可能把不完整的请求当成完整请求处理。正确的做法是:客户端发完请求后调用shutdownOutput(),服务端读到的流结束(read()返回-1)就说明客户端的数据发完了,此时服务端可以放心地发送响应;客户端发完请求后还开着输入流,就可以继续read()响应。
// 客户端发送完请求后,关闭输出通道但保留输入通道 socket.shutdownOutput(); // 服务端此时read()到-1,知道数据发送完毕注意:shutdownOutput()之后,客户端的socket不能再写数据,如果再写会抛SocketException: Socket is closed或者类似异常。不要和close()混淆,close()是彻底关闭整条连接。
4.3 TCP_NODELAY与Nagle算法:为何你的小消息一直不发送
如果你做过低延迟的实时通信(比如即时消息、在线游戏同步),会发现自己明明println了数据,但对端迟迟收不到,延迟可能高达几十甚至几百毫秒。九成可能是Nagle算法在“捣鬼”。
Nagle算法的初衷是避免网络因大量小包而拥塞。它规定:在之前发送的数据未收到ACK之前,不允许发送后续的小包(小于MSS的包),必须等ACK回来或者攒够了MSS大小的数据再发。这在批量传输场景下是好事,但在低延迟场景下,这等于每个小消息都要等一个RTT(往返时间),延迟翻倍,非常难受。
Java里用一行代码关掉它:
socket.setTcpNoDelay(true); // 禁用Nagle算法,小包立即发送我的建议是:实时性敏感的应用一律开启TcpNoDelay;但如果你的场景是大量小消息的持续传输,开与不开差别不大,因为粘包问题会合并数据。不要在没理解业务的情况下盲目追求“低延迟”而顺手关掉Nagle,一切以实际压测数据为准。
4.4 连接保活与心跳:为何连接断了却没感知
TCP有个内置的SO_KEEPALIVE选项,Java里是setKeepAlive(true)。但它默认两个小时才开始第一次探测,间隔又长,对多数实时应用来说形同虚设。
生产环境里更通用的做法是应用层心跳:客户端每隔N秒发一个心跳包(通常是极小的长度字段包),服务端如果在M秒内没收到任何数据,就认为连接已死,主动关闭。心跳还能顺带减少NAT超时、运营商空闲连接回收导致的长连接断连问题。
注意事项:
- 心跳包要单独定义消息类型,不能和业务数据混淆;
- 心跳间隔要小于NAT/防火墙的空闲超时(一般云端LB是300秒,手机网络是120秒左右,保守起见,心跳间隔可以设到30~60秒);
- 如果用Netty,直接用
IdleStateHandler,配置读空闲、写空闲、读写空闲超时时间,非常方便。
5. TCP连接异常与性能排查:从报错信息到定位根因
网络编程写多了,你会对某些报错产生肌肉记忆。我把Java Socket编程中最常见的几个异常和排查心得整理成一个速查表:
| 异常/现象 | 可能原因 | 处理建议 |
|---|---|---|
ConnectException: Connection refused | 服务端端口未监听、服务未启动、防火墙拒绝 | 先ss -lntp查端口监听,再curl -v测端口连通性 |
SocketTimeoutException: Read timed out | 对端长时间未发送数据,触发setSoTimeout | 确认对端是否已经死掉或半关闭;如果是业务需要长等待,调整超时值 |
Connection reset/Broken pipe | 对端在你还写数据的时候强制关闭了连接(常见于客户端异常退出、服务端close后未读完全部数据) | 捕获SocketException并做重连/降级处理;服务端close前尽量读完请求体 |
No buffer space available | 大量TIME_WAIT连接占满端口或文件描述符耗尽 | ss -tan统计TIME_WAIT数量;调整tcp_tw_reuse等内核参数;排查连接泄漏 |
java.net.BindException: Address already in use | 端口已被占用,或上一次程序退出后端口处于TIME_WAIT未释放 | 换一个端口,或设置SO_REUSEADDR(ServerSocket构造时默认开启) |
too many open files | 进程文件描述符耗尽 | ulimit -n调大;排查每连接是否及时关闭 |
我最想强调的一个排查经验是:不要只看Java层的报错,一定要结合操作系统层面的抓包来定位。举一个真实的例子:有次一个服务连接MySQL总间歇性报Connection reset by peer,Java层怎么查都查不出来。后来在服务器上执行:
tcpdump -i eth0 host 192.168.1.50 and port 3306 -w mysql.pcap抓了几分钟包后,用Wireshark打开,发现MySQL服务端在每次应用重启时都会主动发RST包,再一查MySQL的wait_timeout配置,果然设成了30秒,而应用侧的连接池空闲超时设置成了60秒。连接在数据库侧被超时强杀,应用侧还在用,自然被reset。这种问题不抓包,你永远只能靠猜。
6. 高频面试题串讲:把TCP知识转化成面试加分项
每次写网络编程相关文章,总有读者追问面试考什么。这里我把和本文内容强相关的几个典型问题统一作答,顺便给你一套“从广度谈到深度”的回答思路。
6.1 TCP三次握手可以改成两次吗
不能。核心原因是服务端无法确认客户端的接收能力是否正常。如果只有两次握手,当客户端第一次发的SYN在网络中滞留,超时重传后建立了连接,但滞后的旧SYN又到达服务端,服务端会误认为客户端要再建一条连接,白白分配资源。而且三次握手可以携带初始序列号,让双方知道对方的seq起点,保证后续按序处理。不该省的一步绝不能省。
6.2 为什么连接建立是三次,断开是四次
因为建立连接时,服务端的SYN和ACK可以在同一个报文里发(第二次握手),合并了一次往返;而断开连接时,服务端收到FIN后可能还有未发完的数据,所以ACK和FIN必须分开,中间隔了一段“等待数据发完”的时间。
6.3 什么是粘包拆包,如何解决
TCP是字节流协议,没有消息边界。多个小消息可能被一次读出(粘包),一条大消息可能被多次读出(拆包)。解决:固定长度、特殊分隔符、长度字段前置。生产推荐Netty的LengthFieldBasedFrameDecoder。
6.4 服务端主动关闭连接能否避免TIME_WAIT
不能完全避免。TIME_WAIT是主动关闭方必须经历的阶段,目的是保证最后的ACK可达并让旧报文过期。真正要注意的是不要让TIME_WAIT堆积导致端口耗尽,常见手段有:
- 调整内核参数
net.ipv4.tcp_tw_reuse(开启后TIME_WAIT连接可被复用,但注意它只对客户端发起的新连接有效); - 尽量使用长连接,减少短连接数量;
- 设置
SO_LINGER绕过TIME_WAIT(不推荐,非规范做法)。
6.5 为什么HTTP/1.1叫“短连接时代”,HTTP/2才是“长连接主流”
这不是本文重点,但有助于串起知识体系:HTTP/1.0默认短连接,每次请求都要建连+断开,开销巨大;HTTP/1.1默认开启了Connection: keep-alive,一个连接可复用处理多个请求,但仍是“在一个连接上串行排队”;HTTP/2通过多路复用,一个TCP连接并发跑多个流,大大减少连接数。底层依赖的仍然是TCP可靠性——这也解释了为什么HTTP/3要改用QUIC(基于UDP)来避免TCP队头阻塞。理解了这个演进过程,你对TCP在网络世界中的定位会有更立体的感受。
6.6 TCP和UDP怎么选
这个常被拿来考RPC框架选型。一句话:需要可靠、按序、面向连接的场景选TCP;实时性优先、可容忍少量丢包、追求低开销的场景选UDP。具体如文件传输、远程登录、数据库协议都是TCP;而视频直播、语音通话、在线游戏的动作同步,很多是UDP或RUDP(在UDP之上自研可靠机制)。
6.7 Code-level的坑:close()和shutdownOutput()到底差在哪
很简单:
close():释放整个Socket及其所有资源,输入输出通道全部关闭。shutdownInput()/shutdownOutput():只关闭对应的半通道,不影响另一方向的通信,底层发送FIN通知对端“我这半边结束”。
我见过不少项目一开始用close()结束通信,导致对端服务端读到EOF误判为连接异常,其实用户只想发送完数据等服务端回包。这种语义错误会引发一连串业务bug,务必想清楚再调用。
7. 一次完整的TCP长连接实践:带心跳与断线重连的示例
把所有点串起来,我写一个贴近生产的简易示例,场景是客户端每隔3秒上报一次本机内存使用率,服务端实时接收并打印;连接断开后客户端自动重连。
7.1 服务端代码(支持多客户端,读空行即判定连接关闭)
import java.io.*; import java.net.*; import java.util.concurrent.*; public class MonitoringServer { public static void main(String[] args) throws IOException { int port = 8088; ExecutorService pool = new ThreadPoolExecutor(4, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy()); try (ServerSocket server = new ServerSocket(port)) { System.out.println("[MonitorServer] start at " + port); while (true) { Socket client = server.accept(); pool.submit(() -> handle(client)); } } } private static void handle(Socket socket) { try (socket; BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { if ("heartbeat".equals(line)) { // 心跳包,忽略 continue; } System.out.println("[MonitorServer] metric -> " + line); } } catch (IOException e) { System.err.println("[MonitorServer] disconnect: " + e.getMessage()); } } }线程池的CallerRunsPolicy是个小技巧:当任务队列满时,由提交任务的线程(这里是主线程)直接执行任务,避免任务被丢弃,同时起到天然背压效果。这个策略比默认的AbortPolicy友好得多。
7.2 客户端代码(带断线重连与心跳)
import java.io.*; import java.net.*; public class MonitorClient { private static final String HOST = "127.0.0.1"; private static final int PORT = 8088; public static void main(String[] args) throws Exception { while (true) { try { connectAndReport(); } catch (Exception e) { System.err.println("[MonitorClient] connection lost: " + e.getMessage()); Thread.sleep(3000); // 3秒后重连 } } } private static void connectAndReport() throws Exception { Socket socket = new Socket(); socket.connect(new InetSocketAddress(HOST, PORT), 3000); socket.setSoTimeout(5000); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); System.out.println("[MonitorClient] connected"); long lastHeartbeat = System.currentTimeMillis(); while (true) { // 每3秒上报一次数据,中间穿插心跳 long now = System.currentTimeMillis(); if (now - lastHeartbeat >= 3000) { out.println("heartbeat"); double usage = queryMemoryUsage(); out.println("memory:" + usage + "%"); lastHeartbeat = now; } // 若无数据,稍作休息避免CPU空转 Thread.sleep(100); } } private static double queryMemoryUsage() { // 简易模拟:返回50-90之间的随机数 return 50 + Math.random() * 40; } }这个Demo看起来简单,但已经覆盖了连接超时、读超时、心跳、断线重连这几个长连接必备能力。我把readLine换成真正的业务代码(比如解析JSON、校验消息完整性),再套上多进程/多线程架构,就能直接扩展到真实的监控Agent、消息推送SDK等场景。
有一个细节值得强调:客户端里用println发送数据时,PrintWriter会自动在行尾追加换行符,服务端用readLine按行读取,天然匹配。这其实是最简单的一种消息边界方案——按行分隔。如果消息体里包含换行,就必须换成长度帧方案。
8. 关于Java网络编程,最后透点我的实战心得
写到这里,基础知识和实战代码都过完了。最后分享几个我从大量线上问题排查中沉淀下来的个人习惯,希望对你有用。
第一,任何时候都别低估网络环境的复杂程度。本机测试一切正常,不代表到了跨机房、跨运营商、经过了SLB和NAT之后就正常。我吃过最大的亏就是默认“TCP既然是可靠的,那我随便写写就行”。实际上,TCP只保证“数据按序到达对端内核缓冲区”,不保证“对端应用一定能及时读走”;不保证“连接一直活着”;也不保证“读操作不阻塞”。所以,你在应用层该有的超时、重试、心跳、消息边界,一个都不能少。
第二,多利用工具,别只靠肉眼看代码。排查网络问题,tcpdump加Wireshark是透视TCP状态的最佳组合,建议每个Java开发者都花两小时把这两个工具的基本操作学会。很多玄学问题(比如RST、重传、乱序)一旦看到抓包截图,答案自然就浮出来了。
第三,分清楚“Java层能做的”和“Java层不能做的”。TCP的超时重传、滑动窗口、拥塞控制全在内核协议栈里,Java代码碰不到。你可以调调缓冲区大小、开关Nagle、设超时时间、选线程模型,但不要指望Java层能弥补底层协议缺陷。想深究的话,去看Linux内核的tcp_input.c、tcp_output.c,配合《TCP/IP详解》第一卷,会对整个体系有脱胎换骨的理解。
第四,面试中把八股文变成“自己的故事”。光背“三次握手四次挥手”在现在卷到飞起的Java面试中已经不吃香了。你可以主动讲项目里遇到过的报文乱序、粘包导致的排查经历,讲你是如何通过抓包定位到对端wait_timeout设置过短的。面试官最喜欢听到的就是这种“踩过坑、解决过问题、有方法论”的候选人。
Java TCP网络编程这条路,入门容易精通难。如果你能把本文中从ServerSocket到粘包拆包、从三次握手到TIME_WAIT、从单线程Echo到带心跳重连的长连接Demo都自己动手敲一遍、跑一遍、打断点看一遍,你对网络通信的掌控力会远超大多数同龄工程师。出问题不要怕,多抓包,多读报错信息,这些经验积累起来就是你最值钱的底牌。