V8引擎浅析Chrome-V8引擎中的垃圾回收机制和内存泄露优化策略

2次阅读

共计 5408 个字符,预计需要花费 14 分钟才能阅读完成。

V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)分为新生代和老生代两部分。

一、前言

V8 的垃圾回收机制:JavaScript 使用垃圾回收机制来自动管理内存。垃圾回收是一把双刃剑,其好处是可以大幅简化程序的内存管理代码,降低程序员的负担,减少因 长时间运转而带来的内存泄露问题。

但使用了垃圾回收即意味着程序员将无法掌控内存。ECMAScript 没有暴露任何垃圾回收器的接口。我们无法强迫其进 行垃圾回收,更无法干预内存管理

内存管理问题:在浏览器中,Chrome V8 引擎实例的生命周期不会很长(谁没事一个页面开着几天几个月不关),而且运行在用户的机器上。如果不幸发生内存泄露等问题,仅仅会 影响到一个终端用户。且无论这个 V8 实例占用了多少内存,最终在关闭页面时内存都会被释放,几乎没有太多管理的必要(当然并不代表一些大型 Web 应用不需 要管理内存)。但如果使用 Node 作为服务器,就需要关注内存问题了,一旦内存发生泄漏,久而久之整个服务将会瘫痪(服务器不会频繁的重启)。

二、chrome 内存限制

2.1 存在限制

Chrome 限制了所能使用的内存极限(64 位为 1.4GB,32 位为 1.0GB),这也就意味着将无法直接操作一些大内存对象。

2.2 为何限制

Chrome 之所以限制了内存的大小,表面上的原因是 V8 最初是作为浏览器的 JavaScript 引擎而设计,不太可能遇到大量内存的场景,而深层次的原因 则是由于 V8 的垃圾回收机制的限制。由于 V8 需要保证 JavaScript 应用逻辑与垃圾回收器所看到的不一样,V8 在执行垃圾回收时会阻塞 JavaScript 应用逻辑,直到垃圾回收结束再重新执行 JavaScript 应用逻辑,这种行为被称为“全停顿”(stop-the-world)。若 V8 的堆内存为 1.5GB,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收甚至要 1 秒以上。这样浏览器将在 1s 内失去对用户的响 应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响

三、chrome V8 的堆构成

V8 的堆其实并不只是由老生代和新生代两部分构成,可以将堆分为几个不同的区域:

1、新生代内存区:大多数的对象被分配在这里,这个区域很小但是垃圾回特别频繁;

2、老生代指针区:属于老生代,这里包含了大多数可能存在指向其他对象的指针的对象,大多数从新生代晋升的对象会被移动到这里;

3、老生代数据区:属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针;

4、大对象区:这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象;

5、代码区:代码对象,也就是包含 JIT 之后指令的对象,会被分配在这里。唯一拥有执行权限的内存区;

6、Cell 区、属性 Cell 区、Map 区:存放 Cell、属性 Cell 和 Map,每个区域都是存放相同大小的元素,结构简单。

每个区域都是由一组内存页构成,内存页是 V8 申请内存的最小单位,除了大对象区的内存页较大以外,其他区的内存页都是 1MB 大小,而且按照 1MB 对 齐。内存页除了存储的对象,还有一个包含元数据和标识信息的页头,以及一个用于标记哪些对象是活跃对象的位图区。另外每个内存页还有一个单独分配在另外内 存区的槽缓冲区,里面放着一组对象,这些对象可能指向其他存储在该页的对象。垃圾回收器只会针对新生代内存区、老生代指针区以及老生代数据区进行垃圾回收。

四、chrome V8 的垃圾回收机制

4.1 如何判断回收内容

如何确定哪些内存需要回收,哪些内存不需要回收,这是垃圾回收期需要解决的最基本问题。我们可以这样假定,一个对象为活对象当且仅当它被一个根对象 或另一个活对象指向。根对象永远是活对象,它是被浏览器或 V8 所引用的对象。被局部变量所指向的对象也属于根对象,因为它们所在的作用域对象被视为根对 象。全局对象(Node 中为 global,浏览器中为 window)自然是根对象。浏览器中的 DOM 元素也属于根对象。

4.2 如何识别指针和数据

垃圾回收器需要面临一个问题,它需要判断哪些是数据,哪些是指针。由于很多垃圾回收算法会将对象在内存中移动(紧凑,减少内存碎片),所以经常需要进行指针的改写:

目前主要有三种方法来识别指针:

  1. 保守法:将所有堆上对齐的字都认为是指针,那么有些数据就会被误认为是指针。于是某些实际是数字的假指针,会背误认为指向活跃对象,导致内存泄露(假指针指向的对象可能是死对象,但依旧有指针指向——这个假指针指向它)同时我们不能移动任何内存区域。
  2. 编译器提示法:如果是静态语言,编译器能够告诉我们每个类当中指针的具体位置,而一旦我们知道对象时哪个类实例化得到的,就能知道对象中所有指针。这是 JVM 实现垃圾回收的方式,但这种方式并不适合 JS 这样的动态语言
  3. 标记指针法:这种方法需要在每个字末位预留一位来标记这个字段是指针还是数据。这种方法需要编译器支持,但实现简单,而且性能不错。V8 采用的是这种方式。V8 将所有数据以 32bit 字宽来存储,其中最低一位保持为 0,而指针的最低两位为 01

4.3 V8 回收策略

