Java网络编程里UDP协议向来是个“熟悉又陌生”的存在。熟悉是因为八股文里总能看到那三句话无连接、不可靠、面向报文陌生是因为真到项目里要用DatagramSocket和DatagramPacket去收发数据时很多人还是能躲就躲宁愿加一个TCP长连接硬扛。这篇文章我不打算只讲概念而是围绕Java基于UDP协议的核心内容把从API使用、线程模型、MTU与缓冲区这些底层细节到如何在UDP之上做确认重传、怎么用组播广播一条线完整讲清楚。适合正在补Java网络编程基础的同学也适合被安排做UDP通信模块却不知道怎么下手的人。先说说我为什么对UDP这么上心。过去两年我做过一个车间设备数据采集项目设备端用UDP上报心跳和状态数据几百台设备同时往服务器发峰值每秒上万条报文。TCP在这种场景下不是不行而是连接数管理、半包粘包处理、断线重连这些成本全部压在你身上UDP就轻松很多——每个设备一条独立报文服务器只管收数据的完整性由应用层协议去保证。这个体验让我相信搞懂UDP的核心机制对一个Java后端来说不是加分项是实打实的基础能力。1. 为什么还在用UDP先搞清楚它和TCP的分工1.1 一个最容易被忽略的事实UDP不是残缺品很多人把UDP理解成“TCP砍掉了可靠性之后的简化版”这个视角容易误导。UDP出现的场景从来不是“TCP做不了的事”而是“TCP做得太重的场景”。它的设计目标是最小开销地把数据从一个端点搬到另一个端点至于对不对、全不全、顺序乱没乱那是上层协议的事。TCP为了做到可靠传输光头部就占20字节还要维护连接状态、发送窗口、接收窗口、拥塞控制UDP头部只有8字节没有连接表没有握手一个socket可以同时收发想发就发。这套极简设计在实时性要求高、单包数据量小、允许部分丢损的场景里反而比TCP合适得多。一个典型的例子是语音通话。人耳对延迟比对丢包更敏感200毫秒的抖动就能明显察觉到卡顿而丢掉几十毫秒的语音数据耳朵几乎分辨不出来。TCP一旦丢包就会重传重传的数据到达时已经是“过期”的反而造成更严重的延迟堆积。UDP丢了就丢了配合前向纠错或者简单的冗余发送用户体验反而更好。再比如DNS查询一个请求一个响应数据量通常不超过512字节根本不需要建立连接那一套来回握手——这也是DNS基于UDP的根本原因。1.2 从8字节报文头看协议本质UDP报文头固定8字节结构非常简单字段长度作用源端口2字节发送方端口目的端口2字节接收方端口长度2字节UDP报文总长度含头部校验和2字节检测传输错误看到没有它没有序列号、没有确认号、没有标志位更没有窗口大小。这意味着什么意味着UDP既不关心你发了多少数据也不关心对方收到没有甚至不关心对方到底存不存在。send()调用成功只代表数据被交给了操作系统网络栈和“对端收到了”完全是两码事。这是理解后面所有问题的起点——所有可靠性逻辑都得由应用层自己补。1.3 选型场景什么时候必须UDP什么时候千万别用我一般按照下面这张表来做协议选型场景特征推荐协议典型场景大量短小请求一次一问一答UDP 应用层超时重传DNS、服务发现、心跳检测实时音视频、游戏状态同步UDP通话、直播、FPS游戏海量设备低频上报UDP物联网传感器、设备心跳需要可靠有序的大文件/消息TCP文件传输、RPC、消息队列传输层本来就不可控、链路质量差TCP 或 独立实现可靠UDP公网跨地域传输这个表格不是绝对真理但方向上很有参考价值。有个反例我得提醒一下有些团队为了“省事”把本应该用TCP的业务硬改成UDP然后在应用层实现一套完整的可靠传输代码量爆炸不说最后效果还不如直接用TCP。UDP的“自由”是有代价的如果你发现自己要为每个业务都去实现可靠性最好先停下来想想是不是选错了协议。2. 三个核心类一次讲透DatagramSocket、DatagramPacket、InetAddress2.1 发送端三步走一个最简示例Java的UDP编程核心就三个类DatagramSocket负责收发DatagramPacket负责装数据InetAddress负责表示IP地址。先看一个最朴素的发送端import java.net.*; import java.nio.charset.StandardCharsets; public class UdpSender { public static void main(String[] args) throws Exception { // 不指定端口系统自动分配一个临时端口 try (DatagramSocket socket new DatagramSocket()) { String msg hello udp; byte[] data msg.getBytes(StandardCharsets.UTF_8); InetAddress address InetAddress.getByName(127.0.0.1); DatagramPacket packet new DatagramPacket(data, data.length, address, 9090); socket.send(packet); } } }三步创建socket、构造packet关键是填对目的地址和端口、调用send()。注意这里的DatagramPacket构造器data长度传的是实际数据长度不是byte数组的容量。如果byte[]后面还有剩余空间多余部分不会被发送。这一点和后面接收端的用法不一样接收端构造packet时传的是buffer容量。还有一个细节DatagramSocket的无参构造器会自动绑定一个随机端口适合纯发送方。但如果你希望接收方也能认出你这个端口或者需要绑定固定端口配合防火墙规则就用new DatagramSocket(port)或者new DatagramSocket(port, InetAddress.getByName(0.0.0.0))来指定本地绑定地址。2.2 接收端receive()的阻塞本质接收端代码同样简洁import java.net.*; import java.nio.charset.StandardCharsets; public class UdpReceiver { public static void main(String[] args) throws Exception { try (DatagramSocket socket new DatagramSocket(9090)) { byte[] buffer new byte[1024]; 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(来自 packet.getAddress() : packet.getPort() - message); } } }这里面的坑就多起来了。第一receive()是阻塞的而且不会自动超时如果没有人发数据这个线程会一直挂在这里所以生产环境里receive()几乎总是放在一个独立线程或者线程池里跑。第二接收时packet.getData()返回的是整个buffer必须用getLength()得到实际数据长度否则你会读到一堆0字节。第三如果收到的UDP报文比buffer还大多余的部分会被静默丢弃——这点后面讲MTU时还会再提。获取对方地址用packet.getAddress()和packet.getPort()这是UDP能做“应答”的关键因为UDP没有连接你收到一个包想回包时只能靠包里携带的源地址和源端口。2.3 DatagramPacket的本质一个完整数据报的容器理解DatagramPacket最关键的一点一个DatagramPacket对象只对应一个完整的数据报datagram。UDP是面向报文的协议它不像TCP那样是字节流可以随时分片组装UDP发送端的每次send()调用对接收端的receive()来说就是一个独立的整体不会出现半个包或者两个包黏在一起的情况。这个特性既是福利也是约束。福利在于应用层不需要处理粘包和半包问题一次receive()拿到的就是发送方一次send()发的完整内容约束在于每个数据报的最大长度被限制在65507字节后文细算而且一旦数据报在传输过程中被IP层分片任何一片丢了整个数据报就废了。所以我的经验是在设计UDP通信协议时把单个报文控制在合理范围内正常情况下不超过1400字节这样能避免IP分片。你要是有大块数据要传正确的姿势不是塞进一个UDP包而是自己切片加上序号和总片数接收端重组——这其实就是自己做一层类似TCP的流控后面第5章会给出一个实用方案。3. 从零搭一个能用的UDP服务单线程到多线程的完整改造3.1 先写一个能跑的echo服务拿echo服务练手是最直观的。客户端发什么服务端原样回什么。服务端要做到两件事一是正确接收二是从packet里取出地址端口原样返回。public class UdpEchoServer { public static void main(String[] args) throws Exception { try (DatagramSocket socket new DatagramSocket(9090)) { byte[] buffer new byte[2048]; while (true) { DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); String content new String(packet.getData(), 0, packet.getLength(), StandardCharsets.UTF_8); System.out.println(Thread.currentThread().getName() 收到: content); // 原样回给发送方 DatagramPacket response new DatagramPacket( packet.getData(), packet.getLength(), packet.getAddress(), packet.getPort()); socket.send(response); } } } }注意这里response的构建直接用packet.getData()、packet.getLength()、packet.getAddress()、packet.getPort()一个包就把来源信息和载荷都带出来了。这也是UDP应答最优雅的地方不需要维护任何连接映射谁发来的就回给谁天然支持多客户端。但单线程echo服务有个明显限制receive()到send()之间如果处理逻辑变重比如要解析JSON、写数据库这一个处理周期内到达的其他报文都会堆积在系统缓冲区里极端的场景下缓冲区溢出直接丢包。所以生产环境的UDP服务几乎没有裸用单线程的。3.2 加线程池改造真正的UDP服务端形态我的做法是一个专用接收线程负责receive()把收到的数据包装成任务丢给线程池执行如果需要回包DatagramSocket的send()本身是线程安全的多个线程同时send没有问题但在接收线程之外回包时要确保socket是同一个实例、没有被关闭。import java.net.*; import java.nio.charset.StandardCharsets; import java.util.concurrent.*; public class UdpServerPool { private static final int THREAD_POOL_SIZE 16; public static void main(String[] args) throws Exception { ExecutorService pool Executors.newFixedThreadPool(THREAD_POOL_SIZE); try (DatagramSocket socket new DatagramSocket(9090)) { byte[] buffer new byte[2048]; while (true) { DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); // 注意buffer会被复用业务处理必须拷走数据 byte[] data packet.getData(); int len packet.getLength(); InetAddress addr packet.getAddress(); int port packet.getPort(); pool.execute(() - handle(socket, data, len, addr, port)); } } } private static void handle(DatagramSocket socket, byte[] data, int len, InetAddress addr, int port) { String content new String(data, 0, len, StandardCharsets.UTF_8); System.out.println(Thread.currentThread().getName() 处理: content); byte[] resp (echo: content).getBytes(StandardCharsets.UTF_8); try { DatagramPacket response new DatagramPacket(resp, resp.length, addr, port); socket.send(response); } catch (Exception e) { e.printStackTrace(); } } }这里有个极其容易踩的坑接收线程里的byte[] buffer是复用的receive()会把新数据覆盖到同一个数组上。如果你直接把buffer引用丢给线程池线程池还没执行完下一轮receive()就把内容改了处理结果全是错的。正确做法就是上面代码里那样在接收线程内把数据的引用、长度、来源地址端口全部拷出来。我用的是byte数组复制更稳妥的还可以System.arraycopy或者干脆每个任务new一个byte[len]。提示如果业务处理比较慢线程池很容易被打满后续任务会进入排队队列。UDP没有背压机制不会主动通知对端“我处理不过来了”所以队列也要设上限并做好丢弃和监控。3.3 超时、缓冲区与优雅关闭生产环境的UDP接收端最好设置一个接收超时防止线程永久阻塞在receive()上无法退出socket.setSoTimeout(3000); // 3秒没有数据就抛SocketTimeoutException超时的另一个用途是重传——发出请求后等3秒没收到响应就重发。这个在可靠UDP那一章还会展开。另外两个常用setter是socket.setReceiveBufferSize(4 * 1024 * 1024); // 增加内核接收缓冲区缓解瞬时洪峰丢包 socket.setSendBufferSize(1 * 1024 * 1024); // 发送缓冲区同理注意setReceiveBufferSize要在绑定端口之后、接收数据之前调用有些平台实际生效值会比设定值小所以调完之后最好getReceiveBufferSize()确认一下。关于优雅关闭很多人忽略一点要让阻塞在receive()上的线程退出最可靠的办法是关socket。关闭DatagramSocket后阻塞中的receive()会立即抛SocketException线程就能正常结束。所以如果你用单独线程跑接收循环可以用volatile标志位配合socket.close()双重保险。我在项目里是维护了一个AtomicBoolean running循环条件判断它同时外部可调socket.close()来打断阻塞。4. 实测遇到的几个大坑字节上限、MTU分片与丢包乱序4.1 65507字节是怎么算出来的很多面试题喜欢问UDP报文最大长度答案不是UDP头里的16位长度字段决定的65535而是65507。计算很简单IP头20字节 UDP头8字节IPv4数据报总长65535字节留给UDP载荷的空间就是65535 - 20 - 8 65507字节。但这个理论值在实际网络里几乎没意义。以太网MTU通常是1500字节也就是说一个IP数据报超过1500字节就会被IP层分片分成多个不超过MTU的IP分片由对端IP层重组后再交给UDP。问题在于分片在传输过程中只要有任意一片丢了整个数据报都会被丢弃而且发送方毫不知情。这就是为什么我前面一直强调应用层设计报文时要把单包控制在1400字节以内——留50到100字节余量给IP和UDP头再加上可能的隧道开销尽量让报文不触发分片。顺便说一句IPv6下的UDP更严格IPv6路由器不分片要求源节点做路径MTU发现。所以在IPv6环境下随意发大UDP包丢包率会非常感人。注意不要迷信“单UDP包能到65507字节”这个理论值。在公网上发超过MTU的包等于是把可靠性全部押在IP分片重组上IP分片一旦出问题整个包就没了。宁可自己切包也不要赌这一把。4.2 回环地址与真实网络的差异做UDP实验时有个迷惑性很强的现象在localhost上测收发几乎不会丢包延迟也几乎为零。这是因为回环接口127.0.0.1的数据不经过真实网卡和物理链路直接在内核里转了一圈既没有拥塞也没有竞争。很多人在本地测得好好的一上线到真实网络就发现丢包率飙升然后开始怀疑代码。这不是代码问题而是UDP的本质特性。真实网络里丢包来自几个方向一是链路本身拥塞路由器/交换机主动丢包二是接收端缓冲区不足内核把来不及处理的报文丢掉三是分片丢失导致整体作废。要处理这些问题唯一可靠的办法是应用层自己设计可靠性机制。4.3 丢包与乱序的通用处理模式UDP的乱序也是一个高频问题。在局域网内乱序不常见但在跨地域公网传输时不同路径的延迟差异会导致乱序。处理乱序的通用思路是发送方给每个数据报编上序号接收方维护一个有序缓冲区等发现序号不连续时按策略决定是等待还是重传请求。我在设备采集项目里用的策略很简单发送方每条报文带一个自增序列号seq接收方记录最近连续收到的seq如果新来的包seq比期望值大把中间缺失的seq记入“待补”集合连续收到某个缺失包的后续包超过N个或者等待超过T毫秒就向发送方发一个补包请求发送方维护一个最近发送报文的环形缓存收到补包请求就把缓存里的对应报文重发一次这套机制比TCP简单但足够应付绝大多数物联网设备上报场景。再往后如果业务对顺序完整性要求更严格就可以引入更完整的确认重传机制这是下一章的内容。5. 在UDP之上做可靠性确认重传与超时重试的实战方案5.1 一个最小的可靠UDP协议如果业务场景是“客户端发请求服务端必须回响应否则就重发”最省事的方案是给UDP加一层确认机制。设计成三步客户端发送请求报文带上请求ID服务端处理完回一个带相同请求ID的响应客户端设置超时超时未收到响应就重发连续重发达到上限则宣告失败这个协议本身不复杂麻烦的是重传间隔和请求ID的管理。先看代码骨架import java.net.*; import java.nio.ByteBuffer; import java.util.concurrent.ThreadLocalRandom; public class ReliableUdpClient { private static final int TIMEOUT_MS 2000; private static final int MAX_RETRIES 3; public static byte[] request(DatagramSocket socket, InetAddress server, int port, byte[] payload) throws Exception { int requestId ThreadLocalRandom.current().nextInt(1, 1 16); for (int attempt 0; attempt MAX_RETRIES; ) { // 报文格式2字节请求ID 2字节长度 数据 ByteBuffer buf ByteBuffer.allocate(2 2 payload.length); buf.putShort((short) requestId); buf.putShort((short) payload.length); buf.put(payload); byte[] out new byte[buf.position()]; buf.flip(); buf.get(out); socket.send(new DatagramPacket(out, out.length, server, port)); socket.setSoTimeout(TIMEOUT_MS); DatagramPacket packet new DatagramPacket(new byte[2048], 2048); try { socket.receive(packet); ByteBuffer in ByteBuffer.wrap(packet.getData(), 0, packet.getLength()); short respId in.getShort(); short len in.getShort(); if (respId requestId) { byte[] data new byte[len]; in.get(data); return data; } // 请求ID不匹配说明是上一个请求的迟到响应继续等待 } catch (SocketTimeoutException e) { attempt; // 只有真的超时才消耗重试次数 } } throw new IOException(超过最大重试次数); } }几个细节说明。第一请求ID要覆盖住响应重传的情况如果第一次请求超时了但其实服务端已经处理并回了响应只是响应在网络上慢了一步客户端重传后收到一个旧响应通过请求ID判断是旧的直接忽略继续等待新响应。第二超时后重发的是同一个请求ID这样服务端可以根据请求ID做去重避免同一个操作被执行两次——这在写入类请求里尤其重要。5.2 超时时间怎么定不是拍脑袋重传超时时间是最难调的参数。设得太短网络正常时也频繁重传浪费带宽设得太长真的丢包时响应要很久才能补上。我的经验做法是分场景局域网内RTT通常在1ms以内超时设500ms就足够稳妥同城跨机房按RTT的3到5倍留余量比如实测RTT是20ms超时设100到200ms跨地域公网RTT波动大建议设2到3秒重试次数控制在3次以内更精细的做法是动态RTO参考TCP的SRTT估算每次请求记录发送时间和响应时间更新平滑RTT超时定为RTT均值加上4倍抖动。这个在Java里实现也不难维护一个AtomicReference存最近N个样本即可。但如果你的业务没那么苛刻固定超时加手动调参完全够用不必一开始就上动态估算。5.3 什么时候该考虑滑动窗口如果只是“一问一答”的请求响应模式确认重传就够了。但如果要连续传输大量数据比如用UDP传文件、传缓存数据就必须考虑吞吐量与可靠性的平衡。最简单的做法是停等协议Stop-and-Wait发一个等确认再发下一个。缺点是每个包的等待时间都耗在RTT上吞吐上不去。RTT为100ms时每包1400字节理论吞吐还不到112Kbps这在很多场景下是不能接受的。这时候就要引入滑动窗口发送方维护一个窗口允许窗口内多个包同时在途接收方确认的是连续序号。窗口大小决定了同时有多少包在途实际吞吐极限就是窗口大小除以RTT。这个模型说起来轻巧实现时要处理的事情不少接收方要能缓存乱序包、发送方要按窗口维护重传机制、丢包后是回退N还是选择重传Selective Retransmission都需要根据丢包率做选择。我个人建议除非你有明确的高吞吐可靠传输需求否则别自己去实现滑动窗口。现在有不少成熟的可靠UDP协议可供参考实现思路多数源自UDTUDP-based Data Transfer这类研究方向Java生态下也有封装好的框架。但这类方案资料相对分散、踩坑成本高务必先在目标网络环境里做压测再上线。6. 组播与广播Java里容易被低估的两个能力6.1 组播一组主机同时收到数据组播Multicast解决的问题是“一对多但又不是全网广播”只有加入了某个组的机器才能收到发往该组的数据。Java里用MulticastSocket实现核心操作就三个创建socket、加入组、收发。import java.net.*; public class MulticastReceiver { public static void main(String[] args) throws Exception { InetAddress group InetAddress.getByName(239.0.0.1); try (MulticastSocket socket new MulticastSocket(8888)) { socket.joinGroup(group); byte[] buffer new byte[1024]; while (true) { DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); System.out.println(组播消息: new String(packet.getData(), 0, packet.getLength())); } } } }组播地址范围是224.0.0.0到239.255.255.255。发组播包时用普通DatagramSocket往组地址上send就行也可以用MulticastSocket的send。需要注意有些机房和云环境默认禁用了组播因为路由器需要开启PIM等协议才能跨网段转发组播流量所以在真实项目里组播一般用在局域网或者专网内部跨公网几乎没有可能。另一个容易被忽略的点是多网卡主机要选对网络接口。用MulticastSocket时如果机器上有多块网卡组播消息可能从错误的接口发出去。解决办法是先选好NetworkInterfaceMulticastSocket socket new MulticastSocket(8888); socket.setNetworkInterface(NetworkInterface.getByName(eth0)); socket.joinGroup(group);我在设备采集项目中用组播做过设备发现设备开机后向组播组发一条“我上线了IP是xxx”服务器端加入组就能收到不需要预先配置设备IP列表。这个方案比逐个扫描网段高效得多。6.2 广播向全网段说一声广播是更原始的方式目标地址用255.255.255.255或者网段广播地址比如192.168.1.255。Java里实现try (DatagramSocket socket new DatagramSocket()) { byte[] data discover.getBytes(); DatagramPacket packet new DatagramPacket(data, data.length, InetAddress.getByName(255.255.255.255), 9090); socket.send(packet); }广播的优点是简单缺点是打扰所有人整个网段内的主机都会收到包括很多根本不想收到的主机而且路由器默认不转发广播包只能覆盖本地子网。UDP广播在真生产环境我是能不用就不用它会把无关主机拖下水还会带来一定的安全问题。真要实现类似的“全网段通知”组播几乎是更好的选择。6.3 实际项目里怎么选这里总结一下我的选择逻辑。需要跨网段分发数据又没有现成的消息中间件优先考虑在应用层做“逻辑组播”——用一个列表维护所有目标地址程序里循环send模拟组播效果数据量小、目标数量又不多时这种方式最简单可控。局域网内设备发现、服务器间心跳可以用组播。广播基本只适合开发调试或者没有组播权限又确实需要全网段通知的简单场景。另外提醒一点组播和广播报文同样受MTU限制发大数据包照样有分片风险和丢包问题。我在项目里给组播数据定的上限也是1400字节超过就把数据切成多个包加序号发送。最后分享一个我在UDP开发中沉淀下来的习惯无论单播、组播还是广播所有报文设计都遵循同一个格式模板——固定几个字节的协议头协议号、版本号、序列号、时间戳 可选的业务载荷字段 末尾的校验和。这样即使报文满天飞、乱序丢包排查问题的时候也能很快定位是哪条业务链路出了问题。希望这些内容对你有实际帮助。