码农之家

从 GC 到 WeakMap、WeakSet

一、内存泄漏

1.1 简介

内存泄漏: 指计算机科学中的一种资源泄漏, 主要是因为计算机程序 内存 管理疏忽或错误造成程序 未能释放 已经 不再使用 的内存, 因而失去对一段 已分配内存 空间的控制, 程序将继续占用 已不再使用 的内存空间, 或是存储器所存储的对象, 无法通过执行代码被访问到, 令内存资源空耗, 简单讲就是: 已不再使用的内存没有得到释放

内存泄漏会因为减少 可用内存 的数量从而降低计算机的性能, 在最糟糕的情况下, 过多的 不可用内存被分配掉可能会导致设备停止正常工作、应用程序崩溃等严重后果

1.2 内存生命周期

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

  1. 向系统申请所需要的内存
  2. 使用分配到的内存进行读、写操作
  3. 不需要时将内存进行释放

对于上述提到的内存生命周期, 所有语言中第二点都是明确的, 但是对于第一、第三点就不一定了:

  1. C 语言这样的底层语言中因为是手动管理内存的, 所以对于第一、第三点是明确的, 它们有一套底层的内存管理接口, 比如 malloc()free(), malloc() 方法用来申请内存而 free() 方法释放内存
char * buffer;
buffer = (char*) malloc(42);
free(buffer);
  1. 但是呢, 手动管理内存这事本身是很麻烦, 所以大多数语言都提供了 自动内存管理 功能, 从而减轻程序员的负担, JS 也是如此, 它在创建变量 (对象、字符串、函数…) 时是自动进行了内存的分配, 并且在不使用它们时 "自动" 释放, 所以对于 JS 来说第一、第三并不是明确的, 是不可控的

1.3 JS 中内存分配

  1. 值的初始化: 对于开发者来说, JS 的内存管理是自动的、无形的, 为了不让程序员费心分配内存, JS 在定义变量时就自动完成了内存申请、分配
const n = 123;  // 给数值变量分配内存
const s = "azerty"; // 给字符串分配内存

const o = { a: 1, b: null }; // 给对象及其包含的值分配内存

const a = [1, null, "abra"]; // 给数组及其包含的值分配内存(就像对象一样)

function f(a) { return a + 2; } // 给函数(可调用的对象)分配内存

process.on("exit", (code) => {}); // 函数表达式也能分配一个对象

const d = new Date(); // 分配一个 Date 对象

const e = document.createElement('div'); // 分配一个 DOM 元素
  1. 使用值: 使用值的过程实际上是对所 分配内存 进行 读取写入 的操作; 读取写入 可能是写入一个变量或者一个对象的属性值, 甚至传递函数的参数

  2. 内存释放:

  • 大多数内存管理的问题都在这个阶段, 在这里最艰难的任务是找到 哪些被分配的内存确实已经不再需要了。在底层语言中它往往要求开发人员来确定, 在程序中哪一块内存不再需要并且需要手动释放它;
  • 高级语言解释器嵌入了 垃圾回收 简称 GC, 它的主要工作是跟踪内存的分配和使用, 以便当分配的内存不再使用时 自动释放

二、垃圾回收机制(GC)

JS 中通过 垃圾回收 机制来 检测释放 不再使用的内存, 来避免内存泄漏和资源浪费, 同时 垃圾回收时机 通常由 垃圾回收器 自动决定, 而不是由开发人员手动控制, 而 垃圾回收 又简称为 GCGarbage Collection

现代 JS 引擎通常会使用复杂的算法和策略来优化 GC 的性能和效率, 尽量减少对应用程序性能的影响; 因此, 在编写 JS 代码时, 通常不需要过多关注 GC 的时机, 而应该专注于编写高效的代码和合理地管理对象的生命周期, 这里 GC 主要有两种策略: 引用计数标记清除

2.1 引用计数

