☰
Java UDP编程实战:从DatagramSocket到可靠传输与NIO
2026/10/16 4:27:58 网站建设 项目流程

1. 为什么UDP在Java网络编程里总被当成"配角"

很多人学Java网络编程,第一个接触的是TCP的ServerSocket和Socket,因为TCP可靠、有连接、能保证顺序,写起来也符合"打电话"的直觉。等到接触UDP的时候,往往就是一句"UDP不可靠、会丢包"带过,然后扔一个DatagramSocket和DatagramPacket的示例就结束了。这种学法导致一个很尴尬的局面:真到了需要UDP的场景,比如局域网设备发现、实时音视频传输、游戏状态同步、日志采集,脑子里完全没有可用的代码骨架,只能临时去搜。

我自己的体会是,UDP不是TCP的"残次品",它是另一种设计哲学。TCP把可靠性、顺序、重传、拥塞控制全包了,代价是延迟不可控、握手开销大、连接状态占用资源。UDP把这些全部交还给应用层,内核只负责把数据报扔出去或者收进来,剩下的你自己决定。这个"自己决定"就是UDP真正的价值所在,也是它难学的地方——因为你要自己设计一套适合业务的最小可靠机制。

这篇内容我打算把Java里UDP编程从最基础的收发,到实际项目中会遇到的坑,再到怎么在UDP之上做一层轻量的可靠性,完整地捋一遍。适合已经会写Java、但对网络编程只停留在TCP层面的开发者,也适合想搞清楚"UDP到底能干什么、不能干什么"的读者。所有代码都是可以直接跑的最小示例,我会把每个参数为什么这么设讲清楚,而不是只贴一段能跑的代码。

先明确一个前提:Java的UDP API非常薄,核心就两个类——DatagramSocket(负责收发)和DatagramPacket(负责承载数据)。没有连接、没有流、没有accept。理解这一点,后面所有内容都好展开。

2. DatagramSocket与DatagramPacket的最小可用骨架

2.1 发送端:一个数据报是怎么被"扔"出去的

先看发送端。UDP发送的核心动作就三步:建socket、装包、send。但每一步都有细节。

