深入探讨JavaScript中的内存管理
内存管理是编程语言的基本能力,JavaScript 中的内存管理是通过 V8 完成的。V8 的实现遵循 ECMA-262 规范,而规范中没有阐述内存布局以及内存管理相关信息,所以它的原理取决于解释器的实现。唯一肯定的是不管任何编程语言,内存的生命周期是一致的:
- 分配所需要的内存;
- 使用分配的内存(读、写);
- 不需要时将其释放、归还。
基于此背景下本文试图通过内存的生命周期拓展 JavaScript 的内存布局。【推荐学习:javascript视频教程】
开始分配内存之前需要先了解一下数据类型与数据结构。
数据类型
JavaScript 数据类型分为 基本类型
与 引用类型
。
基本类型:在语言最低层且不可变的值称为原始值。所有原始值都可以使用 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)
从内存角度区分基本类型与应用类型,关键在于值在内存中是否可变,基本类型更新会重新开辟空间并改变指针地址,引用类型更新不会改变指针地址但会改变指针所指向的对象;从代码上看,引用类型由基本类型和 {} 组成。
数据结构
JavaScript 程序运行时 V8 会给程序分配内存,这种内存称为 Resident Set(常驻内存集合)
,V8 常驻内存进一步细分成 Stack
和 Heap
。
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()
从图中可以看出函数执行后堆中变量分配情况。小数、字符串、symbol 都开辟了堆空间,说明分配在堆中。
有两个相同的"my-string"字符串,但并没有重复开辟两个字符串空间,因为 v8 内部存在名为 stringTable 的 hashmap 缓存了所有字符串,在 V8 阅读代码并转换为 AST 时,每遇到一个字符串都会换算为一个 hash 值插入到 hashmap 中。所以在我们创建字符串的时候,V8 会先从内存哈希表中查找是否有已经创建的完全一致的字符串,若存在,直接复用。若不存在,则开辟一块新的内存空间存储。这也是为什么字符串是不可变的,修改字符串时需要重新开辟新的空间而不能再原来的空间上作修改。
小整数、boolean、undefined、null、空字符串并没有额外开辟空间,对这些数据类型有两种猜测:
- 存放在栈空间中;
- 存放在堆中但在系统启动时就已经开辟。
其实 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 分为 smi
和 heapNumber
,分别用于存储小整数与小数(包括大整数)。ECMAScript 标准约定 Number 需要被当成 64 位双精度浮点数处理,但事实上一直使用 64 位去存储任何数字在时间和空间上非常低效的,并且 smi 大量使用位运算,所以为了提高性能 JavaScript 引擎在存储 smi 的时候使用 32 位去存储数字而 heapNumber 使用 32 位或 64 位存储数字。