问题引入

考完试在寝室摆烂,刷题之余就顺便点开b站刷刷首页的沙雕视频,几天下来,我的电脑便出现了周期性的卡死和内存泄漏。开始以为是M1pro芯片下macOS的内存泄漏,于是采用注销重新登录来解决。直到某一次,我偶然发现只要关闭全部B站相关的页面,内存压力就会瞬间释放,这时,我才意识到,是叔叔给js里面下过毒,于是便把推文发给一个在b站工作的朋友吐了个槽。

寻找问题

吐槽之后,大佬表示希望收到更详细的问题信息,于是...

回忆了一下自己的经历,简单debug了一下发现,好像是首页的「换一换」按钮导致的。
每次点击会刷新推荐视频,但是,原来的DOM节点并没有被回收,而且莫名其妙导致了Chrome的GC失效。
如图我连续点击多次换刷新首页推荐的按钮,已经导致DOM节点数量暴增,内存开销也从最开始的100M到了1.6G

从上图简单看来,点击「换一换」按钮确实导致了JS堆栈逐渐上升,并且没有梯度下降的迹象,内存中的DOM节点数也呈现相同的规律。
复现过程:

  • 打开b站首页(Chrome Version 96.0.4664.110 (Official Build) (arm64))
  • 鼠标不移入window(避免触发某些监听事件)
  • 等待20s,保证当前页面加载完成,此时js会相对稳定
  • 手动进行一次GC,然后dump内存
  • 点击「换一换按钮」,等待首页推荐刷新全部完成
  • 再次手动GC,dump内存

具体数据如下,每次「换一换」会导致:

  • JS堆栈大小永久性增大1M不到
  • DOM节点数增加1k左右
  • 整个标签页在Chrome任务管理器下增大10M左右

那么有两种可能:

  • 每次「换一换」会导致某个数组或对象增大,比如刷新后的视频都被直接push到了队尾,而没有清空原来的队列。
  • 「换一换」在刷新首页推荐时,重新分配了对象,但是原来的对象依然存在引用,导致无法被GC进而产生内存泄漏。

尝试分析

通过多次执行上述过程,得到了一组内存dump的数据,每组数据之间都仅执行了点击「换一换」和手动GC的操作。从图可以看出,也证实了上述的规律。

通过和大佬的简单交流,得出了如下的结论

  1. 最大的第一个array存了很多api获取的data,每次估计是push进去的导致积累
  2. 第四个最大的闭包closure全是sentry的error wrapper
  3. 直觉告诉我前几个最大的包括了sentry的error上报数据、闭包,api获取的数据每次没有清空导致积累

深入问题

前面的分析确实很有说服力,是单纯的没有清空数据而导致的数据积累,进而产生递增的内存占用。
但是,为什么每次都会增加1k左右无法被回收的DOM节点呢?
于是我尝试从内存dump中查找每次新增「已分离」的元素,并且每次和前一次进行对比,一组奇怪的数据便引起了我的注意:

检查首页推荐视频的DOM发现,这个div正是用来存放推荐视频的。又查找了其他新增加的「已分离」的DOM元素,都能找到相似的结果,那么问题很有可能和他有关。 展开继续查看保留的引用发现,有两个极度可疑的Array:

显然,这就是首页推荐视频的相关数据,那么问题来了:

  • 如果说api获取的数据都是直接push,理论上这个数组应该越来越多,可是我多次点击「换一换」数组里得到的依旧是8个推荐视频。

另外,内存dump每次都和上一次比较,都能发现内容相同的数组。也就是说,在刷新首页推荐后,原来的数据没有被销毁,依然在某个地方保留着引用,进而导致了这些已经分离的DOM节点无法被GC。

进一步查看引用这两个数组的对象,又有了新的发现:

引用了这两个数组的是一个叫「blInlinePlayers」的元素,在首页中检查,它正是新版首页video-card中「鼠标悬停预览播放视频」这个feature使用的播放器。
可是,这个播放器却直接被全局对象「window」所引用,我猜测问题大概就出现在这里。

总结

DOM节点每次增加:有一种可能是因为在初始化播放器后,直接将它挂在到了window中,造成了全局变量污染。在点击「换一换」刷新推荐时,没有调用销毁方法将初始化后的那些播放器销毁,而是直接更新了v-dom上的数据;由于一直保留着引用,进而导致了这些「已分离」的DOM节点无法被GC。
JS Heap中每次数据增加(尤其是JS Array和Typed Array):sentry的error上报数据、闭包,api获取的数据每次没有清空导致积累。


What is broken can be reforged.