深入JavaScript(10)内存泄漏

51 阅读14分钟

JavaScript的内存泄漏

1.1 什么是内存泄漏

引擎中有垃圾回收机制,它主要针对一些程序中不再使用的对象,对其清理回收释放掉内存。

那么垃圾回收机制会把不再使用的对象(垃圾)全都回收掉吗?

其实引擎虽然针对垃圾回收做了各种优化从而尽可能的确保垃圾得以回收,但并不是说我们就可以完全不用关心这块了,我们代码中依然要主动避免一些不利于引擎做垃圾回收操作,因为不是所有无用对象内存都可以被回收的,那当不再用到的对象内存,没有及时被回收时,这种场景称之为内存泄漏(Memory leak)。

1.2 常见的内存泄漏

1.2.1 不正当的闭包

闭包是指有权访问另一个函数作用域中的变量的函数

function fn1() {
    let test = new Array(1000).fill("lyl");
    return function () {
      console.log("lyl");
    };
}
let fn1Child = fn1();
fn1Child();

虽然这是个典型闭包,但是它并没有造成内存泄漏,因为返回的函数中并没有对 fn1 函数内部的引用,也就是说,函数 fn1 内部的 test 变量完全是可以被回收的,再来看:

function fn2() {
    let test = new Array(1000).fill("xianzao");
    return function () {
      console.log(test);
      return test;
    };
}
let fn2Child = fn2();
fn2Child();

这也是个闭包,并且因为 return 的函数中存在函数 fn2 中的 test 变量引用,所以 test 并不会被回收,也就造成了内存泄漏。

那解决办法就是在函数调用后,把外部的引用置空就好了:

let fn2Child = fn2()
fn2Child()
fn2Child = null

还有一种比较隐秘的闭包导致的内存泄露

<body>
    <button id="btn">click</button>

    <script>
      function fn1() {
        let test = new Array(1000000).fill("ky");
        function a() {
          test;
        }
        function b() {}
        return b;
      }
      let fn1Child;

      btn.addEventListener("click", () => {
        fn1Child = fn1();
      });
    </script>
  </body>

原因:当多个函数共享词法环境时,会导致词法环境膨胀,从而导致出现无法触达也无法回收的内存空间。

通俗来说:a函数和b函数的闭包环境是一样的,共享同一个词法环境,如果没有a函数或者a函数没用到test变量,那就不会造成内存泄漏。在return b函数后,a函数就没有了,但是词法环境里仍保留了test,因为这个时间点,优化词法环境的步骤已经结束了,所以就已经回收不了了。

1.2.2 隐式全局变量

JavaScript 的垃圾回收是自动执行的,垃圾回收器每隔一段时间就会找出那些不再使用的数据,并释放其所占用的内存空间。

再来看全局变量和局部变量,函数中的局部变量在函数执行结束后这些变量已经不再被需要,所以垃圾回收器会识别并释放它们。但是对于全局变量,垃圾回收器很难判断这些变量什么时候才不被需要,所以全局变量通常不会被回收,我们使用全局变量是 OK 的,但同时我们要避免一些额外的全局变量产生,如下:

function fn() {
    // test1
    test1 = new Array(1000).fill("xianzao");
    // thiswindowtest2
    this.test2 = new Array(1000).fill("xianzao");
}
fn();

调用函数 fn ,因为 没有声明 和 函数中this 的问题造成了两个额外的隐式全局变量,这两个变量不会被回收,这种情况我们要尽可能的避免,在开发中我们可以使用严格模式或者通过 lint 检查来避免这些情况的发生,从而降低内存成本。

除此之外,我们在程序中也会不可避免的使用全局变量,这些全局变量除非被取消或者重新分配之外也是无法回收的,这也就需要我们额外的关注,也就是说当我们在使用全局变量存储数据时,要确保使用后将其置空或者重新分配,当然也很简单,在使用完将其置为 null 即可,特别是在使用全局变量做持续存储大量数据的缓存时,我们一定要记得设置存储上限并及时清理,不然的话数据量越来越大,内存压力也会随之增高。

var test = new Array(10000)
// do something
test = null

1.2.3 游离DOM引用

考虑到性能或代码简洁方面,我们代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放:

<body>
    <div id="root">
      <ul id="ul">
        <li></li>
        <li></li>
        <li id="li3"></li>
        <li></li>
      </ul>
    </div>

    <script>
      let root = document.querySelector("#root");
      let ul = document.querySelector("#ul");
      let li3 = document.querySelector("#li3");
      // GC
      root.removeChild(ul);
      // ulGC
      ul = null;
      // li3GC
      li3 = null;
    </script>
</body>

假如我们将父节点置空,但是被删除的父节点其子节点引用也缓存在变量里,那么就会导致整个父 DOM 节点树下整个游离节点树均无法清理,还是会出现内存泄漏,解决办法就是将引用子节点的变量也置空,如下图:

image.png

1.2.4 定时器

开发时我们经常会用到计时器,也就是 setTimeoutsetInterval ,先来看一个例子:

