JavaScript 垃圾回收原理与 V8 分代回收
在我们写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将会被回收。
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️⃣ 意外的全局变量
functiontest() { a=10// 忘记 var / let } 2️⃣ 定时器未清除
setInterval(() => {},1000) 3️⃣ 闭包持有大对象
function bibao() { let data = ... return function() { console.log(data) } } const test = bibao() // 当 test不再被需要,仍然保持着对data引用,导致内存泄漏 如何减少或避免内存泄漏
为了避免内存问题,我们就需要针对上面可能出现的问题进行针对性解决
比如
- 少创建全局变量
- 用完及时清除引用
- 清除定时器
- 避免不必要的闭包
- 避免缓存巨大对象
总结
JavaScript 在 V8 中采用基于可达性的分代垃圾回收机制,通过复制算法优化短生命周期对象,通过标记清除与整理管理长生命周期对象
此外,如果有兴趣可以再了解 写屏障和记忆集解决跨代引用问题。
以上如有错误欢迎评论