一文颠覆大众对闭包的认知

20,285 阅读14分钟

网络上流传着许多对闭包的说法,这些说法为了方便理解曲解了闭包的真正原理,本文将会介绍这些原理,并且提供大量demo运行的结果来验证本文的正确性,注意:这可能会颠覆你对闭包的认知,请在家长的陪同下阅读!!!

闭包 & 内存泄漏

网络上对闭包的解释基本上都和 MDN 大同小异,“闭包就是访问了自由变量的函数”,其实这是为了大众方便理解而给出的错误结论(即使是这样似乎也有许多人无法理解闭包)

对于闭包产生的内存泄漏,网络中流传的大多数说法都是:“因为子函数执行时父函数的执行上下文已经退出执行上下文栈,但是由于子函数作用域链的引用导致父函数的 活动对象AO 无法被销毁”导致的。

其实上面的这两个广为流传的方法都是错误的,下面我将为你介绍真正的闭包和其内存泄漏的产生原理。

作用域链 [[Scopes]]

全局代码存储其变量的地方叫做变量对象(VO),函数存储其变量的叫活动对象(AO),VO 和 AO 都是在预编译时确定其内容,然后在代码运行时被修改值。

⚠注意:VO和AO都是在es1、3中才存在的概念,在现在的 es5+ 中已经不存在VO和AO的概念,取而代之的是一个叫做 词法环境(Lexical environment) 的东西,这里搬出来单纯是为了方便大家理解,后面我也将用 词法环境 代替 AO 和 VO 等概念。

关于词法环境可以看看这篇文章 浅析JavaScript词法环境
对于 作用域链AOVO 如果想详细了解可以 看这里

