
简介这是一套面向 Java 开发者的实时传输协议客户端示例资源基于开源 jlibrtp 库实现重点演示了 RTP 会话创建、音视频数据收发、序列号与时间戳同步以及 RTCP 反馈处理等完整流程适用于网络电话、视频会议等需要低延迟多媒体通信的开发场景。压缩包共 45 个文件包含三十余份 Java 源码覆盖协议核心类、数据包处理、参与者管理、会话管理等多个模块并带有声音收发、单播通信等可直接运行的演示程序以及用于验证协议行为的测试用例另有三个 HTML 文档和三个 TXT 说明文件压缩包总大小仅 108 KB结构清晰适合快速导入学习或二次开发。包内附有详细的使用说明读者可据此掌握 RTP 会话的建立、本地与远程地址端口的配置、数据包监听器的注册、sendPacket 发送和接收回调等关键接口的具体用法从而降低从零实现协议的成本并为后续开发自定义的音视频传输应用打下基础。已有 327 人学习下载适合正在学习 Java 网络编程、实时音视频传输或准备在项目中集成实时通信能力的开发者。1. 先给“javartp 客户端”正名你要处理的不是游戏资源包如果你在搜索引擎里敲下RTP三个字母前排结果很可能被 RPG Maker VX Ace 的运行库报错霸占那一串 “RPGVXACE RTP is required to run this game” 让很多人误以为 RTP 是个素材包。但换一个技术语境RTP 是实时传输协议Real-time Transport Protocol走 UDP专门承载音视频数据。这篇文章要解决的是用 Java 写一个能接收 RTP 媒体流的客户端。很多 Java 工程师第一反应是开一个DatagramSocket收 UDP 包再把byte[]直接塞给播放器结果十有八九翻车。RTP 的序列号、时间戳、载荷类型、乱序和丢包恢复缺一个环节播放就是断断续续甚至黑屏。如果你要对接 SIP 语音网关、监控平台推流或者自建流媒体转分发网关下面这条路可以帮你从“能收包”走到“能听清”。2. 为什么第一反应想到的 Javam 反而是个大坑2.1 Javam 的历史遗留问题JDK 9 之后就很难玩提到 Java 处理 RTP很多老资料会指向 JMFJava Media Framework也就是 Javam。这个库在 2003 年发布 1.0.3 之后基本停摆内部大量依赖sun.*私有接口和本地原生库。JDK 8 时代它还能靠一些 hack 跑起来但到了 JDK 9 之后的模块化 JDK跨模块反射被默认禁止Javam 加载 native 库的方式很容易触发IllegalAccessError。我见过不止一个团队在项目里引入jmf.jar结果运行时不是ClassNotFoundException就是NoClassDefFoundError排查半天发现是 Javam 自带的JMFSecurity在做类加载隔离。即便侥幸跑起来它的 RTP 接收模块也是个黑匣子你很难控制抖动缓冲的深度没法精细处理乱序包出了问题只能往上抛异常。2.2 三条可选路线Javam、FFmpeg 封装、纯 Java 自研把选项摆开看工程上一般有三条路方案依赖现代 JDK 兼容性可控性适用场景Javam 的 RTPManagerjmf.jar加本地库JDK 8 勉强JDK 9 困难低黑匣子老项目维护快速原型验证JavaCV / 进程调用 FFmpeg大量 JNI 依赖或子进程较好中但偏离“RTP 客户端”本意要同时处理转码、封装、多格式播放纯 Java UDP RTP 解析只依赖 JDK完全兼容高代码全在自己手里轻量级接收端、网关、协议学习FFmpeg 那条路线我也认真考虑过JavaCV 封装 FFmpeg拉流播放确实省事。但问题是你等于把 FFmpeg 当黑盒子用写出来的不是 RTP 客户端而是“FFmpeg 调用器”。如果标题要的是java rtp 客户端纯 Java 自研才能真正控制收发逻辑也方便后续扩展 RTCP 反馈和自适应缓冲。2.3 自研方案的边界别把协议栈重写当成目的自研绝不等于从零发明协议。RTP 的头部格式、RTCP 报文结构都有 RFC 3550 规定你只需要按规范解析和组装。我一般把自研范围控制在传输控制面UDP 收发、RTP 头解析、抖动缓冲、乱序重排、RTCP RR 反馈、G.711 等简单载荷解码。至于视频 H.264 的封包细节RTP 聚合包 STAP-A、分片包 FU-A第一次做可以先用音频 PCMU 打通全链路再逐步加视频负载格式。判断是否适合自研有一个简单标准你需要长期维护并扩展这套收发逻辑且对延迟、缓冲深度有明确要求那就自研如果只是临时拉流看个效果用现成库快得多。3. 纯 Java 实现 RTP 客户端最小闭环收包、解析、输出 PCM3.1 先写 UDP 接收线程别在主线程里干活的理由RTP 底层是 UDP接收端要做的就是绑定端口、循环收包。我习惯把收包逻辑单独放一个线程因为DatagramSocket.receive()是阻塞的放在业务线程里会卡死主流程。public class UdpRtpReceiver implements Runnable { private final int port; private final int bufferSize 2048; // 常见 MTU 下 1500 字节足够2048 留余量 private final byte[] buffer new byte[bufferSize]; private final RtpPacketParser parser new RtpPacketParser(); private final JitterBuffer jitterBuffer; private volatile boolean closed false; public UdpRtpReceiver(int port, JitterBuffer jitterBuffer) { this.port port; this.jitterBuffer jitterBuffer; } Override public void run() { try (DatagramSocket socket new DatagramSocket(port)) { socket.setSoTimeout(200); // 200ms 超时用于轮询 closed 标志 DatagramPacket packet new DatagramPacket(buffer, buffer.length); while (!closed) { try { socket.receive(packet); RtpPacket rtp parser.parse(packet.getData(), packet.getLength()); if (rtp ! null) { jitterBuffer.add(rtp); } } catch (SocketTimeoutException ignored) { // 超时说明没有新包继续检查 closed 状态即可 } } } catch (SocketException e) { throw new IllegalStateException(绑定端口失败检查端口是否被占用: port, e); } } public void close() { this.closed true; } }这段代码的关键点在setSoTimeout(200)。如果不设置超时receive()会一直阻塞外部调用close()也无法让线程退出。设了超时之后最多 200ms 轮询一次关闭标志收包中断延迟可以接受。缓冲数组复用也很讲究DatagramPacket每次 receive 都会把数据写入同一个byte[]这里new byte[2048]只做一次避免每包都触发 GC 压力。有人习惯在循环里new DatagramPacket那是给垃圾回收器找活干。3.2 解析 RTP 固定头部12 个字节定生死RTP 头固定 12 字节顺序是版本(2bit)、填充位(1bit)、扩展位(1bit)、CSRC 计数(4bit)、标志位(1bit)、载荷类型(7bit)、序列号(16bit)、时间戳(32bit)、SSRC(32bit)。Java 没有无符号整数解析时最大的坑是byte的符号扩展。public class RtpPacket { private final int payloadType; private final int sequence; private final long timestamp; private final long ssrc; private final byte[] payload; public RtpPacket(int payloadType, int sequence, long timestamp, long ssrc, byte[] payload) { this.payloadType payloadType; this.sequence sequence; this.timestamp timestamp; this.ssrc ssrc; this.payload payload; } // getter 省略 }public RtpPacket parse(byte[] data, int length) { if (length 12) { return null; // 小于固定头长度的包直接丢弃 } int version (data[0] 6) 0x03; if (version ! 2) { return null; // 只认 RTP 版本 2 } int payloadType data[1] 0x7F; // 去掉 marker 位 boolean marker (data[1] 0x80) ! 0; int sequence ((data[2] 0xFF) 8) | (data[3] 0xFF); long timestamp ((long) (data[4] 0xFF) 24) | ((data[5] 0xFF) 16) | ((data[6] 0xFF) 8) | (data[7] 0xFF); long ssrc ((long) (data[8] 0xFF) 24) | ((data[9] 0xFF) 16) | ((data[10] 0xFF) 8) | (data[11] 0xFF); int headerLen 12; byte[] payload new byte[length - headerLen]; System.arraycopy(data, headerLen, payload, 0, payload.length); return new RtpPacket(payloadType, sequence, timestamp, ssrc, payload); }位运算这里的 0xFF是必须的。Java 的byte是 -128 到 127直接做移位运算会把负数的高位全部变成 1比如0x80会被当成 -128导致序列号错乱。 0xFF的作用是先转成 0 到 255 的无符号值再参与计算。我一般只校验版本号不校验payloadType是否在预期范围。因为有的推流设备会动态协商载荷类型一开始你拿到的是 96跟 SDP 协商后才确定编码格式太早拦截反而误杀正常流。3.3 G.711 载荷解码把 μ-law 字节变成可播放的 PCMRTP 音频流最常用 G.711 编码PCMUμ-law和 PCMAA-law都叫法不同。接收端拿到的是压缩后的 8 位样本直接播放会发出刺耳噪音必须先解码成 16 位线性 PCM。public class G711Codec { private static final short[] MULAW_DECODE_TABLE new short[256]; static { for (int i 0; i 256; i) { MULAW_DECODE_TABLE[i] decodeSample(i); } } private static short decodeSample(int mu) { // 标准 μ-law 解码公式展开为查表用 int sign (mu 0x80) 0 ? 1 : -1; int exponent (mu 4) 0x07; int mantissa mu 0x0F; int magnitude ((mantissa 3) 0x84) exponent; return (short) (sign * (magnitude - 0x84)); } public static short[] decode(byte[] payload) { short[] pcm new short[payload.length]; for (int i 0; i payload.length; i) { pcm[i] MULAW_DECODE_TABLE[payload[i] 0xFF]; } return pcm; } }生产环境推荐静态初始化查表256 个short也就 512 字节换来的是一次查表代替十几条位运算指令。如果你要处理的是 A-law解码公式里极性位和指数位的位置不同别直接套用。G.711 的采样率固定 8kHz每个样本 8bit所以 20ms 的音频帧恰好是 160 字节这个数字后面调抖动缓冲会反复用到。3.4 把三段拼成一个最小接收骨架收包线程、RTP 解析、G.711 解码连起来就构成一个能跑通的最小客户端public class MinimalRtpClient { public static void main(String[] args) { int port 10000; JitterBuffer jitterBuffer new JitterBuffer(50); // 目标延迟 50ms UdpRtpReceiver receiver new UdpRtpReceiver(port, jitterBuffer); Thread receiverThread new Thread(receiver, rtp-receiver); receiverThread.start(); // 消费线程从抖动缓冲取包并解码 new Thread(() - { while (true) { RtpPacket packet jitterBuffer.poll(); if (packet null) { Thread.onSpinWait(); continue; } short[] pcm G711Codec.decode(packet.getPayload()); // 此处把 pcm 交给 AudioTrack、SourceDataLine 或写入文件 } }, rtp-player).start(); } }这里我直接引出了JitterBuffer它是下一章的主角。如果你只是验证一把可以先用一个无阻塞队列代替但要做好心理准备没有抖动缓冲G.711 的 20ms 一包在公网上播放出来就是断续的这不是代码 bug是网络抖动决定的。4. 从“能收包”到“能听清”抖动缓冲、回绕处理与 RTCP 反馈4.1 JitterBuffer 设计为什么顺序播放反而让声音断断续续UDP 网络本身的延迟是波动的一个 8kHz 音频流每 20ms 发一包但包到达间隔可能是 15ms、30ms、甚至 100ms。如果把收到的包按到达顺序直接播放播放器就会时快时慢听感就是一顿一顿。抖动缓冲的本质是故意多等一段时间把先到的包存起来等播放时间到了再取。这样网络波动被缓冲吸收。我用的实现是一个按序列号索引的稀疏数组public class JitterBuffer { private final RtpPacket[] slots; private final int capacity; // 最多容纳的包数 private final int targetDelayMs; // 目标缓冲延迟单位 ms private final int samplesPerPacket; // 每个包的采样数G.711 20ms 是 160 private int readIndex; // 已播放的位置序列号维度 public JitterBuffer(int targetDelayMs) { this.capacity 512; this.targetDelayMs targetDelayMs; this.samplesPerPacket 160; // 8kHz * 20ms this.slots new RtpPacket[capacity]; } public void add(RtpPacket packet) { int seq packet.getSequence(); int slot Math.floorMod(seq, capacity); slots[slot] packet; } public RtpPacket poll() { RtpPacket packet slots[Math.floorMod(readIndex, capacity)]; if (packet null) { return null; } // 目标延迟内还没到播放时刻继续等 long delayMs (packet.getTimestamp() - lastTimestamp) / 8; // 8kHz 采样率 if (delayMs targetDelayMs) { return null; } slots[Math.floorMod(readIndex, capacity)] null; readIndex; return packet; } }简化版里我用序列号数组直接映射用一个整数readIndex表示预期播放的序列号。Math.floorMod处理负数取模比%安全因为序列号回绕时可能出现负数。目标延迟设多少是门玄学。设 40-60ms 能吸收一般网络抖动设 200ms 几乎不卡但延迟感人。我一般先用 RTCP jitter 字段估算取平均抖动的 3 倍左右作为初始值再根据丢包率动态调。硬编码 50ms 作为起步值不算错但别指望一路通用。4.2 序列号和时间戳的回绕处理以及比较的位运算写法RTP 序列号是 16 位无符号整数范围 0 到 65535。持续高码率流跑个几小时就会回绕。很多人用普通大于小于比较一旦从 65535 跳回 0新包全被当成旧包丢弃。正确写法是用环形差值判断public static boolean isNewer(int seq, int reference) { int diff (seq - reference) 0xFFFF; return diff 32768 diff ! 0; } public static boolean isOlder(int seq, int reference) { return isNewer(reference, seq); }这个写法的原理是把 16 位序列号看成环(seq - reference) 0xFFFF得到的是从 reference 到 seq 的“正向距离”。如果距离在 32768 以内说明 seq 是更新的如果距离正好为 0说明两个包序列号相同视为重复。用在抖动缓冲里判断新包能不能覆盖旧包、判断乱序包是否应该丢弃全都靠这个函数。时间戳是 32 位无符号同理处理。但要注意时间戳的单位不是毫秒而是采样频率的倒数。G.711 是 8000Hz所以时间戳每增加 8000 代表过了 1 秒。换算延迟时用(timestampDelta) / 8得到毫秒这是很多人写错的点直接把时间戳差当毫秒用导致延迟计算全部失真。4.3 RTCP Receiver Report把丢包率告诉对端而不是傻等RTP 和 RTCP 是一对搭档。RTP 传媒体数据RTCP 传质量反馈。接收端应该周期性RFC 3550 建议 5 秒给发送端回一个 RRReceiver Report报文里面带上累计丢包数、抖动值和延迟信息。发送端看到丢包率超过阈值会主动降码率或换编码。public byte[] buildReceiverReport(long ssrc, long remoteSsrc, int fractionLost, int cumulativeLost, int extendedHighestSeq, int jitter) { byte[] report new byte[32]; report[0] (byte) 0x81; // V2, 无填充, 无扩展, RC1(一个接收报告块) report[1] (byte) 0xC9; // PT201, RR report[2] 0x00; report[3] 0x07; // 报文长度(32位字) - 1这里 8 个字减 1 // 写入本端 SSRC writeInt(report, 4, ssrc); // 写入发送端 SSRC writeInt(report, 8, remoteSsrc); // fraction lost 是 8 位小数格式loss / total * 256 report[12] (byte) fractionLost; // cumulative lost 24 位 write24(report, 13, cumulativeLost); // extended highest sequence 和 jitter writeInt(report, 16, extendedHighestSeq); writeInt(report, 20, jitter); // LSR 和 DLSR 一般填 0或从 SR 报文中取 writeInt(report, 24, 0); writeInt(report, 28, 0); return report; }RR 格式里最容易漏的是extended highest sequence它是 32 位高 16 位是序列号回绕计数低 16 位是最近收到的最高序列号。如果你的接收端不知道回绕计数丢包率统计会在一轮回绕之后全错。常见的做法是用一个long记录扩展序列号每收到一个新包判断回绕则高位加一低 16 位就是sequenceNumber。RTCP 报文用同一端口的奇数号端口发送RTP 是偶数端口但实际网络环境里 NAT 和防火墙经常把这个端口挡掉。所以工程上还要支持同端口复用RTP 和 RTCP 都走同一个 UDP socket用payloadType和报文类型区分。遇到对称 RTP 的设备必须这么做。4.4 如何评估自己的实现丢包率、乱序率、抖动值从哪里看写完了功能怎么知道它到底行不行我一般先开着日志跑 10 分钟统计三组指标累计收到的包数、RTCP RR 里的 fraction lost、JitterBuffer 的深度变化。如果 JitterBuffer 的平均深度一直在目标值附近波动说明网络稳定如果深度反复打满说明缓冲太小要加大目标延迟如果深度经常为空说明发送端码率不稳定或丢包严重。更精确的评估方式是抓包。Wireshark 的 RTP 分析功能会把序列号缺口、乱序、抖动全部可视化。用rtp过滤规则选中流再进 Telephony - RTP - Stream Analysis能看到每一个包的到达间隔和抖动值。这个验证步骤在第 6 章再展开它也是 RTP 客户端上线前必须具备的体检项目。5. RTP 客户端踩坑实录5 个让播放翻车的常见问题5.1 能收到数据包播放却断断续续现象日志显示每 20ms 都有包进来但播放声音像卡碟一样一顿一顿。原因没做抖动缓冲或者 JitterBuffer 目标延迟设太短。包到达间隔在网络中本来就波动直接按到达时间播放等于把网络抖动全盘接收。解决引入抖动缓冲先把包按序列号排好再按时间戳节奏取。目标延迟从 80ms 开始试逐步下调到能接受的最低值。G.711 每包 20ms设置 80ms 意味着最多容忍 3-4 个包的乱序到达。5.2 播放到某一点突然跳变像磁带被剪了一段现象声音正常但每隔几十秒会出现一次明显的“跳变”不是卡顿而是内容缺失。原因序列号回绕被当成乱序包丢弃。当序列号从 65535 回到 0 时if (seq lastSeq)的判断直接认为 0 是旧包丢掉了新数据。解决用 4.2 节的环形比较函数替换所有直接大于小于的比较。注意时间戳是 32 位回绕同样要用环形逻辑处理。5.3 偶尔蹦出“吱啦”一声杂音现象播放整体正常但随机出现尖锐噪音。原因乱序包和重复包没有过滤干净。一个迟到的包被插入到已经播放过的位置解码后产生错误的 PCM 采样听到就是爆音。解决JitterBuffer 里对每个序列号槽位做唯一性检查重复序列号的包直接丢弃。同时要处理 RTCP RR 里收到的fractionLost如果乱序率偏高说明网络路径有问题单纯加缓冲治标不治本。5.4 多路流并发时内存涨得离谱现象单路流没问题开到 10 路流以后每秒 GC 好几次老年代一路飙升。原因每个包都创建一个新的byte[]和RtpPacket对象。10 路流每路每秒 50 包每秒就是 500 个对象进入老年代垃圾回收根本追不上。解决接收线程的DatagramPacket复用一个缓冲区RtpPacket 只保留 payload 的引用解码后的小对象生命周期变短尽量复用。更彻底的做法是用直接内存或缓存池但这个优化要到压测真的撑不住时再做否则可读性受损。5.5 长时间运行后音画不同步延迟越积累越大现象音频正常但视频明显滞后又超速时间一长差距拉大到几百毫秒。原因音频和视频的 RTP 时间戳是独立的播放端只各自按时间戳播放没有做跨流同步。音频的时钟是声卡晶振视频的时钟是摄像头晶振二者本身有微小偏差不经校正就会越偏越远。解决接收端要让音频时钟做主参考视频流通过 RTCP SR 报文的 NTP 时间戳换算到音频时间轴。如果你做的是纯音频客户端这个问题暂时不影响但一旦扩展到音视频同步就要早做设计后期加成本高很多。6. 进阶验证与部署用 FFmpeg 当信源、Wireshark 校准、虚拟线程并发收流6.1 用本地 FFmpeg 推 RTP 流做端到端验证写完客户端总得有一个人在那边发流。常见做法是拿 FFmpeg 推一路 G.711 音频流ffmpeg -re -i test.wav -c:a pcm_mulaw -ar 8000 -ac 1 -f rtp rtp://127.0.0.1:10000-re参数模拟实时速率不然 FFmpeg 会以几十倍速度把文件推完。-c:a pcm_mulaw指定编码-ar 8000对齐采样率和客户端解码器保持一致。如果遇到的源设备推的是 PCMA把pcm_mulaw换成pcm_alaw即可。推流启动时 FFmpeg 会把 SDP 信息打印在控制台里面能看到payloadType和端口分配对照你的解析代码确认参数没错。6.2 Wireshark 过滤规则和三项关键指标验证 RTP 实现最靠谱的工具还是 Wireshark。过滤栏输入rtp udp.port 10000在 Telephony - RTP - Stream Analysis 里重点看三个值Delta Time的最大值和平均值反映网络抖动幅度Sequence Number的缺口次数反映丢包RTP 分析面板顶部的 Jitter 值是 RTCP 计算用的标准抖动。这三个值如果和客户端日志里的统计能对上说明你的实现没有漏包、没有多放包。6.3 把接收线程扔给虚拟线程和内存复用习惯如果你用的是 Java 21 或更高版本多路流接收可以换成虚拟线程代码几乎不用改。把new Thread(receiver, rtp-receiver)换成Thread.ofVirtual().name(rtp-receiver).start()然后在主线程里为每一路流建一个虚拟线程。虚拟线程的优点是阻塞 IO 不再占满平台线程池以前 10 路流要开 10 个排大队的线程现在每一路都像是独占一个轻量线程。最后说一个我的血泪经验RTP 客户端的代码写完之后一定要先用模拟信源跑完再连真设备。真设备的 RTP 实现千奇百怪有的不按 RFC 填时间戳有的 RTCP 和 RTP 混在同一个端口还有的丢包率在高峰期能到 20%。你没法控制对端只能在 JitterBuffer 深度、重排序策略上留好调节余地上线后先观察 15 分钟指标再调参。这套流程我每次做集成都不敢跳希望帮到你。本文还有配套的精品资源点击获取