javascript与V8垃圾回收

139 阅读21分钟

前言

作为目前最流行的JavaScript引擎——V8引擎,他从出现的那一刻起便广泛受到人们的关注,我们知道,JavaScript可以高效地运行在浏览器和Nodejs这两大宿主环境中,也是因为背后有强大的V8引擎在为其保驾护航,甚至成就了Chrome在浏览器中的霸主地位。不得不说,V8引擎为了追求极致的性能和更好的用户体验,为我们做了太多太多,从原始的Full-codegenCrankshaft编译器升级为Ignition解释器和TurboFan编译器的强强组合,到隐藏类,内联缓存和HotSpot热点代码收集等一系列强有力的优化策略,V8引擎正在努力降低整体的内存占用和提升到更高的运行性能。

本篇以V8引擎的垃圾回收机制为切入点,讨论V8引擎在js代码执行生命周期中如何采用垃圾回收策略来减少内存占比的,可能在开发中极少遇到由于浏览器端内存溢出导致程序崩溃的情况,但是我们可以基于V8的特点,写出对其更加友好的代码,提高代码的质量。

JavaScript中的内存分配

内存的生命周期有三个阶段

  • 申请内存:按照需求分配内存;
  • 使用内存:对已经分配到的内存进行读写;
  • 释放内存:归还释放的内存。

在JavaScript的内存分配是根据变量的数据类型来进行分配的,内存分为两种类型:

  • 栈内存:因为使用了栈的结构,所以叫栈内存,作为一种简单储存,适合存放生命周期短,占用空间小而且固定的数据,由系统直接管理,进行内存分配和自动释放,所以基本数据类型数据分配在栈存中。
  • 堆内存:可以理解成可以储存任何数据类型的,很大的一个存储空间。堆内存按需进行内存空间的申请,动态分配且不连续,值大小不固定,访问速度比栈内存要慢,无用数据需要JS引擎程序主动去回收。引用类型的数据会同时分配在栈内存和堆内存,其地址存在栈内存,其具体内容存在堆内存中。

为什么要垃圾回收

内存如果不释放,程序有崩溃的风险。

在V8引擎逐行执行JavaScript代码的过程中,当遇到函数的情况时,会为其创建一个函数执行上下文(Context)环境并添加到调用堆栈的栈顶,函数的作用域(handleScope)中包含了该函数中声明的所有变量,当该函数执行完毕后,对应的执行上下文从栈顶弹出,函数的作用域会随之销毁,其包含的所有变量也会统一释放并被自动回收(如果这里你不懂,可以看下《深入Javascript系列》)。试想如果在这个作用域被销毁的过程中,其中的变量不被回收,即持久占用内存,那么必然会导致内存暴增,从而引发内存泄漏导致程序的性能直线下降甚至崩溃,因此内存在使用完毕之后理当归还给操作系统以保证内存的重复利用。

内存的使用可以分为“自动挡”和“手动挡”,对于C/C++这样的底层语言来说,内存的使用是需要手动申请,手动释放的。

我们可以这样理解,你去食堂吃饭,打完饭之后,找个地方坐下(申请内存),在座位上面嘎嘎炫了两大碗(使用内存),吃完之后收拾一下桌面,收走餐盘(释放内存)。当然,我们可以吃完不收拾,就会导致这块内存一直被占用,长此以往没有新的内存可供分配,程序自然就会崩溃。

对于JavaScript这样的高级语言来说,因为有垃圾回收器的工作,在使用的过程中不需要过多的考虑内存使用的问题。

可以这样理解,你去外面的餐厅吃饭,会在服务员的指引下找到餐桌,吃完之后服务员也会对桌子进行清理。对于JS来说,垃圾回收器扮演的就是这样的一个角色

V8的内存限制

虽然V8引擎帮助我们实现了自动的垃圾回收管理,解放了我们勤劳的双手,但V8引擎中的内存使用也并不是无限制的。具体来说,默认情况下,V8引擎在64位系统下最多只能使用约1.4GB的内存,在32位系统下最多只能使用约0.7GB的内存,在这样的限制下,必然会导致在node中无法直接操作大内存对象,比如将一个2GB大小的文件全部读入内存进行字符串分析处理,即使物理内存高达32GB也无法充分利用计算机的内存资源

