文章目录
- [概述 - 抓一条主线](#概述 - 抓一条主线)
- [V8 里最重要的概念](#V8 里最重要的概念)
- [Minor GC](#Minor GC)
- [Major GC](#Major GC)
- [Minor GC 和 Major GC 的区别](#Minor GC 和 Major GC 的区别)
- 对象为什么会进入老生代
- [为什么 GC 会卡顿](#为什么 GC 会卡顿)
-
- [Minor GC 卡顿特点](#Minor GC 卡顿特点)
- [Major GC 卡顿特点](#Major GC 卡顿特点)
- 在实际开发里怎么理解
- [和{}、[] 有什么关系](#和{}、[] 有什么关系)
- 常见误区
-
- ["GC 只和内存大小有关"](#“GC 只和内存大小有关”)
- ["Minor GC 一定没问题"](#“Minor GC 一定没问题”)
- ["只要少 new 就行"](#“只要少 new 就行”)
- 在前端/游戏里最该关注的点
- 一个非常实用的判断方式
- [怎么减少 GC 压力](#怎么减少 GC 压力)
- 一句话总结
- [《空间较小》- 新生代](#《空间较小》- 新生代)
概述 - 抓一条主线
- V8 用"分代回收"来处理内存。
- 核心思想是:大多数对象活不久,少数对象活很久。
- 所以它把堆分成"新生代"和"老生代",分别用不同策略回收。
V8 里最重要的概念
新生代
- 新创建出来的大多数对象先进入这里。
- 特点:
- 空间较小(这里的空间较小看最后的《空间较小》
- 回收频繁
- 回收速度优先
- 这里主要对应 Minor GC。
老生代
- 经历过几次回收还活着的对象,会被提升到这里。
- 特点:
- 空间更大
- 对象活得更久
- 回收更重、更慢
- 这里主要对应 Major GC。
Minor GC
是什么
- Minor GC 可以理解成"新生代垃圾回收"。
- 它主要处理:
- 刚创建出来的短命对象
- 临时数组、临时对象、闭包上下文等
为什么它通常比较快
- 因为 V8 假设:新生代里大部分对象很快就死掉
- 所以 Minor GC 不需要全堆细查,只需要快速筛掉死对象,保留少数还活着的。
常见机制
- V8 在新生代里常用的是一种复制式思路,常被叫做 Scavenge
- 可以粗略理解为:
- 新生代分成两块区域
- 活着的对象复制到另一块
- 死掉的对象直接整片丢弃
- 两块区域角色互换
- 这种方式适合"活对象少"的场景,所以很快。
触发时机
通常是:
- 新生代空间满了
- 分配新对象时发现放不下了
典型现象,如果代码里频繁创建大量短命对象,比如:
- 每帧 {}
- 每帧 []
- 大量临时字符串
- 高频 map/filter/reduce 产生中间对象
就会导致 Minor GC 很频繁。
Major GC
是什么
Major GC 可以理解成"老生代垃圾回收"。
它主要处理:
- 老生代里的对象
- 活得比较久的对象
- 提升上去的缓存、长生命周期结构、闭包引用链等
为什么它更重
因为老生代里:
- 对象更多
- 引用关系更复杂
- 活对象比例通常更高
所以它不能像新生代那样简单复制一遍就完事,通常需要更复杂的算法。
常见机制
Major GC 往往涉及这些步骤中的一些:
- Mark:标记哪些对象还活着
- Sweep:清扫死对象
- Compact:整理内存碎片,把活对象挪紧
所以你会听到这些词: - Mark-Sweep
- Mark-Compact
触发时机
一般是:
- 老生代空间不足
- 老生代碎片太多
- 系统判断需要更深入回收
典型现象
如果代码里有很多:
- 全局缓存
- 长生命周期 Map/Object
- 没释放的事件监听
- 闭包一直引用大对象
- 数组不断扩张又不清理
就容易给老生代很大压力,导致 Major GC。
Minor GC 和 Major GC 的区别
最简单地记:
- Minor GC:回收新生代,快,频繁
- Major GC:回收老生代,重,较慢
再具体一点:
- Minor GC 主要解决"短命垃圾"
- Major GC 主要解决"长期存活后又变垃圾的对象"
对象为什么会进入老生代
通常有几种常见原因:
- 熬过了几次 Minor GC:对象在新生代回收后还活着,就可能被提升。
- 对象太大:某些大对象可能直接进入老生代。
- 新生代放不下:有时也会提前晋升。
所以"进入老生代"不代表它永远重要,只代表它"活得比较久"或者"放不下"。
为什么 GC 会卡顿
因为垃圾回收不是完全免费。即使 V8 有很多优化,比如:
- 增量标记
- 并发标记
- 并行回收
但某些阶段仍然可能需要 Stop-The-World,也就是:JS 执行暂停一下,让 GC 先干活。
所以你会感受到:
- 掉帧
- 卡顿
- 尤其在动画、游戏、实时交互里更明显
Minor GC 卡顿特点
- 单次通常短
- 但如果太频繁,会形成很多小卡顿
Major GC 卡顿特点
- 次数少一些
- 但一次可能更明显、更重
在实际开发里怎么理解
可以把它想成两层保洁:
- Minor GC(像"桌面清理"
- 频率高
- 清理临时纸屑
- 动作快
- Major GC(像"整个仓库大扫除"
- 频率低
- 但很费劲
- 整理旧箱子、搬动东西、清碎片
和{}、[] 有什么关系
关系在于:
- {}
- 临时闭包
- 临时字符串
- 临时函数对象
这些如果大量高频创建,通常先增加的是 Minor GC 压力。
如果其中一些对象又被长期引用住了,就可能:
- 晋升到老生代
- 进一步增加 Major GC 压力
所以常见规律是:
- 高频短命对象多 -> Minor GC 频繁
- 长生命周期对象膨胀 -> Major GC 更重
常见误区
"GC 只和内存大小有关"
不对。还和这些强相关:
- 对象创建频率
- 对象生命周期
- 引用结构复杂度
- 是否碎片化
"Minor GC 一定没问题"
也不对。
- 在游戏、动画、滚动、实时渲染里,很多次小停顿 一样会明显掉帧。
"只要少 new 就行"
不完全对。真正要看的是:
- 是否在热路径里频繁创建
- 是否造成对象逃逸和长生命周期引用
- 是否产生大量中间对象
在前端/游戏里最该关注的点
每帧临时对象
比如:
- 临时 {x, y}
- 临时数组
- 临时矩形、点、向量
- 频繁字符串拼接
这类最容易造成 Minor GC 抖动。
长生命周期缓存
比如:
- 资源缓存
- 节点树引用
- 事件监听没解绑
- 大数组不清理
这类更容易推高 Major GC 压力。
闭包和回调链
比如:
- 定时器持有上下文
- Handler 未 recover
- 监听器一直引用对象
这类容易让本该死掉的对象继续活着。
一个非常实用的判断方式
如果你看到问题是:
- "偶尔非常卡一下" 更像 Major GC 或资源加载等重操作。
如果你看到问题是:
- "持续轻微掉帧、抖动" 很可能有频繁 Minor GC。
当然这只是经验判断,不是绝对。
怎么减少 GC 压力
针对 Minor GC
- 避免热路径里频繁创建临时对象
- 复用 Rectangle、Point、数组、临时结构
- 少造中间数组和中间对象
针对 Major GC
- 清理不用的长生命周期引用
- 解绑事件、清理定时器
- 控制缓存大小
- 避免对象被全局容器长期持有
一句话总结
- V8 的 GC 核心是分代回收:
- Minor GC:清理新生代,处理大量短命对象,快但可能很频繁
- Major GC:清理老生代,处理长生命周期对象,更重,停顿可能更明显
《空间较小》- 新生代
是指:V8 为新生代预留的那块堆内存区域本身比较小(即系统/运行时提供给这类对象存放的内存区域大小较小)。
- V8 把堆分成不同区域
- 其中"新生代"这个区域整体容量相对老生代更小
- 新创建对象通常先放进这个较小的区域里
可以这样理解
把 V8 堆想成两个仓库:
新生代
- 小仓库
- 放新来的东西
- 周转很快
老生代
- 大仓库
- 放活得久的东西
- 清理更麻烦
为什么要设计成"小空间"
因为 V8 的假设是:大多数新对象很快就会死掉,所以没必要一开始就给它们放进一个很大的区域。
放在小一点的新生代里有几个好处:
- 回收更快
- 扫描范围更小
- GC 成本更低
- 更适合高频处理短命对象
注意:对象本身也会影响分配
虽然"空间较小"指的是区域容量,不过你也可以顺带知道一个现实情况:
- 某些特别大的对象,可能不会按普通小对象那样待在新生代里
- 可能直接进别的区域,比如大对象区
所以并不是所有对象都老老实实先进新生代。
但对于一般普通对象,默认理解成"先进新生代"是对的。