这是一种简单且古老的 GC 策略, 首先它会 跟踪 每个对象被 引用的次数, 当对象的 引用计数 时, 表示该对象不再被使用, 是个可被清除的垃圾, 那么在下一次进行 GC 时将自动释放这部分内存

如下代码: 创建了两个对象, 一个赋值给了 obj 另一个赋值给了 obj.user, 代码中变量 obj 虽然没有被使用到, 但是这两个对象的引用次数依然都为 1 所以 GC 时无法释放这部分内存, 从而将会导致内存泄漏

const obj = {
  user: {
    age: 18,
    name: 'lh',
  }
};

上面代码中, 如果我们将 obj 设置为 null, 这时变量指向的对象引用次数为 0
就会在下一轮 GC 时被销毁, 那么它的属性 user 也会被销毁, 也就是说 obj.user 所指向的对象引用次数也变为 0, 那么该对象后面自然也会被销毁

let obj = {
  user: {
    age: 18,
    name: 'lh',
  }
};
obj = null

引用计数 策略确实简单, 但是很快就遇到一个很严重的问题 —— 循环引用, 即对象 A 有一个指针指向对象 B, 而对象 B 也引用了对象 A, 如下代码: obj1obj2 两个对象之间相互引用了, 所以 obj1obj2 两个所指的对象引用次数都为 2, 这时我们即便将 obj1obj2 都设置为 null 这两个对象的引用次数依然不为 0, 但这两个对象确确实实是没有用的, 因为我们已经没有途径可以访问到它们了

let obj1 = {};
let obj2 = {};

obj1.a = obj2; // obj1 引用 obj2
obj12.a = obj1; // obj2 引用 obj1

obj1 = null
obj2 = null

下面再讲一个循环引用的实际例子: 在 IE8 以及更早版本的 IE 中, 对于 DOM 对象是采用 引用计数 策略来回收对象的, 如下代码 myDivElement 这个 DOM 元素(对象)中 circularReferenc 属性引用了 myDivElement 自身, 其实就是一个对象有个属性指向了自己, 这时候如果我们没有将 circularReferenc 移除或者设为 null, 那么 myDivElement 这个 DOM 将会永远无法销毁, 从而造成内容泄漏, 特别是如果 lotsOfData 数据量比较大的情况下, 这个内存泄漏的情况将会更加严重, 同时, 这里其实还有一个错误操作就是在全局声明了变量 div, 在没有手动设置为 null 情况下同样会引起内存的泄漏

var div;
window.onload = function(){
  div = document.getElementById("myDivElement");
  div.circularReference = div;
  div.lotsOfData = new Array(10000).join("*");
};

优点: 简单清晰

缺点:

  • 需要一个庞大(占大内存)的计数器用于记录每个对象的引用次数
  • 无法解决循环引用问题

2.2 标记清除

说到 标记清除 就不得不先提下 可达性 了, JS可达性 简单来说就是指, 程序可以通过某种方式能够访问到该值, 那么称这个值是 可达 的, 是可访问的; 同时所有不可达的值, 将被认为是无效的、无用的值

标记清除 策略就是将 对象是否不再需要 简化定义为 对象是否可达, 其实也好理解如果一个对象我们 无法访问 那么它必然就是无效的 垃圾

标记清除 中会设定一个 root(根) 对象, 在 JSroot 对象是 全局对象, GC 将定期从 root 开始, 一层层往下查找对象, GC 将找到所有 可达对象 和收集所有 不可达对象, 然后对于 不可达 的对象将会进行销毁, 释放其所占用的内存

如下图所示, 虚线框出部分则是从根节点出发, 无法被访问到的对象, 那么这几个对象将被视为垃圾被处理掉

标记清除 中就可以解决上文提到的 循环引用 问题, 因为从根对象出发他们是不可访问到的

