V8中内存泄漏监控及定位

1,459 阅读9分钟

什么是内存泄漏?

内存泄漏是指某个对象被无意间添加了引用,但是实际一直没有使用,一直遍历可达,导致变量一直存在内存中,没有被回收,这就造成了内存泄漏。

至于为什么会发生内存泄漏?虽然V8中有垃圾回收器,可通过标记清除等方法清除垃圾,但在某些情况下某些对象一直被引用,无法被GC,这些对象一直存在内存中直到页面关闭,这就造成了内存泄漏。

引起内存泄漏的几个场景?

1、意外的全局变量

没有被收回的全局变量:

function func() {
	content = "因为content没有被声明,会变成一个全局变量,所以在页面关闭前不会被回收!";
}
func();

上面的函数中的content没有被声明,会变成一个全局变量,所以在页面关闭前不会被回收。

this引起的全局变量:

function func () {
	this.content = "调用func函数,this指向了全局对象(window)"
}
func();

因为调用函数func时,func中的this指向的是window,相当于在全局上声明了一个变量,只有页面关闭才会被收回。

2、未清除的定时器

setTimeout和setInterval在eventloop中属于宏任务,浏览器有单独的线程维护它们的声明周期,所以当页面使用了定时器后,在销毁阶段时没有手动释放这些定时器,这些定时器会一直存活在内存中,不可避免的造成内存泄漏,如下:

// html
<div id="box"></div>

// js
<script>
  const aBox = document.getElementById("box");

  setInterval(() => {
    const div = document.createElement("div");
    div.innerHTML = "未清除定时器,会一直添加";

    aBox.appendChild(div);
  }, 1000);
</script>

上面的例子,每隔1s向box中插入一个div,如果不清楚定时器,该定时器一直会在内存中执行,无法被释放,造成内存泄漏,以下是通过Performance monitor监控的内存泄漏图,具体后面参数后面会讲到。

1.png

3、闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

-- 引用mdn闭包

function closure() {
  const content = "这是在闭包中声明的参数";

  function fn() {
    console.log(content);
  }

  return fn;
}

const c = closure();
c();

closure函数创建了一个局部变量content,和fn的函数。当调用closure函数的时候,会生成一个closure的执行上下文,该执行上下文的词法环境中存放了一个叫content的变量。在调用closure时,会预扫描内部函数,在fn中使用到了content变量,这就构成了闭包,因此会在堆中申请一片空间,创建一个closure的对象,里面存放的是content的实际的值,如下图所示:

2.png

4、DOM引用

DOM元素的生命周期取决于是否挂载在DOM树上,当从DOM树上移除时,对应DOM也会被销毁。如果DOM挂载在DOM树上,并在js中也存在引用,那么到销毁DOM时,并不会被GC,只有当DOM和js引用一起清除时,才会被GC。如下:

// html
<div id="root">
  <ul id="ul">
    <li>li</li>
    <li>li</li>
    <li id="li">li</li>
    <li>li</li>
    <li>li</li>
  </ul>
</div>

<script>
  var aRoot = document.getElementById("root");
  var aUl = document.getElementById("ul");
  var aLi3 = document.getElementById("li");

  // 由于aUl变量存在,所以整个ul不能被GC
  aRoot.removeChild(aUl);
  console.log(aUl);

  // 虽然ul被清空了,但是aLi3引用了ul的子节点,所以ul不会被GC
  aUl = null;
  console.log(aLi3);

  // 此时已无变量引用,可以GC
  aLi3 = null;
</script>

上面代码中声明了三个变量存储root、ul和li的dom,当使用removeChild清除ul dom时,因为ul被js引用了,所以不能被GC。此时,将aUl设置为null,虽然ul的dom和js引用已经被清除,但是因为ul的第三个子元素li,依然被引用,所以ul清除并不会被GC,只有当aLi = null时,此时才会执行GC。

5、未清除的监听器

在开发过程中,会绑定一些事件(click、change、resize等),当页面销毁时,没有清除这些事件,这些事件会一直存在内存中无法被释放,只有当关闭页面时才会被清除,对于一些复杂的单页面应用,若存在大量的监听事件未被清除,则会出现页面卡顿等情况。

下面使用vue组件为例:

// html
<div id="app">
  <p>{{ message }}</p>
  <p>{{ points.x }} - {{ points.y }}</p>
</div>

// js
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script>
  new Vue({
    el: "#app",
    data: {
      message: "hello world",
      points: {
        x: 0,
        y: 0,
      },
    },
    mounted() {
      window.addEventListener("resize", this.resizeFunc);
    },
    beforeDestroy() {
      window.removeEventListener("resize", this.resizeFunc);
    },
    methods: {
      resizeFunc(e) {
        const { innerWidth, innerHeight } = e.currentTarget;
        this.points.x = innerWidth;
        this.points.y = innerHeight;
      },
    },
  });
</script>

如果不在销毁阶段将事件resize销毁,那么该事件会一直存在内存中,直到页面关闭。

6、Map和Set

js中对象引用分为强引用和弱引用。强引用会导致GC不自动回收,会造成内存泄漏。弱引用不会阻止引用对象被GC回收。

弱引用:

// 弱引用
var obj = { name: "chicaboo" };
obj = null;
console.log(obj); // null

除了简单的对象时弱引用,在ES6中,WeakMap和WeakSet也是弱引用,这里就不一一展开了。

强引用:



// 强引用
var obj = { name: "chicaboo" };
var user = { obj };

const map = new Map([[obj, "hello"]]);
const set = new Set([obj]);