自动垃圾回收算法的演变过程中出现了很多算法,但是由于不同对象的生存周期不同,没有一种算法适用于所有的情况。所以 V8 采用了一种分代回收的策 略,将内存分为两个生代:新生代和老生代。

新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。分别对新生代和老生代使用 不同的垃圾回收算法来提升垃圾回收的效率。对象起初都会被分配到新生代,当新生代中的对象满足某些条件(后面会有介绍)时,会被移动到老生代(晋升)。

五、新生代算法

新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。在 Scavenge 的具体实现中,主要是采用一种复制的方式的方法 –cheney 算法。

在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。

六、老生代算法

老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。

在讲算法前,先来说下什么情况下对象会出现在老生代空间中:

1、新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。

2、To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。

老生代中的空间很复杂,有如下几个空间:

enum AllocationSpace {// TODO(v8:7464): Actually map this space's memory as read-only.
  RO_SPACE,    // 不变的对象空间
  NEW_SPACE,   // 新生代用于 GC 复制算法的空间
  OLD_SPACE,   // 老生代常驻对象空间
  CODE_SPACE,  // 老生代代码对象空间
  MAP_SPACE,   // 老生代 map 对象
  LO_SPACE,    // 老生代大空间对象
  NEW_LO_SPACE,  // 新生代大空间对象

  FIRST_SPACE = RO_SPACE,
  LAST_SPACE = NEW_LO_SPACE,
  FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
  LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};

在老生代中,以下情况会先启动标记清除算法:

1、某一个空间没有分块的时候

2、空间中被对象超过一定限制

3、空间不能保证新生代中的对象移动到老生代中

Mark Sweep 是将需要被回收的对象进行标记,在垃圾回收运行时直接释放相应的地址空间,如下图所示 (红色的内存区域表示需要被回收的区域):

Mark Compact 的思想有点像新生代垃圾回收时采取的 Cheney 算法:将存活的对象移动到一边,将需要被回收的对象移动到另一边,然后对需要被回收的对象区域进行整体的垃圾回收。

在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行。

清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。

七、内存泄露和优化

7.1 什么是内存泄露?

存泄露是指程序中已分配的堆内存由于某种原因未释放或者无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统奔溃等后果。。

7.2 常见的内存泄露的场景

7.2.1 缓存

js 开发时候喜欢用对象的键值来缓存函数的计算结果,但是缓存中存储的键越多,长期存活的对象就越多,导致垃圾回收在进行扫描和整理时,对这些对象做了很多无用功。

7.2.2 作用域未释放(闭包)

var leakArray = [];
exports.leak = function () {leakArray.push("leak" + Math.random());
}

模块在编译执行后形成的作用域因为模块缓存的原因,不被释放,每次调用 leak 方法,都会导致局部变量 leakArray 不停增加且不被释放。

闭包可以维持函数内部变量驻留内存,使其得不到释放。

7.2.3 没有必要的全局变量

声明过多的全局变量,会导致变量常驻内存,要直到进程结束才能够释放内存。

7.2.4 无效的 DOM 引用

//dom still exist
function click(){
    // 但是 button 变量的引用仍然在内存当中。const button = document.getElementById('button');
    button.click();}

// 移除 button 元素
function removeBtn(){document.body.removeChild(document.getElementById('button'));
}

7.2.5 定时器未清除

// vue 的 mounted 或 react 的 componentDidMount
componentDidMount() {setInterval(function () {// ...do something}, 1000)
}

vue 或 react 的页面生命周期初始化时,定义了定时器,但是在离开页面后,未清除定时器,就会导致内存泄漏。

7.2.6 事件监听为空白

componentDidMount() {window.addEventListener("scroll", function () {// do something...});
}

在页面生命周期初始化时,绑定了事件监听器,但在离开页面后,未清除事件监听器,同样也会导致内存泄漏。

7.3 内存泄露优化

7.3.1 解除引用

确保占用最少的内存可以让页面获得更好的性能。而优化内存占用的最佳方式,就是为执行中的代码只保存必要的数据。一旦数据不再有用,最好通过将其值设置为 null 来释放其引用——这个做法叫做解除引用(dereferencing)

function createPerson(name){var localPerson = new Object();
    localPerson.name = name;
    return localPerson;
}

var globalPerson = createPerson("Nicholas");

// 手动解除 globalPerson 的引用
globalPerson = null;

解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。

7.3.2 提供手动清空变量的方法

var leakArray = [];
exports.clear = function () {leakArray = [];
}

7.3.3 其他方法

1、在业务不需要的用到的内部函数,可以重构到函数外,实现解除闭包。

2、避免创建过多的生命周期较长的对象,或者将对象分解成多个子对象。

3、避免过多使用闭包。

4、注意清除定时器和事件监听器。

5、nodejs 中使用 stream 或 buffer 来操作大文件,不会受 nodejs 内存限制。

6、使用 redis 等外部工具来缓存数据。

八、总结

js 是一门具有自动回收垃圾收集的编程语言,在浏览器中主要是通过标记清除的方法回收垃圾,在 nodejs 中主要是通过分代回收,Scavenge,标记清除,增量标记等算法来回收垃圾。在日常开发中,有一些不引入注意的书写方式可能会导致内存泄露,多注意自己代码规范。

九、参考

1、V8 的垃圾回收机制与内存限制

2、node 内存限制的问题

3、node 内存控制

4、深入浅出 Nodejs

5、javascript 高级程序设计

正文完
 0