优势:

  • 能有效解决循环引用问题: 该算法相对于 引用计数 就更加合理, 因为 零引用的对象 总是不可访问的, 但是相反却不一定, 比如 循环引用

  • 实现可以说是非常简单的, 就是打标记、清除, 现在的各类 GC 算法也都是它思想的延续、优化

缺点: 在多次回收操作后, 会产生大量的内存碎片, 因为在清除内存后并没有对内存进行整理, 所以会导致剩余的内存不连续

三 标记清除优化

2012 年起, 所有现代浏览器都使用了 标记清除 策略, 之后所有 GC 算法的改进都是基于 标记清除 来进行改进的, 并没有改变策略本身和它对 对象是否不再需要 的判断逻辑(从根对象出发不可达、不可访问), 下列几种是比较常见的几种优化算法:

3.1 空间复制: Scavenge 算法

算法实现思路:

  1. 将整个空间平均分成 from(使用区)to(空闲区) 两部分
  2. 先在 from(使用区) 空间进行内存分配, 当空间快被占满时, 对该空间内所有对象进行 标记清除
  3. from(使用区) 中剩余的 可达对象 拷贝到 to(空闲区)
  4. 复制完成后, 将 from(使用区)to(空闲区) 角色互换, 进行下一轮循环

优点: 不会发生碎片化, 每次都是对其中的一块进行内存回收, 内存分配时也就不用考虑内存碎片等复杂情况

缺点:

  • 内存使用效率低, 把内存分为对等的两份, 通常情况下只能利用到其中的一半来存储,另一半堆一直都空闲
  • 效率低: 需要同时对存活的对象进行操作, 复制操作次数多, 效率降低

3.2 标记压缩(整理)算法

算法实现思路:

  1. 标记: 对所以存活的对象进行标记
  2. 压缩(整理): 将所有 可达对象 移动到内存的其中一端, 这时必然会划分出一个分界线
  3. 清除: 直接释放分界线另一段的内存

优点: 不会产生空间碎片化

缺点: 整理内存空间需要花费一定的时间

3.3 分代回收

分代回收的依据「对象的生存时间呈现两极化」

  1. 大部分对象的生命周期都非常短暂, 存活时间较短
  2. 而另一部分对象生命周期又是很长, 甚至有些对象是一直存在的

算法实现思路:

  1. 把内存划分为 新生代老生代, 这样就可以根据不同生命周期的对象, 采用不同的算法进行 GC
  2. 新生代 中存储新增的对象, 数量相对比较少所以这里一般选用 空间复制 来处理, 同时在 新生代GC 的周期一般比较短, 当一个对象经过多次复制后依然存活, 它将会被认为是生命周期较长的对象, 随后会被移动到老生代中, 采用老生代的 GC 算法进行管理
  3. 老生代 一般都是存活周期较长的对象, 所以 GC 的周期一般比较长, 同时一般采用 标记压缩 算法来处理

3.4 标记增量

标记清理 中需要遍历对象, 对所有 可达不可达 对象进行标记, 但这里有个问题: 如果一个对象特别庞大那么这个遍历时间就会被拉长, 从而阻碍到 JS 正常逻辑的执行

标记增量就是解决上面问题, 该算法将整个 标记 的过程划分为多个步骤, 每执行完一小步就执行一会 JS 逻辑, 直到完成所有的 标记 工作

但这里其实还有一个问题, 每开始新的步骤时, GC 又是如何确定上一次标记到哪里了? 这里就得借助 三色标记法 了, 该算法作为工具可辅助推导, 它精髓在于将遍历过的对象, 按照 是否访问过 这个条件标记成以下三种颜色:

  • 白色: 表示对象尚未被垃圾收集器访问过; 在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象即代表该对象不可达(可被清理)
  • 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过(只标记了一半)
  • 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过(该对象可达)

到此还有可能存在一个问题: 当一次标记结束后, 在执行程序过程中, 代码将 已标记完 对象的引用改为 新的对象, 这时就有可能出现 漏标 的情况, 如下图所示: 在标记阶段 A B C 都已被标记为黑色, 但是在进行程序执行过程中, B 对象由指向 C 改为指向 D

