一、垃圾回收机制的基本概念
- 为什么需要垃圾回收? 在程序运行过程中,计算机需要为对象分配内存。当这些对象不再被使用时,其占用的内存应该被释放,否则会导致内存泄漏,进而影响系统性能甚至引发程序崩溃。如果完全依赖开发者手动释放内存,容易出现以下问题:
-
- 忘记释放内存,导致内存泄漏。
- 误释放仍在使用的内存,导致悬挂指针或程序崩溃。
垃圾回收机制的作用:通过自动检测哪些内存对象已不再被程序使用,并将它们回收,避免上述问题。
- GC 的核心任务:
-
- 检测垃圾对象:判断哪些对象已经不再被使用。
- 释放内存:回收垃圾对象占用的内存,使其可以被重新分配。
- GC 的运行方式:
-
- 垃圾回收通常在程序运行过程中由垃圾回收器(Garbage Collector)自动执行。
二、垃圾回收的常见算法
为了高效完成内存回收,垃圾回收器设计了多种算法,主要包括以下几种:
- 引用计数算法:
-
- 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1。
- 如果同一个值又被赋给另一个变量,那么引用数加 1。
- 如果该变量的值被其他的值覆盖了,则引用次数减 1。
- 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存
优点:引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾。
缺点:计数器占很大的位置,无法解决循环引用无法回收的问题。
- 标记清除算法:
-
- 对于每个被访问到的对象,垃圾回收器会给它打上标记,表示该对象是可达的,即不是垃圾。这个过程确保了所有可达对象都会被标记。
- 垃圾回收器会遍历整个内存,对于没有标记的对象,即被判定为垃圾的对象,会被立即回收,释放内存空间。这样,只有被标记的对象会被保留在内存中,而垃圾对象会被清除。
优点:标记-清除算法相对简单,容易实现。它可以准确地找到不再被引用的对象,并回收内存。
缺点:标记-清除算法会暂停程序的执行,进行垃圾回收操作,标记-清除算法会在回收过程中产生大量的不连续的、碎片化的内存空间。
- 分代回收算法(Generational Garbage Collection):
-
- 基于“对象生命周期假设”:大多数对象的生命周期很短,只有少数对象会长期存活。
- 将内存分为新生代(短生命周期对象)和老生代(长生命周期对象),分别使用不同的算法回收。
优点:提升了垃圾回收效率,是现代垃圾回收器的核心策略。
三、垃圾回收机制的优缺点
优点:
- 简化内存管理:开发者无需手动释放内存,大大降低了编程复杂度。
- 减少错误:有效避免内存泄漏、悬挂指针等常见内存问题。
- 提高程序稳定性:通过自动管理内存,提升了程序运行的安全性。
缺点:
- 性能开销:垃圾回收器需要消耗一定的计算资源,会导致程序性能下降。
- 不可预测性:垃圾回收的触发时间和执行过程通常是不可预测的,可能导致应用程序暂停(Stop-The-World)。
- 复杂性:优化垃圾回收器需要权衡内存使用效率、回收时间和暂停时间等多个因素,增加了实现难度。
四、垃圾是怎样产生的?
1. 局部变量超出作用域
当函数执行完毕后,函数内部定义的局部变量会超出作用域。如果这些变量不再被其他地方引用,它们所占用的内存会被视为垃圾。
- 示例:
function createData() {
let data = { key: "value" }; // data 是一个局部变量
}
createData();
// 当函数执行结束后,data 超出作用域且没有被外部引用,其内存会被回收
- 垃圾产生:当
createData
执行结束时,局部变量data
被销毁,内存中的{ key: "value" }
对象如果没有其他引用,将被回收。
2. 变量被重新赋值
当变量被重新赋值时,旧的引用会被覆盖。如果旧对象没有其他引用,则它成为垃圾。
- 示例:
let A = { name: "jerry" }; // obj 指向一个对象
A = [a,b,c,d]; // 原对象 { name: "jerry" } 没有引用,成为垃圾
- 垃圾产生:
a
指向新的对象[a,b,c,d]
,原来的{ name: "jerry" }
不再被引用,因此会被回收。
3. DOM 元素被移除或替换
当 DOM 元素被移除或替换时,旧的 DOM 节点如果没有其他引用,也会变成垃圾。
- 示例:
let element = document.getElementById("myDiv");
document.body.removeChild(element); // 删除了 DOM 节点
如果 element
没有被其他变量引用,则相关内存会被释放。
- 垃圾产生:
element
对应的 DOM 节点已从文档中移除,且无其他引用,将成为垃圾。
4. 闭包导致的未被释放的内存
如果闭包中引用了不需要的变量,且未及时解除引用,则这些变量可能无法被回收。
- 示例:
function outerFunction() {
let largeData = new Array(100000).fill("data");
return function innerFunction() {
console.log("Using closure");
};
}
let closure = outerFunction();
-
- 垃圾未释放:
largeData
被innerFunction
间接引用,导致无法被回收,即使它已无用。 - 解决方法:显式将无用变量设为
null
,如largeData = null;
。
- 垃圾未释放:
5. 循环引用
前端开发中,循环引用是产生垃圾的常见原因,尤其在对象或 DOM 节点之间。
- 示例:
let obj1 = {};
let obj2 = {};
obj1.ref = obj2;
obj2.ref = obj1; // 两个对象互相引用
obj1 = null;
obj2 = null; // 互相引用的对象没有外部引用,变为垃圾
- 垃圾产生:
obj1
和obj2
互相引用,形成循环引用,但没有其他外部引用它们,因此垃圾回收器会回收它们。
6. 事件监听未正确清除
当事件监听器绑定在某个 DOM 节点上,而节点被删除时,如果监听器没有被移除,则会导致内存泄漏和垃圾堆积。
- 示例:
let button = document.getElementById("myButton");
button.addEventListener("click", function handleClick() {
console.log("Button clicked");
});
document.body.removeChild(button); // 移除 button,但事件监听器仍然存在
- 垃圾未释放:虽然
button
已被移除,但其事件监听器仍然存在,导致按钮占用的内存无法被回收。 - 解决方法:在移除 DOM 元素前,手动移除监听器,如
button.removeEventListener("click", handleClick);
。
五、为什么要进行垃圾回收?
1. 限制内存占用,避免内存泄漏
- 原因:前端代码运行在浏览器中,而浏览器的内存资源是有限的。如果程序不断占用内存而不释放,会导致内存泄漏,最终可能导致性能下降,甚至浏览器崩溃。
- 垃圾回收作用:通过自动检测不再使用的对象,将其占用的内存释放,从而防止内存占用持续增长。
- 示例:
javascript
复制代码
function createData() {
let largeArray = new Array(100000).fill("data"); // 创建一个大数组
return largeArray;
}
createData();
// 如果不需要该数组,垃圾回收器会回收其内存,避免占用资源
2. 简化内存管理,减少开发难度
- 原因:手动管理内存(如手动分配和释放内存)会增加代码复杂性,并容易导致内存泄漏或悬挂指针等问题。尤其在复杂的单页应用(SPA)中,手动追踪和释放所有不需要的资源几乎是不现实的。
- 垃圾回收作用:通过自动检测哪些对象不再被使用,开发者无需显式释放内存,降低了编程难度,提升代码的可维护性。
- 对比:
-
- 手动内存管理(如 C 语言):开发者需要明确分配和释放内存,容易导致问题。
- 自动垃圾回收(如 JavaScript):垃圾回收器会根据对象的可达性,自动释放内存,简化了工作。
3. 提高程序性能,保证用户体验
- 原因:如果没有垃圾回收,内存的长期占用会导致程序性能逐渐下降。例如,在处理大量 DOM 操作、动态加载数据或频繁执行事件监听时,未释放的内存会拖慢页面渲染速度,甚至导致浏览器变卡或无响应。
- 垃圾回收作用:通过释放不再使用的内存,确保程序性能维持在较高水平,提升用户体验。
六、垃圾回收是怎样进行的?
1. 标记-清除算法(Mark-and-Sweep)
这是 JavaScript 中最基础、最常用的垃圾回收算法。
- 步骤:
-
- 标记阶段:从根对象出发,遍历所有可达对象,并做标记。
- 清除阶段:未被标记的对象会被认为是不可达的,回收其内存。
- 示例:
let obj1 = { name: "Alice" };
let obj2 = { name: "Bob" };
obj1.friend = obj2; // obj1 引用了 obj2
obj2 = null; // obj2 被置为 null,但 obj1 仍引用它,所以不会被回收
obj1 = null; // 此时 obj1 和 obj2 都不可达,将被垃圾回收
- 优点:简单高效,适合大多数场景。
- 缺点:可能会在标记阶段暂停程序执行,影响性能,其次,清除之内剩余的内存位置不会变,导航空闲内存不连续,出现了内存碎片,如图,然后内存不是一整块,会导致内存分配问题。
2. 引用计数(Reference Counting)
这种算法通过统计对象被引用的次数来判断对象是否可以回收。
- 原理:
-
- 每个对象有一个引用计数器,记录被引用的次数。
- 引用计数为 0 的对象会被认为是垃圾,释放内存。
- 当对象的引用增加或减少时,更新计数。
- 示例:
let obj1 = { name: "Alice" };
let obj2 = obj1; // obj1 的引用计数为 2
obj1 = null; // obj1 的引用计数为 1
obj2 = null; // obj1 的引用计数为 0,对象被回收
- 缺点:无法处理循环引用。
let obj1 = {};
let obj2 = {};
obj1.ref = obj2;
obj2.ref = obj1;
obj1 = null;
obj2 = null; // 虽然两者都不可达,但互相引用,导致无法被回收
3. 分代回收(Generational Garbage Collection)
现代 JavaScript 引擎(如 V8 引擎)结合了标记-清除和分代回收来提升效率。
- 核心思想:根据对象的生命周期将其划分为不同的代(generation),分别处理:
-
- 新生代(Young Generation):生命周期较短的对象,如局部变量、临时对象。
- 老生代(Old Generation):生命周期较长的对象,如全局变量、闭包引用的变量。
- 工作流程:
-
- 新生代垃圾回收:采用快速、轻量的算法,如复制算法(Copying Collection)。将仍然存活的新生代对象复制到另一块内存空间,不再存活的对象被释放。
- 老生代垃圾回收:采用标记-清除算法,对老生代对象进行垃圾回收。
- 新生代对象在多次回收后仍存活,会被移动到老生代。
- 优点:新生代和老生代分开处理,减少了垃圾回收的开销。
4. 增量回收(Incremental GC)
增量回收将垃圾回收过程分解为多个小阶段,交替进行垃圾回收和程序执行,避免长时间暂停程序。
- 优点:提高响应速度,减少程序卡顿。
- 缺点:实现复杂,可能引入额外的内存开销。
七、V8 引擎对垃圾回收进行了哪些优化?
1. 分代式垃圾回收机制(Generational Garbage Collection)
V8 引擎使用分代垃圾回收策略,将内存分为两部分:
- 新生代(Young Generation):存储生命周期较短的对象(如临时变量、局部变量)。
- 老生代(Old Generation):存储生命周期较长的对象(如全局变量、闭包变量)。
这种机制通过对不同生命周期的对象采取不同的回收策略,显著提高了垃圾回收的效率。
工作原理:
- 新生代垃圾回收:
-
- 新生代使用复制算法(Scavenge)进行回收。
- 内存分为两个空间:From 空间和 To 空间。
-
-
- 活跃的对象存储在
From
空间中。 - 当进行垃圾回收时,活跃对象会被复制到
To
空间,未使用的对象直接被释放。 - 之后,
From
和To
空间角色互换。
- 活跃的对象存储在
-
-
- 这种方法高效处理了生命周期较短的对象,减少了遍历所有内存的开销。
- 老生代垃圾回收:
-
- 老生代对象通常生命周期较长,垃圾回收的频率较低。
- 采用 标记-清除(Mark-and-Sweep) 和 标记-整理(Mark-and-Compact) 算法。
-
-
- 标记-清除:标记可达对象,清除不可达对象。
- 标记-整理:将存活对象压缩到内存的一端,避免内存碎片化。
-
2. 增量式垃圾回收(Incremental Garbage Collection)
在传统的垃圾回收中,标记和清除步骤可能会暂停 JavaScript 的执行(称为“停止世界”)。为减少回收带来的性能停顿,V8 引入了增量式垃圾回收。
工作原理:
- 将标记和清除操作分解为多个小任务,分阶段完成。
- 垃圾回收器和程序的主线程交替运行,避免长时间暂停 JavaScript 代码执行。
- 这种方式大幅提升了应用的响应性,减少了用户感知到的卡顿。
3. 并发垃圾回收(Concurrent Garbage Collection)
为进一步减少垃圾回收对主线程的影响,V8 引擎实现了并发垃圾回收,允许垃圾回收器在后台线程中并发执行某些回收任务。
特点:
- 标记阶段的工作可以在单独的线程中完成,不会阻塞主线程。
- JavaScript 程序的执行和垃圾回收可以部分并行进行,从而降低垃圾回收对性能的影响。
4. 延迟垃圾回收(Lazy Garbage Collection)
为优化性能,V8 会推迟某些垃圾回收任务,优先完成关键的程序逻辑。
策略:
- 如果系统资源允许,垃圾回收任务会被推迟,直到内存压力较大时才执行完全回收。
- 通过延迟非关键回收任务,提升程序的执行效率。
5. 快速垃圾回收策略
处理新生代的快速回收:
- 新生代对象的回收采用 Scavenge 算法,专注于小对象的快速回收。
- 新生代垃圾回收的时间复杂度较低,通常只需要几毫秒即可完成。
优化短生命周期对象:
- JavaScript 中临时对象通常生命周期很短(例如,函数内部的局部变量)。
- V8 针对这些对象进行了特殊优化,通过更快的回收方式(如直接释放或迁移到老生代)提高垃圾回收效率。
6. 内存压缩(Compaction)
在老生代垃圾回收中,V8 使用 标记-整理(Mark-and-Compact) 技术来压缩内存,将存活对象集中存储,释放连续的可用空间,避免内存碎片化。
优势:
- 减少了内存分配失败的风险。
- 提高了后续对象分配的效率。
7. 异步垃圾回收和 Idle Time GC
异步垃圾回收:
- V8 会在浏览器空闲时间(Idle Time)中执行垃圾回收任务。
- 通过在程序执行的间隙完成垃圾回收,最大限度减少对主线程的阻塞。
Idle Time GC 的原理:
- 如果浏览器当前没有用户操作(如滚动、点击),垃圾回收器会利用这些时间段执行回收任务。
- 这种方式特别适合长时间运行的前端应用(如 SPA 应用)。
8. 异常处理与弱引用优化
弱引用处理:
- V8 支持 WeakMap 和 WeakSet,弱引用对象不会影响垃圾回收器判断其是否可达。
- 垃圾回收器会自动回收不再使用的弱引用对象。
优化场景:
- 弱引用常用于缓存管理、DOM 节点管理等场景,避免不必要的内存占用。
9. 结合机器学习的垃圾回收调优
V8 团队利用机器学习对垃圾回收行为进行优化:
- 分析应用的运行特性(如内存分配模式、对象生命周期)。
- 动态调整垃圾回收参数(如触发时间、回收策略)以适应不同的应用场景。
10. 可回收内存阈值的动态调整
V8 引擎会根据系统的可用内存动态调整垃圾回收的阈值:
- 如果可用内存充足,垃圾回收会减少回收频率,以提升性能。
- 如果内存压力较大,垃圾回收器会更频繁地运行,以释放更多的内存。
八、总结
垃圾回收器会根据需要周期性地运行,自动进行垃圾回收,释放不再使用的内存空间。
需要注意的是,垃圾回收器并非实时回收垃圾,而是在特定的时机触发回收操作。这是因为垃圾回收过程会占用一定的计算资源,如果频繁触发垃圾回收,可能会导致程序性能下降。因此,垃圾回收的时机通常由浏览器或 JS 引擎决定,以平衡性能和内存占用。