一、内存泄漏
1.1 简介
内存泄漏: 指计算机科学中的一种资源泄漏, 主要是因为计算机程序 内存
管理疏忽或错误造成程序 未能释放
已经 不再使用
的内存, 因而失去对一段 已分配内存
空间的控制, 程序将继续占用 已不再使用
的内存空间, 或是存储器所存储的对象, 无法通过执行代码被访问到, 令内存资源空耗, 简单讲就是: 已不再使用的内存没有得到释放
内存泄漏会因为减少 可用内存
的数量从而降低计算机的性能, 在最糟糕的情况下, 过多的 不可用内存
被分配掉可能会导致设备停止正常工作、应用程序崩溃等严重后果
1.2 内存生命周期
不管什么程序语言, 内存生命周期基本是一致的:
- 向系统申请所需要的内存
- 使用分配到的内存进行读、写操作
- 不需要时将内存进行释放
对于上述提到的内存生命周期, 所有语言中第二点都是明确的, 但是对于第一、第三点就不一定了:
- 在
C
语言这样的底层语言中因为是手动管理内存的, 所以对于第一、第三点是明确的, 它们有一套底层的内存管理接口, 比如malloc()
和free()
,malloc()
方法用来申请内存而free()
方法释放内存
char * buffer;
buffer = (char*) malloc(42);
free(buffer);
- 但是呢, 手动管理内存这事本身是很麻烦, 所以大多数语言都提供了
自动内存管理
功能, 从而减轻程序员的负担,JS
也是如此, 它在创建变量 (对象、字符串、函数...) 时是自动进行了内存的分配, 并且在不使用它们时"自动"
释放, 所以对于JS
来说第一、第三并不是明确的, 是不可控的
1.3 JS 中内存分配
- 值的初始化: 对于开发者来说,
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 元素
-
使用值: 使用值的过程实际上是对所
分配内存
进行读取
与写入
的操作;读取
与写入
可能是写入一个变量或者一个对象的属性值, 甚至传递函数的参数 -
内存释放:
- 大多数内存管理的问题都在这个阶段, 在这里最艰难的任务是找到
哪些被分配的内存确实已经不再需要了
。在底层语言中它往往要求开发人员来确定, 在程序中哪一块内存不再需要并且需要手动释放它; - 高级语言解释器嵌入了
垃圾回收
简称GC
, 它的主要工作是跟踪内存的分配和使用, 以便当分配的内存不再使用时自动释放
它
二、垃圾回收机制(GC)
JS
中通过 垃圾回收
机制来 检测
和 释放
不再使用的内存, 来避免内存泄漏和资源浪费, 同时 垃圾回收
的 时机
通常由 垃圾回收器
自动决定, 而不是由开发人员手动控制, 而 垃圾回收
又简称为 GC
即 Garbage 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
, 如下代码: obj1
和 obj2
两个对象之间相互引用了, 所以 obj1
和 obj2
两个所指的对象引用次数都为 2
, 这时我们即便将 obj1
和 obj2
都设置为 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(根)
对象, 在 JS
中 root
对象是 全局对象
, GC
将定期从 root
开始, 一层层往下查找对象, GC
将找到所有 可达对象
和收集所有 不可达对象
, 然后对于 不可达
的对象将会进行销毁, 释放其所占用的内存
如下图所示, 虚线框出部分则是从根节点出发, 无法被访问到的对象, 那么这几个对象将被视为垃圾被处理掉
在 标记清除
中就可以解决上文提到的 循环引用
问题, 因为从根对象出发他们是不可访问到的
优势:
-
能有效解决循环引用问题: 该算法相对于
引用计数
就更加合理, 因为零引用的对象
总是不可访问的, 但是相反却不一定, 比如循环引用
-
实现可以说是非常简单的, 就是打标记、清除, 现在的各类
GC
算法也都是它思想的延续、优化
缺点: 在多次回收操作后, 会产生大量的内存碎片, 因为在清除内存后并没有对内存进行整理, 所以会导致剩余的内存不连续
三 标记清除优化
从 2012
年起, 所有现代浏览器都使用了 标记清除
策略, 之后所有 GC
算法的改进都是基于 标记清除
来进行改进的, 并没有改变策略本身和它对 对象是否不再需要
的判断逻辑(从根对象出发不可达、不可访问), 下列几种是比较常见的几种优化算法:
3.1 空间复制: Scavenge 算法
算法实现思路:
- 将整个空间平均分成
from(使用区)
和to(空闲区)
两部分 - 先在
from(使用区)
空间进行内存分配, 当空间快被占满时, 对该空间内所有对象进行标记清除
- 将
from(使用区)
中剩余的可达对象
拷贝到to(空闲区)
- 复制完成后, 将
from(使用区)
和to(空闲区)
角色互换, 进行下一轮循环
优点: 不会发生碎片化, 每次都是对其中的一块进行内存回收, 内存分配时也就不用考虑内存碎片等复杂情况
缺点:
- 内存使用效率低, 把内存分为对等的两份, 通常情况下只能利用到其中的一半来存储,另一半堆一直都空闲
- 效率低: 需要同时对存活的对象进行操作, 复制操作次数多, 效率降低
3.2 标记压缩(整理)算法
算法实现思路:
- 标记: 对所以存活的对象进行标记
- 压缩(整理): 将所有
可达对象
移动到内存的其中一端, 这时必然会划分出一个分界线 - 清除: 直接释放分界线另一段的内存
优点: 不会产生空间碎片化
缺点: 整理内存空间需要花费一定的时间
3.3 分代回收
分代回收的依据「对象的生存时间呈现两极化」
- 大部分对象的生命周期都非常短暂, 存活时间较短
- 而另一部分对象生命周期又是很长, 甚至有些对象是一直存在的
算法实现思路:
- 把内存划分为
新生代
和老生代
, 这样就可以根据不同生命周期的对象, 采用不同的算法进行GC
新生代
中存储新增的对象, 数量相对比较少所以这里一般选用空间复制
来处理, 同时在新生代
中GC
的周期一般比较短, 当一个对象经过多次复制后依然存活, 它将会被认为是生命周期较长的对象, 随后会被移动到老生代中, 采用老生代的GC
算法进行管理老生代
一般都是存活周期较长的对象, 所以GC
的周期一般比较长, 同时一般采用标记压缩
算法来处理
3.4 标记增量
在 标记清理
中需要遍历对象, 对所有 可达
和 不可达
对象进行标记, 但这里有个问题: 如果一个对象特别庞大那么这个遍历时间就会被拉长, 从而阻碍到 JS
正常逻辑的执行
标记增量就是解决上面问题, 该算法将整个 标记
的过程划分为多个步骤, 每执行完一小步就执行一会 JS
逻辑, 直到完成所有的 标记
工作
但这里其实还有一个问题, 每开始新的步骤时, GC
又是如何确定上一次标记到哪里了? 这里就得借助 三色标记法
了, 该算法作为工具可辅助推导, 它精髓在于将遍历过的对象, 按照 是否访问过
这个条件标记成以下三种颜色:
- 白色: 表示对象尚未被垃圾收集器访问过; 在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象即代表该对象不可达(可被清理)
- 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过(只标记了一半)
- 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过(该对象可达)
到此还有可能存在一个问题: 当一次标记结束后, 在执行程序过程中, 代码将 已标记完
对象的引用改为 新的对象
, 这时就有可能出现 漏标
的情况, 如下图所示: 在标记阶段 A
B
C
都已被标记为黑色, 但是在进行程序执行过程中, B
对象由指向 C
改为指向 D
这个问题其实可以使用 写屏障 (Write-barrier) 机制
来规避, 即一旦有黑色对象
引用 白色对象
, 该机制会强制将引用的 白色对象
改为 灰色
, 从而保证下一次增量标记阶段可以正确标记, 这个机制也被称作 强三色不变性
优点: 使得主线程的停顿时间大大减少了, 让用户与浏览器交互的过程变得更加流畅
缺点: 并没有减少主线程的总暂停的时间, 甚至会略微增加; 同时由于对象指针可能发生了变化, 需要使用 写屏障技术
来记录这些引用关系的变化, 所以可能会降低应用程序的吞吐量
3.5 小结
本节所介绍的几个算法, 都是对 标记清除
策略的一个优化, 当然实际情况可能更为复杂, 需要更多的算法来同时进行优化, 比如: 懒性清理、并发回收等等
最后补充下 V8
引擎中常用的几个算法: 分代回收、空间复制、标记清除、标记整理、标记增量……
四、WeakMap & WeakSet
在此姑且认为大家对 Map 和 Set 类型数据都是清楚的, 那么 WeakMap
和 WeakSet
和 Map
Set
又有啥区别呢? 在我看来它们的主要区别如下:
- 在
WeakMap
中Key
只能是一个对象,value
是任意值, 但是在Map
中key
和Value
都可以是任意值 - 在
WeakSet
中value
只能是对象, 但是Set
中可以是任意值 - 在
WeakMap
中Key
值的引用是弱引用
,Map
中则不是 - 在
WeakSet
中value
值的引用是弱引用
,Set
中则不是 - 由于
弱引用
特性(猜测)在WeakMap
、WeakSet
中不存在keys
values
方法 - 由于
弱引用
特性(猜测)在WeakMap
、WeakSet
中不能在初始化时同Map
Set
一样设置初始值
4.1 弱引用
WeakMap
、WeakSet
和 Map
、Set
之间的区别看似很简单也就两句话的事, 但是呢实际上问起 弱引用
大部分人可能还都是一知半解的, 那么什么是 弱引用
呢? 在我看来主要还是和 GC
有关
在上文我们提到目前 JS
中 GC
一般采用 标记清除
, 会遍历对象所有属性, 找到 不可达对象
进行清除, 但是在遍历过程 GC
将会忽略 弱引用
, 也就是 弱引用
指向的那个对象在当前遍历路径中是不可达的, 如果该对象在其他路径也是不可达的那么该对象将被会被回收掉
📢注意, 默认情况下, JS
中所有引用都是 强引用
, 目前使用 弱引用
的就只有 WeakMap
或 WeakSet
了
4.2 Node 中调用 GC、查看内存使用
道理都懂, 但是我们又如何来进行验证呢? 下面我们将在 Node
来验证 WeakMap
、WeakSet
中 弱引用
这一特性, 在开始之前我们需要先清楚在 Node
中如何手动触发 GC
, 如何查看当前内存占用:
- 手动调用
GC
: 在Node
可通过--expose-gc
命令参数, 允许进程手动管理内存, 开启该参数后, 在进程中我们可以通过全局方法gc()
来手动执行GC
node --expose-gc
> gc()
undefined
- 同时在
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
比较感兴趣推荐阅读 《垃圾回收的算法与实现》