每一个函数都有一个 [[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个重要的字节码

  1. CreateFunctionContext 创建函数执行上下文
  2. PushContext 上下文入栈
  3. CreateClosure 创建函数的闭包对象

也就是说,每一个函数执行前都会创建一个闭包,无论这个闭包是否被使用,那么闭包中的内容是什么?如何确定其内容?

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 的预编译阶段。

对于 global.LE,不同环境下的 global.LE 内容不一样,浏览器环境下的作用域链顶层是 [window, Script],并且 script 作用域不会产生闭包对象。但是 node 环境下是 [global, Script.Closure] , node 环境下 Script 是会产生闭包的。

fun1 执行时的作用域链是这样的:[fun1.LE, fun.Closure, global.LE] image.png

我们可以看到 fun1 的作用域链中的确不存在 fun.AO 或者 fun.LE ,而是存在 fun.Closure。并且 fun.Closure 中的内容是 ab 两个变量,并没有 c。这足以证明所有子函数使用的是同一个闭包对象。

细心的你会发现 Closuremethod 的定义执行前就已经包含 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()

但是其实不然,即使返回的的函数没有访问自由变量,只要有任何一个函数将 arr 添加到闭包对象 Closure 中,arr 都不会正常被销毁,所以下面两段代码都会产生内存泄漏

function fun(){
    let arr = Array(10000000)
    function fun1(){// arr 加入 Closure
        console.log(arr)
    }
    return function fun2(){}
}
window.f = fun()// 长久持有fun2的引用

因为 fun1arr 加入了 fun.Closurefun2 又被 window.f 持有引用无法释放,因为 fun2 的作用域链同样包含 fun.Closure,所以 fun.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 作用域链持有 funClosure 引用导致 arr 无法释放。

那么我们将 arr = null 会不会让 arr 被释放呢?答案是会。这里有人可能会疑惑了:

Closure.arr = arrarr 加入到 Closure,然后将 arr = null,这为什么会让 Closure.arr 发生变化呢?

image.png

这说明将变量加入到 Closure 并不是简单的 Closure.arr = arr 的过程,这是一个引用传递,也就是说 Closure.arr 存储的是对变量 arr 的引用,当 arr 变化时 Closure.arr 也会发生变化。这对于 js 来说可能有点难实现,但是 c++ 借助指针的特性要实现这一点是轻而易举的。

上面我们简单的介绍了一下闭包产生内存泄漏的根本原因是因为 Closure 被其所有子函数的作用域链引用,只要有一个子函数没有销毁,Closure 就无法销毁,导致其中的变量也无法销毁,最终产生了内存泄漏。

什么?看了这么多你告诉我你还不知道怎么看是否发生了内存泄漏?

打开Chrome浏览器的控制台的 Performance monitor,看到 JS heap size 变化曲线了吗?如果他不断上升并且你 点击 Memory 中这个垃圾回收的按钮后它依然没有下降到正常值,那么你的代码大概率是发生了内存泄漏,

现在我执行了一段上面的demo,可以看到内存大小是上升了一个量级 image.png

过了一段时间发现他并没有下降的趋势,即使我手动点击垃圾回收按钮,内存也没有回到最开始的正常值,很明显,这就是内存泄漏 image.png 如果你还没有捣鼓出这个界面来,建议先暂停一下然后去 谷歌 一下,因为后面的 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 无法释放最终导致 replaceThingClosure 无法释放。 但是 replaceThingClosure 中存在什么呢?

let leak = theThing;
function unused () { // leak 加入 Closure
    if (leak){} 
};

是的,存在 leak,又因为 leak 指向的是 theThing 的值,虽然首次执行 replaceThingtheThingnull,但是第二次执行 replaceThingtheThing 就变为了一个存在大对象的对象了。

  1. 因为 Closure 无法释放导致其中的 leak 变量也无法释放,导致 theThing 无法释放
  2. theThing 会导致 someMethod 无法释放从而导致 Closure 无法释放

可能你已经看了几遍,最终开始看出了问题。没错,这是一个循环,theThing 导致 Closure 无法释放,Closure 又导致另一个 theThing 无法释放......

这段代码参数内存泄漏的原因可以是因为一环扣一环的引用引起的,我们吧第 ireplaceThing 执行时产生的 leak 叫做 leakitheThing 叫做 theThingiClosure 叫做 Closurei,如果这个函数执行3次,那么它的引用链路应该是这样的:

theThing3(全局作用域) -> someMethod3 -> Closure3 -> leak3 -> theThing2 -> someMethod2 -> Closure2 -> leak2 -> theThing1 -> someMethod1 -> Closure1 -> leak1 -> theThing0 -> null

image.png

可见 replaceThing 每执行一次这个链路中就会多一个 theThing,因为 theThing.longStr 上一个大对象导致内存飙升并且无法回收(引用的源头总是全局的 theThing )。

最粗暴的解决方法肯定是将全局 theThing 变为 null,这如同切断水流的源头一样。

但是在 replaceThing 的最后将 leak = null 也可以打破这个微妙的引用链路。因为这可以让 Closure 中的 leak 也变为 null 从而失去对 theThing 的引用,当在下一次执行 replaceThing 时会因为 theThing = xxx 导致原来的 theThing 失去最后的引用而回收掉,这也会让 theThing.someMethodClosure 可以被回收。

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++;
}

总结

好了,现在我们来吧之前介绍的内容总结一下

  • 每一个函数在执行之前都会进行预编译,预编译时会创建一个空的闭包对象。
  • 每当这个函数预编译时遇到其内部的函数声明时,会快速的扫描内部函数使用了当前函数中的哪些变量,将可能使用到的变量加入到闭包对象中,最终这个闭包对象将作为这些内部函数作用域链中的一员。
  • 只有所有内部函数的作用域链都被释放才会释放当前函数的闭包对象,所谓的闭包内存泄漏也就是因为闭包对象无法释放产生的。
  • 我们还介绍的一个巧妙且经典的内存泄漏案例,并且通过一些demo的运行结果证明了上面这些结论的正确性。

不知道这些知识也没有颠覆你对闭包的认知呢?如果对文章有疑问欢迎评论,如果有收获感谢点赞👍。

相关资料

在js里,如果父函数中的子函数没有交给外部,那么V8对子访问父的变量还会当做closure(闭包)吗?——知乎

轻松排查线上Node内存泄漏问题——nodejs社区

JavaScript中变量存储在堆中还是栈中?——知乎

JavaScript 深入系列——掘金

JS夯实之执行上下文与词法环境——掘金