通过内存空间探究ES中基本类型和引用类型的异同

在开发过程中我们对基本类型和引用类型的一些特性有所了解:
基本类型是按值访问的,所以我们可以尽情地赋值修改不用担心有副作用;
而引用类型是按引用访问的,所以这会导致当我们操作一个复制自另一个引用类型的值时,使得另一个值也发生变化。

说起来可能有点拗口,又是复制又是赋值的,还有什么按值访问按引用访问,看一下demo:

// 声明两个基本类型的变量,simpleVariable2复制自simpleVariable1
let simpleVariable1 = 'simpleVariable1';
let simpleVariable2 = simpleVariable1;
// 打印出两个变量的值,没有什么问题
console.log(simpleVariable1);  // simpleVariable1
console.log(simpleVariable2);  // simpleVariable1
// 修改 simpleVariable2 的值,一切如预期
simpleVariable2 = 'simpleVariable2';
console.log(simpleVariable1);  // simpleVariable1
console.log(simpleVariable2);  // simpleVariable2

// 声明两个复杂类型的变量,complexVariable2复制自complexVariable1
let complexVariable1 = {
    name: 'complexVariable1',
};
let complexVariable2 = complexVariable1;
// 打印出两个变量的值,没有什么问题
console.log(complexVariable1);  // name:complexVariable1
console.log(complexVariable2);  // name:complexVariable1
// 修改complexVariable2中的值,一切就不如预期了:complexVariable1中的值也发生了变化
complexVariable2.name = 'complexVariable2';
console.log(complexVariable1);  // name:complexVariable2
console.log(complexVariable2);  // name:complexVariable2

本文基于以上代码反映的问题对基本类型和引用类型从内存空间的角度做深入学习,从而对变量的声明、引用过程有更深的理解,间接达到提升代码质量的作用。

《JavaScript高级程序设计》中包含的这些内容的章节:4.1 基本类型和引用类型的值

参考文章:

MDN-内存管理

Does JavaScript use stack or heap for memory allocation or both?

stack and heap in V8

前端基础进阶(一):内存空间详细图解

国内外已经有很多大神写了相关的文章或回答,本文基于这些文章学习理解而得。简书上关于 内存空间详细图解 的这篇文章已经写得非常详尽,因此本文仅对那篇文章中没有提及或不够完善的部分做补充。

栈内存与堆内存

栈内存作为内存中的一块区域,遵循先进后出 First-In-Last-Out的存储模式。一般栈内存比较小,但是存取数据的效率远高于堆内存,因此多用来存储一些简单的数据。
堆内存和栈内存正好相反,内存大但是存取数据的效率低,数据存储方式无序且随意,因此适合存放一些复杂的数据结构。

通过这一小段简单的说明,其实对ES中的数据类型可以有一些合理的猜想:
因为栈内存空间小存取速度快,多用来存储一些简单的数据,那么ES中基本类型值很可能就存储栈内存中;
而因为堆内存的一些特性,使得它非常适合拿来存储引用类型值。

在接下来章节中我们会去探究到底是不是这种分配方式。

ES中的内存空间

ECMA-262 标准中没有定义内存布局,因此本文讨论的内存空间是由JavaScript引擎实现的。
这里以V8引擎的内存布局实现举例。

从js垃圾回收机制来认识它的内存空间

参考文章Does JavaScript use stack or heap for memory allocation or both?中提到:

The easiest way is to think for a second. Javascript is a garbage-collected language. GC means that there is data scattered around which has to be cleaned up. Does that sound like the data is stored on the stack? Rather... no, because the stack has strictly ordered data. So, variables are allocated on the heap.

MDN 内存管理中也有提到:

像C语言这样的高级语言一般都有底层的内存管理接口,比如 malloc()和free()。另一方面,JavaScript创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放。 后一个过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者感觉他们可以不关心内存管理。 这是错误的。

JavaScript的垃圾回收机制意味着它会去不断回收杂乱无序的不再使用的变量,而堆内存严格的先进后出的存储模式无法满足垃圾回收机制的运行要求--通过这点可以看出JavaScript中的变量都是存储在堆内存中的。

