问题背景

在接手某个微前端项目后,遇到了一个极其棘手的问题。

在持续操作页面一段时间后,Chrome 浏览器的内存占用会偶发性突破 4GB+,最终达到浏览器限制,导致页面卡死甚至直接闪退。

并且具有下面几个特征:

  • 问题只出现在开发环境
  • 问题无法稳定复现
  • 生产环境未反馈类似问题
  • 本项目是一个微前端项目
  • 即使不做任何操作,一段时间后内存也会暴涨

问题分析

是否是内存泄漏?

内存泄漏的定义

当出现内存问题是,我们第一时间想到的是内存泄漏,但是并不是所有的内存问题都一定的内存泄漏导致的。

内存泄漏的定义是:已经不再需要使用的内存,却仍然被程序持有引用,导致无法被回收

逻辑上已经“死亡”的对象,在 GC 视角下仍然“存活”。

内存泄漏 vs 内存爆炸

内存泄漏可能会导致内存爆炸,但是出现了内存爆炸,并不一定是因为内存泄漏。

例如长列表,如果没有实现虚拟滚动等优化手段的话,在滚动多次后,浏览器会创建大量的 dom 节点,这些 dom 节点会持续存在不会被释放,从而导致内存爆炸,但是这些 dom 节点上在业务上我们是需要的,因此并不能算内存泄漏。

因此,是内存泄漏还是其他什么原因导致的内存问题,还需要进一步排查。

是否是内存泄漏?

而排查是否是内存泄漏其实也简单,前面我们说过,内存泄漏是已经不再需要使用的内存,却仍然被程序持有引用,导致无法被回收而导致的。

因此我们可以按照下面这个步骤排查:

  1. 操作一下页面,让内存问题触发
  2. 在 chrome devtools 性能面板点击 GC,进行多次垃圾回收
  3. 等待一段时间,观察内存占用是否大幅下降至正常水平

如果是内存泄漏,那么由于对象引用,GC 将无法回收,导致内存占用无法下降至正常水平,否则基本可以排除内存泄漏。

实际操作中,发现内存并没有明显下降,说明确实存在内存泄漏问题。

微前端的特殊性

项目采用的是微前端架构,其底层使用的微前端框架基于 qiankun 实现。

在微前端场景下,对象的生命周期变得异常复杂,主要体现在:

  • 子应用 mount / unmount / bootstrap 各种生命周期函数
  • 沙箱隔离带来的内存问题(例如Snapshot 模式下会深拷贝 window 对象)
  • 子应用卸载是否有残留
  • 全局事件、缓存、状态可能跨应用残留

问题的触发可能在:

  • 基座应用
  • 某个子应用
  • 微前端框架本身

最后事实证明,虽然上述并没有造成直接影响,但是对本次内存问题产生了间接影响。

问题排查

常规手段的失效:堆快照

定位内存问题最直接的方法是使用 Chrome Devtools 的堆快照功能,它能非常直观观察堆内存空间的对象以及引用链。

实现步骤是:

  1. 操作前拍一次快照
  2. 操作后拍一次快照
  3. 对比对象数量和保留数量
  4. 分析引用链

然而,堆快照在这次内存问题中会失效了,无法派上用场,因为问题是偶发的,当意识到内存问题触发时,内存通常已经暴涨到较高的水平,此时执行堆快照,浏览器直接就崩溃了,观察任务管理器可以发现,在进行堆快照时,浏览器内存直接继续暴涨,当突破到 6 GB 左右时就会崩溃。

整个问题不难理解,堆快照大概率是通过深拷贝堆内存空间中对象的信息来统计分析,因此它整个操作本身也会造成大量内存占用,当内存占用已经在一个比较高的水准时,使用堆快照会引发内存的进一步暴涨,从而导致崩溃。

因此,在大内存问题排查时,堆快照并不是一个好的选择。

轻量级内存工具

既然无法“看全局”,我决定换一个思路:我不需要观察整个堆内存的情况,我只需要追踪我操作页面这段时间内的内存变化情况即可。

最开始选择的是 Chrome Devtools 的性能监视器,但是它只能观察 JS 堆内存的变化情况,无法统计和分析内部对象信息。

最后,我选择了 Chrome DevTools 的另外两个工具作为突破口:

  • Allocation instrumentation on timeline(按时间分配)
  • Allocation sampling(按调用栈采样)

前者只采集某一段时间内分配的对象内存信息,后者是采集调用栈分配的内存信息,相较于堆快照,它们的优点是更加轻量,性能更好。

定位关键函数:deepcopyArray 和 deepcopyObject

通过调用栈采集分析,我们定位到有两个关键函数,在内存大幅上涨期间分配内存比重非常高。

从函数名称和源代码定位中可以发现,这两个函数位于 Vue devtools 源代码中,并且它们的作用是对对象和数组进行深拷贝。我们知道,深拷贝会完整深度复制对象的所有属性,对内存的影响非常大,因此这很可能是造成这次内存泄漏的关键原因之一。

虽然我们找到关键问题函数,但是整个链路我们缺少最后也是最重要的一环,因为深拷贝确实会导致内存上涨的问题,但是正常情况下,在其被使用完毕后,GC 会将其回收,不会导致内存大幅度上涨且无法被释放。

