前言

本文只是自己的理解,有错误的话一起讨论吧。

垃圾回收

像 C 这样的底层语言,通常都提供了管理内存的接口,例如 malloc 和 free 函数,开发者可以精细的使用内存,写出又小又快的程序,但也增加了开发难度和心智负担,容易出现内存泄漏等问题,且往往难以排查。

像 JS 这样的高级语言,为了开发效率和体验,都具有内存管理的功能,自动在合适的地方分配内存,再由垃圾回收器(GC)定期检查内存中的对象,找出不再使用的对象,然后释放其内存。

本文并不关心 GC 的工作原理、具体算法,不过研究内存泄露又不得不提 GC,所以就先简单了解下 GC 吧。

GC 在管什么

在 JS 中,数据分为两种类型:基本数据类型引用数据类型。详见:数据类型与拷贝

基本数据类型存储在栈上,其大小固定,生命周期明确,内存在出栈时自动释放,无需 GC 的管理。而引用数据类型存储在堆上,在栈空间中只保留了数据在堆中的地址,大小不固定,生命周期不确定,需要 GC 来管理。

堆中的数据在不再使用此对象的时候释放。这里的对象不单指 JS 的引用数据类型,还包括了函数作用域(词法环境)。

现代浏览器使用标记清除算法(或其改进)来判断一个对象是否不再使用,该算法从根对象(globalThis)开始,标记每一个可以被触及的对象(可达性),最后清除没有被标记的对象。

内存泄露

不再用到的内存,没有及时释放,就叫做内存泄漏。

JS 中可以更具体为不再用到的对象仍然被引用,导致 GC 无法回收这部分内存。

常见的内存泄露

使用 FinalizationRegistry 当注册的对象被 GC 回收时,会调用回调函数。

1
2
3
4
// 创建一个回收注册器
const cleanup = new FinalizationRegistry((key) => {
console.log("清理", key);
});

1、全局变量:全局变量在全局作用域中,其生命周期和页面一样长,除非手动清除引用或页面关闭,否则不会被释放。

1
2
3
let arr = [1, 2, 3];
cleanup.register(arr, "arr");
// arr = null; // 手动清除引用

2、console.log:被打印的对象会被引用,无法被 GC 回收。

1
2
3
4
let obj = { a: 1, b: 2 };
cleanup.register(obj, "obj"); // 无输出,即没有被回收
console.log(obj);
obj = null; // 即使手动清除引用,也无法释放obj,因为其被console.log引用

3、定时器:使用完毕的定时器未被清除,会一直占用内存。

1
2
3
4
5
let timer = setInterval(() => {
console.log("定时器");
}, 1000);
// clearInterval(timer);
timer = null; // 清除的只是定时器ID,定时器还是会继续执行

闭包

闭包是一个很抽象的东西,所以不能光谈概念,应该直接看浏览器(V8引擎)是如何处理闭包的。

MDN 对其定义:闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。

首先实现一个非常经典的闭包:为了方便观察,嵌套三层函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function createFoo() {
const doms = Array.from({ length: 100000 }, () => {
return document.createElement("div");
});

function foo() {
const obj = {};

function bar() {
obj;
doms;
return
}
return bar;
}
return foo();
}
let foo = createFoo();
console.dir(foo)

通过 console.dir 输出 foo 函数对象,可以看到 [[Scopes]] 属性,里面赫然就有 Closure 闭包

[[Scopes]] 是一个内部属性,表示函数的作用域链。它是一个数组,其中的每个元素都是一个对象,表示一个作用域。

1
2
3
4
5
Scopes[4]
0: Closure (foo) {obj: {…}}
1: Closure (createFoo) {doms: Array(100000)}
2: Script {cleanup: FinalizationRegistry, foo: ƒ}
3: Global {window: Window, self: Window, document: document, name: '', location: Location, …}

作用域

作用域是定义变量和函数的区域,它是静态的,在编译时决定。函数作用域确定了变量的可访问性和生命周期,但并不确定其值

全局环境有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。

1
2
3
4
// 整个脚本的作用域
2: Script {cleanup: FinalizationRegistry, foo: ƒ}
// 全局作用域,包含 window、document 等全局变量
3: Global {window: Window, self: Window, document: document, name: '', location: Location, …}

除了 Script 和 Global,还有两个 Closure 闭包,它们并不等同于函数作用域,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createFoo() {
const a = {};
const b = {};

function foo() {
const c = {};
foo;
a;
return;
}
return foo;
}
let foo = createFoo();
console.dir(foo);

Closure (createFoo) 中只有 a、foo 两个变量,而 b 变量并没有被引用。变量 c 在 foo 函数作用域内,不在闭包中。

1
2
3
4
Scopes[3]
0: Closure (createFoo) {a: {…}, foo: ƒ}
1: Script {cleanup: FinalizationRegistry, foo: ƒ}
2: Global {window: Window, self: Window, document: document, name: '', location: Location, …}

