谈一谈JavaScript的内存模型

451 阅读9分钟
// 声明一些变量并进行初始化
var a = 5
let b = 'xy'
const c = true

// 重新赋值
a = 6
b = b + 'z'
c = false // TypeError: Assignment to constant variable

对我们程序员来说,声明变量、进行初始化和赋值几乎是每天都在做的一件事情。不过,这些操作本质上做了什么事情呢?JavaScript 是如何在内部对这些进行处理的?更重要的是,了解 JavaScript 的底层细节对我们程序员有什么好处?

本文的大纲如下:

  1. JS 基本类型的变量声明和赋值
  2. JS 的内存模型:调用栈和堆
  3. JS 引用类型的变量声明和赋值
  4. Let vs const

JS 基本类型的变量声明和赋值

我们先从一个简单的例子讲起:声明一个名为 muNumber 的变量,并初始化赋值为 23。

let myNumber = 23

当执行这一行代码的时候,JS 将会 ......

  1. 为变量创建一个唯一的标识符( myNumber
  2. 在栈内存中分配一块空间(将在运行时完成分配)
  3. 将值 23 保存在这个分配出去的空间中

我们习惯的说法是“myNumber 等于23”,但更严谨的说法应该是,myNumber 等于保存着值 23 的那个内存空间的地址。这两者的区别很关键,需要搞清楚。

如果我们创建一个新变量 newVar 并将 myNumber 赋值给它 ......

let newVar = myNumber

...... 由于 myNumber 实际上等于内存地址 “0012CCGWH80”,因此这一操作会使得 newVar 也等于 “0012CCGWH80”,也就是等于保存着值 23 的那个内存地址。最终,我们可能会习惯说“newVar 现在等于 23 了”。

那么,如果我这样做会发生什么呢?

myNumber = myNumber + 1

myNumber 自然会“等于” 24,不过 newVarmyNumber 指向的可是同一块内存空间啊,newVar 是否也会“等于” 24 呢?

并不会。在 JS 中,基本数据类型是不可改变的,在 “myNumber + 1” 被解析为 “24” 的时候,JS 实际上将会在内存中重新分配一块新的空间用于存放 24 这个值,而 myNumber 将会转而指向这个新的内存空间的地址。

再看一个类型的例子:

let myString = 'abc'  
myString = myString + 'd'

JS 初学者可能会认为,无论字符串 abc 存放在内存的哪个地方,这个操作都会将字符 d 拼接在字符串后面。这种想法是错误的。别忘了,在 JS 中字符串也是基本类型。当 abcd 拼接的时候,在内存中会重新分配一块新的空间用于存放 abcd 这个字符串,而 myString 将会转而指向这个新的内存空间的地址(同时,abc 依然位于原先的内存空间中)。

接下来我们看一下基本类型的内存分配发生在哪里。


JS 的内存模型:调用栈和堆

简单理解,可以认为 JS 的内存模型包含两个不同的区域,一个是调用栈,一个是堆。

除了函数调用之外,调用栈同时也用于存放基本类型的数据。以上一小节的代码为例,在声明变量后,调用栈可以粗略表示如下图:

在上面这张图中,我对内存地址进行了抽象,以显示每个变量的值,但请记住,(正如之前所说的)变量始终指向某一块保存着某个值的内存空间。这是理解 let vs const 这一小节的关键。

再来看一下堆。

堆是引用类型变量存放的地方。堆相对于栈的一个关键区别就在于,堆可以存放动态增长的无序数据 —— 尤其是数组和对象。


JS 引用类型的变量声明和赋值

在变量声明与赋值这方面,引用类型变量与基本类型变量的行为表现有很大的差异。

我们同样从一个简单的例子讲起。下面声明一个名为 myArray 的变量并初始化为一个空数组:

let myArray = \[\]

当你声明一个变量 myArray 并通过引用类型数据(比如 [])为它赋值的时候,在内存中的操作是这样的:

  1. 为变量创建一个唯一的标识符(myArray
  2. 在堆内存中分配一块空间(将在运行时完成分配)
  3. 这个空间存放着此前所赋的值(空数组 []
  4. 在栈内存中分配一块空间
  5. 这个空间存放着指向被分配的堆空间的地址

我们可以对 myArray 进行各种数组操作:

myArray.push("first")  
myArray.push("second")  
myArray.push("third")  
myArray.push("fourth")  
myArray.pop()


Let vs const

通常来讲,我们应该尽可能多地使用 const,并且只在确定变量会改变之后才使用 let

重点来了,注意这里的改变究竟指的是什么意思。

很多人会错误地认为,这里的“改变”指的是值的改变,并且可能试图用类似下面的代码进行解释:

let sum = 0  
sum = 1 + 2 + 3 + 4 + 5

let numbers = []  
numbers.push(1)  
numbers.push(2)  
numbers.push(3)  
numbers.push(4)  
numbers.push(5)

是的,用 let 声明 sum 变量是正确的,毕竟 sum 变量的值确实会改变;不过,用 let 声明 numbers 是错误的。而错误的根源在于,这些人认为往数组中添加元素是在改变它的值。

所谓的“改变”,实际上指的是内存地址的改变let 声明的变量允许我们修改内存地址,而 const 则不允许。

const importantID = 489  
importantID = 100 // TypeError: Assignment to constant variable

我们研究一下这里为什么会报错。

当声明 importantID 变量之后,某一块内存空间被分配出去,用于存放 489 这个值。牢记我们之前所说的,变量 importantID 从来只等于某一个内存地址。

当把 100 赋值给 importantID 的时候,由于 100 是基本类型的值,内存中会分配一块新的空间用于存放 100。之后,JS 试图将这块新空间的地址赋值给 importantID,此时就会报错。这其实正是我们期望的结果,因为我们根本就不想对这个非常重要的 ID 进行改动 .......

这样就说得通了,用 let 声明数组是错误的(不合适的),应该用 const 才行。这对初学者来说确实比较困惑,毕竟这完全不符合直觉啊!初学者会认为,既然是数组肯定需要有所改动,而 const 声明的常量明明是不可改动的啊,那为何还要用 const ?不过,你必须得记住:所谓的“改变”指的是内存地址的改变。我们再来深入理解一下,为什么在这里使用 const 完全没问题,并且绝对是更好的选择。

const myArray = []

在声明 myArray 之后,调用栈会分配一块内存空间,它所存放的值是指向堆中某个被分配内存空间的地址。而堆中的这个空间才是实际上存放空数组的地方。看下面的图理解一下:

如果我们进行这些操作:

myArray.push(1)  
myArray.push(2)  
myArray.push(3)  
myArray.push(4)  
myArray.push(5)

这将会往堆中的数组添加元素。不过,myArray 的内存地址可是至始至终都没改变的。这也就解释了为什么 myArray 是用 const 声明的,但是对它(数组)的修改却不会报错。因为,myArray 始终等于内存地址 “0458AFCZX91”,该地址指向的空间存放着另一个内存地址 “22VVCX011”,而这第二个地址指向的空间则真正存放着堆中的数组。

如果我们这么做,则会报错:

myArray = 3

因为 3 是基本类型的值,这么做会在内存中分配一块新的空间用于存放 3,同时会修改 myArray 的值,使其等于这块新空间的地址。而由于 myArray 是用 const 声明的,这样修改就必然会报错。

下面这样做同样会报错:

myArray = ['a']

由于 [‘a’] 是一个新的引用类型的数组,因此在栈中会分配一块新的空间来存放堆中的某个空间地址,堆中这块空间则用于存放[‘a’] 。之后我们试图把新的内存地址赋值给 myArray,这样显然也是会报错的。

对于用 const 声明的对象,它和数组的表现也是一样的。因为对象也是引用类型的数据,可以添加键,更新值,诸如此类。

const myObj = {}  
myObj['newKey'] = 'someValue' // this will not throw an error

知道这些有什么用?

GitHubStack Overflow 年度开发者调查报告) 的相关数据显示,JavaScript 是排名第一的语言。精通这门语言并成为一名“JS 大师”可能是我们梦寐以求的。在任何一门像样的 JS 课程或者一本书中,都会倡导我们多使用 constlet,少使用 var,但他们基本上都没有解释这其中的缘由。很多初学者会疑惑为什么有些用 const 声明的变量在“修改”的时候确实会报错,而有些变量却不会。我能够理解,正是这种反直觉的体验让他们更喜欢随处都使用 let,毕竟谁也不想踩坑嘛。

不过,这并不是我们推荐的方式。Google 作为一家拥有顶尖程序员的公司,它的 JavaScript 风格指南中就有这么一段话:用 const 或者 let 声明所有的局部变量。除非一个变量有重新赋值的需要,否则默认使用 const 进行声明。绝不允许使用 var关键字 (来源)。

虽然他们没有指出个中缘由,不过我认为有下面这些理由:

  1. 预先避免将来可能产生的 bug
  2. const 声明的变量在声明的时候就必须进行初始化,这会引导开发者关注这些变量在作用域中的表现,最终有助于促进更好的内存管理与性能表现。
  3. 带来更好的可读性,任何接管代码的人都能知道,哪些变量是不可修改的(就 JS 而言),哪些变量是可以重新赋值的。

希望本文能够帮助你理解使用 const 或者 let 声明变量的个中缘由以及应用场景。

参考:

  1. Google JS Style Guide
  2. Learning JavaScript: Call By Sharing, Parameter Passing
  3. How JavaScript works: memory management + how to handle 4 common memory leaks