1.垃圾回收的概念
1.1 什么是垃圾回收机制:
GC
即 Garbage Collection
,程序工作过程中会产生很多"垃圾",这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC
就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说,GC
过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的 垃圾回收机制
不是所有语言都有 GC
,一般的高级语言里面会自带 GC
,比如 Java、Python、JavaScript
等,也有无 GC
的语言,比如 C、C++
等,那这种就需要我们程序员手动管理内存了,相对比较麻烦
在像 C/C++ 这样的语言中,开发者需要手动分配(malloc
)和释放(free
)内存。这种方式非常灵活,性能也高,但有两个致命缺点:
- 忘记释放:会导致内存泄漏,程序占用的内存会随着时间推移越来越多,最终可能导致程序崩溃或系统变慢。
- 提前释放:或释放了多次,会导致悬挂指针,当程序尝试访问一个已经被释放的内存地址时,会引发不可预知的错误(通常是程序崩溃)。
JavaScript中存在两种变量:局部变量
和全局变量
。全局变量的生命周期会持续到页面卸载;而局部变量声明在函数中,它的生命周期从函数执行开始,直到函数执行结束,在这个过程中,局部变量会在堆或栈中存储它们的值,当函数执行结束后,这些局部变量不再被使用,它们所占有的空间就会被释放。(不过,当局部变量被外部函数使用时,其中一种情况就是闭包
,在函数执行结束后,函数外部的变量依然指向函数内部的局部变量,此时局部变量依然在被使用,所以不会回收。)
2.垃圾回收机制是如何实现的
2.1核心理念:
GC 机制的核心思想是可达性
。简单来说,就是判断一个对象是否“可达”,如果不可达,它就是“垃圾”。
- 可达的对象:以某种方式被访问或使用的对象。
- 不可达的对象:无法被访问到的对象,可以被安全地回收。
GC 会有一系列的根对象,它们是可达性的起点。在 JavaScript 中,主要的根包括:
全局对象
:比如浏览器环境下的 window
对象,Node.js 环境下的 global
对象。
因为全局对象在应用程序的整个生命周期内都存在。只要你的网页开着,window
对象就永远不会消失。它是所有全局变量和内置API(如 setTimeout
, localStorage
)的宿主。如果它被回收了,整个JavaScript环境就都将崩塌。因此,全局对象是GC最重要、最基础的一个“根”。
函数调用栈
:当前正在执行的函数中的局部变量和参数。
因为当前正在执行的代码和它所依赖的数据,理所当然是“存活”的。调用栈代表了程序执行的“此时此刻”。如果这些正在使用的变量被回收了,程序将立即出错。因此,调用栈中所有栈帧里的变量和参数,都被视为临时的“根”。
活跃的 DOM 树
:页面上存在的 DOM 元素。
因为DOM节点是构成用户界面的实体。用户能看到、能与之交互的元素,必须始终存在于内存中,浏览器需要依据它来进行绘制和响应事件。因此,所有在DOM树上的节点都被认为是“可达的”。
垃圾回收器会从这些“根”
出发,沿着引用链进行遍历。所有能从“根”访问到的对象,都会被认为是“活”的(可达的);反之,所有无法从“根”访问到的对象,就会被认为是“死”的(不可达的),并成为垃圾回收的目标。
// 创建一个根对象,并被变量 user 引用
let user = {
name: "Alice"
};
// 原来的 { name: "Alice" } 对象失去了引用,因此它变成了不可达对象,等待被回收。
user = null;
2.2主流垃圾回收算法
浏览器通常使用的垃圾回收方法有两种:标记清除
,引用计数
。
2.2.1标记清除
这是现代浏览器中最常用的垃圾回收算法。它完美地解决了循环引用
的问题。
- 原理:分为两个阶段:
- 标记阶段:垃圾回收器从
“根”对象
开始,遍历所有可达的对象,并在这些对象上打上一个“标记”,表示它们是存活的。 - 清除阶段:垃圾回收器遍历整个
堆内存
,所有没有被标记的对象都被视为垃圾,并被回收,其占用的内存被释放。
- 标记阶段:垃圾回收器从
- 优点:可以解决循环引用的问题。因为即使
objA
和objB
互相引用,但如果它们都无法从“根”访问到,那么它们就都不会被标记,最终会被一起清除。
function createCircularReference() {
let objA = {};
let objB = {};
objA.b = objB;
objB.a = objA;
}
createCircularReference();
- 缺点:
- 执行效率问题:GC 执行时,需要
暂停整个程序
的运行,如果堆内存很大,标记和清除会很耗时。 - 内存碎片化:清除后,会产生大量不连续的内存碎片。如果之后需要分配一个大对象,可能会因为没有足够大的
连续空间
而失败。
- 执行效率问题:GC 执行时,需要
2.2.2引用计数
这是早期的一种 GC 算法,思想非常简单。
- 原理:为每个对象维护一个
“引用计数器”
。当有一个引用指向该对象时,计数器加1;当引用被移除时,计数器减1。当计数器变为0时,表示该对象不再被需要,可以被回收。 - 优点:实现简单,垃圾可以被
立即回收
,不会有“暂停”的感觉。 - 致命缺点:无法处理
循环引用
。
看下面的例子:
这种情况下,就要手动释放变量占用的内存:
obj1.a = null
obj2.a = null
2.3 V8 引擎的优化:分代回收
为了解决标记-清除算法的效率问题,Google 的 V8 引擎(用于 Chrome 和 Node.js)采用了一种更先进的策略:分代回收
。
这个策略基于一个重要的观察:“大部分对象都是朝生夕死的”。也就是说,很多对象在创建后很快就不再被使用,而少数对象会存活很长时间。
V8 将堆内存分为两个主要区域:
新生代:Scavenge
算法
- 特点:存放生命周期短的对象,空间较小(通常为 1-8MB),垃圾回收频繁且
速度快
。 - 内部结构:新生代内存被平分为两个相等的空间:
From 空间(使用中)
和To 空间(空闲)
。 - **回收过程:
- 新对象首先被分配在 From 空间。
- 当 From 空间快要被占满时,触发一次新生代的 GC。
- GC 会检查 From 空间中的存活对象,并将它们复制到 To 空间。
- 复制完成后,From 空间剩下的所有对象都是垃圾。整个 From 空间被一次性清空。
- From 空间和 To 空间的角色互换,等待下一次 GC。
- 晋升:如果一个对象在新生代中经过了多次 Scavenge 依然存活,那么它被认为是生命周期较长的对象,会被“晋升”到老生代中。此外,如果复制一个对象到 To 空间时,To 空间的使用率超过了25%,该对象也会被直接晋升到老生代。
老生代:标记-清除与 标记-整理 - 特点:存放生命周期长或体积大的对象,空间较大,GC
频率较低
。 - 回收过程:
- 主要使用标记-清除算法,流程如前所述。
- 为了解决内存碎片化问题,V8 引入了标记-整理算法。它在标记阶段之后,不是直接清除垃圾,而是将所有存活的对象向内存的一端移动,然后直接清理掉边界之外的所有内存。这样就得到了连续的空闲空间。
3.减少垃圾回收
3.1 手动处理:
虽然浏览器可以进行垃圾自动回收,但是当代码比较复杂时,垃圾回收所带来的代价比较大,所以应该尽量减少垃圾回收。
- 对
数组
进行优化: 在清空一个数组时,最简单的方法就是给其赋值为[ ],但是与此同时会创建一个新的空对象,可以将数组的长度设置为0,以此来达到清空数组的目的。 - 对
对象
进行优化: 对象尽量复用,对于不再使用的对象,就将其设置为null,尽快被回收。
避免意外的全局变量:
始终使用 const
或 let
声明变量,开启严格模式('use strict';
)。
function leakyFunction() {
// 如果没有 'let' 或 'const', a 会被创建为全局变量
// 它将永远不会被回收,除非手动设为 null
a = new BigObject();
}
警惕闭包:
闭包
是 JavaScript 的强大特性,但也很容易造成内存泄漏。闭包可以使其父函数中的变量在函数执行结束后仍然存活。
function createClosure() {
let largeData = new Array(1e6).fill('*');
// 这个返回的函数持有了对 largeData 的引用
return function() {
...
return largeData.length;
};
}
let myClosure = createClosure();
// 即使 createClosure 执行完毕,largeData 也不会被回收,因为它被 myClosure 引用。
// 如果不再需要它,应手动解除引用。
myClosure = null;
定时器和事件监听器:
setInterval
, setTimeout
和 addEventListener
如果不被正确清理,它们的回调函数和其引用的外部变量都不会被回收。
let element = document.getElementById('my-button');
let largeData = new BigObject();
function onClick() {
// do something with largeData
}
element.addEventListener('click', onClick);
// 正确做法:
// element.removeEventListener('click', onClick);
// element = null;
在组件销毁或元素移除时,务必使用 clearInterval
, clearTimeout
和 removeEventListener
清理掉相关的定时器和监听器。