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

先来两段代码,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)
翻译过来就是执行环境栈,或者叫执行上下文栈

全面攻克js中的堆栈内存及闭包_第1张图片
ECStack

Step2

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

全面攻克js中的堆栈内存及闭包_第2张图片
创建及保存变量

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

Step3

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

全面攻克js中的堆栈内存及闭包_第3张图片
b重新关联


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

Step1

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

全面攻克js中的堆栈内存及闭包_第4张图片
引用类型 - 存储于堆内存

Step2

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

全面攻克js中的堆栈内存及闭包_第5张图片
引用类型 - 存储于堆内存

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


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

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),全局上下文


    全面攻克js中的堆栈内存及闭包_第6张图片
    创建EC
  3. 创建完成,进栈,也就是EC进入ECStack,这个过程叫:进栈执行


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

    全面攻克js中的堆栈内存及闭包_第8张图片
    发现函数,添加函数作用域[[scope]]属性

  5. 执行c = A(2);,要执行函数A,那么需要创建新的上下文,把EC(G)压至栈底,然后进栈执行。

    全面攻克js中的堆栈内存及闭包_第9张图片
    A函数进栈执行

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

    全面攻克js中的堆栈内存及闭包_第10张图片
    执行函数

  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: 
        // 创建函数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: 
        // 创建函数A的活动对象
        AO(B): {
            arguments: [0: 3],
            z: 3,
            this: window
        }
    },

    // A的执行上下文
    EC(A): {
        // 链表初始化为:AO(A) -> VO(G)
        [scope]: VO(G),
        scopeChain: 
        // 创建函数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));
//     }
// }
全面攻克js中的堆栈内存及闭包_第11张图片
简单版绘图

由于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中的堆栈内存及闭包)