【译】JavaScript 内存模型揭秘

为什么原始值不是分配在栈上的

我承认这个标题有点标题党的意思。也许更准确的标题应该是"揭秘当前版本的 V8 中实现的 JavaScript 内存模型(很多内容做了简化)"。V8非常复杂,它有多种运行代码的方式,而且它的各个部分和流水线多年来已经被重写了很多次,所以我今天描述的内容可能明天就过时了。此外,这不会是一篇硬核的内存相关的文章,我真的不是很有资格谈论它。

网上有大量资源声称在 JavaScript 中原始值是分配在栈(Stack)上的,而对象是分配在堆(Heap)上的。这种想法是错误的,至少这不是我见过的大多数 JavaScript 引擎中的语言实现方式。我写这篇文章是为了将来能点进来温故知新,节省我的时间。

文章很长;不想看可直接看结论

  • 所有 JavaScript 值都分配在通过指针访问的堆上,无论它们是object、array、string还是numbers(除了小整数,即 V8 中由于指针标记而导致的smi)。
  • 栈上仅存储临时的、函数局部的和小变量(主要是指针),这在很大程度上与 JavaScript 类型无关。

这些都是实现细节

首先,JavaScript语言本身并未对内存如何布局做出要求。你在ECMAScript规范中无法找到"Stack"或"Heap"这些术语。事实上,我怀疑你在任何语言规范中都找不到关于内存布局的任何信息------即使对于被认为比JavaScript底层得多的C++,也没有在其标准中定义相关的术语。

这些被视为实现细节。当你问JavaScript如何分配内存,就好像在问JavaScript是编译型语言还是解释型语言一样。这是一种错误的问法。解释或编译的不是语言而是实现------我们可以轻松构建简单的JavaScript AST解释器,或者基于栈的虚拟机,或者本地代码的静态LLVM编译器。

然而,作为一个实现细节并不意味着它就是一个不可触碰的神话。您可以通过在Chrome DevTools中进行内存分析来轻松地检查这一点。如果你想了解真相,你可以随时查找VM的源代码------至少对于V8来说,它都是开源的。

本文中的所有示例均基于V8的实现。V8源代码来自commit Id a684fc4c927940a073e3859cbf91c301550f4318

(几乎)一切都在堆上

与普遍看法相反,原始值也像对象一样分配在堆上。

如果你不想真正深挖V8源码,我可以通过一个简单的方法向你证明这一点。

  • 首先使用node --v8-options | grep -B0 -A1 stack-size获取你机器上V8中栈的默认大小。我的电脑上输出的是 864KB。
  • 在一个JavaScript文件上创建一个巨大地字符串,使用process.memoryUsage().heapUsed获取使用堆内存的大小。

下面的代码就可以做到这一点:

js 复制代码
function memoryUsed() {
    const mbUsed = process.memoryUsage().heapUsed / 1024 / 1024
    console.log(`Memory used: ${mbUsed} MB`);
    
}

console.log('before');
memoryUsed()

const bigString = 'x'.repeat(10*1024*1024)
console.log(bigString); // need to use the string otherwise the compiler would just optimize it into nothingness

console.log('after');
memoryUsed() 

在我们创建字符串之前使用的堆内存大小是3.78MB。

在我创建了一个大小为10MB的字符串后,使用的堆内存增加到了13.78MB。

前后相差正好10MB。看看我们之前打印出来的栈大小,它只有864KB------栈不可能存储这样的字符串。

(大部分)原始值是被重用的

String interning

译者注:标题这里原文就是String interning,google机翻出来是"字符串实习"或"字符串驻留",感觉都不太符合中文的语言习惯,所以这里保留原文,下同。

一个简单的问题:对于由'x'.repeat(10*1024*1024)创建的 10 MB 字符串,一个赋值语句(例如const anotherString = bigString)是否会在内存中重复创建这个字符串,以至于最终在堆上分配的内存总大小为 20 MB?

答案是否定的------不会分配重复的字符串。你可能也知道,通常 JavaScript 变量的赋值不会产生与实际值的大小成正比的成本 - 这就是指针的要点,而(大部分)JavaScript 变量都是指针。

