JavaScript垃圾回收机制
什么是垃圾回收
在 JavaScript 使用过程中,会有大量的创建、回收内存操作。所谓的垃圾回收,就是为了管理 何时释放内存、如何释放内存的一套管理机制。
这套垃圾回收机制,广泛存在于一些高级编程语言中,例如常见的编程语言 Java
、Go
、Rust
、Python
等。当然也有些语言是没有垃圾回收机制的,例如 c
、c++
,这些语言的内存释放,需要自行手动释放,比较考验开发人员的编程思维水平。
垃圾回收策略
既然垃圾回收,主要讲的是如何管理、释放内存,作为一门 JIT(运行时)语言,JavaScript 执行时会从全局对象(浏览器是window,NodeJS是global)开始,将可用的值、引用存储在内存中,这种类型,会被标记为 “可达”。若有些对象不再被使用,那么反之标记为 “不可达”, 此类型的内存占用,将在一定条件下,回收释放。
这就不得不说“可达性”、“不可达性”的标记策略了,目前 JavaScript 主要流行过的垃圾回收标记策略,有如下两种。
1.引用计数 (Reference Counting)
该算法,曾长期流行于上古浏览器,特别是以IE浏览器🤡为代表的杰出浏览器。由于当时受到互联网、带宽、硬件设备发展影响,未全面普及,性能相较当今,是略有欠缺的。
特别是引用计数,导致内存无法回收的问题,饱含诟病👻。例:
function fn() { const obj1 = {}; const obj2 = {}; obj1.ref = obj2; // obj1 引用 obj2 obj2.ref = obj1; // obj2 引用 obj1 return obj1; // 返回 obj1 循环引用 }
除此之外,性能低下、内存管理低效,等种种缺陷,导致该算法已经无法适应当今高速发展的互联网时代。引用计数算法,也逐渐退出了浏览器的历史舞台。
在此,向IE浏览器、引用计数致敬🫡,毕竟曾经小时候的我们,都是由此打开了互联网世界的大门,承载了太多美好的回忆。
2.标记清除 (Mark and Sweep)
标记清除算法,也是当今主要流行的垃圾回收算法,包括主流的Chrome、Microsoft Edge、火狐、Safari浏览器都采用了该算法。
特别是众所周知的谷歌 Chrome 浏览器 V8引擎 ,使用了标记清除、增量回收、并发回收等策略,实现了高效内存回收和性能优化。
该算法的 “标记”、“清除”,对应着两个阶段:
1. 🏹 标记阶段
1.1 初始化根对象(window、global)
1.2 递归遍历,标记 “可达性”
1.3 递归结束,反向推出 “不可达性”
2. 🧹 清除阶段
2.1 标记出 “不可达性”
2.2 释放对应内存
2.3 清理 “不可达性” 引用
这就是 “标记清除” 算法的主要工作内容,但是仅仅靠“标记清除”算法,来减小内存占用、提升效率,是肯定不够的,各大浏览器厂商,还对垃圾回收机制有更多的优化点。接下来,我会主要以 V8引擎🚀 为例,继续介绍垃圾回收的优化机制。
分代回收
在未使用分代回收算法前,标记清除算法会遍历整个堆内存,从而标记对象的“可达性”,这个过程不能中途停顿,极大影响 JavaScript 执行效率。而分代回收,大大减少了不必要的对象扫描,从而提升了垃圾回收效率、内存管理效率。
分代回收算法,有两个核心概念:
1. 👶 新生代
新生代区域,主要存储短期存活对象,例如函数执行后需要立即销毁的对象。
V8引擎🚀执行时,会将这类短期存活对象,都先存储在From(使用中)区,然后通过复制算法,将From(使用中)区中的“可达”对象复制到 To(闲置)区域,并清理掉“不可达”对象。最后,进行角色互换,即 Form 区 变更为 To 区,To 区变更为 From 区。
若多次角色互换过程中,对象仍未被销毁,那么该对象着晋升至老生代👴,进行管理。
2. 👴 老生代
老生代区域,主要管理长期存活、内存占用较大的对象。
显然,该区域清理维护的对象类型,要么大,要么杂。所以,老生代区域清理的频率要比新生代区域,低得多。
触发清理的机制,主要有两种情况:
- 🏂 主动清理
- 空闲时清理,在程序空闲时,主动清理。
- 定时清理,监听内存使用率等情况,主动清理。
- 🏃 被动清理
- 内存不足,内存耗尽前,进行清理。
- 内存分配失败,进行清理。
可以看出,分代回收算法通过新生代、老生代,显著提升了内存管理效率,减少了不必要的对象扫描,并优化了垃圾回收性能。
增量回收
💡 “增量”二字,是计算机领域的热门词汇🔥,例如next.js的“增量”渲染,React的Fiber策略。
上一段落,我们讲了分代回收算法,通过自身策略,减少了对象扫描。但是碰到复杂的场景,需要管理的对象数量激增,又导致卡顿咋办? 那么,增量回收算法就可以派上用场啦。
什么是“增量回收?
简单一句话,就是将回收过程分为多个步骤,在碰到优先级更高的任务时,可以中途中断、暂停,等到空闲时又恢复执行。这样,可以有效减少垃圾回收机制阻塞导致的性能问题。
除此之外,我们前面提到的 标记清除,在增量回收的加持下,进行了优化。也就是 增量标记策略。
什么是 “增量标记”?
所谓的增量标记,就是通过 “三色标记”算法,实现“标记”阶段,可暂停、恢复。三色,故名思义指三种颜色:
- 白色
- 初始化标记(未标记)
- 标记结束后,仍未扫描到,定义为“不可达”对象
- 灰色
- 自身被标记,引用对象未标记
- 执行中状态 🐝
- 黑色
- 自身已标记,引用对象已标记
- 完成状态 ✅
1.从根对象开始标记
2.灰色对象标记为黑色,以此继续执行类推
2.标记任务完成,白色为“不可达性”
从上图得知,标记从根对象开始,将可访问对象“白色”标记为“灰色”,处理完一个对象标注为”黑色“,然后将自己的引用对象标注“灰色”,以此类推,直到全部标记完成,则标注“黑色”,意味着标记结束。此时,仍未被标记到的“白色”,为“不可达性”。
三色算法,为 “标记” 流程,增加了“状态”的概念,这三种颜色,分别对应着三种不同的工作状态,这也就意味着通过“标记”状态,“标记”流程可以根据调度需求,实现中断、暂停、恢复的操作。
当遇到需要变更状态的对象,需要遵循“强三色不变式”、“弱三色不变式”定律。这些策略,是为了防止对象状态变更后被异常回收。
定律 | 状态变更 | 屏障策略 |
强三色不变式 | “黑”变“灰” | 回溯 |
弱三色不变式 | “白”变“灰” | 前进 |
三色法,状态变更,都遵循以上策略
总而言之,增量回收是在分代回收、标记清除基础之上,通过实现暂停、恢复策略,进一步优化了垃圾回收机制,减少了对 JavaScript 执行的影响,改善了卡顿的情况。
并发回收
并发回收,是继分代回收、增量回收之后,进一步进行优化的垃圾回收策略。虽然 增量回收,提供了暂停、恢复功能,但是碰到复杂程度更高的应用场景,频繁的暂停、恢复,仍然会导致卡顿,所以,V8引擎🚀 引入了并发回收策略。
那并发回收,具体做了哪些优化点呢?
1. 📌 并发标记(Concurrent Marking)
在 增量回收 段落,我们已经解释了什么是“增量标记”。简单来说,并发回收在此基础上,进行了扩展,称之为“并发标记”。也就是使“增量标记”的垃圾回收线程可应用执行线程,交替执行。这样的设计,使资源利用率,大大提升,有效降低应用卡顿的次数。
2. 🪞 写屏障(Write Barrier)
上面的并发标记说到,垃圾回收线程可与应用执行线程交替执行。但这里也引出了一个问题,垃圾回收访问的状态,与程序执行状态可能不一致。由此引出了写屏障机制。
写屏障机制就是为了对象引用发生变化(状态变更)时,更好的追踪、更新对象的存活状态,以防止相关对象被误回收。也就是说,当对象引用发生变化时,写屏障机制会及时更新对象的“三色标记”状态,确保垃圾回收与JavaScript 执行时,获取的状态保持一致,从而避免对象误回收的问题。
综上所述,V8引擎🚀 使用并发回收的“并发”的机制,实现 JavaScript 执行与垃圾回收交替执行,从而提升应用运行的流畅度。
并行回收
上面的段落中,我们讲了并发回收使用“交替执行”的策略,进一步提高了垃圾回收的执行效率。但是要知道,并发机制的优化场景始终有限,所以,接下来我们介绍他的另外一位好兄弟,“并行回收”。
并行回收,指多个线程并行(同时)执行垃圾回收任务。也就是垃圾回收清理任务,被拆分城多个子任务,分配给多个线程同时执行。
总而言之,并行回收,主要是发生在标记阶段、垃圾回收清除阶段执行的,该策略,在并发回收基础之上,继续提升了垃圾回收效率。
内存碎片
上述段落中,我们已经讲解了 V8引擎🚀 的各种优化策略。
既然是垃圾回收,那么在频繁执行内存释放的时候,肯定会造成较多的内存碎片。
什么是内存碎片?
举个简单的例子,你有个📖书柜,放了很多书。你高中毕业了,学科类型的书被你全部清理了出来。但是每层书架,还有很多分散的空位,于是你重新整理了书柜,现在整整齐齐排满了两层。这些分散的空位就是内存碎片,那在 V8引擎🚀 中,如何整理“分散的空位”呢?
简单来说,V8引擎🚀 在标记清除算法执行后,进行了“可达性”标记,分配相应的内存,到新生代、老生代。
其中,老生代在清理内存占用时候,通过执行标记整理算法,会将所有“可达”相关的内存占用,集中在内存的一端,然后清理掉剩余的“不可达”内存占用,从而减少内存碎片现象。
1.老生代区域,未清理前
2.经过标记整理算法整理,集中在内存的一边(例如左侧)
3.清理掉剩余的“不可达”内存占用
艺术来源于生活,其实这种清理“内存碎片”方式,跟我们整理书柜相似。并且,这种内存碎片标记整理算法,在其他语言中也非常常见,且应用广泛。
总结
优秀的开发思路总是通用的。
从上述的优化策略来看,V8引擎🚀 考虑的点,从代码的执行层,在到系统资源的调度分配方式层,几乎都有涉及。
从如何标记、加快标记、如何暂停恢复标记、如何管理内存、如何清理、如何加速清理..,几乎都有提升。难怪说为啥V8引擎🚀 牛逼🐮,除了硬件、网速的区别,相较于上古时期的引擎,简直就是脱胎换骨。
然而,V8的优化策略还不仅仅如此,我也是罗列了一些主要的优化策略,如果想看更多,可以直接去 V8引擎🚀 官网地址查阅。
但是 V8的优化策略在厉害,毕竟是作为一个自动分配、销毁内存的引擎,我们开发的过程中,千万不要以为一切万事大吉了,错误的编码思路、方式很有可能造成另外一种问题,就是“内存泄漏”,关于内存泄漏,下次我空的时候我还会单独列一篇文章,根大家分享解答。
如果你有不同的看法,可以前往我的主页,“留言”页面给我留言。