我不知道的JS之JavaScript内存模型

2,379 阅读10分钟

首先声明:ECMAScript规范中并没有明确定义出ECMAScript实现中的各个运行时区域的划分,这里的内存模型的划分主要是指V8引擎的实现

对于高级的计算机语言来说,需要对计算机所操作的数据进行抽象化,因此就有了数据类型(实际上对于计算机来说这些数据都是0和1的组合,并且操作也仅限于与或非等逻辑操作)。抽象出数据类型的好处是可以让程序员在使用数据的时候不需要显示的对这些数据的基本特征进行描述,比如在使用一个数字类型的数据,程序员不需要告诉计算机数字类型的特征以及存储方式等细节;并且计算机语言可以在不同的数据类型上定义一些合法的可以执行的操作。

数据类型

JavaScript中的数据类型主要分为两大类:基本类型和引用类型;其中基本类型有七种,引用类型是包含Object和所有继承自Object的类型。八种基本类型如下表所示:

类型 描述
Boolean
只有false和true两个值
Null
只有一个值Null
Undefined
void操作符的结果、声明之后没有赋值的变量、没有返回值的函数的执行结果
Number
根据ECMAScript标准,JavaScript中的数字类型都是基于IEEE754标准的双精度64位二进制格式的值,所以严格上JavaScript没有整数类型;并且也正是这一标准才导致JavaScript中的0.1 + 0.2 != 0.3问题
BigInt
JavaScript中一个新的数字类型,可以用任意精度表示整数。即使超过Number的安全整数范围限制,也可以安全地存储和操作
String
用于表示文本数据,ECMAScript中的字符串是不可变的,也就是说字符串一旦创建,他们的值就不能改变。要改变某个变量保存的字符串,首先要销毁原来的字符串,然后用另一个包含新值的字符串填充该变量。
Symbol
表示唯一的不可修改的值
Object
对象,可以看成是一组属性的集合

要想知道一个变量的类型,可以用typeof操作符,typeof的返回值有undefinednumberstringbooleanbigintsymbolfunctionobject,其中function也是继承自object的引用类型;要注意typeof null === 'object',对于这点据说是早期JavaScript的一个BUG,一直保留至今,之所以一直没修改过来,主要是为了兼容老的代码,并且逻辑上也说得通:

  • typeof的原理:JavaScript类型值是存在32 BIT 单元里,32位有1-3位表示TYPE TAG,其它位表示真实值而表示Object的标记位正好是低三位都是0。而对于null值来说,所有位都是0,因此typeof就返回object
  • 从逻辑角度来看,null值表示一个空对象指针,而这也正是使用typeof操作符检测null值时会返回"object"的原因(《JavaScript高级程序设计》中的解释)

V8的内存空间

在JavaScript执行过程中,v8引擎会把内存分为三个区块:代码空间、栈空间和堆空间。其中代码空间主要是用来存储可执行代码的,程序运行时用到的数据都是存储在栈空间和堆空间的。我们目前主要讨论栈空间和堆空间。

栈空间和堆空间

  • 栈空间:这里的栈指的就是我们所熟知的调用栈,而我们都知道栈是一种后进先出的数据结构
  • 堆空间:堆也是一种数据结构,是一颗完全二叉树,通常采用数组来存储完全二叉树,实际上V8的堆要更为复杂,后面讲到垃圾回收的时候会了解到,在这里只需要知道堆空间是看起来比较零散的内存空间就可以了

栈空间和堆空间都是干什么用的?

我们知道v8引擎需要用执行栈来维护程序执行期间的上下文状态,而要想通过栈这种数据结构达到最高效率的切换上下文,那么执行栈就必须用数组来实现。这里要注意,这里的数组可不是JavaScript中的数组,而是要保证数组的每一项都可以用相同长度的位来存储,只有这样JavaScript引擎才可以通过执行栈的索引来快速切换上下文,以提高程序的效率。 所以v8引擎中的栈存储的都是相同位数的值,从这个角度看,哪些类型值可以保存在栈中呢?Boolean、Null、Undefined、Number都可以,这些类型的值都可以用32位的数据来表示。那么剩下的数据类型Symbol、BigInt、Object、String就都是要保存在堆中的,栈里面只会保存这些值的地址的引用。

这里要特别注意字符串是保存在堆中的
这里有很多人都认为原始值都是存储在栈中的,包括在我写这篇文章的时候参考的很多资料也都是这么写的,而实际上这是错误的。 这点我们用字符串做个简单的推理你就知道是说不通的。 第一栈里面的值发生改变时,JavaScript引擎是直接修改内存中的值,而字符串在JavaScript中是一个不可变的值,也就是说用新的字符串替换旧的字符串时,如果在栈中操作是无法操作的。

var s = 'abc'
var a = 111
s = 'bcd' // 如果这时候在栈中重新开辟内存保存bcd,再把新的内存地址赋值给s,那么s和a在栈中的相对位置就会发生改变

