UDP 多发多收 | JavaSE
UDP 多发多收
一、学习目标
学完本章,你应该能够:
- 能够解释 UDP“一发一收”和“多发多收”在程序结构上的本质区别。
- 能够使用
while循环实现 UDP 客户端持续发送消息。 - 能够使用
while循环实现 UDP 服务端持续接收不同数据报。 - 能够理解为什么一个 UDP 接收端可以接收来自多个发送端的数据。
- 能够通过
DatagramPacket获取每一条消息对应的发送方 IP、端口和实际数据长度。 - 能够正确设计客户端的退出逻辑。
- 能够发现并处理循环复用
DatagramPacket时的数据长度问题。
二、核心知识
2.1 从单发单收到多发多收
上一章实现的是:
客户端
│
│ send 一次
▼
服务端
│
│ receive 一次
▼
程序结束
代码结构类似:
socket.send(packet);
服务端:
socket.receive(packet);
执行一次就结束。
但真正的网络程序显然不能:
发一句话就退出。
例如聊天程序至少应该:
发送:
你好
发送:
今天在干嘛?
发送:
晚上打球吗?
发送:
exit
所以程序必须从:
执行一次
升级为:
循环执行
最直接的方案就是:
while (true) {
...
}
因此:
UDP 多发多收的本质不是学习新的网络 API,而是把已经学会的 UDP 收发逻辑放入循环中。
2.2 客户端为什么需要循环
单发客户端:
创建 Socket
↓
发送一次
↓
关闭 Socket
多发客户端:
创建 Socket
↓
┌───────────────┐
│ 读取用户输入 │
│ ↓ │
│ 创建 Packet │
│ ↓ │
│ send() │
└───────┬───────┘
│
└──不断循环
因此:
DatagramSocket socket =
new DatagramSocket();
while (true) {
// 输入
// 编码
// 创建数据报
// 发送
}
注意:
DatagramSocket一般创建一次即可,不需要每发送一条消息就重新创建一个 Socket。
否则就变成:
发送第一条
创建 Socket A
发送第二条
创建 Socket B
发送第三条
创建 Socket C
这没有必要。
更合理:
创建 Socket
↓
发送 A
发送 B
发送 C
发送 D
↓
退出时关闭
2.3 服务端为什么也需要循环
单收服务端:
socket.receive(packet);
只执行一次。
收到第一条之后:
程序结束
显然无法持续提供服务。
因此:
while (true) {
socket.receive(packet);
}
服务端模型:
UDP Server : 8080
│
receive(packet)
│
┌─────────┴─────────┐
│ │
没消息 有消息
│ │
阻塞 ▼
处理数据报
│
▼
下一轮 receive
因此服务端可以:
一直运行
一直等待
一直接收
2.4 UDP 服务端为什么可以接收多个客户端
这是本章最重要的理解之一。
假设存在三个客户端:
Client A
192.168.1.10:52001
Client B
192.168.1.11:53002
Client C
192.168.1.12:54003
都向:
Server
192.168.1.100:8080
发送 UDP 数据报。
服务端只需要:
DatagramSocket socket =
new DatagramSocket(8080);
然后:
while (true) {
socket.receive(packet);
}
就可能依次收到:
A → Server
C → Server
B → Server
A → Server
为什么?
因为 UDP 是:
无连接
+
数据报独立寻址
服务端并没有建立:
Client A ←→ Server
这种长期连接关系。
它只是告诉操作系统:
发往本机 UDP 8080 的数据报交给这个 Socket。
于是:
Client A ─┐
Client B ─┼──→ UDP : 8080
Client C ─┘
都可以被同一个 DatagramSocket 接收。
2.5 服务端如何区分是谁发来的
虽然所有客户端都向:
8080
发送,但是每一个收到的:
DatagramPacket
中都包含发送方信息。
可以获取:
packet.getAddress()
发送方 IP。
以及:
packet.getPort()
发送方端口。
例如:
消息:你好
来源:192.168.1.10:52001
下一条:
消息:晚上好
来源:192.168.1.11:53002
因此服务器可以根据:
IP + Port
识别一次 UDP 数据报来自哪个网络端点。
三、使用方法
3.1 UDP 多发客户端
完整客户端:
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
public class UDPClient {
public static void main(String[] args) throws Exception {
System.out.println("=== UDP 客户端启动 ===");
try (DatagramSocket socket =
new DatagramSocket();
Scanner scanner =
new Scanner(System.in)) {
InetAddress serverAddress =
InetAddress.getLoopbackAddress();
while (true) {
System.out.print("请输入消息:");
String message =
scanner.nextLine();
if ("exit".equalsIgnoreCase(message)) {
System.out.println("客户端退出");
break;
}
byte[] data =
message.getBytes(
StandardCharsets.UTF_8
);
DatagramPacket packet =
new DatagramPacket(
data,
data.length,
serverAddress,
8080
);
socket.send(packet);
}
}
}
}
3.2 客户端执行流程
先创建:
DatagramSocket socket =
new DatagramSocket();
操作系统为客户端分配临时端口。
例如:
127.0.0.1:53126
然后:
Scanner scanner =
new Scanner(System.in);
持续读取用户输入。
主循环:
while (true) {
...
}
每轮:
读取 String
↓
判断 exit
↓
UTF-8 编码
↓
byte[]
↓
创建 DatagramPacket
↓
send()
3.3 为什么 exit 不发送给服务器
代码:
if ("exit".equalsIgnoreCase(message)) {
break;
}
意味着:
exit
只是:
客户端自己的退出控制指令。
它不会被发送给 UDP 服务端。
这通常是合理的。
因为一个服务端可能同时服务:
Client A
Client B
Client C
如果 Client A 输入:
exit
不应该意味着:
整个 Server 退出
而应该只是:
Client A 自己停止发送
因此要区分两个概念:
业务消息
和:
本地控制命令
3.4 UDP 多收服务端
一个直观实现:
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.nio.charset.StandardCharsets;
public class UDPServer {
public static void main(String[] args) throws Exception {
System.out.println("=== UDP 服务端启动 ===");
try (DatagramSocket socket =
new DatagramSocket(8080)) {
while (true) {
byte[] buffer =
new byte[4096];
DatagramPacket packet =
new DatagramPacket(
buffer,
buffer.length
);
socket.receive(packet);
String message =
new String(
packet.getData(),
packet.getOffset(),
packet.getLength(),
StandardCharsets.UTF_8
);
String clientIp =
packet.getAddress()
.getHostAddress();
int clientPort =
packet.getPort();
System.out.println(
"来自 " +
clientIp +
":" +
clientPort
);
System.out.println(
"消息:" + message
);
System.out.println(
"长度:" +
packet.getLength() +
" bytes"
);
System.out.println(
"--------------------------"
);
}
}
}
}
这个版本每轮:
new DatagramPacket(...)
创建新的接收包。
逻辑非常直观。
3.5 服务端完整执行过程
new DatagramSocket(8080)
↓
绑定 UDP 8080
↓
while(true)
↓
创建接收 Packet
↓
receive(packet)
↓
阻塞等待
↓
收到某个数据报
↓
读取 getLength()
↓
UTF-8 解码
↓
获取 IP + Port
↓
打印
↓
下一轮
服务端不会因为:
一个客户端退出
而自动退出。
它会继续:
receive(...)
等待其他数据报。
四、原理与进阶
4.1 DatagramPacket 能不能重复使用?
可以。
例如:
byte[] buffer =
new byte[4096];
DatagramPacket packet =
new DatagramPacket(
buffer,
buffer.length
);
while (true) {
socket.receive(packet);
}
但是这里有一个非常容易被忽略的细节。
第一次收到:
Hello
假设只有:
5 bytes
接收完成后:
packet.getLength()
会变成:
5
DatagramPacket 的 length 不仅表示:
上一次实际收到多少数据
在接收时还表示:
允许使用多少缓冲区空间接收下一份数据
因此,如果循环直接复用:
socket.receive(packet);
下一条发送:
这是一个长度远大于 5 字节的消息
就存在被截断的风险。
4.2 复用 DatagramPacket 的正确方式
如果希望避免每轮创建新对象,可以:
byte[] buffer =
new byte[4096];
DatagramPacket packet =
new DatagramPacket(
buffer,
buffer.length
);
while (true) {
packet.setLength(buffer.length);
socket.receive(packet);
String message =
new String(
packet.getData(),
packet.getOffset(),
packet.getLength(),
StandardCharsets.UTF_8
);
System.out.println(message);
}
关键:
packet.setLength(buffer.length);
在下一次接收前恢复:
最大可接收长度
因此两种方案都可以:
方案 A:每轮创建新的 Packet
while (true) {
byte[] buffer = new byte[4096];
DatagramPacket packet =
new DatagramPacket(
buffer,
buffer.length
);
socket.receive(packet);
}
优点:
逻辑直观
不容易踩复用长度问题
方案 B:重复使用 Packet
byte[] buffer = new byte[4096];
DatagramPacket packet =
new DatagramPacket(
buffer,
buffer.length
);
while (true) {
packet.setLength(buffer.length);
socket.receive(packet);
}
优点:
避免每轮创建对象
但必须理解:
packet length 状态
4.3 UDP 为什么不需要“每个客户端一个线程”
当前模型:
Client A ─┐
Client B ─┼──→ DatagramSocket.receive()
Client C ─┘
每个数据报是独立的。
服务端可以:
receive 一个
处理一个
receive 一个
处理一个
因此简单 UDP 接收器并不天然要求:
一个客户端 = 一个线程
这与后面的 TCP 差异非常明显。
TCP 中:
Client A
↓
Socket A
Client B
↓
Socket B
Client C
↓
Socket C
连接具有持续状态。
因此 TCP 多客户端服务端通常需要进一步结合:
多线程 / 线程池
而 UDP 基础接收模型没有这个连接层面的要求。
4.4 但 UDP 服务端完全不需要多线程吗?
也不能这么绝对。
假设:
socket.receive(packet);
收到数据以后,服务端需要处理一个耗时任务:
大型计算
数据库访问
磁盘 IO
调用外部服务
如果主线程直接处理:
receive
↓
耗时 10 秒
↓
下一次 receive
这 10 秒期间应用程序无法及时主动调用下一次 receive()。
因此真实高并发 UDP 服务仍然可能采用:
接收线程
+
工作线程
+
队列
但是:
这属于更进一步的并发服务器设计问题,并不是 UDP 多发多收的基础要求。
五、实践应用
5.1 多客户端日志接收器
可以把 UDP 服务端理解为一个简单日志中心:
程序 A ─┐
程序 B ─┼──→ UDP Log Server
程序 C ─┘
每个客户端发送:
[INFO] 用户登录
服务端记录:
192.168.1.10:53100
[INFO] 用户登录
这是典型的:
多发送端
+
单接收端
模型。
5.2 简易消息广播的基础
当前:
Client → Server
已经能做到:
多个 Client
↓
Server
如果进一步让服务端记录:
每个客户端 IP + Port
再把收到的消息发送给其他客户端:
Client A ──→ Server
│
┌──────┼──────┐
▼ ▼ ▼
Client B Client C Client D
就能逐渐发展出:
简易 UDP 聊天系统
不过广播、在线客户端维护等内容已经属于综合应用,不是本章必须实现的主线。
六、常见问题
6.1 多发多收到底比一发一收多了什么?
核心只有:
while (...)
但是程序生命周期发生了巨大变化。
一发一收:
收一次
结束
多发多收:
持续运行
持续等待
持续处理
6.2 UDP 服务端如何知道某一条消息是谁发送的?
使用:
packet.getAddress()
和:
packet.getPort()
每个 DatagramPacket 都带有对应远端信息。
6.3 多个客户端为什么可以同时向 8080 发数据?
因为:
8080
是:
服务端的目标端口
不同客户端拥有不同的:
源 IP
+
源端口
例如:
192.168.1.10:51001 → 192.168.1.100:8080
192.168.1.11:51002 → 192.168.1.100:8080
并不冲突。
6.4 客户端退出以后服务端为什么还在等待?
UDP 没有建立类似 TCP 那样的持续连接。
客户端:
socket.close();
只是关闭自己的 UDP Socket。
服务端不会收到:
TCP 连接断开
这样的连接状态事件。
它仍然继续:
receive(...)
6.5 能不能通过 UDP 服务端知道“客户端已经离线”?
仅仅依靠:
客户端突然不再发送 UDP 数据
不能立即可靠判断:
客户端主动退出了
还是:
客户端暂时没消息
还是:
网络断了
如果业务需要在线状态,通常需要额外设计:
登录消息
心跳
超时
离线消息
等应用层协议。
6.6 为什么我第一条短消息正常,第二条长消息却被截断?
如果复用了同一个:
DatagramPacket
检查是否忘记:
packet.setLength(buffer.length);
因为接收完成后 Packet 的长度会更新为:
实际收到的长度
6.7 服务端需要写 exit 吗?
通常不需要。
因为服务端本来就是:
长期运行程序
开发测试阶段可以直接停止进程。
如果需要优雅关闭,可以以后设计:
管理员命令
关闭标志
线程中断
Socket.close()
本章暂不展开。
七、练习与验收
7.1 知识问答
- UDP 一发一收与多发多收的核心程序结构有什么不同?
- 客户端为什么只需要创建一次
DatagramSocket? - 服务端为什么需要循环执行
receive()? - 一个 UDP 服务端为什么可以接收多个发送端的数据?
- 服务端如何知道消息来自哪个客户端?
- UDP 客户端关闭后,服务器为什么不会自动停止?
- UDP 为什么很难仅凭“没收到消息”判断客户端是否离线?
- 重复使用同一个
DatagramPacket时为什么要注意length? packet.setLength(buffer.length)有什么作用?- UDP 多客户端模型为什么不像 TCP 那样天然需要“一个客户端一个 Socket 连接”?
7.2 代码阅读
阅读:
while (true) {
String message =
scanner.nextLine();
if ("exit".equals(message)) {
break;
}
byte[] data =
message.getBytes(
StandardCharsets.UTF_8
);
DatagramPacket packet =
new DatagramPacket(
data,
data.length,
serverAddress,
8080
);
socket.send(packet);
}
回答:
socket应该创建在循环内还是循环外?为什么?exit是否会发送给服务端?- 每次循环都会创建什么新对象?
- 客户端本地端口是否会随着每条消息改变?
- 如果服务器没启动,
send()正常返回是否代表有人收到?
7.3 手写代码
关闭 AI 自动补全。
实现:
UDPChatClient
要求:
- 持续读取用户输入。
- 每条消息发送到
127.0.0.1:9999。 - 使用 UTF-8。
- 输入
exit退出客户端。
实现:
UDPChatServer
要求:
- 监听 UDP
9999。 - 持续接收。
- 输出消息。
- 输出发送方 IP。
- 输出发送方端口。
- 输出字节数。
7.4 Debug
下面程序存在潜在问题:
byte[] buffer =
new byte[1024];
DatagramPacket packet =
new DatagramPacket(
buffer,
buffer.length
);
while (true) {
socket.receive(packet);
System.out.println(
packet.getLength()
);
}
第一次收到:
3 bytes
下一次客户端发送:
100 bytes
请分析:
packet.getLength()在第一次接收后发生了什么变化?- 第二次接收前 Packet 当前允许的接收长度可能是多少?
- 应如何修改?
7.5 综合训练
运行:
UDPServer : 9999
然后同时启动:
Client A
Client B
Client C
分别输入:
A:我是 A
B:我是 B
C:我是 C
观察服务端打印:
消息
IP
端口
并解释:
服务端是如何使用同一个
DatagramSocket区分三台客户端的?
7.6 本章验收
关闭资料后,能够完整画出:
Client A ─┐
Client B ─┼──→ DatagramSocket : 9999
Client C ─┘
│
▼
receive(packet)
│
┌───────┼───────┐
▼ ▼ ▼
Data IP Port
并能独立写出:
while (true) {
socket.receive(packet);
}
以及客户端:
while (true) {
...
socket.send(packet);
}
同时能够解释 DatagramPacket 循环复用时的长度问题,即可认为本章掌握。