你可以通过使用 Chrome DevTools 的 Memory 面板进行内存分析来检验这一点。

通过下面的代码片段创建一个html文件。

html 复制代码
<body>
    <button id='btn'>btn</button>
    <script>  
    const btn = document.querySelector('#btn')
    btn.onclick = () => { 
        const string1 = 'foo'
        const string2 = 'foo'
    }
    </script>
</body>

运行内存分析并单击按钮创建两个具有相同字符串值 foo 的变量。

可以看到堆上只分配了一个堆字符串。

Chrome DevTools 不显示指针驻留在内存中的位置,而是显示它们指向的位置。还有您看到的数字,例如@206637不代表原始内存地址。如果要检查实际内存,则需要使用native debugger。

这被称作 string interning在V8中, 它被实现为StringTable

arduino 复制代码
explicit StringTable(Isolate* isolate);
  ~StringTable();

  int Capacity() const;
  int NumberOfElements() const;

  // Find string in the string table. If it is not there yet, it is
  // added. The return value is the string found.
  Handle<String> LookupString(Isolate* isolate, Handle<String> key);

  // Find string in the string table, using the given key. If the string is not
  // there yet, it is created (by the key) and added. The return value is the
  // string found.
  template <typename StringTableKey, typename IsolateT>
  Handle<String> LookupKey(IsolateT* isolate, StringTableKey* key);

Oddballs

V8 中有一个特殊的原始值子集,称为 Oddball

scala 复制代码
type Null extends Oddball;
type Undefined extends Oddball;
type True extends Oddball;
type False extends Oddball;
type Exception extends Oddball;
type EmptyString extends String;
type Boolean = True|False;

早在script的第一行代码运行之前,它们就由 V8 预先分配在了堆上 ------ 无论你的 JavaScript 程序后续是否真的用到了它们,都没有关系。

它们总是被复用的------每种Oddball类型只有一个值。

kotlin 复制代码
function Oddballs() {
            this.undefined = undefined
            this.true = true
            this.false = false
            this.null = null
            this.emptyString = ''
        }
const obj1 = new Oddballs()
const obj2 = new Oddballs()

为上面的script代码拍摄堆快照,可以看到:

看到了吗?每个Oddball类型在堆上的内存位置是相同的,即使它们的值是由不同的对象的属性指向的。

当我们创建"具有"Oddball值的 JavaScript 变量时,我们可以这样理解,就好像它们是在我们的 JavaScript 程序中被"召唤"的 ------ 我们无法创建或销毁它们。

(大部分)JavaScript 变量都是指针

深入研究源代码,我们可以发现我们在 JavaScript 程序中创建的变量只是指向位于堆上的这些 C++ 对象的内存地址。

例如,对undefined来说,在V8中的实现 是:

ini 复制代码
V8_INLINE Local<Primitive> Undefined(Isolate* isolate) {
  using S = internal::Address;
  using I = internal::Internals;
  I::CheckInitialized(isolate);
  S* slot = I::GetRoot(isolate, I::kUndefinedValueRootIndex);
  return Local<Primitive>(reinterpret_cast<Primitive*>(slot));
}

而这是GetRoot在V8中的实现 ,它返回一个内存地址。

php 复制代码
V8_INLINE static internal::Address* GetRoot(v8::Isolate* isolate, int index) {
    internal::Address addr = reinterpret_cast<internal::Address>(isolate) +
                             kIsolateRootsOffset +
                             index * kApiSystemPointerSize;
    return reinterpret_cast<internal::Address*>(addr);
  }

我的另一篇文章 更详细地介绍了 JavaScript 变量是如何实现的。

Numbers是很复杂的

在 V8 中,64 位架构(V8 术语为 smi)上范围从 -2³¹ 到 2³¹-1 的整数是经过严格优化的,因此可以直接在指针内部进行编码,而无需为其分配额外的存储空间。它并不是 V8 或 JavaScript 所独有的。 OCamlRuby 等许多其他语言也这样做。

