
简介这是一份面向Java开发者的RTP实时传输协议客户端实现资源包基于jlibrtp库封装完整的RTP/RTCP通信机制可帮助理解实时音视频与流媒体数据传输的核心流程。资源共45个文件以39个Java源码为主辅以3个HTML说明文档和3个TXT指南整体仅108KB结构紧凑便于快速定位。包内包含RTP会话管理、数据包收发、RTCP报告处理等核心类并提供SoundReceiverDemo、UnicastExample等可运行示例以及用于验证协议栈的测试代码。已有327人学习下载。通过阅读README与对照源码读者可快速掌握在Java环境中创建RTPSession、设置SSRC、绑定本地端口、发送及监听RTP数据包的完整流程并理解时间戳、序列号与同步机制适合正在开发音视频通话、直播推流或实时数据交互应用的开发者参考。1. RTP 客户端是什么先搞清楚你要解决的是推流、拉流还是双向会话RTP 客户端在 Java 项目里通常不是个独立系统而是整个音视频链路里的媒体面组件。做监控平台的人拿到「javartp 客户端」这个检索词多半是要从 GB28181 设备或国标平台拉视频流做呼叫中心的人则是要接 SIP 呼叫里的 RTP 媒体做分析服务的人可能只是想把摄像头 RTP 流收下来喂给解码器。三拨人最终都落在同一件事上用 Java 完成 UDP 上报文的收发、RTP 头解析、序列号和时间戳的维护以及和 RTCP 的配合。这个方案能解决的是在不引入 WebRTC 全家桶的前提下让业务系统直接接入标准 RTP 流适合有一定 Java 网络编程基础、想自己掌控媒体面的开发者。先把你要做的是推流、拉流还是双向会话想清楚后面所有选型和编码才不会走偏。2. 选型先于编码Java 里做 RTP 客户端的三条路各自代价在哪2.1 纯 java.net 手写 RTP协议透明坑也透明我在实际项目里很少直接引入重量级多媒体框架因为业务系统往往只关心流里的那一点数据监控平台的告警截图、呼叫中心的话路状态、质检系统的音频转写。这个时候自己用 DatagramSocket 实现 RTP 收发是性价比最高的做法。RTP 的标准在 RFC 3550 里写得很清楚12 字节的固定头部是所有实现的地基版本号2 bit、填充标志Padding、扩展标志Extension、CSRC 计数、Marker、负载类型Payload Type7 bit、序列号16 bit、时间戳32 bit、SSRC32 bit。解析头部的代码不长但每一行都值得逐字确认ByteBuffer buf ByteBuffer.wrap(packet.getData(), packet.getOffset(), packet.getLength()); byte first buf.get(); byte second buf.get(); int version (first 6) 0x03; int padding (first 5) 0x01; int extension (first 4) 0x01; int cc first 0x0F; int marker (second 7) 0x01; int payloadType second 0x7F; int seq buf.getShort() 0xFFFF; long timestamp buf.getInt() 0xFFFFFFFFL; long ssrc buf.getInt() 0xFFFFFFFFL;这段逻辑里最容易被忽略的有两处。第一Java 的 getShort 和 getInt 是有符号的必须用 0xFFFF、 0xFFFFFFFFL 转成无符号数否则序列号大于 32767 时你会看到负数丢包统计直接崩掉。第二padding 和 extension 位一直存在payload 的起点不是固定的 12 字节如果对方在包尾加了填充而你直接按 12 字节切负载解码器会收到脏数据。处理方式是先读固定头再看 padding 位和 cc 值算出扩展头和填充的长度最后从正确位置切负载。纯手写路线能让你完全掌握协议行为调试时心里很有底。代价是你需要自己处理的事变多了SIP 或别的信令协议和媒体面怎么衔接、RTCP 怎么发、序列号断了怎么办、网络地址转换场景下端口怎么守。如果只是内部系统之间传流这条路最稳也最容易定位问题。2.2 基于现成 Java RTP 库Jitsi / jlibrtp / Netty 组合的取舍如果不想从头啃 RFC常见的做法是引入现成的 Java RTP 库。老项目里见得最多的是 jlibrtp它把 RTP 会话、参与者和 RTCP 调度都封装好了文档虽少但代码直白适合学习协议的时候对照着读。问题在于它维护得并不勤快新的 JDK 版本上偶尔会遇到模块化或安全策略相关的兼容问题。另一个方向是 Jitsi 的 libjitsi里面包含完整的 RTPManager、SRTP、丢包重传和音视频会话管理能力全但依赖很重对一个只想拉 GB28181 视频流的服务来说有些过度。Netty 不算 RTP 库但它提供的 DatagramChannel 和 EventLoop 很适合做高吞吐的媒体通道。我会用 Netty 搭收发骨架再自己写 RTP 编解码 Handler这样既不用背整个多媒体框架又能顺畅处理多路并发。选型这件事的答案完全取决于你的场景我把它整理成一张对比表方案依赖规模协议完整度维护成本最适合的场景纯 DatagramSocket零自控高但可控单路或少量路数、内部拉流jlibrtp小完整但维护少中需改兼容学习协议、原型验证libjitsi大完整低需要 SRTP、多路会话的独立项目Netty 自研 Handler中按需中多路聚合、自定义转发做 GB28181 客户端的时候我最后一般会选 Netty 或纯 Socket原因是国标的媒体面还能切到 TCP 封装Netty 的传输层抽象能让你在 UDP 和 TCP 之间来回切换不用重写业务代码。2.3 RTP 与 RTCP 必须成对出现SR/RR 报文能告诉你什么初学者常犯的错误是只实现了 RTP 接收完全忽略 RTCP。RFC 3550 把 RTP 和 RTCP 设计成必须协同工作的协议RTP 传媒体数据RTCP 传服务质量信息包括 Sender Report发送方报告SR和 Receiver Report接收方报告RR。SR 由发送方周期发出里面有 NTP 时间戳、RTP 时间戳、累计发送包数和累计发送字节数RR 由接收方发出包含丢包率、累计丢包数、最高序列号、到达时间抖动以及指向最近 SR 的 LSR 和 DLSR。如果你只做一个拉流客户端至少要周期回 RTCP RR否则不少国标平台和流媒体服务会判定这个客户端会话异常表现就是信令流程走完但媒体很快断报错信息往往指向 rtp session false。构造 RR 的关键在于复制对方 SR 里的 NTP 时间戳中间 32 位作为 LSR记录从收到 SR 到此刻经过的时间作为 DLSR单位是 1/65536 秒ByteBuffer rtcp ByteBuffer.allocate(32); rtcp.put((byte) 0x81); // V2P0RC1 rtcp.put((byte) 201); // PT201 表示 RR接收方报告 rtcp.putShort((short) 7); // 长度字段整包 32 字节除以 4再减 1等于 7 rtcp.putInt((int) ssrc); // 本端 SSRC rtcp.putInt((int) remoteSsrc); rtcp.put((byte) 0); // fraction lost这里先填 0 rtcp.put((byte) 0); // cumulative lost 的高 8 位 rtcp.putShort((short) 0); // cumulative lost 的低 16 位 rtcp.putInt(extendedMaxSeq); rtcp.putInt((int) jitter); rtcp.putInt((int) lsr); // 对方 SR 里的 NTP 时间戳中间 32 位 rtcp.putInt((int) dlsr); // 从收到 SR 到此刻的延时1/65536 秒为单位RR 里最容易填错的是 length 字段和 LSR/DLSR。length 必须以 32 位字为单位再减 1整个 RR 固定头 8 字节加接收报告块 24 字节合计 32 字节所以 length 是 7。LSR 取的是对方 SR 里 NTP 时间戳的中间 32 位不是全部 64 位DLSR 是本地系统时间差换算成 1/65536 秒。这两处错了不会立刻报错但平台侧算出来的往返时延会乱丢包率统计也不准。RTCP 的发送周期按会话带宽比例计算RFC 3550 建议 RTCP 占用带宽约为会话带宽的 5%发送间隔有最小限制客户端里固定按 5 秒一次是安全的选择。五秒钟一个 32 字节的报文对网络的压力可以忽略不计却能让对端一直认为这个会话是活的。3. 从零跑通 javartp 客户端SDP 通知下的 UDP 会话建立与 RTP 接管3.1 先解决 SDP从 INVITE/200 OK 里拿端口、编码格式和 SSRC一个 RTP 客户端不会凭空知道该往哪个端口收发。在 SIP 和 GB28181 场景里媒体参数通过 SDP 在信令报文中传递。我见过不少人在这一步翻车拿着网上找的 RTP 示例绑死一个端口就开始收结果发现设备把流发到了 SDP 里约定的另一个端口。SDP 里真正要解析的关键行是 m、c 和以 a 开头的媒体属性。典型的 GB28181 响应里会有类似下面的片段v0 mvideo 20000 RTP/AVP 96 cIN IP4 192.168.1.64 artpmap:96 PS/90000 assrc:123456789m 行里的 20000 是媒体收流端口c 行里是对端 IPartpmap 说明 payload type 96 对应 PS 封装、时钟 90000Hzassrc 在部分实现里会带上发送端的同步源标识。解析时我习惯用最简单的逐行匹配正则写复杂了反而容易踩多空格和换行符的坑String sdp ...; // 来自 200 OK 的 body int mediaPort 0; String mediaIp null; for (String line : sdp.split(\\r?\\n)) { if (line.startsWith(mvideo)) { String[] parts line.split( ); mediaPort Integer.parseInt(parts[1]); // parts 是 [mvideo,20000,RTP/AVP,96] payloadType Integer.parseInt(parts[3]); } else if (line.startsWith(cIN)) { mediaIp line.split( )[2]; } else if (line.startsWith(artpmap:)) { String[] pair line.substring(9).split( ); // pair[0] 96pair[1] PS/90000 } }这段逻辑有几个边界要留意。m 行可能不是 video 而是 audio如果你的客户端只处理视频不能看到 m 就取。c 行可能出现在会话级或媒体级媒体级优先所以后出现的 c 行应该覆盖先出现的。rtpmap 里的时钟频率不总是 90000音频常见 8000 或 48000视频 PS 和 H.264 一般是 90000后面所有时间戳换算都要基于这个值。拿到这几个参数后本地 DatagramSocket 的绑定端口在拉流场景下有两种选择显式指定为信令协商好的端口或者用 0 让系统分配随机端口再把实际端口写进响应 SDP。这里有个常见的理解偏差SDP 里 m 行的端口是对方告诉你去哪里收流不是你本机要监听的端口。把这两者搞混是 GB28181 联调时最常见的失败原因之一。3.2 最小可用的 RTP 接收客户端绑定端口、收包、解析、丢包统计业务上我倾向于把 RTP 接收做成一个独立的循环线程跟解码、告警等业务解耦。这样接收线程只负责收包和统计业务线程从队列里取数据即使业务方处理慢了最多队列积压不会阻塞 UDP 收包导致内核缓冲区溢出。try (DatagramSocket socket new DatagramSocket(recvPort)) { socket.setSoTimeout(1000); byte[] buffer new byte[65535]; DatagramPacket packet new DatagramPacket(buffer, buffer.length); int expectedSeq -1; long lostCount 0; long totalCount 0; while (!Thread.currentThread().isInterrupted()) { socket.receive(packet); RtpHeader header parseHeader(packet.getData(), packet.getLength()); if (expectedSeq ! -1) { int diff (header.seq - expectedSeq) 0xFFFF; if (diff 0 diff 10000) { lostCount (diff - 1); // 中间丢了多少包 } else if (diff 0) { // 乱序或重传这里只计数不立刻当新包处理 } } expectedSeq (header.seq 1) 0xFFFF; totalCount; payloadQueue.offer(new RtpPacket(header, Arrays.copyOfRange( packet.getData(), header.payloadOffset, packet.getLength()))); } }这里的几个参数值得细说。setSoTimeout(1000) 是为了让 receive 能超时退出如果不设线程关闭时只能靠 interrupt 碰运气很多线上服务就是因为这个退出不干净导致端口一直占着。丢包判断用 0xFFFF 把差值转成无符号数规则是差值大于 0 且小于 10000 才认为是丢包如果差值超过 10000更可能是乱序或对端重启后序列号归零强行按丢包统计会把告警系统刷爆。buffer 大小 65535 是 UDP 的理论上限实际 RTP 包一般在 1200 到 1400 字节之间。GB28181 的 PS 封装经常超过网络链路 MTU发送端会做分片所以接收端不要期待一个 RTP 包就是一整帧画面。队里存的应该是带 header 的完整封装而不是只存 payload因为后面做抖动和去重还要用序列号和时间戳。3.3 PS 封装和 H.264 裸流GB28181 场景下的 payload 处理如果接的是标准 RFC 3550 RTP 里的 H.264负载通常是单 NAL 单元或 RTP 分片处理起来相对直观。但 GB28181 设备大多是 PS over RTP每个 RTP 包里装的是 MPEG-2 Program Stream 的一小段多个 RTP 包拼起来才还原出一个完整的 PS 包。PS 包以 0x000001BA 起始码开头后续的 PES 包以 0x000001E0 开头H.264 数据藏在 PES 负载里。我第一次接国标流时在这里栽过跟头直接把每个 RTP payload 当成一帧 H.264 裸流送去解码器出来的画面全是花屏。正确的做法是先把 payload 按起始码切片只保留从 0x000001BA 开始的完整 PS 包再跳过 PS 系统头找到第一个 PES从 PES 里剥出 ES 数据后按 Annex-B 格式拼接送给解码器。byte[] payload rtpPacket.getPayload(); int offset 0; while (offset payload.length - 3) { if ((payload[offset] 0xFF) 0x00 (payload[offset 1] 0xFF) 0x00 (payload[offset 2] 0xFF) 0x01 (payload[offset 3] 0xFF) 0xBA) { // 找到 PS 包头记录起始位置 int psStart offset; // 解析 PS 头长度跳到第一个 PES 起始码 0x000001E0 // 取出 PES 里的 ES 数据写入 esBuffer offset 4 psHeaderLength; } else { offset; } }这段代码的关键是 psHeaderLength 的计算。PS 包头里有 program stream map 和 system header 信息起始码后面的字节里用低 6 位表示后续头长度因此从起始码到负载数据之间的总长度需要按那个字段解析而不是固定值。实际项目中每家设备在这个封装上未必严格遵守规范有的设备还会在关键帧前塞入额外填充字节。调试时我会把 RTP payload 前 64 字节 dump 成 hex对着起始码检查位置对不对这一步能过滤掉九成以上的解码黑屏问题。提示GB28181 平台经常在关键帧前插入多包填充数据解析时要用「先找起始码再跳长度」的方式不能假设 PS 头长度固定为 14 或 16 字节。4. 避坑指南5 个让 RTP 客户端「看起来死了」的真实问题4.1 设备端报 start preview failedrtp session false会话从没被激活现象信令流程走完收包线程也在跑但画面黑屏设备端日志出现 start preview failed maybe rtp session false or preview links nun看起来像是平台认为预览会话根本不存在。原因这个报错几乎都指向 RTP 会话没有被正确激活。常见具体原因有三个一是只收 RTP 不回 RTCP平台侧在几十秒内判定客户端不可达二是客户端实际监听端口和应答 SDP 里写的端口不一致三是 SSRC 对不上客户端用它自己的 SSRC 过滤包把设备的包全丢了。解决先抓包确认流有没有到本机有流再看 SSRC 是否与 SDP 或包头部一致最后补上周期 RTCP RR。按这个顺序排查大概率十分钟内定位。不要一上来就怀疑解码器黑屏问题里有一半是 RTP 会话本身没建好。4.2 拉流几分钟后必断UDP 通道被中间网元老化掉了现象拉流正常三到五分钟必断程序没有任何异常重新 INVITE 又能恢复恢复后又是几分钟断一次极其规律。原因典型的是会话保活缺失。UDP 是无连接的中间网元包括防火墙和 NAT 表项会在一段时间没有交互后老化掉媒体通道。很多协议栈默认只做 SIP 层保活但媒体面没有流量时通道照样被回收。解决在客户端里启动一个 5 秒定时器周期发送 RTCP RR 或 RTCP APP 保活包。发 RR 时把收到的媒体包统计结果带上一发两用既保活了会话又上报了质量。如果对端支持 RTP over TCP也可以切换到 TCP 封装从根本上绕开 UDP 会话老化问题。4.3 seq 回绕被当成丢包误告警是怎么刷屏的现象丢包率被算成百分之几十监控页面告警刷屏但实际画面只是轻微卡顿甚至完全正常。原因序列号回绕是正常现象65535 后归零另外 GB28181 设备断线重连后 SSRC 可能变化序列号也会从 0 重新开始。误告警多半是因为代码里没有区分正常回绕和真丢包直接拿新序列号减旧序列号回绕瞬间变成一个巨大的差值。解决丢包判断必须用无符号差值(newSeq - lastSeq) 0xFFFF而不是直接比较大小。拿到新包时如果序列号明显小于上个包且时间戳发生了合理变化按新会话重新初始化状态机而不是叠加到旧统计上。简单说丢包统计要对会话状态敏感对跨会话的数据睁一只眼闭一只眼。4.4 重启即报 Address already in usesocket 没关干净现象程序第一次启动正常停掉再启动就报端口绑定失败有时候要等几十秒才能重新绑定成功。原因RTP 是 UDP没有 TCP 的 TIME_WAIT 状态端口占用通常是上一次进程没有完全关闭 socket或一个进程里多个实例绑了同一端口。另一个隐蔽原因是 DatagramSocket 默认不设置 SO_REUSEADDR某些系统环境下快速重启会碰到内核里的残留绑定。解决创建 socket 前先调用setReuseAddress(true)退出时显式关闭并让接收线程 join别依赖垃圾回收兜底。如果业务上允许动态端口就直接用 0 获取临时端口再把实际端口通过信令带回对端这是最不会撞车的做法。生产环境多实例部署时一定要用这种方案。4.5 时间戳恒为 0 或乱跳别把对端时间戳当基准现象按 RTP 时间戳换算播放时间得到的值完全不可用音频视频对不上画面时而快进时而暂停。原因部分中低端设备和自研平台的 RTP 实现不按实际采样时钟填时间戳更常见的是时间戳初始值随机但增量正确。如果客户端直接拿第一个包的时间戳当基准后面所有换算都会偏移一个固定值看起来就是声音画面不同步。解决不要信任对端时间戳的绝对值只信任差值。以第一个有效包到达的系统时间作为参考零点之后的播放时间按(timestamp - firstTimestamp) / clockRate累加。如果差值也不稳就退回按包到达间隔做抖动缓冲。这个策略对付杂牌设备特别有效属于典型的血泪经验值得在客户端里做成可配置项。5. 进阶验证与调优把客户端从「能跑」推到「能上线」5.1 用抓包数据反推客户端实现是否正确Wireshark 里过滤 rtp 或 rtcp看流的方向、payload type 和时间戳增量。如果视频是 90000Hz 时钟、25 帧每秒帧间时间戳增量应该在 3600 附近帧内分片包的增量会非常小。如果看到序列号大量乱序先怀疑接收线程消费太慢导致系统 UDP 缓冲区溢出。命令行下建议把包落盘再分析tcpdump -i eth0 udp port 20000 -w rtp.pcap对着抓包文件确认三件事RTCP RR 是否每五秒出现一次SDP 里的媒体端口与实际收包端口是否一致对端断开重连后 SSRC 是否变化。这三项都正常客户端网络层基本可以判定健康。5.2 抖动缓冲用到达间隔而不是包序号说话简单有效的策略是统计最近 N 个包的到达间隔把超过三倍平均间隔的到达看作一次抖动事件根据抖动事件的频率动态调整缓冲深度。缓冲不是越大越好100 毫秒以内通常可接受超过 200 毫秒交互体验会明显变差。监控场景可以偏大对讲场景必须偏小这个参数要在运行时暴露出来。5.3 上线前的十分钟检查清单检查项操作预期结果端口可达抓包或直接发流验证能稳定收到 RTP 包RTCP 在发抓包过滤 rtcp约每 5 秒一个 RR重启不占端口连续重启 5 次无 Address already in use长时间稳定性保持拉流 8 小时丢包率平稳内存无增长我自己每次上线前都会把抓包文件留一份标好时间戳和会话 ID。出错时先看包再看代码能省掉大量「到底是谁的问题」的拉扯。这个习惯救过我很多次希望帮到你。本文还有配套的精品资源点击获取