Java基于Jaffree转RTSP流推JSMpeg的内存优化问题
问题背景
- 使用Jaffree库调用FFmpeg转换并展示RTSP流,功能与node-rtsp-stream库完全一致,仅将实现语言从Node.js更换为Java
- 核心处理流程:
- 携带正确参数启动ffmpeg进程,输出内容到标准输出
- 捕获标准输出产生的数据包/字节流
- 通过WebSocket将数据发送到客户端
- 客户端使用JSMpeg播放流,逻辑和node-rtsp-stream方案完全一致,目前功能可正常运行,但存在内存占用过高的问题
现有代码片段
Jaffree库进程处理代码
protected Executor startExecution(final Process process, final AtomicReference<T> resultReference) { Executor executor = new Executor(contextName); if (stdOutReader != null) { executor.execute("StdOut", new Runnable() { @Override public void run() { T result = stdOutReader.read(process.getInputStream()); if (result != null) { boolean set = resultReference.compareAndSet(null, result); if (!set) { LOGGER.warn("Ignored result of reading STD OUT: {}", result); } } } }); } }
StdOutReader实现代码
private static final int BUFFER_SIZE = 2048000; @Override public T read(final InputStream stream) { Try.run(() -> { final byte[] buffer = new byte[BUFFER_SIZE]; int length; while ((length = stream.read(buffer)) >= 0) { final byte[] finalBuffer = new byte[length]; System.arraycopy(buffer, 0, finalBuffer, 0, length); socket.sendMessage(finalBuffer); } }).onFailure(throwable -> { throw new RuntimeException("FFmpeg error", throwable); }); return null; }
问题说明
- 底层流是Process返回的
InputStream,实际为BufferedInputStream - 无法提前获知流中可用字节的长度,而JSMpeg需要接收准确长度的帧/数据包才能无异常播放,因此预先声明了超大字节数组来适配最大的数据包,导致内存占用过高
- 预期流程:FFmpeg标准输出每输出一个数据包就直接通过WebSocket转发,当前实际运行流程确实符合预期:
StdOut sends packet -> passing packet to WebSocket -> StdOut sends packet -> passing packet to WebSocket -> ... - 尝试过获取当前InputStream中待读取的字节数,但是BufferedInputStream无法实现该需求
已尝试无效方案
- 用
Reader按行读取再转字节,返回的长度不符合要求,无法正常播放 - 在ffmpeg参数中指定数据包的缓冲区/长度,要么不生效要么流损坏
- 观察到数据包长度都是188字节的倍数,但按该规则对齐后流仍损坏,推测数据包长度必须和标准输出返回的实际长度完全一致
- 调用
readAllBytes()读取会导致循环直接终止 - 使用
transferTo和ByteArrayOutputStream相关方法也没有效果 - 调用
InputStream的available()方法获取长度也不符合要求 - 尝试用Spring 5 WebFlux的
DataBuffer将Stream转成Flux订阅,但只能读取单个字节,不知道怎么读取当前完整缓冲区内容
待解决问题
有没有办法修改JSMpeg让它支持接收固定长度的数据包(比如固定2048字节)?或者能不能通过标准输出的特定字节预测数据包长度?请问该需求是否可实现,要怎么做?
补充说明
- 按照Jaffree作者Denis Kokorin的回答用
PipeOutput实现了功能,但存在完全一样的限制,PipeOutput底层也是将流内容复制到预先指定大小的byte数组中,输出结果如下:
Array length: 262144, actual length: 9588 Array length: 262144, actual length: 4700 Array length: 262144, actual length: 9024 Array length: 262144, actual length: 10152 Array length: 262144, actual length: 18048
- 需要通过WebSocket发送的是实际长度的数组,而非整个预定义数组的长度,原因是JSMpeg的运行机制:
音视频内部缓冲区很小(分别为512kb和128kb),JSMpeg 会丢弃旧的甚至未播放的数据来腾出空间接收新数据,以此保证最低延迟,网络拥堵时可能会出现解码 artifacts,但延迟控制效果很好。
- JSMpeg默认缓冲区大小是
524288,如果发送预定义大小的数组会导致没有内容能正常展示 - 猜测用
ChannelOutput也会有类似问题,如果没有的话麻烦告知怎么把SeekableByteChannel的数据通过WebSocket发送
你提到了Spring,大概率也用了Spring的WebSocket实现,Spring默认在WebSocket上用STOMP协议,WebSocket本身和TCP Socket类似可以发送任意字节,STOMP和HTTP类似,所以不用STOMP可以直接流式发送字节。
- 确认使用Spring 5的
spring-boot-starter-websocket依赖,场景必须用STOMP,是通过SimpMessagingTemplate.convertAndSend方法传入byte[]参数来发送数据的
我还想建议一个替代方案:如果内容存不下就写入磁盘。
- 该方案不适用,因为处理的是直播流,要求延迟尽可能低
文件上传后直接编码为MJPEG。
- 流格式不是MJPEG是
mpegts,JSMpeg只是库名而已
所用FFmpeg命令
ffmpeg -loglevel level+info -i rtsp://test:test@10.10.10.10:554/stream -n -rtsp_transport tcp -f mpegts -codec:v mpeg1video -r 30 -s 480x320 -b:v 1500k -bf 0 -an -vf scale=480:320 -
解决方案
方案1:直接调整缓冲区大小(成本最低,立即可用)
你当前设置的2MB缓冲区完全是冗余的,从你给出的日志可以看到实际单包最大长度只有18KB左右,直接把BUFFER_SIZE调整为64KB就可以覆盖所有场景,内存占用直接降到原来的1/32,完全满足需求。InputStream.read(byte[])本身就会返回实际读取到的字节数,你后续只复制前length个字节发送,只要缓冲区大小大于最大可能的单包长度就不会出现截断问题,64KB的缓冲区完全不会有内存压力。
方案2:基于MPEG-TS格式拆分数据包(更规范)
你输出的是标准mpegts格式,每个ts包固定为188字节,且包头有固定同步字节0x47。你可以把缓冲区设为188字节的整数倍(比如188*20=3760字节),读取到数据后按同步字节拆分出完整的188字节ts包再发送,完全不需要大缓冲区,也能保证JSMpeg正常解码。
方案3:修改JSMpeg适配固定长度包
如果需要Java侧发送固定长度的包,只需要修改JSMpeg的输入逻辑:在JSMpeg的接收层增加缓冲区拼接逻辑,收到固定长度的包后先写入内部缓冲区,累积到足够的完整ts包长度再送入解码器即可,不影响原有低延迟特性。
内容的提问来源于stack exchange,提问作者MattIT

