22、内存调试:使用 Chrome 的 Memory 面板分析 WebRTC 内存泄漏问题
做 WebRTC 开发,最怕什么?
我个人最怕的就是内存泄漏。视频通话跑着跑着,内存占用一路飙升,最后浏览器直接崩溃。用户那边看到的,就是画面卡住、黑屏、或者干脆标签页挂了。
说实话,WebRTC 的内存泄漏问题,比普通网页要隐蔽得多。因为里面涉及了媒体流、PeerConnection、编解码器、网络传输……每一个环节都可能藏着泄漏点。今天我就带你用 Chrome 的 Memory 面板,把这些泄漏点一个个揪出来。
为什么 WebRTC 容易内存泄漏?
先说说根因。WebRTC 本质上是一个实时多媒体管道。这条管道里,数据一直在流动:
- 摄像头采集的视频帧
- 麦克风采集的音频数据
- 编码后的 RTP 包
- ICE 连接状态
- Stats 统计对象
你想想看,这些对象如果创建了没销毁,或者引用链没断开,GC(垃圾回收)就永远收不掉它们。我在项目中遇到过最典型的情况:每次创建 PeerConnection 时都 new 了一个 MediaStream,但关闭通话时只关了连接,忘了释放流。结果呢?内存里躺着几十个废弃的 MediaStream 对象,每个还带着一堆 Track。
Chrome Memory 面板入门
打开 Chrome 开发者工具,切到 Memory 面板。你会看到三个选项:
| 选项 | 用途 | 我什么时候用 |
|---|---|---|
| Heap snapshot | 拍一张内存快照 | 对比前后状态,找泄漏对象 |
| Allocation instrumentation on timeline | 记录一段时间内的内存分配 | 追踪泄漏发生的瞬间 |
| Allocation sampling | 采样分析内存分配 | 性能开销大,我一般不用 |
嗯,这里要注意:做 WebRTC 内存调试,我习惯用「Heap snapshot」配合「Allocation timeline」。前者看静态结果,后者看动态过程。
实战:用 Heap Snapshot 抓泄漏
假设你有一个视频通话应用。用户 A 和用户 B 通话结束后,内存应该回到通话前的水平。如果回不去,那就是泄漏了。
操作步骤很简单:
- 在通话开始前,拍一张快照(Snapshot 1)
- 建立通话,正常通话 30 秒
- 挂断通话,等 10 秒让 GC 跑一次
- 再拍一张快照(Snapshot 2)
- 对比两张快照
具体怎么对比?看这里:
// 在 Memory 面板中
1. 选中 Snapshot 2
2. 在顶部下拉菜单选择 "Comparison"
3. 选择 Snapshot 1 作为基准
4. 按 "Delta" 排序,看哪些对象增加了
你会看到类似这样的结果:
| Constructor | # New | # Deleted | # Delta | Size Delta |
|---|---|---|---|---|
| MediaStream | 5 | 0 | +5 | +120KB |
| RTCPeerConnection | 3 | 0 | +3 | +80KB |
| VideoTrack | 5 | 0 | +5 | +60KB |
| AudioTrack | 5 | 0 | +5 | +40KB |
看到没?MediaStream 和 RTCPeerConnection 都增加了,而且一个都没释放。这就是泄漏的铁证。
追踪泄漏源头:Allocation Timeline
快照对比能告诉你「泄漏了什么」,但有时候你还需要知道「泄漏发生在哪一步」。这时候就要用 Allocation timeline 了。
操作流程:
- 在 Memory 面板选择 "Allocation instrumentation on timeline"
- 点击 "Start" 开始记录
- 执行一次完整的通话流程(创建 → 通话 → 挂断)
- 点击停止记录
你会看到一条时间轴,上面有密密麻麻的蓝色竖线。每条竖线代表一次内存分配。点击某条竖线,就能看到那个时刻分配了哪些对象。
我曾经用这个方法抓到一个特别隐蔽的泄漏:每次收到远端视频流时,代码里都会 new 一个 VideoFrame 对象,然后把它传给一个全局的 canvas 做处理。但处理完后,canvas 的引用没清掉,导致 VideoFrame 一直被持有。每次通话都会累积几十个 VideoFrame,内存就这么一点点涨上去了。
常见 WebRTC 泄漏模式
根据我的经验,WebRTC 内存泄漏主要有三种模式。我列个表,方便你对照排查:
| 泄漏模式 | 典型表现 | 排查方向 |
|---|---|---|
| PeerConnection 未关闭 | RTCPeerConnection 对象持续增加 | 检查 close() 是否调用,是否在错误处理中遗漏 |
| MediaStream 未释放 | MediaStream / MediaStreamTrack 对象堆积 | 检查 stop() 调用,检查全局变量引用 |
| 事件监听器未移除 | 回调函数对象持续增加 | 检查 addEventListener 对应的 removeEventListener |
说白了,这三种模式的核心都是同一个问题:创建了,没销毁。
SVG:WebRTC 内存泄漏排查流程
下面这张图,是我自己总结的排查流程。每次遇到内存问题,我就按这个步骤走一遍,基本都能找到根因。
如何追踪引用链?
找到泄漏对象后,下一步就是看「谁还引用着它」。Chrome 的 Heap Snapshot 里,点击某个对象,下面会显示它的 Retaining Tree(保留树)。
举个例子:你发现一个 MediaStream 对象没释放。点开它,看 Retaining Tree:
MediaStream
└─ Window (全局对象)
└─ appState (你的全局状态对象)
└─ currentStream (一个属性,还指向这个流)
看到了吗?问题出在 appState.currentStream 这个全局引用上。通话结束后,你没有把 appState.currentStream 设为 null,所以 GC 认为这个 MediaStream 还在用,就不回收它。
修复方法很简单:
// 通话结束时
function endCall() {
// 关闭 PeerConnection
peerConnection.close();
peerConnection = null;
// 停止所有 Track
localStream.getTracks().forEach(track => track.stop());
// 释放引用 —— 这一步很多人会忘
appState.currentStream = null;
// 如果还有全局的 peerConnection 引用,也要清掉
appState.peerConnection = null;
}
一个真实案例
我记得有一次,线上反馈说视频通话页面用久了会卡。我打开 Memory 面板一看,RTCPeerConnection 对象有 20 多个。但用户明明只打了 3 通电话。
排查后发现,问题出在重连逻辑上。每次网络波动时,代码会创建一个新的 PeerConnection 来重连。但旧的 PeerConnection 只是调用了 close(),却没有把全局引用置空。结果呢?旧的 PeerConnection 对象虽然状态是 "closed",但引用链还在,GC 收不掉。
修复方案:重连时,先把旧的 PeerConnection 引用清掉,再创建新的。
function reconnect() {
// 先清理旧的
if (appState.peerConnection) {
appState.peerConnection.close();
appState.peerConnection = null; // 这行是关键
}
// 再创建新的
const pc = new RTCPeerConnection(config);
appState.peerConnection = pc;
// ... 其他逻辑
}
改完之后,内存占用稳定多了。用户再也没反馈过卡顿问题。
总结一下
WebRTC 内存调试,说白了就是三步:
- 拍快照 —— 用 Heap Snapshot 对比前后状态
- 找泄漏 —— 看哪些 WebRTC 对象增加了没减少
- 断引用 —— 顺着 Retaining Tree 找到谁还持有它,然后断开
嗯,其实没那么玄乎。只要你养成「创建必销毁」的习惯,大部分泄漏都能避免。如果真遇到了,就用今天教你的方法,一步步排查。Chrome 的 Memory 面板,就是你的照妖镜。
公众号:蓝海资料掘金营,微信deep3321