为什么会有这种限制

  1. 这个要回到V8引擎的设计之初,起初只是作为浏览器端JavaScript的执行环境,在浏览器端我们其实很少会遇到使用大量内存的场景,因此也就没有必要将最大内存设置得过高。
  2. JS单线程机制
作为浏览器的脚本语言,JS的主要用途是与用户交互以及操作DOM,那么这也决定了其作为单线程的本质,单线程意味着执行的代码必须按顺序执行,在同一时间只能处理一个任务。试想如果JS是多线程的,一个线程在删除DOM元素的同时,另一个线程对该元素进行修改操作,那么必然会导致复杂的同步问题。既然JS是单线程的,那么也就意味着在V8执行垃圾回收时,程序中的其他各种逻辑都要进入暂停等待阶段,直到垃圾回收结束后才会再次重新执行JS逻辑。因此,由于JS的单线程机制,垃圾回收的过程阻碍了主线程逻辑的执行。

虽然JS是单线程的,但是为了能够充分利用操作系统的多核CPU计算能力,在HTML5中引入了新的Web Worker标准,其作用就是为JS创造多线程环境,允许主线程创建Worker线程,将一些任务分配给后者运行。在主线程运行的同时,Worker在后台运行,两者互不干扰。等到Worker线程完成计算任务,再把结果返回给主线程。这样的好处是, 一些计算密集型或高延迟的任务,被Worker线程负担,主线程(通常负责UI交互)就会很流畅,不会被阻塞或者拖慢。Web Worker不是JS的一部分,而是通过JS访问的浏览器特性,其虽然创造了一个多线程的执行环境,但是子线程完全受主线程控制,不能访问浏览器特定的API,例如操作DOM,因此这个新标准并没有改变JS单线程的本质。
  1. 有垃圾回收机制
垃圾回收本身也是一件非常耗时的操作,假设V8的堆内存为1.5G,那么V8做一次小的垃圾回收需要50ms以上,而做一次非增量式回收甚至需要1s以上,可见其耗时之久,而在这1s的时间内,浏览器一直处于等待的状态,同时会失去对用户的响应,如果有动画正在运行,也会造成动画卡顿掉帧的情况,严重影响应用程序的性能。因此如果内存使用过高,那么必然会导致垃圾回收的过程缓慢,也就会导致主线程的等待时间越长,浏览器也就越长时间得不到响应。

基于以上几点,V8引擎为了减少对应用的性能造成的影响,采用了一种比较粗暴的手段,那就是直接限制堆内存的大小,毕竟在浏览器端一般也不会遇到需要操作几个G内存这样的场景。但是在node端,涉及到的I/O操作可能会比浏览器端更加复杂多样,因此更有可能出现内存溢出的情况。

解决

V8为我们提供了可配置项来让我们手动地调整内存大小,但是需要在node初始化的时候进行配置,我们可以通过如下方式来手动设置。

// 该命令可以用来查看node中可用的V8引擎的选项及其含义
node --v8-options
// 设置新生代内存中单个半空间的内存最小值,单位MB
node --min-semi-space-size=1024 xxx.js

// 设置新生代内存中单个半空间的内存最大值,单位MB
node --max-semi-space-size=1024 xxx.js

// 设置老生代内存最大值,单位MB
node --max-old-space-size=2048 xxx.js
process.memoryUsage()方法来让我们可以查看当前node进程所占用的实际内存大小。

\

  • heapTotal:表示V8当前申请到的堆内存总大小。
  • heapUsed:表示当前内存使用量。
  • external:表示V8内部的C++对象所占用的内存。
  • rss(resident set size):表示驻留集大小,是给这个node进程分配了多少物理内存,这些物理内存中包含堆,栈和代码片段。对象,闭包等存于堆内存,变量存于栈内存,实际的JavaScript源代码存于代码段内存。使用Worker线程时,rss将会是一个对整个进程有效的值,而其他字段则只针对当前线程。

在JS中声明对象时,该对象的内存就分配在堆中,如果当前已申请的堆内存已经不够分配新的对象,则会继续申请堆内存直到堆的大小超过V8的限制为止。

\

V8引擎中的回收策略

V8引擎的垃圾回收策略是基于代际假说,认为:

  • 大部分的对象都是"朝生暮死"的;
  • 不死的对象会活的很久。

