JavaScript V8引擎GC机制
分析常见的GC机制以及V8引擎的GC机制

JavaScript V8引擎GC机制
在计算机科学中,垃圾回收(英语:Garbage Collection,缩写为GC)是指一种自动的存储器管理机制。当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。垃圾回收最早起源于LISP语言。
而许多高级语言,如JavaScript、Golang、C#、Python、Java等,都有垃圾回收机制,这些语言的垃圾回收机制减轻了程序员管理内存的负担,也在很大程度上确保了内存的正确分配,本文主要分析常见的GC机制以及V8引擎的GC机制。
GC机制
常见的几种GC算法有:标记-清除算法、复制算法、标记-压缩算法、分代收集算法等,而这些算法的实现都离不开对于对象是否存活的判断。
对象存活判断
要实现对对象存活性的判断一般有两种方式:引用计数和可达性分析。
引用计数
引用计数是一种最简单的垃圾回收算法,它的基本思路是:设置一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;当计数器为0时,就回收内存。
缺点:
- 无法解决对象循环引用的问题(A引用B,B引用A时,由于两者的引用计数都不为0,所以无法回收内存)。
- 无法解决循环引用无法回收的问题。
可达性分析
可达性分析算法的基本思路是:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
常见GC算法
该部分引用自《图解常见 GC 算法和垃圾收集器》
(1) 标记 -清除算法
"标记-清除"(Mark-Sweep)算法,如它的名字一样,算法分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的
它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
(2) 复制算法
"复制"(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低
(3) 标记-压缩算法
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法
根据老年代的特点,有人提出了另外一种"标记-压缩"(Mark-Compact)算法,标记过程仍然与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
(4) 分代收集算法
GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。 "分代收集"(Generational Collection)算法,把堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用"标记-清理"或"标记-压缩"算法来进行回收
JavaScript中的内存管理与GC
JavaScript作为一门高级语言,其内部的内存管理统一由引擎管理,这里我们以Chronium内核以及Node.js所采用的V8引擎为例,来分析JavaScript中的内存管理与GC机制。
内存分配
为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。
let n = 123 // 给数值变量分配内存
let s = 'azerty' // 给字符串分配内存
let o = {
a: 1,
b: null,
} // 给对象及其包含的值分配内存
// 给数组及其包含的值分配内存(就像对象一样)
let a = [1, null, 'abra']
function f(a) {
return a + 2
} // 给函数(可调用的对象)分配内存
// 函数表达式也能分配一个对象
someElement.addEventListener(
'click',
function () {
someElement.style.backgroundColor = 'blue'
},
false
)
V8引擎的GC
事实上,V8引擎顶层通过分代收集算法划分出新生代和老生代两个区域,新生代中的对象存活时间短,对新老生代分别采用不同的GC算法来确保内存分配和清理的高效可靠。
V8内存结构
- 新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。
- 老生代(old_space):新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区和老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。
- 大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。
- 代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域。
- map区(map_space):存放Cell和Map,每个区域都是存放相同大小的元素,结构简单
新生代
新生代中所采用的GC算法(Minor GC)是:Scavenge算法,它是一种采用复制算法的垃圾回收算法,将内存一分为二,每一部分空间称为semispace,一般来说只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace称为From空间,处于闲置状态的空间称为To空间。
而新生代垃圾回收过程主要包括两个阶段:
提示 >新生代中的垃圾回收器又被成为副垃圾回收器
- 标记-复制阶段: 在这个阶段,垃圾收集器会标记新生代中的存活对象。然后,它将存活的对象复制到To空间。这个操作使得新生代中的From空间中的非存活对象可以被简单而高效地清除。
- 清除阶段: 在清除阶段,垃圾收集器会清除"From空间"中的无效对象,使得整个新生代重新可用。同时,为了下一轮的垃圾回收,"From空间"和"To空间"将会交换角色,即"From空间"变成"To空间",而"To空间"变成"From空间"。
Minor GC通常发生频繁,因为大多数对象在创建后很快就变得不可达,因此新生代的垃圾回收可以快速地识别和清理这些短寿命的对象,从而提高整体的垃圾回收效率。如果一个对象经过几轮Minor GC后仍然存活,它将被晋升到老生代,以便在老生代垃圾回收中进行处理。
老生代
与新生代对应,老生代中的垃圾回收器被称为主垃圾回收器,而由于老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。因此在老生代中使用Scavenge算法显然不是一种高效的做法。因此,在老生代中,V8引擎选择了Mark-Sweep(标记-清除)和Mark-Compact(标记-整理)算法。
- 标记-清除阶段:与新生代一样,老生代首先会遍历内存,并标记所有可达对象,之后的清除过程则与新生代完全不同:由于老生代只有一块内存空间,因此不需要进行复制操作,直接删除所有不可达对象即可。
- 标记-整理阶段:通过标记-清除后会产生一个问题——由于被清除的对象在内存中不是连续的,可能会产生较多的内存碎片而影响后续内存分配,因此需要额外进行标记-清除操作。在这个阶段,垃圾收集器会先标记所有的存活对象,然后将所有存活对象向一端移动,之后清理边界外的所有内存。
通过以上两个阶段,老生代中的垃圾回收器可以高效地清理内存碎片,从而提高内存分配的效率。
总结
本文主要分析了JavaScript中的内存管理与GC机制,其中包括了常见的GC算法以及V8引擎的GC机制,希望对你有所帮助。