// 清空obj,因为obj被user、Map和Set强引用,故仍然能拿到obj的值,不会被GC
obj = null;
console.log("user", user.obj); // { name: "chicaboo" }
console.log("map", map); // key: { name: "chicaboo" }, value: 'hello'
console.log("set", set); // value: { name: "chicaboo" }

上面例子中已经说明,当obj被map和set引用,因为Map和Set是强引用,所以不主动清楚的情况,obj会一直被引用不会被GC。

7、未删除的console

在开发过程中,会经常使用console打印,帮助调试,但如果在特殊场景下使用了console也会造成内存泄漏,接下来展示其中一个场景如下:

// html
<button id="click">click</button>

// js
<script>
  class TestGC {
    constructor() {
      this.init();
    }

    init() {
      this.name = new Array(100000).fill("isGC???");
      console.log(this);
    }
  }

  document.getElementById("click").onclick = function () {
    new TestGC();
  };
</script>

每次点击button按钮,会打印TestGC中的this实例,但因为this指向的是TestGC,name变量是TestGC原型链上的一个属性,存放了长度为10w的一个数组,每次点击按钮,都是创建一个新的实例,使用console打印this.name会一直存在内存中不会被回收,下面试使用monitor监视的图例:

3.png

8、其他内存泄漏情况

  • iframe:使用iframe时,若父界面或者子界面中添加了父界面对象的引用,那么在iframe卸载时,就会出现部分对象无法被GC;
  • web worker:使用web worker时,如果频繁且不加容错的使用web worker在主进程和工作现成之间传递数据,可能会导致内存泄漏。

如何监控内存泄漏?

上面讲了什么是内存泄漏,以及造成内存泄漏的一些原因,我们知道存在内存泄漏,对于一个复杂的系统,要定位代码中哪里出现内存泄漏有一定难度,下面使用Chrome提供的工具来定位内存泄漏的原因。

1、Performance monitor

打开控制台-使用快捷键shift + command + p,调出搜索框,输入performance monitor,如下图所示:

4.png

上图各项指标表示:

  • CPU usage - 当前站点的 CPU 使用量;
  • JS heap size - 应用的内存占用量;
  • DOM Nodes - 内存中 DOM 节点数目;
  • JS event listeners- 当前页面上注册的 JavaScript 时间监听器数目;
  • Documents - 当前页面中使用的样式或者脚本文件数目;
  • Frames - 当前页面上的 Frames 数目,包括 iframe 与 workers;
  • Layouts / sec - 每秒的 DOM 重布局数目;
  • Style recalcs / sec - 浏览器需要重新计算样式的频次;

2、Performance

除了使用performance,来查看是否有内存泄漏的情况,如下:

5.png

分析定位引起内存泄漏的地方

分析定位内存泄漏的地方使用Chrome自带的Memory,里面有三个选项,分别为内存快照、使用一段时间的内存分配情况、使用一段时间的函数调用情况,如下图所示:

6.png

1、内存快照

使用内存快照抓起2次快照,进行比较,通过比较信息,可定位造成内存泄漏的原因,如下图所示:

7.png

2、抓取一段时间内的内存分配

对于复杂的交互场景,要使用内存快照清楚的定位到内存泄漏的场景比较困难,因此可以使用Memory中Allocation instrumentation on timeline,查看不会被回收的内存,如下图所示:

8.png

3、抓取一段时间内函数的内存使用情况

查看调用栈的情况,可使用memory中的Allocation sampling,如下:

9.png

实例分析

说了这么多,是时候检验下效果了,网上随便找了个例子,如下:

var t = null;
var replaceThing = function() {
  var o = t
  var unused = function() {
    if (o) {
      console.log("hi")
    }        
  }
 
  t = {
        longStr: new Array(100000).fill('*'),
        someMethod: function() {
                       console.log(1)
                    }
      }
}
setInterval(replaceThing, 1000)

首先使用performance monitor查看内存情况,发现js heap一直在增加,如下图所示:

10.png

js heap一直在增加,说明存在内存泄漏的情况。

使用Allocation sampling查看函数调用情况,如下:

11.png

通过函数调用的监控,可以看到引起内存泄漏的原因是replaceThing。

使用memory heap snapshot生成两个快照进行对比,看看具体情况,如下:

12.png

从图中可以看到快照2比快照1增加了4m内存,对比具体看下增加的地方,如下:

13.png

从图中可以看到洗澡能的内存主要是array这一部分,看一下具体array中存放了什么,如下:

14.png

可以定位到longStr和someMethod造成的。

另外,可以使用Allocation instrumengtation on timeline查看内存分配情况,如下:

15.png

从图中可以看到,选中未被释放的时间线,可以在下方看到,未被回收的内存有Object、closure、system、Array,我们一个个来分析,首先是Object如下:

16.png

首先是存放两个值,一个时someMethod和longStr,分析代码可以看到,在全局声明了一个t的全局变量,在定时器中声明一个变量o指向t,并将一个对象赋值给t,同时在内部会有一个函数unused内部使用了变量o,因此o一直不适用不会被GC,o指向了t,t是全局变量,因此t也不会被GC,所以造成内存泄漏的原因有几个:

  • 全局对象t;
  • o引用了t,o在内存函数unused中使用了,所以o不会被回收;
  • t中声明的longStr会一直存在内存中,不会被GC;
  • t中的someMethod构成了闭包,不会被GC;
  • 因为使用了定时器,所以内存会一直增加;

这里面造成无法GC的原因是因为o引用了t,o在unused中使用了,但是unused没有被调用,所以t一直不会被释放,解决办法,让o不劫持t的引用,删除unused即可。如下所示:

17.png

可以看到一段时间分配的内存会被GC回收。