JS垃圾回收机制

JavaScript是门魅力无限的语言,关于它的GC(垃圾回收)方面,你了解多少呢?想来大部分人是因为面试才去看一些面试题从而了解的垃圾回收。

当然,我们可不仅仅是为了面试,其目的是一次性彻底搞懂 GC!假如你对其中某块内容不太理解,不要着急,先读完整篇文章了解内容再回过头来仔细看一遍就会清晰很多,干货满满,先赞后看哦。

image-20230813014556183

操作系统中的存储结构

  • CPU 只能从内存中加载指令,因此执行程序必须位于内存。通用计算机运行的大多数程序通常位于可读写内存,称为内存,也称为 随机访问内存(也被称为RAM:Random Access Memory)。

  • 内存由一个很大的字节数组来组成,每个字节都有各自的地址,CPU 根据程序计数器的值从内存中提取指令,这些指令可能引起对特定内存地址的额外加载与存储。

  • 在理想状态下,程序和数据都应永久驻留在内存中。由于以下两个原因,这是不可能的:

    1. 内存通常太少,不能永久保存所有需要的内存和数据;
    2. 内存是 易失性 的存储设备,停电时就会失去所有内容;
  • 最为常用的外存设备为磁盘和硬盘,它能存储程序和数据。

    内存区别于储存:

    Key 内存 储存
    Basis 内存是临时存储数据的。一旦系统断电,数据就会消失。 存储器在永久基础上存储数据。即使系统电源关闭,数据也会持续存在。
    Speed 内存的速度比存储快。 与内存相比,存储的速度较慢。
    组成部分 内存是由寄存器组成的,速度非常快。 存储器是由磁条/部件组成的,比存储器慢。
    Size 与存储相比,内存大小相当小,一般以MB或几GB为单位。 存储的大小非常大,以GB或几个TB为单位。
    数据访问 内存是一个可以即时访问数据的组件。 存储器不能像内存那样快速改变或访问数据。
    Types 内存的类型有–缓存内存、主内存和辅助内存。 存储类型包括–HDD、SSD、CD、DVD、软盘、磁带、蓝光光盘等。
    Cost 记忆的成本相对较高。 存储器的成本比内存模块要低。
  • 大多数程序(例如谷歌浏览器的安装文件)都保存在硬盘上,当要执行的时候才加载到内存,操作系统会为该应用程序创建一个新的进程,并为它分配必要的资源,如内存空间、文件描述符等。还会为其开辟一个线程以运行程序。如下图:

    image-20230812223723276

  • 在上图中,操作系统会为每个应用程序创建一个进程,并且每个应用程序都占据着一定的内存空间。

  • 进程又能创建线程,并且每个进程最少拥有一个线程,称为主线程,如下图:

    image-20230812225242377

  • 一个谷歌浏览器拥有多个线程,用于运行每一个浏览器页面。

    image-20230812230749988

  • 根据速度和价格,各种不同的存储可以按层次来分类,如上图所示。层次越高,价格越贵,速度越快。

内存管理

在前面的内容中我们对内存进行了介绍,那么接下来我们认识一下内存管理。

像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()free()。相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

然而,内存的读取和释放,决定了程序性能。

内存生命周期

不管什么程序语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还

所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像 JavaScript 这些高级语言中,大部分都是隐含的。

通过下面的代码我们来简单分析一下整个内存管理的生命周期:

1
2
3
4
5
6
7
8
9
10
// 分配内存
const foo = {
name: "moment",
age: "18",
};
// 使用内存
foo.name = "xun";
console.log(foo);
// 内存回收
foo = null;

JavaScript 的内存分配:为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。

使用内存:实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

内存回收:当内存不再需要使用时释放。

JS怎么分配内存的

在JavaScript中,不同的数据类型分配不同的内存,而存放这些数据的内存又可以分为两部分:栈内存堆内存

JavaScript 中有7种原始类型,它们分别是 空值(null)、未定义(undefined)、布尔值(boolean)、数字(number)、字符串(string)、 任意精度整数(Bigint)、符号(symbol),这些类型都会被存放到栈内存中。

JavaScript中的引用类型,比如 ObjectArray,它们是存在堆内存的,JavaScript不允许直接操作堆内存,我们操作对象时,操作的实际是对象的引用,而不是实际对象,这就相当于 C语言 中的指针,这个指针指向了堆里面的实际的对象。函数也是引用类型,当我们定义一个函数时,会在堆内存中开辟一块内存空间,将函数体代码以字符串的形式存进去。然后将这块内存的地址赋值给函数名,函数名和引用地址会存在栈上。

