全面攻克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数据类型可以分为三类:
- 基本类型(值类型):Number String Boolean Null Undefined
-
引用类型:Object Function
这里可能有人会回答Array,正则等,但他们其实也是Object,可以把他们理解为 Object 的分支 -
其他类型:Symbol
ES6新增,创建唯一值
栈内存与堆内存各自的作用
栈内存:提供代码运行的环境,存储基本类型值
堆内存:提供引用类型存储的环境空间
回到开始的地方,将代码一步步解析,看看在浏览器里是怎么执行的(深入V8底层实现原理)。同时使用ProcessOn来绘图,一步步绘制出执行结果
Step1
浏览器加载页面后,想要代码自上而下执行,那么它需要一个执行环境,而这个执行环境,就是我们所说的全局作用域,也就是开辟了一个栈内存。
全局作用域专业名词为:ECStack(Exeuction Context Stack)
翻译过来就是执行环境栈,或者叫执行上下文栈
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相关联。
第二段代码,也就是引用类型赋值的,那么它的存储方式又有所不同
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);
这里我就不再画图了,太累,用文字一步步解释吧
- 创建变量a,创建值,发现是个引用类型,所以新开一个堆内存(继续假设空间地址为AAAFFF000),存储n: 1
- 创建变量b,将b 与 空间地址AAAFFF000相关联
- 由于没有创建变量,所以来到 创建值 -> 关联 这一步,发现值是个引用类型,新开一个堆内存(假设空间地址为AAAFFF111),存储n: 2。
- a.x,此时a关联的空间地址为AAAFFF000,所以在该堆内存里创建x,值为{n: 2}
- 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);
用图文的方式一步步说明代码是如何执行的
-
浏览器开启,创建ECStack,用于执行代码
-
创建EC(G),全局上下文
创建EC -
创建完成,进栈,也就是EC进入ECStack,这个过程叫:进栈执行
进栈执行 -
执行
发现函数,添加函数作用域[[scope]]属性let x = 1; function A() {...};
,这一步我就不再画堆内存了,画起来还是很浪费时间的。发现有函数,需要添加函数[[scope]]属性。所有在当前的上下文当中,只要创建了函数,那么必然会给函数添加[[scope]]属性。所以说,实际上执行上下文跟作用域本质是两个不同的东西。
-
执行
A函数进栈执行c = A(2);
,要执行函数A,那么需要创建新的上下文,把EC(G)压至栈底,然后进栈执行。
-
执行函数A,并把2赋值给形参y。
执行函数
执行函数前,需要做一些准备工作,先记录自己的作用域[scope] -> AO(A),还有作用域链scopeChain,scopeChain保存着函数的链式关系(也就是上一层作用域是谁,再上一层又是谁),当某个变量在该作用域中查找不到的时候,就会去上层作用域查找。准备工作完成就能执行函数了,创建属性arguments(因为arguments是类数组,所以我这里就用[0: 2]来表示)及其他函数中的变量。作用域是在函数创建的时候就有的,而作用域链是在函数执行的时候才产生的
- 最后一句执行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;