前言
本文只是自己的理解,有错误的话一起讨论吧。
不得不承认,GC已经为开发者”擦好了屁股”,在浏览器环境中,通过JS写不出正儿八经的传统内存泄露,但是闭包是一个例外,它能导致开发者无法通过代码访问到的对象仍然被引用,不能被GC回收,从而类似内存泄露。
抠字眼不是本文的目的,什么是内存泄露也不是本文的重点,了解闭包、了解作用域才是本文内容。
垃圾回收
像 C 这样的底层语言,通常都提供了管理内存的接口,例如 malloc 和 free 函数,开发者可以精细的使用内存,写出又小又快的程序,但也增加了开发难度和心智负担,容易出现内存泄漏等问题,且往往难以排查。
像 JS 这样的高级语言,为了开发效率和体验,都具有内存管理的功能,自动在合适的地方分配内存,再由垃圾回收器(GC)定期检查内存中的对象,找出不再使用的对象,然后释放其内存。
本文并不关心 GC 的工作原理、具体算法,不过研究内存泄露又不得不提 GC,所以就先简单了解下 GC 吧。
GC 在管什么
在 JS 中,数据分为两种类型:基本数据类型和引用数据类型。详见:数据类型与拷贝
基本数据类型存储在栈上,其大小固定,生命周期明确,内存在出栈时自动释放,无需 GC 的管理。而引用数据类型存储在堆上,在栈空间中只保留了数据在堆中的地址,大小不固定,生命周期不确定,需要 GC 来管理。
堆中的数据在不再使用此对象的时候释放。这里的对象不单指 JS 的引用数据类型,还包括了函数作用域(词法环境)。
现代浏览器使用标记清除算法(或其改进)来判断一个对象是否不再使用,该算法从根对象(globalThis)开始,标记每一个可以被触及的对象(可达性),最后清除没有被标记的对象。
内存泄露
不再用到的内存,没有及时释放,就叫做内存泄漏。
JS 中可以更具体为不再用到的对象仍然被引用,导致 GC 无法回收这部分内存。
常见的内存泄露
使用 FinalizationRegistry 当注册的对象被 GC 回收时,会调用回调函数。
1 | // 创建一个回收注册器 |
1、全局变量:全局变量在全局作用域中,其生命周期和页面一样长,除非手动清除引用或页面关闭,否则不会被释放。
1 | let arr = [1, 2, 3]; |
2、console.log:被打印的对象会被引用,无法被 GC 回收。
1 | let obj = { a: 1, b: 2 }; |
3、定时器:使用完毕的定时器未被清除,会一直占用内存。
1 | let timer = setInterval(() => { |
闭包
闭包是一个很抽象的东西,所以不能光谈概念,应该直接看浏览器(V8引擎)是如何处理闭包的。
MDN 对其定义:闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。
首先实现一个非常经典的闭包:为了方便观察,嵌套三层函数。
1 | function createFoo() { |
通过 console.dir 输出 foo 函数对象,可以看到 [[Scopes]]
属性,里面赫然就有 Closure 闭包。
[[Scopes]]
是一个内部属性,表示函数的作用域链。它是一个数组,其中的每个元素都是一个对象,表示一个作用域。
1 | Scopes[4] |
作用域
作用域是定义变量和函数的区域,它是静态的,在编译时决定。函数作用域确定了变量的可访问性和生命周期,但并不确定其值。
全局环境有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。
1 | // 整个脚本的作用域 |
除了 Script 和 Global,还有两个 Closure 闭包,它们并不等同于函数作用域,看下面的代码:
1 | function createFoo() { |
Closure (createFoo)
中只有 a、foo 两个变量,而 b 变量并没有被引用。变量 c 在 foo 函数作用域内,不在闭包中。
1 | Scopes[3] |
我们可以得出显而易见的结论,闭包是其对应函数作用域的子集。
调试查看作用域
并没有 API 可以获取某个函数作用域的对象,但可以通过浏览器调试来查看作用域内的东西。
作用域区域包含了当前函数可以访问的作用域,其中本地就包含了该函数自己作用域的东西。
不过这里的作用域也并不是真正的、静态的函数作用域,而是执行上下文。
作用域和执行上下文是两个不同但强相关的概念,作用域是静态的,执行上下文是动态的,是函数在执行时创建的环境。执行上下文包含 this(指向当前函数执行时的上下文对象) 、作用域内变量的引用、作用域链。
正因为是执行上下文,所以此时 foo 并没有执行,自然也无法查看到 foo 的作用域。下面让 foo 执行一下。
闭包(createFoo)出现了。
作用域链
JS 其中一个特性就是允许在函数中定义函数。每个函数都有自己的作用域,而 V8 也会创建全局作用域。这样就形成了作用域链。作用域链保证了执行环境里有权访问的变量和函数是有序的。
JS 的作用域是词法作用域,作用域链的顺序是按照函数定义时的位置来决定的。即始终是函数作用域–> 脚本作用域 ->全局作用域。
在调用一个变量时,会在当前函数的作用域中查找,如果没有找到,就会沿着作用域链向上查找,直到找到全局作用域。
闭包的特性也就出现了:开发者可以从内部函数访问外部函数的作用域。
解析过程
一切看起来都很美好,只需要把 JS 代码整体解析一遍,就能确定作用域链,还能进行优化,将用不到的变量清除掉,然后执行即可。
但 JS 是边解析边执行的,并且实现了惰性解析。以一段闭包代码为例:
1 | let foo = createFoo(); |
存在问题的惰性解析过程:
- 提升:首先进入了当前脚本作用域,在当前作用域进行变量提升、函数提升,所以能在 createFoo 函数声明之前调用它。
- 跳过函数代码:当解析到 createFoo 函数声明时,因为惰性解析,会跳过函数内部的代码,仅生成一个函数对象,并不会为其生成 AST 和字节码。
- 执行:生成完脚本顶层代码的抽象语法树后,就开始自上而下执行代码。
- 调用 createFoo 函数,从函数对象中取出函数代码,和前面的过程一样,解析代码,跳过内部的 foo 函数代码,只生成其函数对象。
- 然后将 createFoo 函数推入调用栈中执行,并创建执行上下文。
- 执行完成后出栈,返回 foo 函数对象给全局变量 foo,并销毁 createFoo 函数的执行上下文。
- 然后调用 foo 函数,同样的过程,会发现找不到变量 a 了,因为 a 在 createFoo 的执行上下文中,已经被销毁了,无法实现闭包。
也就是说,JS 引擎需要知道一个函数是否引用了外部变量,而预解析器
正是负责这个过程。
预解析器
当遇到函数声明时,并不会真的直接跳过函数代码,而是让预解析器对函数代码进行一次快速解析。用于检查函数内部是否引用了外部变量,这里的检查是彻底的、有针对性的,不会跳过任何代码,包括函数内部的函数代码。
预解析器会创建一个闭包对象,每当解析到一个变量被内部函数引用时,就将这个变量的引用放入闭包对象中。最后将该闭包对象放入所有内部函数对象的 [[Scopes]]
数组中。所以闭包是其对应函数作用域的子集。
而作用域链的实现也就是遍历 [[Scopes]]
数组,按顺序查找变量。
看下面这段代码:
1 | createFoo(); |
foo 函数没有被执行,而在执行 createFoo 函数时,foo 函数已经被预解析成函数对象,并且可以看到其 createFoo 函数闭包,包含了 a、b 两个变量的引用。
由于 a、b 对象仍然被引用,所以在销毁 createFoo 函数执行上下文时,GC 不会在堆中回收 a、b 对象。
闭包/作用域链是“继承”的
子函数会继承父函数的 [[Scopes]]
属性。从词法作用域上来讲,这是捕获机制。
1 | let foo = createFoo(); |
输出:
1 | [[Scopes]]: Scopes[3] |
即使 bar 函数没有引用 createFoo 函数作用域的 a、b 变量,但其父函数 foo 引用了,所以 bar 函数对象的 [[Scopes]]
属性中也包含了 createFoo 函数的闭包对象。
从 JS 对象上来讲,这类似继承行为,这么说也比较好理解,但实际上是词法作用域链的捕获机制。
在函数定义时,所有嵌套函数(即使是更深层的嵌套函数)都会关联到它们的父级作用域链,确保在函数调用时可以访问正确的外部变量,即使子函数并未显式使用这些变量。换句话说,bar 函数的 [[Scopes]] 属性在定义时就已经包含了 foo 的作用域链,这样即使 bar 在之后执行,也能正确解析作用域链。
这可能会导致内存泄露,我们后面再说。
总结闭包
闭包可以从两方面说:
- 在 JS 规范上,闭包是允许函数访问其外部作用域变量的机制。
- 在引擎实现上,闭包是对应函数作用域的子集对象,包含了该函数中被其内部函数引用了的变量的引用。该闭包对象会放入所有内部函数对象的
[[Scopes]]
属性中,作为作用域链的一部分。 - 子函数对象会继承父函数对象的
[[Scopes]]
属性。
脚本作用域和全局作用域总是会在 [[Scopes]]
中。而函数作用域只有在其变量被引用时,才会形成闭包对象出现在内部函数的 [[Scopes]]
中。
闭包与内存泄露
了解了闭包与作用域,就明白为什么闭包可能会导致内存泄露了。
看下面这个例子,doms 这 10w 个 dom 对象将不会被 GC 回收,如果你完全不明白为什么,应该回去看看上面的内容。
1 | function createFoo() { |
如果你不清楚 [[Scopes]]
,不清楚作用域,不知道闭包的实现,那么看起来就像是 GC 出 BUG 了一样,一个看起来永远也无法触及的 doms 对象居然没有被回收,闭包导致了内存泄露!
发生了什么
在执行 createFoo 函数时,输出一下 foo 函数对象。
1 | function createFoo() { |
可以看到 foo 函数对象的 [[Scopes]]
中的 createFoo 函数的闭包对象,包含了 doms 对象。
回顾总结闭包时的话:
在引擎实现上,闭包是对应函数作用域的子集对象,包含了该函数中被其内部函数引用了的变量的引用。该闭包对象会放入所有内部函数对象的
[[Scopes]]
属性中,作为作用域链的一部分。
注意是所有,所以 doms 仍然被引用着,而 foo 是脚本作用域的对象,自然不会被 GC 回收。
foo 可能并不在全局或脚本作用域中,但只要 foo 函数仍然存在,那么其 [[Scopes]] 也就引用着开发者无法访问的 doms 对象,我认为这就是发生了内存泄露。
闭包对象继承导致内存泄露
子函数会继承父函数的 [[Scopes]]
属性,这也会导致内存泄露。
1 | const bar = createFoo()(); |
即使 bar 中没有访问 doms,但 foo 用到了,因为闭包对象继承,所以 bar 函数对象的 [[Scopes]]
属性中也包含了 doms 对象。即使再也无法触及 doms,但它仍然被引用,无法被 GC 回收。
with 函数(已弃用)
with 函数用于将某个对象添加到作用域链的顶部。
该函数已被弃用,并在严格模式中禁止,但挺有趣的,了解一下也不错。
1 | var a, x, y; |
总结
闭包确实可能导致隐藏的内存泄露,但这并不是闭包本身的问题,闭包这个机制使 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