深入JS语言实现(一)——从一个JavaScript变量存储位置的问题开始

413 阅读5分钟

这是我参与11月更文挑战的第10天,活动详情查看2021最后一次更文挑战

前言

对于一个经常以逛社区代替严肃学习的前端工程师来说,平时必不可少的会看到各种关于研究JavaScript语言实现相关的单篇文章或系列。其中有些是由对V8源码、浏览器实现等有着丰富经验且眼界高的工程师们所著,有些文章的作者或许对背后的原理只有粗浅的了解、只是发出来作为自己学习的一个总结和记录。

前端社区的火热放大了这种良莠不齐的负面影响,或许会在无意中导致某SDN那样的结果——后续搜索相关知识的人会困扰于相关文章的相似度、甚至是其中知识的矛盾点,导致在查找知识的过程中走弯路。

对此笔者的建议有两点:首先是学好英文,流畅阅读英文的基础之上才能无碍阅读英文社区中关于类似问题的讨论,有些领域和一些小众问题上英文社区历史讨论的含金量往往会高出中文社区;第二是阅读技术文章时进行比对,并且多阅读系列文章或成文的书籍,那样碰到理解有误情况的概率会比较低。

(最后,虽然推崇严肃学习和看系列文章,但也不妨碍我拿这件事来水一篇更文挑战。)

问题的开始

话说回来,今天在逛知乎时发现了一个问题的回答:

前端有哪些知识是错误的,却以讹传讹的很多人让很多人都理解错了?

回答指出了:

JavaScript基础类型和引用类型对象的内存存放位置不像很多文章中说的那样即“基础类型存放在栈上、引用类型存放在堆上”,因为JavaScript中的string大小和object一样是可变的,所以底层实现一般是在栈上保留了一个指向堆内存的指针和string的长度。

笔者寻思了一下,确实看到过不止一篇文章有这样的“基础类型放栈内存,引用类型放堆内存”的说法,该回答附带的文章「前端进阶」JS中的栈内存堆内存就有这样的描述。这种说法结合了我们平时对OS中进程内存的理解(代码段、数据段、BSS、堆栈空间),并给出了看上去非常“合理”的划分理由:

引用数据类型存储在堆内存中,因为引用数据类型占据空间大、大小不固定。 如果存储在栈中,将会影响程序运行的性能; 引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。 当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体

这样的“看上去合理”也会有更大的迷惑性,让读者觉得“这样的解释很有道理”,从而为错误的理解赋予合理性。不过对知识的掌握往往本就有从错误到正确认识的过程。这篇文章的评论区和上面那个回答中就有对文章正确性的讨论。我们从这篇文章的理解开始,对比一下其他的理解和一些关于讲解V8实现的文章,来看一下JavaScript中真正的变量存储实现是如何做的。

错误的理解

「前端进阶」JS中的栈内存堆内存这篇文章总结了这些结论:

  • JS的内存空间分为栈(stack)、堆(heap)、池(一般也会归类为栈中)。其中栈存放变量,堆存放复杂对象,池存放常量,所以也叫常量池。
  • 基本数据类型保存在栈内存中,因为基本数据类型占用空间小、大小固定,通过按值来访问,属于被频繁使用的数据。
  • 需要注意的是闭包中的基本数据类型变量不保存在栈内存中,而是保存在堆内存中。
  • 引用数据类型存储在堆内存中,因为引用数据类型占据空间大、大小不固定。 如果存储在栈中,将会影响程序运行的性能; 引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。 当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
  • 引用类型的复制会为新的变量自动分配一个新的值保存在变量中, 但只是引用类型的一个地址指针而已,实际指向的是同一个对象
  • 栈内存由于它的特点,所以它的系统效率较高。 堆内存需要分配空间和地址,还要把地址存到栈中,所以效率低于栈。
  • 栈内存中变量一般在它的当前执行环境结束就会被销毁被垃圾回收制回收, 而堆内存中的变量则不会,因为不确定其他的地方是不是还有一些对它的引用。 堆内存中的变量只有在所有对它的引用都结束的时候才会被回收。
  • 闭包中的变量并不保存中栈内存中,而是保存在堆内存中。 这也就解释了函数调用之后之后为什么闭包还能引用到函数内的变量。
  • 现在的 JS 引擎可以通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上。

后记

这是笔者写的第一篇关于JavaScript语言实现的文章,也算是从一个很小的视角切入,对JavaScript语言的V8实现做了一点浅薄的介绍。后续会在自己掌握了V8相关知识后像这篇文章一样,多引用和对比一些大佬们的理解和代码实现,将正确的理解和关键知识点都梳理总结出来。