22、内存调试:使用 Chrome 的 Memory 面板分析 WebRTC 内存泄漏问题

做 WebRTC 开发,最怕什么?

我个人最怕的就是内存泄漏。视频通话跑着跑着,内存占用一路飙升,最后浏览器直接崩溃。用户那边看到的,就是画面卡住、黑屏、或者干脆标签页挂了。

说实话,WebRTC 的内存泄漏问题,比普通网页要隐蔽得多。因为里面涉及了媒体流、PeerConnection、编解码器、网络传输……每一个环节都可能藏着泄漏点。今天我就带你用 Chrome 的 Memory 面板,把这些泄漏点一个个揪出来。

为什么 WebRTC 容易内存泄漏?

先说说根因。WebRTC 本质上是一个实时多媒体管道。这条管道里,数据一直在流动:

  • 摄像头采集的视频帧
  • 麦克风采集的音频数据
  • 编码后的 RTP 包
  • ICE 连接状态
  • Stats 统计对象

你想想看,这些对象如果创建了没销毁,或者引用链没断开,GC(垃圾回收)就永远收不掉它们。我在项目中遇到过最典型的情况:每次创建 PeerConnection 时都 new 了一个 MediaStream,但关闭通话时只关了连接,忘了释放流。结果呢?内存里躺着几十个废弃的 MediaStream 对象,每个还带着一堆 Track。

核心原则: WebRTC 的内存泄漏,90% 是因为「对象创建了,但引用没断开」。剩下的 10%,是闭包和事件监听器搞的鬼。

Chrome Memory 面板入门

打开 Chrome 开发者工具,切到 Memory 面板。你会看到三个选项:

选项 用途 我什么时候用
Heap snapshot 拍一张内存快照 对比前后状态,找泄漏对象
Allocation instrumentation on timeline 记录一段时间内的内存分配 追踪泄漏发生的瞬间
Allocation sampling 采样分析内存分配 性能开销大,我一般不用

嗯,这里要注意:做 WebRTC 内存调试,我习惯用「Heap snapshot」配合「Allocation timeline」。前者看静态结果,后者看动态过程。

实战:用 Heap Snapshot 抓泄漏

假设你有一个视频通话应用。用户 A 和用户 B 通话结束后,内存应该回到通话前的水平。如果回不去,那就是泄漏了。

操作步骤很简单:

  1. 在通话开始前,拍一张快照(Snapshot 1)
  2. 建立通话,正常通话 30 秒
  3. 挂断通话,等 10 秒让 GC 跑一次
  4. 再拍一张快照(Snapshot 2)
  5. 对比两张快照

具体怎么对比?看这里:

// 在 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 都增加了,而且一个都没释放。这就是泄漏的铁证。

小技巧: 在 Constructor 搜索框里输入 "MediaStream" 或 "RTCPeerConnection",可以快速定位 WebRTC 相关对象。我每次排查泄漏,第一步就是搜这两个关键词。

追踪泄漏源头:Allocation Timeline

快照对比能告诉你「泄漏了什么」,但有时候你还需要知道「泄漏发生在哪一步」。这时候就要用 Allocation timeline 了。

操作流程:

  1. 在 Memory 面板选择 "Allocation instrumentation on timeline"
  2. 点击 "Start" 开始记录
  3. 执行一次完整的通话流程(创建 → 通话 → 挂断)
  4. 点击停止记录

你会看到一条时间轴,上面有密密麻麻的蓝色竖线。每条竖线代表一次内存分配。点击某条竖线,就能看到那个时刻分配了哪些对象。

我曾经用这个方法抓到一个特别隐蔽的泄漏:每次收到远端视频流时,代码里都会 new 一个 VideoFrame 对象,然后把它传给一个全局的 canvas 做处理。但处理完后,canvas 的引用没清掉,导致 VideoFrame 一直被持有。每次通话都会累积几十个 VideoFrame,内存就这么一点点涨上去了。

注意: Allocation timeline 会记录所有内存分配,数据量很大。建议只录制关键操作(比如建立连接、接收流、关闭连接),不要录太久。否则 Chrome 可能会卡死。

常见 WebRTC 泄漏模式

根据我的经验,WebRTC 内存泄漏主要有三种模式。我列个表,方便你对照排查:

泄漏模式 典型表现 排查方向
PeerConnection 未关闭 RTCPeerConnection 对象持续增加 检查 close() 是否调用,是否在错误处理中遗漏
MediaStream 未释放 MediaStream / MediaStreamTrack 对象堆积 检查 stop() 调用,检查全局变量引用
事件监听器未移除 回调函数对象持续增加 检查 addEventListener 对应的 removeEventListener

说白了,这三种模式的核心都是同一个问题:创建了,没销毁

SVG:WebRTC 内存泄漏排查流程

下面这张图,是我自己总结的排查流程。每次遇到内存问题,我就按这个步骤走一遍,基本都能找到根因。

WebRTC 内存泄漏排查流程 1. 发现异常 2. 拍 Heap Snapshot 3. 对比 Delta 有泄漏? 正常,结束排查 4. 定位泄漏对象 5. 追踪引用链 6. 修复代码

如何追踪引用链?

找到泄漏对象后,下一步就是看「谁还引用着它」。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;
}
我的习惯: 每次创建 WebRTC 相关对象时,我都会在代码里写一个对应的 cleanup 函数。创建和销毁成对出现,这样就不容易漏。比如 createPeerConnection() 对应 destroyPeerConnection(),createMediaStream() 对应 releaseMediaStream()。

一个真实案例

我记得有一次,线上反馈说视频通话页面用久了会卡。我打开 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;
  // ... 其他逻辑
}

改完之后,内存占用稳定多了。用户再也没反馈过卡顿问题。

避坑指南: 我曾经以为 close() 之后对象就会被回收。后来才发现,close() 只是关闭连接,不会释放对象。你必须手动断开所有引用,GC 才会来收。这个坑,我踩过不止一次。

总结一下

WebRTC 内存调试,说白了就是三步:

  • 拍快照 —— 用 Heap Snapshot 对比前后状态
  • 找泄漏 —— 看哪些 WebRTC 对象增加了没减少
  • 断引用 —— 顺着 Retaining Tree 找到谁还持有它,然后断开

嗯,其实没那么玄乎。只要你养成「创建必销毁」的习惯,大部分泄漏都能避免。如果真遇到了,就用今天教你的方法,一步步排查。Chrome 的 Memory 面板,就是你的照妖镜。


公众号:蓝海资料掘金营,微信deep3321