JavaScript 垃圾回收机制详解
JavaScript 垃圾回收机制详解
在 JavaScript 中,内存管理是自动的。当变量不再被需要时,它们所占用的内存会被垃圾回收器(Garbage Collector, GC)自动释放。理解 GC 原理有助于写出更高效、更不易内存泄漏的代码。
一、核心概念:可达性(Reachability)
垃圾回收的基本判断标准是可达性:从根(Root)对象出发,通过引用链可以访问到的对象,就是“可达的”,需要保留;无法到达的对象则被标记为垃圾,等待回收。
根对象通常包括:
- 全局对象(浏览器中为
window,Node.js 中为global) - 当前执行函数的局部变量和参数
- 调用栈上的其他函数变量
- 某些内置对象(如 DOM 树的根节点)
letuser={name:'Alice',friend:{name:'Bob'}};// user 引用对象 {name:'Alice', friend: ...} 从根可达,对象存活user=null;// 现在 {name:'Alice', friend:{...}} 不再可达,GC 可回收二、常见的垃圾回收算法
1. 引用计数(Reference Counting)
原理:每个对象维护一个引用计数器,记录有多少个地方引用了它。当引用计数降为 0 时,对象立即被回收。
leta={value:1};// 对象 {value:1} 引用计数 = 1letb=a;// 引用计数 = 2a=null;// 引用计数 = 1b=null;// 引用计数 = 0 → 立即回收致命缺点:循环引用
functioncreateCycle(){letobj1={};letobj2={};obj1.ref=obj2;obj2.ref=obj1;return'done';}createCycle();// obj1 和 obj2 互相引用,即使函数执行完毕离开作用域,它们的引用计数仍为 1,永远无法回收。优点:实现简单,回收及时,没有明显暂停。
缺点:无法处理循环引用;计数器维护带来额外开销(每次赋值都要更新)。现代 JS 引擎已不采用此方案作为主要算法,仅在某些场景(如 COM 对象)中保留。
2. 标记-清除(Mark-and-Sweep)
这是现代引擎的核心算法,完美解决循环引用问题。
流程:
- 标记阶段:从根对象出发,深度遍历所有可达对象,并标记为“活跃”。
- 清除阶段:遍历整个堆内存,将未被标记的对象回收,并将内存归还给空闲列表。
// 循环引用示例functionfoo(){leta={};letb={};a.b=b;b.a=a;}foo();// 执行后,a, b 无法从根对象到达 → 标记阶段不会标记它们 → 被清除优点:能处理循环引用。
缺点:
- 回收时会产生内存碎片(空闲内存不连续)。
- 标记和清除可能导致全停顿(Stop-The-World),页面卡顿。
3. 标记-整理(Mark-Compact)
为了解决内存碎片问题而提出。它在标记阶段后增加整理阶段:将所有存活对象移动到内存一端,然后直接清理边界外的所有内存。
// 想象内存布局// 整理前:[A][ ][B][ ][C][ ] → 整理后:[A][B][C][ ]优点:消除碎片,分配大对象更容易。
缺点:移动对象有额外开销,停顿时间更长。通常与分代回收配合使用。
4. 分代回收(Generational Collection)
几乎所有现代 JS 引擎(V8、SpiderMonkey)都采用分代假说:
大多数对象“朝生暮死”,存活时间短;少数对象存活时间长。
引擎将内存堆划分为新生代(Young Generation)和老生代(Old Generation),分别使用不同策略。
新生代(Young Generation)
- 存放存活时间短的对象,空间较小(通常 1~8 MB)。
- 回收算法:Scavenge(半空间复制)。
- 将新生代分为两个等大的半空间(From 和 To)。
- 分配都在 From 空间进行。
- 当 From 空间快满时触发 Minor GC:
- 标记 From 空间的存活对象。
- 将存活对象复制到 To 空间,并紧凑排列。
- 交换 From 和 To 的角色。
- 优点:速度快,只处理存活对象,复制同时完成了整理。
- 对象晋升:经过两次 Minor GC 仍存活的对象,会移动到老生代。
老生代(Old Generation)
- 存放存活时间长的对象,空间大(可达几百 MB)。
- 回收算法:标记-清除+标记-整理(Major GC)。
- 通常在老生代内存不足时触发,频率低,但停顿时间长。
三、V8 引擎的优化手段
为了减少全停顿带来的卡顿,V8 采用了多种技术将垃圾回收工作拆解到多个小步中执行。
| 技术 | 说明 |
|---|---|
| 增量标记(Incremental Marking) | 将标记阶段拆成许多小段,与 JS 执行交替进行,每执行一小段 JS 就标记一点,缩短连续停顿时间。 |
| 惰性清理(Lazy Sweeping) | 按需清理垃圾页,不必一次性清扫整个堆,减少清理阶段的暂停。 |
| 并发标记(Concurrent Marking) | 标记工作完全在后台线程中执行,JS 主线程可以持续运行,几乎无停顿。 |
| 并发清理(Concurrent Sweeping) | 清理也在后台线程进行,主线程仅需短暂同步。 |
| 并行回收 | 主线程和多个辅助线程同时进行回收工作,利用多核加速。 |
V8 的分代回收简图:
堆内存 ├── 新生代 (Young Gen) │ ├── From 空间 (活动区) │ └── To 空间 (空闲区) ← Scavenge 算法 │ ↳ 两次存活 → 晋升到老生代 └── 老生代 (Old Gen) ├── 标记-清除 / 标记-整理 ├── 增量标记 / 并发标记 └── 惰性清理四、开发者如何配合 GC
虽然 GC 是自动的,但不规范的代码会造成内存泄漏,导致性能下降甚至崩溃。
常见内存泄漏及避免方法
- 全局变量意外污染
functionleak(){bar='this is global';// 未声明变量,挂载到 window}- 遗忘的定时器或回调
constobj={data:newArray(10000).fill('*'),start(){this.timer=setInterval(()=>{console.log(this.data);},1000);}};// 组件卸载时必须 clearInterval(this.timer),否则 obj 无法被回收- 闭包引用大对象
functionouter(){constbigData=newArray(1000000);returnfunctioninner(){console.log('hello');// bigData 虽然未在 inner 中使用,但因闭包作用域链,可能被 V8 保留};}constfn=outer();// bigData 常驻内存// 优化:将 bigData = null 在 return 前释放- 游离的 DOM 引用
letbtn=document.getElementById('myBtn');btn.remove();// 从 DOM 树移除,但 JS 变量 btn 仍引用它,GC 不回收btn=null;// 手动清除引用现代辅助工具
- WeakRef:创建对对象的弱引用,不阻止 GC。
- FinalizationRegistry:在对象被回收后执行回调,用于清理资源。
letobj={data:'important'};constregistry=newFinalizationRegistry((heldValue)=>{console.log(`对象被回收,清理资源:${heldValue}`);});registry.register(obj,'some resource');obj=null;// 未来 GC 回收后触发回调总结
- JavaScript 垃圾回收基于可达性。
- 核心算法从引用计数演进到标记-清除,再配合分代回收和增量/并发优化以降低停顿。
- 开发者应养成良好的内存管理习惯,避免循环引用、清除定时器、及时解除 DOM 引用等,利用弱引用处理特定场景。
理解这些机制后,你不仅能写出更高效的代码,还能在排查内存问题时快速定位根因。
