ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java网络编程实战:从TCP/IP三次握手到Socket代码实践

Java网络编程实战:从TCP/IP三次握手到Socket代码实践 搞网络编程这几年我最大的感受是大部分人学了Java基础之后就卡在了网络编程这一关。为什么卡因为网上资料要么太偏理论上来就是报文格式、状态转换图看得人头大要么太偏实务只顾着贴代码根本不解释为什么这么写。这次我整理了一份从网络编程三要素开始一直延伸到TCP/UDP协议最后落到Java Socket代码实践的完整笔记把我自己在实际开发中反复踩过的坑、面试中被问过的高频题以及处理过的线上案例都塞进去了。这份笔记比较适合两类人一是准备Java面试、尤其是被“三次握手四次挥手”折磨过的人二是刚接触Socket编程、想搞清楚代码背后原理的初学者。也可以当作你的速查手册遇到问题翻开就能用。Java网络编程绕不开“IP地址、端口号、协议”这三要素。说实在的这三个概念单独拎出来谁都能说几句但真正把它们串联起来理解网络通信的全貌很多写了几年代码的人也没能做到。我在带新人的时候发现他们往往能背出IP是什么、端口是什么但遇到“为什么客户端连接服务器要用IP端口组合”“为什么UDP不需要三次握手”这类问题时就卡住了。其实问题的答案就藏在这三要素的关系里。IP地址解决的是“去哪”的问题它定位到一台设备端口号解决的是“找谁”的问题它定位到设备上的具体进程协议解决的是“怎么说”的问题它规定了通信双方的语言规则。三者缺一不可少了任何一个通信都无法建立。可以这样理解你把IP地址想象成某栋写字楼的地址端口号就是楼里的某个办公室房间号协议则是你进入办公室后和里面的人交流时使用的语言。光知道地址你到了写字楼但不知道去几楼几号房间白跑一趟知道房间号但不会说对方的语言交流起来鸡同鸭讲。这三要素各自分工明确组合在一起才能完成一次有效通信。先来看IP地址。Java里处理IP地址的核心类是InetAddress它屏蔽了底层地址表示细节无论IPv4还是IPv6都能通过一套API操作。实际开发中我用得最多的两个方法InetAddress address InetAddress.getByName(www.example.com); // 返回主机名 System.out.println(address.getHostName()); // 返回IP地址字符串 System.out.println(address.getHostAddress());这个方法调用了底层DNS解析把域名转成IP。但要注意getByName传入的是一个不存在的主机名时不会立刻抛异常而是等到实际尝试建连的时候才暴露问题。这属于DNS懒加载特性在写网络诊断工具时要格外留意。然后是端口号。端口号范围是0到65535其中0到1023是公认端口Well-Known Ports比如HTTP的80、HTTPS的443、FTP的211024到49151是注册端口49152到65535是动态或私有端口。Java服务端绑定端口时如果不放心某个端口是否被占用可以用ServerSocket尝试绑定捕获BindException来做检测这种思路比命令行查端口要优雅不少。万一遇到“Address already in use”的报错多半就是端口被其他进程占用或处于TIME_WAIT状态后文我会专门聊这个。最后是协议。协议本质上是一套“约法三章”双方都遵守这套规则才能互相理解。网络编程中我主要接触的是TCP和UDP这两个协议承载了绝大多数互联网应用。TCP像快递寄贵重物品要确认收货、丢了要补发过程可靠但也相对慢UDP像发广播喊一嗓子就不管了不保证对方听没听见但胜在快、省事。后面我会把两个协议的关键机制、适用场景、Java实现方式逐一拆开讲。聊网络编程可以绕过物理层和数据链路层那些枯燥的细节但TCP/IP四层模型一定要建起来。从下到上分别是网络接口层、网际层、传输层和应用层Java程序员打交道最多的就是传输层和应用层。传输层里的两个主角就是TCP和UDP也是本笔记的核心内容。TCP全称传输控制协议它最鲜明的特点是用“三次握手”建立连接用“四次挥手”释放连接。很多面试题考这个不少候选人能背出SYN、ACK、FIN这几个标志位但问一句“为什么握手需要三次而不是两次或者四次”就不知道怎么回答了。这里我用自己的理解讲明白。三次握手的过程是这样的第一次客户端发送一个带有SYN标志的报文请求建立连接同时带上一个初始序列号比如x。 第二次服务端收到SYN后回复一个SYN-ACK报文既确认客户端序列号ackx1也请求建立服务端到客户端的连接携带自己的初始序列号y。 第三次客户端收到后回复一个ACK确认报文确认服务端序列号acky1。到这一步双方都确认了“我能收到你发的消息你也能收到我发的消息”连接才算建立成功。为什么三次是刚好的因为握手过程要解决两个核心问题确认双方收发能力正常以及同步双方各自的初始序列号。两次握手只能让服务端确认客户端能发、自己能收但无法让客户端确认服务端有接收能力如果只有两次握手服务端发出的SYN-ACK万一丢了它自己还不知道会一直等待客户端发数据白白浪费资源。至于四次握手完全没必要——客户端向服务端发请求、服务端向客户端发请求这两个方向本来就能在两次请求和两次响应里完成合并一下就变成了三次。我当年不懂这个逻辑硬背标志位面试官一追问就露馅现在想想关键在于理解“确认”这件事在TCP里是双向的。四次挥手的过程也容易记混。我整理了一个简易流程客户端发送FIN表示“我的数据发完了我要关闭连接”。 服务端回复ACK表示“收到你的FIN我这边还有数据没发完你先等着”。 服务端数据发完后发送FIN表示“我的数据也发完了可以关闭了”。 客户端回复ACK表示“收到连接关闭”。四个步骤缺一不可因为TCP是双工通道两个方向的关闭必须独立进行。哪个方向先完成数据传输哪个方向就主动发起FIN没必要等对方数据也发完再一起关。这里要特别提一个高频考点主动关闭方在第四次挥手后会进入TIME_WAIT状态通常等待2MSL两倍最大报文段生存时间后才完全关闭。很多没做过线上问题排查的开发者不理解为什么要有这个等待。核心原因有两个一是确保最后的ACK能到达对方万一丢失对方会重发FIN此时连接还没彻底释放旧报文才能被正确接收二是让晚到的报文在网络中自然消亡避免污染后续使用同一端口的新连接。基于这个机制我再强调一个生产环境常识服务端主动关闭连接时很可能积累大量处于TIME_WAIT状态的端口导致新连接无法绑定。处理方式我后面在问题排查章节详细展开。TCP的可靠性不只体现在握手和挥手还有一整套“组合拳”确认应答机制、超时重传、滑动窗口、流量控制、拥塞控制。确认应答指接收方收到数据后要回一个ACK发送方没收到就认为丢了超时后重发。滑动窗口解决的是效率问题——不是发一条等一条而是一次允许发送多条数据窗口大小根据网络状况动态调整。流量控制是接收方告诉发送方“你慢点我快处理不过来了”拥塞控制则是网络整体“堵车”时发送方主动降低发送速度。这些机制我自己用的过程里不需要手动操作但排查问题时要懂比如线上请求变慢就要考虑是不是触发了拥塞控制导致的降速。和TCP追求极致可靠不同UDP走的是“轻装上阵”路线。没有连接、没有握手挥手、没有确认重传、没有流量控制。用UDP发送数据时直接封装一个数据报扔给网络至于对方收没收到、收到后乱序没乱序发送方一概不关心。Java里的UDP编程最核心的类是DatagramSocket和DatagramPacket。发送方构造一个包含数据、目标IP、目标端口的数据包交给DatagramSocket发出去接收方创建指定端口的DatagramSocket不断接收DatagramPacket。UDP的API比TCP简洁不少因为少了一层连接管理但是相应的你要自己处理丢包重试、乱序排序这类问题。我用一个小代码示例演示UDP发送方的写法DatagramSocket socket new DatagramSocket(); String message hello, udp; byte[] buf message.getBytes(StandardCharsets.UTF_8); InetAddress address InetAddress.getByName(127.0.0.1); DatagramPacket packet new DatagramPacket(buf, buf.length, address, 9090); socket.send(packet); socket.close();UDP接收方DatagramSocket socket new DatagramSocket(9090); byte[] buf new byte[1024]; DatagramPacket packet new DatagramPacket(buf, buf.length); while (true) { socket.receive(packet); String msg new String(packet.getData(), 0, packet.getLength(), StandardCharsets.UTF_8); System.out.println(msg); }这里有一个非常经典的坑——DatagramPacket的getData()返回的是整个buffer数组而不是收到的数据长度。如果你直接用new String(packet.getData())会把byte数组后面没写入的“脏数据”也转出来导致乱码或者多余字符。正确做法是使用getLength()来限定有效数据长度。我见过好多人在UDP调试工具里收到一长串没意义的字符多半就是这个原因。另外UDP单个数据报的大小不是随便定的。IPv4下UDP报文理论上限是65507字节65535减去IP头20字节和UDP头8字节但实际网络链路层的MTU通常只有1500字节左右一超过就容易在网络上被分片或者直接丢弃。所以业内一般建议UDP数据报控制在1472字节以内1500减去IP头和UDP头。做音视频传输或者游戏帧同步时这个值是反复测试后认定的安全线。UDP虽然“不可靠”但它在某些场景下反而是最合适的选择。首当其冲的是DNS查询你访问一个网站第一步就是向DNS服务器发起UDP请求问域名对应的IP地址。如果DNS用TCP每个查询都要握手建连网页打开速度会明显变慢。其次是音视频通话和直播这类场景对实时性要求极高偶尔丢一帧画面影响不大但排队重传已丢失的数据包会让整个画面卡顿体验极差。再次是网络游戏的状态同步尤其是射击类和MOBA类游戏玩家位置、操作指令需要高频推送基于UDP派生的自定义协议比如用KCP这类可靠UDP方案再封装能提供比TCP更低延迟的传输路径。我在做物联网设备数据上报时也遇到过类似选择设备每隔几秒上报一次状态数据量小、频率高用UDP省去了建立和销毁连接的开销吞吐量显著提升。既然TCP和UDP各有优劣那做技术选型时到底怎么取舍我习惯用三个维度判断可靠性需求、实时性需求、连接数规模。如果业务对数据完整性有硬性要求比如文件传输、邮件收发、转账交易那必须选TCP丢一个字节都可能引发事故。 如果业务以实时交互为主且能容忍偶发数据丢失比如语音通话、视频会议、游戏操作指令那就选UDP并配合应用层重传策略。 如果服务端需要同时维持大量客户端的长连接比如IM软件、消息推送传统TCP每连接一个线程的模式在数千连接时性能会极速恶化要么改用TCP加线程池/事件驱动模型要么干脆基于UDP设计自己的可靠通信协议。在Java技术栈里我曾经用NIO重写过一套支持上万TCP长连接的消息推送网关选用TCP是因为消息不能丢。但如果当时场景换成实时位置上报我会毫不犹豫切UDP因为位置信息本身就是周期刷新的丢一个包等下一个周期就好完全没有必要为它维持连接状态。说到Java Socket编程很多人以为就是ServerSocket配Socket其实里面可以展开讲的东西非常多。先看最基本的TCP服务端模型这也是我面试新人时必问的一个点ServerSocket serverSocket new ServerSocket(8080); while (true) { Socket socket serverSocket.accept(); // 每个连接单独开线程处理 new Thread(() - handle(socket)).start(); }这段代码能跑但线上绝对不能这么干。每来一个连接就新建线程当并发达到几百上千时线程上下文切换开销和内存占用足以把服务拖垮。所以生产环境至少会用线程池来做资源控制再进一步则是用NIO非阻塞IO配合Selector实现单线程管理大量连接。Java NIO的核心思路是“事件驱动”你把通道注册到Selector上告知它你关心哪些事件可读、可写、有连接到达然后一个线程不断轮询就绪事件有事件才处理没事件就继续监听。这样一条线程就能管理成千上万的通道。Netty就是对这套机制做了极致的封装优化在Java网络编程领域几乎成了事实标准绝大多数高性能RPC框架底层都在用它。不过这里我要提醒初学者学习阶段先把BIO阻塞IO的模型吃透NIO可以渐进地学但别一上来就追Netty源码。Socket编程的基本功在于理解三次握手相关状态、读写缓冲区的处理、半关闭状态下的输入输出流行为这些有了学NIO只是换个线程模型。反之直接啃Netty很容易被Reactor模式、EventLoop这些概念绕晕。还有一个几乎每个写过TCP的人都绕不开的痛点粘包和拆包。TCP是流式协议没有消息边界概念。发送方调用两次write写入的数据接收方可能一次性read读到全部发送方一次write的数据也可能因为网络缓冲区限制被拆成多次read读取。对于使用固定长度消息的应用来说这是致命问题——收端根本不知道一条消息在哪截断。解决方案业界有几种成熟做法消息头携带长度字段自定义协议结构比如“4字节长度消息体”收端先读长度再读消息体。 使用特殊分隔符比如消息末尾加\n、\r\n或者自定义的结束标志Felix框架和Netty自带的DelimiterBasedFrameDecoder就是干这个的。 定长消息发送方把每条消息都补齐到固定长度接收方按固定长度截取适合消息大小比较整齐的场景。我自己做电信运营商话单采集网关时用过第二种方案消息之间用换行符分隔再用Netty的LineBasedFrameDecoder处理非常省心。如果你是用原生BIO编程就得自己在InputStream上做缓存和判断代码繁琐但能加深理解。我强烈建议初学者自己实现一次“长度字段拆包”对TCP的流特性体会会非常深。另一个经常被忽略的技术点是被墙在防火墙外的NAT穿透。在局域网里调试UDP程序互相收发消息毫无障碍但放到公网上客户端和服务端之间有大量NAT设备UDP数据报很容易被路由丢弃导致“我发了消息你怎么收不到”的诡异现象。这是UDP应用落地的第一道坎很多开发者不知道UDP在公网环境的不稳定性误判成自己代码写错了。调试时可以先在本机、局域网内做通通信测试确认代码逻辑没问题再去研究NAT映射、打洞这些进阶方案。线上问题排查这部分我认为是本文最有价值的地方。真实项目里的网络故障往往不是你代码语法错了而是一些层面的问题。我挑选几个高频问题配上排查思路做成速查表平时遇到直接对照着查现象可能原因排查思路与解决方向客户端连接服务端超时服务端未启动、防火墙拦截、网络上丢包先ping测试网络连通性再用telnet IP 端口测试端口连通性最后查服务端进程是否存活连接报错 Address already in use端口被占用或处于TIME_WAIT无法释放用netstat -ano查端口占用情况确认是否存在大量TIME_WAIT连接服务端可开SO_REUSEADDR客户端能连接但读不到数据服务端未flush、粘包对端处理逻辑不对检查服务端是否调用flush用抓包工具观察数据是否真实到达网卡发送方发消息后接收方收不到UDP未绑定端口、数据包在网络上被丢本机先测127.0.0.1确认再逐步扩展到局域网检查防火墙对UDP端口的限制高并发下服务响应越来越慢线程数超限、连接未关闭导致连接数耗尽用jstack导线程栈重点观察是否有大量线程堆积查服务端打开的socket数是否不断上涨莫名出现大量502/504错误后端连接池被耗尽或上游服务过载检查连接池配置参数结合网络连接数评估是否需要扩容在这些问题中最经典也最容易被忽视的是TIME_WAIT。我之前维护过一个统计服务运行时间越长新连接失败率越高重启后能缓解一阵子过段时间又复发。最后查出来每处理一条数据服务端就主动关闭连接导致大量连接进入TIME_WAIT状态占用的端口和局部连接资源迟迟得不到释放。解决方案是在ServerSocket上设置SO_REUSEADDR让处于TIME_WAIT的连接可以快速被新连接复用地址同时调整内核的tcp_tw_reuse参数双管齐下才把问题压下去。面试官如果问到“线上TIME_WAIT过多怎么处理”你要能说清楚这几个层面才算真正理解这个场景。SO_REUSEADDR这个参数我需要单独拎出来讲。在Java里通过ServerSocket.setReuseAddress(true)设置它的作用是允许处于TIME_WAIT状态的连接与新连接占用同一个端口。很多人搞不清它和SO_REUSEPORT的区别SO_REUSEADDR解决的是端口复用和接盘问题SO_REUSEPORT则允许完全并行的多个socket绑定同一端口做负载均衡后者Java原生支持得不好一般靠Nginx、Envoy这类组件下沉处理。关于连接的其他排查我还想聊一个细节Socket的close()行为。在TCP编程中调close()并不意味着立刻物理断开连接它只是通知对端“我方已发送完数据并关闭输出方向”对端仍然可以继续发送数据。这种半关闭状态通过Socket.shutdownOutput()可以显式触发在自定义协议里大有用处比如客户端发完请求后主动shutdownOutput()服务端读请求时就能确定到达流式消息的结尾从而安全结束读取。我在设计一个简单的HTTP响应式客户端时就用过这个技巧避免客户端等待服务端响应时出现死锁。面试中网络编程相关的高频题其实都可以从这篇文章里找到答案的雏形我这里把八股文部分做个索引式总结方便突击复习三次握手为什么不是两次防止已失效的连接请求突然又传到服务端造成资源浪费同时确认双方收发能力都正常。 四次挥手为什么比握手多一次因为数据发送完毕后两个方向的关闭是独立进行的服务端要先发送完剩余数据才能响应FIN。 TCP如何保证可靠传输分片序号编码、确认应答、超时重传、滑动窗口、流量控制、拥塞控制叙述时最好一条条展开。 TCP和UDP的区别连接性、可靠性、传输方式、速度、头部开销、数据边界这六个维度对比说清楚就能加分。 SYN Flood是什么攻击者发送大量SYN但不完成握手导致服务端半连接队列被占满无法服务正常用户。应对方式包括SYN Cookie、限制半连接数、缩短SYN超时时间。 TCP粘包产生的原因和处理方案原因说清楚TCP流式传输无边界即可方案上提长度字段、分隔符、定长消息三种。有些面经喜欢顺带问一下IPv6和IPv4的区别顺带展开一次IPv6地址长度128位解决了IP地址枯竭问题去掉了校验和字段、路由器不再分片等功能。国内运营商网络已经全面支持IPv6云服务器默认也有IPv6地址Java的ServerSocket、DatagramSocket默认绑定的通配地址可以兼容双栈但自己写地址判断时要注意InetAddress返回的可能是IPv6格式导致字符串比较出现偏差。我实际踩过这个坑写一个白名单过滤器用getHostAddress()取到的IP格式是0:0:0:0:0:0:0:1而不是预期中的127.0.0.1排查了半小时才发现本机回环地址在IPv6环境下有另一种表现形态。判断本机回环时最好同时判断IPv4的127.0.0.1和IPv6的::1或者直接调InetAddress.isLoopbackAddress()方法。最后我分享一点个人心得。学习Java网络编程时很多人陷入一个误区手里拿着BIO的代码示例心里想着Netty的亿级并发代码跑通了就觉得完事了但其实连底层的数据流动都没看见。我的建议是首个项目代码跑通之后一定要用抓包工具比如Wireshark抓一次本机的回环流量亲眼看看三次握手的SYN、SYN-ACK、ACK如何按序出现看看HTTP请求报文在TCP流里是怎么切分成多个段的。这一步完成后你对网络协议在操作系统层面的运行方式会有重新认识。我当年做完这个练习回来再读NIO和Netty的文档困扰很久的概念瞬间就通了。另外一个有价值的实践是给自己留一个“协议调试工具箱”存储网络调试常用命令和排查结论。从最早记录netstat、tcpdump、lsof这些命令到后来把线上问题复盘都整理进去现在遇到新问题先翻自己的笔记大部分都能找到相似场景。这个习惯比收藏一堆文档要有用得多。工具可以用系统自带的下沉功能也可以用现成的网络调试助手重点是你要理解每一层在做什么而不是被工具界面牵着走。如果后续想深入建议按这个顺序发展先把TCP状态转换图牢牢记住再研究TCP的拥塞控制和滑动窗口细节然后切换到Java NIO的Reactor模型最后读Netty源码。其中每一个阶段我都会坚持做一件小事用抓包工具观察那些状态是怎么迁移的、数据是怎么流动的。这比反复看文档更让人记忆深刻。这条学习路径走下去Java网络编程对你来说就不再是背出来的面试题而是真正能解决线上问题的一把钥匙。
返回列表