li-jia-nan / my-blog

个人技术博客,同步掘金,文章写在 Issues 里
43 stars 1 forks source link

V8引擎垃圾回收原理解析 #20

Open li-jia-nan opened 1 year ago

li-jia-nan commented 1 year ago

在过去很长一段时间内,JavaScript开发者很少遇到需要对内存进行精确控制的场景,也缺乏控制的手段,说到内存泄漏,大家可能首先想到早期浏览器中的卡顿问题,如果内存占用过多,基本等不到代码进行垃圾回收,用户已经开始不耐烦的刷新网页了。

随着node的发展,JavaScript的应用场景早已不再局限在浏览器中,在浏览器中那些短时间执行的场景中,由于运行时间短,而且运行在用户的机器中,随着进程的退出,内存会释放,几乎没有内存管理的必要。但是,随着node在服务端的广泛应用,在其它语言里存在的问题在JavaScript中也逐渐暴露出来了。

我们在学习JavaScript的时候听说过垃圾回收机制,JavaScript就是由垃圾回收机制来进行自动内存管理的,这使得开发者在编写JavaScript的时候,不需要像其它语言那样时刻关注内存分配和释放的问题。只在浏览器中进行开发时,几乎很少有人遇到因为垃圾回收对项目构成性能影响的情况,Node极大的拓宽了JavaScript的应用场景,当应用场景从浏览器延伸到各种场景中时,我们就能发现,内存管理的好坏、垃圾回收状态的优良与否至关重要,而不管是在浏览器环境、还是node环境中,这一切都与V8引擎息息相关。

1. V8的内存限制

在一般的后端语言中,在基本的内存使用上是没有限制的,但是在node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统约为1.4G,32位系统约为0.7G),在这样的限制下,node无法直接将一个大文件读入内存进行处理,即使电脑的物理内存有16G,在单个node进程的情况下,内存也无法得到充足的使用。

造成这个问题的原因在于V8引擎,所以node中使用JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。这套管理机制在浏览器中使用起来绰绰有余,足以胜任前端页面中的所有需求,但是在node环境中,却限制了开发者对大内存文件的分析和处理。

尽管在服务端操作大内存也不是常见的需求场景,但有了限制之后,我们的行为就如同带着镣铐跳舞,如果在实际应用中不小心触碰到这个界限,会造成进程退出,如果是在浏览器环境中,会导致浏览器白屏、或者卡死。只有在知晓其原理后,才能避免问题,更好的进行内存管理。(这段话摘自《Node深入浅出》,个人认为写的很好)

2. V8的对象分配

在V8中,JavaScript对象是通过堆内存来进行分配的。Node提供了内存使用量的查看方式,在node环境中输入以下代码:

console.log(process.memoryUsage());

执行以上代码,将会得到输出的内存的使用信息(单位是字节):

1111111.jpg

在memoryUsage方法返回的参数中:

当我们在代码中申明变量并且赋值时,所使用的对象的内存就分配在堆中,如果已申请的堆的空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8引擎的限制为止。

那么问题来了,V8为何要限制堆的大小?

当然了,这个限制并不是死的,V8为我们提供了方法,可以手动打开限制,从而让我们使用更多的内存:

在命令行中输入以下代码:node --v8-options,然后我们会在命令行窗口中看到V8的选项,这里我们可以看到下面几个选项:

222.jpg

在Node启动时,我们可以传递--max-old-space-size或者--max-new-space-size来调整内存限制大小,比如:

上述参数在环境初始化时生效,一旦生效,就不能动态改变,只能手动调整,如果遇到内存不够的情况,可以用这个方法手动放宽限制,从而避免由内存问题引起的网页白屏或者奔溃,接下来让我们了解一下垃圾回收方面的策略,在限制的前提下,带着镣铐跳出的舞蹈并不一定就难看。(这段话摘自《Node深入浅出》,个人认为写的很好)

3. V8的垃圾回收机制

在展开介绍垃圾回收机制之前,有必要简略介绍下V8用到的各种回收算法

3.1 垃圾回收算法

V8的垃圾回收算法主要基于分代式垃圾回收机制,在早期的垃圾回收中,人们发现没有一种算法能够胜任所有的场景,因为在实际应用中,对象的生存周期长短不一,不同的算法只能针对特定的情况发挥作用。因此,在现代的垃圾回收算法中,根据对象的存活时间将垃圾回收进行了不同分代,主要分为新生代和老生代,然后分别对不同分代的内存使用不同的算法。

