面试官:闭包为什么不会被销毁

13,653 阅读10分钟

前言

如标题所示,这是秋招小鹏一面的问题,我回答的很浅,只是解释了闭包的现象,好像没有答到深层次,既然不会,那就来学习下,其实这就是垃圾回收机制的内容了(小鹏 hr 后泡池子中……

我当时的回答是这样的:闭包函数需要用到外层函数的闭包变量,若是被销毁了,window 就无法拿到数据了,很不合理……

闭包

一句话理解闭包就是一个函数以某种方式被外层函数给抛到全局作用域后,仍保留着对外层函数作用域的引用

自然情况下数据是有生命周期的,全局变量的销毁是伴随浏览器的关闭。这里所说的数据是指函数内部的数据,它的生命周期是存在于函数的执行过程中,也就说函数执行完毕是会被销毁掉的,AO(可理解为函数执行上下文)不复存在

比如下面这段代码

1.png

createCounter 函数有个 count 数据,被内层抛出函数所引用,所以 counter 就是个闭包,如何证明这个 counter 闭包没有被销毁呢,我们控制台能拿到就能证明闭包没有被销毁,如下:

2.png

如何进行销毁呢,可以直接给 counter 置为 null 即可 counter = null

现在再试试看能否从浏览器控制台拿到 counter 呢,已经为 null

4.png

垃圾回收机制

js 的垃圾回收机制是有个好处的,它是 v8 引擎的一个功能,可以自动清理所谓的垃圾,相比较什么 C 语言这个垃圾回收机制挺智能的,似乎不用我们担忧内存问题,实则不然……

垃圾回收机制的垃圾的意思并不是说内存大就是垃圾,而是有没有用,这个有没有用又是由人决定的,但是有个问题,有些值我们人自己也不清楚这个值被调用完是否还会用,比如这个🌰

5.png

x 和 y 被调用完后续是否还会使用,我们自己是不清楚的

有些人估计会疑问,x,y 被调用完都没地方调用了,肯定是不要了,因此就是垃圾。这里你可以放到 浏览器 中调用,控制台是可以拿到的,人是可变的,有时候这个值我想要,有时候我又可以认为这个值我不想要了。控制台可以拿到就说明浏览器是不敢动我们无法确定要不要的数据

再举个🌰,值很明确不是垃圾,这是前些天秋招字节一面被问过的闭包输出题,同时也考察了编译原理

6.png

i 到后面一执行就变成了 3,这是因为 var 变量不会形成块级作用域,声明的 i 是通用的,代码从上往下执行,在执行到 result[0]() 时, i 已经累加到 3 了。这里要是不懂可以回看我之前的闭包文章:juejin.cn/post/729683…

这里我想要讲的重点是,我们很明确 i 是有用的,你不知道用户会写几个这样的函数,再或者说这个函数放到了按钮的点击事件中,你为了确定按钮点击有效,这个值将会永远存在,这样理解会更清晰,因此 i 不是垃圾

JavaScript 中的 垃圾回收机制(Garbage Collection, GC)是自动管理内存的一种机制,用于回收不再使用的内存空间,以防止内存泄漏和优化内存使用。通俗点理解就是:垃圾的定义是由人决定的,人决定不要的就不要,但是对于 GC 来讲,它是不清楚我们人是否要还是不要,但是人家唯一可以确定的一点就是,我们代码中若明确不要了,那么 GC 就一定会释放它的空间

比如这个栗子

7.png

x ,y 被数组 [1, 2] 赋值完后,又被其他数据赋值了,那么数组 [1, 2] 就没用了,GC 就会将它清走

现在我们重新来理解内存泄露

内存泄露

对于 GC 来讲,它看到的垃圾一定是人确定了不要的地方,而没有确定的垃圾它是不会去清走的,因此有时候,我们希望上面的 x,y 就是垃圾,但是 GC 又不清走,这部分内存停留在空间中,占用内存空间,我们就可以称之为内存泄露

8.png

因此我们解决内存泄露就是将我们确定不想要的数据通过某种方式,告诉 GC 明确不需要了,GC 才会帮你把那些垃圾清走。刚刚给 x, y 设置为其他数据时,就是告诉 GC 帮我把 [1, 2] 清理掉

至于 GC 是如何清理掉垃圾的,就主要涉及到两种方式了,一个引用计数,一个标记清除,引用计数是早期垃圾回收机制的算法,现在主要是靠标记清除,标记清除的基础上又被划分为分代式(新生、老年)、星历图……

理解闭包导致内存泄露

还是回到这个闭包情形

1.png

我们调用完很容易忘记给 counter 置为 null,我们看似是给 counter 这个函数置为 null,其实它的词法环境就是闭包,给 counter 置为 null 时,它的词法环境也会随之被清走,这个栗子我们看不出收益会有多大,因为闭包空间太小了,但是真实开发中,有些情况下闭包的使用都是伴着大量 dom 的,此时给他解决内存泄露就显得很有必要了

词法环境就是函数执行上下文中的一部分,它一般用于存函数内部 let,const 声明的变量,还有一部分是存 var 声明的变量环境

这个时候你就会发现,闭包导致的内存泄露对于 GC 来讲其实很普通,就像下面这个栗子一样,GC 只是不确定是否销毁这部分空间而已

5.png

销毁闭包主要是去销毁被抛出函数的词法环境的空间

但是又有一种特殊情形, GC 知道这部分空间是明确要回收的,但是自己又回收不了

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <button id="btn">Increase</button>
    <script>
        function createIncrease() {
            const dom = new Array(10000).fill(0).map((_, i) => {
                const div = document.createElement('div'); 
                div.innerHTML = i;
                document.body.appendChild(div); 
                return div;
            });

            function increase() {
            }
            function _temp () {
                dom;
            }

            return increase;
        }

        let increase
        const btn = document.querySelector('#btn'); 
        btn.addEventListener('click', () => {
            increase = createIncrease()
        });
    </script>
</body>

</html>

可以看到 外层函数抛出的函数并没有用上 dom 数据,但是这个数据又被一个没有抛出的函数所引用,调用函数之前,还没有生成 1w 个 dom 元素,调用之后会生成,此时手动 GC 一下,是无法清理这部分 dom 的,我们可以在浏览器 Memory 中看到,live server 运行后点击启动 Memory 查看当前内存,然后点击按钮生成 dom,再点击 扫把 这个按钮去手动 GC,此时再启动 Memory 查看此时的内存,二者进行比较发现多了 1 w 个 HTMLDivElement, 这就是 dom,你手动清除也无效

9.png

这种闭包非常隐蔽,increase 的词法环境是空的,_temp 函数的词法环境是 dom,二者词法环境是放在一起的,这就导致 increase 明明没有引用 dom 这个闭包但是闭包仍然会存在,无法销毁

这就是多个函数共用一个词法环境,词法环境膨胀,导致明明确定要回收的垃圾 GC 无法回收

标记清除

标记-清除(Mark-and-Sweep)是现在的浏览器的 GC 算法,该算法如名字所示就是两个阶段,先标记后清除,并且是定期执行的

标记阶段

  • 从根对象(如全局对象、当前执行上下文的变量等)开始,遍历所有可达的对象,并标记它们。标记是采用的二进制,默认情况下所有对象都是 0 ,若发现具有可达性,那么标记为 1
  • 可达对象是指从根对象出发,通过引用链可以访问到的对象。

清除阶段

  • 遍历内存中的所有对象,清除那些没有被标记为可达的对象,也就是为 0 的对象。
  • 被清除的对象所占用的内存空间将被回收,以便重新分配。

比如这个🌰:

function createObjects() {
    let obj1 = { name: 'Object 1' };
    let obj2 = { name: 'Object 2' };
    let obj3 = { name: 'Object 3' };

    obj1.ref = obj2; // obj1 引用 obj2
    obj2.ref = obj3; // obj2 引用 obj3

    return obj1; // 返回 obj1
}

let root = createObjects(); // 根对象引用 obj1
console.log(root);

这里的 root 位于全局,就是根对象,于是开始遍历可达对象,他引用了 Obj1,obj1 被标记为 1,obj1 引用 obj2,于是将 obj2 标记为 1,obj2 引用 obj3 ,标记 obj3 为 1。清除阶段直接遍历内存中所有的对象,清除标记为 0 的对象

引用计数

引用计数(Reference Counting)是早期的 GC 算法,虽然效率高(实时执行),但是有很大的缺陷,之所以被标记清除替代是因为引用计数无法处理循环引用问题。

引用计数是去维护每个对象的引用计数来管理内存,当每个对象被引用时,它的引用计数就会增加,当一个引用被删除时,其引用计数就会减少,直到一个对象的引用计数为 0 时,表示该对象不会再被引用,可以被回收

function createObjects() {
    let obj1 = { name: 'Object 1' };
    let obj2 = { name: 'Object 2' };

    obj1.ref = obj2; // obj1 引用 obj2
    obj2.ref = obj1; // obj2 引用 obj1

    return obj1; // 返回 obj1
}

let root = createObjects(); // 根对象引用 obj1
console.log(root);

当一个对象被创建时,他们的引用计数都分别为 1, obj1 和 obj2 互相循环引用,分别变成 2,root 对象 引用 obj1 时,obj1 的引用计数仍为 2,当 root 变量被删除或者重新赋值时,obj1 的引用计数减少为 1,没有到 0,导致内存泄露……

标记清除可以解决这种循环引用问题,因为他是通过看根对象的可达性,最后设置一个 root = null,obj1 和 obj2 就没有关系了,但是若用引用计数来看,它的算法最终看的是对象的引用计数,obj1 和 obj2 因为存在循环引用导致它们的引用计数永远不会变成 0。

最后

所以为什么闭包不会被销毁?如今的浏览器 GC 算法采用的都是标记清除,标记清除的依据是看对象的可达性,而闭包对 GC 来讲,本质上是内部函数的词法环境保持了对外部作用域变量的引用,因此这个对象是具备可达性这个条件的,被打上了 1 的二进制,因此不会被销毁。若是早期的浏览器采用的引用计数,引用计数清理垃圾的依据是看该对象的引用计数是否为 0 ,而闭包引用了外部作用域,那些对象的引用计数会 ++,不会回归到 0 ,因此不会销毁

文章中若出现错误内容还请各位大佬见谅并指正。如果有任何问题或建议,欢迎指出,另外,有不懂之处欢迎在评论区留言。如果觉得文章对你的学习有所帮助,还请 关注、点赞、收藏 一键三连,感谢支持!欢迎关注我的公众号: Dolphin_Fung