let someResource = getData();
setInterval(() => {
    const node = document.getElementById("Node");
    if (node) {
      node.innerHTML = JSON.stringify(someResource);
    }
}, 1000);

代码中每隔一秒就将得到的数据放入到 Node 节点中去,但是在 setInterval 没有结束前,回调函数里的变量以及回调函数本身都无法被回收。

什么才叫结束呢?也就是调用了 clearInterval 。如果没有被 clear 掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也没法被回收。所以在上例中, someResource 就没法被回收。

同样, setTiemout 也会有同样的问题,所以,当不需要 interval 或者 timeout 时,最好调用 clearInterval 或者clearTimeout 来清除,另外,浏览器中的 requestAnimationFrame 也存在这个问题,我们需要在不需要的时候用cancelAnimationFrame API 来取消使用。

1.2.5 时间监听器

当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样就造成意外的内存泄漏。

<template>
  <div></div>
</template>
<script>
export default {
  created() {
    window.addEventListener("resize", this.doSomething);
  },
  beforeDestroy() {
    window.removeEventListener("resize", this.doSomething);
  },
  methods: {
    doSomething() {
      // do something
    },
  },
};
</script>

1.2.6 Map、Set对象

当使用 Map 或 Set 存储对象时,同 Object 一致都是强引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收。

如果使用 Map ,对于键为对象的情况,可以采用 WeakMap,WeakMap 对象同样用来保存键值对,对于键是弱引用(注:WeakMap 只对于键是弱引用),且必须为一个对象,而值可以是任意的对象或者原始值,由于是对于对象的弱引用,不会干扰 Js 的垃圾回收。

如果需要使用 Set 引用对象,可以采用 WeakSet,WeakSet 对象允许存储对象弱引用的唯一值,WeakSet 对象中的值同样不会重复,且只能保存对象的弱引用,同样由于是对于对象的弱引用,不会干扰 Js 的垃圾回收。

这里可能需要简单介绍下,谈弱引用,我们先来说强引用,之前我们说 JS 的垃圾回收机制是如果我们持有对一个对象的引用,那么这个对象就不会被垃圾回收,这里的引用,指的就是 强引用 ,而弱引用就是一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,因此可能在任何时刻被回收。

// obj
let obj = {id: 1}
// obj
obj = null
// {id: 1} GC
// 这是一个简单的通过重写引用来清除对象引用,使其可回收。

// 再看下面这个:
let obj = {id: 1}
let user = {info: obj}
let set = new Set([obj])
let map = new Map([[obj, 'xianzao']])
// obj
obj = null
console.log(user.info) // {id: 1}
console.log(set)
console.log(map)

这里重写了 obj 以后, {id: 1} 依然会存在于内存中,因为 user 对象以及后面的 set/map 都强引用了它,Set/Map、对象、数组对象等都是强引用,所以我们仍然可以获取到 {id: 1} ,我们想要清除那就只能重写所有引用将其置空了。

接下来我们看 WeakMap 以及 WeakSet:

let obj = {id: 1}
let weakSet = new WeakSet([obj])
let weakMap = new WeakMap([[obj, 'xianzao']])

// obj
obj = null
// {id: 1} GC

使用了 WeakMap 以及 WeakSet 即为弱引用,将 obj 引用置为 null 后,对象 {id: 1} 将在下一次 GC 中被清理出内存。

1.2.7 Console

在一些小团队中可能项目上线也不清理这些 console,殊不知这些 console 也是隐患,同时也是容易被忽略的,我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console 如果输出了对象也会造成内存泄漏。

所以,开发环境下我们可以使用控制台输出来便于我们调试,但是在生产环境下,一定要及时清理掉输出。

1.3 内存泄漏排查、定位与修复

首先来看个例子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>test</title>
  </head>
  <body>
    <button id="click">click</button>
    <h1 id="content"></h1>
    <script>
      let click = document.querySelector("#click");
      let content = document.querySelector("#content");
      let arr = [];
      
      function closures() {
        let test = new Array(10000).fill("lyl");
        return function () {
          return test;
        };
      }
      
      click.addEventListener("click", function () {
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        content.innerHTML = arr.length;
      });
    </script>
  </body>
</html>

这是一个由不正当使用闭包构成的内存泄漏例子。

有一个 closures 函数,这是一个闭包函数,我们为页面中的 button 元素绑定了一个点击事件,每次点击都将执行 10 次闭包函数并将其执行结果 push 到全局数组 arr 中,由于闭包函数执行结果也是一个函数并且存在对原闭包函数内部数组 test 的引用,所以arr 数组中每一项元素都使得其引用的闭包内部 test 数组对象无法回收,arr 数组有多少元素,也就代表着我们存在多少次闭包引用,所以此程序点击次数越多,push 的越多,内存消耗越大,页面也会越来越卡。

1.3.1 排查问题

一般我们会使用Chrome的 devTool来排查问题,通过它可以帮助我们分析程序中像性能、安全、网络等各种东西,也可以让我们快速定位到问题源。