这个问题其实可以使用 写屏障 (Write-barrier) 机制 来规避, 即一旦有黑色对象 引用 白色对象, 该机制会强制将引用的 白色对象 改为 灰色, 从而保证下一次增量标记阶段可以正确标记, 这个机制也被称作 强三色不变性

优点: 使得主线程的停顿时间大大减少了, 让用户与浏览器交互的过程变得更加流畅

缺点: 并没有减少主线程的总暂停的时间, 甚至会略微增加; 同时由于对象指针可能发生了变化, 需要使用 写屏障技术 来记录这些引用关系的变化, 所以可能会降低应用程序的吞吐量

3.5 小结

本节所介绍的几个算法, 都是对 标记清除 策略的一个优化, 当然实际情况可能更为复杂, 需要更多的算法来同时进行优化, 比如: 懒性清理、并发回收等等

最后补充下 V8 引擎中常用的几个算法: 分代回收、空间复制、标记清除、标记整理、标记增量……

四、WeakMap & WeakSet

在此姑且认为大家对 MapSet 类型数据都是清楚的, 那么 WeakMapWeakSetMap Set 又有啥区别呢? 在我看来它们的主要区别如下:

  1. WeakMapKey 只能是一个对象, value 是任意值, 但是在 MapkeyValue 都可以是任意值
  2. WeakSetvalue 只能是对象, 但是 Set 中可以是任意值
  3. WeakMapKey 值的引用是 弱引用, Map 中则不是
  4. WeakSetvalue 值的引用是 弱引用, Set 中则不是
  5. 由于 弱引用 特性(猜测)在 WeakMapWeakSet 中不存在 keys values 方法
  6. 由于 弱引用 特性(猜测)在 WeakMapWeakSet 中不能在初始化时同 Map Set 一样设置初始值

4.1 弱引用

WeakMapWeakSetMapSet 之间的区别看似很简单也就两句话的事, 但是呢实际上问起 弱引用 大部分人可能还都是一知半解的, 那么什么是 弱引用 呢? 在我看来主要还是和 GC 有关

在上文我们提到目前 JSGC 一般采用 标记清除, 会遍历对象所有属性, 找到 不可达对象 进行清除, 但是在遍历过程 GC 将会忽略 弱引用, 也就是 弱引用 指向的那个对象在当前遍历路径中是不可达的, 如果该对象在其他路径也是不可达的那么该对象将被会被回收掉

📢注意, 默认情况下, JS 中所有引用都是 强引用, 目前使用 弱引用 的就只有 WeakMapWeakSet

4.2 Node 中调用 GC、查看内存使用

道理都懂, 但是我们又如何来进行验证呢? 下面我们将在 Node 来验证 WeakMapWeakSet弱引用 这一特性, 在开始之前我们需要先清楚在 Node 中如何手动触发 GC, 如何查看当前内存占用:

  1. 手动调用 GC: 在 Node 可通过 --expose-gc 命令参数, 允许进程手动管理内存, 开启该参数后, 在进程中我们可以通过全局方法 gc() 来手动执行 GC
node --expose-gc
> gc()
undefined
  1. 同时在 Node 中可通过 process.memoryUsage() 方法查看当前进程内存使用情况, 在内存前后相差比较大的情况, 可通过该方法返回值 heapUsed 来进行验证, 该参数表示 V8 引擎的内存使用量, 单位为 bytes(字节), 更多参数说明参考 官方文档
node
> process.memoryUsage()
# 这时 heapUsed 大概占用 6M
{
  rss: 43139072,
  heapTotal: 8159232,
  heapUsed: 6361952, 
  external: 1030580, 
}

4.3 验证: Map 中 key 的引用是「强引用」

node --expose-gc

