ARTICLE DETAIL

资讯详情

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

Java网络编程核心要点:从TCP三次握手到Socket实战避坑指南

Java网络编程核心要点:从TCP三次握手到Socket实战避坑指南 Java网络编程这块说难不难说简单也真不简单。我在这个方向摸爬滚打好几年从最初被Socket连接搞得焦头烂额到现在能顺手处理各种连接异常和协议细节中间踩过的坑实在太多了。今天这篇笔记就把我从网络编程三要素到TCP/UDP协议核心内容梳理一遍该讲透的原理一个不落该避开的坑也尽量都指出来。无论你是准备面试的Java开发还是刚接触Socket编程的学生党这篇文章应该能省下你不少查资料的功夫。1. 网络编程三要素IP、端口、协议很多人一上来就写Socket代码结果连IP是干什么的、端口为什么有范围限制都没搞清楚。网络编程三要素——IP地址、端口号、通信协议——这三个概念是整个网络通信的地基必须在写代码之前理解透。1.1 IP地址网络中的门牌号IP地址负责定位设备类比起来就是快递的收货地址。Java里主要接触IPv4和IPv6两类IPv4是32位整数平时见到的192.168.1.100就是它的点分十进制表示IPv6由于地址空间更大用128位表示Java网络库对两者都有支持但实际开发中IPv4仍是主流。写代码时你会经常碰到InetAddress这个类比如InetAddress address InetAddress.getByName(www.example.com); System.out.println(address.getHostAddress());这里有个典型的坑getByName会发起DNS解析如果网络不通或域名不存在它会抛UnknownHostException。我早年写过定时任务每次执行都去解析域名结果某天DNS抖动任务大面积失败。后来改成启动时缓存InetAddress对象问题就解决了。另外回环地址127.0.0.1和本机局域网IP不是一回事。客户端连127.0.0.1只表示连自己这台机器连192.168.x.x才表示走局域网网卡。你要区分应用场景本机调试用回环跨机通信必须用对端可达的IP。1.2 端口号同一台设备上的进程标识一台服务器可以同时运行Web服务、数据库、缓存服务它们怎么区分靠端口号。端口号范围是0到65535其中0到1023为知名端口如HTTP是80HTTPS是443MySQL是3306通常需要管理员权限才能绑定。Java里两个核心对象和端口强相关ServerSocket(int port)服务端监听指定端口。Socket(String host, int port)客户端连接目标端口。开发时最大的痛点是端口被占用。常见报错是java.net.BindException: Address already in use: JVM_Bind这个异常的根源在于端口处于TIME_WAIT状态或已被其他进程占用。排查步骤我建议用系统命令查Windowsnetstat -ano | findstr 8080Linux/macOSlsof -i :8080或netstat -anp | grep 8080定位到PID后再看是哪个进程占用确实没用就kill掉。很多新手一看端口冲突就换端口其实先查占用进程才是正解——很多服务凭端口号通信你乱换端口会导致客户端连不上。1.3 协议通信双方都懂的语言规则有地址、有端口双方还得说同一种语言这个“语言”就是协议。TCP和UDP是传输层最核心的两个协议也是Java网络编程绕不过去的主角。日常开发中95%以上的网络通信场景HTTP、数据库连接、消息队列底层走的是TCP因为它可靠、有序、面向连接。但也不是所有场景都适合TCP比如游戏帧同步、实时音视频流丢一帧等重传不如直接跳过这时候UDP的低延迟优势就体现出来了。理解三要素之间的协作关系再看后面的代码就不会懵IP找到机器端口找到进程协议规定数据怎么说话。2. TCP协议核心机制三次握手与四次挥手TCP是面向连接的可靠传输协议它的可靠性不是白来的靠的是一套复杂的机制。面试中最常问、工作中最容易出问题的也是这套机制。2.1 三次握手建立连接的过程三次握手解决的核心问题是确认通信双方都有收发能力并同步初始序号。完整流程是这样的客户端发送SYN1, seqx给服务端进入SYN_SENT状态。服务端收到后回复SYN1, ACK1, seqy, ackx1进入SYN_RCVD状态。客户端收到后发送ACK1, acky1双方进入ESTABLISHED状态。为什么必须是三次而不是两次这里牵扯到一个经典问题。如果只有两次握手服务端无法确认客户端是否收到了自己的SYN响应。设想一个场景客户端发的包在网络中迷路重传后连接建立但那个迷路的包又到了服务端服务端就会为一条“幽灵连接”白白分配资源。三次握手通过客户端的最终ACK确保服务端知道“客户端确实收到我的回应了”。我写网络框架时习惯用tcpdump观察握手过程比如tcpdump -i any port 8080 -nn能看到清晰的SYN、SYN-ACK、ACK三个包的流转。如果你抓包只看到前两个包没有来自客户端的ACK那基本可以断定客户端在收到响应前就出了岔子——要么被防火墙拦截要么客户端在connect超时前就放弃连接了。2.2 四次挥手断开连接的过程说完了建立连接断开连接同样有讲究。TCP的连接是全双工的双方可以同时收发。因此断开连接时需要双方各自关闭自己的发送方向这就是四次挥手的由来主动方发送FIN1, sequ进入FIN_WAIT_1。被动方回复ACK1, acku1进入CLOSE_WAIT主动方进入FIN_WAIT_2。被动方处理完剩余数据发送FIN1, seqw进入LAST_ACK。主动方发送ACK1, ackw1进入TIME_WAIT等待2MSL后关闭。为什么要TIME_WAIT等2MSL报文最长生存时间两个原因一是确保最后一个ACK能到达对端万一丢了对端会重发FIN二是让迷路的老报文自然消失避免影响下一个相同四元组的连接。这些问题不光是面试八股文工作中绝对会遇到。最典型的是服务重启时端口被占用经常就是TIME_WAIT端口堆积。系统默认TIME_WAIT要等2MSL通常60秒左右快速重启服务就会报Address already in use。2.3 TCP如何保证可靠传输TCP的可靠体现在几个机制上序号与确认应答每个字节都有序号接收方确认收到的序号后才能继续发后续数据。超时重传发送方若在超时时间内未收到ACK就重传数据。流量控制通过滑动窗口调节发送速率防止接收方被数据淹没。拥塞控制网络拥塞时主动降低发送速率避免加剧拥塞。Java应用层面你感知不到这些机制的细节但你能感知到它们的副作用延迟高、偶尔超时、长连接卡顿。调试TCP性能问题我强烈建议先把Nagle算法的影响排除掉——它会把小包合并后发送交互性强但包体小的应用如即时通信会感觉响应变慢。Java里可以通过setTcpNoDelay(true)关闭它Socket socket new Socket(); socket.setTcpNoDelay(true);2.4 粘包与拆包TCP字节流带来的大头问题很多人把TCP当成了“消息协议”这是大误区。TCP是流式协议它没有消息边界只保证字节顺序。你send两次数据对端可能一次性收到合并后的内容也可能分多次收到拆开的内容——这就是粘包和拆包问题。解决思路通常有四类固定长度每条消息定长不足则补位实现简单但浪费带宽。分隔符比如按换行符\n切分适合文本协议。长度字段消息头写长度后面跟消息体Netty的LengthFieldBasedFrameDecoder就是干这个的。自定义协议采用类型长度数据的TLV格式最灵活通用性强。写原生Socket时我习惯用“长度字节数组”的方式发送端先把长度用DataOutputStream.writeInt()写入再写内容接收端先读4字节长度再按长度读完整内容。下面这段代码是我常用的发送端写法ByteArrayOutputStream baos new ByteArrayOutputStream(); DataOutputStream dos new DataOutputStream(baos); dos.writeInt(data.length); dos.write(data); byte[] frame baos.toByteArray(); socket.getOutputStream().write(frame);接收端则先读4字节再读body。如果处理不好边界就会出现消息读一半、下一条多读半个包这类恶心问题。这部分在调网络程序时特别容易踩后面专门再讲排查。3. UDP协议核心机制无连接的高效传输和TCP的可靠不同UDP走的完全是另一条路线不建立连接、不确认、不重传、不保证顺序。它的口号就是“尽力而为丢了就丢了”。3.1 UDP协议的特点UDP报头只有8个字节源端口、目的端口、长度、校验和而TCP报头至少要20字节。头部开销小、面向数据报、有边界——这几个特点让UDP天然适合两类场景延迟敏感型视频会议、网络直播、在线游戏。一个画面或者一帧声音丢了立刻重传反而导致全盘卡顿。查询响应型DNS查询就是经典UDP应用发一个请求等一个响应丢包了就客户端自己超时重试。Java里UDP通信的核心类只有两个DatagramSocket和DatagramPacket。一次UDP写的长度不宜过大超过MTU通常1500字节左右就会被IP层分片分片后只要一片丢失整个数据报就丢掉。所以UDP发送大数据的策略要先做应用层分包每包控制在1200字节左右比较稳妥。3.2 TCP与UDP的对比对比维度TCPUDP连接状态面向连接需三次握手无连接直接发包可靠性可靠有确认重传不可靠不确认不重传有序性保证字节顺序不保证顺序传输模式字节流无边界数据报有边界首部大小20字节以上8字节速度慢开销大快开销小适用场景HTTP、数据库、文件传输音视频、DNS、实时游戏一句话总结追求可靠选TCP追求低延迟可容忍丢包选UDP。3.3 用Java实现UDP通信UDP的Java实现比TCP简单得多。发送端如下try (DatagramSocket socket new DatagramSocket()) { byte[] data hello udp.getBytes(StandardCharsets.UTF_8); InetAddress address InetAddress.getByName(127.0.0.1); DatagramPacket packet new DatagramPacket(data, data.length, address, 9090); socket.send(packet); }接收端如下try (DatagramSocket socket new DatagramSocket(9090)) { byte[] buffer new byte[2048]; DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); String message new String(packet.getData(), 0, packet.getLength(), StandardCharsets.UTF_8); System.out.println(收到: message); }注意new String时用packet.getLength()而不是buffer.length——这是最容易踩的坑。如果用了后者会出现一堆ASCII码0的乱码因为receive不会自动清空缓冲区。DatagramSocket默认是阻塞的receive()会一直等着。若要设置超时使用setSoTimeout(3000)配合捕获SocketTimeoutException处理超时。还有一点UDP不存在真正意义上的“服务端和客户端”之分两端都是平等的socket只是谁先监听谁先发而已。4. Java网络编程的实操要点Socket API与代码实战理论滚了一圈该上手写点实际能跑的东西了。这里我会给出一个完整的TCP服务端客户端通信示例并拆解Java网络API的关键细节。4.1 TCP服务端与客户端的标准写法服务端代码import java.io.*; import java.net.*; public class TcpServer { public static void main(String[] args) throws IOException { int port 8080; try (ServerSocket serverSocket new ServerSocket(port)) { System.out.println(服务端已启动监听端口: port); while (true) { Socket socket serverSocket.accept(); // 每个连接一个线程处理生产环境建议用线程池 new Thread(() - handleClient(socket)).start(); } } } private static void handleClient(Socket socket) { try (BufferedReader in new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) { String line; while ((line in.readLine()) ! null) { System.out.println(收到客户端消息: line); out.println(服务端响应: line); } } catch (IOException e) { e.printStackTrace(); } } }客户端代码import java.io.*; import java.net.*; public class TcpClient { public static void main(String[] args) throws IOException { try (Socket socket new Socket()) { socket.connect(new InetSocketAddress(127.0.0.1, 8080), 5000); BufferedReader in new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); out.println(你好服务端); System.out.println(in.readLine()); } } }这段代码有几个细节值得注意第一handleClient里用了独立的线程处理每个连接。单线程循环里直接调用readLine是阻塞的一个客户端连上来不发数据服务端就卡在那后面的客户端全部进不来。生产环境千万别这么干。第二客户端我用了Socket()无参构造加connect(addr, timeout)显式连接而不是new Socket(host, port)。区别在于后者无法控制超时时间一旦目标不可达可能要等操作系统默认的两三分钟才报错。设个5秒超时体验完全不同。第三PrintWriter构造中的true参数是自动刷盘确保println后数据立即写出去。网络编程中忘记flush导致服务端等不到数据的情况我遇到不知多少次了。4.2 线程模型选型从new Thread到线程池上面示例代码用new Thread演示原理可以生产环境就是灾难。高并发下连接数百上千线程数暴涨直接导致内存耗尽。更好的方案是线程池配合有界队列ExecutorService executor Executors.newFixedThreadPool(16); while (true) { Socket socket serverSocket.accept(); executor.submit(() - handleClient(socket)); }如果连接量再大、单连接需要维持长连接建议直接上NIO框架。Java自带的NIO编程复杂度太高项目里一般直接选Netty。Netty的EventLoop线程模型、零拷贝、编解码框架都是久经验证的比自己用Selector硬写容易得多。4.3 Socket选项调优的小细节Socket提供了一些选项用好了能解决很多隐患setTcpNoDelay(true)关闭Nagle算法降低小包延迟。setSoTimeout(ms)设置读取超时避免线程无限阻塞。setKeepAlive(true)TCP层的心跳检测检测对端是否存活默认两小时一次间隔较长。setReuseAddress(true)重启服务时允许端口复用缓解TIME_WAIT导致的绑定失败。setReuseAddress(true)这个要重点提一下。服务端程序做不停机发布时旧进程还占着端口新进程启动就会BindException。设置这个选项后在部分操作系统上能减少这类问题但治标不治本发布策略上建议用优雅停机先停止接收新连接等旧连接处理完再退出。5. 常见问题与排查技巧实录接下来这部分是我实际干活时总结出来的经验账本。网络编程很多坑表面上看着像代码问题其实都是底层机制导致的。5.1 “Address already in use”该怎么排查当服务端启动报这个错先分清是哪种情况端口确实被其他进程占用。旧进程已退出但端口还处于TIME_WAIT状态。同一进程重复绑定了同一个端口。排查命令Linux如下netstat -tlnp | grep 8080 lsof -i :8080如果大量TIME_WAIT堆积可以检查系统参数net.ipv4.tcp_tw_reuse是否开启。不过这个参数要配合tcp_timestamps一起用才生效不一定所有场景都适合开启生产环境调整系统内核参数前一定要评估业务形态防止副作用。如果只是开发调试最快的办法是换一个端口或者等一两分钟再启动。但不推荐用硬改内核参数的方式来绕过问题正确做法是让服务优雅退出确保连接能正常走完四次挥手。5.2 “Connection reset”与“Broken pipe”的区别这两个异常是TCP编程里最脸熟的Connection reset通信过程中对端机器异常断网、崩溃或主动关闭了连接本端收到一个RST包。Broken pipe本端往已关闭的连接里写数据时操作系统抛出的异常。它们的本质都是对端连接已经不存在了而你还在读写。我见过最多的问题是服务端里某个客户端断开但服务端程序没从Socket流中读到EOF即read()返回-1的时机就可能误操作。处理思路是服务端read()返回-1就表示对端已关闭输出应关闭连接并清理资源。Java的BufferedReader在外面包一层while ((line in.readLine()) ! null)为的就是准确感知EOF不要靠捕获异常来检测连接断开。5.3 粘包拆包的真实场景与解决我在一个物联网项目中处理过比较典型的粘包问题。设备每次上报的报文长度不固定有的十几字节有的几百字节。直接按输入流read读一会儿并包一会儿拆包解析出来的数据惨不忍睹。当时的解决方案就是长度帧。设备端和服务器约定了统一的二进制协议前2字节是报文长度大端序后面是报文体。服务端用DataInputStream先readUnsignedShort()拿到长度再readFully(body)读完整包从此数据再也没乱过。DataInputStream dis new DataInputStream(socket.getInputStream()); while (true) { int len dis.readUnsignedShort(); // 阻塞直到读满2字节 byte[] body new byte[len]; dis.readFully(body); // 阻塞直到读满len字节 handlePacket(body); }这里有个进阶技巧readFully本质是循环读直到装满保证不会被半包坑到。但注意它可能阻塞很久配套setSoTimeout才有保障。5.4 UDP丢包与对方的简单排查UDP开发中屡见不鲜的问题客户端发了数据服务端收到零字节或没反应。排查步骤如下确认服务端监听的端口是否正确用netstat -ulnp | grep 端口号查看UDP监听。确认客户端发包目标IP和端口正确。UDP没连接发错地方没人提醒。用抓包工具确认包是否发出、是否到达。tcpdump -i any udp port 9090一目了然。注意防火墙策略iptables或系统安全组可能丢弃UDP包。我在一个局域网联调场景里服务端在Windows客户端在Linux包就是过不去。最后发现是Windows防火墙默认拦了UDP入站。加了一条允许规则后包就到了。这种基础环境问题排查起来最花时间建议大家先做环境验证再怀疑代码。6. 面试高频题与学习路线建议聊到Java网络编程自然绕不开面试。网上流传的“Java面试八股文”里网络这块几乎必考而且越来越喜欢问底层原理和边界情况。6.1 面试题中的网络编程考点这里我筛选几个出现频率最高的简单展开为什么TCP连接需要三次握手前文已分析关键在于确认双方的收发能力并同步初始序号防止历史重复连接干扰新连接。为什么TIME_WAIT要等2MSL一是保证最后的ACK能送达二来让旧连接的报文在网络中自然消亡。TCP和UDP的区别从连接、可靠、有序、开销、场景五个维度回答这是送分题。TCP粘包怎么解决回答三种方案定长、分隔符、长度字段。能讲清楚角度和取舍就算答出深度。如果UDP丢包严重怎么办答案分两层底层优化控制包大小、避免分片、调整缓冲区、确认网络质量应用层解决增加校验、序号、超时重传机制。Java的NIO和BIO区别BIO阻塞、每连接一线程NIO通过选择器支持单线程管理多连接。再追问就用Netty举例。6.2 学习路线建议第一步吃透三要素和TCP/UDP原理。把本文前面几节彻底搞明白阅读《TCP/IP详解 卷1》的TCP部分重点是三次握手、四次挥手、可靠传输与拥塞控制。第二步动手写原生Socket代码。至少完成以下练习TCP双向通信、TCP文件传输、UDP聊天室、逐包限长发送。不要急着用框架原生Socket能让你对底层有切肤之感。第三步接触NIO与Netty。理解Channel、Buffer、Selector三者关系然后去搞Netty的官方文档和源码解读重点看它的编解码器和拆包方案。第四步结合框架验证。实际项目里连接数据库、调HTTP接口、用消息队列都是网络编程的经典场景。这时候你已经能在应用层分析网络问题了。我个人实际操作中最深的感受是网络编程的功底不是靠背八股文堆出来的是排错排出来的。每踩一次Connection reset每抓一次包你对TCP/IP的理解都会加深一层。这套笔记覆盖的从三要素到TCP/UDP的整个链路按这个路径扎实走下去面Java网络编程的岗位基本不会慌。
返回列表