惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - 三生石上(FineUI控件)
L
LangChain Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
Blog — PlanetScale
Blog — PlanetScale
H
Hackread – Cybersecurity News, Data Breaches, AI and More
D
Docker
T
Tailwind CSS Blog
T
The Blog of Author Tim Ferriss
U
Unit 42
B
Blog
N
Netflix TechBlog - Medium
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
O
OpenAI News
M
MIT News - Artificial intelligence
D
DataBreaches.Net
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
L
LINUX DO - 热门话题
C
CERT Recently Published Vulnerability Notes
V
Visual Studio Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
N
News and Events Feed by Topic
Vercel News
Vercel News
T
Tenable Blog
Security Latest
Security Latest
C
Check Point Blog
云风的 BLOG
云风的 BLOG
PCI Perspectives
PCI Perspectives
月光博客
月光博客
TaoSecurity Blog
TaoSecurity Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Project Zero
Project Zero
雷峰网
雷峰网
IT之家
IT之家
H
Hacker News: Front Page
Microsoft Security Blog
Microsoft Security Blog
B
Blog RSS Feed
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Last Week in AI
Last Week in AI
G
Google Developers Blog
Forbes - Security
Forbes - Security
The Register - Security
The Register - Security
Cyberwarzone
Cyberwarzone
小众软件
小众软件
Martin Fowler
Martin Fowler
K
Kaspersky official blog
P
Proofpoint News Feed
T
Threatpost
Google Online Security Blog
Google Online Security Blog
Microsoft Azure Blog
Microsoft Azure Blog

轻笑Chuckle

NestJS实践杂记 React-记-3 React-记-2 React-记-1 Vue3.5 更新了啥 Git lint 相关 集成 lint 代码规范工具 Monorepo 单仓库多应用 Vue+TS 实现虚拟列表 开发了一个 Canvas 2D 渲染引擎 Canvas 2D 事件 Canvas 2D 贝塞尔曲线 Canvas 2D 进阶 Canvas 2D 基础 Git 规范与实践 Git 熟知熟用 解决System占CPU过高 浏览器HTTP缓存 浏览器渲染流程
闭包与内存泄漏
『轻笑Chuckle』 · 2024-09-12 · via 轻笑Chuckle

前言

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

不得不承认,GC已经为开发者”擦好了屁股”,在浏览器环境中,通过JS写不出正儿八经的传统内存泄露,但是闭包是一个例外,它能导致开发者无法通过代码访问到的对象仍然被引用,不能被GC回收,从而类似内存泄露。
抠字眼不是本文的目的,什么是内存泄露也不是本文的重点,了解闭包、了解作用域才是本文内容。

垃圾回收

像 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");

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

1
2
3
4
let obj = { a: 1, b: 2 };
cleanup.register(obj, "obj");
console.log(obj);
obj = null;

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

1
2
3
4
5
let timer = setInterval(() => {
console.log("定时器");
}, 1000);

timer = null;

闭包

闭包是一个很抽象的东西,所以不能光谈概念,应该直接看浏览器(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: ƒ}

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");


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 回收。

foo 可能并不在全局或脚本作用域中,但只要 foo 函数仍然存在,那么其 [[Scopes]] 也就引用着开发者无法访问的 doms 对象,我认为这就是发生了内存泄露。

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

子函数会继承父函数的 [[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) {

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引擎的闭包优化-秒懂
Node.js Memory Leaks: How to Debug And Avoid Them?
Grokking V8 closures for fun (and profit?)
An interesting kind of JavaScript memory leak
Trash talk: the Orinoco garbage collector
JS Memory Leak And V8 Garbage Collection