垃圾回收机制

2023/3/21 JavaScript

# 前置知识

垃圾回收(Garbage Collection,缩写为GC)是一种自动的存储器管理机制。当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。垃圾回收最早起源于LISP (opens new window)语言。目前许多语言如Smalltalk、Java、C#和D语言都支持垃圾回收器,我们熟知的JavaScript具有自动垃圾回收机制。

  • 基本数据类型与栈内存:js中基本数据类型,这些值都有固定的大小,往往保存在栈中(闭包除外),由系统自动分配存储空间。我们可以直接操作保存在栈内存空间的值,因此基本数据类型都是按照值来访问的,数据在栈内存中的存储与使用方式类似于数据结构中的堆栈数据结构,遵循先进后出的原则。
  • 引用数据类型与堆内存:js中的引用数据类型大小是不固定的,引用数据类型的值时保存在堆内存中的对象。js不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间。在操作对象时,实际上是在操作对象的引用而不是实际对象。保存在栈内存中的一个地址(对象的引用)与堆内存的实际值相关联。

基本类型在当前执行环境结束时销毁,而引用类型不会随执行环境结束而销毁,只有当所有引用它的变量不存在时这个对象才被垃圾回收机制回收。因此内存回收分为栈和堆内存的回收:

  1. 栈内存回收

    栈垃圾回收的方式非常简便,当一个函数执行结束之后,JavaScript引擎会通过向下移动ESP(栈指针) (opens new window)来销毁该函数保存在栈中的执行上下文,遵循先进后出的原则。

  2. 堆内存回收

    堆垃圾回收,当函数直接结束,栈空间处理完成了,堆空间的数据虽然没有被引用,但是还是存储在堆空间中,需要垃圾回收器将堆空间中的垃圾数据回收。

垃圾回收的必要性:每当创建一个实体时,都要动态分配内存,如果不释放,就会消耗完系统中所有可用的内存,造成系统崩溃。

# 堆内存回收

# 标记清除法

当变量进入执行环境,就标记这个变量为“进入环境”,永远不能释放进入环境的变量所占的内存,因为只要执行流进入相应的环境,就可能用到他们。当变量离开环境的时候,就将其标记为“离开环境”,垃圾收集器在运行时会给存储在内存中的所有变量都加上标记,然后,它会去掉环境中的变量以及被环境中标量引用的标记,在此以后,再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾回收器完成内存清除工作,销毁那些带标记的值,并回收他们所占的内存空间。

缺点:在垃圾回收的过程中会停止整个应用程序,用户体验差;释放的内存空间可能不是连续的,过于碎片化而很难被重新利用。

# 引用计数

根据被引用的次数,当声明一个变量并将一个引用类型赋值给该变量时,这个值得引用次数就是1,相反,如果包含对这个值引用的变量又取得了另外一个值,这个值得引用次数就-1,当这个值得引用次数变为0的时候,就说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。这样,垃圾收集器下次再运行时,就会释放哪些引用次数为0所占的内存。

缺点:

  • 循环引用情况下会造成使用完的变量所占用的内存无法被释放。所以尽量不要在代码中出现对象的循环引用。
  • 时间开销大,因为引用计数算法需要维护引用数,一旦发现引用数发生改变需要立即对引用数进行修改。

# 分代回收

V8主要的垃圾回收算法是基于分代式垃圾回收机制。垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。

TIPS

代际假说

  • 大部分对象在内存中存在的时间很短,很多对象一经分配内存,很快就变得不可访问。
  • 不死的对象,会活的更久。

在V8中,主要将内存分为新生代和老生代两代:

  • 新生代:存活时间较短的对象,临时分配的内存,如临时变量、字符串等。
  • 老生代:存活时间较长或常驻内存的对象,如主控制器、服务器对象等。

针对新生代和老生代,V8存在两个不同的垃圾回收器:副垃圾回收器和主垃圾回收器。

TIPS

V8引擎可申请的内存大小是有限制的:

  • 64位系统下能使用约1.4GB。新生代占32MB,老生代占1400MB。
  • 32位系统下能使用约0.7GB。新生代占16MB,老生代占700MB。
// 调整老生代内存,单位是MB。
node --max-old-space-size=2048 xxx.js

// 调整新生代内存,单位是KB。
node --max-new-space-size=2048 xxx.js
1
2
3
4
5

# 副垃圾回收器

副垃圾回收器主要是采用Scavenge算法进行新生代的垃圾回收,它把新生代划分为两个区域:Fromfrom_space)(使用)和Toto_space)(闲置),开始分配对象时,先在From空间中进行分配,From区域快被写满时会进行垃圾回收,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,并且有序的排列起来,复制后空闲区域就没有内存碎片了,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换(又称翻转),由此便能无限循环进行垃圾回收。

在Scavenge的具体实现中,主要采用了Cheney算法。Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。

Scavenge的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短。

当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代移动到老生代中的过程称为晋升

晋升的条件有两个:

  1. 对象是否经历过Scavenge回收。

  2. To空间的内存占用比超过限制(25%)。

    设置25%这个限制值的原因是当这次Scavenge回收完成后,这个To空间将变成From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

