美文网首页前端架构系列程序员
全面攻克js中的堆栈内存及闭包

全面攻克js中的堆栈内存及闭包

作者: 羽晞yose | 来源:发表于2020-06-27 15:23 被阅读0次

    先来两段代码,a 和 o.a 各输出什么?

    let a = 0;
    let b = a;
    b++;
    alert(a);
    
    let o = {};
    o.a = 0;
    let b = o;
    b.a = 10;
    alert(o.a);
    

    应该很多人会回答:a 是 0,o.a 是 10。
    没错,但对了一半,因为alert()方法会将输出结果执行toString(),所以正确答案是:'0' 和 '10'
    这里考察的知识点是对js数据类型的理解,也就是能分得清基础类型和引用类型

    js数据类型可以分为三类:

    1. 基本类型(值类型):Number String Boolean Null Undefined
    2. 引用类型:Object Function
      这里可能有人会回答Array,正则等,但他们其实也是Object,可以把他们理解为 Object 的分支
    3. 其他类型:Symbol
      ES6新增,创建唯一值

    栈内存与堆内存各自的作用

    栈内存:提供代码运行的环境,存储基本类型值
    堆内存:提供引用类型存储的环境空间

    回到开始的地方,将代码一步步解析,看看在浏览器里是怎么执行的(深入V8底层实现原理)。同时使用ProcessOn来绘图,一步步绘制出执行结果


    Step1

    浏览器加载页面后,想要代码自上而下执行,那么它需要一个执行环境,而这个执行环境,就是我们所说的全局作用域,也就是开辟了一个栈内存
    全局作用域专业名词为:ECStack(Exeuction Context Stack)
    翻译过来就是执行环境栈,或者叫执行上下文栈

    ECStack

    Step2

    代码开始执行,解析代码:let a = 0; let b = a;
    所有等号赋值都需要经过三个步骤:
    创建变量 -> 创建值 -> 关联
    每个执行环境都有一个变量对象,也叫值存储区,Variable Object,简写为VO
    那么在值存储区里就会保存变量a,以及它的值0,然后让它们之间关联起来
    接着保存变量b,b = a,所以b也指向0。有人会理解为b = a,所以是将a的值拷贝一份,再将b和新的0进行关联,其实并不是,它们都是指向同样的值0(你可以简单的理解为这是一个优化策略,节省了值的存储)

    创建及保存变量
    更误:String类型并非存储在栈内存当中,而是存储在堆内存当中,其他基本类型值没什么问题,但是对到字符串并非如此,并不是在栈内存中如上所述,具体可参考文章:我不知道的JS之JavaScript内存模型中的堆空间和栈空间

    Step3

    执行代码b++;所以此时VO里多存储了一个值1,一个变量只能关联一个值,所以会先解除b和0的关联关系,并将b跟1相关联。

    b重新关联

    第二段代码,也就是引用类型赋值的,那么它的存储方式又有所不同

    Step1

    let o = {}; o.a = 0; let b = o;
    在上面栈内存与堆内存各自的作用里说了,堆内存是引用类型存储的环境空间。也就是说,当执行到 {} 的时候,发现该值是个引用类型,所以需要将该值存储到堆内存里(前面基本类型值都是存在栈内存当中),然后将0与堆内存的空间地址相关联

    引用类型 - 存储于堆内存

    Step2

    执行代码b.a = 10;
    此时与b关联的存储空间为AAAFFF000,那么就会去到该堆内存里,将保存值10,并将a与10相关联。而o与b都是关联的同一个堆内存空间地址,所以去获取o.a的时候,值也会变为10。

    引用类型 - 存储于堆内存

    以上,就是为什么基本类型值不会相互产生影响,而引用类型的值会更改的底层原理。因为基础类型是与值直接相关联,而引用类型关联的是一个空间地址。


    下面各输出什么?先别往下翻,自行画图并写出输出结果

    let a = {
        n: 1
    };
    
    let b = a;
    
    a.x = a = {
        n: 2
    };
    
    console.log(a.x);
    console.log(b);
    

    这里我就不再画图了,太累,用文字一步步解释吧

    1. 创建变量a,创建值,发现是个引用类型,所以新开一个堆内存(继续假设空间地址为AAAFFF000),存储n: 1
    2. 创建变量b,将b 与 空间地址AAAFFF000相关联
    3. 由于没有创建变量,所以来到 创建值 -> 关联 这一步,发现值是个引用类型,新开一个堆内存(假设空间地址为AAAFFF111),存储n: 2。
    4. a.x,此时a关联的空间地址为AAAFFF000,所以在该堆内存里创建x,值为{n: 2}
    5. a关联空间地址AAAFFF111,但注意,上一步操作已经更改了AAAFFF000,所以这一步虽然改变了a的关联空间,但不会对AAAFFF000产生影响。同时,a.x的关联也被解除,因为a重新关联了新的空间地址

    总结以上代码执行后,目前两个空间地址存储的值:
    AAAFFF000: {n: 1, x: {n: 2}}
    AAAFFF111: {n: 1}

    因此
    第一句:a的关联地址是AAAFFF111,里面没有x,所以输出undefined
    第二句:b的关联地址没变,一直是AAAFFF000,所以输出{n: 1, x: {n: 2}}

    上面的细节点在于:
    一:看到等号,则要记住等号执行的三步操作,由于a.x = a并没有创建变量,所以接下来是创建值和关联
    二: a.x = a = {n: 2}; 等价于a.x = {n: 2}; a = {n: 2}; 注意两句的顺序,对应上面3、4点
    如果文字理解不了,建议按照上面的流程一步步画图理解,加深印象


    GO/VO/AO/EC及作用域和执行上下文

    先来几个名词
    GO:全局对象(Global Object)
    ECStack: 执行环节栈(Exeuction Context Stack)
    EC:执行环境(Exeuction Context,也叫执行上下文)
    |-- VO:变量对象(Variable Object)
    |-- AO:活动对象(Activation Object,函数的叫AO,理解为VO的一个分支)
    Scope:作用域,创建函数的时候赋予
    Scope Chain:作用域链

    这里多了一个词,EC,在上面只说了ECStack,并没有说EC,因为放在函数这块说更合适,也就是之前那篇文章里说的执行上下文三种类型

    • 全局执行上下文
    • 函数执行上下文
    • Eval 函数执行上下文

    先来一段代码:

    let x = 1;
    function A(y) {
        let x = 2;
    
        function B(z) {
            console.log(x+y+z);
        }
    
        return B;
    }
    let C = A(2);
    C(3);
    

    用图文的方式一步步说明代码是如何执行的

    1. 浏览器开启,创建ECStack,用于执行代码

    2. 创建EC(G),全局上下文


      创建EC
    3. 创建完成,进栈,也就是EC进入ECStack,这个过程叫:进栈执行


      进栈执行
    4. 执行let x = 1; function A() {...};,这一步我就不再画堆内存了,画起来还是很浪费时间的。发现有函数,需要添加函数[[scope]]属性。所有在当前的上下文当中,只要创建了函数,那么必然会给函数添加[[scope]]属性。所以说,实际上执行上下文跟作用域本质是两个不同的东西。

      发现函数,添加函数作用域[[scope]]属性
    5. 执行c = A(2);,要执行函数A,那么需要创建新的上下文,把EC(G)压至栈底,然后进栈执行。

      A函数进栈执行
    6. 执行函数A,并把2赋值给形参y。
      执行函数前,需要做一些准备工作,先记录自己的作用域[scope] -> AO(A),还有作用域链scopeChain,scopeChain保存着函数的链式关系(也就是上一层作用域是谁,再上一层又是谁),当某个变量在该作用域中查找不到的时候,就会去上层作用域查找。准备工作完成就能执行函数了,创建属性arguments(因为arguments是类数组,所以我这里就用[0: 2]来表示)及其他函数中的变量。作用域是在函数创建的时候就有的,而作用域链是在函数执行的时候才产生的

      执行函数
    1. 最后一句执行c的我就不再画了,原理同上,创建EC(B)巴拉巴拉巴拉…

    最后附上伪代码

    // 第一步:创建全局执行上下文,并将其压入ECStack中
    ECStack = [
        // 全局执行上下文
        EC(G): {
            ..., // 包含全局对象原有属性
            x = 1;
            A = function(y){...};
            A[[scope]] = VO(G); // 创建函数的时候就确定了其作用域
        }
    ]
    
    // 第二步:执行函数A(2)
    ECStack = [
        // A的执行上下文
        EC(A): {
            // 链表初始化为:AO(A) -> VO(G)
            [scope]: VO(G),
            scopeChain: <AO(A), VO(G)>
            // 创建函数A的活动对象
            AO(A): {
                arguments: [0: 2],
                y: 2,
                x: 2,
                B: function(z){...},
                B[[scope]] = AO(A),
                this: window
            }
        },
    
        // 全局执行上下文
        EC(G): {
            ..., // 包含全局对象原有属性
            x = 1;
            A = function(y){...};
            A[[scope]] = VO(G); // 创建函数的时候就确定了其作用域
        }
    ]
    
    // 第三步:执行B/C函数 C(3)
    ECStack = [
        // B的执行上下文
        EC(B): {
            [scope]: AO(A),
            scopeChain: <AO(B), AO(A), VO(G)>
            // 创建函数A的活动对象
            AO(B): {
                arguments: [0: 3],
                z: 3,
                this: window
            }
        },
    
        // A的执行上下文
        EC(A): {
            // 链表初始化为:AO(A) -> VO(G)
            [scope]: VO(G),
            scopeChain: <AO(A), VO(G)>
            // 创建函数A的活动对象
            AO(A): {
                arguments: [0: 2],
                y: 2,
                x: 2,
                B: function(z){...},
                B[[scope]] = AO(A),
                this: window
            }
        },
    
        // 全局执行上下文
        EC(G): {
            ..., // 包含全局对象原有属性
            x = 1;
            A = function(y){...};
            A[[scope]] = VO(G); // 创建函数的时候就确定了其作用域
        }
    ]
    

    检验学习情况的时候到了,下面这道题请动手画图,并输出正确结果:

    let a = 12,
        b = 12;
    
    function fn () {
        let a = b = 13;
        console.log(a, b);
    }
    
    fn();
    console.log(a, b);
    

    图我就不画了,自行练手吧,这里有一个额外的知识点需要说一下,let a = b = 13这个转化后,应该是let a = 13; b = 13,所以在EC(fn)上下文中,是没有变量b的,那么它会去上层作用域链中查找并更改。
    因此输出答案:13 13; 12 13


    额外拓展练习:闭包
    来一道题,这里其实使用上面的知识已经能答出正确答案了才对,当你画出图后,你也就能看出为什么说闭包会导致没法释放内存了(形成无法销毁的上下文)

    let i = 1;
    let fn = (i) => (n) => console.log(n + (++i));
    let f = fn(1); // 形成闭包
    f(2);
    fn(3)(4); // 并没有形成闭包,两个上下文都可以被释放
    f(5);
    console.log(i);
    
    // 上面箭头函数的代表以下代码块
    // let fn = function (i) {
    //     return function (n) {
    //         return console.log((n + (++i));
    //     }
    // }
    
    简单版绘图

    由于EC(fn)中返回的匿名函数被变量 f 所引用,所以可以理解为f=function () {console.log(n + (++i))},EC(F)执行后上下文会被销毁,但由于变量f引用了EC(fn)中的匿名函数,导致EC(fn)不能被销毁,所以变量对象AO(fn)就会一直存在,因此i一直都能被EC(f)所访问,还被EC(f)一直修改,这就形成了闭包。
    fn(3)(4);虽然也是闭包,但它可以释放,因为EC(fn)内部有没被外部所引用的。(图没画是因为再画整个图就乱得没法看了)
    闭包的作用有两个:保存和保护,对到这个例子i一直没法被释放就是保存,i没法被外部所访问到就是保护

    这道题需要手动做标记,标记i在每次执行后值为多少
    不懂最好自行一步步画图,因为函数有形参i,因此相当于EC(fn)中有自己的变量i,并且被保存着(函数柯里化),还有++i和i++的区别,++1是在执行的时候已经叠加,i++是执行完才会有加1,分得清这两个知识点后,自行标记一下应该就能得出正确答案了: 4; 8; 8; 1;

    相关文章

      网友评论

        本文标题:全面攻克js中的堆栈内存及闭包

        本文链接:https://www.haomeiwen.com/subject/jcprxktx.html