公众号:小博的前端笔记
浏览器垃圾回收机制详解
核心目标: 自动管理内存,回收不再使用的对象占用的空间,防止内存泄漏。
一、 关键概念
-
可达性 (Reachability):
- 判断对象是否"存活"的根本标准。
- 根对象 (Roots): 包括全局对象(如
window
)、当前执行函数的局部变量和参数、活跃的闭包作用域链上的变量、DOM 元素引用等。 - 可达对象: 从根对象出发,通过引用链(对象属性、数组元素等)能够访问到的对象。
- 不可达对象: 从任何根对象出发都无法访问到的对象。这些对象就是垃圾回收的目标。
-
常见垃圾回收算法:
-
1. 标记-清除 (Mark-and-Sweep) - 主流浏览器核心算法
-
阶段1:标记 (Mark): 从所有根对象开始,递归遍历所有能被访问到的对象,将它们标记为"可达"。
-
阶段2:清除 (Sweep): 遍历整个堆内存,将所有未被标记为"可达"的对象视为垃圾,回收它们占用的内存空间。
-
优点: 解决了循环引用问题(两个或多个对象互相引用,但整体不可达时,也能被回收)。
-
缺点:
- 会产生内存碎片。回收后内存空间不连续,可能导致后续大对象分配失败,即使总空闲内存足够。
- 执行期间(尤其是全堆标记),可能会阻塞 JavaScript 主线程,造成页面卡顿(Stop-The-World)。
-
-
2. 引用计数 (Reference Counting) - 已逐渐被淘汰,但需了解
-
原理: 每个对象维护一个引用计数器。当有其他对象引用它时,计数器加1;当引用解除时,计数器减1。当计数器变为0时,立即回收该对象。
-
优点: 回收及时,一旦计数为0立刻回收。
-
致命缺点:
- 无法解决循环引用问题: 如果对象 A 引用了 B,B 又引用了 A,即使它们都已不再被外部使用,它们的引用计数永远为1,无法回收,导致内存泄漏。这在早期 IE(涉及 COM 对象)中非常常见。
-
现状: 现代浏览器主 GC 算法不再使用纯引用计数。但一些局部场景(如某些 DOM API 实现)可能仍隐含有类似思想。
-
-
二、 现代浏览器的优化策略(以 V8 引擎为例)
为了克服标记-清除的缺点(碎片、卡顿),现代浏览器(尤其是 V8)采用了更高级的分代和并行/增量/并发回收策略:
-
分代收集 (Generational Collection):
-
核心思想: 根据对象的存活时间将堆内存划分为不同的"代"(通常是两代)。
- 新生代 (New Space/Young Generation): 存放新创建 的对象。大部分对象在这里"朝生夕死"(存活时间短)。空间较小,回收频繁且快速 (使用 Scavenge 算法,一种复制算法)。
- 老生代 (Old Space/Old Generation): 存放从新生代中存活过多次 GC 的对象,以及一些大对象 (直接分配在老生代)。对象存活时间长。空间较大,回收较少但耗时较长 (主要使用标记-清除 或优化的标记-整理/标记-压缩算法)。
-
优势: 针对不同生命周期的对象使用不同的回收策略,大幅提升效率。大部分快速回收发生在新生代,对用户感知小。
-
-
新生代回收:Scavenge 算法 (复制算法)
-
将新生代划分为两个等大的
From
空间 和To
空间。 -
新对象只分配到
From
空间。 -
垃圾回收时:
- 标记
From
空间中存活的对象。 - 将存活的对象复制 到
To
空间(保持紧凑排列,无碎片)。 - 清空整个
From
空间。 - 交换
From
和To
空间的角色。
- 标记
-
对象晋升 (Promotion): 如果一个对象在新生代的多次回收(通常1-2次)中都能存活下来,或者
To
空间使用率超过25%,则将它移动到老生代。 -
优点: 极其快速、无内存碎片。
-
缺点: 只能使用一半的新生代空间(空间换时间)。
-
-
老生代回收:标记-清除/标记-整理/增量标记
-
标记-清除: 基础算法,但会产生碎片。
-
标记-整理 (Mark-Compact): 在标记完成后,将所有存活的对象向内存一端移动 ,然后清理掉边界外的内存。解决了碎片问题,但移动对象成本更高。
-
增量标记 (Incremental Marking):
- 问题: 标记整个老生代可能耗时很长,导致明显卡顿。
- 方案: 将标记过程拆分成很多小的"步进"。让 GC 工作与 JS 主线程交替执行。每次只标记一小部分对象,然后让主线程执行一会儿 JS,再回来标记下一小部分。
- 难点: 在增量过程中,JS 执行可能修改对象的引用关系(比如删除了一个已标记对象的引用),导致标记结果不准确("写屏障" Write Barrier 技术用于跟踪这些修改)。
-
并发标记 (Concurrent Marking) / 并行标记 (Parallel Marking):
- 并发标记: 利用辅助线程(与 JS 主线程并发运行)进行标记工作。主线程几乎不阻塞。
- 并行标记: 利用多个辅助线程并行进行标记工作。加速标记过程。
-
惰性清理/增量清理: 类似增量标记,将清理阶段也拆分成小块执行。
-
三色标记法 (Tri-color Marking):
- 用于支持增量/并发标记的状态跟踪机制。
- 白色: 尚未被 GC 访问(初始状态或确定为垃圾)。
- 灰色: 已被 GC 访问,但其引用的对象还未被完全检查(在待处理队列中)。
- 黑色: 已被 GC 访问,且其引用的所有对象也都已被检查过(安全对象)。
- GC 从灰色对象开始工作(广度优先遍历),目标是所有对象最终非黑即白。白色对象即可回收。
-
三、 常见内存泄漏场景(面试重点!)
即使有 GC,代码不当仍会导致内存泄漏(对象不再使用但意外地保持可达):
-
意外的全局变量: 未使用
var
/let
/const
声明的变量会成为window
的属性。javascript
inifunction leak() { oops = 'I am global!'; // 泄漏到 window this.accidental = 'Me too!'; // 在非严格模式且 leak 被当作函数调用时,this 指向 window }
-
遗忘的定时器 (Timers) / 回调 (Callbacks):
javascript
inilet bigData = getHugeData(); setInterval(() => { const node = document.getElementById('node'); if (node) { node.innerHTML = JSON.stringify(bigData); // bigData 被 interval 回调闭包引用,即使节点移除也不会释放 } }, 1000); // 忘记 clearInterval 时,bigData 和关联的闭包作用域会一直存在
-
游离的 DOM 引用: 在 JS 中保留了 DOM 元素的引用,即使该元素已从 DOM 树中移除。
javascript
javascriptlet elements = { button: document.getElementById('myButton'), image: document.getElementById('myImage') }; function removeButton() { document.body.removeChild(elements.button); // 从 DOM 移除按钮 // elements.button 仍然存在引用!无法被 GC }
-
闭包: 过度或不慎使用闭包可能导致外部函数的变量被长期持有。
javascript
javascriptfunction outer() { let hugeArray = new Array(1000000).fill('*'); return function inner() { console.log('Hello, but I hold a reference to hugeArray!'); }; } const holdHuge = outer(); // inner 函数闭包引用了 hugeArray,即使 outer 执行完毕 // 除非 holdHuge 被释放,否则 hugeArray 不会被回收
-
未清理的事件监听器: 在不需要时未移除事件监听器,特别是对已移除的 DOM 元素或即将销毁的组件。
javascript
javascriptfunction setupListener() { const element = document.getElementById('clickMe'); element.addEventListener('click', onClick); // 添加监听器 } function tearDown() { // 忘记移除监听器!即使 element 被移除,onClick 和它引用的任何变量都会被保留 // document.body.removeChild(document.getElementById('clickMe')); }
四、 如何避免内存泄漏 & 性能优化
-
避免常见陷阱: 注意上述泄漏场景(全局变量、定时器、DOM 引用、闭包、事件监听器)。
-
使用弱引用 (Weak References): 当主引用不需要强持有对象时使用:
WeakMap
/WeakSet
: 键是弱引用(不影响 GC),非常适合存储与 DOM 节点或对象关联的元数据。当键对象被回收时,对应的值也会被自动清除。WeakRef
(ES2021) /FinalizationRegistry
(ES2021): 提供更底层的弱引用和对象被回收后的回调机制(慎用)。
-
及时清理:
- 使用
clearInterval()
/clearTimeout()
清除不需要的定时器。 - 使用
removeEventListener()
移除不需要的事件监听器。 - 在单页应用(SPA)组件销毁(如 React
componentWillUnmount
, VuebeforeDestroy
)时,主动清理定时器、事件监听器、取消网络请求、释放大对象引用(设置为null
)。
- 使用
-
合理使用闭包: 明确闭包持有引用的对象及其生命周期。
-
利用开发者工具:
-
Chrome DevTools - Memory 面板:
- Heap Snapshot: 查看某个时刻堆内存中所有对象的分布和大小,定位大对象和泄漏对象。
- Allocation instrumentation on timeline: 实时追踪对象的分配和保留情况,直观发现未被回收的对象。
- Allocation sampling: 采样模式,性能开销小,用于发现分配热点。
-
Performance Monitor: 监控 JS Heap Size、DOM Nodes 等指标随时间的变化,观察是否持续增长。
-
五、 面试回答要点总结
- 核心原理: 强调 可达性 是判断垃圾的唯一标准。根对象 -> 引用链。
- 主流算法: 明确现代浏览器主要使用 标记-清除 作为基础,配合 分代收集 进行优化。
- V8 优化: 重点阐述 新生代 (Scavenge) 和 老生代 (标记-清除/整理 + 增量/并行/并发标记) 的区别和优化点(解决碎片、减少卡顿)。
- 内存泄漏: 必须能列举至少 3 种常见泄漏场景(全局变量、定时器回调、游离 DOM 引用)并解释原因。 这是面试官最常问的!
- 避免措施: 强调及时清理(定时器、事件监听器)、善用弱引用(
WeakMap
)、利用开发者工具定位。 - 性能意识: 知道 GC 可能导致卡顿(Stop-The-World),优化策略(增量/并发)就是为了减少这种影响。
示例回答 (面试时简化版):
"浏览器垃圾回收的核心目标是自动回收不再使用的内存。它的基本依据是可达性 :从根对象(如全局对象、当前执行栈的变量)出发,沿着引用链能访问到的对象是存活的,否则就是垃圾。主流算法是标记-清除:先标记所有可达对象,然后清除未被标记的垃圾。
现代浏览器(如 Chrome 的 V8 引擎)采用分代收集 优化。内存分为新生代 和老生代 。新生代存放新对象,使用快速的 Scavenge 算法 (复制,无碎片),回收频繁。存活下来的对象会晋升 到老生代。老生代存放长期存活对象,使用标记-清除 或标记-整理 (解决碎片)算法,回收较慢。为了减少老生代回收导致的页面卡顿,V8 采用了增量标记 (把标记过程拆分成小块与 JS 交替执行)和并行标记(用多个线程并行标记)。
即使有 GC,代码不当仍会导致内存泄漏 ,常见的有:1)意外的全局变量 ;2)忘记清除的定时器或回调 (其闭包引用的变量无法释放);3)游离的 DOM 引用 (JS 保留了已移除 DOM 的引用);4)未移除的事件监听器 。避免方法包括:避免全局变量、及时
clearInterval/removeEventListener
、在组件销毁时清理资源、合理使用闭包,以及利用WeakMap
存储与 DOM 关联的元数据。开发者可以用 Chrome DevTools 的 Memory 面板(Heap Snapshots, Allocation Timeline)来检测内存泄漏和性能问题。"