# 主垃圾回收器

由于老生代空间大,数据大,所以不适用Scavenge算法,主要是采用标记-清除算法和标记-整理算法。

# 标记-清除算法(Mark-Sweep)
  1. 标记:标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
  2. 清除:将垃圾数据进行清除。
  3. 产生内存碎片:对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存。
# 标记-整理算法(Mark-Compact)
  1. 标记:标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
  2. 整理:接着让所有存活的对象都向一端移动。
  3. 清除:然后直接清理掉端边界以外的内存。

在V8的回收策略中两者是结合使用的。在Mark-Sweep和Mark-Compact之间,由于Mark-Compact需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact。

垃圾回收工作是需要占用主线程的,必须暂停JS脚本执行等待垃圾回收完成后恢复,这种行为称为全停顿。由于老生代内存大,全停顿对性能的影响非常大,为了降低老生代的垃圾回收而造成的卡顿,V8将标记过程分为一个个的子标记过程,同时让垃圾回收标记和JavaScript应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记算法(Incremental Marking)

# 增量标记算法(Incremental Marking)
  1. 增量标记算法在工作的时候不会暂停程序,它会有序穿插在程序执行时,并逐步完成标记清除的工作。将所有的对象进行三色标记区分,一色:未搜索过的对象;二色:正在搜索的对象;三色:搜索完成的对象。
  2. 对搜索完成的对象执行标记清除算法,释放内存空间。

JavaScript引擎的优化:

  • 并行垃圾回收(parallel):并行是主线程和协助线程同时执行同样的工作,但是这仍然是一种全停顿的垃圾回收方式,但是垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销),主要是新生代中使用。
  • 增量垃圾回收(incremental):增量式垃圾回收是主线程间歇性的去做少量的垃圾回收的方式。我们不会在增量式垃圾回收的时候执行整个垃圾回收的过程,只是整个垃圾回收过程中的一小部分工作。做这样的工作是极其困难的,因为JavaScript也在做增量式垃圾回收的时候同时执行,这意味着堆的状态已经发生了变化,这有可能会导致之前的增量回收工作完全无效。通过JavaScript间歇性的执行,同时也间歇性的去做垃圾回收工作,JavaScript的执行仍然可以在用户输入或者执行动画的时候得到及时的响应。
  • 并发垃圾回收(concurrent):并发是主线程一直执行JavaScript,而辅助线程在后台完全的执行垃圾回收。这种方式是这三种技术中最难的一种,JavaScript堆里面的内容随时都有可能发生变化,从而使之前做的工作完全无效。最重要的是,现在有读/写竞争(read/write races),主线程和辅助线程极有可能在同一时间去更改同一个对象。这种方式的优势也非常明显,主线程不会被挂起,JavaScript可以自由地执行,尽管为了保证同一对象同一时间只有一个辅助线程在修改而带来的一些同步开销。主要是老生代使用。

一旦堆的动态分配接近极限的时候,将启动并发标记任务。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用。在JavaScript执行的时候,并发标记在后台进行。同时会在辅助线程在进行并发标记的时候会一直追踪每一个JavaScript对象的新引用。当并发标记完成或者动态分配到达极限的时候,主线程会执行最终的快速标记步骤;在这个阶段主线程会被暂停,这段时间也就是主垃圾回收器执行的所有时间。在这个阶段主线程会再一次的扫描根集以确保所有的对象都完成了标记;然后辅助线程就会去做更新指针和整理内存的工作。并非所有的内存页都会被整理,加入到空闲列表的内存页就不会被整理。在暂停的时候主线程会启动并发清理的任务,这些任务都是并发执行的,并不会影响并行内存页的整理工作和JavaScript的执行。

写代码时可以的优化:

  • 减少js中垃圾回收:通过清空一个对象来获取“新对象”(delete key),虽然这种做法比简单的通过{}来创建对象要耗时一些,但是在实时性要求很高的代码中,这一点短暂的时间消耗,将会有效的减少垃圾堆积,并且最终避免垃圾回收暂停,这是非常值得的
  • 数组array优化:将[]赋值给一个数组对象,是清空数组的捷径(例如:arr = [];),但是需要注意的是,这种方式又创建了一个新的空对象,并且将原来的数组对象变成了一小片内存垃圾!实际上,将数组长度赋值为0(arr.length = 0)也能达到清空数组的目的,并且同时能实现数组重用,减少内存垃圾的产生。
  • 避免循环引用。

# JS内存泄漏判定

  • 本地打包一个去掉压缩、拥有sourcemap及没有任何console的生产版本(console会保留对象引用,阻碍销毁;去掉压缩和保留sourcemap有利于定位源码)。
  • 启动本地服务,打开控制台选择内存Memory,选择所需的快照场景,例如堆快照,点击左上角红色点点,拍快照。
  • 查看不同时间所查看的快照大小变化情况。
  • 不断在会话间切换,通过timeline看到有内存不被释放,而且生成detached dom证明有内存泄漏存在。

参考资料:

最近更新: 2023年03月22日 16:35:46