于此,V8把堆分为新生代老生代两种,对于新生代通常只是维护1~8M的容量,而老生代所支持的容量就大得多。对于这两块区域,V8分别使用不同的垃圾回收器,分别对应不同的算法,以便更加高效的进行垃圾回收。

  • 副垃圾回收器 - Scavenge:主要是负责新生代的垃圾回收
  • 主垃圾回收器 - Mark-Sweep & Mark-Compact:主要是负责老生代的垃圾回收

新生代

一般情况下,大多数小的对象都会被分配到新生代,这个地方虽然比较小但是更新是比较频繁的,所以需要一种比较快的算法。

在新生代中所使用的算法是Scavenge,这个算法是早在1970年就提出的理论。它会把新生代分成FromTo两块区域,当From区域存满时,它会将From区域中存活的对象复制到To区域中去,并将内存有序的排列起来,完成之后释放From内存,最后将From和To区域进行翻转,即原来的From变成To,现在的To变成From

我们可以从上图可以观察到,复制的操作不仅保存了存活的对象,也没有产生内存碎片,无需整理内存。对于Scavenge算法来说,是一种典型的使用牺牲空间换取时间的算法。因为它在空间上的开销很适合这种少量内存的新生代垃圾回收。

新生代为什么都设置的很小?

因为新生代使用Scavenge算法每次执行清理操作时,都需要将存活的对象从From区域复制到To区域。复制这一个过程本身就需要时间成本,如果把新生代设置的很大,那么就会导致时间太长,为了执行效率一般不会把新生代设置的很大。

\

垃圾回收器是怎么知道哪些对象是活动对象和非活动对象的呢?

有一个概念叫对象的可达性,表示从初始的根对象(window,global)的指针开始,这个根指针对象被称为根集(root set),从这个根集向下搜索其子节点,被搜索到的子节点说明该节点的引用对象可达,并为其留下标记,然后递归这个搜索的过程,直到所有子节点都被遍历结束,那么没有被标记的对象节点,说明该对象没有被任何地方引用,可以证明这是一个需要被释放内存的对象,可以被垃圾回收器回收。

老生代

对于老生代来说,存活兑现的对象占比比较大,使用Scavenge算法会导致效率较低,而且还要浪费一半的内存空间。这两个问题让该算法在处理老生代时显得捉襟见肘,所以我们需要新的算法来处理这里内存。

来源

老生代的来源主要有两个

  • 对象空间占用较大,一些大的对象会直接被放到老生代。
  • 从新生代来的晋升对象

新生代的对象满足一定条件就可以被转移到老生代,称为晋升,晋升的途径包括:

  • 对象是否经历过一次Scavenge算法,对于经历过一个新生代循环的对象,下次不会被复制到To区域,而是被扔到老生代。
  • To区域的内存占比已经超过25%。之所以有这个限制是因为,To区域在经历一个Scavenge算法之后,会与From区域进行角色互换,后续的内存分配都是在From区域进行的,如果内存使用过高,甚至是溢出,则会影响后续的对象分配,所以才会有25%的限制。

Mark-Sweep

老生代是使用Mark-Sweep(标记-清除)算法来进行回收未存活的对象,它的过程主要是分为两个阶段

  • 标记:标记阶段会从一组根元素开始,递归遍历这个组根元素,在遍历过程中,能够到达的元素标记为存活对象,没有到达的对象则就判断为垃圾。
  • 清除:依旧是递归遍历,清除所有未被标记的活动对象。

    上图我们可以观察到,经过一次Mark-Sweep之后会产生一段一段的不连续的内存碎片,如果我们不对这些碎片进行处理,后面有大对象需要内存时,很有可能造成无法分配,所有我们需要新的方法来处理这些内存碎片。

Mark-Compact

为了解决如上问题,Mark-Compact被提出来,它将所有的活动对象往一端移动,移动完成之后,直接清理掉边界外的内存,这样就可以得到连续的内存。

性能优化问题

全停顿 - stop-the-world

由于垃圾回收是在JS引擎中进行的,在进行垃圾回收时为了避免JavaScript应用逻辑和垃圾回收器的内存资源竞争导致的不一致的问题,垃圾回收器会将JavaScript应用暂停,这个过程,被称为全停顿(stop-the-world)

