V8的内存管理模式

一个运行的程序通常是通过在内存中分配一部分空间来表示的。这部分空间被称为驻留集(Resident Set)。 V8的内存管理模式有点类似于Java虚拟机(JVM),它会将内存进行分段:

  • 代码 Code:实际被执行的代码
  • 栈 Stack:包括所有的携带指针引用堆上对象的值类型(原始类型,例如整型和布尔),以及定义程序控制流的指针。
  • 堆 Heap:用于保存引用类型(包括对象、字符串和闭包)的内存段

V8的垃圾回收机制

分代式垃圾回收

V8的垃圾回收策略主要基于「分代式垃圾回收机制」,基于这个机制,V8把内存分为「新生代(New Space)」和 「老生代 (Old Space)」。

新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。

设置老生代内存空间的最大值选项 --max-old-space-size,设置新生代内存空间的大小选项 --max-new-space-size

新生代中的垃圾回收

在新生代中,主要通过 Scavenge 算法进行垃圾回收。

Scavenge

在Scavenge算法中,它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另外一个处于闲置状态。处于使用状态的semispace称为From空间,处于闲置状态的semispace称为To空间。当我们分配对象时,先是从From空间中分配。当开始进行垃圾回收时,会检查From空间中存活的对象,这些存活的对象会被复制到To空间中,而非存活的对象占用的空间会被释放。完成复制后,From空间和To空间角色互换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace空间之间进行复制。

在新生代中的对象怎样才能到老生代中?

在新生代存活周期长的对象会被移动到老生代中,主要符合两个条件中的一个:

1. 对象是否经历过Scavenge回收。

对象从From空间中复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收,如果已经经历过了,则将该对象从From空间中复制到老生代空间中。

2. To空间的内存占比超过25%限制。

当对象从From空间复制到To空间时,如果To空间已经使用超过25%,则这个对象直接复制到老生代中。这么做的原因在于这次Scavenge回收完成后,这个To空间会变成From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

老生代中的垃圾回收

对于老生代的对象,由于存活对象占比较大比重,使用Scavenge算法显然不科学。一来复制的对象太多会导致效率问题,二来需要浪费多一倍的空间。所以,V8在老生代中主要采用「Mark-Sweep」算法与「Mark-Compact」算法相结合的方式进行垃圾回收。

Mark-Sweep

Mark-Sweep是标记清除的意思,分为标记和清除两个阶段。在标记阶段遍历堆中的所有对象,并标记存活的对象,在随后的清除阶段中,只清除标记之外的对象。

但是Mark-Sweep有一个很严重的问题,就是进行一次标记清除回收之后,内存会变得碎片化。如果需要分配一个大对象,这时候就无法完成分配了。这时候就该Mark-Compact出场了。

Mark-Compact

Mark-Compact是标记整理的意思,是在Mark-Sweep基础上演变而来。Mark-Compact在标记存活对象之后,在整理过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

Incremental Marking

鉴于Node单线程的特性,V8每次垃圾回收的时候,都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复应用逻辑,被称为「全停顿」。在分代垃圾回收中,一次小垃圾回收只收集新生代,且存活对象也相对较少,即使全停顿也没有多大的影响。但是在老生代中,存活对象较多,垃圾回收的标记、清理、整理都需要长时间的停顿,这样会严重影响到系统的性能。所以「增量标记 (Incrememtal Marking)」被提出来。它从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记,拆分为许多小「步进」,每做完一「步进」就让JavaScript应用逻辑执行一小会,垃圾回收与应用逻辑这样交替执行直到标记阶段完成。

导致内存泄漏的原因

  • 全局变量的使用
  • 定时器或者回调函数
  • 代码中存在大量未使用的对象的引用
  • 闭包

全局变量的使用

正常来说,javascript允许使用未声明的变量,例如在函数中未用var关键字声明的变量,默认会被定义为全局变量,例如

function foo(arg) {
    bar = "全局变量";
}

// 相当于
function foo(arg) {
    window.bar = "全局变量";
}

如果用this.bar创建,但是在全局下访问这个foo函数也会导致bar成为一个全局变量

function foo() {
    this.variable = "全局转变";
}

// 在全局的环境下调用foo, this指向window
foo();

防止这种书写错误的方法就是在文件声明 use strict 关键字

定时器或者回调函数

例如我们经常使用 setInterval 实现类似下面的方法

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

上面的例子中,setInterval 函数保持这对node和someResource的引用,由于处理函数没法没回收,所以他们的引用也没法被回收,如果someResource存储着大量数据,必然会占用内存,无法回收内存,唯一处理就是把移除这些回调

var itv = setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        node.innerHTML = JSON.stringify(someResource));
		clearInterval(itv);
    }
}, 1000);

值得庆幸的是,现在很多浏览器的垃圾回收算法都可以帮我们避免这些问题!

dom引用

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // Much more logic
}

function removeButton() {
    // The button is a direct child of body.
    document.body.removeChild(document.getElementById('button'));

    // At this point, we still have a reference to #button in the global
    // elements dictionary. In other words, the button element is still in
    // memory and cannot be collected by the GC.
}

闭包

javascript的一个特性是闭包,通过闭包可以在某一块内存中保持着对各自的环境变量的引用,导致内存无法被gc回收,下面是一个典型的例子

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

上面例子中,每秒执行 replaceThing 函数,这个函数里面声明 originalThing 指向 theThing 对象,每次执行 theThing 赋予一个巨大的数组,相当于 originalThing 也引用这个有巨大的数组的对象,在 theThings 里面有一个someMethod 方法,当闭包创建是,他们使用同一个父作用域,所以 unused 也共享同一个作用域,而 unused 方法保持着对 originalThing 这巨大对象的引用,导致 gc 无法回收,由于每次执行都开辟新的内存保持着对这巨大数组的引用,导致内存一直上涨,最后导致内存泄漏

参考资料

https://www.open-open.com/lib/view/open1460614607587.html

https://cnodejs.org/topic/4fa94df3b92b05485007fd87