变量存储
由于不同引擎下变量存储方式会有些许不同,因此本文的结论都是依据的 V8 引擎。
我们通常认为:基本类型存储在栈中,这些类型的值会分配固定大小的内存;引用类型存储在堆中,值的大小不固定,栈中存的只是一个堆内存的引用地址。
举个例子:
let newVar = 23
let myNumber = 24
let myString = 'abcd'
let myArray = []
对应的存储略图:
如果我们将 newVar
赋给 myNumber
呢?
结果是并不会开辟新内存,而是两个变量指向同一个内存地址对应的值。
现在我们执行:
myNumber++
既然 newVar
和 myNumber
指向同样的内存地址,那么 myNumber
会不会随着 newVar
的改变而改变呢?答案是不!
由于 JS 中的原始数据类型是不可变的,当 myNumber ++
执行后,JS 会在内存中分配一个新地址,将 24 作为其值存储,myNumber
将指向新地址。
在步入 ES6+ 时代之后,我们应该尽可能地使用 const
,而只有在变量可能被改变的情况下使用 let
。
使用 const
声明的变量,其 call stack 中所对应的 值/堆内存地址 是不能发生变化的。但引用类型在堆中的值是可以发生变化的,只要保证引用地址指向不变即可。
栈是一种先进后出的数据结构,因此如果我们执行一个函数,其中声明的变量,在函数执行结束之后就会自动出栈。
那么这样就有一个问题:闭包中的变量是怎么存储的呢?
假如有如下代码:
function b() {
let c = 1
return function() {
return ++c
}
}
const d = b()
d() // 2
d() // 3
如果说当函数 b
执行完就将内部声明的所有变量出栈,那么为什么后续执行 d
可以获得内部变量 c
的值呢?
我们不妨在控制台调试一下上面的代码:
可以看到,V8 将包含全局变量在内的所有变量放到了一个叫 Scope
的空间中。
在执行到第 14 行之前(执行完第 13 行),函数 b
被放入了全局 Global
对象中,变量 c
还没赋值,因此为 undefined
。
当执行到第 16 行之前(执行完第 15 行之后),Scope
中新增了一个 Closure(b)
也就是函数 b
的闭包,闭包中包含先前声明的变量 c
。
我们再多声明几个闭包内没有用到的变量:
function b() {
let c = 1
let e = 2
let f = []
return function() {
return ++c
}
}
const d = b()
d() // 2
d() // 3
在执行到 16 行后 17 行前,c
、e
和 f
都在 Local
中。
执行完第 17 行,我们发现,Closure
中只会保留闭包函数中用到的变量。那么问题来了,我们先前说基本类型都是存储在栈内存中,既然变量 e
和 f
都出栈了,为什么 c
可以保留下来呢?
因此在这个情况下,基本类型也是存储在堆内存而不是栈内存中的。
并且,整个 Scope
也是存在堆内存而非栈内存中的,我们可以访问到这个对象:
function b() {
let c = 1
let e = 2
let f = []
return function() {
return ++c
}
}
const d = b()
console.dir(d)
由此我们可以得知,V8 在堆内存中使用一个特殊的 Scope
对象来保存变量。
但这并不能推翻基本变量存在栈内存中这个说法,我们可以将依照上述性质将变量分成三种:
- 局部变量
- 全局变量
- 被捕获变量
前面两个不用说,一个存储在 Local
,一个存储在 Global
中。被捕获变量则不同于局部变量,虽然同在函数中声明,但函数 return
后仍有未执行的作用域使用到了这些变量,这些变量就叫做被捕获变量。
因此,除了局部变量中的基本类型值之外,其余的变量全部存储在堆中!
例外:存储大小限制。
我们知道,栈中存储的值大小是固定的,由于操作系统对每组线程的栈内存有一定的限制,为适应线程各种操作系统,所以 Node.js 默认的栈大小为 984k。
我们假设调用栈最大为 984k,假如我在函数中声明了一个大小为 1MB 的字符串,调用栈放不下该怎么办呢?
因此,当这个字符串大到调用栈放不下的时候,就会被存到堆内存当中!在v8引擎中(很多别的编程语言也是这么做的),对值得驻留的字符串内存中相同的字符串只会保存一份,值得驻留的字符串指的是在有些场景下会重复出现的字符串,当两个变量保存相同的字符串时,它们实际上是保存了这个字符串在内存中的地址。这叫作字符串驻留。
注意:v8内部有一个名为stringTable的hashmap缓存了所有字符串,在V8阅读我们的代码,转换抽象语法树时,每遇到一个字符串,会根据其特征换算为一个hash值,插入到hashmap中。在之后如果遇到了hash值一致的字符串,会优先从里面取出来进行比对,一致的话就不会生成新字符串类。这也是为什么我们不能直接用下标的方式修改字符串: V8 中的字符串都是不可变的。
那么对于基本类型的存储方式来说:
- 字符串:存在堆里,栈中为引用地址,如果存在相同字符串,则引用地址相同。
- 数字:小整数存在栈中,其他类型存在堆中。
- 其他类型:引擎初始化时分配唯一地址,栈中的变量存的是唯一的引用。