栈空间和堆空间:数据是如何存储的
让人疑惑的代码
1
2
3
4
5
6
7
8
function foo() {
var a = 1;
var b = a;
a = 2;
console.log(a);
console.log(b);
}
foo();
1
2
3
4
5
6
7
8
function foo() {
var a = { name: ' 极客时间 ' };
var b = a;
a.name = ' 极客邦 ';
console.log(a);
console.log(b);
}
foo();
执行第一段代码,打印出来 a 的值是 2,b 的值是 1
再执行第二段代码,最终 a 和 b 打印出来的值都是{name:”极客邦”}
JavaScript 是什么类型的语言
比如 C 语言在定义变量之前,就需要确定变量的类型,你可以看下面这段 C 代码:
1
2
3
4
5
6
7
int main()
{
int a = 1;
char* b = " 极客时间 ";
bool c = true;
return 0;
}
上述代码声明变量的特点是:在声明变量之前需要先定义变量类型。我们把这种在使用之前就需要确认其变量数据类型的称为静态语言
我们把在运行过程中需要检查数据类型的语言称为动态语言。比如我们所讲的 JavaScript 就是动态语言,因为在声明变量之前并不需要确认其数据类型
虽然 C 语言是静态,但是在 C 语言中,我们可以把其他类型数据赋予给一个声明好的变量,如:
1
c = a
前面代码中,我们把 int 型的变量 a 赋值给了 bool 型的变量 c,这段代码也是可以编译执行的,因为在赋值过程中,C 编译器会把 int 型的变量悄悄转换为 bool 型的变量,我们通常把这种偷偷转换的操作称为隐式类型转换。
而支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言。在这点上,C 和 JavaScript 都是弱类型语言。
JavaScript 的数据类型
- 弱类型,意味着你不需要告诉 JavaScript 引擎这个或那个变量是什么数据类型,JavaScript 引擎在运行代码的时候自己会计算出来
- 动态,意味着你可以使用同一个变量保存不同类型的数据
1
2
3
4
5
6
var bar;
bar = 12;
bar = ' 极客时间 ';
bar = true;
bar = null;
bar = { name: ' 极客时间 ' };
1
2
3
4
5
6
7
8
9
10
11
12
var bar;
console.log(typeof bar); //undefined
bar = 12;
console.log(typeof bar); //number
bar = ' 极客时间 ';
console.log(typeof bar); //string
bar = true;
console.log(typeof bar); //boolean
bar = null;
console.log(typeof bar); //object
bar = { name: ' 极客时间 ' };
console.log(typeof bar); //object
JavaScript 中的数据类型一种有 8 种
- 使用 typeof 检测 Null 类型时,返回的是 Object
- Object 类型比较特殊,它是由上述 7 种类型组成的一个包含了 key-value 对的数据类型
- 我们把前面的 7 种数据类型称为原始类型,把最后一个对象类型称为引用类型
把它们区分为两种不同的类型,是因为它们在内存中存放的位置不一样,看看 JavaScript 的原始类型和引用类型到底是怎么储存的
内存空间
在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。
栈空间和堆空间
这里的栈空间就是我们之前反复提及的调用栈,是用来存储执行上下文的
1
2
3
4
5
6
7
function foo() {
var a = ' 极客时间 ';
var b = a;
var c = { name: ' 极客时间 ' };
var d = c;
}
foo();
执行一段代码时,需要先编译,并创建执行上下文,然后再按照顺序执行代码
当执行到第 3 行代码时,其调用栈的状态
变量 a 和变量 b 的值都被保存在执行上下文中,而执行上下文又被压入到栈中,所以你也可以认为变量 a 和变量 b 的值都是存放在栈中的
执行第 4 行代码,由于 JavaScript 引擎判断右边的值是一个引用类型,这时候处理的情况就不一样了,JavaScript 引擎并不是直接将该对象存放到变量环境中,而是将它分配到堆空间里面,分配后该对象会有一个在“堆”中的地址,然后再将该数据的地址写进 c 的变量值,最终分配好内存的示意图如下所示
对象类型是存放在堆空间的,在栈空间中只是保留了对象的引用地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的,相当于多了一道转手流程
所以,原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的
为什么一定要分“堆”和“栈”两个存储空间呢?所有数据直接存放在“栈”中不就可以了吗?
这是因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率
比如文中的 foo 函数执行结束了,JavaScript 引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了
通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间
看看它最后一步将变量 c 赋值给变量 d 是怎么执行的?
赋值操作和其他语言有很大的不同,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址
所以 d=c 的操作就是把 c 的引用地址赋值给 d
变量 c 和变量 d 都指向了同一个堆中的对象,所以这就很好地解释了文章开头的那个问题,通过 c 修改 name 的值,变量 d 的值也跟着改变,归根结底它们是同一个对象
再谈闭包
你知道了作用域内的原始类型数据会被存储到栈空间,引用类型会被存储到堆空间,基于这两点的认知,我们再深入一步,探讨下闭包的内存模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo() {
var myName = ' 极客时间 ';
let test1 = 1;
const test2 = 2;
var innerBar = {
setName: function (newName) {
myName = newName;
},
getName: function () {
console.log(test1);
return myName;
},
};
return innerBar;
}
var bar = foo();
bar.setName(' 极客邦 ');
bar.getName();
console.log(bar.getName());
- 当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文
- 在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo - 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。
- 接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 JavaScript 引擎又将 test1 添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了 myName 和 test1 两个变量了。
- 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中
画出执行到 foo 函数中“return innerBar”语句时的调用栈状态
当执行到 foo 函数时,闭包就产生了
当 foo 函数执行结束之后,返回的 getName 和 setName 方法都引用“clourse(foo)”对象,所以即使 foo 函数退出了,“clourse(foo)”依然被其内部的 getName 和 setName 方法引用。所以在下次调用 bar.setName 或者 bar.getName 时,创建的执行上下文中就包含了“clourse(foo)”。
innerBar 返回后,含有 setName 和 getName 对象,这两个对象里面包含了堆中的 closure(foo)的引用。虽然 foo 执行上下文销毁了,foo 函数中的对 closure(foo)的引用也断开了,但是 setName 和 getName 里面又重新建立起来了对 closure(foo)引用。
产生闭包的核心有两步:
- 第一步是需要预扫描内部函数
- 第二步是把内部函数引用的外部变量保存到堆中
垃圾回收:垃圾数据如何自动回收
不同语言的垃圾回收策略
垃圾回收一般分手动和自动回收两种策略
C/C++是手动回收的策略,什么时候分配内存,销毁内存都是代码控制
1
2
3
4
5
6
7
8
9
10
11
// 在堆中分配内存
char* p = (char*)malloc(2048); // 在堆空间中分配 2048 字节的空间,并将分配后的引用地址保存到 p 中
// 使用 p 指向的内存
{
//....
}
// 使用结束后,销毁这段内存
free(p);
p = NULL;
我们先 malloc 分配内存,然后使用,不再需要的时候 free 释放内存
另外一种是自动垃圾回收,比如 JavaScript Java Python 等,垃圾数据由垃圾回收器释放,不需要手动释放
因为数据存储在栈和堆两种内存空间,我们分两个唯独来分析
调用栈中数据是如何回收的
1
2
3
4
5
6
7
8
9
10
function foo() {
var a = 1;
var b = { name: ' 极客邦 ' };
function showName() {
var c = ' 极客时间 ';
var d = { name: ' 极客时间 ' };
}
showName();
}
foo();
当执行到第六行的时候,调用栈和堆空间状态如下
原始类型分配到栈中,引用类型分配到堆中
执行到到 showName 函数的时候,引擎会创建 showName 上下文呢,压入到调用栈中,最终执行到 showName 函数时,调用栈就如上图所示。
还有一个记录当前执行状态的指针,叫做 ESP,指向了调用栈中 showName 函数的执行上下文,表示正在执行 showName
执行完毕以后,进入到 foo 函数,这个时候销毁 showName 函数的执行上下文,ESP 就帮上忙了,会把 ESP 下移到 foo 函数执行的上下文,下移操作就是销毁 showName 函数执行上下文的过程
showName 函数执行完毕后,ESP 向下移动到了 foo 函数的执行上下文,showName 的执行上下文虽然保存在占内存中,但是已经无效内存了
当 foo 再次调用另外一个函数的时候,这个内容会被直接覆盖,存放另外一个函数的上下文
所以一个函数执行完毕以后,引擎通过向下移动 ESP 销毁函数保存在栈中的上下文
堆中数据是如何回收的
foo 结束以后,ESP 应该是指向全局执行上下文的,showName 和 foo 函数的执行上下文都处于无效的状态,不过保存在堆中的对象依然占据空间
代际假说和分代收集
代际假说特点:
- 大部分对象在内存存在时间很短,很多对象已经分配内存,很快就不可访问了
- 不死的对象会或者更久
V8 把 堆 分为新生代和老生代两个区域,新生代存放时间短的对象,老生代存放生存时间长的对象
新生代一般支持 1 ~ 8M,老生代支持容量很大
- 副垃圾回收器,负责新生代垃圾回收
- 主垃圾回收期,负责老生代垃圾回收
垃圾回收的工作流程
- 标记空间中的活动对象和非活动对象,活动对象就是还在使用的,非活动对象就是可以进行垃圾回收的对象
- 回收非活动对象的内存,在所有标记完成以后,统一清理内存中被标记为可回收的对象
- 整理内存,一般频繁回收对象,会存在大量不连续的空间,叫做空间碎片,后面如果还有分配连续大内存可能会出现内存不足的情。所以最后一步是整理内存碎片,但是不是必须的
副垃圾回收器
大多数小的对象分配到了新生区,区域不大,但是回收比较频繁
新生区使用 scavenge 算法处理:把空间对半分为两个区域,一个是对象区域,一个是空闲区域
新生区加入对象的时候放到了对象区域,对象区域快满的时候,执行一次垃圾回收
首先对垃圾做标记,标记完以后进入垃圾清理阶段,副垃圾回收器会把存活的对象复制到空闲区域,并把他们有序的排列起来。所以复制过程也相当于完成了内存整理操作。再也没有内存碎片了
完成复制后,对象区域和空闲区域角色反转,可以一直无限重复使用下去
如果新生区设置太大的话,清理时间过长,为了执行效率,一般设的比较小
不过太小的话容易填满,也正是因为这样,还采取了对象晋升策略,如果两次垃圾回收后还存活对对象,移到了老生区
主垃圾回收器
一般大的对象直接分配到老生区,它对象占用空间大,存活时间长
如果按照 scavenge 算法,复制大对象很浪费时间,效率不高,还浪费了一半空间,所以不采用。主垃圾回收器采用标记 - 清除的算法进行垃圾回收
首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程能达到的对象称为活动对象,没有达到的称为垃圾数据
比如 showName 执行退出以后,调用栈和堆空间如下
这个时候 ESP 向下移动指向了 foo 函数上下文,如果遍历调用栈是找不到饮用 100 地址的变量,所以 1003 这块数据为垃圾数据,标记红色
1050 这块数据被变量 B 引用了,标记为活动对象
接下来是垃圾清除阶段:
不过一块内存多次执行 标记 - 清除算法后产生了大量不连续的内存碎片,于是有 标记 - 整理算法,后续不是直接对可回收对象进行清理,而是让所有存活的对象向一端移动,然后清除掉端边界以外的内存
全停顿
我们知道了 V8 是使用副垃圾回收器和主垃圾回收器处理垃圾回收的,不过 JavaScript 运行在主线程,执行垃圾回收算法都需要让正在执行的 JavaScript 代码停止下来,回收完毕后在恢复,这种行为叫做全停顿
比如 1.5G 垃圾 V8 实现一次完整回收要 1s,那么应用的性能和响应都直线下降
新生代因为空间小影响不大,老生代就不一样了。
为了降低老生代回收造成的卡顿,V8 把标记过程分为一个个的子过程,让回收标记和 JavaScript 代码逻辑交替运行,直到标记阶段完成。叫做增量标记算法
把垃圾回收任务分为很多小任务,小任务时间段,穿插在其他任务中执行,不会感觉有明显的卡顿
编译器和解析器:V8 如何执行一段 JavaScript 代码的
要深入理解 V8 的工作原理,你需要搞清楚一些概念和原理,比如接下来我们要详细讲解的编译器(Compiler)、解释器(Interpreter)、抽象语法树(AST)、字节码(Bytecode)、即时编译器(JIT)等概念,都是你需要重点关注的
编译器与解释器
机器不能直接理解我们的代码,需要在执行程序之前,把代码翻译成机器能读懂的机器语言。
按语言执行的流程可以分为解释型语言和编译型语言
编译型语言在执行前需要编译器的编译过程,编译后直接保留机器能读懂的二进制文件。后面运行程序就不需要执行二进制文件了,也不需要重新编译,比如 C/C++,GO 等编译型语言
编译型语言编写的程序每次运行都需要经过解释器对程序进行动态解释和执行,比如 Python 和 JavaScript 等解释型语言
- 编译型语言的编译过程中,依次对源代码进行词法分析,语法分析,生成了抽象语法树(AST),然后优化代码,生成机器码
- 解释型语言,对源代码进行词法分析,语法分析,生成 AST,基于 AST 生成字节码,根据字节码执行程序
V8 是如何执行一段 JavaScript 代码的
生成 AST 和执行上下文
解释型语言还是编译型预研都会生成 AST,这和渲染引擎把 HTML 转为计算机可以理解的 DOM 树情况类似
我们看看下面的代码:
1
2
3
4
5
6
var myName = ' 极客时间 ';
function foo() {
return 23;
}
myName = 'geektime';
foo();
它生成的 AST 是这样的
AST 和代码结构很类似,可以看成是代码结构化的表示。后续的工作都依赖于 AST
AST 是一个很重要的数据结构,在很多地方被应用
比如 babel 代码转换器,把 es6 代码转成了 es5 代码,先把 es6 源代码转成 ast,然后把 es6 语法的 ast 转成 es5 愈发的 ast,最终生成 es5 的源代码
eslint 是检查 js 编写规范的差距,也是把源代码转换成 ast,然后通过 ast 检查代码规范化问题
生成 ast 有两个阶段
词法分析,把一行行的源代码拆解成一个个的 token,token 是语法上不能再分的,最小的单个字符串
上图
1
var myName = '极客时间';
简单地定义了一个变量。关键词 var 标识符 myName 赋值运算符 = 和 字符串四个都是 token,代表不同的属性
语法分析,是第二阶段,把第一阶段的 token 数据,转成了 ast。如果出现错误的话,会抛出语法错误
所以整个生成的步骤就是先分词,再解析
接下来是生成字节码,解释器 Ignition 登场了。它会根据 ast 生成字节码,解释执行字节码
V8 一开始没有字节码,直接转成了机器码,效率很高但是随着在手机上发布以后,运行在 512M 等第内存的手机上,内存占用问题就出现了,因为需要使用大量的内存去存放转换后的机器码。
V8 大幅重构了引擎架构,引入了字节码
字节码是介于 AST 和机器码之间的代码,与特定类型的机器码无关,字节码需要通过解释器把转换为机器码后才能执行
字节码占用的空间远远超过了字节码
接下来就是执行代码的阶段了
解释器 Ignition 会逐条解释执行。如果发现有热点代码,比如重复执行多次,编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码。然后再次执行这段代码的时候就直接使用编译后的机器码就好了。大大提升了代码效率
解释器 Ignition 是点火器的意思,编译器 TurboFan 是涡轮增压的意思,寓意着代码启动时通过点火器慢慢发动,一旦启动,涡轮增压介入,其执行效率随着执行时间越来越高效率,因为热点代码都被编译器 TurboFan 转换了机器码,直接执行机器码就省去了字节码“翻译”为机器码的过程。
解释器配合编译器是最近很火的技术, Java 和 Python 的虚拟机也都是基于这种技术实现的,这种技术叫做即时编译 JIT
JavaScript 的性能优化
- 提升单词脚本执行速度,避免 长任务 霸占主线程,可以快速响应交互
- 减少 文件体积