找到 Performance 这一面板,它可以记录并分析在网站的生命周期内所发生的各类事件,我们就可以通过它监控我们程序中的各种性能情况并分析,其中就包括内存,如下图:

image.png

先清理一下GC,点击开始录制进入录制状态,接着疯狂点击页面中 click 按钮 10 次,这时页面上的数值应该是 100,我们再点击一下小垃圾桶,手动触发一次 GC。

再次疯狂点击页面中 click 按钮 10 次,这时页面上的数值应该是 200,然后停止录制。

这时候我们可以发现:尽管我们在点击10次后执行GC,但heap并没有清空,这就是内存泄漏。

1.3.2 分析定位

Chrome Devtool 还为我们提供了 Memory 面板,它可以为我们提供更多详细信息,比如记录 JS CPU 执行时间细节、显示 JS 对象和相关的DOM节点的内存消耗、记录内存的分配细节等。

其中的 Heap Profiling 可以记录当前的堆内存 heap 的快照,并生成对象的描述文件,该描述文件给出了当下 JS 运行所用的所有对象,以及这些对象所占用的内存大小、引用的层级关系等等,用它就可以定位出引起问题的具体原因以及位置。

image.png

重新刷新下页面,清除GC,生成第一次快照:

左侧列表中的 Snapshot 1 代表了我们生成的快照1,也就是刚刚那一刻的内存状态。

选中 Snapshot 1 后就是右侧视图表格了,表格左上方有一个下拉框,它有四个值:

  1. Summary:按照构造函数进行分组,捕获对象和其使用内存的情况,可理解为一个内存摘要,用于跟踪定位DOM节点的内存泄漏;

  2. Comparison:对比某个操作前后的内存快照区别,分析操作前后内存释放情况等,便于确认内存是否存在泄漏及造成原因;

  3. Containment:探测堆的具体内容,提供一个视图来查看对象结构,有助分析对象引用情况,可分析闭包及更深层次的对象分析;

  4. Statistics:统计视图;

该下拉默认会为我们选择 Summary ,所以下方表格展示的就是快照1中数据的内存摘要,简单理解就是快照1生成的那一刻,内存中都存了什么,包括占用内存的信息等等。

接下来来简单了解下 Summary 选项数据表格的列都表示什么:

  1. Constructor:显示所有的构造函数,点击每一个构造函数可以查看由该构造函数创建的所有对象;

  2. Distance:显示通过最短的节点路径到根节点的距离,引用层级;

  3. Shallow Size:显示对象所占内存,不包含内部引用的其他对象所占的内存;

  4. Retained Size:显示对象所占的总内存,包含内部引用的其他对象所占的内存;

然后同上述操作一样,清除GC,点击一次click,再生成快照,总共生成四次快照。

接下来可以比较第三次和第四次快照中的数据对比; 这样可以过滤很多没有变化的数据,方便对比。

其中,Constructor列是构造函数,每一个构造函数点击都可以查看由该构造函数创建的所有对象,还是要先介绍下此列中常见的构造函数大致代表什么:

  1. system、system/Context 表示引擎自己创建的以及上下文创建的一些引用,这些不用太关注,不重要;

  2. closure 表示一些函数闭包中的对象引用;

  3. array、string、number、regexp 这一系列也能看出,就是引用了数组、字符串、数字或正则表达式的对象类型;

  4. HTMLDivElement 、 HTMLAnchorElement 、 DocumentFragment 等等这些其实就是你的代码中对元素的引用或者指定的 DOM 对象引用;

1.3.2.1 closure

首先看闭包,可以发现有10次闭包,指向第20行:

image.png

image.png

说明哪怕清除GC后,还是有闭包数据导致占用内存。

1.3.2.2 array

这里array可以发现,是由于每次执行10次 arr.push(closures()) 导致的,数量也是10,且在GC清除后,数据内容都为上次基础之上添加的"lyl"。

image.png

1.3.2.3 总结

至此,可以确认,错误有两点:

  1. 20行的闭包引用数组造成的内存泄漏;

  2. 全局变量 arr 的元素不断增多造成的内存泄漏;

1.3.3 修复验证

在实际项目中,比如全局对象一直增大这个问题,全局对象我们无法避免,但是可以限制一下全局对象的大小,根据场景可以超出就清理一部分。

比如闭包引用的问题,不让它引用,或者执行完置空。

总之,一切都需要根据具体场景选择解决方案,解决之后重复上面排查流程看内存即可。

1.4 常见的前端内存问题

  1. 内存泄漏:主要注意上述问题;

  2. 内存膨胀:即在短时间内内存占用极速上升到达一个峰值,想要避免需要使用技术手段减少对内存的占用;

  3. 频繁 GC:GC 执行的特别频繁,一般出现在频繁使用大的临时变量导致新生代空间被装满的速度极快,而每次新生代装满时就会触发 GC,频繁 GC 同样会导致页面卡顿,想要避免的话就不要搞太多的临时变量,因为临时变量不用了就会被回收;