栈存储无论分配新的空间还是释放空间(压栈和退栈)都很简单,访问栈里的变量也快速,但其缺点是每次压栈的空间大小是固定的,因此里面的变量的数目及其数据结构大小也是固定的。

压栈和退栈是随着函数调用同步进行的,当函数结构后期栈空间会被立即释放,里面的变量的数据无法保留。如果要保留,让函数外面的继续,比如闭包,必须将其存入堆内存中,闭包中的变量也是存在堆内存中,堆存储的特点是不会随函数的结束而自动让数据消失。

堆在分配和释放空间时要做相当多的工作,比如分配时寻找合适大小的空间,对不用的空间做垃圾扫描和垃圾回收,甚至要将碎片化的空闲空间整合在一起。此外访问堆里的数据也要比栈更慢。所有这些都让堆储存的运行代价很高,影响性能。

垃圾回收(Garbage Collection)

程序工作过程中会产生很多 垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC 就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说,GC 过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的 垃圾回收

垃圾产生&为何回收

我们知道写代码时创建一个基本类型、对象、函数……都是需要占用内存的,但是我们并不关注这些,因为这是引擎为我们分配的,我们不需要显式手动的去分配内存。

但是,你有没有想过,当我们不再需要某个东西时会发生什么?JavaScript 引擎又是如何发现并清理它的呢?

我们举个简单的例子

1
2
3
4
let test = {
name: "isboyjc"
};
test = [1,2,3,4,5]

如上所示,我们假设它是一个完整的程序代码。

我们知道 JavaScript 的引用数据类型是保存在堆内存中的,然后在栈内存中保存一个对堆内存中实际对象的引用,所以,JavaScript 中对引用数据类型的操作都是操作对象的引用而不是实际的对象。可以简单理解为,栈内存中保存了一个地址,这个地址和堆内存中的实际值是相关的

那上面代码首先我们声明了一个变量 test,它引用了对象 {name: 'isboyjc'},接着我们把这个变量重新赋值了一个数组对象,也就变成了该变量引用了一个数组,那么之前的对象引用关系就没有了,如下图:

image-20230812235903208

没有了引用关系,也就是无用的对象,这个时候假如任由它搁置,一个两个还好,多了的话内存也会受不了,所以就需要被清理(回收)

用官方一点的话说,程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存,那么对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃。

怎么回收

大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。

高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。

如上文所述自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。

在 JavaScript 内存管理中有一个概念叫做 可达性,就是那些以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收

至于如何回收,其实就是怎样发现这些不可达的对象(垃圾)它并给予清理的问题, JavaScript 垃圾回收机制的原理说白了也就是定期找出那些不再用到的内存(变量),然后释放其内存

你可能还会好奇为什么不是实时的找出无用内存并释放呢?其实很简单,实时开销太大了

我们都可以 Get 到这之中的重点,那就是怎样找出所谓的垃圾?

这个流程就涉及到了一些算法策略,有很多种方式,我们简单介绍两个最常见的:

  • 引用计数算法
  • 标记清除算法

引用计数算法

引用计数(Reference Counting),这是最初级的垃圾收集算法,它把 对象是否不再需要 简化定义为 对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,目前很少使用这种算法了,因为它的问题很多,不过我们还是需要了解一下。

它的策略是跟踪记录每个变量值被使用的次数。

  • 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1;
  • 如果同一个值又被赋给另一个变量,那么引用数加 1;
  • 如果该变量的值被其他的值覆盖了,则引用次数减 1;
  • 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存;

我们来看看下面这个列子:

1
2
3
4
5
let a = new Object()  // 此对象的引用计数为 1(a引用)
let b = a // 此对象的引用计数是 2(a,b引用)
a = null // 此对象的引用计数为 1(b引用)
b = null // 此对象的引用计数为 0(无引用)
... // GC 回收此对象

这种方式是不是很简单?确实很简单,不过在引用计数这种算法出现没多久,就遇到了一个很严重的问题——循环引用,即对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A ,如下面这个例子:

1
2
3
4
5
6
7
function test(){
let A = new Object()
let B = new Object()

A.b = B
B.a = A
}

如上所示,对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,它们的引用数量都是 2,但是,在函数 test 执行完成之后,对象 A 和 B 是要被清理的,但使用引用计数则不会被清理,因为它们的引用数量不会变成 0,假如此函数在程序中被多次调用,那么就会造成大量的内存不会被释放。

我们再用标记清除的角度看一下,当函数结束后,两个对象都不在作用域中,A 和 B 都会被当作非活动对象来清除掉,相比之下,引用计数则不会释放,也就会造成大量无用内存占用,这也是后来放弃引用计数,使用标记清除的原因之一。

