一、代际假说和分代收集
代际假说有两个特点:
- 第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
- 第二个是不死的对象,会活得更久。
V8 会把内存堆分为新生代和老生代两个区域,新生代存放生命周期短的对象,老生代则存放生命周期长的对象。在 64 位系统中,老生代的空间大概 1400M,新生代的空间为 32M。在 32 位系统中,老生代的空间大概为 700M,新生代的空间为 16M。对于这两块区域, V8 使用两个不同的垃圾回收器。
- 副垃圾回收器,负责新生代的垃圾回收。
- 主垃圾回收器,负责老生代的垃圾回收。
针对不同区域,V8 采用了不同的垃圾回收算法,但是都有相同的执行流程。
- 标记空间中活动对象和非活动对象。非活动对象即可以进行垃圾回收的对象;
- 统一清理内存中被标记为可回收的对象;
- 内存整理。当清理完内容后,会存在大量不连续的空间,因此需要重新整理。
二、新生代垃圾回收
通常情况下,大多数小的对象都会被分配到新生代,因此新生代的垃圾回收比较频繁。
新生代中采用了 Scavenge 算法 来处理,即把新生代会对半划分为两个区域,一半是对象空间 from,另一半是空闲空间 to。
一开始所有元素都在 from 空间,当该空间快满时则执行垃圾回收操作。
执行垃圾回收时,副垃圾回收器先标记活动对象,并将这些对象复制到 to 空间,使得 to 空间的内存是连续的。
接着清除 from 空间,回收非活动对象所占用的内存空间。
最后 from 空间和 to 空间的角色会发生交换,下一次的垃圾回收将在新的 from 空间中进行。
新生代存在以下晋升机制:
- 当对象从
from空间复制到to空间的过程中,若to空间的内存占用率超过 25% 时,则该对象直接晋升到老生代。这是为了避免新生代内存被过快地填满,导致频繁触发垃圾回收。 - 经过两次垃圾回收仍存活的对象会晋升到老生代。
三、老生代垃圾回收
老生代中除了从新生代进行的对象外,一些大的对象也会直接分配到老生代,因此老生代中的对象特点是内存空间占用大和存活时间长。
由于 Scavenge 算法在处理存活时间长和大内存对象时存在效率和内存利用率方面的不足,因此 V8 引擎选择使用标记-清除(Mark-Sweep)和标记-整理(Mark-Compact)算法来处理老生代的垃圾回收。
在标记清除算法中,V8 引擎会从根对象开始,遍历所有可达对象并进行标记。在标记阶段结束后,未被标记的对象即被视为垃圾,进而被清理释放内存。然而,标记清除算法可能会导致内存碎片化的问题,即内存空间被分割成许多不连续的小块,影响后续大对象的分配和内存的使用效率。
V8 引擎的标记整理算法可以看作是标记清除算法的增强。其实现原理如下:
- 标记阶段:和标记清除算法一样,遍历所有对象,将可达的活动对象进行标记。
- 整理阶段:在清除之前进行整理操作,移动活动对象的位置,使它们在内存地址上变得连续。
- 回收阶段:对活动对象右侧的空间进行整体回收。
通过这样的方式,标记整理算法解决了标记清除算法导致的内存碎片化问题。回收后得到的内存空间基本上是连续的,在后续使用中可以尽可能地最大化利用释放出来的空间。
四、垃圾回收优化策略
- 全停顿
由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿。
- 增量垃圾回收
增量回收是为了减少垃圾回收过程中的停顿时间,将回收操作分解为多个小步骤,穿插在 JavaScript 程序的执行过程中,从而降低对程序执行的影响。把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行。
- 惰性清理
在增量标记之后,要进行清理非活动对象的时候,垃圾回收器发现了其实就算是不清理,剩余的空间也足以让 JS 代码跑起来,所以就延迟了清理,让 JS 代码先执行,或者只清理部分垃圾,而不清理全部。
- 并行垃圾回收
垃圾回收器在主线程中执行垃圾回收的任务的同时,再引入多个辅助线程来并行处理,这样就会加速垃圾回收的执行速度。
- 并发垃圾回收
主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。
总结
V8 引擎的垃圾回收机制是一个高度复杂且精细的系统,通过巧妙的内存区域划分、多种算法的有机结合以及一系列的优化策略,成功实现了对内存的高效管理和利用。开发人员深入理解这一机制,遵循良好的编程规范和实践,能够充分发挥 V8 引擎的优势,构建出性能卓越、稳定可靠的 JavaScript 应用程序。