作为高级编程语言,JavaScript有着和其他高级编程语言类似的内存管理机制——基于堆栈内存的管理机制。数据对象动态地分配到栈内存或者堆内存上,并且语言引擎维护着自动的内存回收机制。同时,JavaScript具备自身的语言特点,内存管理机制为这些特点实现了自己的内存管理机制上的扩展。内存管理分为三部分:内存分配、内存使用、垃圾回收(内存回收)。JavaScript的内存分配和垃圾回收是底层引擎自动维护的,并且不对外暴露操作接口,大部分时候它们对JavaScript开发人员来说是无感知的。当我们构建大型程序,当我们想要编写出卓越的产品,理解它们底层的工作机制是必需的。
堆栈内存管理
栈(stack)是一种典型的后进先出(Last in First Out)的数据结构(大部分时候是数组或单链表实现),其操作主要有压栈(push)与出栈(pop)两种操作。堆(Heap)是一种特别的完全二叉树。内存区域以栈结构管理则称为栈内存,内存区域以堆结构管理则称为堆内存。栈的数据结构的特点是访问迅速,缺点是存储数据大小固定,不易扩展,堆的数据结构增加了访问呢复杂度,但是优势是不限制数据大小,两个数据结构在内存管理上的应用区别如下表:
栈内存 | 堆内存 | |
---|---|---|
数据类型 | 存储基础类型数据 | 存储引用值类型数据 |
数据大小特征 | 存储的值大小固定 | 存储的值大小可变 |
访问方式 | 按值访问 | 按引用访问 |
空间大小 | 空间小 | 空间大 |
运行效率 | 查找、更新效率高 | 查找、更新效率低 |
数据顺序特征 | 先进后出,后进先出 | 无序数据 |
内存分配特征 | 内存分配连续 | 内存分配不保证连续 |
大部分时候栈内存和堆内存配合工作,共同构成计算机程序的内存管理机制,我们可称其为堆栈内存管理。大部分定长的基础类型数据,譬如整型数字(INTEGER)、字符(chart)、布尔(Boolean),都存储在栈内存上;而作为结构体的复杂对象,数据大小、成员数量不固定,在栈上存储固定大小的引用指针,指针指向在堆内存上存储的实体。两者配合工作的模型如下图所示:
变量a
和b
属于基础数据类型,他们是按值访问的。变量m
和n
是复杂对象,在栈结构上存储了它们的引用,当取值时会沿着引用到堆内存寻找具体的对象值。如果用户声明一个新变量c
,并赋值为1
,操作系统会在栈内存加入c
的声明,并赋值为1
。如果用户声明新变量z
,并进行赋值操作z = m
,操作系统会在栈内存加入z
的声明,并将z
声明在栈上保存的引用(地址)值赋值为m
所保存的引用(地址)。此时m
和z
指向了同一个堆内存中的对象。这就是按值访问和按引用访问。
JavaScript 内存分配
JavaScript并不是完全复刻了一个简单的堆栈内存管理机制,在JavaScript中,数据类型是动态类型,具备弱类型特征。JavaScript有七种原始值类型,分别是Null
,Undefined
,Boolean
,Number
,BigInt
,String
,Symbol
,其余所有JavaScript变量都属于对象,包括函数。原则上,原始值类型数据存储在栈内存上,对象(包括方法)存储在堆内存上。在实际中,String
类型值的大小并不固定,为了保证基础类型数据在内存管理行为上的统一,JavaScript内存管理借助常量池来管理这些大小可变的基础数据类型以及一些内置的常量(Function
,Array
,RegExp
等)。常量池有着和堆内存类似的内存分配方式,但是没有和堆内存类似的垃圾回收方式。
常量池的对象在程序运行期间通常不会进行回收以提高程序运行效率。例如,用户先声明了第一个字符串str1 = 'javascript'
,又声明了第二个字符串str2 = 'javascript'
。操作系统不会创建一个新的字符串,而是直接复用已存在的,使得新的字符串声明和旧的声明指向同一个字符串对象,即str1 === str2
为真。任何新创建的对象永远不会等于已存在的对象。
全局的
Symbol
值(使用Symbol.for()
创建)和BigInt
内存分配也是类似字符串的方式。null
、undefined
、true
、false
的值作为常量也是分配在常量池上。
执行以下变量声明会申请内存分配:
function func() {
let a = 1; // 声明数字变量
let str1 = 'javascript'; // 声明字符串变量
let arr = [1, 2, 3]; // 声明数组变量
let obj1 = { n: 'name' }; // 声明对象变量
}
func();
以下是基于V8 JavaScript 引擎的内存分配图解:
执行以下代码比较字符串的声明和对象声明的区别:
function func() {
let str2 = 'javascript'; // 声明值已存在的字符串变量
let obj2 = obj1; // 对象声明和赋值
let obj3 = { n: 'name' }; // 声明和已存在的对象模式相同并且值相同的对象
}
func();
执行代码后的新的内存分配如下:
字符串存储在常量池中,以便使得字符串类型数据按值引用。由于字符串对象轻易不进行回收的缘故,在使用超长字符串值的时候,就需要关注程序的内存使用情况。
扩展:
let
声明和const
声明的不同点就在于栈内存中保存的变量名和变量值的配对是否可改变。
对于原始值数据类型,操作系统在程序在执行过程中对数据的出栈、入栈操作,构成了天然的分配、回收操作,所以它是迅速的,不需要特别回收机制实现的。我们下文中讨论的垃圾回收,是堆内存中的垃圾回收。堆内存中的数据不仅需要单独的回收机制,在进行堆内存分配的时候,也需要判断申请的内存单元是否是占用中的状态。
闭包和全局中的变量
在讨论闭包的文章中我们介绍过,闭包是一个入口函数和包含环境变量的特殊数据结构体。当闭包捕获外层函数(方法)的自由变量时,JavaScript引擎会将闭包实现为一个包含闭包函数和所捕获的自由变量的结构体。在这种情况下,如果自由变量是原始值,它会被“搬运”到闭包结构体中,这个结构体存储在堆内存中,如果捕获了对象,闭包结构体会添加对象的引用。这么做的原因是为了方便外层函数退出,优化内存的使用。由此,闭包中的捕获的原始值类型值,会被“搬运”到堆内存中。
全局作用域下声明的变量会被绑定到全局对象的属性上,全局对象本身是一个数据结构体,它的属性值(全局变量)也会保存到堆内存上。
内存的使用
使用变量值基本上是指在分配的内存上进行读写。读写变量值或对象的属性值,甚至给函数传递参数都会使用值,这些都是内存的使用。当一个堆内存中的对象不再使用的时候,便会触发垃圾回收机制。
JavaScript垃圾的回收机制触发时机和执行都是自动的,底层引擎没有向上暴漏垃圾回收相关的接口。不过正是由于它的自动性,有时候给程序状态带来混乱。了解并且配合垃圾回收机制,避免遭遇内存问题,也是优秀的开发工程师必备的能力。
垃圾回收(内存回收)算法
垃圾回收核心的问题是确定内存空间的是否“不再需要”。当垃圾回收过程被触发后,垃圾回收器只能堆不再需要的内存进行“回收”以便再利用。早期浏览器主要存在两种垃圾回收算法的实现,分别是引用计数算法和标记清除算法。目前流行的V8 JavaScript引擎将垃圾回收器实现了以标记清除为主要,(变体的)复制算法为次要的混合实现,在健壮性和执行效率上都有很大提升。当前小节内容参考维基百科中对垃圾回收算法的介绍。
1. 引用计数
引用计数垃圾回收是最初级的垃圾回收算法。此算法把确定对象是否仍然需要这个问题简化为确定对象是否仍有其他引用它的对象。如果没有指向该对象的引用,那么该对象称作“垃圾”或者可回收的。它的具体步骤如下:
对于堆内存中的对象:
- 当一个对象被创建并赋值给一个声明时,它的引用数量为1。
- 每当将对象赋值给一个变量声明或者对象属性时,它的引用计数加1。
- 每当引用对象的变量声明或者对象属性被其他值覆写时,它的引用计数减1。
- 垃圾回收器周期性扫描内存,对引用计数为0的对象进行回收。
请看以下示例代码:
let arr = [1, 2]; // 堆内存中创建数组对象 [1, 2],引用计数为1
let obj = { // 堆内存中创建 obj 对象,对象属性 numArr 被赋值为 arr,数组对象 [1, 2]引用计数为2
numArr = arr;
}
arr = null; // 变量 arr 声明被覆写,数组对象 [1, 2]引用计数为1
obj.numArr = []; // 对象 obj 属性 numArr 被覆写,数组对象 [1, 2]引用计数为0,垃圾回收器扫描时会被认为“不再需要”
引用计数垃圾回收算法最大的特点是实现简单,难以处理循环引用。下列代码示例展示了循环引用。
function func() {
let obj1 = {
name = 'obj1';
child = null;
};
let obj2 = {
name = 'obj2';
child = null;
};
obj1.child = obj2;
obj2.child = obj1;
}
func();
哪怕func()
执行完毕,函数对象被回收,在函数执行过程中创建的由obj1
和obj2
指向的对象的引用计数始终为1,它们占用的内存无法被回收器回收。IE8以及其更早版本的IE中,BOM和DOM对象并非是原生JavaScript对象,它是由C++实现的组件对象模型对象(COM,Component Object Model),而COM对象使用引用计数算法来实现垃圾回收,所以即使浏览器使用的是标记清除算法,只要涉及到COM对象的循环引用,就还是无法被回收掉,经常发生内存泄漏的问题。
2. 标记-清除
这个算法将“对象不再需要”这个定义简化为“对象不可达”。
这个算法假定有一组叫做root
的对象。在JavaScript中,root
是全局对象。垃圾回收器将定期从这些root
开始,暂停整个程序的运行线程,找到从这些root
能引用到的所有对象,然后找到从这些对象能引用到的所有对象。找对所有在引用地对象后,对没有被引用地对象进行直接清除回收,回收完成后恢复运行线程(具体来说,暂停运行线程是为了进行标记的需要,运行中的程序线程会修改引用关系,使标记发生错误)。这个方法的直接好处就是循环引用不再是一个问题。这个算法的缺点是会产生大量的空闲空间碎片,也使大容量对象不容易获得连续的内存空间,而造成空间浪费。在JavaScript中暂停程序的运行也可能会造成页面卡顿。目前所有现代化的主流浏览器引擎均使用此算法实现垃圾回收器。
算法的工作流程如下图所示,可以看到垃圾回收执行完毕后内存空间碎片化。
很多时候,垃圾回收器在回收期间需要搬运对象使它们保存在连续的内存空间上,以消除碎片化空间。这就成了“标记-清除-压缩”算法。
3. 复制
与“标记-清除-压缩”算法类似,复制算法主要为了解决内存碎片化的问题,不同的是复制算法需要程序将所拥有的内存空间分成两个部分,标记完成之后直接将还在使用中的对象复制到空间另一部分。程序运行所需的存储对象先存储在其中一个分区(定义为“分区0”)。同样暂停整个程序的全部运行线程,进行标记后,回收期间将保留的存储对象搬运汇集到另一个分区(定义为“分区1”),完成回收,程序在本次回收后将接下来产生的存储对象会存储到“分区1”。在下一次回收时,两个分区的角色对调。复制算法要比“标记-清除-压缩”运行速度要快,但复制算法要求了更多地内存占用,属于用空间换时间地情景。
下图展示了复制清除算法的流程。
4. 分代
由于“复制”算法对于存活时间长,大容量的储存对象需要耗费更多的移动时间,和存在储存对象的存活时间的差异。需要程序将所拥有的内存空间分成若干分区,并标记为年轻代空间和年老代空间。程序运行所需的存储对象会先存放在年轻代分区,年轻代分区会较为频密进行较为激进垃圾回收行为,每次回收完成幸存的存储对象内的寿命计数器加1。当年轻代分区存储对象的寿命计数器达到一定阈值或存储对象的占用空间超过一定阈值时,则被移动到年老代空间,年老代空间会较少运行垃圾回收行为。
一般情况下,还有永久代的空间,用于涉及程序整个运行生命周期的对象存储,例如运行代码、数据常量等,该空间通常不进行垃圾回收的操作(例如上文中讨论的常量池)。通过分代,存活在局限域,小容量,寿命短的存储对象会被快速回收;存活在全局域,大容量,寿命长的存储对象就较少被回收行为处理干扰。目前主流的高级编程语言引擎都引入了分代机制,包括V8 JavaScript、Java、.Net。
V8 JavaScript引擎中的垃圾回收实现
从上文中对垃圾回收算法的介绍中,我们可以看到每个单纯的垃圾回收算法实现都有其自身局限性,暂停程序运行会造成页面卡顿,频繁移动大对象有性能问题,同时内存碎片化问题也会影响程序内存再分配的效率。V8 JavaScript引擎基于“标记-清除-压缩”算法,综合各算法的长处,规避垃圾回收工作带来的负面影响,形成了目前更全面的垃圾回收实现。
垃圾回收代际假说表明,计算机程序产生的对象,大部分都是生命周期短的,而产生的长周期对象一般情况下反而特别长。它们的生命周期呈现两极分化现象。对长周期对象进行频繁地扫描会浪费程序性能。
V8 JavaScript引擎在实现垃圾回收机制时,使用了分代回收,将内存分为新生代和老生代空间。新生代空间使用Scavenge算法实现垃圾回收器,老生代使用“标记-清除-压缩”算法实现垃圾回收器,Scavenge算法是复制清除算法的一种增强算法。新生代的垃圾回收器被称为副垃圾回收器,老生代的垃圾回收器被称为主垃圾回收器。
分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率。
在执行垃圾回收期间,副回收期还应用了并行处理,主回收器应用了并发处理,提高了回收期工作的效率。
1. 副垃圾回收器
副垃圾回收器Scavenge算法是增强的复制回收算法,复制回收最大的问题是在执行垃圾回收期间需要JavaScript程序进程全停顿来进行标记,JavaScript 是一门单线程的语言,这个停顿会阻塞JavaScript代码的执行。如果一次停顿时间过长,页面就会出现卡顿现象。为了减少页面出现停顿的次数,V8 JavaScript引擎引入了并行机制进行新生代的垃圾回收。所谓并行机制,在停顿主线程进行垃圾回收期间,引擎引入了多个辅助线程同时对内存对象进行标记和搬运,减少JavaScript主线程停顿的时长。如下图所示。
当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。因为这个算法要求多一份空闲空间,所以V8 JavaScript引擎限制了新生代内存地大小,对于比较大的对象,会直接搬运到老生代进行管理。
2. 主垃圾回收器
主垃圾回收器是“标记-清除-压缩”算法的实现,主垃圾回收器主要负责管理长生命周期的对象和大对象。在运行效率优化上,主垃圾回收器引入了增量标记与懒性清理机制,进行并发标记、并发清除,以及并行压缩,减少了停顿标记的时间,降低了垃圾回收线程对JavaScript主线程运行地影响。
并行是指暂停程序线程,辅助线程间同时进行工作。并发是指不暂停程序线程,辅助线程和程序线程同时进行工作。
增量标记(可暂停的标记)
正常的标记过程是对程序申请的堆内存进行一次全表扫描标记,这也是垃圾回收线程长时间暂停程序主线程的原因。增量标记是部分扫描堆内存,在程序线程需要执行的时候,把线程的使用权交还给程序线程进行执行,当程序线程进入闲时状态,重新启动标记任务直至完成整次标记任务,这样就会减少程序的卡顿。这里需要解决的问题是,当标记任务暂停后,程序线程的运行会修改对象的引用关系,导致已标记的状态错误。为了解决这个问题,V8 JavaScript引擎采用了三色标记法和写屏障。
与原始的非黑即白的对象标记不同,三色标记法使用黑灰白三种颜色标记对象的使用状态。白色表示未被标记的对象;灰色表示自身已被标记,但是成员变量未被标记的对象;黑色表示自身和成员变量都被标记的对象。
三色标记法按照如下规则工作。
- 在JavaScript程序进程闲时触发垃圾回收进程,将堆内存内所有对象初始化标记为白色——未标记状态。
- 从root对象集合出发,对访问的对象进行灰色标记,然后访问灰色对象的成员变量。
- 标记灰色对象的成员变量为灰色,然后将成员对象标记为灰色的对象标记为黑色。
- 重复第二步和第三步的标记工作,当JavaScript程序进程需要运行时暂停,交还程序进程控制权。
- 在JavaScript程序进程进入闲时恢复标记工作,从灰色状态的对象开始继续标记工作。
- 重复第二步和第三步的标记工作直到整个堆内存完成标记。
工作流程示例图如下:
图中的每一步都可以暂停,然后从灰色标记对象处恢复标记任务。直到没有灰色标记对象,完成整个引用对象的扫描。
当暂停标记任务后,JavaScript程序线程修改了对象的引用关系,那么,就需要写屏障来保证增量标记中对象标记状态的准确性。写屏障的工作规则如下:当一个黑色标记的对象A
的成员变量赋值给了一个对象B
,判断对象B
的标记状态,如果是黑色或者灰色,不影响标记状态,如果是白色,将对象B
的标记状态置为灰色,以便恢复标记工作时可以正常对对象进行标记。那么对于标记为黑色的对象修改后不再引用了该怎么处理呢?V8 JavaScript引擎没有做任何处理,已经标记为黑色的对象在这次扫描整体完成后不再被引用了,也不会被清除,但是不会影响它在下次标记中被标记为白色后进行清除。
结合下图进行说明:
在一次暂停中,对象M与成员N解除了成员关系,对象Y成为了对象M的新成员,此时设置Y为灰色,标记工作唤起后会对对象Y及其成员进行正常标记,M的原对象N因为已经被标记为黑色,不会在当前扫描中修改状态,不会在当前回收进程中被清除,而是在下次标记清除工作中进行处理。
懒性清理
标记完成之后,主垃圾回收器不一定立即执行清除任务,而是仍然等待JavaScript程序线程的闲时状态,在闲时进行内存清理工作。内存清理工作也可以随时暂停和唤起,不必需一次清理完毕,清理过程也引入了并行机制,清理完毕后引擎会对堆内存进行压缩操作,减少内存碎片化。
有关闲时状态
整体来讲,主垃圾回收器融合使用了几种优化策略,标记、清理工作都是尽量在JavaScript程序线程空闲时进行的,保证程序的流畅运行。那么JavaScript程序线程的闲时状态是什么呢?这个不得不介绍JavaScript的事件循环运行时模型,有机会单开一篇讨论。
3. GC roots
GC roots
(根集合)是垃圾回收器进行可达性分析的起点,所有从GC roots
出发能直接或间接访问到的对象都被视为存活对象。以下是一些常见的GC roots
:
- 全局对象:
window
、global
。 - 当前执行上下文中的活动对象:正在执行的函数内部的变量、闭包中引用的外部变量。
- DOM节点:所有未被移除的DOM元素的引用。
- 活动线程和事件队列中的引用:setTimeout和Promise回调中引用的对象、未解绑的事件监听器。
- 内置对象和系统引用:当前正在执行的作用域链(Scope Chain)、内置对象(如
Array
、Function
、JSON
)的引用。
4. 垃圾回收触发时机
对于次要垃圾回收器,下面情形会触发它的回收工作:
- 当年轻代的活动空间被填满时触发。
- 当程序请求分配新的内存,并且年轻代没有足够内存时触发。
- 当空闲时段有足够的空闲时间时触发。但并不是一定会触发,因为频繁的触发可能导致本可以在次要垃圾回收中得到回收的对象被移入了老年代。
对于主要垃圾回收器,下面情形会触发它的回收工作:
- 当老年代中的对象占用空间增长到超过某个启发式计算的内存限制时触发。
- 如果整个堆的使用情况超过了特定的内存阈值,基于启发式算法,系统可能触发主要垃圾回收。
- 当堆大小达到某个策略设定的开始增量标记的限制时,会开始在空闲时段执行增量标记,增量标记完成后,开始清除和压缩,最终完成主要垃圾回收。
- 在检测到应用的长期不活跃状态时,甚至在没有达到内存限制的情况下,可能主动进行主要垃圾回收来减少内存占用。
配合垃圾回收器工作
在我们了解JavaScript垃圾回收机制后,在开发工作中我们可以注意一些问题,以编写出运行效率高、内存使用不易出问题的程序。下面列举一些情景。
1. 避免全局变量
全局变量引用的对象不会被释放,当全局对象不再使用时,及时赋值null
,解除对对象的引用。另外注意未使用声明关键字的赋值操作会自动绑定到全局环境中。
function func() {
obj = { name: 'obj' }; // 因为 obj 未使用声明关键字,会被绑定到全局对象上。
return 1;
}
func();
console.log(window.obj); // 输出对象 obj
2. 被遗忘的定时器和回调函数
定时器将回调注册到全局,在不需要的时候需要手动清理。以下代码中,当dataDom
被销毁时,注册的回调一直在静默执行,且导致store
对象无法回收。
function updateData() {
const store = new DataStore();
setInterval(function() {
const dataDom = document.getElementById('json-data-show');
if(dataDom) {
dataDom.innerHTML = JSON.stringify(store.getData());
}
}, 10000); // 每 10 秒更新一次
}
updateData();
3. 使用WeakMap/WeakSet
当我们要存储一些大对象的集合的时候,经常要采用Array
、Map
或Set
,一旦一个对象存入这些集合,当不再需要这些对象的时候这些对象仍然被集合对象引用着,无法被垃圾回收器回收。此时我们需要手动删除它们,或者,直接使用垃圾回收友好的WeakMap
、WeakSet
集合。垃圾回收器总会扫描WeakMap
、WeakSet
集合中键的值,一旦它没有被其他对象引用,就会标记为可回收。
4. 频繁创建或者拼接字符串时使用缓冲区
const buffer = [];
for (const i = 0; i < 1000; i++) {
buffer.push('some value');
}
const finalString = buffer.join("");