现在像这样直接内存暴涨最终导致浏览器崩溃,说明垃圾回收机制并未将这些对象回收,因此,我们将下一步的重点放在垃圾回收机制上。

真正的根因:Vue Devtools 的隐藏引用链

V8 垃圾回收与可达性

要想彻底解决这个内存问题,我们必须先对浏览器的 GC 有一定的了解。现代浏览器基本都采用标记清除法来实现垃圾回收,通常来说有三个核心步骤:

  1. 标记”活”的对象
  2. 清除未被标记的对象(死对象)
  3. 内存整理(可选)

从这里可以看出,关键在于如何定义什么是活的对象。在现代垃圾回收机制中,GC 会从一组根(DOM 根节点、当前执行上下文和全局对象等)出发,递归遍历每一个“可达”的对象,这些对象会被标记成“活对象”,而未被比较的对象则说明无法被访问,是“死对象”,需要被垃圾回收。

事实上,现代浏览器为 GC 做了大量的优化工作,实际比上述更加复杂,例如基于代际假说将对象分为新生代和老生带,使用主副垃圾回收器进行垃圾回收,使用并行、并发、增量等方式优化。 如果你有兴趣,可以参考笔者下面的文章。 深入浏览器引擎 IV:V8 垃圾回收机制 用 JavaScript 实现 V8 的垃圾回收:从MinorGC到MajorGC

也就是说,前面通过 deepcopyArray 和 deepcopyObject 创建的对象,必然会被某些对象引用着,并且这些对象形成的引用链最终指向根(DOM 根节点、当前执行上下文或全局对象等)。我们需要找到这个引用链。

最后一块拼图:引用链

确定好目标后,实现起来也很简单,使用前面提到过的按时间分配的内存工具,我们可以找到在某段时间内对象的内存分配情况,其中就包括引用链。

Vue devtools 做了什么

最终问题定位如图所示:

从图片中可以看出,Vue devtools 在 window 全局对象下挂载了一个对象,并且这个对象中的某个属性保存了前面通过 deepcopyArray 和 deepcopyObject 深拷贝的对象数据,正是由于这些数据被挂载在 window 全局对象上,导致对于 GC 来说,这些对象都是“可达的”,是“活对象”,因此不会将它们回收,从而导致了内存泄漏。

并且通过对 deepcopyArray 和 deepcopyObject 两个函数进行断点回溯,可以发现,它们会监听 vuex 的 commit 操作,结合前面的引用链可以推测:

Vue Devtools 会在时间轴组件中,记录 Vuex commit 的操作,其中会深拷贝 commit 传递的参数,并且 vue devtools 出于设计考虑,将这部分数据直接存储在 window 全局对象中,并且没有手动将其释放,从而导致了内存泄漏。

这也解释了为什么问题只发生在开发环境且是偶发的,因为我在开发环境会习惯性打开浏览器 devtools,从而导致 Vue Devtools 被激活,并且只有我切换时间轴组件时,它才会自动开始记录深拷贝 Vuex commit 的参数。

微前端扮演的角色

虽然整个链路都排查完毕,但是还有一个问题困扰我,虽然深拷贝+全局对象引用确实会导致内存泄漏,但是只要拷贝的对象不是特别大,那么也不会在短时间内导致内存极速大幅上涨。

也就是说:项目代码中很可能存在大数据的 VueX commit 事件。

顺着这个思路,很快就定位到代码。

从代码中可以看到,在这个微前端是通过事件总线来进行状态管理和消息通讯的,而在当前这个子应用中,老代码为了方便直接监听微前端的全局更新事件,并将包含基座应用在内的整个状态对象都通过 VueX commit 到自身的状态管理库中,这是导致本次内存泄漏问题的间接原因之一。

整个链路梳理

Vue Devtools 在时间轴中会监听 Vuex commit 函数事件,并且这个过程是自动触发的,它会深拷贝 commit 的参数,并且将其存储在 window 全局对象的属性上,导致这些对象是否是“可达的”,从而导致 GC 无法将其回收。

再加上子应用中直接将微前端整个全局状态对象都 commit 到自己的 Vuex 中,这就会导致哪怕在页面上进行微小的操作,只要触发了微前端的这个事件,就会导致一个大数据对象被深拷贝,从而使得内存占用大幅度上涨。

解决方案

微前端优化

从代码上来看,将整个微前端的全局状态直接简单地同步到 vuex 中肯定是不合理的,并且如果由于用户操作导致全局状态更新事件被频繁触发,那么将会短时间内频繁 commit 更新一个很大的对象对性能的影响也是非常大的。

因此,即使不考虑 vue devtools,这种方式也应该优化。

提案

此外,Vue Devtools 自身的设计缺陷是导致本次内存泄漏问题的直接原因:

  • 深拷贝是合理的,因为要保存快照
  • 自动记录是不合理的,应该像 Chrome Devtools 性能面板那样手动启动,并且提供清空回收的功能。

基于这次排查,笔者向 Vue Devtools 官方提交了一个 Proposal。