对于 C/C++ 等底层语言的程序员来说,使用内存需要手动申请,用完后手动释放,而对于使用 JavaScript 的程序员,由于 V8 等 JS 引擎采用自动回收的垃圾回收机制,因此在使用时通常不需要关心内存的使用情况。
但这并不意味着程序员就不需要关心 JS 底层的内存管理机制,原因有两个:
-
不需要手动操作内存不代表写出的 JS 程序就一定安全,如果代码执行过程中存在变量未被垃圾回收器成功回收或者闭包占用过多等情况,积少成多后有可能造成内存泄漏,潜在提高应用卡顿的风险;
-
V8 引擎存在内存限制,以 Node 为例,64 位系统下约为 1.4 GB,32 位系统下约为 0.7 GB。尽管对于写写网页的前端程序员来说内存基本够用,不太可能会出现内存不足的情况,但是对于写 Node 的服务端程序员来说,比如要读写一个 2G 多的大文件就会出现内存不足的情况。
-
至于为什么要限制内存的大小,一方面是 V8 最初仅为浏览器设计,不太可能遇到用大量内存的场景;一方面如果内存占用过多,会延长垃圾回收执行所花费的时间,由于 JS 脚本的执行、UI 渲染和垃圾回收的执行都在主线程上,会导致网页卡顿或用户响应不及时等情况。
-
当然,这个限制也不是不能打开,V8 提供了选项让我们使用更多的内存。在启动 Node 时可以传递
--max-old-space-size或--max-new-space-size来调整内存限制的大小:node --max-old-space-size=1700 test.js // 设置老生区内存大小 node --max-new-space-size=1024 test.js // 设置新生区内存大小 // 关于新生区和老生区的概念见下文
-
因此,有必要了解 V8 的垃圾回收机制,这对于熟悉 JS 的底层原理,提升代码质量都有很大的帮助。
V8 的垃圾回收机制分为栈空间和堆空间下的垃圾回收,堆空间的新生区和老生区的回收机制也有所不同,本文针对这几种情况分别进行讲解。
栈空间垃圾回收
相信大家都知道,JavaScript 中 number、boolean 等数据类型的值都存储在栈空间中。栈又称调用栈,用于存放 JS 代码执行时产生的执行上下文,如全局上下文、函数上下文等,执行上下文中包含了代码执行过程中的各种变量:
那栈空间的垃圾回收是如何完成的呢?
在栈空间中,有一个记录当前执行状态的指针(称为 ESP),指向当前正在执行的执行上下文:
如果当前内容执行完毕,需要销毁它的执行上下文以释放空间时,这时候只需要将 ESP 下移到调用栈中所要销毁的执行上下文的下一级执行上下文即可,这个下移操作就是销毁当前执行上下文的过程:
虽然要销毁的执行上下文仍保存在栈内存中,但是它已经是无效内存了。比如当调用另一个函数时,这块内容会被直接覆盖掉,用来存放新的执行上下文。
以上便是栈空间垃圾回收的过程,可以看出非常简单粗暴,相比之下,堆空间的垃圾回收就比较复杂了。
堆空间垃圾回收
JavaScript 中所有对象(包括数组。函数)都存储在堆空间中,栈空间中的变量仅保存指向该对象的一个指针,如下所示:
在这种情况下,移动 ESP 仅仅只能清除栈空间保存的指针,堆空间中的对象并没有被清除。要回收堆中的垃圾对象,需要用到 V8 中的垃圾回收器。
V8 的垃圾回收机制基于著名的代际假说(The Generational Hypothesis),代际假说是垃圾回收领域中一个重要的术语,后续垃圾回收的策略都是建立在该假说的基础之上。 代际假说认为:
-
大部分新对象的生存时间比较短,在一次垃圾回收周期内被回收;
-
不死的对象,会活得更久。
基于代际假说,V8 使用了一种分代式的垃圾回收机制:将堆分为新生代和老生代两个区域,新生代中存放生存时间短的对象,老生代中存放生存时间久的对象。经过两次垃圾回收仍然存活的对象将被移动到老生代中。另外,当新生区的空间不足时,新对象会被直接分配到老生区。
为什么要分为新生代和老生代呢?因为在实际的应用中,对象的生存周期一般长短不一,不同的垃圾回收算法只有在特定情况下才有最好的效果。按对象的存活时间将内存进行不同的分代,然后分别对不同分代的内存施以更高效的算法,才能在整体上提高垃圾回收的效率。
如下为 V8 堆内存的分代示意图:
新生区通常只支持 1~8 M 的容量,而老生区支持的容量会大很多。
对于新生区和老生区这两块区域,V8 引擎的垃圾回收机制会有所不同。
新生区的垃圾回收机制
新生区的垃圾回收机制采用 Scavenge 算法进行垃圾回收。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域(也称 From 空间),一半是空闲区域(也称 To 空间):
新加入的对象都会存放到对象区域,当对象区域快占满时,就会执行一次垃圾清理操作:
-
首先,将对象区域中的可达对象标记为活动对象;
-
标记完成后,将这些活动对象复制到空闲区域中,并给活动对象做标记,如果下一次垃圾回收时仍为活动对象,就会被移动到老生代中,这个过程也叫对象晋升。同时,这些对象会被有序地排列起来,所以整个复制过程,相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了;
-
将活动对象复制到空闲区域之后,需要更新栈中对该对象的引用地址,这样才能保证正确的指向;
-
最后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作。这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
由于每次执行清理操作时,都需要将活动对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。
也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,V8 采用对象晋升策略,即经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
对象晋升的另一个判断条件是空闲区域的内存占用比。当从对象区域复制一个对象到空闲区域时,如果空闲区域已经使用了超过 25%,则这个对象直接晋升到老生代空间中。
设置 25% 这个限制值的原因是当这次回收完成后,这个对象区域将变成空闲区域,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。
Scavenge 算法的缺点是只能使用堆内存中的一半,但由于只复制存活的对象,并且通常情况下生命周期短的存象只占少部分,所以它在时间效率上有优异的表现,是典型的牺牲空间换时间的算法。
老生区的垃圾回收机制
由于老生区的对象比较大,如果要在老生区中使用 Scavenge 算法进行垃圾回收,复制这些大的对象会花费较多时间,导致回收执行效率不高,还会浪费一半的空间。
因此 V8 对于老生区的垃圾回收采用 Mark-Sweep 和 Mark-Compact 相结合的方式。
Mark-Sweep 也叫标记清除算法,分为标记和清除两个阶段:
-
标记阶段遍历老生区中所有的对象,标记活着的对象;
-
随后的清除阶段,清除没有被标记的对象。
可以看出,Scavenge 只复制活着的对象,而 Mark-Sweep 只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收算法执行效率较高的原因。
如下是 Mark-Sweep 的垃圾回收过程:
可以看出,Mark-Sweep 最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态,即会产生大量内存碎片,这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况。
为了解决内存碎片问题,Mark-Compact(标记整理) 被提出来,它会整理的过程中,将活着的对象往一端移动,移动完成后,直接清除存活对象区域以外的内存区域完成回收:
总结一下,Mark-Sweep 只清除未存活对象,因此执行效率较高,但是会产生大量内存碎片;Mark-Compact 会对存活对象进行整理,将它们往内存的一端移动,因此不会产生内存碎片,但是由于需要移动对象,所以执行速度不可能很快。
因此,对于老生区的垃圾回收,V8采用 Mark-Sweep 和 Mark-Compact 相结合的方式,分为标记、清除和整理阶段:
-
标记阶段通过变量是否可达来判断其是否为活动对象。通常从一个根对象进行递归遍历,所有遍历到的对象都是可达的,为活动对象。没有遍历到的对象为非活动对象。
-
清除阶段,将非活动对象清除;
-
整理阶段(Defragmenting)阶段是可选的。如前所述,经过垃圾回收之后,活动对象将内存块分割的很零碎,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。这个时候会进行整理,将活动对象复制到相同连续的内存区域内:
针对垃圾回收的优化
我们知道,浏览器的 JS 是执行在主线程上的,除此之外,垃圾回收、UI 渲染等任务也都在主线程上执行。
在最初,GC 运行在主线程上,与 JS 交替执行。在 GC 执行阶段,主线程会停止 JS 代码执行,待垃圾回收完毕后再恢复脚本执行,这被称为全停顿(Stop-the-World)。
在全停顿期间,除了垃圾回收任务以外,其他任务,包括脚本执行、UI 渲染及定时器回调等任务都会被暂停或得不到执行,此时如果有更优先级的任务需要执行,是无法得到及时响应的,比如用户输入、动画执行等,造起页面卡顿的现象。
对此,Google 经过多年研究产出了三种优化方式来改进垃圾回收执行过程:
-
并行(Parallel)。在主线程执行垃圾回收任务的同时,开几个辅助线程同时进行,这样可以大大减少主线程全停顿(Stop the World)的时间;
-
增量(increment)。将主线程上的垃圾回收任务分成多个小任务,与 JS 交替执行。这种方式并没有缩短 GC 工作的时间,但是给了主线程响应高优先级任务的时间,可以有效减少卡顿;
-
并发(concurrent)。并发是让主线程专注执行 JS, 开启辅助线程进行垃圾回收。这种方式没有了全停顿,可以完全解放主线程。
总结
JS 不需要手动垃圾回收不意味着程序员可以毫不在意程序运行过程中的内存使用情况,一方面程序有可能造成内存泄露,一方面 V8 引擎存在内存限制。不过 V8 允许程序员设置参数来扩大内存的使用限制。
V8 引擎的垃圾引擎回收机制分为栈空间以及堆空间的垃圾回收,而堆空间又分为新生代和老生代的垃圾回收。
栈空间使用一个叫 ESP 的指针,通过将该指针移动到下一个执行上下文即可完成栈空间的垃圾回收。
堆空间中新生代的垃圾回收基于 Scavenge 算法,通过将对象区域的活动对象移动至空闲区域,并整理内存碎片实现垃圾回收。
堆空间中老生代的垃圾回收结合了 Mark-Sweep 和 Mark-Compact 算法,分为标记、清除和可选的整理阶段。
垃圾回收执行过程中 JS 脚本的执行会暂停,这称为全停顿。为了减小全停顿带来的影响,Google 采用了并行、增量和并发三种优化方式。
关于 V8 引擎的垃圾回收机制的介绍到此结束,如果对你有所帮助,欢迎点赞评论加关注~~
参考资料
- 浅谈V8垃圾回收机制;
- 13 | 垃圾回收:垃圾数据是如何自动回收的?
- 《深入浅出 Node.js》第五章