在我们编写 JavaScript 代码时,很少手动释放内存。不像 C/C++ 需要使用 free(),JS 引擎会帮我们自动回收内存。
但是,自动回收不等于不需要理解内存。很多前端的性能问题、页面卡顿、内存暴涨,其实都和垃圾回收机制有关。因此理解垃圾回收机制也尤为重要。
什么是垃圾回收?
垃圾回收(Garbage Collection,简称 GC):
自动识别'无法再访问'的对象,并释放其占用的内存。
核心问题只有一个:如何判断一个对象'没用了'?
那么基于这个定义,没有被引用的对象,就是垃圾。比如:
let obj = { name: "Tom" }
obj = null
当 obj = null 后:
- 原来的对象不再被引用
- 成为垃圾
- 等待回收
内存模型基础
JavaScript 的内存中主要可以分为两个部分:
- 栈(Stack)——存放基本类型和引用地址
- 基本类型(number、string、boolean、null、undefined、symbol、bigint)
- 函数的引用地址
- 堆(Heap)——存放对象
- 对象
- 数组
- 函数
- 闭包
- DOM 引用
例如:
let obj = { name: "Tom" }
obj存在栈中,因为这个是引用地址,{ name: "Tom" }存在堆中。
垃圾回收核心原理
那么怎么知道对象是否是垃圾?
标记清除算法
JS 引擎中有标记清除算法用来判断是否是垃圾,整个过程就是从 root 出发,看看是否能够被访问到。
Root 包括:
- 全局对象(浏览器中的
window,Node 中的global) - 当前调用栈中的变量
- 正在执行函数里的局部变量
- 闭包中被引用的变量
那么整个算法过程就分为标记阶段与清除阶段。
- 标记阶段
假设我们目前有以下这些对象:
Root ├── A
│ └── B
└── C
D
- A 被 Root 引用
- B 被 A 引用
- C 被 Root 引用
- D 没有人引用
整个过程就是先从 root 出发,先找到了 A 与 C,为 A 与 C 打上了'可达'标记。
继续向下遍历通过 A 找到了 B,给 B 打上了可达标记,整个过程就是一次深度优先搜索(DFS)或广度优先搜索(BFS)。
遍历结束后:
- A、B、C 被标记为'可达'
- D 没被访问到
- 清除阶段
引擎会扫描整个堆内存。
对于每一个对象:
- 如果有'标记' → 保留
- 如果没有标记 → 释放内存
因此 D 将会被回收。
我们知道 JS 是单线程的,那么在垃圾回收的时候,主线程暂停,会执行这个垃圾回收,那么如果频繁进入垃圾回收就会造成页面卡顿。
V8 的分代回收机制
在早期垃圾回收模型中,所有对象都被一视同仁地扫描和回收。但在实际运行中,V8 发现了一个非常重要的现象:
绝大多数对象的生命周期都非常短。
例如:
function render() {
let temp = { x: 1, y: 2 }
return temp.x
}
在这个函数中,temp 只在函数执行期间存在。函数执行结束后,它就不再被引用,很快就会成为垃圾对象。
但与此同时,也存在一些对象会贯穿整个应用生命周期:
const config = { ... }
这些对象几乎不会被销毁。
如果所有对象都使用同一种回收策略:
- 要么频繁扫描整个堆(性能浪费)
- 要么回收不及时(内存膨胀)
因此,V8 采用了 分代垃圾回收机制(Generational GC)。
思想介绍
分代回收机制,将内存分成了两类:
- 新生代(Young Generation)
- 老生代(Old Generation)
核心思想就是新创建的对象大概率会很快死亡;存活较久的对象,大概率会继续存活。
新生代
首先,新创建的对象都先放在新生代,这个空间会比较小,大小通常比较小就几 MB,并且这个空间回收频率快,回收速度高。
整个新生代又分成两个空间:
- From Space
- To Space
具体新生代使用的是 复制算法(Copying Collection),也称为 Scavenge 算法。
算法流程:
From Space: [A][B][C][D]
To Space: [空]
其中:
- A、C 还被引用
- B、D 已经不可达
当垃圾回收执行时:
1️⃣ 停止 JS 执行
2️⃣ 从 Root 出发标记存活对象(这里与标记清除有区别,这里是标记复制,但是标记的流程是一样的)
3️⃣ 把'活着的对象'复制到 To Space
变成如下形式:
To Space: [A][C]
4️⃣ 清空整个 From Space
5️⃣ 交换两个空间角色
然后等待下一次执行。
老生代
那么什么时候对象会进入老生代呢?
进入老生代的条件是:
- 在新生代经历多次垃圾回收仍然存活
- 对象太大
老生代的特点就是:
- 存放生命周期较长的对象
- 空间更大
- 回收频率更低
- 回收成本更高
我们已经知道新生代的算法是复制算法,但是老生代使用的算法是:
- Mark-Sweep(标记清除)
- Mark-Compact(标记整理)
标记清除我们已经介绍过了,那么这里我们介绍标记整理。
在清除后,整个内存空间会存在很多空隙碎片,那么就需要进行整理:
[A][ ][B][ ][ ][C] |转换成下面的形式 [A][B][C][ ][ ][ ]
对象会被'挪动'到连续空间。
整个过程
我们可以看下面这个流程:
Root ↓ 新对象 → 新生代(复制算法) ↓(存活多次)晋升 ↓ 老生代(标记清除 + 标记整理)
以上是整个分代回收基本流程。
内存泄漏是什么
内存泄漏就是该回收的对象一直被引用,导致无法回收。
具体有:
1️⃣ 意外的全局变量
function test() {
a = 10 // 忘记 var / let
}
2️⃣ 定时器未清除
setInterval(() => {}, 1000)
3️⃣ 闭包持有大对象
function bibao() {
let data = ...
return function() {
console.log(data)
}
}
const test = bibao()
// 当 test 不再被需要,仍然保持着对 data 引用,导致内存泄漏
如何减少或避免内存泄漏
为了避免内存问题,我们就需要针对上面可能出现的问题进行针对性解决。
比如:
- 少创建全局变量
- 用完及时清除引用
- 清除定时器
- 避免不必要的闭包
- 避免缓存巨大对象
总结
JavaScript 在 V8 中采用基于可达性的分代垃圾回收机制,通过复制算法优化短生命周期对象,通过标记清除与整理管理长生命周期对象。
此外,还可以了解写屏障和记忆集解决跨代引用问题。
