原文首发地址:https://juejin.cn/post/7079995358624874509
原文作者: 月夕
网络上流传着许多对闭包的说法,这些说法为了方便理解曲解了闭包的真正原理,本文将会介绍这些原理,并且提供大量demo运行的结果来验证本文的正确性,注意:这可能会颠覆你对闭包的认知,请在家长的陪同下阅读!!!
网络上对闭包的解释基本上都和 MDN 大同小异,“闭包就是访问了自由变量的函数”,其实这是为了大众方便理解而给出的错误结论(即使是这样似乎也有许多人无法理解闭包)
对于闭包产生的内存泄漏,网络中流传的大多数说法都是:“因为子函数执行时父函数的执行上下文已经退出执行上下文栈,但是由于子函数作用域链的引用导致父函数的 活动对象AO
无法被销毁”导致的。
其实上面的这两个广为流传的方法都是错误的,下面我将为你介绍真正的闭包和其内存泄漏的产生原理。
[[Scopes]]
全局代码存储其变量的地方叫做变量对象(VO),函数存储其变量的叫活动对象(AO),VO 和 AO 都是在预编译时确定其内容,然后在代码运行时被修改值。
⚠注意:VO和AO都是在es1、3中才存在的概念,在现在的 es5+ 中已经不存在VO和AO的概念,取而代之的是一个叫做 词法环境(Lexical environment) 的东西,这里搬出来单纯是为了方便大家理解,后面我也将用 词法环境 代替 AO 和 VO 等概念。
关于词法环境可以看看这篇文章 浅析JavaScript词法环境
对于 作用域链
、AO
、VO
如果想详细了解可以 看这里
每一个函数都有一个 [[Scopes]]
属性,其存储的是这个函数运行时的作用域链,除了当前函数的 词法环境LE,作用域链的其他部分都会在其父函数预编译时添加到函数的 [[Scopes]]
属性上(因为父函数也需要预编译后才能确定自己的 函数词法环境(function environment)
),所以 js 的作用域是词法作用域。
// 1: 全局词法环境global.LE = {t,fun}
let t = 111
function fun(){
// 3: fun.LE = {a,b,fun1}
let a = 1
let b = 2
function fun1() {
// 5: fun1.LE = {c}
let c = 3
}
// 4: fun1.[[Scopes]] = [global.LE, fun.LE]
}
// 2: fun.[[Scopes]] = [global.LE]
fun()
上面代码在 fun()
被调用前,会立即预编译 fun
函数,这一步会得到 fun
的词法环境(LE),然后运行 fun 函数,在执行到 let a = 1
的时候,会将变量对象到 a 属性改成 1。后面也是一样
[[Scopes]]
就像一个数组一样,每一个函数的 [[Scopes]]
中都存在当前函数的 LE 和上级函数的 [[Scopes]]
。在函数运行时会优先取距离当前函数 LE 近的变量值,这就是作用域的就近原则。
但是(重点来了)
上面介绍的 [[Scopes]]
可能就是大家熟知的,这在以前是对的。
其实每一个 词法作用域
都会有一个 outer
属性指向其上级 词法作用域
,根据这个 outer
链路完全可以构成作用域链,为什么要多此一举弄一个 Closure
出来呢?
这就是涉及到闭包和内存泄漏问题,如果单纯的通过 outer 链路来实现作用域链,那么存在一个闭包时,就会导致整个作用域链中的所有 词法环境 都无法回收,但是此时如果我们只使用了父级词法环境中的一个变量,而 V8 为了让我们能使用这个一个变量付出如此大的内存代价,很显然是不值得的。而 [[Scopes]]
+ Closure
就是他们的解决方案。
所以现在的 V8 中已经发生了改变(Chrome 中已经可以看到这些变化),在为一个函数绑定词法作用域时,并不会粗暴的直接把父函数的 LE 放入其 [[Scopes]]
中,而是会分析这个函数中会使用父函数的 LE 中的哪些变量,而这些可能会被使用到的变量会被存储在一个叫做 Closure
的对象中,每一个函数都有且只有一个 Closure
对象,最终这个 Closure
将会代替父函数的 LE 出现在子函数的 [[Scopes]]
中
网络上的说法是:父函数的 AO 直接会被放入子函数的
[[Scopes]]
中,也没有提到 LE 和Closure
对象,很明显这放在现在来看是不对的,当前后面我会给出例子证明。
Closure
在V8中每一个函数执行前都会进行预编译,预编译阶段都会执行3个重要的字节码
也就是说,每一个函数执行前都会创建一个闭包,无论这个闭包是否被使用,那么闭包中的内容是什么?如何确定其内容?
Closure
跟 [[Scopes]]
一样会在函数预编译时被确定,区别是当前函数的 [[Scopes]]
是在其父函数预编译时确定, 而 Closure
是在当前函数预编译时确定(在当前函数执行上下文创建完成入栈后就开始创建闭包对象了)。
当 V8 预编一个函数时,如果遇到内部函数的定义不会选择跳过,而是会快速的扫描这个内部函数中使用到的本函数 LE 中的变量,然后将这些变量的引用加入 Closure
对象。再来为这个内部函数函数绑定 [[Scopes]]
,并且使用当前函数的 Closure
作为内部函数 [[Scopes]]
的一部分。
注意:每一次遇到内部声明的函数/方法时都会这么做,无论其内部函数/方法的声明嵌套有多深,并且他们使用的都是同一个
Closure
对象。并且这个过程 是在预编译时进行的而不是在函数运行时。
// 1: global.LE = {t,fun}
var t = 111
// 2: fun.[[Scopes]] = [global.LE]
function fun(){
// 3: fun.LE = {a,b,c,fun1,obj},并创建一个空的闭包对象fun.Closure = {}
let a = 1,b = 2,c = 3
// 4: 遇到函数,解析到函数会使用a,所以 fun.Closure={a:1} (实际没这么简单)
// 5: fun1.[[Scopes]] = [global.LE, fun.Closure]
function fun1() {
debugger
console.log(a)
}
fun1()
let obj = {
// 6: 遇到函数,解析到函数会使用b,所以 fun.Closure={a:1,b:2}
// 7: method.[[Scopes]] = [global.LE, fun.Closure]
method(){
console.log(b)
}
}
}
// 执行到这里时,预编译 fun
fun()
1、2发生在全局代码的预编译阶段,3、4、5、6、7发生在 fun 的预编译阶段。
fun1 执行时的作用域链是这样的:[fun1.LE, fun.Closure, global.LE]
我们可以看到 fun1
的作用域链中的确不存在 fun.AO
或者 fun.LE
,而是存在 fun.Closure
。并且 fun.Closure
中的内容是 a
和 b
两个变量,并没有 c
。这足以证明所有子函数使用的是同一个闭包对象。
细心的你会发现 Closure
在 method
的定义执行前就已经包含 b
变量,这说明 Closure
在函数执行前早已确定好了,还有一点就是 Closure
中的变量存储的是对应变量的引用地址,如果这个变量值发生变化,那么 Closure
中对应的变量也会发生变化(后面会证明)
而且这里 fun1
并没有返回到外部调用形成网络上描述的闭包(网络上很多说法是需要返回一个函数才会形成闭包,很显然这也是不对的),而是直接在函数内部同步调用。
结论:每一个函数都会产生闭包,无论 闭包中是否存在内部函数 或者 内部函数中是否访问了当前函数变量 又或者 是否返回了内部函数,因为闭包在当前函数预编译阶段就已经创建了。
是不是有点颠覆到你对闭包的认知了呢?别急,后面还有更多呢。
说到闭包那么就不得不说内存泄漏,首先我们要搞清楚为什么会内存泄漏?
所谓闭包产生的内存泄漏就是因为闭包对象 Closure
无法被释放回收,那么什么情况下 Closure
才会被回收呢?
这当然是在没有任何地方引用 Closure
的时候,因为 Closure
会被所有的子函数的作用域链 [[Scopes]]
引用,所以想要 Closure
不被引用就需要所有子函数都被销毁,从而导致所有子函数的 [[Scopes]]
被销毁,然后 Closure
才会被销毁。
这与许多网络上的资料是不一样的,常见的说法是必须返回的函数中使用的自由变量才会产生闭包,也就是下面这样
function fun(){
let arr = Array(10000000)
return function(){
console.log(arr);// 使用了 arr
}
}
window.f = fun()
随便一提:如果对于上述代码复杂到控制台直接执行然后尝试window.f=null是不会减少内存的,原因是Chrome控制台会打印最后一句代码也就是window.f = fun()的内容,这正好是我们返回的函数。还有就是Chrome打印函数时会对函数持有引用,所以我们执行window.f=null并不会释放 fun() ,因为 fun() 还被控制台持有引用。
如果要解决这个问题可以通过 clear() 来清空控制台或者在window.f = fun()后加其他代码来防止控制台输出这个函数,例如这样:window.f = fun();null
但是其实不然,即使返回的的函数没有访问自由变量,只要有任何一个函数将 arr 添加到闭包对象 Closure
中,arr 都不会正常被销毁,所以下面两段代码都会产生内存泄漏
function fun(){
let arr = Array(10000000)
function fun1(){// arr 加入 Closure
console.log(arr)
}
return function fun2(){}
}
window.f = fun()// 长久持有fun2的引用
因为
fun1
让arr
加入了Closure
,fun2
又被window.f
持有引用无法释放,因为fun2
的作用域链包含Closure
,所以Closure
也无法释放,最终导致arr
无法释放产生内存泄漏。
function fun(){
let arr = Array(10000000)
function fun1() {// arr 加入 Closure
console.log(arr)
}
window.obj = {// 长久持有 window.obj.method 的引用
method(){}
}
}
fun()
同理是因为
window.obj.method
作用域链持有fun1
的Closure
引用导致arr
无法释放。
那么我们将 arr = null
会不会让 arr
被释放呢?答案是会。这里有人可能会疑惑了:
Closure.arr = arr
将 arr
加入到 Closure
,然后将 arr = null
,这为什么会让 Closure.arr
发生变化呢?
这说明将变量加入到 Closure
并不是简单的 Closure.arr = arr
的过程,这是一个引用传递,也就是说 Closure.arr
存储的是对变量 arr
的引用,当 arr
变化时 Closure.arr
也会发生变化。这对于 js 来说可能有点难实现,但是 c++ 借助指针的特性要实现这一点是轻而易举的。
上面我们简单的介绍了一下闭包产生内存泄漏的根本原因是因为 Closure
被其所有子函数的作用域链引用,只要有一个子函数没有销毁,Closure
就无法销毁,导致其中的变量也无法销毁,最终产生了内存泄漏。
什么?看了这么多你告诉我你还不知道怎么看是否发生了内存泄漏?
打开Chrome浏览器的控制台的 Performance monitor,看到 JS heap size 变化曲线了吗?如果他不断上升并且你 点击 Memory 中这个垃圾回收的按钮后它依然没有下降到正常值,那么你的代码大概率是发生了内存泄漏,
现在我执行了一段上面的demo,可以看到内存大小是上升了一个量级
过了一段时间发现他并没有下降的趋势,即使我手动点击垃圾回收按钮,内存也没有回到最开始的正常值,很明显,这就是内存泄漏
如果你还没有捣鼓出这个界面来,建议先暂停一下然后去 谷歌 一下,因为后面的 demo 我不会贴出运行结果图,需要你自己在电脑上运行查看内存变化。
下面是一个经典的内存泄漏的例子,在大多数与闭包内存泄漏的文章或者书籍中都能看到他的影子
let theThing = null;
let replaceThing = function () {
let leak = theThing;
function unused () {
if (leak){}
};
theThing = {
longStr: new Array(1000000),
someMethod: function () {
}
};
};
let index = 0;
while(index < 100){
replaceThing()
index++;
}
为了防止各位看官轻易尝试导致电脑崩溃,我把原来例子中的 setInterval 换成了一个有限的循环
可能比较容易发现上面代码发生内存泄漏的原因是因为 someMethod
,因为 theThing
是全局变量导致 someMethod
无法释放最终导致 replaceThing
的 Closure
无法释放。 但是 replaceThing
的 Closure
中存在什么呢?
let leak = theThing;
function unused () { // leak 加入 Closure
if (leak){}
};
是的,存在 leak
,又因为 leak
指向的是 theThing
的值,虽然首次执行 replaceThing
时 theThing
是 null
,但是第二次执行 replaceThing
时 theThing
就变为了一个存在大对象的对象了。
Closure
无法释放导致其中的 leak
变量也无法释放,导致 theThing
无法释放theThing
会导致 someMethod
无法释放从而导致 Closure
无法释放可能你已经看了几遍,最终开始看出了问题。没错,这是一个循环,theThing
导致 Closure
无法释放,Closure
又导致另一个 theThing
无法释放…
这段代码参数内存泄漏的原因可以是因为一环扣一环的引用引起的,我们吧第 i
次 replaceThing
执行时产生的 leak
叫做 leaki
,theThing
叫做 theThingi
, Closure
叫做 Closurei
,如果这个函数执行3次,那么它的引用链路应该是这样的:
theThing3(全局作用域) -> someMethod3 -> Closure3 -> leak3 -> theThing2 -> someMethod2 -> Closure2 -> leak2 -> theThing1 -> someMethod1 -> Closure1 -> leak1 -> theThing0 -> null
可见 replaceThing
每执行一次这个链路中就会多一个 theThing
,因为 theThing.longStr
上一个大对象导致内存飙升并且无法回收(引用的源头总是全局的 theThing )。
最粗暴的解决方法肯定是将全局 theThing
变为 null
,这如同切断水流的源头一样。
但是在 replaceThing
的最后将 leak = null
也可以打破这个微妙的引用链路。因为这可以让 Closure
中的 leak
也变为 null
从而失去对 theThing
的引用,当在下一次执行 replaceThing
时会因为 theThing = xxx
导致原来的 theThing
失去最后的引用而回收掉,这也会让 theThing.someMethod
和 Closure
可以被回收。
let theThing = null;
let replaceThing = function () {
let leak = theThing;
function unused () {
if (leak){}
};
theThing = {
longStr: new Array(1000000),
someMethod: function () {
}
};
leak = null // 解决问题
};
let index = 0;
while(index < 100){
replaceThing()
index++;
}
好了,现在我们来吧之前介绍的内容总结一下
不知道这些知识也没有颠覆你对闭包的认知呢?如果对文章有疑问欢迎评论,如果有收获感谢点赞。
在js里,如果父函数中的子函数没有交给外部,那么V8对子访问父的变量还会当做closure(闭包)吗?——知乎
轻松排查线上Node内存泄漏问题——nodejs社区
JavaScript中变量存储在堆中还是栈中?——知乎
JavaScript 深入系列——掘金
JS夯实之执行上下文与词法环境——掘金