由浅入深的带你找到前端内存泄漏的根源,彻底解决问题

229 阅读10分钟

相信你在工作中一定碰到过内存泄漏问题,内存泄露在通常情况下,并不会影响应用的功能,直到应用运行时间足够长,请求或者操作足够多的话,问题才会暴露,同时也会带来一些损失。如何更简单地分析、暴露且解决内存泄漏问题,几乎是我们每一个开发者亟需掌握的技能。本次我会给你介绍排查内存泄漏的工具,并且结合一个实际的例子来具体分析发生内存泄漏时的具体排查步骤。

所谓“工欲善其事,必先利其器”,我们先来看下哪些工具可以帮我们排查内存泄漏。

工具一:Chrome 任务管理器工具

第一种方法是 Chrome 的任务管理器。入口在浏览器右上角三个点 -> 更多工具 -> 任务管理器。

通过任务管理器这个工具,我们可以用来粗略地查看内存使用情况,这里主要关注两列信息:

一列是内存占用空间,表示原生内存。DOM 节点存储在原生内存中,如果这个值正在增大,就说明正在创建 DOM 节点。另外一列是 JavaScript 使用的内存,表示 JS 堆。这一列主要看括号中的数值就可以了,这个值是内存使用的实时值,表示页面上的可访问对象正在使用的内存量。如果这个数值在增大,要么是正在创建新对象,要么是现有对象正在增长。

工具二:Chrome DevTools Performance 面板

第二个工具是 Performance 时间轴面板,通过这个工具我们可以实时查看到 JavaScript 内存使用情况、节点数量、监听器数量。我们可以通过下面这段代码来模拟一下内存泄漏,你可以在浏览器中运行一下,实际的操作更能让我们熟悉调试工具。

<button id="grow">start</button>
<script>
  // 点击按钮,就执行一次函数,申请一块内存
  var arr = [];
  function grow() {
    var a = new Array(2000000).fill(1);
    var b = new Array(2000000).fill(1);
    arr.push(b);
  }
  document.getElementById('grow').addEventListener('click', grow);
</script>

打开控制台,切换到 Performance 面板,然后勾选 Memory 复选框。如果你想看页面首次加载过程内存使用情况的话,可以按 Command + R 刷新页面,就会自动记录整个加载过程。想看某些操作前后的内存变化的话,操作前点“黑点”按钮开始记录,操作完毕点“红点”按钮结束记录。

记录完毕后勾选中部的 JS Heap,蓝色折线表示内存变化趋势,如果总体趋势不断上涨,没有大幅回落,就再通过手动 GC 来确认:再操作记录一遍,操作结束前或者过程中做几次手动 GC(点“黑色垃圾桶”按钮),如果 GC 的时间点折线没有大幅回落,整体趋势还是不断上涨,就有可能存在内存泄漏。

另外可以通过 Performance monitor 来更直观地查看 CPU、内存等使用情况。入口是在控制台右上角三个点展开,查看更多工具里面。打开之后就可以实时看到 CPU、JS 堆、DOM 节点的实时变化情况。

工具三:Chrome DevTools Memory 面板

时间轴面板可以直观、实时地监控内存使用情况,但是要深入分析,定位问题原因,还得靠 Memory 面板。这个面板有 3 个选项,分别是堆快照、内存分配情况和内存分配时间轴,我先给你解释一下这几个的作用:

  • 堆快照(Take Heap Snapshot),用来具体分析各类型对象存活情况,包括实例数量、引用路径等等。
  • 内存分配情况(Record Allocation Profile),用来查看分配给各函数的内存大小。
  • 内存分配时间轴(Record Allocation Timeline),用来查看实时的内存分配及回收情况。

其中内存分配时间轴和堆快照比较有用,时间轴用来定位内存泄漏操作,对快照用来具体分析问题。这里你先有个印象,知道 Memory 面板的这几个功能。后面会结合真实案例来详细介绍怎么使用它们。

找到内存泄露问题的四个步骤

知道了这些调试工具,我们怎么具体排查出内存泄漏的根源问题呢?

接下来我会以一个稍微复杂一点的例子来分析,给你梳理排查内存泄漏的步骤,我们边用工具分析,边确认逻辑流程漏洞。

你先看一下这段代码,它做的事情是每次点击页面按钮时会调用 replaceThing 这个函数,t 得到包含一个大数组和一个新闭包(也就是 someMethod)的新对象。同时,变量 unused 是一个引用 o 的闭包。这段代码看起来比较常规,但是频繁触发按钮操作的时候就会触发内存泄漏,下面我们来针对这个代码片段做详细的分析:

var t = null;
var replaceThing = function() {
  var o = t;
  var unused = function() {
    if (o) {
      console.log("hi");
    }
  }
  t = {
    longStr: new Array(1000000).fill('*'),
    someMethod: function() {
      console.log(1);
    }
  }
}
setInterval(replaceThing,100);
var btnEl = document.getElementById('grow');
btnEl.addEventListener('click', replaceThing);

步骤 1:先确认是否真的存在内存泄漏

前面我们有讲到 Performance 面板,我们可以先通过 Performance 面板来确定是否存在内存泄漏。Performance 面板能实时看到 DOM 节点数和事件监听器的变化趋势,我们按照这个步骤来操作:

切换到 Performance 面板

