1. 为什么我选择用UDP做这个C/S通信案例
1.1 从一次局域网调试说起
前阵子帮一个做物联网设备的朋友排查问题,他的设备端每隔一秒往服务器发一次状态心跳,用的是TCP长连接。设备数量一上来,服务器端就出现大量连接堆积,资源占用高得离谱。我看了下场景:心跳包很小,丢一两个也无所谓,下一包马上就到,根本不需要TCP那种"必须送达、顺序保证"的重型机制。当时我就建议他把心跳通道换成UDP,改完之后服务器负载直接降了一个数量级。
这件事让我意识到,很多做Java网络编程的朋友,一提到Socket就默认上TCP,对UDP的印象停留在"不可靠、会丢包、不能用"这种模糊认知上。其实UDP在实时性要求高、能容忍少量丢包、或者需要一对多广播的场景里,反而是更合适的选择。所以我打算用一个完整的Java C/S通信案例,把UDP这套东西从头到尾讲透,包括协议原理、Socket API怎么用、代码怎么写、坑在哪里。
这个案例适合谁看?如果你已经会写基本的Java程序,懂一点多线程,想搞清楚网络编程里UDP这条线到底怎么落地,那这篇内容就是给你准备的。我会从最基础的数据报概念讲起,一路写到能跑起来的客户端和服务端,中间穿插我自己踩过的坑和调试技巧。看完你至少能做到:独立写出一个UDP的C/S通信程序,知道什么时候该用它、什么时候不该用,遇到问题知道往哪个方向排查。
1.2 UDP和TCP到底差在哪,别再用错场景
要理解UDP编程,先得把UDP和TCP的本质区别搞清楚。我用一个生活化的类比:TCP像是打电话,你得先拨号、对方接听、确认双方都在线,然后才能说话,说的每句话对方都会"嗯"一声确认收到,顺序也不会乱;UDP像是寄明信片,你写好地址直接扔进邮筒,不确认对方收没收到,也不保证几张明信片按顺序到达,甚至可能丢几张。
这个差异落到技术层面,核心是这么几点:
| 对比维度 | TCP | UDP |
|---|---|---|
| 连接方式 | 面向连接,三次握手 | 无连接,直接发 |
| 可靠性 | 保证送达、去重、按序 | 不保证,可能丢、可能乱序 |
| 传输效率 | 头部开销大,有拥塞控制 | 头部仅8字节,无拥塞控制 |
| 传输方式 | 字节流 | 数据报(一个个独立包) |
| 适用场景 | 文件传输、网页、登录 | 实时音视频、心跳、广播、游戏 |
关键要理解"数据报"这个概念。TCP是字节流,你发100字节、再发200字节,接收端可能一次读到300字节,边界是模糊的。UDP不一样,你发一个100字节的包,接收端要么完整收到这100字节,要么什么都没收到,绝不会出现"收到一半"或者"两个包粘在一起"的情况。这个特性叫消息边界保留,是UDP编程里非常重要的一个点,后面写代码时会反复用到。
那什么时候该用UDP?我的经验判断标准是三条:第一,能容忍丢包,比如实时语音,丢一帧就丢一帧,重传反而造成延迟;第二,需要低延迟,比如游戏里的位置同步,等TCP重传的功夫玩家早跑远了;第三,需要一对多,比如局域网设备发现、服务广播。反过来,凡是涉及钱、涉及数据完整性、涉及顺序的,老老实实上TCP,别为了省事用UDP给自己挖坑。
1.3 这个案例的整体设计思路
我设计的这个C/S通信案例,目标很明确:客户端发送消息,服务端接收并回复,双方能持续对话。为了把UDP的特性体现出来,我做了几个设计决策。
第一,服务端用单线程循环接收。UDP是无连接的,服务端不需要为每个客户端维护连接,一个DatagramSocket就能接收所有客户端发来的包,通过包里的来源地址区分是谁发的。这跟TCP服务端要为每个连接开一个线程完全不同,资源占用低得多。
第二,客户端和服务端都用DatagramPacket来封装数据。这是UDP编程的核心类,发送时把数据、目标地址、目标端口打包进去,接收时用一个空包去接,接完从包里取出数据和来源信息。
第三,我加了一个简单的应用层确认机制。UDP本身不保证送达,但业务上如果确实需要知道对方收没收到,可以在应用层自己实现一个"收到请回复"的逻辑。这个案例里我会演示怎么加,但也会说明什么时候不该加。
第四,端口和地址都做成可配置的。实际项目里硬编码IP和端口是大忌,我把它抽成常量或者参数,方便你改。
整个案例的代码量不大,但麻雀虽小五脏俱全,把UDP编程的发送、接收、地址解析、异常处理、资源释放这些环节都覆盖到了。下面我分模块拆开讲。
2. 核心API拆解与实操前的准备
2.1 DatagramSocket和DatagramPacket这两个类必须吃透
Java的UDP编程,核心就两个类:DatagramSocket和DatagramPacket。把这两个类搞明白,UDP编程就掌握了一大半。
DatagramSocket可以理解成"邮局",它负责收发数据报。它有两个关键构造方法:new DatagramSocket()会绑定一个随机可用端口,通常客户端用这个;new DatagramSocket(int port)会绑定指定端口,服务端用这个,因为客户端得知道往哪个端口发。这个类提供了两个核心方法:send(DatagramPacket)发送,receive(DatagramPacket)接收。注意receive是阻塞的,没收到包就一直卡在那里,这一点跟TCP的accept类似。
DatagramPacket可以理解成"信封",它封装了数据内容、数据长度、目标地址、目标端口。发送用的包和接收用的包构造方式不一样,这是新手最容易搞混的地方。
发送用的包,构造时要指定"发给谁":
// 数据、数据长度、目标IP、目标端口 byte[] data = "hello".getBytes(); InetAddress target = InetAddress.getByName("127.0.0.1"); DatagramPacket sendPacket = new DatagramPacket(data, data.length, target, 8888);接收用的包,构造时只指定"缓冲区多大",不指定来源,因为来源是收到之后才知道的:
byte[] buffer = new byte[1024]; DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length); socket.receive(receivePacket); // 收到后,receivePacket里就有了来源地址和数据收到之后,通过receivePacket.getAddress()拿来源IP,getPort()拿来源端口,getData()拿数据,getLength()拿实际数据长度。这里有个坑:getData()返回的是整个缓冲区,不是实际收到的数据。如果你缓冲区开了1024字节,实际只收到10字节,getData()还是返回1024字节的数组,后面全是0。所以一定要用getLength()来截取有效数据,这个坑我后面会专门讲。
2.2 环境准备和项目结构
环境这块没什么特殊要求,JDK 8以上就行,我用的是JDK 17,语法上没用到太新的特性,向下兼容。开发工具随意,IDEA、Eclipse、VS Code都能写。不需要任何第三方依赖,纯JDK自带的java.net包就够。
项目结构我建议这样组织,清晰好维护:
udp-demo/ ├── src/ │ ├── UdpServer.java // 服务端 │ ├── UdpClient.java // 客户端 │ └── UdpConstants.java // 常量配置UdpConstants.java放公共配置,避免IP端口散落在各处:
public class UdpConstants { public static final int SERVER_PORT = 8888; public static final String SERVER_HOST = "127.0.0.1"; public static final int BUFFER_SIZE = 1024; public static final String CHARSET = "UTF-8"; }这里我特意把字符集抽出来。UDP传输的是字节,字符串转字节、字节转字符串都要指定字符集,不指定就用平台默认,跨平台时中文会乱码。统一用UTF-8是最稳的做法,这个细节很多人会忽略,等到线上出现乱码才回头找。
提示:缓冲区大小
BUFFER_SIZE设多大合适?UDP单个数据报理论上最大能到64KB左右(受IP层限制),但实际网络环境中,超过MTU(通常1500字节)的包会被分片,分片后任何一片丢了整个包就废了。所以业务上单个UDP包建议控制在1400字节以内,缓冲区开1024或2048都行,别开太大浪费内存。
2.3 端口选择与防火墙注意事项
端口选择有个经验:1024以下的端口是系统保留端口,需要管理员权限才能绑定,普通程序别去碰。1024到49151是注册端口,一般应用用这个范围。49152以上是动态端口,系统临时分配用。我选8888,属于注册端口,好记又不容易冲突。
实际部署时,防火墙是UDP编程绕不开的坎。TCP有连接状态,防火墙容易判断;UDP无连接,很多防火墙默认策略是"只允许内部发出去的UDP回包,不允许外部主动发进来的UDP包"。所以如果你在云服务器上跑UDP服务端,客户端连不上,第一件事就是检查安全组和防火墙有没有放行UDP端口。这个坑我踩过不止一次,本地测试好好的,一上服务器就收不到包,排查半天发现是防火墙。
另外提一句,127.0.0.1是回环地址,只能本机通信。要让局域网其他机器连上,服务端绑定时要用0.0.0.0或者不指定地址(默认绑所有网卡),客户端则要填服务端的真实局域网IP。这个区别新手经常搞混,本地测试用127.0.0.1没问题,一换机器就连不上。
3. 服务端与客户端的完整实现
3.1 服务端:一个循环搞定收发
服务端的逻辑很直白:绑定端口,开一个缓冲区,循环接收,收到就处理,处理完回复。我先把完整代码贴出来,然后逐段讲。
import java.net.*; import java.nio.charset.StandardCharsets; public class UdpServer { public static void main(String[] args) { DatagramSocket socket = null; try { // 1. 绑定端口,服务端必须指定端口 socket = new DatagramSocket(UdpConstants.SERVER_PORT); System.out.println("服务端已启动,监听端口:" + UdpConstants.SERVER_PORT); byte[] buffer = new byte[UdpConstants.BUFFER_SIZE]; // 2. 循环接收 while (true) { // 每次接收都要新建一个包,避免复用导致数据错乱 DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length); socket.receive(receivePacket); // 阻塞等待 // 3. 解析数据,注意用 getLength() 截取有效长度 String msg = new String( receivePacket.getData(), 0, receivePacket.getLength(), StandardCharsets.UTF_8 ); InetAddress clientAddr = receivePacket.getAddress(); int clientPort = receivePacket.getPort(); System.out.printf("收到来自 %s:%d 的消息:%s%n", clientAddr.getHostAddress(), clientPort, msg); // 4. 构造回复 String reply = "服务端已收到:" + msg; byte[] replyBytes = reply.getBytes(StandardCharsets.UTF_8); DatagramPacket sendPacket = new DatagramPacket( replyBytes, replyBytes.length, clientAddr, clientPort ); socket.send(sendPacket); } } catch (Exception e) { e.printStackTrace(); } finally { // 5. 释放资源 if (socket != null && !socket.isClosed()) { socket.close(); } } } }逐段说下关键点。第一步绑定端口,new DatagramSocket(8888),如果端口被占用会抛BindException,这个异常要能识别,通常意味着有另一个程序占着这个端口,换个端口或者杀掉占用进程。
第二步循环接收,这里有个细节:DatagramPacket我放在循环体内每次新建。为什么?因为receive方法会修改传入的包对象,把来源地址、实际长度等信息写进去。如果你在循环外建一个包反复用,虽然大多数情况也能跑,但一旦出现异常或者数据长度变化,容易出诡异问题。每次新建是最稳妥的,代价就是一点对象创建开销,对UDP这种场景完全可以接受。
第三步解析数据,重点看new String(data, 0, length, charset)这三个参数。第一个是缓冲区,第二个是起始偏移0,第三个是getLength(),第四个是字符集。为什么不用new String(data, charset)?因为那样会把整个缓冲区都转成字符串,包括后面没用的0字节,结果就是字符串末尾一堆乱码。这个坑非常经典,我第一次写UDP就栽在这里,收到的消息后面总是跟着一串问号。
第四步回复,用收到的来源地址和端口构造发送包,原路返回。注意这里clientAddr和clientPort是从接收包里取的,不是写死的,这样服务端才能同时服务多个客户端。
第五步资源释放,放在finally里,保证异常时也能关闭。DatagramSocket不关的话,端口会一直被占用,下次启动就报BindException。
3.2 客户端:发送、接收、再发送
客户端比服务端稍微绕一点,因为它既要发也要收,而且收的时候也是阻塞的。我设计成"发一条、收一条"的问答模式,逻辑清晰。
import java.net.*; import java.nio.charset.StandardCharsets; import java.util.Scanner; public class UdpClient { public static void main(String[] args) { DatagramSocket socket = null; try { // 1. 客户端不指定端口,系统随机分配 socket = new DatagramSocket(); // 2. 设置接收超时,避免服务端没响应时永久阻塞 socket.setSoTimeout(5000); InetAddress serverAddr = InetAddress.getByName(UdpConstants.SERVER_HOST); Scanner scanner = new Scanner(System.in); System.out.println("客户端已启动,输入消息发送(输入 exit 退出)"); while (true) { System.out.print("> "); String input = scanner.nextLine(); if ("exit".equalsIgnoreCase(input)) { break; } // 3. 发送 byte[] sendBytes = input.getBytes(StandardCharsets.UTF_8); DatagramPacket sendPacket = new DatagramPacket( sendBytes, sendBytes.length, serverAddr, UdpConstants.SERVER_PORT ); socket.send(sendPacket); // 4. 接收回复 byte[] buffer = new byte[UdpConstants.BUFFER_SIZE]; DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length); try { socket.receive(receivePacket); String reply = new String( receivePacket.getData(), 0, receivePacket.getLength(), StandardCharsets.UTF_8 ); System.out.println("服务端回复:" + reply); } catch (SocketTimeoutException e) { System.out.println("等待服务端响应超时,请检查服务端是否启动"); } } } catch (Exception e) { e.printStackTrace(); } finally { if (socket != null && !socket.isClosed()) { socket.close(); } } } }客户端第一个关键点是new DatagramSocket()不指定端口,让系统随机分配一个。因为客户端不需要被别人主动连接,端口是多少无所谓,系统分配就行。
第二个关键点是setSoTimeout(5000)。UDP不可靠,服务端可能没启动、可能挂了、可能包丢了,如果不设超时,receive会一直阻塞,程序看起来像卡死。设了5秒超时后,超时会抛SocketTimeoutException,我们捕获它给用户一个友好提示。这个设置在实际项目里几乎是必须的,我强烈建议你养成习惯。
第三个关键点是发送和接收的包分别构造。发送包指定目标地址端口,接收包只指定缓冲区。这里我在循环内每次新建缓冲区,避免上一轮的数据残留影响下一轮。
3.3 跑起来看看效果
代码写完,先启动服务端,再启动客户端。服务端控制台输出:
服务端已启动,监听端口:8888客户端输入"你好",服务端立刻打印:
收到来自 127.0.0.1:54321 的消息:你好客户端收到回复:
服务端回复:服务端已收到:你好注意客户端端口54321是系统随机分配的,每次启动可能不一样,这就是UDP无连接的体现——服务端不关心你是谁,只根据包里的来源地址回给你。
你可以同时启动多个客户端,服务端都能处理,因为它是单线程循环,来一个包处理一个。这就是UDP服务端资源占用低的原因,不需要为每个客户端维护连接状态。
3.4 参数计算:缓冲区到底开多大
前面提了缓冲区大小,这里展开算一下。UDP数据报的结构是:IP头20字节 + UDP头8字节 + 数据。以太网MTU是1500字节,所以UDP数据部分最大约1472字节(1500 - 20 - 8)。超过这个值,IP层会分片,分片增加丢包风险。
所以我的建议是:业务上单个UDP包控制在1400字节以内,缓冲区开1024或2048。如果你要传大文件,别用UDP硬扛,要么自己在上层做分片重组,要么直接换TCP。我见过有人用UDP传几MB的文件,自己写分片和重传,最后代码复杂度比直接用TCP高十倍,得不偿失。
字符编码也要算进去。UTF-8下,一个中文占3字节,一个英文占1字节。如果你缓冲区开1024,理论上能放341个中文。但实际业务里还要留余量,别卡着边界用。
4. 常见问题与排查技巧实录
4.1 收到乱码怎么办
乱码是UDP编程最高频的问题,没有之一。原因通常有三个:发送端和接收端字符集不一致、接收时没截取有效长度、缓冲区复用了脏数据。
第一个原因最好排查,两端都统一用StandardCharsets.UTF_8,别用new String(bytes)这种依赖平台默认字符集的写法。Windows默认GBK,Linux默认UTF-8,跨平台必乱。
第二个原因就是前面反复强调的getLength()。我整理了一个对照表:
| 错误写法 | 正确写法 | 后果 |
|---|---|---|
new String(packet.getData()) | new String(packet.getData(), 0, packet.getLength(), UTF_8) | 末尾一堆乱码 |
new String(packet.getData(), UTF_8) | 同上 | 同上 |
| 不指定字符集 | 指定UTF_8 | 跨平台乱码 |
第三个原因是缓冲区复用。如果你在循环外建了一个byte[] buffer反复用,上一轮的长数据会残留在缓冲区里,下一轮短数据覆盖不全,就会读到脏数据。解决办法是每次接收前清空缓冲区,或者干脆每次新建。我倾向于每次新建,代码清晰,不容易出错。
4.2 服务端收不到包怎么排查
服务端收不到包,按这个顺序排查,基本能定位:
服务端启动了吗:先确认服务端进程在跑,端口监听正常。可以用
netstat -anp | grep 8888(Linux)或netstat -ano | findstr 8888(Windows)看端口有没有被监听。地址对不对:客户端连的是
127.0.0.1还是局域网IP?如果服务端在另一台机器,客户端必须填那台机器的真实IP,不能填127.0.0.1。防火墙放行了吗:这是最常见的坑。云服务器要检查安全组,本地要检查系统防火墙。UDP端口要单独放行,很多人只放行了TCP忘了UDP。
端口被占用了吗:如果服务端启动时报
BindException,说明端口被别的程序占了,换个端口。包真的发出去了吗:在客户端
send之后打印一行日志,确认代码执行到了。有时候是客户端逻辑问题,根本没发出去。
我做过一个速查表,贴在下面方便对照:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 服务端无任何输出 | 包没到达 | 检查IP、端口、防火墙 |
| 服务端报BindException | 端口被占用 | 换端口或杀进程 |
| 客户端receive超时 | 服务端没回或包丢了 | 检查服务端逻辑、网络 |
| 收到乱码 | 字符集或长度问题 | 统一UTF-8,用getLength |
| 只能收到第一个包 | 缓冲区或包复用问题 | 每次新建包和缓冲区 |
4.3 UDP丢包了怎么办
UDP丢包是常态,不是异常。处理方式取决于业务需求。
如果业务能容忍丢包,比如实时视频、游戏位置同步,那就别管它,丢就丢了,下一帧马上来。硬要重传反而增加延迟,得不偿失。
如果业务需要可靠,有两个选择。一是在应用层自己实现确认重传机制:发送方发完等ACK,超时没收到就重发,收到重复包就丢弃。这个逻辑不难,但要处理好超时时间、重传次数、去重。二是在UDP之上用现成的可靠协议,比如QUIC,不过那又是另一个话题了。
我在案例里演示的是最简单的"发一条收一条",其实已经隐含了一个同步确认:客户端发完就等回复,收到回复说明这一轮成功了,超时说明可能丢了。如果你要连续发多条不等回复,那就得自己加序号和ACK了。
注意:应用层做重传时,超时时间别设太短。局域网内50到100毫秒合理,公网建议200到500毫秒。设太短会导致大量无谓重传,反而加重网络负担。
4.4 几个我踩过的坑
第一个坑:DatagramSocket的receive方法会阻塞,如果你在UI线程里调用,界面会卡死。正确做法是放到独立线程里,或者用setSoTimeout配合循环。这个在写桌面程序时特别容易犯。
第二个坑:DatagramPacket的getData()返回的数组长度是缓冲区大小,不是实际数据长度。我前面强调过,但还是要再说一遍,因为太容易错了。判断实际长度永远用getLength()。
第三个坑:服务端回复时,如果客户端已经关闭了socket,回复的包会发到一个没人监听的端口,这个包就丢了,服务端不会收到任何错误。UDP就是这样,发出去就不管了,所以别指望通过send的返回值判断对方收没收到。
第四个坑:端口号别用字符串拼。有人把端口写成"8888"然后Integer.parseInt,多此一举还容易出错。端口就是int,直接用。
第五个坑:测试时用localhost和127.0.0.1有时候行为不一致,某些系统上localhost会解析成IPv6的::1,导致绑定IPv4的服务端收不到。统一用127.0.0.1最稳。
5. 从案例延伸到实际应用场景
5.1 局域网设备发现
UDP最经典的应用之一就是局域网设备发现。原理很简单:客户端往广播地址255.255.255.255的某个端口发一个探测包,局域网内所有监听该端口的设备都会收到,然后各自回复自己的信息。这样客户端不用知道设备IP,就能发现它们。
实现上,发送端要开setBroadcast(true),否则广播包发不出去。接收端正常绑定端口接收即可。这个模式在智能家居、打印机发现、局域网游戏联机里用得非常多。相比TCP,UDP广播是一对多的天然优势,TCP做不到。
5.2 实时心跳与状态上报
回到开头那个物联网的例子。设备端每隔一秒发一个心跳包,包含设备ID和状态,服务端收到就更新设备在线状态。丢一两个心跳无所谓,下一个马上补上。这种场景用UDP,服务端不需要维护成千上万的连接,一个socket全搞定,资源占用极低。
如果要判断设备离线,服务端可以记录每个设备最后一次心跳的时间,超过比如5秒没收到就标记离线。这个逻辑简单可靠,比TCP的断线检测还省事。
5.3 什么时候坚决不能用UDP
说了这么多UDP的好,也得说说什么场景坚决不能用。凡是涉及数据完整性、顺序性、不能丢的,一律上TCP。比如文件传输、数据库操作、支付交易、登录认证,这些用UDP就是给自己找麻烦。我见过有人为了"高性能"用UDP传订单数据,结果丢了一单,赔了不少钱。性能再高,数据错了就是零分。
判断标准很简单:问自己一句"这个数据丢一条会怎样"。如果答案是"无所谓"或者"下一帧会补上",用UDP;如果答案是"会出大事",用TCP。别为了技术而技术,合适才是最好的。
5.4 后续可以怎么扩展
这个案例是个起点,你可以基于它做很多扩展。比如加一个简单的应用层协议,定义消息格式(类型+长度+内容),支持不同类型的消息。比如加一个客户端列表,服务端记录所有发过消息的客户端,支持群发。比如加一个心跳机制,客户端定时发心跳,服务端清理超时的客户端。
再往深了走,可以研究一下Java的NIO,用DatagramChannel做非阻塞的UDP通信,单线程处理大量并发。不过那是另一个层次的内容了,等你把基础的DatagramSocket玩熟了再往上走。
我个人在实际操作中的体会是,UDP编程的难点不在API,API就那么几个方法,半天就能学会。真正的难点在于理解它的"不可靠"本质,并且在业务设计上接受它、利用它。很多人写UDP代码时总想着"怎么保证不丢",结果把代码写得无比复杂,反而失去了UDP轻量的优势。正确的思路是:先判断业务能不能容忍丢包,能容忍就大胆用,不能容忍就换TCP,别在UDP上硬造可靠性。这个思路转变过来,UDP用起来就顺手了。