JavaScript中的垃圾回收机制
JavaScript中的垃圾回收机制是如何工作的?如何避免内存泄漏?有哪些最佳实践?
考察点分析
本题主要考察以下维度:
- 内存管理的基本概念:垃圾回收的定义、必要性及基本原理
- 内存分配和回收机制:标记清除、引用计数等算法实现
- 识别潜在的内存问题以及解决方案:内存泄漏的常见场景和预防措施
具体评估点:
- 垃圾回收算法的工作原理
- 内存泄漏的识别与处理
- 代码优化与内存管理最佳实践
- 浏览器内存管理机制的理解
- 开发中的内存优化策略
技术解析
关键知识点
垃圾回收基本原理
- 自动内存管理:JavaScript 引擎自动执行内存分配与回收
- 可达性分析:从根对象出发确定对象是否可访问
- 回收时机:由垃圾回收器自行决定,不可手动触发
主要回收算法
- 标记清除(Mark-and-Sweep):现代浏览器主要使用
- 引用计数(Reference Counting):早期实现方式
- 分代回收(Generational Collection):V8 引擎采用
内存泄漏场景
- 全局变量:意外创建的全局变量
- 闭包:未正确处理的闭包引用
- 事件监听器:未及时移除的事件绑定
- 定时器:未清理的定时器
- DOM 引用:游离的 DOM 节点引用
原理剖析
1. 垃圾回收算法详解
标记清除(Mark-and-Sweep)
当函数进入执行上下文,里面的变量对象会变成活动对象,此时会被加上使用中的标记,当函数执行完成,活动对象变成非活动对象,此时标记变则取消标记,当 GC 开始执行时,不被标记变量就会被清除掉,占用的内存也会释放。
引用计数(Reference Counting)
function fun() { let arr = [1, 2, 3] const arr1 = arr arr = null console.log(arr) // null console.log(arr1) // [1,2,3] } fun()
当
fun
函数执行时,先给arr
赋值,arr
存的是数组的地址,此时数组被引用的次数为1
,接着arr
赋值给arr1
,arr1
存的不是arr
的值,而是数组的引用,此时数组的被引用次数为2
,然后arr
再赋值null
。此时虽然 arr 的值被设置为null
了,但是数组还是存在的,只是它的引用次数减为了1
,所以arr1
此时的值还是[1,2,3]。也正是如此,由于arr1
还在使用这个数组,此时,所以它就不能被回收。要让它被回收只有两种方式:
fun()
函数执行完,由于数组是创建在该函数下面的,函数执行完成弹栈之后,arr1
被清除,那数组占用的内存自然也被回收了。arr1
也跟着设置为null
这样一来数组的引用就变成了0
,此时虽然当fun
函数还在,但是数组也会被回收。
分代回收(Generational)
v8 引擎吸取了上面几种策略算法的经验,将他们垃圾回收的思路结合起来,将要处理的内存垃圾分成新生代和老生代,他们使用了各自不同的策略,使得效率和内存的消耗达到一个相对平衡。
新生代(Scavenge)算法 由于在 js 中我们大部分变量是定义在函数中的,而这些函数在使用之后就直接弹栈了,由于他们来去匆匆,占用的内存也不算太多,所以它们需要高效率的内存回收算法而且还要保证内存的连续性,让新的变量有充足的内存放置。 从这里我们第一时间想到的就是 copying 算法,是的 Scavenge 算法用的也正是这个思想使得这部分的垃圾回收变得效率且规整。
老生代(Mark-Sweep 和 Mark-Compact)标记清除和标记整理算法 在内存清理中总会存在某些“钉子户”,它们可能是全局变量,可能是生命周期较长的变量,总之每次清理内存都有它们,但是它们又一直被使用,无法回收,这样在进行回收内存时也会造成不必要的损耗。 V8 引擎在新生代进行内存回收时会标记它们,如果发现它们两次出现,并且没有被回收,或者它在新生代中占了回收前内存的 25%,那么 V8 引擎就会将它们晋升到老生代。 由于老生代中的变量不会频繁创建和清除,因此使用标记清除算法相对来说是更加经济实惠的,但是由于标记清除算法会造成内存碎片过多从而出现内存的浪费,因此又用上了标记整理,这样在老生代内存被清除之后会把内存碎片给整理好,从而避免了浪费。
增量标记(Incremental Marking) 按理说 V8 引擎的回收算法到这里就结束了,但是我们知道,老生代中的变量要么是比较老的,要么是比较大的,而且还可能会随着时间推移越来越多,这样由于在执行 GC 会停止整个应用程序,这样一旦清理的内存比较多,那清理的时间会相对应延长,这样的结果就是用户体验特差。 所以避免这种情况出现,V8 引擎运用了增量标记的方式,将要这一次性回收内存分成了很多份,然后分次回收,这样卡顿不会那么严重,用户体验自然就会大大提升。
常见误区
- 认为手动将变量设为 null 或 undefined 后,对象会立刻被回收。
事实:仅解除引用,垃圾回收器会在合适时机进行回收。
- 闭包一定会导致内存泄漏。
事实:闭包本身不会导致内存泄漏,只有当闭包中引用的变量被长期持有且未正确释放时,才可能引发泄漏。
- 认为循环引用一定会导致内存泄漏。
事实:现代垃圾回收器(如 V8 引擎的标记-清除算法)可以处理大多数循环引用。但在涉及 DOM 或旧版 IE 时,循环引用仍可能导致泄漏。
- 认为所有全局变量都会导致内存泄漏。
事实:只有那些长期持有大量数据且未释放的全局变量才可能导致泄漏。合理使用全局变量并不会直接引发泄漏。
- 认为移除 DOM 节点后,内存会立刻释放。
事实:如果 JavaScript 中仍持有对 DOM 节点的引用,内存不会被释放,必须手动解除引用。
- 认为只有复杂应用才会发生内存泄漏。
事实:即使是简单应用,如果未正确管理引用(如未清理事件监听器或缓存),也可能发生泄漏。
- 认为所有 JavaScript 引擎的垃圾回收机制完全相同。
事实:不同引擎(如 V8、SpiderMonkey、JavaScriptCore)的实现可能有所不同,尤其是在处理边缘情况时。
问题解答
垃圾回收的对比总结:
机制 | 核心思想 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
标记清除 | 标记可达对象,清除不可达对象 | 能处理循环引用,通用性强 | 回收时暂停程序,可能影响性能 | 大多数场景 |
引用计数 | 记录引用次数,次数为 0 时回收 | 实时性高 | 无法处理循环引用,维护开销大 | 已逐渐被淘汰 |
分代回收 | 根据对象存活时间分代,分别回收 | 高效,减少对长生命周期对象的频繁扫描 | 实现复杂 | 现代 JavaScript 引擎 |
解决方案
如何避免内存泄漏
及时解除引用 不再使用的对象应手动解除引用(如设为 null 或 undefined)。
示例:
let largeObject = new Array(1000000).fill("data"); // 使用完后解除引用 largeObject = null;
移除事件监听器 在 DOM 元素移除或组件销毁时,移除绑定的事件监听器。
示例:
function addListener() { const button = document.getElementById("myButton"); button.addEventListener("click", onClick); } function removeListener() { const button = document.getElementById("myButton"); button.removeEventListener("click", onClick); }
避免意外的全局变量 未声明的变量会变成全局变量,可能导致内存泄漏。
示例:
function foo() { bar = "意外全局变量"; // 未使用 let/const/var }
使用 WeakMap 和 WeakSet WeakMap 和 WeakSet 存储弱引用,不会阻止垃圾回收。
示例:
let obj = { name: "Alice" }; const weakMap = new WeakMap(); weakMap.set(obj, "data"); obj = null; // obj 可以被回收
清理定时器和回调 使用 setTimeout 或 setInterval 后,确保在不需要时清除。
示例:
const timer = setTimeout(() => { console.log("定时任务"); }, 1000); // 清除定时器 clearTimeout(timer);
避免循环引用 虽然现代垃圾回收器能处理大多数循环引用,但在涉及 DOM 或旧版浏览器时仍需注意。
示例:
let element = document.getElementById("myElement"); element.someData = { element }; // 循环引用 element = null; // 手动解除引用
深度追问
- V8引擎中新生代对象晋升到老生代的条件是什么? V8 引擎中新生代对象晋升(Promotion)到老生代的条件主要有以下几种情况:
- 存活时间阈值
- 经过两次 Scavenge 算法回收依然存活的对象
- 这类对象被认为是生命周期较长的对象,需要移至老生代
- 空间占用阈值
- 当一个对象在新生代中占用空间超过 25% 时
- 直接晋升到老生代,避免新生代空间的过度占用
- 对象大小阈值
- 当一个对象的大小超过新生代空间的 25%
- 直接在老生代分配,不会在新生代分配
- 动态晋升策略
- V8 会动态调整晋升策略
- 根据当前内存使用情况和 GC 频率来调整晋升条件
- 如何优化内存使用?
- 数据结构优化(如:按需加载数据)
- 使用对象池模式
- 及时清理不用的引用
- 使用合适的数据结构
- 分批处理大数据
- 使用 WeakMap/WeakSet 存储对象引用
- 避免内存泄漏的闭包使用
- 使用流式处理大文件
- 使用虚拟列表优化长列表
- 使用防抖和节流控制频繁操作
Last updated 06 Mar 2025, 13:07 +0800 .