3.1.1 V8的内存分代

在V8中,主要将内存分为新生代老生代两种,新生代中的对象存活时间较短,老生代中的对象存活时间较长(或常驻内存中),如下图所示:

新生代内存空间 老生代内存空间

V8堆的整体大小就是新生代内存空间加上老生代内存空间,前面我们提到的两个命令行就可以用于设置这个空间的最大值,需要注意的是,这个最大值需要在启动的时候就指定,因此,V8使用的内存无法根据情况自动扩充,当内存分配过程中超过极限值的时候,就会引起进程出错,页面卡死,白屏。

3.1.2 新生代(Scavenge算法)

在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收,具体实现中采用的是Cheney算法,这是一种采用复制的方式实现的垃圾回收算法,具体过程是:

  1. 先将堆内存一分为二,每个内存空间称为semispace(半空间)
  2. 在这两个semispace中,只有一个处于使用中,另一个处于闲置中
  3. 处于使用状态的空间称为From空间,处于闲置状态的空间称为To空间
  4. 当我们分配对象时,先是在From空间中进行分配
  5. 开始进行垃圾回收时,会检查From空间中存活的对象
  6. 存活的被复制到To空间中,非存活对象占用的空间被释放
  7. 完成复制后,From空间和To空间的角色发生对换

简而言之,在新生代垃圾回收过程中,就是通过将存活对象在两个semispace空间之间进行复制,分代回收堆内存如下图所示:

新生代内存空间 老生代内存空间
semispace(From) semispace(To)

流程图如下:

需要注意的是:

3.1.3 对象晋升(新 => 老)

在单纯的Scavenge算法中,From空间中的对象会被复制到To空间中去,然后对两个空间进行角色对换(又称翻转)。但是在分代式垃圾回收的前提下,From空间中的对象在复制到To空间时会进行检查。在一定条件下,将存活时间上的对象移动到老生代中,也就是完成对象晋升。

需要注意的是,满足对象晋升的条件主要有以下两个:

这个晋升流程可以用以下的流程图来表示:

stateDiagram-v2
[*] --> From
From --> 经历过Scavenge
From --> 未经历Scavenge
未经历Scavenge --> To
经历过Scavenge --> To内存>25%
经历过Scavenge --> To内存<25%
To内存<25% --> To
To内存>25% --> 移动到老生代

对象成功晋升后,将会在老生代内存空间中作为存活时间较长的对象来对待,通过新的回收算法处理。

3.1.4 老生代(标记清除 & 标记整理)

在老生代中,因为有大量的存活对象,如果依旧使用Scavenge算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法Mark-Sweep(标记清除)Mark-Compact(标记整理)来进行管理。

Mark-Sweep(标记清除)分为标记清除两个阶段,具体步骤如下:

  1. 在标记阶段遍历堆中的所有对象
  2. 然后标记活着的对象
  3. 在清除阶段中,将未标记的对象进行清除

Mark-Sweep最大的问题是在进行一次标记回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,为了解决这个内存碎片问题,Mark-Compact(标记整理)被提出来,这个算法是在Mark-Sweep(标记清除)的基础上演变来的,它们的差别在于对象在标记为死亡后,在整理的过程中,将存活对象向一端移动,移动完成后,直接清除掉边界外的内存。

Mark-Compact(标记整理)分为标记清除整理三个阶段,具体步骤如下:

  1. 在标记阶段遍历堆中的所有对象
  2. 然后标记活着的对象
  3. 在清除阶段中,将未标记的对象进行清除
  4. 对内存空间进行整理,将存活对象向一端移动
  5. 移动完成后,直接清除掉边界外的内存

流程图如下:

至此就完成了一次老生代垃圾回收的全部过程。下面的表格是三种垃圾回收算法的简单对比:

回收算法 Mark-Sweep Mark-Compact Scavenge
速度 中等 最慢 最快
空间 少(有碎片) 少(无碎片) 双倍空间(无碎片)
是否移动对象