因此从技术上讲,smi可以存在于栈上,因为它们不需要在堆上分配额外的存储空间,具体取决于变量的声明方式:

  • const a = 123可能存在于栈上。
  • var a = 123位于堆上,因为它作为全局对象(译者注:即window对象)的属性存在,而全局对象存在于内存中的固定位置。

它还取决于脚本的其余部分正在做什么以及运行时环境。优化编译器尽可能多地将指针保存在寄存器中,并且仅在寄存器耗尽等情况下才会溢出到栈中。

关于numbers的另一个复杂之处是,与其他类型的原始值不同,它们可能不会被重用:

  • 对于smi,它们被编码为可识别的无效指针,这些指针不指向任何内容,因此"复用"的整个概念并不真正适用于它们。
  • 对于HeapNumber (那些不被视为smi的numbers)来说:
    • 当它们被对象的属性指向时,它就变成一个可变的HeapNumber,它允许更新值而无需每次都分配新的HeapNumber。我相信这种设计决策对于大多数使用模式来说是一种有益的权衡,但代价是使HeapNumber不可共享。
    • 其它时候它们可以 被复用,但前提是不会产生额外的性能开销:例如,像 3 + 0.14314/100 这样的计算将导致分配值 3.14 的两个 HeapNumber,因为检查 HeapNumber3.14是否已经存在是不值得的。

为了验证我们的理论,让我们尝试一下这个代码片段:

csharp 复制代码
function MyNumbers() {
        this.smi = 123
        this.number = 3.14
      }
const num1 = new MyNumbers()
const num2 = new MyNumbers()

拍一张堆快照,可以看到:

如果你仔细观察,你会发现有两个smi 似乎"指向"同一个内存位置@427911smi 是指向任何地方的立即整数值。造成这种情况的原因是,它们对于相同的值 123 具有相同的位模式,即使从指针标记上来说它们是无效指针,Chrome DevTools 的堆快照还是"盲目地"将它们视为了指针。

至于HeapNumber,它们指向不同的内存位置@427915@427927,这意味着它们由于是可变的HeapNumber而不会被复用。

总结

下面的图表概念性地说明了 V8 中一些可能的内存布局:

结束语

计算机内存是一个极其复杂的话题。我绝不是这个话题的专家。几乎每个与内存相关的问题的答案都因编译器和处理器架构的不同而不同。例如,我们的变量并不总是在内存(RAM)中 ------ 它们可以直接加载到目标寄存器中,作为立即值成为指令的一部分,甚至完全优化为空。只要保留规范定义的所有语言语义,编译器就可以做任何它想做的事 ------ 所谓的 as-if规则

如果你有兴趣学习内存布局等底层细节,JavaScript 并不是一个很好的学习工具,因为像 V8 这样的 JavaScript 引擎太复杂、太强大了。最好从 C 或 C++ 开始,并使用 godbolt 来理解源码是如何变成机器码的。

相关推荐
香蕉可乐荷包蛋2 小时前
浅入ES5、ES6(ES2015)、ES2023(ES14)版本对比,及使用建议---ES6就够用(个人觉得)
前端·javascript·es6
未来之窗软件服务2 小时前
资源管理器必要性———仙盟创梦IDE
前端·javascript·ide·仙盟创梦ide
西哥写代码4 小时前
基于cornerstone3D的dicom影像浏览器 第十八章 自定义序列自动播放条
前端·javascript·vue
清风细雨_林木木4 小时前
Vue 中生成源码映射文件,配置 map
前端·javascript·vue.js
雪芽蓝域zzs4 小时前
JavaScript splice() 方法
开发语言·javascript·ecmascript
森叶5 小时前
Electron 主进程中使用Worker来创建不同间隔的定时器实现过程
前端·javascript·electron
霸王蟹5 小时前
React 19 中的useRef得到了进一步加强。
前端·javascript·笔记·学习·react.js·ts
霸王蟹5 小时前
React 19版本refs也支持清理函数了。
前端·javascript·笔记·react.js·前端框架·ts
codelxy5 小时前
vue引用cesium,解决“Not allowed to load local resource”报错
javascript·vue.js
程序猿阿伟6 小时前
《社交应用动态表情:RN与Flutter实战解码》
javascript·flutter·react native