ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java实现RTP客户端:协议解析、音视频组帧与避坑指南

Java实现RTP客户端:协议解析、音视频组帧与避坑指南 简介面向Java开发者的RTP实时传输协议学习资料包内含jlibrtp-0.2.2开源库及其完整示例源码重点演示了RTP客户端与服务端的实现方式。资源覆盖RTP数据包结构、RTCP控制报文、会话管理SSRC设置、网络参数配置等核心知识点适合需要快速上手实时音视频传输开发的初中级Java工程师。包体共45个文件其中39个Java源码文件提供了RTP/RTCP会话、数据收发和测试等完整实现另有3个HTML帮助文档和3个TXT说明文件含README辅助阅读整个压缩包仅108KB轻量易用。已有327人学习下载。借助库中的单播示例与音频收发演示读者可对照代码理解sendPacket、RTPListener回调及RTCP反馈机制并基于readme指南自行搭建客户端-服务端交互流程为构建低延迟多媒体通信应用打下基础。1. Java 写 RTP 客户端到底在写什么值不值得自己动手如果接到一个“把摄像头 RTP 流接进 Java 服务”的需求很多人的第一反应是搜一个现成的 javartp 客户端库。实际搜索一圈会发现Java 生态里能开箱即用的 RTP 客户端远比想象中少JMF 早就不更新了GitHub 上几个轻量库也多年没动真正能长期维护的业务代码往往是在 DatagramSocket 之上自己写一套几百行的接收解析。RTP 本身是纯媒体传输协议不负责播放、不负责重传、也不管数据怎么编码所以客户端真正的工作是四件事收 UDP 包、拆 RTP 头、按 Payload Type 组帧、交给解码器或扬声器。这篇笔记适合需要自己实现或维护 RTP 客户端的 Java 工程师也适合刚接手音视频模块、想搞懂序列号回绕和分片重组的人。2. 选型与协议基础为什么 JMF 不能用RTP 客户端的最小组成2.1 先看事实Java 生态里可用的 RTP 库其实只有这三条路我在好几个项目里都见过同一种开场先搜“Java RTP 客户端”然后找到一个看起来很全的库引入依赖跑 Demo结果要么在 64 位 JDK 上编译报错要么处理不了 H.264 的 FU-A 分片最后还是回到自己写解析。这不是 Java 不行而是 RTP 在 Java 生态里一直处于“有实现但没有统一维护”的状态。老牌实现里JMFJava Media Framework是绕不开的名字它确实自带 RTP 接收和播放链路但已经停止活跃维护很多年。FMJ 是它的开源延续分支把很多类路径和 Native 调用做了兼容但 RTP 这块对现代编码格式的支持仍然停留在老协议时代。jlibrtp 这类轻量库能完成 RTP 包的收发和解析但编解码、RTCP、抖动缓冲都得自己接而且更新频率很低。Netty 在传输层很好用但它不提供 RTP 协议解析只给你一个 UDP 通道和 ByteBuf。换句话说Java 里没有一个像 FFmpeg 那样一揽子解决“解析 解码 输出”的官方库。选择方案时我会先画一条边界。如果项目只需要低并发、简单的 PCM 音频流jlibrtp 这类库还能勉强凑合但一旦涉及多路流、动态 Payload Type、H.264 分片重组、RTCP 统计库反而成了限制。真正生产环境里我一般会选择自己在 DatagramSocket 或 Netty 之上实现 RTP 客户端把协议解析控制在几百行内。这样做的好处是每一层都看得见出问题能用 Wireshark 对照而不是在黑匣子里猜。坏处是需要对协议细节足够熟悉尤其字节序、回绕、分片重组这三块。方案维护状态是否自带 RTP 解析适合场景JMF停止活跃维护有但较老教学演示不建议新项目FMJ社区维护慢有兼容老接口老项目迁移jlibrtp多年少更新有较轻量学习、简单实验Netty 自研解析活跃无高并发、可控性要求高的生产项目纯 DatagramSocket 自研自己维护无中小规模、想完全掌控协议2.2 RTP 包头与 Java 字节序读懂这 12 个字节RTP 固定头一共 12 字节所有字段都是网络字节序也就是大端。Java 的 ByteBuffer 默认就是大端所以解析时不要自己手写移位直接用 ByteBuffer 按顺序读就行。最容易翻车的是“无符号”这件事。Java 的 byte 是 -128 到 127short 是 -32768 到 32767但 RTP 协议里的 Sequence Number、Timestamp、SSRC 都是无符号的。解析时必须用 0xFF、 0xFFFF、 0xFFFFFFFFL把它们还原成真正的数值。固定头的布局看起来简单但位操作很容易错。第一个字节的高两位是 VersionRTP 固定为 2接下来一位是 Padding 标记表示负载末尾有填充字节再一位是 Extension 标记表示后面有扩展头低四位是 CSRC Count。第二个字节的最高位是 Marker低七位是 Payload Type。Sequence Number 是 16 位Timestamp 和 SSRC 都是 32 位。我在项目里要求所有新人都先背下这个结构因为后面所有的乱序、花屏、静音问题最后都要回到这 12 个字节上排查。解析时还要处理 CSRC 列表和扩展头。CSRC 一般多播会议才用单播流里通常为 0。Extension 头如果置位紧接着会有一个 16 位的长度字段单位是 32 位字所以要按长度 * 4跳过。Padding 的处理则是看负载最后一个字节它表示末尾有多少个填充字节真正有效负载要减去这个数。public final class RtpPacket { public int version; public boolean padding; public boolean extension; public boolean marker; public int payloadType; public int sequenceNumber; public long timestamp; public long ssrc; public byte[] payload; public static RtpPacket parse(DatagramPacket datagram) { ByteBuffer buf ByteBuffer.wrap(datagram.getData(), datagram.getOffset(), datagram.getLength()); if (buf.remaining() 12) { throw new IllegalArgumentException(RTP 包不足 12 字节); } RtpPacket pkt new RtpPacket(); int first buf.get() 0xFF; pkt.version (first 6) 0x03; pkt.padding ((first 5) 0x01) 1; pkt.extension ((first 4) 0x01) 1; int csrcCount first 0x0F; int second buf.get() 0xFF; pkt.marker ((second 7) 0x01) 1; pkt.payloadType second 0x7F; pkt.sequenceNumber buf.getShort() 0xFFFF; pkt.timestamp buf.getInt() 0xFFFFFFFFL; pkt.ssrc buf.getInt() 0xFFFFFFFFL; for (int i 0; i csrcCount; i) { buf.getInt(); } if (pkt.extension) { int extensionLength buf.getShort() 0xFFFF; buf.position(buf.position() extensionLength * 4); } int payloadLength buf.remaining(); if (pkt.padding) { int paddingLength buf.get(buf.limit() - 1) 0xFF; payloadLength - paddingLength; } byte[] payload new byte[payloadLength]; buf.get(payload); pkt.payload payload; return pkt; } }这个解析器是 RTP 客户端的核心后面所有逻辑都建立在它之上。buf.getShort() 0xFFFF这行尤其重要如果把 0xFFFF去掉序列号大于 32767 时就会变成负数乱序判断和丢包统计会全部失效。时间戳字段用 long 接收是因为 32 位无符号数超过 int 上限后需要保留完整值。Version 不等于 2 的包应该直接丢弃常见于把 RTCP 或其他 UDP 协议误认为是 RTP。2.3 RTCP、SDP 与 RTP 的关系客户端不能只处理 RTP很多 RTP 客户端写着写着才发现客户端还需要处理 RTCP 和 SDP。RTP 本身不负责协商 Payload Type 是多少也不负责告诉对端用什么采样率。这些信息在 SDP 里比如mvideo 5004 RTP/AVP 96表示视频流在 5004 端口Payload Type 96artpmap:96 H264/90000表示 PT 96 对应 H.264时钟频率 90000。如果客户端先拿到 SDP就可以在建立传输前把 PT 和编码格式的映射关系准备好。RTCP 则承担质量控制。Sender Report 里带有 NTP 时间和 RTP Timestamp 的对应关系这是做音画同步和精确播放时钟的关键。Receiver Report 用于反馈丢包统计虽然不会触发重传但能让发送端调整码率。默认情况下RTP 使用偶数端口RTCP 使用相邻的奇数端口但 SDP 里有artcp-mux时两者会复用同一个端口。这也是“端口通但收不到包”的常见来源之一你只开了一个 Socket 接收对端却把 RTCP 发到了另一个端口或者反过来把 RTCP 混进了 RTP 端口。判断一个包是 RTP 还是 RTCP不能只靠端口。因为两者 Version 都是 2且都可能有 Marker 位。最实用的办法是看第二个字节的 Payload TypeRTCP 的 Packet Type 是 200 到 204SR、RR、SDES、BYE、APP而 RTP 的 Payload Type 通常小于 128。虽然动态 PT 理论上有碰撞风险但实际流媒体系统里几乎不会把 RTP PT 配到 200 以上。3. 用 DatagramSocket 把 RTP 接进来接收、拆包、发送的最小代码3.1 接收循环与 RtpPacket 解析一个能跑的最小接收器实现 RTP 客户端的第一步不是引入框架而是用 DatagramSocket 做出一个能持续收包的循环。UDP 的特点是连接less每次 receive 拿到的 DatagramPacket 需要完整记录源地址、源端口和数据长度。缓冲区大小我一般给 2048 字节因为 RTP 负载通常不超过 MTU 1500 字节加上 IP 和 UDP 头也远不到 2048。如果后面要处理超过 MTU 的分片那是发送端的问题接收端只需要按 RTP 分片重组不需要一个超大缓冲。接收循环要处理两类问题阻塞和关闭。DatagramSocket 默认是永久阻塞的所以要么用setSoTimeout(100)让 receive 每 100 毫秒超时一次要么在另一线程调用 close 让 receive 抛 SocketException。我一般用超时方式因为 RTP 客户端需要定期清理超时的分片缓存这个清理动作正好可以放在超时异常里做。不要在主线程里做任何编解码或 IO 操作RTP 到达速率受发送端控制接收线程只做解析和入队处理工作交给工作线程。public class RtpReceiver { private final DatagramSocket socket; private final boolean closed false; public RtpReceiver(int port) throws SocketException { socket new DatagramSocket(port); socket.setSoTimeout(100); } public void run() { byte[] buffer new byte[2048]; DatagramPacket packet new DatagramPacket(buffer, buffer.length); while (!closed) { try { socket.receive(packet); RtpPacket rtp RtpPacket.parse(packet); handleRtp(rtp, packet.getAddress(), packet.getPort()); } catch (SocketTimeoutException e) { cleanExpiredFragments(); } catch (IOException e) { if (!closed) { // 记录异常但不要退出循环 } } } } private void handleRtp(RtpPacket rtp, InetAddress address, int port) { if (rtp.version ! 2) { return; } // 按 SSRC Payload Type 路由到对应的处理链 route(rtp); } }这里的handleRtp只是一个入口真正处理要按 SSRC 分流。多个 RTP 流可能同时到达同一个端口只靠目标端口无法区分所以路由 key 必须包含 SSRC。如果项目里用的是 Netty可以把这段逻辑平移到 ChannelHandler 里DatagramPacket 换成io.netty.channel.socket.DatagramPacketByteBuffer 换成 ByteBuf但协议解析逻辑完全不用变。接收线程里严禁分配大对象每个包都 new 一个 RtpPacket 在高并发下会产生明显 GC 压力后面第 5.4 节会专门讲怎么优化。3.2 发送端把 G.711 数据封装成 RTP 包并推进时间戳接收客户端通常也要发送比如回音、对讲、包中转场景。发送端的核心是正确填充 RTP 固定头并且保证时间戳按采样数递增。很多人在这里犯的第一个错是把时间戳当 Unix 毫秒填第二个错是把时间戳增量按系统时间算。RTP 时间戳的单位不是毫秒而是采样周期。对于 8kHz 的 G.711每秒 8000 个采样所以 20 毫秒的音频包是 160 个采样时间戳增量就是 160。对于 48kHz 的 Opus20 毫秒是 960 个采样时间戳增量就是 960。对视频来说常见时钟频率是 90000所以每帧增量是90000 / 帧率25 帧就是 3600。这些值都来自 SDP 的artpmap字段不能只看包长。public class RtpSender { private final DatagramSocket socket; private final InetAddress remoteAddress; private final int remotePort; private final int payloadType; private final long ssrc 0x12345678L; private int sequenceNumber 0; private long timestamp 0; public void sendPayload(byte[] payload, int samplesInPacket, boolean marker) throws IOException { int rtpLength 12 payload.length; byte[] rtp new byte[rtpLength]; ByteBuffer buf ByteBuffer.wrap(rtp); buf.put((byte) 0x80); // V2, P0, X0, CC0 buf.put((byte) ((marker ? 0x80 : 0x00) | (payloadType 0x7F))); buf.putShort((short) sequenceNumber); buf.putInt((int) timestamp); buf.putInt((int) ssrc); buf.put(payload); DatagramPacket packet new DatagramPacket(rtp, rtp.length, remoteAddress, remotePort); socket.send(packet); sequenceNumber (sequenceNumber 1) 0xFFFF; timestamp samplesInPacket; } }sequenceNumber (sequenceNumber 1) 0xFFFF这行是发送端的回绕处理。序列号是 16 位到 65535 后必须回到 0如果不做这个处理发送超过六万包后会出现一段负数序列号接收端按无符号解析时反而没问题但对端如果按有符号处理就会乱。时间戳是 32 位加到溢出也无所谓Java 的int溢出正好等价于 32 位回绕但为了后续作差值我建议接收端统一用 long 保存完整值。发送端的 Marker 位要按媒体类型处理。G.711 音频通常在静音段起始包或讲话突发第一包置位视频通常在每一帧的最后一个 RTP 包置位。如果拿不准最简单做法是音频不置位H.264 视频每个访问单元的最后一个包置位。Marker 不直接影响解码但接收端会拿它辅助组帧和播放状态判断错了会出现音频忽大忽小、视频卡顿边界不准。3.3 序列号与时间戳回绕为什么不能用 int 直接比较RTP 客户端最常被问的问题之一就是序列号会不会溢出答案是会而且是设计上就必须会。16 位序列号最大 65535之后回绕到 0。32 位时间戳同样会回绕如果按 48000Hz 采样率算大概一天多回绕一次。如果代码里写if (currentSequence lastSequence)回绕瞬间会把新包当成老包导致缓存错误、丢包统计异常。正确做法是用有符号差值来判断前后关系。RTP 规范里推荐的做法是把两个无符号数做差然后看差值是否在半个周期范围内。序列号用 16 位所以(current - last) 0xFFFF的结果如果小于 32768说明 current 在 last 之后如果大于 32768说明 current 在 last 之前。时间戳是 32 位判断范围变成了2^31。public static boolean isNewerSequence(int current, int last) { int diff (current - last) 0xFFFF; return diff ! 0 diff 32768; } public static boolean isNewerTimestamp(long current, long last) { long diff (current - last) 0xFFFFFFFFL; return diff ! 0 diff 0x80000000L; }这段代码几乎每个 RTP 客户端都会用到。 0xFFFF和 0xFFFFFFFFL保证了差值在回绕后仍然能正确判断方向。注意不要直接返回diff 0因为回绕后这个结果会反。真正的序列号比较不是数值大小比较而是环形空间上的方向判断类似时钟指针超过半圈就视为“更旧”。在实现乱序缓存和丢包检测时我用这个差值做两件事一是判断新包是否比缓存中的最后一个包更新二是计算diff - 1得到丢包数量。如果 diff 太大比如超过 1000往往不是真丢了 1000 个包而是流中断后重连或 SSRC 变更这时候应该重置状态而不是补一堆空洞。4. 按 Payload Type 处理媒体G.711、H.264、Opus 的组帧与分片重组4.1 G.71120 毫秒一包最怕字节序和静音包G.711 是 RTP 里最简单的负载Payload 就是压缩后的音频采样一个字节一个采样。8kHz 采样率下20 毫秒是 160 字节30 毫秒是 240 字节。客户端把连续几个 RTP 包的 payload 按时间戳拼接就能得到完整的音频帧序列。G.711 有两种变体PCMU 和 PCMA对应 Payload Type 0 和 8在 SDP 里通常写PCMU/8000和PCMA/8000。G.711 虽然简单但有一个 Java 特有的坑G.711 的每个字节是无符号 8 位而 Java 的 byte 是有符号。如果直接把 byte 转成 short 播放大于 127 的采样值全会变成负数声音会严重失真。正确做法是(byteValue 0xFF)后转换成 16 位 PCM再送给音频输出。很多新手拿到 RTP 数据后直接ByteBuffer.getShort()出来的声音是爆炸噪音根源就在符号扩展。另一个坑是静音包。很多网关实现会在静音时不发 RTP 包或者发送舒适噪声CNPayload Type 13。如果接收端等到下一个包才计算间隔播放线程会因为没有新数据而等待表现为卡顿。正确做法是维护一个基于 RTP 时间戳的播放时钟即使没有包到达播放线程也按上次时间戳和采样率继续推进超过一定时间再进入静音状态。Java 的 javax.sound.sampled.SourceDataLine 适合做这件事数据来了写进去数据没来就写静音采样。G.711 直接播放一般不需要 JitterBuffer因为包很小、采样率低网络抖动 20 到 40 毫秒都可以通过音频设备缓冲吸收。但如果经过公网传输我会加一个 60 到 120 毫秒的 JitterBuffer收到包先按时间戳排队播放线程按时间戳取出而不是一到就播。4.2 H.264 RTP 分包Single NAL、FU-A 与 STAP-A 的重组顺序H.264 是 RTP 客户端里最容易写错的负载。因为 H.264 的 NAL 单元大小经常超过 MTU发送端会把一个 NAL 拆成多个 RTP 包传输这就是 FU-A 分片。如果客户端不重组分片只把每个 RTP payload 直接丢给解码器视频必然花屏。H.264 在 RTP 里的封装方式主要看 RTP payload 的第一个字节1 到 23单 NAL 单元一个 RTP 包就是一个完整 NAL。24STAP-A多个 NAL 聚合在一个 RTP 包里。25STAP-B带 DoN 的聚合包少用。28FU-A分片单元。29FU-B带 DoN 的分片单元少用。判断分片类型的代码很简单取 payload 第一个字节的低 5 位。如果是 28说明是 FU-A。FU-A 的 payload 结构是两字节头加分片数据第一个字节叫 FU indicator格式是 F、NRI、28第二个字节叫 FU header高三位是 S 和 E 标志加 R低五位是真正的 NAL type。S 表示分片起始E 表示分片结束。重组时要把这两个字节替换成原始 NAL Header再拼上所有分片的负载部分。public static byte[] assembleFua(ListRtpPacket fragments) { if (fragments.isEmpty()) { return null; } byte[] firstPayload fragments.get(0).payload; byte fuIndicator firstPayload[0]; byte fuHeader firstPayload[1]; int forbiddenBit (fuIndicator 7) 0x01; int nri (fuIndicator 5) 0x03; int nalType fuHeader 0x1F; byte nalHeader (byte) ((forbiddenBit 7) | (nri 5) | nalType); ByteArrayOutputStream output new ByteArrayOutputStream(4096); output.write(nalHeader); for (RtpPacket packet : fragments) { output.write(packet.payload, 2, packet.payload.length - 2); } return output.toByteArray(); }这段重组代码能跑但只适合小的测试样本。生产级重组必须做三件事校验第一个包 S 标志为 1校验最后一个包 E 标志为 1校验中间包既不是 S 也不是 E。实际网络丢包时很可能只收到 S 没收到 E或者收到 E 但中间缺了包这时必须丢弃整个 NAL不能把不完整的数据交给解码器。解码器收到被截断的 NAL 后通常要等到下一个 IDR 帧才能恢复所以花屏会持续很久。我一般用一个MapLong, FragmentBuffer按 SSRC 缓存分片每个缓存记录当前 NAL 的分片列表、上一个 RTP 时间戳、起始时间。收到新分片时如果 S 标志为 1说明上一个 NAL 已经结束或丢失立刻清空旧缓存再开始新缓存。再配合一个每 200 毫秒执行一次的清理任务把没有收到 E 的超时缓存丢弃避免内存里堆积大量半截 NAL。这个“收到 S 就清空”的规则是花屏问题的最大救星。4.3 Opus 与动态 PT拿到 Payload Type 后先查 SDP别硬编码Opus 在现代 VoIP 里越来越常见它的 RTP 封装比 H.264 简单得多每个 RTP 包就是一个 Opus 包不需要分片重组。真正复杂的反而是 Payload Type 映射。Opus 没有固定的 RFC 默认 PT通常由 SDP 动态分配比如artpmap:111 opus/48000/2。如果客户端把 111 当成 H.264或者把 PT 111 直接按 PT 8 处理解码立刻失败。处理动态 PT 的正确方式不是维护一张“常见 PT 表”而是先解析 SDP建立从 PT 到 Codec 的映射后再创建接收链路。SDP 里还有两个参数对接收端很重要afmtp:111 minptime10; useinbandfec1前者是包最小时长后者表示是否启用带内 FEC。RTP 客户端解出 Opus 包后可以把这些参数传给 Opus 解码器。注意 Opus 的解码时间戳单位是 48000不是 8000如果沿用了 G.711 的 8000 去算播放时间声音会变快约 6 倍。Opus 还有一个特点是支持 DTX不连续传输静音时可能不发包。接收端要维持播放时钟不能依赖“包到了才播”。我见过一个客户端Opus 流静音 500 毫秒后播放线程直接卡死其实就是把接收间隔当成播放节奏。解决方法是把播放线程设计成独立时钟每 20 毫秒醒来一次按时间戳决定是播真实数据还是播舒适噪声。对于视频的 H.264 和音频的 Opus 混跑场景音画同步要靠 RTCP Sender Report。RTP 时间戳与 NTP 时间的关系会周期性在 SR 包里给出客户端维护两条流各自的(RTP timestamp, NTP timestamp)映射播放时把两条流都换算到同一 NTP 轴上再对齐。只靠收到顺序对齐音视频在网络抖动下必然对不上。5. 常见问题与避坑RTP 客户端半年运行下来的 6 个实操记录5.1 现象音频忽快忽慢听感一顿一顿这个现象最容易误导人新手第一反应是网络抖动于是拼命加 JitterBuffer结果越加越慢。真正原因是播放线程没有按 RTP 时间戳驱动而是按“收到一个包就播一个包”驱动。网络一旦乱序或轻微抖动播放节奏就会跟着乱。原因在于 RTP 时间戳已经包含了采样时刻信息G.711 在 8kHz 下每 160 时间戳就是 20 毫秒播放线程应该按照这个间隔去消费数据而不是依赖包到达间隔。解决方法是给客户端加一个独立的播放时钟音频输出设备每次回调时计算“当前应该播放到的时间戳”然后从 JitterBuffer 里取对应数据取不到就补静音或舒适噪声。对于 8kHz 的 G.711JitterBuffer 初始深度建议 100 到 150 毫秒带宽窄的网络可以放到 200 毫秒但不要无限加大否则延迟会高到影响对讲体验。5.2 现象视频花屏而且切流后长时间不恢复这类问题常见于 H.264 流。表面现象是画面花、绿、撕裂持续好几秒甚至直到下次 I 帧才恢复。排查时抓包发现 FU-A 分片都在每个 RTP 包头也正常问题出在重组缓存的清理逻辑。原因通常是收到一个新的 S 分片时没有清空上一个未完成的 NAL 缓存。比如前一个 NAL 丢了最后一个分片缓存里残留了 90% 的数据新的 S 包进来后旧数据和旧数据拼接解码器拿到的是两个 NAL 的缝合体。解决方法是无论当前缓存是否完整只要收到 S 标志为 1 的分片就先清除同 SSRC 的分片缓存。另一个连带措施是超时清理超过 200 到 300 毫秒没有收完的分片缓存直接丢弃。这个清理动作可以放在接收线程的 SocketTimeout 分支里不额外增加线程。5.3 现象端口通、网络通但客户端一直收不到 RTP先确认一件事你绑定的端口是不是 SDP 里媒体流实际使用的端口。很多 RTP 流默认偶数端口发 RTP、奇数端口发 RTCP如果发送端把 RTP 发到 5004RTCP 发到 5005而客户端只绑定 5004RTCP 会丢但也影响不大。真正的坑是artcp-mux这时 RTP 和 RTCP 共用一个端口客户端如果收到 RTCP 包后直接按 RTP 解析会因为 RTCP 头格式不同产生各种异常。解决办法是在路由逻辑的最前面判断包类型。RTP 和 RTCP 的 Version 都是 2但 RTCP 的 Packet Type 是 200 到 204。解析完第二个字节后先判断payloadType 200如果成立就进入 RTCP 处理分支而不是继续走 RTP 解析。同时绑定本地端口建议开启SO_REUSEADDR多路流快速重连时避免端口被 TIME_WAIT 状态占用。5.4 现象Java 进程内存缓慢上涨GC 越来越频繁RTP 客户端比一般 Web 服务更容易触发 GC 问题因为每个包都会产生新对象。如果接收线程每包都 new 一个 byte[]、new 一个 RtpPacket、再 new 一个 ByteArrayOutputStream在 24 小时连续流下对象数量是惊人的。把 JVM 堆从 1GB 调到 8GB 只能延后 OOM 的时间不能解决对象分配速率问题。解决方向有三个第一接收缓冲区复用不要在循环里new DatagramPacket第二解析后的负载尽量复用 byte[]用ByteBuffer.wrap或数组切片避免复制第三分片重组用预分配的输出流不要在每包上创建 ByteArrayOutputStream。如果项目用的是 Netty直接使用PooledByteBufAllocator和ByteBuf的retain机制注意及时release。判断是否到位可以用 JFR 或-Xlog:gc观察 GC 频率连续跑 24 小时Full GC 次数应当趋近于 0。5.5 现象多路流同时接入后画面串流、音画不对应只靠源 IP 和源端口来区分会话在 RTP 场景下不够可靠。原因是一个 RTSP 会话重新建立后源端口可能不变但 SSRC 变了或者同一个服务器向同一端口发送多路媒体流时源地址完全相同。这时候唯一稳定标识是 RTP 头里的 SSRC。我习惯在路由层用MapLong, StreamStatekey 是 SSRCvalue 里放当前 Payload Type、分片缓存、序列号、最后活跃时间。新包到达时先查 SSRC如果不存在且包的 version 合法就创建新状态如果 SSRC 变化但源地址相同要把旧状态的缓存清掉并打印告警。每次 RTCP SR 到达时还要校验 SR 里的 SSRC 是否与当前 RTP 流一致不一致说明发端发生了切换必须重置 RTP 解包状态。5.6 现象Java 启动失败怎么解决报 OutOfMemoryError 或 GC overhead音视频服务常见启动参数问题是把堆设置过小或者机器上有多个 JDK启动脚本用错了版本。先确认实际运行的是哪个 JDK用java -version和echo $JAVA_HOME对照一下。如果环境变量里配了多个 JDK优先在启动脚本里显式写死 JDK 路径避免 Java 服务在重启后用了另一个版本。对于 RTP 客户端我更推荐把堆内存控制在一个合理范围比如 1GB 到 2GB然后通过减少对象分配来降低压力。不要依赖-Xmx8000m这种“大堆保平安”方案因为超大堆会导致 GC 停顿时间边长音视频延迟反而更不稳定。如果已经出现java.lang.OutOfMemoryError: Java heap space先抓一份堆转储看是谁占的内存大概率是分片缓存没有按 SSRC 清理而不是单纯堆不够。6. 验证与进阶给 RTP 客户端加一个离线回放测试台验证 RTP 客户端不能只靠连一次真实摄像头。真实流不可控网络抖动和编码配置天天变出了问题很难判断是客户端 bug 还是对端问题。我建议在项目里保留一个离线回放测试台把抓包导出的 RTP payload 保存成本地文件客户端离线读取并按原始时间戳注入解析链路这样每次改完代码都能跑同一份数据回归。最简单做法是用 Wireshark 打开 pcap过滤出 RTP 包后导出为 CSV 或 raw 文件然后在 Java 里按行读取把十六进制 payload 转成 byte[] 后调用接收处理函数。回放时要按时间戳控制注入节奏最好用真实时间间隔否则看不出乱序和超时清理逻辑是否正常。稳定性测试我一般跑 72 小时。判定标准有三条一是收到的包数减去解析成功的包数为 0二是缓存中残留的未完成分片数始终小于 10三是 GC 日志里没有 Full GC。跑完一遍再故意注入丢包把每 100 个包里随机丢掉 3 个看客户端能否在下一帧关键帧恢复。H.264 流允许丢包后的短暂花屏但必须在收到下一个 IDR 帧后恢复。进阶顺序上我建议先做 JitterBuffer再做 RTCP Receiver Report最后做 FEC 或关键帧请求。JitterBuffer 是音视频质量的基石没有它任何丢包重传都是空中楼阁。实现时按 SSRC 独立排队每个队列记录当前最大的连续时间戳播放线程按时间戳消费而不是按入队顺序消费。做 Receiver Report 时把统计信息按发送间隔回传但不要为了精度频繁发 UDP 包。最后说一个自己的习惯每次改协议解析代码我都会用同一份抓包文件跑回归并打印每个包的 version、marker、payloadType、sequenceNumber、timestamp、ssrc。这个习惯救过我三次其中两次是改字节序改坏了一次是 CSRC 解析偏移算错导致后续全乱。先让脚本告诉你哪里变了再谈优化。希望帮到你。本文还有配套的精品资源点击获取
返回列表