深入探讨JavaScript中的内存管理

javascriptjavascript 2023-08-28 16:48:29 1090
摘要: 内存管理是编程语言的基本能力,JavaScript中的内存管理是通过V8完成的。V8的实现遵循ECMA-262规范,而规范中没有阐述内存布局以及内存管理相关信息,所以它的原理取决于解释器的实现。唯一肯定的是不管任何编程语言...

内存管理是编程语言的基本能力,JavaScript 中的内存管理是通过 V8 完成的。V8 的实现遵循 ECMA-262 规范,而规范中没有阐述内存布局以及内存管理相关信息,所以它的原理取决于解释器的实现。唯一肯定的是不管任何编程语言,内存的生命周期是一致的:

  1. 分配所需要的内存;
  2. 使用分配的内存(读、写);
  3. 不需要时将其释放、归还。

基于此背景下本文试图通过内存的生命周期拓展 JavaScript 的内存布局。【推荐学习:javascript视频教程】

开始分配内存之前需要先了解一下数据类型与数据结构。

数据类型

JavaScript 数据类型分为 基本类型引用类型

1.png

基本类型:在语言最低层且不可变的值称为原始值。所有原始值都可以使用 typeof 运算符测试所属基本类型(除了null,因为typeof null === "object")。所有原始值都有它们相应的对象包装类(除了 null 和 undefined),这为原始值提供可用的方法。基本类型的对象包装类有 Boolean、Number、String、Symbol。

引用类型:表示内存中的可变的值,JavaScript 中对象是唯一可变的。Object、Array、函数等都属于对象。给对象定义属性可通过 Object.defineProperty() 方法,读取对象属性信息可通过 Object.getOwnPropertyDescriptor()。

基本类型与引用类型可以互转,转换的行为称为 装箱拆箱

装箱:基本类型 => 引用类型 e.g: new String('call_me')

拆箱:引用类型 => 基本类型 e.g: new String('64').valueOf()、new String('64').toString()

以下是一些开发过程中常见的类型转换:

  • number -> string: let a = 1 => a+"" / String(a)
  • string -> number: let a = "1" => +a / ~~a / Number(a)
  • any -> boolean: let a = {} => !a / !!a / Boolean(a)

从内存角度区分基本类型与应用类型,关键在于值在内存中是否可变,基本类型更新会重新开辟空间并改变指针地址,引用类型更新不会改变指针地址但会改变指针所指向的对象;从代码上看,引用类型由基本类型和 {} 组成。

数据结构

2.png

JavaScript 程序运行时 V8 会给程序分配内存,这种内存称为 Resident Set(常驻内存集合),V8 常驻内存进一步细分成 StackHeap

Stack(栈) 是自动分配大小固定的内存空间,并由系统自动释放。栈数据结构遵循先进后出的原则,线性有序存储,容量小,系统分配效率高。

Heap(堆) 是动态分配大小不固定的内存空间,不会自动释放(释放依赖 GC)。堆数据结构是一棵二叉树结构,容量大,速度慢。

一个线程只有一个栈内存空间,一个进程只有一个堆空间。

栈内存空间默认大小是 864KB,也可通过 node --v8-options | grep -B0 -A1 stack-size 查看。

栈结构其实经常可以看到,当写了一段报错代码时,控制台的错误提示就是一个栈结构。从下往上看调用路径,最顶部就是错误位置。例如最顶部抛出 Maxium call stack size exceeded 错误就代表当前调用超出了栈的限制。

堆中的结构划分为 新空间(New Space)旧空间(Old Space)大型对象空间(Large object space)代码空间(Code-space)单元空间(Cell Space)属性单元空间(Property Cell Space)映射空间(Map Space),新空间和旧空间在后面会详细介绍。

大型对象空间(Large object space):大于其他空间大小限制的对象存放在这里。每个对象都有自己的内存区域,这里的对象不会被垃圾回收器移动。

代码空间(Code-space):存储已编译的代码块,是唯一可执行的内存空间。

单元空间(Cell Space)、属性单元空间(Property Cell Space)和映射空间(Map Space):这些空间分别存放 Cell,PropertyCell 和 Map。这些空间包含的对象大小相同,并且对对象类型有些限制,可以简化回收工作。