我们可以得出显而易见的结论,闭包是其对应函数作用域的子集

调试查看作用域

并没有 API 可以获取某个函数作用域的对象,但可以通过浏览器调试来查看作用域内的东西。

作用域区域包含了当前函数可以访问的作用域,其中本地就包含了该函数自己作用域的东西。

不过这里的作用域也并不是真正的、静态的函数作用域,而是执行上下文

作用域和执行上下文是两个不同但强相关的概念,作用域是静态的,执行上下文是动态的,是函数在执行时创建的环境。执行上下文包含 this(指向当前函数执行时的上下文对象) 、作用域内变量的引用、作用域链。

正因为是执行上下文,所以此时 foo 并没有执行,自然也无法查看到 foo 的作用域。下面让 foo 执行一下。

闭包(createFoo)出现了。

作用域链

JS 其中一个特性就是允许在函数中定义函数。每个函数都有自己的作用域,而 V8 也会创建全局作用域。这样就形成了作用域链。作用域链保证了执行环境里有权访问的变量和函数是有序的。

JS 的作用域是词法作用域,作用域链的顺序是按照函数定义时的位置来决定的。即始终是函数作用域–> 脚本作用域 ->全局作用域

在调用一个变量时,会在当前函数的作用域中查找,如果没有找到,就会沿着作用域链向上查找,直到找到全局作用域。

闭包的特性也就出现了:开发者可以从内部函数访问外部函数的作用域。

解析过程

一切看起来都很美好,只需要把 JS 代码整体解析一遍,就能确定作用域链,还能进行优化,将用不到的变量清除掉,然后执行即可。

但 JS 是边解析边执行的,并且实现了惰性解析。以一段闭包代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
let foo = createFoo();
foo();
function createFoo() {
const a = {};
const b = {};

function foo() {
const c = {};
a;
return;
}
return foo;
}

存在问题的惰性解析过程:

  1. 提升:首先进入了当前脚本作用域,在当前作用域进行变量提升函数提升,所以能在 createFoo 函数声明之前调用它。
  2. 跳过函数代码:当解析到 createFoo 函数声明时,因为惰性解析,会跳过函数内部的代码,仅生成一个函数对象,并不会为其生成 AST 和字节码。
  3. 执行:生成完脚本顶层代码的抽象语法树后,就开始自上而下执行代码。
  4. 调用 createFoo 函数,从函数对象中取出函数代码,和前面的过程一样,解析代码,跳过内部的 foo 函数代码,只生成其函数对象。
  5. 然后将 createFoo 函数推入调用栈中执行,并创建执行上下文
  6. 执行完成后出栈,返回 foo 函数对象给全局变量 foo,并销毁 createFoo 函数的执行上下文。
  7. 然后调用 foo 函数,同样的过程,会发现找不到变量 a 了,因为 a 在 createFoo 的执行上下文中,已经被销毁了,无法实现闭包。

也就是说,JS 引擎需要知道一个函数是否引用了外部变量,而预解析器正是负责这个过程。

预解析器

当遇到函数声明时,并不会真的直接跳过函数代码,而是让预解析器对函数代码进行一次快速解析。用于检查函数内部是否引用了外部变量,这里的检查是彻底的、有针对性的,不会跳过任何代码,包括函数内部的函数代码。

预解析器会创建一个闭包对象,每当解析到一个变量被内部函数引用时,就将这个变量的引用放入闭包对象中。最后将该闭包对象放入所有内部函数对象的 [[Scopes]] 数组中。所以闭包是其对应函数作用域的子集

而作用域链的实现也就是遍历 [[Scopes]] 数组,按顺序查找变量。

看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
createFoo();
function createFoo() {
const a = {};
const b = {};
console.dir(foo)

function foo() {
a;

function bar() {
b;
}
return bar;
}
return foo;
}

foo 函数没有被执行,而在执行 createFoo 函数时,foo 函数已经被预解析成函数对象,并且可以看到其 createFoo 函数闭包,包含了 a、b 两个变量的引用。

由于 a、b 对象仍然被引用,所以在销毁 createFoo 函数执行上下文时,GC 不会在堆中回收 a、b 对象。

闭包/作用域链是“继承”的

子函数会继承父函数的 [[Scopes]] 属性。从作用域上来讲,这是捕获机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let foo = createFoo();
const bar = foo();
console.dir(bar)

function createFoo() {
const a = {};
const b = {};

function foo() {
a;
b;

function bar() {}

return bar;
}
return foo;
}

输出:

1
2
[[Scopes]]: Scopes[3]
0: Closure (createFoo) {a: {…}, b: {…}}

