一、浏览器的 V8 垃圾回收机制
V8 垃圾回收机制是什么?
V8 垃圾回收机制是一种内存自动管理技术,它能够检测和清除不再使用的对象,以释放内存并避免内存泄漏。由于 JavaScript 语言的特性,它是一种垃圾回收语言,需要借助于垃圾回收机制来管理内存,以避免内存泄漏等问题。
V8 回收机制的来源
V8 回收机制的来源是基于 JavaScript 的动态性和灵活性。JavaScript 是一种动态语言,它不需要事先声明变量的类型,而是在运行时动态确定。因此,在运行时,需要动态地分配和释放内存。此外,JavaScript 具有自动垃圾回收的特性,它可以在程序运行时自动回收不再使用的内存,从而避免了手动内存管理的麻烦和危险。V8 的垃圾回收机制正是为了满足这些需求而产生的。
V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)分为新生代和老生代两部分。
什么是准确式 GC?
准确式GC是指垃圾回收器可以精确地识别程序中哪些内存区域是不再使用的,并对这些内存区域进行回收。这种垃圾回收器的实现需要解决一些问题,比如如何准确地标记内存中的对象是否正在使用,以及如何快速地找到这些对象。
在准确式GC中,垃圾回收器使用根对象作为起点,遍历程序的对象引用关系图,并标记所有可以从根对象到达的对象。这些被标记的对象被认为是正在使用的,而未被标记的对象则被认为是可以回收的垃圾。垃圾回收器会回收这些垃圾对象,并将空闲内存返回给程序使用。
与准确式GC相对的是非准确式GC。在非准确式GC中,垃圾回收器只能识别一部分垃圾对象,并对整个内存区域进行回收。这种方式有时候可能会将还在使用的对象误认为是垃圾对象,从而造成内存泄漏或程序崩溃等问题。因此,准确式GC在实际应用中更为广泛。
为什么需要垃圾回收?
JavaScript 是一种解释性语言,通常不需要显式地分配内存。相反,它使用动态内存分配的方法来管理内存。这意味着,当一个对象不再被使用时,开发者不需要显式地释放它,而是由垃圾回收机制自动识别并释放它。这种方式使得开发者能够更加专注于业务逻辑,而无需考虑内存管理的问题。
但是,如果垃圾回收机制不及时或者不够准确,就会出现内存泄漏或者内存占用过多等问题。因此,合理地利用垃圾回收机制是非常重要的。
新、老生代内存大小对比
- 新生代内存空间的大小为 32MB,由于新生代内存空间比较小,因此 V8 使用了更为轻量级的垃圾回收算法。
- 老生代内存空间的大小为 1.4GB,因此 V8 使用了更为复杂的垃圾回收算法。
二、新、老生代算法
新生代算法
- Scavenge GC 算法
-
- 在 Scavenge 的具体实现中,主要采用 Cheney 算法。
- Cheney 算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。当我们分配对象时,先是在 From 空间中进行分配,当开始进行垃圾回收时,会检查 From 空间中的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间的角色发生对换,简而言之,在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。
- 缺点:只能使用堆内存中的一半
GC 算法概述
- 新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。
- 在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。
老生代算法
- 标记清除算法(Mark-Sweep)
- 标记压缩算法(Mark-Compact)
- 增量标记算法(Incremental Marking)
- 延迟清理(lazy sweeping)
- 增量式整理(incremental compaction)
老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。
先来说下什么情况下对象会出现在老生代空间中:
- 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
- To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。
老生代中的空间很复杂,有如下几个空间
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不变的对象空间
NEW_SPACE, // 新生代用于 GC 复制算法的空间
OLD_SPACE, // 老生代常驻对象空间
CODE_SPACE, // 老生代代码对象空间
MAP_SPACE, // 老生代 map 对象
LO_SPACE, // 老生代大空间对象
NEW_LO_SPACE, // 新生代大空间对象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情况会先启动标记清除算法:
- 某一个空间没有分块的时候
- 空间中被对象超过一定限制
- 空间不能保证新生代中的对象移动到老生代中
在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行。
清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象向一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
增量标记算法(Incremental Marking)
为了避免出现 JavaScript 应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的 3 种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”( stop-the-world )。在 V8 的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但 V8 的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full 垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善。
为了降低全堆垃圾回收带来的停顿时间,V8 先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking) ,也就是拆分为许多小“步进”,每做完一“步进”就让 JavaScript 应用逻辑执行一会,垃圾回收与应用逻辑交替执行直到标记阶段完成。
V8 在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的 1/6 左右。
V8 后续还引入了延迟清理(lazy sweeping) 与增量式整理(incremental compaction) ,让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行整理,进一步利用多核性能降低每次停顿的时间。
三、内存泄露
什么是内存泄露?
内存泄露指的是应用程序中的内存被错误地分配或使用,导致内存不能被及时地释放或回收,最终导致内存资源的浪费或耗尽的现象。当内存泄露严重时,会导致应用程序运行缓慢或崩溃。
哪些操作会造成内存泄漏?
- 没有及时清理不再使用的对象和变量:在 JavaScript 中,对象和变量都是通过引用来传递和使用的。如果一个对象或变量不再使用,但没有被释放,就会导致内存泄露。
- 意外的全局变量:由于使用未声明的变量,而意外的创建了一个全局变量,意外的全局变量会一直存在于内存中,因为它们不会被垃圾回收器回收。
- 定时器和回调函数未清理:定时器(setInterval)和回调函数的引用需要被清理,否则它们可能会一直留在内存中,导致内存泄露。
- 循环引用:循环引用是指两个或多个对象相互引用,导致它们之间无法被垃圾回收器回收,进而导致内存泄露。(例如:获取一个 DOM 元素的引用,而后面这个元素被删除,由于我们一直保留了对这个元素的引用,所以它也无法被回收。)
- 闭包:不合理的使用闭包,从而导致某些变量一直被留在内存当中。
如何避免造成内存泄露?
- 及时清理不再使用的对象和变量:确保及时清理不再使用的对象和变量,使它们能够被垃圾回收器及时回收。
- 避免使用全局变量:尽可能地避免使用全局变量,特别是未声明的全局变量。
- 清理定时器和回调函数:及时清理定时器和回调函数的引用。
- 避免循环引用:避免循环引用,可以使用弱引用或手动解除引用的方式来处理对象之间的循环引用。
- 使用内存分析工具:可以使用内存分析工具来监测内存泄露,找出泄露的原因,并及时解决问题。
四、内存溢出
什么是内存溢出?
内存溢出是指程序在申请内存时,没有足够的内存可供使用,导致操作系统分配给程序的内存空间不足,出现异常情况,例如程序崩溃、运行变慢等现象。
哪些会造成内存溢出?
内存泄漏:程序中存在一些无用的对象或变量没有被及时释放,导致内存占用逐渐增加,最终导致内存溢出。
递归调用:如果递归调用的层数太多,会导致栈空间不足,从而引发栈溢出。
数据库连接未关闭:如果程序中打开了大量数据库连接但没有及时关闭,会导致内存占用逐渐增加,最终导致内存溢出。
如何避免造成内存溢出?
及时释放无用的对象和变量:当一个对象或变量不再需要时,应该及时将其释放,避免占用过多内存空间。
避免无限递归调用:当需要使用递归算法时,应该设置递归调用的最大层数,避免栈溢出。
合理使用数据库连接:当不需要使用数据库连接时,应该及时关闭连接,避免占用过多内存空间。