一、前言
在相当长一段时间里,JS运行时的内存问题都不被前端开发人员所关注。
一方面,日常开发中基本不会遇上需要对内存精准控制的场景,另一方面,写JS不需要像写 C/C++ 那样在开发过程中随时关注内存的分配和释放问题。
随着生态的逐渐完善,JS的执行环境也不再局限于浏览器中。目前,JS主要的执行场景包括服务端(NodeJS、Deno)、桌面端(Electron)、浏览器(Chrome、Microsoft Edge)。
其中,JS执行引擎 V8 因其优异的性能表现,已经成为主流。因此,本文对JS内存管理模型的研究也将基于V8展开。
二、内存结构
上图展示了V8引擎的内存结构,整体上分成两部分:
2.1 堆内存
这里是存储对象或动态数据的地方,也是占比最大的内存区域。堆内可以细分以下区域:
-
新生代(Young generation)
新生代是新对象存在的地方,这些对象中的大多数都是短暂存在的。
这部分空间很小(默认情况下16~32M
),并且拆分成了两个空间。
空间使用 Minor GC (Scavenger) 进行垃圾回收。 -
老生代(Old generation)
新生代中经历了两个Minor GC 周期的对象会被转移到老生代中存放。这里占据着大量的内存空间(默认情况下700~1400M
)
空间使用 Major GC(Mark-Sweep & Mark-Compact) 进行垃圾回收。 -
大对象区(LARGE OBJECT SPACE)
超过一定大小的对象会直接在大对象区中被创建,并在不被使用时将其直接回收。 -
代码区(CODE SPACE)
这是 即时编译器(JIT) 存储编译代码块的地方。 -
其他区(CELL, PROPERTY CELL,MAP SPACE)
这些空间存放大小相同的对象,并且对它们指向的对象类型有一些限制。
比如MAP SPACE
里存放的是hidden class信息,这能让V8快速定位到对象值所在的内存区。
2.2 栈内存
栈是用来存储静态数据的地方,内容主要包括:
-
基本类型(Number, Boolean, String, Null, Undefined, Symbol, BigInt)
对于基本类型,系统会为新的变量在栈内存中分配一个新值。 -
引用类型
系统会为新的变量在栈内存中分配一个值,这个值是一个对象的引用。 -
调用栈
解释器创建了调用栈来记录函数的调用过程。
每调用一个函数,解释器就把该函数添加进调用栈,解释器会为被添加进来的函数创建一个栈帧(用来保存函数的局部变量以及执行语句)并立即执行。
如果正在执行的函数还调用了其他函数,新函数会继续被添加进入调用栈。
三、内存回收
栈内存 其实是由操作系统进行自动管理的,本文不做讨论。
堆内存 由V8进行管理,它占据最大的内存空间,并且随着程序运行时间的增加可能会持续增长,最终耗尽内存。它也会变得碎片化,影响程序运行的速度。这时内存回收的重要性就体现出来了。
要进行内存回收,需要先明确一个问题:什么样的数据可以被回收。
V8通过回收 不可达对象 来释放堆内存,整个回收过程总体可以分为 标记 和 回收 两个阶段,涉及到的原理是 三色标记 和 分代回收 。
3.1 新生代
从 2.1堆内存 小节中我们知道,新生代被拆分成两个小空间,使用 Minor GC (Scavenger) 进行垃圾回收。Minor GC (Scavenger) 我们可以简称为次要GC。
两个拆分出来的空间分别称之为 to-space 和 from-space。新加入的对象都会存放到from-space,当from-space被填满时,会触发次要GC。
GC过程:
- 标记 从堆栈指针开始递归遍历 from-space 中的对象图查找 活跃对象。
- 复制 将这些对象复制到 to-space 中(包括被这些对象引用的所有对象)。
重复此操作,直到扫描 from-space 中的所有对象。另外,to-space 会分配连续的内存块,以减少碎片。 - 清除 清空 from-space,因为此时剩余的对象都是可回收的。
- 交换 将 to-space 和 from-space 互换,即 to-space 变成 from-space。
回收的最后一步是更新引用已移动的原始对象的指针。每个被复制的对象都会留下一个新地址,用于更新原始指针以指向新位置。
此时还存在一个问题:随着活跃对象的累积,from-space 很快会被填满。
这时就轮到老生代出场了,在新生代中经历两次GC并存活下来的对象,会被转移到老生代,这个过程被称为晋升。如下图:
至此,新生代一次完整的垃圾回收就完成了。
3.2 老生代
在老生代中,垃圾回收为主要GC(Major GC),包含了 标记清除(Mark-Sweep) 和 标记整理(Mark-Compact)。
GC过程:
- 标记 垃圾收集器识别哪些对象正在使用,哪些对象未使用。正在使用或可从GC根域递归访问的对象被标记为活跃对象。
- 清除 清除未被标记为活跃对象的数据。
- 整理 如果碎片较多,会将存活的对象移动到一起,减少碎片提高内存使用率。
3.3 三色标记
之前提到,垃圾回收时会先标记 活跃对象 来区分对象是否应该被回收,这里就涉及到了三色标记算法。
标记位有三种颜色:
- 白色 对象未被标记。
- 灰色 对象已经被标记,但对象内属性还未遍历完成。
- 黑色 对象已经被标记,且对象内的属性也已完成遍历(活跃对象)。
标记过程:
- 开始标记
初始所有对象都是白色,当收集器发现白色对象并将其推送到标记工作列表时,会将其标记成灰色。
- 标记完成对象
当收集器访问目标对象的所有字段后,会将对象的颜色由灰色变为黑色。
- 标记结束
当没有灰色对象时,代表标记结束。此时剩余的白色对象表示无法访问,可以被回收。
经历过以上三步,一次完整的标记过程就完成了。
3.4 回收优化
现在我们知道,一次垃圾回收总需要经历 标记,回收,整理 等阶段。
事实上,整个垃圾回收的过程是非常耗时的,比如光是标记整个堆内存的活跃对象可能就要花费数百毫秒,并且期间会阻塞程序的正常的执行。
[图片上传失败...(image-ad8178-1632313567745)]
对此,V8也在持续优化,目前主要的优化方式有:
增量标记
通过将标记任务拆分成一系列小任务,确保每次标记任务的持续时间在5~10
毫秒。
当堆的占用空间达到某个阈值大小时,开始激活标记任务,此后每分配一定量的内存,就会执行增量标记。
增量标记与常规标记一样,本质上都是深度优先搜索,同样使用的是三色标记算法。-
并行 & 并发
-
并行
V8会创建辅助线程,与主线程同时处理GC任务。这样GC时间就约等于总时间除以协同的线程数了。 -
并发
这里的并发是指主线程不再处理GC任务,而是由辅助线程来执行。这样的好处是垃圾回收不再阻塞正常任务的执行。
-
惰性清除
当所有对象都被标记完成,此时已经可以进行垃圾清除的工作。但实际上这部分工作可以延迟执行,尤其是当内存足够的时候。
V8会在合适的时间点执行清除工作,比如工作线程空闲,或者内存不足的时候。
总结
内存管理是一件非常复杂的事情,本文主要从 内存结构 和 内存回收 两个方面进行了介绍,并且隐藏了其中的一些细节。
在V8的内存管理模型中,其实能学习到一些通用的内存管理思想和性能优化方法。
比如,垃圾回收总是会围绕标记,清除,整理展开。
在标记算法上,V8
和 JAVA
,Golang
,PHP
等编程语言一样,使用了三色标记。
在性能优化上,多进程/多线程, 分步, 异步, 延迟 等方式也总能发挥作用。
参考资料
- 《深入浅出nodeJS》
- https://v8.dev/
- https://deepu.tech/memory-management-in-programming
- https://en.wikipedia.org/wiki/Tracing_garbage_collection#Implementation_strategies
- http://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
原文已在玩物得志技术公众号上发布,链接:https://mp.weixin.qq.com/s/7mvP5jv5sBGNfnThap6JSQ