即使 bar 函数没有引用 createFoo 函数作用域的 a、b 变量,但其父函数 foo 引用了,所以 bar 函数对象的 [[Scopes]] 属性中也包含了 createFoo 函数的闭包对象。

从 JS 对象上来讲,这类似继承行为,这么说也比较好理解,但实际上是词法作用域链的捕获机制
在函数定义时,所有嵌套函数(即使是更深层的嵌套函数)都会关联到它们的父级作用域链,确保在函数调用时可以访问正确的外部变量,即使子函数并未显式使用这些变量。换句话说,bar 函数的 [[Scopes]] 属性在定义时就已经包含了 foo 的作用域链,这样即使 bar 在之后执行,也能正确解析作用域链。

这可能会导致内存泄露,我们后面再说。

总结闭包

闭包可以从两方面说:

  1. 在 JS 规范上,闭包是允许函数访问其外部作用域变量的机制。
  2. 在引擎实现上,闭包是对应函数作用域的子集对象,包含了该函数中被其内部函数引用了的变量的引用。该闭包对象会放入所有内部函数对象的 [[Scopes]] 属性中,作为作用域链的一部分。
  3. 子函数对象会继承父函数对象的 [[Scopes]] 属性。

脚本作用域和全局作用域总是会在 [[Scopes]] 中。而函数作用域只有在其变量被引用时,才会形成闭包对象出现在内部函数的 [[Scopes]] 中。

闭包与内存泄露

了解了闭包与作用域,就明白为什么闭包可能会导致内存泄露了。

看下面这个例子,doms 这 10w 个 dom 对象将不会被 GC 回收,如果你完全不明白为什么,应该回去看看上面的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function createFoo() {
const doms = Array.from({ length: 100000 }, () => {
return document.createElement("div");
});
cleanup.register(doms, "doms");

// bar没有被返回,只有他通过闭包调用了doms。
function bar() {
doms;
}
function foo() {}

return foo;
}
let foo = createFoo();
foo();

如果你不清楚 [[Scopes]],不清楚作用域,不知道闭包的实现,那么看起来就像是 GC 出 BUG 了一样,一个看起来永远也无法触及的 doms 对象居然没有被回收,闭包导致了内存泄露!

发生了什么

在执行 createFoo 函数时,输出一下 foo 函数对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createFoo() {
const doms = Array.from({ length: 100000 }, () => {
return document.createElement("div");
});
cleanup.register(doms, "doms");
console.dir(foo); // 加上这行

function bar() {
doms;
}
function foo() {}

return foo;
}
let foo = createFoo();

可以看到 foo 函数对象的 [[Scopes]] 中的 createFoo 函数的闭包对象,包含了 doms 对象。

回顾总结闭包时的话:

在引擎实现上,闭包是对应函数作用域的子集对象,包含了该函数中被其内部函数引用了的变量的引用。该闭包对象会放入所有内部函数对象的 [[Scopes]] 属性中,作为作用域链的一部分。

注意是所有,所以 doms 仍然被引用着,而 foo 是脚本作用域的对象,自然不会被 GC 回收。

闭包对象继承导致内存泄露

子函数会继承父函数的 [[Scopes]] 属性,这也会导致内存泄露。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const bar = createFoo()();

function createFoo() {
const a = Array.from({ length: 100000 }, () => {
return document.createElement("div");
});
cleanup.register(a, "a");

function foo() {
a;
console.dir(bar);

function bar() {}

return bar;
}
return foo;
}

即使 bar 中没有访问 doms,但 foo 用到了,因为闭包对象继承,所以 bar 函数对象的 [[Scopes]] 属性中也包含了 doms 对象。即使再也无法触及 doms,但它仍然被引用,无法被 GC 回收。

with 函数(已弃用)

with 函数用于将某个对象添加到作用域链的顶部。

该函数已被弃用,并在严格模式中禁止,但挺有趣的,了解一下也不错。

1
2
3
4
5
6
7
8
var a, x, y;
var r = 10;
with (Math) {
// 该作用域链中包含了 Math 对象,能直接找到 PI、cos、sin 属性。
a = PI * r * r;
x = r * cos(PI);
y = r * sin(PI / 2);
}

总结

闭包确实可能导致隐藏的内存泄露,但这并不是闭包本身的问题,闭包这个机制使 JS 语言更加灵活。

参考

「硬核JS」你真的了解垃圾回收机制吗
MDN-闭包
MDN-内存管理
垃圾回收-现代 JavaScript 教程
JS的垃圾回收机制
JavaScript 内存泄漏教程-阮一峰
C++ 的高性能垃圾回收(GC)-v8
js垃圾回收机制
Javascript中的垃圾回收(GC)
垃圾回收-JavaScript Guidebook
学习Javascript闭包(Closure)-阮一峰
图解 Google V8 — V8是如何实现闭包的?
JS-V8引擎的闭包优化-秒懂