每个空间(除了大型对象空间(Large object space))都由若干个 Page 组成。一个 page 是由操作系统分配的一个连续内存块,一个内存块大小为 1MB

从内存角度区分栈与堆,关键在于用完是否立即释放。

相信读者们看到这里肯定会联想到数据类型与堆栈的关联,网上和一些书籍的结论是:原始值分配在栈上,而对象分配在堆上。这个说法真的对吗?带着问题我们进入第二步:使用分配的内存。

内存模型

Node 提供了 process.memoryUsage() 方法描述 Node.js 进程的内存使用情况(以字节 Bytes 为单位)

$ node
> process.memoryUsage()

假设原始值分配在栈上,而对象分配在堆上是对的,结合栈空间只有 864KB。如果我们声明一个 10MB 的字符串,看看堆内存是否会发生变化。

const beforeMemeryUsed = process.memoryUsage().heapUsed / 1024 / 1024;

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

const afterMemeryUsed = process.memoryUsage().heapUsed / 1024 / 1024;

console.log(`Before memory used: ${beforeMemeryUsed} MB`); // Before memory used: 3.7668304443359375 MB
console.log(`After memory used: ${afterMemeryUsed} MB`); // After memory used: 13.8348388671875 MB

堆内存消耗接近 10 MB,说明字符串存储在堆中

那么小字符串以及其他基本类型是否同样的存储在堆中呢,我们借助谷歌浏览器的 Memery 堆快照(Heap snapshot)进行分析。

打开谷歌浏览器无痕模式 Console 中输入以下代码,并分析执行前后的变量变化。

function testHeap() {
    const smi = 18;
    const heapNumber = 18.18;
    const nextHeapNumber = 18.18;
    const boolean = true;
    const muNull = null;
    const myUndefined = undefined;
    const symbol = Symbol("my-symbol");
    const emptyString = "";
    const string = "my-string";
    const nextString = "my-string";
}
testHeap()

3.png

从图中可以看出函数执行后堆中变量分配情况。小数、字符串、symbol 都开辟了堆空间,说明分配在堆中

有两个相同的"my-string"字符串,但并没有重复开辟两个字符串空间,因为 v8 内部存在名为 stringTable 的 hashmap 缓存了所有字符串,在 V8 阅读代码并转换为 AST 时,每遇到一个字符串都会换算为一个 hash 值插入到 hashmap 中。所以在我们创建字符串的时候,V8 会先从内存哈希表中查找是否有已经创建的完全一致的字符串,若存在,直接复用。若不存在,则开辟一块新的内存空间存储。这也是为什么字符串是不可变的,修改字符串时需要重新开辟新的空间而不能再原来的空间上作修改。

小整数、boolean、undefined、null、空字符串并没有额外开辟空间,对这些数据类型有两种猜测:

  1. 存放在栈空间中;
  2. 存放在堆中但在系统启动时就已经开辟。

4.png

5.png

其实 V8 中有一个特殊的原始值子集,称为 Oddball。它们在运行之前由 V8 预先分配在堆上,无论 JavaScript 程序是否实际使用到它们。从整个堆空间查看这些类型的分配,boolean、undefined、null、空字符串分配在堆内存中且属于 Oddball 类型。无论何时分配空间对应的内存地址永远是固定的(空字符串@77、null@71、undefined@67、true@73)。但并未找到小整数,证明函数局部变量小整数存在栈中,但定义在全局中的小整数则是分配在堆中

同样都是表示 Number 类型,小整数和小数在存储上有什么区别呢?

一般编程语言在区分 Number 类型时需要关心 Int、Float、32、64。在 JavaScript 中统称为 Number,但 v8 内部对 Number 类型的实现可没看起来这么简单,在 V8 内部 Number 分为 smiheapNumber,分别用于存储小整数与小数(包括大整数)。ECMAScript 标准约定 Number 需要被当成 64 位双精度浮点数处理,但事实上一直使用 64 位去存储任何数字在时间和空间上非常低效的,并且 smi 大量使用位运算,所以为了提高性能 JavaScript 引擎在存储 smi 的时候使用 32 位去存储数字而 heapNumber 使用 32 位或 64 位存储数字