JS栈和堆:数据是如何存储的
- 背景
- JavaScript 是什么类型的语言
- JavaScript 的数据类型
- 内存空间
- 栈空间和堆空间
- 再谈闭包
背景
JS有多种数据类型:数字型,字符串型,数组型等,虽然 JavaScript 并不需要直接去管理内存,但是实际项目中为了能避开一些不必要的坑,还是需要了解数据在内存中的存储方式。例如下面两段示例代码:
function foo() {
var a = 1
var b = a
a = 2
console.log(a)
console.log(b)
}
foo()
// 执行结果:
// 2
// 1
function foo() {
var a = {name: 'yy'}
var b = a
a.name = 'qq'
console.log(a)
console.log(b)
}
foo()
// 执行结果:
// {name: 'qq'}
// {name: 'qq'}
第一段代码没什么难以理解的,但是如果你对第二段代码感到迷惑,想要彻底弄清楚这个问题,就需要先从 JavaScript 这种语言说起。
JavaScript 是什么类型的语言
每种编程语言都具有内建的数据类型,但不同语言它们的数据类型常有不同之处,使用方式也很不一样。比如 C 语言在定义变量之前,就需要确定变量的类型,我们将这种称为静态语言。相反地,像 JavaScript 这种在运行过程中需要检查数据类型的语言称为动态语言。
虽然 C 语言是静态语言,但是在 C 语言中我们可以把其他类型数据赋予给一个声明好的变量,比如将 int
型变量赋值给 bool
型变量,因为在赋值过程中,C 编译器会把 int
型的变量悄悄转换为 bool
型的变量,通常将这种偷偷转换的操作称为隐式类型转换,而支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言。这这点上,C 和 JavaScript 都是弱类型语言。
JavaScript 的数据类型
现在我们知道,JavaScript 是一种弱类型、动态语言。意味着:
- 弱类型:你不需要告诉 JavaScript 引擎这个变量是什么数据类型,JavaScript 引擎在运行代码时会自己计算出来
- 动态:你可以使用同一个变量保存不同类型的数据(变量没有数据类型,值才有数据类型)
JavaScript 中的数据类型一共有 8 种,它们分别是:
类型 | 描述 |
---|---|
Boolean | true 和 false 两个值 |
Null | null |
Undefined | 一个没有被赋值的变量会有个默认值 undefined ,变量提升时的默认值也是 undefined |
Number | 数字型 |
BigInt | JavaScript 中的任意精度整数,可以安全存储和操作大整数,即使超出 Number 能够表示的安全整数范围。(Number 数据类型大于或等于 2 的 1024 次方的数值 JavaScript 无法表示,会返回 infinity ,ES2020 引入一种新的数据类型 BigInt 来解决这个问题) |
String | 字符串 |
Symbol | 符号类型是唯一的并且是不可修改的,通常用来作为 Object 的 key |
Object | 在 JavaScript 中,对象可以看作是一组属性的集合 |
需要注意的是:
- 使用
typeof
检测null
时,返回的是object
,这是当初 JavaScript 语言的一个 Bug,为了兼容老的代码所以一直保留至今 - Object 类型比较特殊,它是由上述 7 种类型组成的一个包含了 key,value键值对的数据类型
- 我们把前面 7 中数据类型称为原始类型,最后一个对象类型称为引用类型,因为它们在内存中存放的位置不一样。
内存空间
在 JavaScript 执行过程中,只要有三种类型内存空间,分别是代码空间、栈空间和堆空间。其中的代码空间主要是存储可执行代码。今天主要来介绍栈空间和堆空间。
栈空间和堆空间
这里的栈空间就是在 JS 调用栈 文中反复提及的调用栈,先来看下面这段示例代码:
function foo() {
var a = 'yy'
var b = a
var c = {name: 'qq'}
var d = c
}
foo()
在 JS 调用栈 这篇文章中讲解过,当执行一段代码时,需要先编译并创建执行上下文,然后再按照顺序执行代码。下图是执行到 b = a
这行代码时其调用栈的状态图,可以参考:
此时,变量 a
和 变量 b
的值都被保存在执行上下文中,而执行上下文又被压入栈中,所以也可以认为变量 a
和变量 b
的值都是存放在栈中的。
接下来继续执行 c = {name: 'qq'}
这行代码,由于 JavaScript 引擎判断右边的值是一个引用类型,此时 JavaScript 引擎不是直接将该对象存放在变量环境中,而是将它分配到堆空间里,分配后该对象会有一个在“堆”中的地址,然后再将该数据的地址写进 c
的变量值,最终分配好内存的示例图如下:
从上图可以清晰观察到,对象类型时存放在堆空间的,栈空间只保留了对象的引用地址,当 JavaScript 需要访问该数据时,是通过栈中的引用地址来访问的。
为何一定要分“堆”和“栈”两个存储空间呢?所有数据直接都存放在“栈”中可以么?
不可以。因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了所有数据都存放在栈空间中,会影响到上下文切换的效率,进而影响到整个程序的执行效率。
例如文中的foo
函数执行结束了,JavaScript 引擎需要离开当前的执行上下文,只需要将指针下移到全局上下文的地址就行了,foo
函数执行上下文栈区空间全部回收。所以,通常情况下,栈空间都不会设置太大。
在 JavaScript 中,赋值操作和其他语言有很大的不同,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。所以 d = c
这行代码的操作就是把 c
的引用地址赋值给了 d
,具体可参考下图:
从图中看出,变量 c
和变量 d
都指向了同一个堆中的对象。这就很好解释了在文章开头的示例代码 2 中,通过 a
修改 name
的值,变量 b
的值也会跟着改变,因为归根到底它们是同一个对象。
再谈闭包
在知道了作用域内的原始数据类型会被存储到栈空间,引用类型会被存储到堆空间,基于这两点的认知,进一步探讨下闭包的内存模型。
关于什么是闭包,可以参考文章 JS作用域链和闭包 这篇文章。
还是以这段代码为例:
function foo() {
var myname = 'yy'
let test1 = 1
const test2 = 2
var innerbar = {
getName: function() {
console.log(test1)
return myname
},
setName: function(newName) {
myname = newName
}
}
return innerbar
}
var bar = foo()
bar.setName('qq')
bar.getName()
console.log(bar.getName())
由于变量 myname
、test1
、test2
都是原始类型数据,所以在执行 foo
函数时,它们会被压入到调用栈中;当 foo
函数执行结束后,调用栈中的 foo
函数的执行上下文会被销毁,其内部变量 myname
、test1
、test2
也应该一同被销毁。但是根据 JS作用域链和闭包 文中的分析,由于 foo
函数产生了闭包,所以变量 myname
、test1
并没有被销毁,而是保存在内存中,现在我们站在内存模型的角度来分析这段代码的执行流程:
- 当 JavaScript 引擎执行到
foo
函数时,首先会编译并创建一个空的执行上下文 - 编译过程中遇到内部函数
setName
,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了foo
函数中的myname
变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建一个"closure(foo)"
对象(这是一个内部对象,JavaScript 无法访问),用来保存myname
变量 - 接着继续扫描到
getName
方法,发现函数内部引用了变量test1
,于是 JavaScript 引擎又将test1
添加到"closure(foo)"
对象中 - 此时,堆中的
"closure(foo)"
对象中就包含了myname
和test1
两个变量了 - 由于
test2
并没有被内部函数引用,所以它依旧保存在调用栈中
通过以上分析,可以画出执行到 foo
函数中 return innerbar
语句时的调用栈状态图如下:
当执行到 foo
函数时,闭包就产生了;当 foo
函数执行结束之后返回的 getName
和 setName
方法都引用了 "closure(foo)"
对象,所以即使 foo
函数退出了,"closure(foo)"
依然被其内部的 setName
和 getName
方法引用,所以在下次调用 bar.setName
或 bar.getName
时,创建的执行上下文中就包含了 "closure(foo)"
。
总的来说,产生闭包的两个核心步骤:第一步是需要预扫描内部函数,第二步是把内部函数引用的外部变量保存到堆中。