第二,字符串的长度肯定是可以超过32的,而当它的长度超过32时栈内存的一个单元是存储不了的。 另外有一点,其实在v8引擎中(很多别的编程语言也是这么做的),对值得驻留的字符串内存中相同的字符串只会保存一份,值得驻留的字符串指的是在有些场景下会重复出现的字符串,当两个变量保存相同的字符串时,它们实际上是保存了这个字符串在内存中的地址。这叫作字符串驻留String Interning

栈空间和堆空间都是怎么保存数据的?

直接来看一段代码:

function foo(){
    var a = "luwei" // 1
    var b = a  // 2
    var c = 1  // 3
    var d = c  // 4
    var e = {name:"luwei"}  //5
    var f = e  // 6
    a = 'luwei-frontend'  // 7
    f.name = 'luwei-boy' // 8
    c = 2 // 9
}
foo()

JavaScript引擎在遇到声明语句时,会在栈中申请一个空间,当给这个变量赋值时,会有两种情况:

  1. 如果值的类型是Boolean、Null、Undefined、Number,那么直接把这个值保存在栈中
  2. 如果是其他类型的值,JavaScript引擎会在堆中申请一个内存空间,然后把这个值保存在堆中,再将这个值在堆中的引用赋值给这个值在栈中的内存空间

上面的代码在执行到第6步时,内存的分布应该如下图所示: 接着执行第7、8、9步:

  • 执行第7步,因为这时候给a重新赋值了一个新的字符串,这时候会在堆内存上重新申请一个空间存储luwei-frontend,在将申请的基地址赋值给a
  • 第8步对于右值得操作和第7步一样,不一样的是这里多了一步求左值得过程,会先找到f.name的内存地址,再将新的字符串的基地址复制到这个地址
  • 第9步因为操作的是Number类型,就直接在栈内存中做替换操作

执行完之后内存的分布大致如下图:

v8是怎么通过栈来管理函数调用的?

我们通过一个例子来看函数调用过程中栈内存的变化:

function add(num1,num2){
    var x2 = num1;
    var y2 = num2;
    var ret = x2 + y2;
    return ret;
}

function main()
{
    var a = 5;
    var b = 6;
    var c = add(a,b);
    return c;
}

下面就来分析一下这段代码执行时内存中栈的变化;

1. 当运行完var b = 6时内存分布应该如下图所示:

图中用到了两个CPU寄存器,其中esp寄存器始终指向当前栈的栈顶,而ebp寄存器指向的是当前函数的起始位置,函数的起始位置又叫做函数的栈帧指针;因为程序当前正在指向main函数,所以ebp寄存器指向的是main函数的起始位置10000f91

2. 接下来程序进入add函数

程序进入add函数之前会做这么几件事:

  1. 把当前函数(父函数)的栈帧指针入栈
  2. esp指针+1,因为要让esp寄存器始终指向栈顶 这时内存分布如下图: 程序进入add函数之后会:
  3. 将x2入栈,这时候x2的位置就是add函数的栈帧指针,当前执行函数已经变成add了,所以要把ebp寄存器的值修改为add的栈帧指针
  4. 执行完var ret = x2 + y2之后内存分布应该如下图所示:
3. 接下来程序退出add函数,并且回复main函数的运行环境
  1. add函数此时已经运行完了,需要销毁为add函数中的局部变量分配的空间,那么其实就是数据一个个的出栈就可以,但是出栈几个数据呢。CPU怎么知道你为add函数分配了几个变量呢?这时候ebp寄存器的作用就凸显出来了,esp的作用也可以体现;ebp的作用是存储当前函数的栈帧指针,那么此时ebp寄存器存储的是add函数的起始位置,我们只需要将ebp寄存器的值-1写入到esp寄存器就可以瞬间将栈恢复到执行add函数时的位置。
  2. 我们的目的是要恢复main函数移交执行权时的环境,也就是ebp需要指向main函数的栈帧指针,esp需要指向栈顶;还记得第2步我们在进入add函数之前的一个操作吗?我们把当前函数的栈帧指针入栈了,也就是说这时候只需要执行一次出栈操作,把出栈的数据赋值给ebp寄存器就实现了恢复ebp重新指向main函数的栈帧指针。
  3. 这时候执行var c = add(a,b)语句的时候add(a,b)的值已经有结果了,那么运行完之后的内存分布就如下图所示:

好了,我们现在理解了栈是怎么管理函数调用的了,使用栈有非常多的优势:

  1. 栈的结构和非常适合函数调用过程。因为函数可以被其他函数调用,子函数在被父函数调用的时候,父函数必定在子函数调用结束之后才有可能结束,因此在函数的声明周期上符合后进先出;另外由于函数有作用域,因此在资源分配的角度也符合后进先出
  2. 在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。

参考文章

极客时间浏览器工作原理与实践—12 | 栈空间和堆空间:数据是如何存储的?
极客时间图解 Google V8—11 | 堆和栈:函数调用是如何影响到内存布局的?
Memory usage of JavaScript string type with identical values
JavaScript’s Memory Model
奇技淫巧学 V8 之六,字符串在 V8 内的表达
JavaScript中String的存储
JavaScript字符串底层是如何实现的?

本文使用 mdnice 排版