import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; public class UdpSender { public static void main(String[] args) throws Exception { // 1. 创建发送用的socket,不指定端口,系统随机分配一个本地端口 DatagramSocket socket = new DatagramSocket(); String msg = "hello udp"; byte[] data = msg.getBytes("UTF-8"); // 2. 目标地址和端口 InetAddress target = InetAddress.getByName("127.0.0.1"); int targetPort = 9999; // 3. 装包:数据、长度、目标地址、目标端口 DatagramPacket packet = new DatagramPacket(data, data.length, target, targetPort); // 4. 发送 socket.send(packet); socket.close(); } }

这里有几个点值得说。第一,new DatagramSocket()不传参数时,系统会随机分配一个本地端口,这个端口在send之后其实没什么用,因为UDP是无连接的,发送方不需要"保持"什么。第二,DatagramPacket的构造函数里,data.length是实际要发送的字节数,不是数组容量。如果你传了一个1024大小的数组但只填了10个字节,却传data.length,那就会把后面1024减10个字节的垃圾数据一起发出去。这个坑我见过不止一次,尤其是从缓冲区复用的代码里改过来的时候。

第三,getBytes("UTF-8")显式指定编码。UDP传输的是字节,不是字符。发送端和接收端的编码必须一致,否则中文会乱码。默认平台编码在不同系统上不一样,显式指定是最稳的做法。

2.2 接收端:为什么缓冲区大小是个关键决策

接收端比发送端多一个"等"的动作。

import java.net.DatagramPacket; import java.net.DatagramSocket; public class UdpReceiver { public static void main(String[] args) throws Exception { // 1. 绑定到指定端口,接收方必须固定端口,否则发送方不知道往哪发 DatagramSocket socket = new DatagramSocket(9999); // 2. 准备缓冲区 byte[] buf = new byte[1024]; DatagramPacket packet = new DatagramPacket(buf, buf.length); System.out.println("listening on 9999..."); // 3. 阻塞等待,直到收到一个数据报 socket.receive(packet); // 4. 从packet里取实际收到的数据 String msg = new String(packet.getData(), 0, packet.getLength(), "UTF-8"); System.out.println("received: " + msg); System.out.println("from: " + packet.getAddress() + ":" + packet.getPort()); socket.close(); } }

receive是阻塞的,线程会停在这里直到有数据报到达。packet.getLength()返回的是实际收到的字节数,不是缓冲区大小。这一点和发送端对应——发送端传的是实际长度,接收端取的也是实际长度。

缓冲区大小怎么定?这是UDP编程里第一个需要认真想的问题。UDP数据报的理论最大长度受IP层限制,大约是65507字节(65535减去IP头和UDP头)。但实际上,超过MTU(常见1500字节,减去IP和UDP头后约1472字节)的数据报会在IP层被分片,任何一个分片丢失,整个数据报就废了。所以实践中,如果你不想处理分片带来的丢包放大问题,单包数据最好控制在1472字节以内。缓冲区设成2048或4096是比较常见的做法,留出余量。

注意:如果发送的数据报比接收缓冲区大,多出来的部分会被直接丢弃,而且不会有任何异常。getLength()只会返回缓冲区能装下的那部分。这是个静默失败,排查起来很烦。

2.3 一个能双向通信的最小闭环

把上面两个合起来,做一个发送端发消息、接收端回消息的闭环,能更直观地理解UDP的"无连接"到底意味着什么。

// 接收端:收到消息后,往来源地址回一条 socket.receive(packet); String msg = new String(packet.getData(), 0, packet.getLength(), "UTF-8"); System.out.println("server got: " + msg); String reply = "ack:" + msg; byte[] replyData = reply.getBytes("UTF-8"); DatagramPacket replyPacket = new DatagramPacket( replyData, replyData.length, packet.getAddress(), packet.getPort()); socket.send(replyPacket);

注意packet.getAddress()和packet.getPort()——接收到的包自带来源地址和端口,直接拿来当回复目标就行。这就是UDP"无连接"的体现:服务端不需要维护任何连接状态,每个包自带来源信息,回的时候照着填。这也意味着服务端可以同时处理成千上万个客户端,因为根本没有"连接"这个对象需要维护。

3. 单播、广播、组播:三种发送模式的代码差异

3.1 单播:最常用也最容易被误解的模式

单播就是一对一,上面写的所有代码都是单播。但有个细节很多人没注意:UDP的单播发送方,如果目标端口没有程序在监听,会发生什么?

答案是:发送方完全不知道。send方法会正常返回,不会抛异常。因为UDP没有握手,发送方把包交给内核,内核把包发出去,至于对方收没收到、端口通不通,发送方一无所知。如果对方主机在线但端口没开,对方内核会回一个ICMP端口不可达消息,但这个ICMP消息不一定能传回发送方,而且Java层面也拿不到。所以UDP的"发送成功"只代表"本机内核接受了这个包",不代表任何远端状态。

这个特性决定了UDP编程的一个基本原则:不要依赖发送成功来判断对方是否收到,要靠应用层的应答机制。后面讲可靠性的时候会展开。

3.2 广播:往整个网段喊话的正确姿势

广播是把包发给同一网段内所有主机。Java里用DatagramSocket的setBroadcast(true)开启广播权限,然后往广播地址发送。

DatagramSocket socket = new DatagramSocket(); socket.setBroadcast(true); // 255.255.255.255 是受限广播地址,只在本地网段有效 InetAddress broadcastAddr = InetAddress.getByName("255.255.255.255"); byte[] data = "who is there".getBytes("UTF-8"); DatagramPacket packet = new DatagramPacket(data, data.length, broadcastAddr, 8888); socket.send(packet);

广播地址有两种:受限广播地址255.255.255.255和子网定向广播地址(比如192.168.1.255)。前者不经过路由器,只在本地网段传播;后者理论上可以跨网段,但实际中路由器通常默认关闭定向广播转发。做局域网设备发现的时候,用255.255.255.255就够了。

广播的坑在于:不是所有环境都允许广播。有些云服务器、容器网络、企业网络会屏蔽广播包。我遇到过在本地开发环境跑得好好的广播发现逻辑,一部署到某类云主机上就完全收不到,排查半天才发现是网络环境不支持。所以广播方案一定要有降级预案,比如配置静态地址列表。

3.3 组播:比广播更可控的一对多

组播是"发给一组感兴趣的主机",比广播精准。加入组播组用MulticastSocket。

// 接收端:加入组播组 MulticastSocket socket = new MulticastSocket(8888); InetAddress group = InetAddress.getByName("230.0.0.1"); socket.joinGroup(group); byte[] buf = new byte[1024]; DatagramPacket packet = new DatagramPacket(buf, buf.length); socket.receive(packet); System.out.println("got: " + new String(packet.getData(), 0, packet.getLength(), "UTF-8")); socket.leaveGroup(group); socket.close();
// 发送端:往组播地址发 MulticastSocket socket = new MulticastSocket(); InetAddress group = InetAddress.getByName("230.0.0.1"); byte[] data = "multicast msg".getBytes("UTF-8"); DatagramPacket packet = new DatagramPacket(data, data.length, group, 8888); socket.send(packet); socket.close();

组播地址范围是224.0.0.0到239.255.255.255。其中224.0.0.0到224.0.0.255是本地链路组播,不会被路由器转发,适合局域网内使用。230.0.0.1这类是普通组播地址。

组播相比广播的优势:只有加入了组的主机才会收到包,不会打扰网段内其他主机;而且组播在支持的网络设备上可以被更高效地转发。但组播同样依赖网络设备支持,跨网段组播需要路由器配置组播路由协议,这个在普通开发环境里基本不用考虑。

三种模式的对比:

模式目标地址示例适用场景网络依赖
单播一台主机具体IP点对点通信无特殊要求
广播整个网段255.255.255.255局域网设备发现需网络允许广播
组播一组主机230.0.0.1集群内消息分发需网络支持组播

4. 那些文档里不写、但一定会踩的坑

4.1 数据报边界:UDP不是流,别用TCP的思维读

TCP是字节流,你发三次,对方可能一次读到全部,也可能分十次读到。UDP不是,UDP保留消息边界:你发一个包,对方receive一次就收到这一个包,不会多也不会少(除非丢包)。这个特性叫"数据报边界保留"。

听起来是好事,但坑在于:如果你用TCP的思维写UDP接收循环,比如想"读满1024字节再处理",就会出问题。UDP的receive每次只返回一个完整的数据报,你不能控制它返回多少。正确的做法是每次receive都当成一个独立的消息处理。

反过来,发送端也要注意:不要把一个逻辑消息拆成多个包发,除非你的应用层协议能处理重组。因为UDP不保证顺序,拆包之后可能乱序到达,重组逻辑得自己写。

4.2 缓冲区复用导致的"脏数据"问题

为了减少对象创建,很多人会复用DatagramPacket和缓冲区:

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(), "UTF-8"); // 处理msg }

这段代码本身没问题,因为每次都用了getLength()。但如果你写成new String(packet.getData(), "UTF-8"),就会把整个1024字节缓冲区都转成字符串,包括上一次残留的数据。如果这次收到的包比上次短,后面就会带出上次的尾巴。这个bug很隐蔽,因为短包和长包交替出现的时候才会暴露。

提示:永远用packet.getLength()来界定有效数据范围,不要用buf.length或packet.getData().length。

4.3 端口占用与SO_REUSEADDR

UDP的端口绑定有个和TCP不一样的地方。TCP的TIME_WAIT状态会导致端口暂时无法重用,UDP没有连接状态,理论上关闭后端口立即可用。但如果你在同一个端口上快速重启服务,偶尔还是会遇到BindException: Address already in use。

原因是UDP socket关闭后,如果还有数据报在途,内核可能还会保留一段时间。解决办法是设置SO_REUSEADDR:

DatagramSocket socket = new DatagramSocket(null); socket.setReuseAddress(true); socket.bind(new InetSocketAddress(9999));

注意这里先用new DatagramSocket(null)创建一个未绑定的socket,设置选项后再bind。如果直接用new DatagramSocket(9999),绑定已经发生了,再设setReuseAddress就晚了。

4.4 发送频率过高导致的本地丢包

UDP发送太快的时候,丢包不一定发生在网络上,可能发生在本地。内核的socket发送缓冲区满了之后,send调用会阻塞(阻塞模式下)或者丢包(非阻塞模式下)。接收端也一样,如果应用层处理速度跟不上,内核接收缓冲区满了,后续到达的包会被直接丢弃。

这个现象在压测的时候特别明显:发送端疯狂send,接收端处理慢,然后发现丢包率很高,以为是网络问题,其实是接收端缓冲区溢出。解决办法有两个:一是增大socket缓冲区(setReceiveBufferSize),二是让接收端处理逻辑尽量轻量,或者用单独的线程池处理业务逻辑,接收线程只负责收包。

socket.setReceiveBufferSize(1024 * 1024); // 1MB socket.setSendBufferSize(1024 * 1024);

注意这两个设置要在绑定之前调用才有效,和setReuseAddress一样。

5. 在UDP之上搭一层够用的可靠性

5.1 什么时候需要自己造可靠性

UDP不可靠,但"不可靠"不等于"不能用"。要不要在UDP上做可靠性,取决于业务能容忍多少丢包。

  • 实时音视频:丢几帧无所谓,重传反而增加延迟,不做重传。
  • 游戏状态同步:位置信息丢了就丢了,下一帧会覆盖,不做重传。
  • 指令下发、配置同步、文件传输:丢一个包就可能导致状态不一致,必须做可靠性。

所以可靠性不是"要不要做"的问题,而是"做到什么程度"的问题。下面给一套最小可用的机制:序号加确认加重传。

5.2 序号、确认与超时重传的最小实现

思路很简单:每个包带一个自增序号,接收端收到后回一个ACK,发送端发出去之后启动定时器,超时没收到ACK就重发。

先定义包格式。为了简单,用文本协议,头部用固定分隔符:

SEQ|TYPE|PAYLOAD

SEQ是序号,TYPE是DATA或ACK,PAYLOAD是实际数据。

发送端核心逻辑:

public class ReliableSender { private final DatagramSocket socket; private final InetAddress target; private final int targetPort; private int seq = 0; private static final int TIMEOUT_MS = 500; private static final int MAX_RETRY = 3; public ReliableSender(DatagramSocket socket, InetAddress target, int targetPort) { this.socket = socket; this.target = target; this.targetPort = targetPort; } public boolean sendWithAck(String payload) throws Exception { int currentSeq = seq++; String msg = currentSeq + "|DATA|" + payload; byte[] data = msg.getBytes("UTF-8"); for (int attempt = 0; attempt < MAX_RETRY; attempt++) { DatagramPacket packet = new DatagramPacket(data, data.length, target, targetPort); socket.send(packet); // 等待ACK socket.setSoTimeout(TIMEOUT_MS); try { byte[] buf = new byte[256]; DatagramPacket ackPacket = new DatagramPacket(buf, buf.length); socket.receive(ackPacket); String ack = new String(ackPacket.getData(), 0, ackPacket.getLength(), "UTF-8"); String[] parts = ack.split("\\|", 3); if (parts.length >= 2 && "ACK".equals(parts[1]) && Integer.parseInt(parts[0]) == currentSeq) { return true; } // 收到不是本次的ACK,继续等 attempt--; } catch (SocketTimeoutException e) { // 超时,重试 } } return false; } }

接收端:

public class ReliableReceiver { public static void main(String[] args) throws Exception { DatagramSocket socket = new DatagramSocket(9999); byte[] buf = new byte[2048]; Set<Integer> received = new HashSet<>(); while (true) { DatagramPacket packet = new DatagramPacket(buf, buf.length); socket.receive(packet); String msg = new String(packet.getData(), 0, packet.getLength(), "UTF-8"); String[] parts = msg.split("\\|", 3); if (parts.length < 3) continue; int seq = Integer.parseInt(parts[0]); String type = parts[1]; if ("DATA".equals(type)) { // 回ACK,不管是否重复都回,防止ACK丢失导致发送端一直重传 String ack = seq + "|ACK|"; byte[] ackData = ack.getBytes("UTF-8"); socket.send(new DatagramPacket(ackData, ackData.length, packet.getAddress(), packet.getPort())); if (received.add(seq)) { System.out.println("deliver: " + parts[2]); } else { System.out.println("duplicate, dropped: " + seq); } } } } }

这套机制能处理三种情况:包丢了(发送端超时重传)、ACK丢了(发送端重传,接收端去重)、包重复到达(接收端用Set去重)。但它不处理乱序——如果业务要求严格顺序,接收端还得加一个序号排序缓冲。

5.3 这套机制的边界在哪里

上面这套东西够用,但别指望它能替代TCP。它的局限很明显:

第一,没有拥塞控制。发送端不管网络状况,超时就重传,网络越差重传越多,可能把网络压得更死。TCP的慢启动、拥塞避免就是解决这个的。

第二,没有流量控制。接收端处理不过来也不会通知发送端减速,只能靠丢包间接体现。

第三,序号会溢出。int类型的序号发到二十多亿会回绕,如果这时候还有老包在途,去重逻辑会误判。实际项目里要么用long,要么在回绕时做特殊处理。

第四,单线程阻塞式收发,吞吐量有限。真要高吞吐,得用NIO的DatagramChannel配合Selector,那是另一个话题了。

所以我的建议是:如果业务对可靠性要求接近TCP,直接用TCP;如果只是需要"比裸UDP稍微可靠一点",上面这套就够了。不要试图用UDP造一个完整的TCP,那是重复造轮子且大概率造不好。

6. 从阻塞到NIO:DatagramChannel的适用场景

6.1 阻塞式DatagramSocket的瓶颈在哪

前面所有代码都是阻塞式的:一个线程receive,收到包处理,再receive。如果处理逻辑耗时,比如要查数据库、要写文件,那接收就会被卡住,后续到达的包在内核缓冲区排队,排满了就丢。

一个常见的优化是接收和处理分离:接收线程只负责receive,收到包就扔进一个队列,业务线程池从队列里取。这样接收线程能一直保持高速收包。这个方案在中等负载下很好用,代码也简单。

但如果连接数或者包速率再上一个量级,线程模型就不够看了。这时候需要DatagramChannel。

6.2 DatagramChannel的非阻塞收发

DatagramChannel是NIO体系里的UDP通道,支持非阻塞模式和Selector多路复用。

import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.DatagramChannel; public class NioUdpReceiver { public static void main(String[] args) throws Exception { DatagramChannel channel = DatagramChannel.open(); channel.bind(new InetSocketAddress(9999)); channel.configureBlocking(false); ByteBuffer buffer = ByteBuffer.allocate(2048); while (true) { buffer.clear(); InetSocketAddress sender = (InetSocketAddress) channel.receive(buffer); if (sender == null) { // 没有数据可读,非阻塞模式下立即返回null Thread.sleep(1); continue; } buffer.flip(); byte[] data = new byte[buffer.remaining()]; buffer.get(data); String msg = new String(data, "UTF-8"); System.out.println("from " + sender + ": " + msg); } } }

非阻塞模式下,receive没有数据时返回null,不会阻塞。上面用Thread.sleep(1)是偷懒写法,实际应该配合Selector:

Selector selector = Selector.open(); channel.register(selector, SelectionKey.OP_READ); while (true) { selector.select(); Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> it = keys.iterator(); while (it.hasNext()) { SelectionKey key = it.next(); it.remove(); if (key.isReadable()) { DatagramChannel ch = (DatagramChannel) key.channel(); ByteBuffer buf = ByteBuffer.allocate(2048); InetSocketAddress sender = (InetSocketAddress) ch.receive(buf); buf.flip(); byte[] data = new byte[buf.remaining()]; buf.get(data); System.out.println("got: " + new String(data, "UTF-8")); } } }

Selector的好处是一个线程可以管理多个channel,适合需要同时监听多个端口的场景。但对于单个UDP端口的场景,Selector带来的收益有限,反而增加了代码复杂度。我的经验是:单端口、中等负载,阻塞式加线程池就够了;多端口或者需要和其他NIO通道统一管理,再上Selector。

6.3 ByteBuffer在UDP场景下的使用要点

DatagramChannel收发用的是ByteBuffer,和DatagramPacket的字节数组不太一样。几个容易搞混的点:

receive之前要clear()或者compact(),把position归零,否则上次读剩的数据会影响这次。receive之后要flip(),把limit设为position、position归零,才能从buffer里读数据。读完数据后,如果还要继续用这个buffer,再clear()。

这套clear、flip的操作是NIO的基本功,但UDP场景下因为每个包都是独立的,很容易忘记在循环开头clear,导致第二个包开始就读不到数据。我建议在循环体第一行就写buffer.clear(),形成肌肉记忆。

7. 实际项目里的UDP选型判断与调试手段

7.1 什么场景该选UDP,什么场景坚决用TCP

选型这件事,我的判断标准是三条:

延迟敏感度。如果业务对延迟的要求是毫秒级,且能容忍少量丢包,选UDP。比如实时对战游戏的位置同步、语音通话的音频帧。TCP的重传机制在丢包时会引入至少一个RTT的延迟,这个在实时场景里是不可接受的。

消息独立性。如果每条消息都是独立的、不依赖前一条,选UDP。比如日志上报、监控指标采集,丢一条就丢一条,不影响后续。反过来,如果是文件传输、数据库同步这种强依赖顺序和完整性的,用TCP。

一对多需求。如果需要广播或组播,UDP是唯一选择,TCP不支持这些模式。

反过来说,如果业务是HTTP接口调用、文件上传下载、数据库连接,这些场景TCP已经做得很好,没有任何理由换成UDP。我见过有人为了"性能"把HTTP接口改成UDP,结果花了大量精力做可靠性,最后性能和TCP差不多,还引入了一堆bug。这是典型的选型错误。

7.2 用tcpdump和Wireshark定位UDP丢包

UDP出问题的时候,第一步是确认包到底有没有发出去、有没有收到。这时候抓包是最直接的手段。

在Linux上,用tcpdump抓UDP包:

tcpdump -i any -n udp port 9999 -w udp.pcap

-i any抓所有网卡,-n不解析域名,-w写入文件。抓完之后用Wireshark打开分析。

判断丢包位置的方法:在发送端抓包,看包有没有被发出去;在接收端抓包,看包有没有到达。如果发送端有、接收端没有,丢在网络中间;如果发送端就没有,问题在应用层或者本地内核。

还有一个更简单的办法:在代码里打日志。发送端每次send前打一条,接收端每次receive后打一条,对比两边的序号就能看出丢了多少、丢在哪一段。这个方法虽然土,但在没有抓包权限的环境里特别管用。

7.3 几个我踩过的真实坑

第一个坑:在容器环境里绑定0.0.0.0和绑定具体IP的区别。有次部署到容器里,服务绑定的是127.0.0.1,结果容器外完全访问不到。改成0.0.0.0或者不指定地址(默认绑定所有网卡)就好了。UDP和TCP在这点上行为一致,但UDP因为没有连接建立过程,排查起来更隐蔽——TCP连不上会立刻报错,UDP就是静默丢包。

第二个坑:MTU导致的诡异丢包。有次传一个比较大的配置包,本地测试没问题,跨机房就丢。后来发现是包大小超过了路径MTU,被分片后某个分片丢了。把包大小控制在1400字节以内就稳定了。这个教训是:UDP包大小不要贴着MTU上限设,留点余量。

第三个坑:多网卡环境下的源地址选择。机器有多块网卡的时候,UDP发送默认走路由表选的那块网卡,源地址可能不是你以为的那个。如果接收端做了源地址校验,就会拒收。解决办法是用DatagramSocket绑定指定的本地地址,强制从某块网卡出去。

DatagramSocket socket = new DatagramSocket(new InetSocketAddress("192.168.1.100", 0));

这样源地址就是192.168.1.100,而不是路由表选的地址。

7.4 一个可复用的UDP工具类骨架

最后给一个我在多个项目里用过的工具类骨架,把常用的收发、超时、缓冲区设置都封装好了。

public class UdpEndpoint implements Closeable { private final DatagramSocket socket; private final byte[] recvBuf; private final DatagramPacket recvPacket; public UdpEndpoint(int port, int bufferSize) throws SocketException { this.socket = new DatagramSocket(null); this.socket.setReuseAddress(true); this.socket.setReceiveBufferSize(bufferSize); this.socket.setSendBufferSize(bufferSize); this.socket.bind(new InetSocketAddress(port)); this.recvBuf = new byte[bufferSize]; this.recvPacket = new DatagramPacket(recvBuf, recvBuf.length); } public void send(String msg, String host, int port) throws IOException { byte[] data = msg.getBytes(StandardCharsets.UTF_8); DatagramPacket packet = new DatagramPacket(data, data.length, InetAddress.getByName(host), port); socket.send(packet); } public String receive(int timeoutMs) throws IOException { socket.setSoTimeout(timeoutMs); socket.receive(recvPacket); return new String(recvPacket.getData(), 0, recvPacket.getLength(), StandardCharsets.UTF_8); } public InetSocketAddress lastSender() { return new InetSocketAddress(recvPacket.getAddress(), recvPacket.getPort()); } @Override public void close() { socket.close(); } }

这个类把setReuseAddress和缓冲区设置放在bind之前,避免了前面说的顺序问题。receive带超时,避免无限阻塞。lastSender方便拿到来源地址做回复。用StandardCharsets.UTF_8替代字符串编码名,避免UnsupportedEncodingException。

用起来大概是这样:

try (UdpEndpoint endpoint = new UdpEndpoint(9999, 64 * 1024)) { while (true) { try { String msg = endpoint.receive(1000); System.out.println("got: " + msg); endpoint.send("ack", endpoint.lastSender().getHostString(), endpoint.lastSender().getPort()); } catch (SocketTimeoutException e) { // 超时,继续循环 } } }

这套骨架在日志采集、设备心跳、局域网发现这几个场景里都跑过,稳定性没问题。真正上线的时候,接收循环外面要包一层异常处理,避免单个包的异常把整个循环干掉。

UDP这东西,入门容易,用好难。难的不是API,而是对"不可靠"这三个字的理解——你得清楚你的业务能容忍什么、不能容忍什么,然后决定在UDP之上加多少东西。加多了不如用TCP,加少了业务扛不住。这个度,只能靠对业务的理解来把握。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询