[JS] 内存管理与V8垃圾回收机制
内存管理简介
内存管理是控制和协调软件应用程序访问计算机内存的方式的过程。
当一个程序运行在某个操作系统上时,进程需要拥有对RAM的访问权限,以实现:
- 载入程序需要执行的字节码;
- 存储正在执行的程序所使用的数据值和数据结构;
- 载入程序执行所需的任何运行时系统。
一个进程在启动时,会向操作系统申请内存空间。一个进程的内存空间被分为多个区域,其中最主要的两个区分别是栈区和堆区。
栈区
栈区的内存分配符合栈先进后出这一特性,并且栈区的大小通常是固定的。
- 与堆区不同,栈区的变量查询比较简单,且通常只在栈顶进行数据的存储和读取,只需要维护一个栈指针即可,读写操作非常快;
- 存储在栈中的数据必须在编译时确定其大小,并且在运行时不会动态变化;
- 在进程中每调用一个函数,就会推入一个栈帧(stack frame)。每一个栈帧都记录着函数执行所需要的数据。例如,当一个函数声明了一个新变量时,变量会被添加到栈顶的栈帧中,当函数执行完毕返回时,栈帧会被清除,内部所有变量也都会被清除;
- 多线程进程的每一个线程拥有各自的调用栈;
- 栈区的内存管理由操作系统负责;
- 常见的存储于栈区的数据有:局部变量、指针、函数帧;
- 栈区常见的异常是“栈溢出异常”(stack overflow error),这是因为栈区相比于堆区要小很多。
堆区
堆区用于动态地分配内存,程序需要通过指针在堆区中查找数据。
- 堆区与栈区相比可以存储更多数据,但是查询数据比较慢;
- 堆区用于存储动态大小的数据;
- 多线程进程的多个线程共享一个堆区;
- 常见的存储于堆区的数据有:全局变量,对象、字符串等引用类型;
为什么需要关注内存管理
内存的容量有限,如果程序不加节制地使用内存而不释放,最终会导致内存耗尽,可能导致程序或操作系统崩溃。因此,编程语言通常提供自动内存管理的机制,以避免这种情况发生。
在讨论内存管理时,我们通常指的是如何管理堆内存。
-
这是因为栈内存的管理由操作系统自动完成,通常只要避免递归调用导致栈溢出,就不会出错;
-
而堆内存需要程序员手动管理分配与释放,虽然自由度更好,但也带来了更大的风险与复杂度。
内存管理方法
-
手动内存管理
开发者需要自行分配和释放对象的内存。例如,C 和 C++ 提供了
malloc
、realloc
、calloc
和free
函数来管理内存,开发者必须在程序中分配和释放堆内存,并有效地使用指针来管理内存。 -
垃圾回收(GC)
垃圾回收是现代语言中最常见的内存管理方式之一,通常在某些时间间隔运行,因此可能会产生称为“暂停时间”的轻微开销。JVM(Java/Scala/Groovy/Kotlin)、JavaScript、C#、Golang、OCaml 和 Ruby 默认使用垃圾回收进行内存管理。
-
标记 - 清除算法
这通常是一个两阶段的算法,首先标记仍然被引用的对象为“存活”,然后在下一阶段释放未存活对象的内存。
-
引用计数算法
每个对象都会有一个引用计数,当对它的引用发生变化时,该计数会增加或减少,当计数变为零时,就会进行垃圾回收。由于无法处理循环引用,这种方法很少被使用。
-
V8 引擎中的内存管理
V8 引擎被 NodeJS、Deno、Electron 等运行时以及 Chrome、Chromium、Brave、Opera 和 Microsoft Edge 等浏览器使用。
由于 JavaScript 是一种解释性语言,它需要一个引擎来解释和执行代码。V8 引擎负责解释 JavaScript 并将其编译为原生机器码。
V8 是用 C++ 编写的,可以嵌入到任何 C++ 应用程序中。
V8内存结构
JavaScript 是单线程的,V8 引擎为每个 JavaScript 上下文生成一个进程;如果使用了工作线程(service workers),则 V8 引擎会为每个工作线程生成一个新的进程。
上图的内容是 V8 引擎运行时在物理内存中常驻的部分,即常驻集(Resident Set),主要包含了栈区和堆区,其中堆区又细分为多个区域。
堆区
V8 将对象或者动态数据存储于堆区。
垃圾回收(Garbage Collection)作用于这里的新生区(New space)和老生区(Old space)。
堆区被细分为以下几个区域:
- 新生代区(New Space):存放新对象的地方,这些对象大多数寿命较短。这个空间较小,并且包含两个半空间(Semi-space)。这部分空间由“Scavenger(小型垃圾回收,Minor GC)”管理。
- 老生代区(Old Space):存放那些在新生代中经过了两次小型垃圾回收后仍然存活的对象。这部分空间由“主要垃圾回收(Major GC,标记-清除和标记-压缩)”管理。
- 指针区(Old Pointer Space):存放那些含有指向其他对象的指针的存活对象。
- 数据区(Old Data Space):存放仅包含数据而不包含指向其他对象的指针的对象。字符串、封装的数字以及未封装双精度数组在经过两次小型垃圾回收后,会被移动到此处。
- 大对象区(Large Object Space):存放比其他空间大小限制更大的对象。每个对象都拥有自己的
mmap
内存区域。大对象不会被垃圾回收器移动。 - 代码区(Code Space):即时编译器(Just In Time, JIT)存储已编译代码块的地方。此空间是唯一具有可执行内存的空间(虽然代码也可能被分配到大对象空间,并且那些代码也是可执行的)。
- Cell Space:用于存储固定大小的
Cell
对象,这些对象通常保存与 JavaScript 运行时相关的简单数据或元数据,如对象的内部元数据、优化状态、属性访问信息等。 - Property Cell Space:专门存储与 JavaScript 对象属性相关的
PropertyCell
对象,优化属性访问性能。 - Map Space:存储 JavaScript 对象的内部结构
Map
对象,用于描述对象的布局和属性,支持对象的高效访问和继承机制。
每个区都由若干个页组成,页是操作系统分配的一块连续内存。除了大对象区,其它区的页大小都是 1MB。
栈区
每一个 V8 进程都有一个栈内存区,存储:函数调用栈帧、基本数据、指向对象的指针。
V8内存使用示例
示例代码:
class Employee {
constructor(name, salary, sales) {
this.name = name;
this.salary = salary;
this.sales = sales;
}
}
const BONUS_PERCENTAGE = 10;
function getBonusPercentage(salary) {
const percentage = (salary * BONUS_PERCENTAGE) / 100;
return percentage;
}
function findEmployeeBonus(salary, noOfSales) {
const bonusPercentage = getBonusPercentage(salary);
const bonus = bonusPercentage * noOfSales;
return bonus;
}
let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);
堆区与栈区的变化:
从图中可以观察到:
- 全局作用域保存在栈上的“全局帧”中。
- 每次函数调用都会作为帧添加到栈内存中。
- 所有局部变量,包括参数和返回值,都保存在栈上的函数帧中。
- 所有原始类型直接存储在栈上。
- 所有对象类型(如
Employee
和Function
)在堆上创建,并通过栈上的指针进行引用。在 JavaScript 中,函数本质上也是对象,这也适用于全局作用域。 - 从当前函数调用的函数会被推入栈的顶部。
- 当函数返回时,其对应的帧会从栈中移除。
- 一旦主进程完成,堆上的对象将不再有栈中的指针引用,并成为孤立对象。
V8 垃圾回收
V8 通过垃圾回收来自动管理堆内存,它通过释放孤立对象(Orphan Objects)所占用的内存来腾出空间。孤立对象是指哪些不再被栈直接或间接引用的对象。
V8 的垃圾回收是基于分代结构的。这里的分代就是指上文提到的新生区和老生区。
为什么要分代?
在垃圾回收中,有一个重要的概念叫The Generational Hypothesis(不知道中文社区是否有相关的翻译,大致译为“代际假说”)。这个假说指出,大多数对象的生命周期很短,它们在短时间内失活。这一假说适用于大多数动态语言。
V8的分代结构就是基于这种假说设计的。
V8的垃圾回收器是一种压缩/移动式的垃圾回收器,这意味着它会在垃圾回收时复制那些存活下来的对象。
在垃圾回收时复制对象是开销很大的操作。但根据上面的假说,我们知道只有非常小比例的对象实际上能在垃圾回收中存活下来。
回收过程仅移动那些存活的对象,其他所有的分配都自动成为“隐式”垃圾。
这意味着我们只需支付与存活对象数量成比例的复制成本,而不是与所有分配的对象数量成比例。
V8 有两个垃圾回收器:
- Major GC(Mark-Compact):在整个堆区回收垃圾;
- Minor GC(Scavenger):在新生区回收。
Minor GC(Scavenger)
对象会先被分配在新生代空间中,该空间相对较小。
minor GC 的过程:
新生代空间被分为两个大小相等的半空间(semi-space):to-space 和 from-space。大多数分配是在 from-space 中进行的(除了某些类型的对象,如总是分配在老生代空间的可执行代码)。当 from-space 被填满时,就会触发 minor GC。
案例分析1:
from-space 已存在4个对象 A、B、C、D,现在要给E分配空间,from-space 空间不够,触发 minor GC。
触发minor GC,假设A、D仍存活,而B、C已失活,那么A、D会被复制到 to-space。
B、C已失活,会被清除,将 to-space 和 from-space 反转,在 from-space 中为 E 分配空间。
至此,完成依次minor GC。
在Minor GC中存活两次的对象,将不再复制到 to-space,而是转移到老生区。
示例图:
Major GC(Full Mark-Compact)
Major GC主要分为三个步骤:标记、清除、压缩。
-
标记:识别出哪些对象仍然在运行时中存活,并需要保留。
垃圾回收器从根集合(root set)开始,这些根集合包含了已知的对象指针,如执行栈和全局对象。然后,回收器遍历每个指针,查找并标记所有可达的对象。这是一个递归过程,垃圾回收器会继续跟踪这些对象中的每个指针,直到所有可达的对象都被标记为止。
-
清除:清理内存中不再使用的对象,并将空闲空间整理成可供将来使用的内存块。
将失活对象留下的内存空隙添加到一个称为空闲列表(free-list)的数据结构中的过程。当标记完成后,垃圾回收器找到由不可达对象留下的连续空隙,并将它们添加到适当的空闲列表中。空闲列表按内存块的大小进行分离,以便快速查找。将来当我们需要分配内存时,只需查看空闲列表并找到一个合适大小的内存块。
-
压缩:通过压缩内存来减少碎片化,从而优化内存利用效率。
垃圾回收器根据碎片化程度,选择一些内存页进行压缩。它将存活的对象复制到其他未被压缩的页中,使用空闲列表来管理这些页。这一过程中,分散的小空隙被整合,从而有效减少碎片。
性能优化
性能问题
上述的垃圾回收器采用的是一种“全停顿式”的做法。在执行垃圾回收操作时,会暂停应用程序的主线程,直到垃圾回收完成。这种方法的主要问题在于,它会在应用程序的执行过程中造成不可预见的延迟,从而影响用户体验。
- 页面卡顿:由于主线程被暂停,页面无法响应用户的输入,从而导致卡顿现象。
- 渲染不流畅:当页面渲染过程中遇到垃圾回收操作时,页面的渲染速度会明显减慢,导致动画或交互的流畅性下降。
- 延迟增大:执行垃圾回收期间,所有其他任务都会被推迟,增加了整体的响应时间。
优化技术
为了减少全停顿式垃圾回收对性能的影响,V8引擎开发了Orinoco项目,采用了先进的并行、增量和并发技术,以优化垃圾回收性能,最大限度地减少对主线程的影响。
-
并行(Parallel)
并行垃圾回收技术通过让主线程和多个辅助线程同时进行垃圾回收工作,减少每个线程的工作负担。虽然仍然属于全停顿式,但多个线程分担任务,使得总的暂停时间被显著缩短。这种方法实现较为简单,只需确保各线程间的同步即可。
-
增量(Incremental)
增量垃圾回收将垃圾回收任务分割成多个小片段,间歇性地执行。这种方法允许主线程在两次垃圾回收任务之间继续执行JavaScript代码,减少了对主线程的长期停顿影响。
-
并发(Concurrent)
并发垃圾回收允许主线程在执行JavaScript代码的同时,辅助线程在后台完成垃圾回收任务。通过这种方法,主线程完全不需要暂停,从而避免了用户体验的下降。但是实现起来很复杂,需要处理多线程环境下的读/写竞争问题。(主线程 JS 在读堆区的数据,helper 线程的GC在修改堆区的数据)
实际应用
-
新生代垃圾回收
V8在新生代垃圾回收过程中采用并行清除(Scavenging)技术,将工作分配给多个辅助线程。每个线程会接收一部分指针,沿着这些指针查找并将所有存活对象迅速移动到目标空间(To-Space)。在对象迁移过程中,清除任务通过原子读取、写入和比较并交换(compare-and-swap)操作来进行同步,因为其他清除任务可能通过不同路径找到了相同的对象,并试图进行迁移。
成功迁移对象的线程随后会返回并更新指针,同时留下一个转发指针,以便其他线程在访问到该对象时能更新其对应的指针。
为了更快地分配存活对象,清除任务会使用线程本地分配缓冲区(Thread-Local Allocation Buffers, TLABs)。
-
老生代垃圾回收
V8的老年代垃圾回收从并发标记(Concurrent Marking)开始。当堆内存接近动态计算的阈值时,V8会启动并发标记任务。每个辅助线程被分配了一些指针来追踪,并标记它们找到的每一个对象。这些标记操作完全在后台进行,而主线程仍然继续执行JavaScript代码。标记期间,使用写屏障(Write Barriers)来跟踪JavaScript在标记期间创建的新引用。
当并发标记完成或达到动态分配的限制时,主线程会执行一个快速标记终结(Marking Finalization)步骤,这个阶段标志着老年代垃圾回收的暂停时间开始。在这期间,主线程会再次扫描根对象,确保所有存活对象都被正确标记,然后和一些辅助线程一起,启动并行压缩(Parallel Compaction)和指针更新(Pointer Updating)。老年代中的所有页(Pages)并不都适合压缩——不适合压缩的页会通过之前提到的空闲列表(Free Lists)进行清扫。在主线程暂停期间,V8会启动并发清扫(Concurrent Sweeping)任务。这些任务会与并行压缩任务和主线程自身同时进行,即使JavaScript代码在主线程上继续运行,清扫任务也可以同时执行。
-
空闲时间垃圾回收(Idle-time GC)
V8提供了一种机制,允许嵌入者触发垃圾回收,即使JavaScript程序本身无法触发。在空闲时间,GC可以发布“空闲任务”,这些任务会在将来被触发。像Chrome这样的嵌入者可能会有一些关于空闲时间的概念。例如,在Chrome中,每秒60帧动画的情况下,浏览器大约有16.6毫秒来渲染每一帧。如果动画工作提前完成,Chrome可以选择在下一个帧之前利用这些空闲时间运行一些GC创建的空闲任务。
要点总结
V8的垃圾回收器经历了多年的改进,逐步引入了并行、增量和并发技术,这些改进使得大量工作得以转移至后台任务,显著减少了暂停时间、延迟和页面加载时间,优化了动画、滚动和用户交互的流畅性。
尽管对于大多数开发者来说,在编写JavaScript时无需关注垃圾回收的细节,但理解其工作原理有助于优化内存管理和编写更高效的代码。例如,由于V8的堆结构为分代设计,短生命周期的对象对于垃圾回收器来说成本较低,因为只有存活下来的对象才会产生开销。这种编程模式不仅适用于JavaScript,还适用于许多使用垃圾回收机制的编程语言。
引用文章
[2] 🚀 Demystifying memory management in modern programming languages | Technorage (deepu.tech)