结论

既然已经得出了结论那么为什么还有之后那么多章节,而且之后的章节中还提到了栈内存,这和得出的结论是否矛盾呢?在翻了一些文章结合理解之后得出了如下较为系统的结论:

  • ES的标准中没有明确定义内存布局,内存布局由具体的JavaScript引擎实现。
  • 通过内存回收机制可以看出JavaScript中的变量都是存储在堆内存中的
  • 在之后章节中提到的栈内存,是由js引擎在堆内存中模拟的一个类似于栈内存的对象。

内存空间总览

image
↑↑↑图片来自内存空间详细图解

基本类型的内存空间

如上一节中猜测的一般:对于基本类型,其值直接存放在栈内存中,我们可以直接通过变量名访问到它们的值。因此书本中说

基本数据类型是按值访问的,因为可以操作保存在变量中的实际的值。

// 声明一个基本类型的变量
const simpleVariable = 'simpleVariable';
// 由simpleVariable复制一个copySimpleVariable变量
let copySimpleVariable = simpleVariable;
// 因为基本类型的变量是按值访问的,所以修改copySimpleVariable的值会改了copySimpleVariable的值不会对simpleVariable产生影响
copySimpleVariable = 123;

引用类型的内存空间

而对于引用类型来说,它们的值存放在堆内存中,变量名保存的是一个内存地址,当我们访问这个变量的时候实际访问的是一个引用地址,通过这个引用地址访问到存在在堆内存中的值。

// 声明一个引用类型
const m = {
    name: 'complexVariable',
};
// 声明一个新的引用类型,复制自complexVariable
const n = complexVariable;

如这段代码所示,当我们声明一个复制自 m 的引用类型 n 时,并不是像基本类型一般会直接修改这个变量的值,而是给这个变量一个指向堆内存的引用地址的值,如图所示:
image
↑↑↑图片来自内存空间详细图解

当操作引用类型值时内存空间发生了什么变化

对引用类型的操作大体有如下几种:

  • 复制
  • 增加、删除、修改属性
  • 赋值

当发生复制操作时,内存空间如上节中提到的所示,当我们声明一个复制自 m 的引用类型 n 时,并不是像基本类型一般会直接修改这个变量的值,而是给这个变量一个指向堆内存的引用地址的值。此时发生的情况是书本中说的

引用类型的值是按引用访问的。


当发生增加、删除、修改属性时,会通过变量的内存地址的值访问到在堆内存中实际的对象,并对这个实际的对象进行相应的增加、删除、修改属性操作。此时的情况便是在书本下方加的注解

但在为对象添加属性时,操作的是实际的对象。

正因为是操作了实际的对象,所以会导致所有指向这个堆内存中对象的变量都发生变化,这就是文章开头demo中说到的情况:

声明两个复杂类型的变量,complexVariable2复制自complexVariable1。

修改complexVariable2中的值,一切就不如预期了:complexVariable1中的值也发生了变化


当发生赋值操作时,操作对象的值会赋值为堆内存中将要赋值的对象的内存地址。而此时就算操作的变量是复制自别的引用类型变量,也会接触两个变量之间的关系,无论操作哪个对象都不会对另一个产生影响:

// 声明一个引用类型变量
let complexVariable1 = {
    name: 'complexVariable1',
    code: '01',
};
// 声明一个复制自complexVariable1的变量
let complexVariable2 = complexVariable1;
// 修改complexVariable2的name属性,会操作在堆内存中的实际对象导致complexVariable1也发生变化
complexVariable2.name = 'complexVariable2';
console.log(complexVariable1.name);  // complexVariable2
// 而当我们做赋值操作的时候,会将一个新的内存地址赋值给complexVariable2
complexVariable2 = {
    name: 'new complexVariable2',
    code: '007',
}
// 现在complexVariable2和complexVariable1已经没有半毛钱关系了
complexVariable2.name = 'change back to complexVariable1';
console.log(complexVariable1.name);  // complexVariable2

通过对内存空间的学习,可以清楚地知道变量发生创建赋值修改操作时js引擎做了哪些动作,从而更好地把握并使用各个变量。