# 1. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 5M
> gc()
> process.memoryUsage()
{
  rss: 43270144,
  heapTotal: 6848512,
  heapUsed: 5389472,
  external: 1027423,
  arrayBuffers: 16644
}

# 2. DEMO: 创建 Map 类型数据 Key 设置为一个长数组对象
> let arr = new Array(5 * 1024 * 1024)
> const map = new Map()
> map.set(arr, 123)

#  3. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 45M
> gc()
> process.memoryUsage()
{
  rss: 88129536,
  heapTotal: 52215808,
  heapUsed: 47376088,
  external: 1027463,
  arrayBuffers: 16644
}

# 4. 将 arr 设置为 null, root -> arr 这条路径不可达
> arr = null

# 5. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概还是 45M 左右
> gc()
> process.memoryUsage()
{
  rss: 87982080,
  heapTotal: 49332224,
  heapUsed: 47648456,
  external: 1027463,
  arrayBuffers: 16644
}

# 6. 获取长数组对象 arr 的长度, 可以正常访问长数组对象, 说明没有被回收
> map.keys().next().value.length
5242880

如上代码:

  • 先创建了一个长数组对象 arr
  • 同时又创建了 Map 对象并且为它设置了键值对, key 为长数组对象 arr、值为 123
  • 最后将变量 arr 设置为 null, 但由于 Map 的键是 强引用, 所以在这里 arr 实际上是不会被销毁的, 因为我们可以其他途径获取到这个长数组对象, 从「将 arr 设置为 null 的前后内存变化」也可以验证这一观点
  • 我们依然可以通过 keys 方法获取到这个长数组对象, 说明该对象并没有被销毁

4.4 验证: WeakMap 中 key 的引用是「弱引用」

node --expose-gc

# 1. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 5M
> gc()
> process.memoryUsage()
{
  rss: 44531712,
  heapTotal: 7110656,
  heapUsed: 5378232,
  external: 1027423,
  arrayBuffers: 16644
}

# 2. DEMO: 创建 WeakMap 类型数据 Key 设置为一个长数组对象
> let arr = new Array(5 * 1024 * 1024)
> const map = new WeakMap()
> map.set(arr, 123)

#  3. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 45M
> gc()
> process.memoryUsage()
{
  rss: 89276416,
  heapTotal: 49070080,
  heapUsed: 47378360,
  external: 1027463,
  arrayBuffers: 16644
}

# 4. 将 arr 设置为 null, root -> arr 这条路径不可达
> arr = null

# 5. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 5M, 说明长数组对象被销毁了
> gc()
> process.memoryUsage()
{
  rss: 47333376,
  heapTotal: 7372800,
  heapUsed: 5820624,
  external: 1027463,
  arrayBuffers: 16644
}

# 6. 报 keys 方法不存在, 在 WeakMap 中没有其他途径拿到这个值
> map.keys().next().value.length

如上代码: 同样的例子, 在 WeakMap 中表现和 Map 就完全不一致, 主要原因是 WeakMap弱引用, GC 过程中将会忽略所有 弱引用, 其他 强引用 路径如果都不可达, 那么对象就会被销毁

4.5 验证: WeakMap 中当 key 被销毁时, 对应值也会被相应的销毁掉

node --expose-gc

# 1. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 5M
> gc()
> process.memoryUsage()
{
  rss: 43810816,
  heapTotal: 7110656,
  heapUsed: 5384432,
  external: 1027423,
  arrayBuffers: 16644
}

# 2. DEMO: 创建 WeakMap 类型数据 Key 设置为一个长数组对象
> let key = {} 
> const map = new WeakMap()
> map.set(key, new Array(5 * 1024 * 1024))

#  3. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 45M
> gc()
> process.memoryUsage()
{
  rss: 88096768,
  heapTotal: 49332224,
  heapUsed: 47359856,
  external: 1027463,
  arrayBuffers: 16644
}