JavaScript高级程序设计第四版 4.3.2 小节有提到的。在 IE8 以及更早版本的 IE 中,BOMDOM 对象并非是原生 JavaScript 对象,它是由 C++ 实现的 组件对象模型对象(COM,Component Object Model),而 COM 对象使用 引用计数算法来实现垃圾回收,所以即使浏览器使用的是标记清除算法,只要涉及到 COM 对象的循环引用,就还是无法被回收掉,就比如两个互相引用的 DOM 对象等等,而想要解决循环引用,需要将引用地址置为 null 来切断变量与之前引用值的关系,如下:

1
2
3
4
5
6
7
8
9
10
11
// COM对象
let ele = document.getElementById("xxx")
let obj = new Object()

// 造成循环引用
obj.ele = ele
ele.obj = obj

// 切断引用关系
obj.ele = null
ele.obj = null

不过在 IE9 及以后的 BOMDOM 对象都改成了 JavaScript 对象,也就避免了上面的问题。

优点:

引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾。

缺点:

引用计数的缺点想必大家也都很明朗了,首先它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的。

标记清除算法

标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异。

就像它的名字一样,此算法分为 标记清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。

你可能会疑惑怎么给变量加标记?其实有很多种办法,比如当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表,当前还有很多其他办法。其实,怎样标记对我们来说并不重要,重要的是其策略

引擎在执行 GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组 对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象文档DOM树 等。

整个标记清除算法大致过程就像下面这样

  • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0;
  • 然后从各个根对象开始遍历,把不是垃圾的节点改成1;
  • 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间;
  • 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收;

优点:

标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单。

缺点:

标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片,并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题。如下图:

image-20230813010050238

假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配。如下图:

image-20230813010230686

那如何找到合适的块呢?我们可以采取下面三种分配策略

  • First-fit,找到大于等于 size 的块立即返回
  • Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
  • Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回

这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fitBest-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择

综上所述,标记清除算法或者说策略就有两个很明显的缺点

  • 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
  • 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢

归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了

标记整理(Mark-Compact)算法就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存。如下图:

image-20230813011227539

V8对GC的优化

我们在上面也说过,现在大多数浏览器都是基于标记清除算法,V8 亦是,当然 V8 肯定也对其进行了一些优化加工处理,那接下来我们主要就来看 V8 中对垃圾回收机制的优化。

分代式垃圾回收

试想一下,我们上面所说的垃圾清理算法在每次垃圾回收时都要检查内存中所有的对象,这样的话对于一些大、老、存活时间长的对象来说同新、小、存活时间短的对象一个频率的检查很不好,因为前者需要时间长并且不需要频繁进行清理,后者恰好相反,怎么优化这点呢?分代式就来了。

V8 的垃圾回收策略主要是基于 分代式垃圾回收策略,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。

新老生代

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收

新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量,而老生代的对象为存活时间较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大

V8 整个堆内存的大小就等于新生代加上老生代的内存。如下图:

image-20230813012453685

对于新老两块内存区域的垃圾回收,V8 采用了两个垃圾回收器来管控,我们暂且将管理新生代的垃圾回收器叫做新生代垃圾回收器,同样的,我们称管理老生代的垃圾回收器叫做老生代垃圾回收器好了。

了解更多,详见

Q&A

怎么理解内存泄漏?

详见

怎么解决内存泄漏,代码层面如何优化?

  1. 减少查找

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    var i, str = ""
    function packageDomGlobal() {
    for(i = 0; i < 1000; i++) {
    str += i
    }
    }

    // 第二种情况。我们采用局部变量来保存保存相关数据
    function packageDomLocal() {
    let str = ''
    for(let i = 0; i < 1000; i++) {
    str += i
    }
    }
  2. 减少变量声明

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 第一种情况,循环体中没有抽离出值不变的数据
    var test = () => {
    let arr = ['czs', 25, 'I love FrontEnd'];
    for(let i = 0; i < arr.length; i++){
    console.log(arr[i]);
    }
    }

    // 第二种情况,循环体中抽离出值不变的数据
    var test = () => {
    let arr = ['czs', 25, 'I love FrontEnd'];
    const length = arr.length;
    for(let i = 0; i < length; i++){
    console.log(arr[i]);
    }
    }
  3. 使用 Performance + Memory 分析内存与性能

相关链接

[1] 内存管理

[2] 「硬核JS」你真的了解垃圾回收机制吗

[3] 一文让你彻底搞懂JS垃圾回收机制