零基础学Java TCP编程:从三次握手到粘包半包实战
学Java的零基础朋友十有八九会在TCP协议这儿卡一下。网上搜“tcp协议”满屏都是三次握手、四次挥手的图每个字都认识连起来看就犯晕。但TCP这玩意儿又是躲不开的你写个聊天程序、做个文件上传、调一个后端接口底层全是它在跑。这篇文章我不想贴大段RFC文档也不整那些背完就忘的八股名词就站在零基础的角度用Java代码把一个TCP程序从头到尾跑起来再把每一步的“为什么”讲明白。你读完不需要精通协议细节只需要知道TCP在Java里怎么用、遇到问题往哪个方向查、面试聊起来怎么接得住。这篇文章适合刚学完Java语法、想往网络编程再走一步的人也适合正在准备java面试、想把TCP这块讲清楚的朋友。我会先讲清楚TCP在整个Java开发里的位置再用大白话拆解三次握手和四次挥手最后用完整的ServerSocket和Socket代码带你实操一把。所有代码我都会贴出来你可以直接复制到IDE里跑跑完再回头看概念会有种“原来是这么回事”的感觉。1. 零基础学TCP协议先搞清楚它在整个Java开发里的位置1.1 为什么零基础学Java一定要碰TCP很多刚开始学Java的人有个误区觉得TCP协议是网络工程师才需要研究的写Java代码只要会用框架就行。这话对了一半。你用Spring Boot写个接口确实不需要自己创建Socket连接但一旦你开始接触以下场景TCP就再也绕不开两个系统之间做实时数据推送比如IM聊天、游戏服务器自己写一个RPC框架或者理解Dubbo、gRPC的底层原理接手物联网设备接入设备通过TCP上报数据排查线上接口偶发超时、连接被重置的问题面试被问到“你了解TCP吗”“TCP和UDP怎么选”在Java的生态里TCP编程是NIO、Netty这些高性能网络框架的基础。你如果直接跳到Netty会发现里面全是Channel、Pipeline、ByteBuf这些概念不懂TCP的话很难理解它们到底在封装什么。反过来先老老实实把ServerSocket和Socket跑明白后面学框架会顺畅非常多。1.2 网络分层TCP到底在哪一层干活要理解TCP先得知道它在网络模型里的位置。咱们不背七层模型就记住一个偷懒版本数据要从你电脑发到另一台电脑中间要经过好几道“包装工序”。最上面是你的Java程序它产生了一段数据比如一句“你好”。这段数据往下走到了TCP这一层TCP会给它加一个“快递单头”写上源端口和目标端口、序号、校验信息等。再往下走IP这层又加一个头写上源IP地址和目标IP地址。最后通过网卡把这一整套数据发出去。你可以把TCP理解成“负责可靠传输的快递公司”把IP理解成“负责认路的路网”。IP只负责把包裹送到目标大门不保证包裹内容完整TCP负责在包裹上编号、确认签收、丢了就重发。Java里的Socket就是程序员和这个快递公司打交道的窗口。日常开发里我们经常把“TCP/IP”放一起说其实TCP和IP是不同层的两个协议。面试官问“tcp/ip协议”的时候通常默认指整个TCP/IP协议族包括IP、TCP、UDP、HTTP这些。真正面试时如果被追问能分清楚这两层的关系会加不少分。2. TCP连接建立与断开三次握手、四次挥手用大白话讲透2.1 三次握手每次连接背后都在互相确认什么三次握手是TCP最经典的面试题也是很多人背了又忘的内容。我换个场景讲A和B第一次见面想确认双方都能正常收发信息。第一次A对B说“你好我是A你能听到吗”第二次B听到后回复“听到了我是B你能听到我吗”第三次A再回复“听到了我们开始聊吧”三次之后双方都确认了一件事我能发出去对方能收到对方能发出来我也能收到。这样连接才算建立。对应到TCP报文里这三次分别是SYN、SYNACK、ACK。第一次是发起方主动发送一个SYN包告诉对方“我要建立连接”第二次是接收方响应一个SYNACK表示“我收到了我也准备好了”第三次是发起方再回一个ACK表示“我知道你准备好了”。有一个细节很多人会忽略为什么不是两次握手因为如果只有两次接收方无法确认自己的回复是否被对方收到。极端情况是一个过期的连接请求突然到达服务器服务器回了SYNACK如果这是第二次服务器就认为连接建立了开始分配资源等着但客户端根本没有这个连接的意思造成资源浪费。第三次握手可以让服务器确认“客户端真的收到了我的响应”避免这种无效连接。2.2 四次挥手为什么断开比建立还多一步断开连接比喻成两个人结束通话。假设A主动挂断第一次A说“我说完了准备挂了”FIN第二次B说“知道了我这边还有几句话要说你等等”ACK第三次B说完之后说“我也说完了可以挂了”FIN第四次A说“行挂了吧”ACK所以四次挥手本质上是两个方向各自独立关闭。TCP允许数据双向流动所以每一方向都需要单独确认关闭。A发送FIN表示“我不再发数据了”B收到后回复ACK表示“我知道了”但这时候B可能还有数据要发给A所以B的数据通道还开着。等B把数据发完再发一个FIN告诉A“我也不发了”A回一个ACK连接才彻底关闭。这里经常考的还有一个知识点TIME_WAIT状态。最后一次ACK是A发出的但A不能马上关闭要等一小段时间防止这个ACK丢了导致B重发FIN。这段等待时间通常是2倍的最大报文段生存时间。在服务端程序里如果你经常快速重启服务偶尔会遇到端口被占用很多时候就是老的连接还在TIME_WAIT状态里没释放。2.3 TCP与UDP的选择面试也爱问的对比面试里经常跟着三次握手后面问的就是TCP和UDP的区别。记忆方法非常简单TCP是可靠、面向连接、按顺序到达的UDP是不可靠、无连接、只管发不保证收的。一个类比是TCP是打电话接通后双方实时对话中间断了系统会帮你重新连UDP是寄明信片丢了你也不知道补不补看心情。实际项目里怎么选我自己的判断标准是场景推荐协议原因文件传输、支付、登录注册TCP数据完整性要求高丢一个字节都可能出大问题视频通话、语音通话、游戏实时操作UDP延迟优先丢少量帧可以接受重传反而卡顿日志上报、监控指标采集UDP允许少量丢失追求吞吐量DNS域名解析UDP请求响应都很短一次往返就够零基础阶段先记住一个结论就够了TCP用可靠换效率UDP用效率换简单。后面接触Netty时两个协议都有对应的实现原理懂了再切换不迟。3. Java里的TCP编程从零手写一个可运行的服务端与客户端3.1 开发前准备JDK与一个编辑器就够TCP编程在Java里不需要任何额外依赖官方类库的java.net包就提供了全套能力。你只需要安装JDK 8或更高版本我用的是JDK 8长期稳定电脑上有个IDEIDEA或者Eclipse都行实在没有用记事本也能跑一个能运行代码的终端或IDEA自带控制台准备阶段要提醒一句JDK安装完之后在命令行输入java -version能正常输出版本号才算装好。Win11上经常出现装完JDK但命令行不识别的问题一般是环境变量里JAVA_HOME没配置或者PATH里没加%JAVA_HOME%\bin。这个步骤属于Java基础如果不熟先把环境调通再往下写代码。3.2 服务端实现ServerSocket的核心代码逐行解析服务端的职责是监听某个端口等待客户端连接然后跟连上的客户端交换数据。Java里核心类就是ServerSocket。先写一个最简单的版本实现“客户端发一句话服务端收到后原样返回”。import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; public class TcpServer { public static void main(String[] args) throws Exception { int port 8080; // 1. 创建服务端Socket绑定端口8080 try (ServerSocket serverSocket new ServerSocket(port)) { System.out.println(服务端已启动监听端口 port); // 2. accept()是阻塞方法会一直等客户端的连接 while (true) { Socket socket serverSocket.accept(); System.out.println(接收到客户端连接 socket.getRemoteSocketAddress()); // 3. 读取客户端数据按UTF-8解码避免中文乱码 BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); // 4. 发送数据给客户端 OutputStream outputStream socket.getOutputStream(); PrintWriter writer new PrintWriter(outputStream, true); String line; while ((line reader.readLine()) ! null) { System.out.println(收到客户端消息 line); writer.println(服务端已收到 line); } // 5. 收完数据关闭资源 writer.close(); reader.close(); socket.close(); } } } }一步步拆开讲。new ServerSocket(port)很重要这一步做了三件事创建Socket对象、绑定端口、进入监听状态。端口号范围是0到65535建议用1024以上的端口避免跟系统常用端口冲突。accept()是阻塞方法这意味着程序运行到这一行时会停住直到有客户端来连接才会继续往下执行。对一个简单的服务端来说这个阻塞特性可以被利用来循环接收连接但要注意这个版本是单线程的同一时间只能处理一个客户端后连进来的客户端得排队。这里有两个容易踩的坑。第一个是中文乱码InputStream读出来的是字节BufferedReader默认会用平台编码解码Windows上可能是GBKLinux是UTF-8不一致就会出现问号乱码。我上面强制指定了StandardCharsets.UTF_8这算是一个规范姿势。第二个坑是PrintWriter第二个参数autoFlush我传了true意思是一调用println就自动把缓冲区内容刷出去。如果这里是false对方可能一直等不到数据以为没有回复。3.3 客户端实现Socket连接与服务端交互有了服务端客户端就简单了。客户端核心类是Socket连接时指定服务器的IP地址和端口号。import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.PrintWriter; import java.net.Socket; import java.nio.charset.StandardCharsets; public class TcpClient { public static void main(String[] args) throws Exception { String serverHost 127.0.0.1; int serverPort 8080; // 1. 连接服务端这个操作会触发TCP三次握手 try (Socket socket new Socket(serverHost, serverPort)) { System.out.println(已连接到服务端 socket.getRemoteSocketAddress()); // 2. 组织发送数据 OutputStream outputStream socket.getOutputStream(); PrintWriter writer new PrintWriter(outputStream, true, StandardCharsets.UTF_8); // 3. 读取服务端回复 BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); writer.println(你好我是客户端); String response reader.readLine(); System.out.println(服务端回复 response); writer.close(); reader.close(); } } }注意看我在PrintWriter构造方法里也指定了UTF-8。JDK 10之后PrintWriter支持传入Charset对象JDK 8的话不能这么传需要用new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)包一层再传给PrintWriter。我给的是JDK 8环境平时演示会写成OutputStreamWriter这里为了简洁写成了JDK 10写法你根据自己JDK版本调整即可。new Socket(serverHost, serverPort)这一步会触发TCP三次握手握手成功才返回。如果服务端没启动这里会抛ConnectException: Connection refused如果服务端地址不可达会一直卡着直到超时。所以客户端写完后先启动服务端再启动客户端这是个好习惯。3.4 把代码跑起来观察三次握手的真实表现实操的时候我建议分两个终端窗口看效果。第一个终端先跑TcpServer看到“服务端已启动监听端口8080”后第二个终端跑TcpClient。正常情况服务端会打印“接收到客户端连接”客户端会打印“服务端回复服务端已收到你好我是客户端”。如果你用的还是IDE可以直接在IDEA里双开把TcpServer的main方法跑一次再把TcpClient的main方法跑一次运行窗口会分别展示两个程序的控制台输出。有一个小技巧能够看到握手过程在Windows的命令窗口或Linux终端里执行netstat -ano再根据端口号过滤。Windows下用netstat -ano | findstr 8080Linux下用netstat -anp | grep 8080或者ss -tunap。你会看到连接状态从SYN_SENT变成ESTABLISHED这两个状态就对应着三次握手的进行中和完成后状态。程序退出之后服务端那侧可能还会看到TIME_WAIT状态这也印证了前面说的四次挥手收尾需要等待一段时间的问题。4. TCP流式传输的经典坑粘包与半包以及解决套路4.1 粘包与半包到底怎么产生的TCP是一个流协议什么叫流就是没有边界。你可以把TCP连接想象成一根自来水管你往里倒水对端打开水龙头接水。你倒一杯水不代表对方接到的也是一杯水量你连续倒三杯对方可能一次接到了三杯混在一起的水也可能先接到一半过一会儿再接剩下的一半。对应到程序里就是粘包和半包粘包连续写了两次或多次数据对方一次就读出来一大串分不清边界半包一次完整的数据被拆成了几段对方读了半天读不全报错或解析失败为什么会出现这种情况因为TCP的数据会被操作系统按缓冲区大小、网络拥塞程度、MTU等因素进行拆分或合并。你在Java的OutputStream里写10个字节操作系统可能立刻发出去也可能攒更多数据再发。接收方read时更不一定一次读满你想要的数量。4.2 三种常见解决方案既然TCP不给边界那么应用层就得自己画边界。业界常见方案有三种按从简单到复杂的顺序排列。第一种是固定长度消息。规定每条消息都是100字节不足的补空格。接收方每次都读100字节读到就是一条完整消息。优点是实现简单缺点是浪费空间业务消息长也好短也好都要占固定大小。第二种是分隔符方式。每个消息末尾加一个特殊标记比如换行符\n或者自定义的#END#。接收方读到分隔符就知道一条消息结束了。上面写的代码就是用的readLine()以换行为界。这种方式适用于文本数据如果消息内容本身包含分隔符需要做转义处理。第三种是长度前缀方式。每个消息前头先写4个字节表示消息长度后面跟真正的消息内容。接收方先读长度再读对应字节数。这是最通用、最推荐的方案Netty、Dubbo等框架底层都基于这个思路。4.3 Java代码里的落地写法让我们用长度前缀的方式改造一下客户端发送逻辑。假设消息是你好TCP先把这个字符串转成UTF-8字节数组然后计算字节数组的长度写到一个4字节的int里再跟上字节数组本身。import java.io.DataOutputStream; import java.net.Socket; public class LengthPrefixedClient { public static void main(String[] args) throws Exception { try (Socket socket new Socket(127.0.0.1, 8080)) { String message 你好TCP; byte[] body message.getBytes(StandardCharsets.UTF_8); DataOutputStream output new DataOutputStream(socket.getOutputStream()); // 先写4字节长度再写内容 output.writeInt(body.length); output.write(body); output.flush(); socket.shutdownOutput(); } } }DataOutputStream.writeInt会按大端序写入4个字节接收端只要用DataInputStream.readInt读就能拿到长度值然后循环读取直到读满length个字节。用这种方式即使网络把你一次写入的数据拆成了多段接收方也能正确拼回完整消息。实际生产环境里消息格式会比这个复杂通常还会包含消息类型、版本号、序列化方式这些字段。但本质都是同一个思路用固定格式的头部描述消息让接收方知道怎么切分。5. 实操中常见问题与排查技巧实录5.1 端口被占用服务启动失败怎么办新手最容易遇到的是java.net.BindException: Address already in use: JVM_Bind意思是端口被占用。原因可能是上一个服务端程序没退出干净也可能是另一个程序在占用这个端口。解决步骤很固定在命令行执行netstat -ano | findstr 8080记下最后一列的PID然后打开任务管理器找到对应PID的进程结束掉。Linux下用lsof -i:8080然后kill -9 PID。如果不想手动杀进程也可以换一个端口比如8081、9090但要注意换了端口之后客户端连接的那个端口也要同步改否则还是连不上。还有一个值得记住的细节前文提到的TIME_WAIT状态也会占用端口。如果服务端频繁重启偶尔看到同一个端口被占用等一两分钟自动释放是正常的不用太焦虑。5.2 Connection refused与连接超时的排查思路这两个报错经常被混在一起但它们的原因差别很大。ConnectException: Connection refused中文翻译是连接被拒绝。客户端发起连接时目标服务器会直接回一个RST包拒绝。常见场景是服务端程序没启动、端口写错、防火墙拦截了对应的入站连接。ConnectTimeoutException或者连接一直卡住直到超时说明客户端发出的SYN包没有得到任何响应。常见场景是目标IP地址在网络层面不可达、启用了防火墙静默丢弃、服务器上服务绑定的IP不是客户端访问的那个IP。排查的时候先确认服务端程序是否真的启动了再确认端口对不对然后ping一下目标IP确认网络通不通最后检查防火墙和安全组规则。记住这个顺序能省很多时间。5.3 服务端如何优雅处理多客户端我们上面写的单线程服务端有一个硬伤accept()只能一个个处理客户端第一个客户端不关闭连接的话第二个客户端连上来也只能排队等。真实场景不能这么干。最简单的改进方式是两个台阶。第一台阶是用多线程每接受一个连接就丢给一个新线程去处理。try (ServerSocket serverSocket new ServerSocket(8080)) { while (true) { Socket socket serverSocket.accept(); new Thread(() - handleSocket(socket)).start(); } }这个版本能处理多个客户端了但线程数量一多频繁创建线程的开销就成了新瓶颈。第二台阶是用线程池ExecutorService pool Executors.newCachedThreadPool(); while (true) { Socket socket serverSocket.accept(); pool.submit(() - handleSocket(socket)); }再往后就进入NIO的思路了用单线程或少量线程管理大量连接这也是Netty做的事。零基础阶段不用一步到位理解NIO但应该知道从单线程阻塞、到多线程、到线程池、到NIO是一条顺理成章的发展路线面试问“多客户端怎么处理”时从这条路线展开讲会显得你真懂。5.4 我的调试工具箱最后分享几个我平时排查TCP问题常用的命令零基础也可以直接上手。netstat -ano查看系统所有TCP连接包括状态比如LISTENING、ESTABLISHED、TIME_WAITss -tunapLinux上的替代命令信息更全还能看到进程名和PIDtelnet 127.0.0.1 8080快速测试端口是否开放能连上会显示连接成功tcpdumpLinux上的抓包工具能看到所有TCP报文适合深入排查Wireshark图形化抓包软件能看到三次握手时SYN、ACK的具体顺序在Windows上不建议一上来就装Wireshark先学会netstat就够日常用了。等真的需要看协议细节再上tcpdump或Wireshark不迟。6. 零基础自学Java路线上TCP协议该学到什么程度写到这里我再补充一点关于学习节奏的体会。TCP协议的知识点非常多从拥塞控制到重传机制从滑动窗口到慢启动一封长篇大论都不一定能写完。对零基础来说现阶段不需要全啃下来按照“先会用、再懂原理、最后深入细节”的顺序推会更高效。第一层会用。能写ServerSocket和Socket的通信程序知道端口是什么知道客户端怎么连服务端遇到Connection refused知道大概原因。这一层对应上面第3节的内容。第二层懂原理。能讲清楚三次握手四次挥手知道TCP和UDP的核心区别知道什么是粘包半包能说出长度前缀方式的解决思路。这一层对应第2节和第4节。第三层深入细节。开始研究滑动窗口理解拥塞控制算法对比TCP和UDP在不同网络环境下的表现差异。这一层比较适合准备Java面试进阶或者写Netty框架的朋友。个人实际体验是大多数日常工作用到前两层就够了第三层更多是面试和框架源码研究需要。你如果走到第三层可以去看《TCP/IP详解》的相关章节以及Netty官方文档里关于ChannelOption配置的说明里面很多参数比如SO_RCVBUF、TCP_NODELAY都是TCP协议的Java映射。还有一个小建议学习TCP的时候多把概念和代码对应起来。比如accept()对应被动打开connect()对应主动打开setSoTimeout()对应超时控制close()对应发送FIN。这样在理解和记忆上都会轻松很多。我自己带过不少零基础的学员发现一个共性凡是能把三次握手和Java代码里的accept()、connect()对应上的人后面学NIO、Netty都会顺很多。TCP这块不需要背很多数字和状态名先把一条连接从建立、通信到断开的全过程跑通了心里就有底了。最后分享一个小技巧调试TCP程序的时候启动服务端和客户端之前先开一个终端窗口跑netstat -ano等程序运行时再执行一次对比连接状态的变化很多概念不用死记看一遍状态变化就记住了。