从上表中可以看到,标记清除不需要移动对象,其它两种算法需要移动对象,因此这两种算法的执行速度不如标记清除,所以在取舍上,V8主要使用Mark-Sweep(标记清除),在空间不足以对晋升的对象进行分配时才使用Mark-Compact(标记整理)

3.1.5 垃圾回收小结

从V8垃圾回收机制的角度可以看出,新生代设计为一个较小的内存空间是合理的,而老生代空间过大,对于垃圾回收并无特别意义。V8对内存的限制对于浏览器页面而言,内存使用是绰绰有余了;而对于后端服务器来说,也能满足大多数场景了,并不会影响正常场景下的使用。但是对于垃圾回收的特点和JavaScript单线程的执行情况,垃圾回收是影响性能的因素之一,想要提高应用的性能,还是需要注意让垃圾回收尽量少的执行。

3.2 避免内存泄漏

3.2.1 少用闭包

我们知道,作用域链上的对象访问只能向上,这样外部无法向内部访问,在JavaScript中,实现外部作用域访问内部作用域中的变量的方法就叫做闭包,这得益于高阶函数的特性:函数可以作为参数或者返回值

function foo() {
    function bar() {
        var local = "局部变量";
        return function() {
            return local;
        };
    };
    var baz = bar();
    console.log(baz());
}; 

一般而言,在bar()函数执行完成之后,局部变量local会随着作用域的销毁而被回收,但是这里的特点是,返回了一个匿名函数,而且这个匿名函数具备了访问local的条件,如果要在外部访问local,只需要通过这个中间函数稍作周转即可。

闭包是JavaScript的高级特性,可以利用它产生很多奇妙的效果,但是它的问题在于,一旦有变量引用了这个中间函数,这个中间函数将不会被释放,同时也会使原始的作用域得不到释放,作用域中产生的内存也不会被释放,除非不再有引用,才会被逐步释放。

3.2.2 少创建全局变量

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

在ES5中以,var声明的方式在全局作用域中创建一个变量时,或者在函数作用域中不以任何声明的方式创建一个变量时,都会无形地挂载到window全局对象上。当进行垃圾回收时,在标记阶段因为window对象可以作为根节点,在window上挂载的属性均可以被访问到,并将其标记为活动的常驻内存,因此也就不会被垃圾回收,只有在整个进程退出时全局作用域才会被销毁。如果你遇到需要必须使用全局变量的场景,那么请保证一定要在全局变量使用完毕后将其设置为null,从而触发回收机制。

3.2.3 手动清除定时器

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

// 在vue中
created() {
    this.id = setInterval(cb, 500);
},
beforeDestroy() {
    clearInterval(this.id);
},
// 在React中
useEffect(() => {
    const id = setInterval(cb, 500);
    return () => clearInterval(id);
}, []);

3.2.4 手动清除事件监听器

removeEventListener()方法用于移除由addEventListener()方法添加的事件句柄,在组件销毁时移除事件处理函数,以减少内存泄漏,提高应用性能:

// 在vue中
created() {
    document.addEventListener('click', e => cb(e));
},
beforeDestroy() {
    document.removeEventListener('click', e => cb(e));
},
// 在React中
useEffect(() => {
    document.addEventListener('click', e => cb(e));
    return () => document.removeEventListener('click', e => cb(e));
}, []);

3.2.5 养成清理log的好习惯

通过前几个示例我们会发现如果我们一旦疏忽,就会容易地引发内存泄漏的问题,除此之外,其实大量的console.log也会引起内存泄漏的问题,在生产环境中,我们应该清除大多数非必要的console,不清除的话会比较耗性能。如果是调用一两次就没什么,万一放到了循环里就很过分了

4. 总结

Node将JavaScript的主要场景从浏览器环境扩展到了服务器端,相应考虑的细节也和浏览器端不一样,总的来说,内存在Node中受到了一定限制,不能随心所欲地使用,但也不是完全不擅长,本文中主要参考了《Nodejs深入浅出》这本书,从不同方面讲解了Node的内存分配和V8引擎的垃圾回收机制,理论性知识比较多,虽然日常业务中可能用不到,但是相信对大家的面试有所帮助(我上次就被问到了),此外,由于V8引擎的源码是用C++实现的,所以这里没有做深入研究(主要是我不会C++),最后,感谢阅读,如果文中有错误的地方,还希望能够在评论区指正。