简介:这份资源是Java基于UDP协议实现可靠通信系统的完整程序源码,面向学习网络编程、分布式系统设计的高校学生与开发者,帮助解决UDP不可靠传输下的数据包丢失、乱序与重传等核心难题。压缩包共132个文件,约1.13MB,以43个java源文件与63个class编译文件为主体,辅以9个xml配置、3个jar依赖及少量gif、jpg等资源,源码按Client与Server两部分组织,涵盖DatagramSocket与DatagramPacket的封装使用、序列号与确认机制、超时重传策略、CRC校验及流量控制等关键实现。已有187人学习下载。通过研读客户端请求发起、服务器循环接收与响应确认的完整链路,读者可掌握UDP数据包的序列化与反序列化、错误处理及业务逻辑分层思路,为网络编程课程设计或分布式通信项目提供可直接参考的代码范本与排错经验。
1. 从「丢包就崩」到「自己扛住」:Java 基于 UDP 的可靠通讯系统到底在解决什么
用 Java 写 UDP 通讯,很多人第一次跑通就翻车:本地回环测试一切正常,一放到跨机房或者弱网环境,消息就开始丢、顺序开始乱、大包直接截断。TCP 明明帮你把可靠性做完了,为什么还要自己基于 UDP 造一套可靠通讯系统?答案藏在场景里——实时对战、行情推送、音视频信令、物联网设备上报,这些场景要的是「低延迟优先、可靠性按需定制」,TCP 的队头阻塞和重传策略反而成了负担。这个标题讲的就是:用 Java 的DatagramSocket/DatagramPacket打底,自己实现一套带确认、重传、序号、去重的可靠层,让 UDP 在可控成本下达到「够用」的可靠。它适合已经会写 Java Socket、想搞懂可靠传输底层机制、或者课程设计需要一份能跑通源码的人。下面我按「协议怎么设计 → 代码怎么落地 → 坑在哪」的顺序,把一套能复现的方案讲清楚。
2. 可靠层协议怎么设计:序号、确认、重传三件套
UDP 本身只保证「尽力而为」,它把可靠性、有序性、去重全部甩给应用层。所以基于 UDP 做可靠通讯,本质是在应用层重新实现 TCP 的一部分机制,但又不能照抄 TCP——照抄就失去了用 UDP 的意义。核心要解决四件事:数据不丢、数据不重、数据有序、大包能拆。下面先把协议头设计清楚,再谈收发两端的状态机。
2.1 自定义协议头:8 字节定长字段怎么排
可靠层的第一件事是给每个 UDP 数据报加一个协议头,接收端靠它判断这是新数据、重复数据还是确认包。我一般用一个固定 8 字节的头,字段排布如下:
| 字段 | 长度 | 含义 |
|---|---|---|
| magic | 2 字节 | 魔数,固定值,用来快速丢弃非法包 |
| type | 1 字节 | 包类型:0 数据、1 ACK、2 心跳、3 结束 |
| flags | 1 字节 | 标志位,bit0 表示分片,bit1 表示最后一片 |
| seq | 2 字节 | 序列号,0~65535 循环 |
| ack | 2 字节 | 确认号,表示「这个序号之前的都收到了」 |
序列号用 2 字节而不是 4 字节,是因为大多数课程设计和中小规模场景下,单连接在途未确认包不会超过几百个,2 字节足够循环使用。如果你要做高吞吐,把 seq 和 ack 扩到 4 字节即可,头变成 12 字节。魔数的作用是防止把别的程序发到同一端口的包误当成自己的协议包,接收端第一件事就是校验 magic,不匹配直接丢。
注意:协议头字段的字节序必须两端统一。Java 的
ByteBuffer默认是大端,如果你用DataOutputStream写writeShort也是大端,保持一致就不会出现「本地对、跨机器错」的玄学问题。
2.2 发送端状态机:滑动窗口 + 超时重传
发送端不能发完就不管,它要维护一个「已发送但未确认」的窗口。每发一个数据包,就把它放进一个pending映射里,key 是 seq,value 是发送时间戳和重传次数。收到 ACK 后,把 ack 号之前的所有包从 pending 里移除。如果某个包超过 RTO(重传超时)还没被确认,就重发,重传次数超过上限就判定连接不可达。
// 发送端核心:发送并登记待确认包 private final Map<Integer, PendingPacket> pending = new ConcurrentHashMap<>(); private static final int MAX_RETRY = 5; private static final long BASE_RTO_MS = 200; public void sendReliable(byte[] payload) { int seq = nextSeq(); byte[] packet = buildPacket(TYPE_DATA, seq, lastAck, payload); PendingPacket pp = new PendingPacket(packet, System.currentTimeMillis(), 0); pending.put(seq, pp); socket.send(new DatagramPacket(packet, packet.length, remoteAddr, remotePort)); } // 定时任务:扫描超时包并重传 private void scanAndRetransmit() { long now = System.currentTimeMillis(); for (Map.Entry<Integer, PendingPacket> e : pending.entrySet()) { PendingPacket pp = e.getValue(); long rto = BASE_RTO_MS * (1L << pp.retry); // 指数退避 if (now - pp.sendTime > rto) { if (pp.retry >= MAX_RETRY) { pending.remove(e.getKey()); onConnectionLost(); return; } pp.retry++; pp.sendTime = now; socket.send(new DatagramPacket(pp.data, pp.data.length, remoteAddr, remotePort)); } } }这段代码的关键点有三个。第一,pending用ConcurrentHashMap,因为发送线程和定时重传线程会并发访问。第二,RTO 用指数退避,BASE_RTO_MS * (1 << retry),第一次 200ms,第二次 400ms,第三次 800ms,避免网络拥塞时疯狂重传把链路打爆。第三,重传次数上限MAX_RETRY设 5 次,超过就回调onConnectionLost,让上层决定是重连还是报错。参数怎么调:局域网 BASE_RTO 可以设 50~100ms,公网建议 200~500ms;MAX_RETRY 太小会误判断线,太大又会让上层等太久,5 次是个折中。
2.3 接收端去重与有序交付
接收端收到数据包后,先看 seq。如果 seq 小于等于已经连续交付的最大序号,说明是重复包,直接丢弃但依然要回 ACK——因为对方可能没收到上一次 ACK。如果 seq 大于期望序号,说明中间有空洞,先放进一个乱序缓冲区,等空洞补齐再连续交付。ACK 号用「已连续收到的最大 seq + 1」,这样发送端一看 ack 就知道哪些能清出 pending。
// 接收端核心:去重、缓存乱序、连续交付 private int expectedSeq = 0; private final Map<Integer, byte[]> outOfOrder = new TreeMap<>(); public void onDataReceived(int seq, byte[] payload) { if (seq < expectedSeq) { sendAck(expectedSeq); // 重复包,补发 ACK return; } if (seq == expectedSeq) { deliver(payload); expectedSeq++; // 尝试把缓冲区里连续的包吐出来 while (outOfOrder.containsKey(expectedSeq)) { deliver(outOfOrder.remove(expectedSeq)); expectedSeq++; } } else { outOfOrder.put(seq, payload); // 乱序,先缓存 } sendAck(expectedSeq); }这里用TreeMap而不是HashMap,是因为乱序缓冲区需要按 seq 有序遍历,TreeMap天然有序,取expectedSeq时直接containsKey即可。expectedSeq和 seq 都是 2 字节循环,实际工程里要处理回绕:当 expectedSeq 到达 65535 后归零,比较大小不能直接用<,要用「带符号的差值」判断。这是新手最容易翻车的地方,本地测试包量小永远碰不到,一压测就出乱序。
3. 用 Java 把收发两端跑起来:最小可运行工程
协议设计完,接下来是把它变成能跑的 Java 代码。这一章给出一套最小可运行的双端结构:一个ReliableUdpSocket封装收发逻辑,一个Sender主类和一个Receiver主类分别启动。代码基于标准库java.net.DatagramSocket,不依赖任何第三方包,JDK 8 以上都能编译。
3.1 工程结构与线程模型
我一般把工程拆成四个类,职责单一,方便你替换任意一层:
| 类名 | 职责 |
|---|---|
PacketCodec | 协议头编解码,build 和 parse |
ReliableUdpSocket | 封装发送、接收、重传、去重 |
Sender | 启动发送端,读取输入并调用 sendReliable |
Receiver | 启动接收端,注册回调处理交付数据 |
线程模型是三个线程:一个接收线程阻塞在socket.receive(),一个定时重传线程用ScheduledExecutorService每 50ms 扫一次 pending,一个业务线程负责调用发送。接收线程收到包后解析类型,数据包走onDataReceived,ACK 包走onAckReceived清理 pending。这样收发互不阻塞,重传也不会卡住接收。
// 接收线程:阻塞收包并分发 private void receiveLoop() { byte[] buf = new byte[1500]; while (running) { try { DatagramPacket dp = new DatagramPacket(buf, buf.length); socket.receive(dp); Packet p = PacketCodec.parse(dp.getData(), dp.getLength()); if (p == null) continue; // 魔数不对,丢弃 if (p.type == TYPE_DATA) { onDataReceived(p.seq, p.payload); } else if (p.type == TYPE_ACK) { onAckReceived(p.ack); } } catch (IOException e) { if (running) onError(e); } } }缓冲区设 1500 字节,是因为以太网 MTU 通常是 1500,UDP 载荷超过这个值会在 IP 层分片,分片丢失会导致整个包作废。所以应用层要主动分片:发送前把大于 1400 字节的数据切成多片,每片带上 flags 的 bit0 和 bit1 标记。接收端按 seq 重组,最后一片到达后拼成完整消息再交付。这个分片逻辑是可靠层的一部分,不能省。
3.2 发送端启动与参数配置
发送端启动时要绑定本地端口、指定远端地址,并启动重传定时器。下面是一个可直接抄的启动骨架:
public class Sender { public static void main(String[] args) throws Exception { ReliableUdpSocket sock = new ReliableUdpSocket( 9000, // 本地端口 "127.0.0.1", 9001, // 远端地址 200, // BASE_RTO_MS 5 // MAX_RETRY ); sock.start(); // 模拟发送 100 条消息 for (int i = 0; i < 100; i++) { String msg = "hello-" + i; sock.sendReliable(msg.getBytes(StandardCharsets.UTF_8)); } Thread.sleep(5000); // 等重传和 ACK 处理完 sock.shutdown(); } }参数说明:本地端口 9000 是发送端自己的 UDP 端口,远端 9001 是接收端监听端口。BASE_RTO_MS和MAX_RETRY直接决定弱网下的表现,本地测试用 200ms 和 5 次足够。sendReliable内部会做分片、登记 pending、发送。注意Thread.sleep(5000)只是演示用,真实业务里应该等所有 pending 清空或超时再退出,否则最后几条消息可能还没确认就关了 socket。
3.3 接收端启动与交付回调
接收端绑定端口后启动接收线程,注册一个回调处理完整消息:
public class Receiver { public static void main(String[] args) throws Exception { ReliableUdpSocket sock = new ReliableUdpSocket( 9001, // 本地监听端口 null, 0, // 接收端不需要预设远端 200, 5 ); sock.setMessageHandler((data) -> { String s = new String(data, StandardCharsets.UTF_8); System.out.println("delivered: " + s); }); sock.start(); Thread.sleep(30000); sock.shutdown(); } }setMessageHandler注册的回调只在消息完整重组后被调用,所以业务层拿到的永远是完整、有序、去重后的数据。这是可靠层的价值:上层不用关心 UDP 的乱序和丢包。接收端不需要预设远端地址,因为第一个数据包到达时可以从DatagramPacket里拿到来源地址,动态记录即可。但要注意,如果同时有多个发送端连同一个接收端口,需要按来源地址区分会话,每个会话独立维护 expectedSeq 和乱序缓冲区,否则序号会串。
4. 避坑与排查:可靠 UDP 最容易翻车的 5 个点
这套东西本地跑通很容易,一上真实网络就各种问题。下面是我踩过的五个坑,按「现象 → 原因 → 解决」写清楚,你遇到时可以直接对号入座。
4.1 现象:本地测试全过,跨机器就大量丢包
原因通常不是代码,而是 MTU 和分片。本地回环 MTU 很大,发 2000 字节也不分片;跨机器走以太网,超过 1500 字节的 UDP 包在 IP 层分片,任何一片丢失整个包就废了,而你的重传是按整个包重传,效率极低。解决:应用层主动分片,单片载荷控制在 1400 字节以内,并在协议头 flags 里标记分片和最后一片。接收端按 seq 重组,不要依赖 IP 层分片。
4.2 现象:ACK 发出去了,发送端还在重传
原因是 ACK 包本身也可能丢。如果接收端只在收到数据时回一次 ACK,这个 ACK 丢了发送端就会重传,重传后接收端发现是重复包,按 2.3 的逻辑会补发 ACK,这样最终能收敛。但如果你在重复包分支里直接 return 而不补发 ACK,发送端就会一直重传到 MAX_RETRY 然后误判断线。解决:收到任何数据包,无论新旧,都回一次当前 expectedSeq 的 ACK。这是「后悔药」,成本极低但能救很多命。
4.3 现象:压测时序号突然乱掉,消息顺序错乱
原因是 2 字节 seq 回绕。当 seq 从 65535 回到 0,如果你用seq < expectedSeq判断重复,0 会被误判成旧包丢弃。解决:比较时用带符号差值,(short)(seq - expectedSeq) < 0才算旧包。Java 里把 int 强转 short 再比较,能正确处理回绕。这个坑本地小包量永远碰不到,一压测就现形,属于典型的血泪经验。
4.4 现象:接收端内存持续上涨,最后 OOM
原因是乱序缓冲区没有上限。如果发送端发了 seq=100 的包,接收端 expectedSeq=0,中间 99 个包一直没到,outOfOrder就会一直堆积。解决:给乱序缓冲区设一个上限,比如 512 个包,超过就丢弃最旧的或者直接判定会话异常。同时给整个会话设一个空闲超时,长时间没有新包就清理状态。可靠不等于无限缓存,边界必须自己划。
4.5 现象:程序退出时最后几条消息丢失
原因是shutdown直接关了 socket,pending 里的包还没确认。解决:shutdown要优雅——先停止接受新发送请求,然后等待 pending 清空或超时(比如 3 秒),最后再关 socket 和线程池。如果业务允许,可以在关闭前发一个 TYPE_END 包并等对方 ACK,确认对端知道会话结束。这个细节决定了你的系统是「看起来能用」还是「真的能用」。
5. 进阶:把可靠层做成可配置的策略,而不是写死的逻辑
前面给的实现是「够用版」,但真实项目里不同场景对可靠性的要求不一样。行情推送可能允许丢少量旧数据但不能延迟,文件传输必须一个字节都不能少。所以最后一章讲一个具体技巧:把重传策略、窗口大小、分片阈值做成可配置参数,让同一套代码适配不同场景。
我一般会抽一个ReliableConfig类,把关键参数集中管理:
| 参数 | 默认值 | 适用场景 |
|---|---|---|
| baseRtoMs | 200 | 公网 200~500,局域网 50~100 |
| maxRetry | 5 | 实时场景 2~3,文件传输 10+ |
| maxWindow | 256 | 高吞吐调大,低延迟调小 |
| fragmentSize | 1400 | 以太网固定,特殊链路按 MTU 减 60 |
| outOfOrderLimit | 512 | 内存紧张调小,弱网调大 |
然后ReliableUdpSocket的构造函数接收这个 config,所有硬编码常量替换成config.getXxx()。这样换场景只改配置不改代码。更进一步,可以把「是否需要有序交付」也做成开关:实时位置更新场景下,旧的位置数据到了直接丢,不需要缓存乱序,这样能省掉 TreeMap 的开销和延迟。实现方式是在onDataReceived里判断config.isOrdered(),false 时直接交付并更新 expectedSeq 为max(expectedSeq, seq+1)。
验证这套东西是否真的可靠,我习惯用两个手段。第一,在发送端和接收端各加一个计数器,发送端统计sentCount和retransmitCount,接收端统计receivedCount和duplicateCount,跑完后对比sentCount == receivedCount,重传率能反映网络质量。第二,人为制造丢包:在接收线程里加一行if (Math.random() < 0.1) continue;模拟 10% 丢包,看系统能不能在 MAX_RETRY 内把数据补齐。这个测试比任何理论分析都直观,我第一次跑的时候发现重传率高达 30%,排查后才发现是 ACK 没补发导致的无效重传。
最后一个习惯:任何可靠协议的上限都是「网络本身还能通」。如果链路完全断了,重传再多次也没用,这时候要快速失败并通知上层,而不是死等。我一般会在连续 MAX_RETRY 次重传失败后,直接触发onConnectionLost,让上层决定重连还是降级。可靠层的职责是「在链路还能通的前提下尽量不丢」,不是「保证一定送达」,这个边界想清楚,代码就不会写歪。希望帮到你。
本文还有配套的精品资源,点击获取