这个结果我们显然是不能接受的,新生代的垃圾回收还好,内存比较小,但是对于老生代的垃圾回收就会造成页面的卡顿,为了优化用户体验,V8也提出了一系列的解决方案。但是主要思想就是如下两个方向:

  • 大任务拆成一个个的小任务,分步进行;
  • 将一些任务放在后台执行,不占用主线程。

增量标记 - Incremental marking

增量标记就是把标记任务分成多个阶段,每个阶段都只标记一部分对象,和主线程穿插进行。

为了支持增量标记,V8必须支持垃圾回收的暂停恢复,这里采用了黑白灰三色标记法

  • 黑色表示这个节点被垃圾回收根引用到了,而且该节点的子节点都已经标记完成了
  • 灰色表示这个节点被 垃圾回收根引用到了,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点
  • 白色表示此节点还没未被垃圾回收器发现,如果在本轮遍历结束时还是白色,那么这块数据就会被收回

引入黑白灰标记法之后,就可以通过标记判断,重新开始或是暂停正在进行的标记。

惰性清理 -  Lazy sweeping

增量标记完成之后,惰性清理才是真正能够清理垃圾的阶段。当我们的内存能够使我们流畅的运行代码,其实我们是没有必要进行清理内存的,它会稍稍延迟一下清理过程,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象都清理完毕。

并行 - parallel

新生代的垃圾回收采取并行策略提升垃圾回收速度,它会开启多个辅助线程来执行新生代的垃圾回收工作

并行执行需要的时间等于所有的辅助线程时间的总和加上管理的时间

并行执行的时候也是全停顿的状态,主线程不能进行任何操作,只能等待辅助线程的完成

这个主要应用于新生代的垃圾回收

并发 - Concurrent

并发就是在 JS 主线程运行的时候,同时开启辅助线程,清理和主线程没有任何逻辑关系的垃圾。

如何避免内存泄漏

在我们写代码的过程中,基本上都不太会关注写出怎样的代码才能有效地避免内存泄漏,或者说浏览器和大部分的前端框架在底层已经帮助我们处理了常见的内存泄漏问题,但是我们还是有必要了解一下常见的几种避免内存泄漏的方式,毕竟在面试过程中也是经常考察的要点。

尽可能少地创建全局变量

在ES5中以var声明的方式在全局作用域中创建一个变量时,或者在函数作用域中不以任何声明的方式创建一个变量时,都会无形地挂载到window全局对象上,如下所示:

var a = 1; // 等价于 window.a = 1;
function foo() {
    a = 1;
}

等价于

function foo() {
    window.a = 1;
}

我们在foo函数中创建了一个变量a但是忘记使用var来声明,此时会意想不到地创建一个全局变量并挂载到window对象上,另外还有一种比较隐蔽的方式来创建全局变量:

function foo() {
    this.a = 1;
}
foo(); // 相当于 window.foo()

当foo函数在调用时,它所指向的运行上下文环境为window全局对象,因此函数中的this指向的其实是window,也就无意创建了一个全局变量。当进行垃圾回收时,在标记阶段因为window对象可以作为根节点,在window上挂载的属性均可以被访问到,并将其标记为活动的从而常驻内存,因此也就不会被垃圾回收,只有在整个进程退出时全局作用域才会被销毁。如果你遇到需要必须使用全局变量的场景,那么请保证一定要在全局变量使用完毕后将其设置为null从而触发回收机制。

手动清除定时器

在我们的应用中经常会有使用setTimeout或者setInterval等定时器的场景,定时器本身是一个非常有用的功能,但是如果我们稍不注意,忘记在适当的时间手动清除定时器,那么很有可能就会导致内存泄漏,示例如下:

const numbers = [];
const foo = function() {
    for(let i = 0;i < 100000;i++) {
        numbers.push(i);
    }
};
window.setInterval(foo, 1000);

由于我们没有手动清除定时器,导致回调任务会不断地执行下去,回调中所引用的numbers变量也不会被垃圾回收,最终导致numbers数组长度无限递增,从而引发内存泄漏。

少用闭包

闭包是JS中的一个高级特性,巧妙地利用闭包可以帮助我们实现很多高级功能。一般来说,我们在查找变量时,在本地作用域中查找不到就会沿着作用域链从内向外单向查找,但是闭包的特性可以让我们在外部作用域访问内部作用域中的变量,示例如下:

function foo() {
    let local = 123;
    return function() {
        return local;
    }
}
const bar = foo();
console.log(bar()); // -> 123

/*
foo函数执行完毕后会返回一个匿名函数,该函数内部引用了foo函数中的局部变量local,并且通过变量bar来引用这个匿名的函数定义,通过这种闭包的方式我们就可以在foo函数的外部作用域中访问到它的局部变量local。一般情况下,当foo函数执行完毕后,它的作用域会被销毁,但是由于存在变量引用其返回的匿名函数,导致作用域无法得到释放,也就导致local变量无法回收,只有当我们取消掉对匿名函数的引用才会进入垃圾回收阶段
*/

清除DOM引用

以往我们在操作DOM元素时,为了避免多次获取DOM元素,我们会将DOM元素存储在一个数据字典中,示例如下:

const elements = {
    button: document.getElementById('button')
};

function removeButton() {
    document.body.removeChild(document.getElementById('button'));
}

在这个示例中,我们想调用removeButton方法来清除button元素,但是由于在elements字典中存在对button元素的引用,所以即使我们通过removeChild方法移除了button元素,它其实还是依旧存储在内存中无法得到释放,只有我们手动清除对button元素的引用才会被垃圾回收。

弱引用

通过前几个示例我们会发现如果我们一旦疏忽,就会容易地引发内存泄漏的问题,为此,在ES6中为我们新增了两个有效的数据结构WeakMap和WeakSet,就是为了解决内存泄漏的问题而诞生的。其表示弱引用,它的键名所引用的对象均是弱引用,弱引用是指垃圾回收的过程中不会将键名对该对象的引用考虑进去,只要所引用的对象没有其他的引用了,垃圾回收机制就会释放该对象所占用的内存。这也就意味着我们不需要关心WeakMap中键名对其他对象的引用,也不需要手动地进行引用清除,我们尝试在node中演示一下过程。

node --expose-gc // --expose-gc 表示允许手动执行垃圾回收机制
// 手动执行一次垃圾回收保证内存数据准确
> global.gc();
undefined

// 查看当前占用的内存,主要关心heapUsed字段,大小约为4.4MB
> process.memoryUsage();
{ rss: 21626880,
  heapTotal: 7585792,
  heapUsed: 4708440,
  external: 8710 }

// 创建一个WeakMap
> let wm = new WeakMap();
undefined

// 创建一个数组并赋值给变量key
> let key = new Array(1000000);
undefined

// 将WeakMap的键名指向该数组
// 此时该数组存在两个引用,一个是key,一个是WeakMap的键名
// 注意WeakMap是弱引用
> wm.set(key, 1);
WeakMap { [items unknown] }

// 手动执行一次垃圾回收
> global.gc();
undefined

// 再次查看内存占用大小,heapUsed已经增加到约12MB
> process.memoryUsage();
{ rss: 30232576,
  heapTotal: 17694720,
  heapUsed: 13068464,
  external: 8688 }

// 手动清除变量key对数组的引用
// 注意这里并没有清除WeakMap中键名对数组的引用
> key = null;
null

// 再次执行垃圾回收
> global.gc()
undefined

// 查看内存占用大小,发现heapUsed已经回到了之前的大小(这里约为4.8M,原来为4.4M,稍微有些浮动)
> process.memoryUsage();
{ rss: 22110208,
  heapTotal: 9158656,
  heapUsed: 5089752,
  external: 8698 }

在上述示例中,我们发现虽然我们没有手动清除WeakMap中的键名对数组的引用,但是内存依旧已经回到原始的大小,说明该数组已经被回收,那么这个也就是弱引用的具体含义了。

总结

其实,对于大部分的前端开发人员,在日常开发中基本不会关注内存管理问题,但是了解一些垃圾回收的内部原理,对于一些优化项目还是有帮助的 。

V8引擎对于不同的区域、不能功能的内存,分别执行不同的策略,其中Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象, 这样合理分配极大提升了清理效率;再加上各种功性能优化的手段让我们在使用的过程中,根本不会体验到卡顿。

技术交流

  1. 欢迎关注公众号“燕小书”,回复:“技术交流”进微信技术交流群,公众号会陆续发布优质文章。
  2. 前端知识库: www.yuque.com/linhao-00ft…