开始记录(有必要从头记的话)开始记录 -> 操作 -> 停止记录 -> 分析 -> 重复确认

确认存在内存泄漏的话,缩小范围,确定是什么交互操作引起的

这里我们记录了 5s,记录过程中我们每隔 1s 点击一次按钮,可以看到面板上蓝色 JS 堆呈梯度上升,也就是没触发一次点击,内存都在增加。确定了这段代码在按钮点击的时候是存在内存泄漏的。

步骤 2:分析堆快照,找出可疑对象

好,第一步已经完成了,当我们确定了频繁点击按钮会触发内存泄漏,接下来第二步通过堆快照来进一步分析出可以对象:

切换到 Memory 面板,截快照 1

做一次可疑的交互操作,截快照 2

对比快照 2 和 1,看数量增量变化情况

做多次次可疑的交互操作,截更多快照对比

验证猜测,确定什么东西没有被按预期回收

通过抓取两份快照,两份快照中间执行步骤 1 可能存在内存泄漏的操作,抓取后比对两份快照的区别,查看增加的对象是什么,回收的对象又是哪些。内存快照我们关注 Shallow Size,也就是对象自身占用内存的大小。通过对比可以发现,触发按钮之后在抓取的内存快照,快照 2 的对象占用内存大小明显比快照 1 多很多,选中某个分配的数组,还可以看到数组是哪里使用的,这里显示的就是全局变量 arr。

步骤 3:定位问题,找到原因

好,通过第二步我们锁定可疑对象,再进一步定位问题,找到具体原因:

这里我要先解释一个术语,Distance,这个 Distance 是指与当前对象与全局对象根之间的距离。如果某类型的绝大多数对象的 Distance 都相同,只有少数对象的距离偏大,就有必要仔细查查。我们可以看到这个示例中 arr 的 Distance 已经高达 481,也就意味着这个变量距离 root 的引用的链路长度很长很长。我们就要具体分析一下为什么引用链路会这么长。

var t = null;
var replaceThing = function() {
  var o = t;
  var unused = function() {
    if (o) {
      console.log("hi");
    }
  }
  t = {
    longStr: new Array(1000000).fill('*'),
    someMethod: function() {
      console.log(1);
    }
  }
}
var btnEl = document.getElementById('grow');
btnEl.addEventListener('click', replaceThing);

回到代码我们分析一下,replaceThing 的第一次创建,这个对象被全局变量 t 持有,所以回收不了,后来每一次调用,这个对象都被上一个 replaceThing 函数内部的 o 局部变量持有而回收不了。而这个函数内部的局部变量 o 在 replaceThing 首次调用时被创建的对象 someMethod 方法持有,该方法挂载的对象被全局变量 t 持有,所以也回收不了。

这样层层持有每一个函数的调用,都会持有函数上次调用时内部创建的局部变量,导致函数即使执行结束,这些局部变量也无法回收。

所以,第三步我们做的事情就是:

检查该类型对象的 Distance 是否正常,大多数实例都是 3 级 4 级,个别到 10 级以上算异常。

查看路径深度 10 级以上的实例,什么东西引用着它,定位到问题源头。

步骤 4:释放引用,修复验证

到这里基本找到问题源头了,接下来就是解决问题了,解决问题的思路就是想办法断开这个引用,梳理逻辑流程,将其它地方存在的不需要的引用都释放掉,最后修改验证。

因为闭包的典型实现方式是每个函数对象都有一个指向字典对象的关联,这个字典对象表示它的词法作用域。只要变量被任何一个闭包使用了,就会被添到词法环境中,被该作用域下所有闭包共享。这是闭包引发内存泄漏的关键,所以如果把 unused 删掉(或者把 unused 里的 o 访问去掉),就能解决内存泄漏。

我们把 unused 删掉,或者把 unused 里的 o 访问去掉时再次运行一下:

可以看到,在触发按钮时会有 JS 堆的增加,但到一定程度就会自动触发垃圾回收机制,到后面 JS 堆曲线就下降了。到这里,这个示例中闭包导致的内存泄漏问题就解决了。

好,我们来总结一下这个案例中我们是怎么定位问题的。第一步通过 Performance 工具发现内存呈阶梯上升,且手动 GC 内存都无法下降,说明内存泄漏了。然后第二步我们通过 Memory 工具抓取一段时间的内存申请情况,可以确定嫌疑函数是 replaceThing。接着对比内存快照发现,没有回收的是 replaceThing 内部创建的对象。然后进一步分析内存快照发现,之所以没有被回收,是因为每次函数调用创建的这个对象会被存储在函数上一次调用时内部创建的局部变量 o 上。最后得出结论局部变量 o 在函数执行结束时没有被回收,是因为它被创建的对象的 someMethod 方法持有。解决办法就是把 unused 删掉,或者把 unused 里的 o 访问去掉。

总结

最后我们来回顾一下讲的内容。这节课我们讲了怎么通过 Chrome 的调试工具对内存泄漏进行定位排查。墨菲定律告诉我们,如果事情有变坏的可能,不管可能性有多小,它总会发生。对应到内存泄漏就是,当系统足够复杂的后,它总是可能会发生的。内存泄漏发生的时候可以通过分析工具定位到可疑原因,然后分析堆快照定位可疑对象,结合代码定位问题,找到原因,对内存泄漏进行及时修复。