藏起来的JS(四) - GC(垃圾回收机制)
前言:于无声处听惊雷
小时候我们看过《倚天屠龙记》,其中的张无忌练成了九阳神功之后,神功自动护体,真气自动流转,站着让敌人攻击都没事,睡着了都能自动修炼,是不是很羡慕?我们今天要了解的GC,恰恰就有九阳神功的风采。
不过它也很低调,让你平时都难以察觉它在自动运转。善战者无赫赫之功。JavaScript 的精华所在,恰恰就是这些我们经常忽视的地方。现在,让我们一起寻找 JS 设计师藏起来的绝世神功。
本文将带你探索 JS 背后默默守护世界和平的GC(垃圾回收机制)。
PS:神功点击就送
一. 内存管理(GC 本质)
不管是什么编程语言编写的程序,小到一句console.log('Hello world');
,大到数百亿参数、占用几十 GB 的大型AI模型,最终都需要加载到内存中去执行。可以说内存管理是编程的底层基础。
如此看来,内存管理无疑是所有编程语言的必修内功,那么如何进行内存管理呢?各个语言都有自己的见解,大致可以分为纯靠程序员手工匠心雕琢的手动管理,以及解放程序员大脑的自动管理两大类,下面我们将来了解一下它们各自的优缺点。
手动管理
以C、C++为代表的偏底层语言主张将性能和内存使用效率优化到极致,榨干每一KB内存,绝不浪费。它们的特点就是程序员需要直接控制内存的分配与释放,通过malloc
、free
(C)或是new
、delete
(C++)操作。
-
示例:
// 分配堆内存 int *arr = malloc(5 * sizeof(int)); // 逐个赋值 arr[0] = 1; arr[1] = 2; arr[2] = 3; arr[3] = 4; arr[4] = 5; // 释放内存,如果没有调用free()来释放内存,就会出现内存泄露情况 free(arr); // 防止野指针情况,此时arr指向NULL arr = NULL;
-
优点:
- 手动内存管理使得程序员能够像微雕大师一样让每一个字节的内存都物尽其用,带来了极致的性能和内存使用效率。
- 手动管理内存看起来原始,但是在计算机中,越是原始、偏向底层,其适配性就越是好。超高的适配性让C/C++ 代码可在几乎所有硬件架构上高效运行。
-
缺点:
- 对程序员的要求极高,稍有不慎就引发各种问题
- 内存泄露:忘记释放资源,无人使用的资源会“占着茅坑不拉屎”。要知道内存总共就这么多,内存泄露的越多,能用的就越少,最终将导致程序崩溃。
- 悬空指针(野指针):指向不存在的内存,导致系统报错,比如说访问已经被释放的内存。
- 双重释放:将同一指针重复释放,
- 内存碎片:可用内存充足却无法分配。比如说连续分配了100B、200B、300B三块空间,现在释放了100B和300B,然后有一个350B的内存请求,此时无法满足它的要求,因为两块空间为碎片,无法合并使用。
- 使得代码复杂度和调试难度指数级增长,项目的开发与维护成本激增。
- 对程序员的要求极高,稍有不慎就引发各种问题
自动管理
那么就有被 C/C++ 狠狠折磨的朋友要问了:“C/C++ 还是太吃操作了,有没有什么简单好用的管理内存的方式呢?” 有的,兄弟!有的!
自动管理内存应运而生,GC
是自动内存管理的核心实现,拥有 GC 的编程语言都是高级编程语言,比如说JS、Python。它们主张解放程序员的大脑,让他们无需纠结内存如何分配与回收,有更多的精力去考虑业务逻辑之类的问题。
你尽管放手去做,在 JS 背后的 GC 会默默守护世界和平,堪称功德无量。
-
示例:
// 堆内存中存储的对象被object1引用,标记为可达 let object1 = { name: 'obj1' }; // 对象失去引用,下次GC时被清除 object1 = null;
-
优点:
-
提高开发效率: 将程序员的负担交给了编程语言,解放了生产力,让程序员得以专注业务逻辑。同时,无需手动编写
malloc/free
或new/delete
来分配与回收内存,代码更简洁。 - 减少内存错误: 未使用的对象自动回收,防止内存泄漏。
- 内存安全: 自动管理确保每个对象只被回收一次,防止了双重释放。
- 简化代码维护: 内存相关Bug大幅减少,调试更轻松。由于内存管理逻辑由语言运行时统一处理,降低了人为错误,使得代码更健壮。
-
提高开发效率: 将程序员的负担交给了编程语言,解放了生产力,让程序员得以专注业务逻辑。同时,无需手动编写
-
缺点:
-
性能开销大:
- 传统 GC 执行时会阻塞主线程(Stop The World,STW),导致 UI 卡顿或者后台任务延迟,即使经过一代代优化仍然存在。
- 动态类型和高频对象操作增加 GC 扫描成本,导致周期性性能波动,出现性能抖动。
- 这些性能消耗对于高性能要求的程序来说是致命的,这也是为什么C/C++能够经久不衰。
-
不可控性:
- GC 实际不可预测,程序员只能依赖引擎调度,可能在进行关键操作时触发 STW。
- GC 的回收是有延迟的,导致程序的实际内存占用高于实际需求,难以精确控制资源释放。
- 采用 GC 的编程语言缺乏直接干预 GC 的 API(如强制回收、指定回收等),只能通过设计模式间接影响,优化手段有限。
-
性能开销大:
注意
- GC 固然方便,让开发者快乐地从繁琐的手动内存管理中解放出来。但是正如 MDN 的内存管理一节中说的那样:“这个自动性是混乱的潜在根源:它让开发者错误地以为他们不需要担心内存管理”。
- 为了避免出现这个错误认知,我们有必要进行了解一下 GC 的运行机制。
二. 引用计数算法
引用计数算法其实不算是V8引擎的主力算法,现代浏览器引擎(如 V8、SpiderMonkey)已不再将引用计数作为主流 GC 算法,但部分场景下会结合引用计数优化性能(如 V8 对短期对象的快速计数回收)。
那么我们为什么要了解它呢?因为引用计数算法虽然简单,但它是 GC 发展史上的重要里程碑,其设计思想与现代 GC 的核心逻辑存在微妙的关联:
- 引用计数的本质是通过计数器追踪对象引用数量(而非 “可达性标记”),当计数为 0 时判定对象可回收。这一思路虽与现代 GC 的 “标记可达性分析”(从根对象遍历引用链) 不同,但两者的核心目标一致 ——识别不再被使用的对象。
- “它山之石,可以攻玉”,引用计数是 GC 最直观的实现范式,通过它能快速建立 “对象存活 = 被引用” 的基础认知。通过学习引用计数算法,我们也可以很快理解现代 GC 的本质:通过标记可达性来判断对象是否存活,从而决定要不要清理该对象(主流算法如标记清除、标记整理、Scavenge 算法都基于这一核心本质思想,只是在实现细节和优化策略上有所不同)。
下面我们就来了解一下引用计数算法,出发!
原理
每个对象维护一个引用计数器,当有新引用指向该对象时计数 + 1,引用移除时计数 - 1。当计数器为 0 时,对象被回收。
-
示例:
// 此时堆内存中新创建了一个对象,其被obj1引用 // 此时引用计数器 + 1 = 1 let obj1 = { name: 'obj1' }; // 此时obj2也指向该对象 // 此时引用计数器 + 1 = 2 let obj2 = obj1; // 引用移除 obj1 = null; // 2-1=1 obj2 = null; // 1-1=0 // 此时堆内存中对应的对象引用为0,等待GC清除
重大缺陷(循环引用)
引用计数简单好用,为什么现在大家都不愿意用了呢,原因就是它有一个重大的缺陷--循环引用。我愿把它称为 “左脚踩右脚,螺旋升天”,跳出三界之外,不在五行之中,从此脱离 GC 掌控。
-
循环引用: 两个对象互相引用,但外界无引用。这种情况下,引用计数法无法回收这两个对象。
-
示例:
// 循环引用 function func() { let obj1 = { name: 'obj1' }; let obj2 = { name: 'obj2' }; // obj1 引用 obj2 obj1.innerObj = obj2; // obj2 引用 obj1 obj2.innerObj = obj1; } func(); // 函数func执行完毕后,obj1 和 obj2 互相引用,但外界无法访问它们 // 此时按道理应该回收掉它们,但是引用计数法无法回收这两个对象 // 原因就是obj1和obj2的引用计数不为0(互相引用,形成死循环),JS引擎无法识别出它们要被回收
-
对执行上下文有疑惑的朋友可以去我的博客《藏起来的JS(三) - 执行上下文、作用域链与闭包》补补课哈
所以现在大家转而使用标记-清除、标记-整理、Scavenge 算法之类的算法,解决了循环引用这一情况。
三. 现代 GC 运行机制(很重要)
在 JavaScript 的内存管理体系中,栈内存负责维护程序运行时的上下文状态(如变量引用、函数调用栈),其特点是读写速度极快但空间容量较小;而堆内存则是存储对象实例和数据的主要区域,也是垃圾回收(GC)的核心作用范围。要了解 GC,我们先来了解一下堆内存中的组成。
- 以最常用的V8引擎为例,堆内存中大致可以划分为:
- 新生代内存区(New Space / Young Generation)
- 老生代内存区(Old Space / Old Generation)
- 大对象区(Large Object Space)
- 映射区(Map Space)
- 代码区(Code Space)
- 其他区域
只需要重点了解新生代内存区和老生代内存区即可。
新生代内存区(New Space / Young Generation)
- 存放存活时间短的小对象(如临时变量、局部对象)。
- GC 触发条件: From Space 分配满时触发 Scavenge 回收。
- 副垃圾回收器进行管理内存。
结构
-
由大小相等的分配区(From Space)和存活区(To Space)构成。
- From Space 为当前活跃的分配区,新对象在此创建;
- To Space 是当前闲置的回收区,用于存储 GC 时存活的对象。
-
新生代内存区的特点: 回收频率很高,速度很快,但是空间利用率很低,因为有一半的内存空间处于"闲置"状态。这里的“闲置”状态就是指 GC 触发时,占据新生代内存区一半空间的 To Space 处于临时闲置状态,准备要与 From Space 交替使用。
GC算法
- 新生代内存区采用 Scavenge 算法进行管理。
- GC 触发: 新的对象会优先分配至 分配区(From Space),GC 触发时,From Space 中存活的对象(仍被引用的对象) 会被复制到 To Space 中,其引用地址也更新为新地址。而后未被引用的对象以及被复制完的存活对象则像垃圾一样被 GC 直接清除,其占据的空间也被一并释放,整个 From Space 都将被清空。
- GC 结束: 分配区(From Space)和存活区(To Space)将会进行两极反转。原来的 To Space(其中存储着从上次GC中存活的对象)变为新的 From Space,等待新对象的到来,而原来的 From Space(已在GC中被清空)变为新的 To Space,等待下一次 GC 时接收存活对象。
- Scavenge 算法确保了新生代内存区在不出意外的情况下,可以无限重复使用下去。如此循环GC多次,在此期间存活多次的存活对象将晋升到老生代(对象晋升策略)。
Scavenge 算法图解
-
GC 未触发时
-
GC 触发过程1:复制存活对象至 To Space
-
GC 触发过程2:原From Space被清空
-
GC 结束:原From Space与原To Space交换身份
老生代内存区(Old Space / Old Generation)
- 存放生命周期长的大对象。
- GC 触发条件: 老生代内存占用超过阈值、或新生代晋升对象过多时触发标记 - 清除 - 整理。
- 主垃圾回收器进行管理内存。
对象晋升策略
前面我们提到,在多次GC之后存活下来的存活对象将会晋升到老生代内存区,我们将其称为对象晋升策略。这其实有些偏颇了,对象的晋升情况不是只有单单的多次GC存活。
-
情况1 GC多次存活(年龄晋升): 对象每经历一次 Scavenge GC 后仍存活,其 GC计数器 + 1,其数值可以称之为 “年龄” ,当年龄超过阈值(默认为15次,但实际上在V8引擎的动态调整中会少得多,可能2次就行了)时,对象会被晋升到老生代,可以把这个过程称之为“熬资历”。
-
情况2 大对象: 若对象的初始大小超过了新生代单个页的容量(“页”为内存分配的单位,在64位下新生代的页通常为1 MB),V8会直接将其分配到老生代,不经过新生代。
-
情况3 To Space占用过多: 如果 GC 时,To Space 内的占用率超过了阈值(一般为25%),存活对象会被提前晋升到老生代(即使其年龄未达标)。
-
晋升策略决策树:
结构
老生代的内存比新生代大得多,存的东西也多很多,所以其内部的区域划分比起新生代来说复杂很多。
- 基于v8 11.0+引擎的老生代结构如下
分区名称 存储内容 垃圾回收策略 Old Space 长期存活的普通对象(含引用关系),如闭包、全局变量引用的对象 (标记-清除)+(标记-整理) Code Space JIT 编译后的机器码(优化后的函数) 单独标记,与数据区回收策略分离 Large Object Space 大小超过 kMaxRegularHeapObjectSize
(默认约 1MB)的对象每个对象独占内存页,直接标记清除 Map Space 对象的 Map 元数据(描述属性布局、原型链等) 与 Old Space 协同回收,优化元数据访问 Cell Space 小尺寸数据(如 SMI 整数、指针) 部分版本中与老生代协同管理
老规矩,我们不可能全拿下所有的分区,大多数分区其实也很难用到,了解一下就差不多了。我们要着重了解的其实是 Old Space 分区所使用的 GC 算法 标记-清除-整理。
GC 算法
因为老生代区的内存大小和其中存储的对象大小远超新生代区,所以 Scavenge 算法所进行的对半开的频繁批量复制区内的对象操作太简单粗暴,也太耗费时间了。为了性能着想,老生代需要采用更适合的 GC 操作,也就是标记-清除-整理。
-
标记-清除-整理: “标记-清除-整理” 其实是指一个 GC 过程,真正的 GC 算法要分为 标记-清除 以及 标记-整理 两个算法。
-
标记-清除(Mark-Sweep): 这是最常用的算法,简单来说就是在要清除对象上做好标记,然后再进行清除。
- 标记阶段: 从根对象(如全局变量、调用栈中的变量)开始,递归标记所有可达对象。
- 清除阶段: 遍历整个堆内存,回收未被标记的对象。
-
标记-整理(Mark-Compact): 这是为了解决标记-清除算法操作之后,堆内存中出现的碎片化问题而设计的算法。简单来说就是,将呈碎片化分布的对象打好标记,然后 V8 引擎将会出手将碎片化分布的对象重新排列好。
- 标记阶段: 同上面的标记-清除算法的标记阶段。
- 整理阶段: 将所有存活对象移动到内存的一端,然后清除边界外的内存。
-
标记-清除(Mark-Sweep): 这是最常用的算法,简单来说就是在要清除对象上做好标记,然后再进行清除。
标记-清除-整理 过程图解
-
标记-清除 之 标记
-
标记-清除 之 清除
-
标记-整理 之 标记
-
标记-整理 之 整理
优化(增量标记算法)
众所周知,JS 的 V8 引擎为单线程,也就是我们常说的主线程。那么问题来了,现在主线程又要运行你的 JS 代码,又要去管理堆内存。线程只有一个,它是否会分身乏术呢?
答案是肯定的,前面提到的 Scavenge 算法以及标记-清除、标记-整理算法都会造成主线程阻塞,因为 V8 引擎的垃圾回收器在执行这些算法时必须暂停主线程的执行,引起全局阻塞(也就是前面介绍自动内存管理时的 STW)。
其中标记-清除、标记-整理这两个算法造成的 STW 尤其严重,因为标记阶段需要从根对象(如全局变量、调用栈)出发,递归遍历整个对象图,标记所有可达对象。对于大型应用,这个过程所要花费的时间可能要数百毫秒甚至更长时间,导致出现了明显的 STW。
- 这时候聪明的 JS 设计师就采用了增量标记算法来对标记-清除-整理这个过程进行优化。
-
增量标记算法(Incremental Marking): 将标记过程碎片化,每次只标记一小部分对象,然后暂停标记,让主线程执行一段时间,以此减少 STW 的时间,如此循环直到标记完成。
-
过程:
-
1. 初始标记(STW)
- 暂停主线程,标记所有根对象(如全局变量、当前调用栈中的变量)。
- 这个阶段通常非常快,因为根对象数量较少。
-
2. 增量标记(与主线程交替执行)
- 垃圾回收器每次标记一小部分对象(例如,先遍历 100 个对象),然后暂停,让主线程继续执行。
-
3. 重新标记(STW)
- 再次暂停主线程,处理在增量标记阶段由写屏障记录的引用变化,确保所有可达对象都被标记。
- 这个阶段比初始标记稍长,但远短于传统标记的总时间。
-
4. 清除 / 整理阶段
- 回收未标记的对象,或整理内存空间。这个阶段也可以采用增量方式或并发执行(取决于具体实现)。
-
1. 初始标记(STW)
-
过程:
增量标记算法图解
-
增量标记算法优化后的 GC
四. 开发习惯
像 JS 这类依赖 GC 机制的语言,我们无需手动管理内存,省去很多麻烦,但是这不代表我们不用注意这方面,养成良好的开发习惯可以让我们的代码更健壮、性能更好。
避免内存泄漏:确保 GC 能识别 “不可达对象”
GC 仅回收根对象无法访问的对象,若代码让对象长期 “被根引用”,会导致内存泄漏。未声明的变量会挂载到 window
(浏览器)或 global
(Node),成为根对象的直接引用,长期占用内存。
-
措施:
-
管控全局变量
-
少用全局变量。
// 坏代码:意外全局,导致 leakObj 永远存活 function bad() { leakObj = new Array(1e6); // 未用 let/const 声明,自动全局 } // 好代码:显式声明局部变量,函数结束后栈引用消失 function good() { const safeObj = new Array(1e6); }
-
如果实在要用全局变量(比如说缓存),用完之后主动置为
null
,断开根引用。// 全局缓存,使用后清理 window.cache = { data: new Array(1e6) }; window.cache = null; // 让 GC 回收缓存对象
-
-
谨慎使用闭包
闭包会捕获外部变量,延长其生命周期(如闭包引用大对象,导致大对象无法回收)。-
若闭包不再使用,主动释放外部引用:
function createClosure() { let data = new Array(1e6); const closure = () => { if (data) { console.log(data.length); data = null; // 首次使用后释放引用 } }; return closure; } // 使用示例 const fn = createClosure(); fn(); // 第一次调用:打印长度并释放data fn(); // 后续调用:data已为null,这就是主动释放外部引用
-
-
处理DOM引用
对于 DOM 节点的循环引用(如事件监听),需显式移除监听。-
示例:
// 坏代码:事件监听导致循环引用 element.onclick = function () { // ... }; // 好代码:组件销毁时移除监听 element.onclick = null; element.removeEventListener('click', handler);
-
-
管控全局变量
优化对象生命周期:协助 GC 高效回收
-
措施:
-
- 优先使用局部变量
- 函数内的局部变量(如
function foo() { const obj = {} }
)在函数执行完后,栈引用立即消失,可被 新生代 GC(Scavenge 算法)快速回收(新生代 GC 速度远快于老生代)。
-
- 及时释放无用引用
-
对不再使用的对象,主动置
null
,帮助 GC 提前识别 “不可达”(现代 GC 虽能分析,但明确释放更高效)。 -
示例:
function processData() { const bigData = new Array(1e6); // 处理数据... bigData = null; // 主动释放,加速 GC 回收 }
- 3. 解耦循环引用
-
虽然现代 GC(标记清除)能处理循环引用,但代码层面仍建议解耦,减少 GC 分析成本。
-
示例:
// 坏代码:循环引用 const a = {}; const b = {}; a.inner = b; b.inner = a; // 好代码:使用后主动断开 a.inner = null; b.inner = null;
-
-
降低 GC 性能开销:减少主线程阻塞
-
措施:
-
1. 减少短生命周期对象的创建频率
- 频繁创建临时对象(如循环内创建
{}
)会导致 新生代 GC 频繁触发(Scavenge 虽快,但太频繁仍有开销),所以我们要减少短生命周期对象的创建频率。 -
优化:
- 复用对象(如对象池):
const obj = {}; 循环内复用 obj
。 - 批量处理:合并多次操作,减少中间对象(如用
Array.join
替代多次+=
拼接字符串)。
- 复用对象(如对象池):
- 频繁创建临时对象(如循环内创建
-
2. 控制大对象的创建
- 大对象(超过新生代阈值的对象)会 直接进入老生代,老生代 GC(标记 - 整理)代价更高。
-
优化:
- 拆分大对象为小对象(让小对象在新生代回收)。
- 避免频繁创建大对象(如图像数据、大数组)。
-
3. 监控 GC 状态
- 浏览器: 通过 Chrome DevTools → Performance 观察 “GC Event”,若 GC 频繁且耗时久,排查代码(如大量临时对象)。
-
Node.js: 启动时添加
--trace_gc
参数输出 GC 日志,或用v8-gc-log-parser
分析,定位是否因新生代空间不足导致频繁 Scavenge。
-
五. 结语
GC 固然好用,但不意味着程序员可以高枕无忧,了解一些 GC 运行机制和 GC 算法是很有必要的,可以帮助我们避开很多坑。希望这篇博客可以让你有所收获,我不胜荣幸。
如果本篇博客中有一些错误或是缺漏,欢迎你在评论区指出我的错误,大家一起进步,万分感激!