# 4. 将 key 设置为 null, weakMap 中 value 是否会被销毁?
> key = null

# 5. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 5M, 说明 weakMap 中值也被销毁了
> gc()
> process.memoryUsage()
{
  rss: 46563328,
  heapTotal: 8421376,
  heapUsed: 5660160,
  external: 1027463,
  arrayBuffers: 16644
}

从上面代码执行情况有如下结论: 在 weakMap 中当键被销毁时, 对应的值也会被销毁

4.6 验证: Set 中 value 的引用是「强引用」

node --expose-gc

# 1. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 5M
> gc()
> process.memoryUsage()
{
  rss: 44859392,
  heapTotal: 7110656,
  heapUsed: 5383376,
  external: 1027423,
  arrayBuffers: 16644
}

# 2. DEMO: 创建 Set 并添加一个长数组对象
> let arr = new Array(5 * 1024 * 1024)
> const set = new Set()
> set.add(arr)

#  3. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 45M
> gc()
> process.memoryUsage()
{
  rss: 89030656,
  heapTotal: 49332224,
  heapUsed: 47619536,
  external: 1027463,
  arrayBuffers: 16644
}

# 4. 将 arr 设置为 null, root -> arr 这条路径不可达
> arr = null

# 5. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 45M
> gc()
> process.memoryUsage()
{
  rss: 89423872,
  heapTotal: 49332224,
  heapUsed: 47630192,
  external: 1027463,
  arrayBuffers: 16644
}

# 6. 获取长数组 arr 的长度, 发现还是可以拿到
> set.values().next().value.length
5242880

如上代码:

  • 先创建了一个长数组对象 arr
  • 同时又创建了 Set 对象并将长数组对象 arr 作为值添加到 Set
  • 最后将变量 arr 设置为 null, 但由于 Set 的键是 强引用, 所以在这里长数组对象 arr 实际上是不会被销毁的, 因为我们可以其他途径获取到这个长数组对象, 从「将 arr 设置为 null 的前后内存变化」也可以验证这一观点
  • 但我们依然可以通过 values 方法获取获取到这个长数组对象, 说明该对象并没有被销毁

4.7 验证: WeakSet 中 value 的引用是「弱引用」

node --expose-gc

# 1. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 5M
> gc()
> process.memoryUsage()
{
  rss: 44285952,
  heapTotal: 7110656,
  heapUsed: 5388328,
  external: 1027423,
  arrayBuffers: 16644
}

# 2. DEMO: 创建 WeakSet 并添加一个长数组对象 arr
> let arr = new Array(5 * 1024 * 1024)
> const set = new WeakSet()
> set.add(arr)

#  3. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 45M
> gc()
> process.memoryUsage()
{
  rss: 88752128,
  heapTotal: 49332224,
  heapUsed: 47623728,
  external: 1027463,
  arrayBuffers: 16644
}

# 4. 将 arr 设置为 null, root -> arr 这条路径不可达
> arr = null

# 5. 手动调用 GC, 并查看内存情况, 这时 heapUsed 大概为 5M, 说明长数组对象被销毁了
> gc()
> process.memoryUsage()
{
  rss: 47202304,
  heapTotal: 7372800,
  heapUsed: 5879664,
  external: 1027463,
  arrayBuffers: 16644
}

如上代码: 同样的例子, 在 WeakSet 中表现和 Set 就完全不一致, 主要原因是 WeakSet 是弱引用, GC 过程中将会忽略所有 弱引用, 其他 强引用 路径如果都不可达, 那么对象就会被销毁

五、总结

到此就先告个段落吧, GC 各种策略、算法还是比较多的比较复杂的, 本文也只是做了简单介绍, 越往下深究发现东西越多, 如果大家对于 GC 比较感兴趣推荐阅读 《垃圾回收的算法与实现》

六、参考

原文链接:https://juejin.cn/post/7226747781662457911 作者:墨渊君