本期内容
前言
内存结构
内存回收
总结
在相当长一段时间里,JS运行时的内存问题都不被前端开发人员所关注。一方面,日常开发中基本不会遇上需要对内存精准控制的场景,另一方面,写JS不需要像写 C/C++ 那样在开发过程中随时关注内存的分配和释放问题。
随着生态的逐渐完善,JS的执行环境也不再局限于浏览器中。目前,JS主要的执行场景包括服务端(NodeJS、Deno)、桌面端(Electron)、浏览器(Chrome、Microsoft Edge)。
其中,JS执行引擎 V8 因其优异的性能表现,已经成为主流。因此,本文对JS内存管理模型的研究也将基于V8展开。
上图展示了V8引擎的内存结构,整体上分成两部分:
这里是存储对象或动态数据的地方,也是占比最大的内存区域。堆内可以细分以下区域:
新生代(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快速定位到对象值所在的内存区。栈是用来存储静态数据的地方,内容主要包括:
基本类型(Number, Boolean, String, Null, Undefined, Symbol, BigInt)
对于基本类型,系统会为新的变量在栈内存中分配一个新值。引用类型
系统会为新的变量在栈内存中分配一个值,这个值是一个对象的引用。调用栈
解释器创建了调用栈来记录函数的调用过程。每调用一个函数,解释器就把该函数添加进调用栈,解释器会为被添加进来的函数创建一个栈帧(用来保存函数的局部变量以及执行语句)并立即执行。如果正在执行的函数还调用了其他函数,新函数会继续被添加进入调用栈。栈内存 其实是由操作系统进行自动管理的,本文不做讨论。
堆内存 由V8进行管理,它占据最大的内存空间,并且随着程序运行时间的增加可能会持续增长,最终耗尽内存。它也会变得碎片化,影响程序运行的速度。这时内存回收的重要性就体现出来了。
要进行内存回收,需要先明确一个问题:什么样的数据可以被回收。
V8通过回收 不可达对象 来释放堆内存,整个回收过程总体可以分为 标记 和 回收 两个阶段,涉及到的原理是 三色标记 和 分代回收 。
从 2.1堆内存 小节中我们知道,新生代被拆分成两个小空间,使用 Minor GC (Scavenger) 进行垃圾回收。Minor GC (Scavenger) 我们可以简称为次要GC。
两个拆分出来的空间分别称之为 to-space 和 from-space。新加入的对象都会存放到from-space,当from-space被填满时,会触发次要GC。
GC过程:
回收的最后一步是更新引用已移动的原始对象的指针。每个被复制的对象都会留下一个新地址,用于更新原始指针以指向新位置。
此时还存在一个问题:随着活跃对象的累积,from-space 很快会被填满。
这时就轮到老生代出场了,在新生代中经历两次GC并存活下来的对象,会被转移到老生代,这个过程被称为晋升。如下图:
至此,新生代一次完整的垃圾回收就完成了。
在老生代中,垃圾回收为主要GC(Major GC),包含了 标记清除(Mark-Sweep) 和 标记整理(Mark-Compact)。
GC过程:
之前提到,垃圾回收时会先标记 活跃对象 来区分对象是否应该被回收,这里就涉及到了三色标记算法。
标记位有三种颜色:
标记过程:
开始标记初始所有对象都是白色,当收集器发现白色对象并将其推送到标记工作列表时,会将其标记成灰色。
标记完成对象当收集器访问目标对象的所有字段后,会将对象的颜色由灰色变为黑色。
标记结束当没有灰色对象时,代表标记结束。此时剩余的白色对象表示无法访问,可以被回收。
经历过以上三步,一次完整的标记过程就完成了。
现在我们知道,一次垃圾回收总需要经历 标记,回收,整理 等阶段。
事实上,整个垃圾回收的过程是非常耗时的,比如光是标记整个堆内存的活跃对象可能就要花费数百毫秒,并且期间会阻塞程序的正常的执行。
对此,V8也在持续优化,目前主要的优化方式有:
增量标记通过将标记任务拆分成一系列小任务,确保每次标记任务的持续时间在 5~10
毫秒。当堆的占用空间达到某个阈值大小时,开始激活标记任务,此后每分配一定量的内存,就会执行增量标记。增量标记与常规标记一样,本质上都是深度优先搜索,同样使用的是三色标记算法。
并行 & 并发
并行
V8会创建辅助线程,与主线程同时处理GC任务。这样GC时间就约等于总时间除以协同的线程数了。并发
这里的并发是指主线程不再处理GC任务,而是由辅助线程来执行。这样的好处是垃圾回收不再阻塞正常任务的执行。惰性清除 当所有对象都被标记完成,此时已经可以进行垃圾清除的工作。但实际上这部分工作可以延迟执行,尤其是当内存足够的时候。V8会在合适的时间点执行清除工作,比如工作线程空闲,或者内存不足的时候。
内存管理是一件非常复杂的事情,本文主要从 内存结构 和 内存回收 两个方面进行了介绍,并且隐藏了其中的一些细节。
在V8的内存管理模型中,其实能学习到一些通用的内存管理思想和性能优化方法。比如,垃圾回收总是会围绕标记,清除,整理展开。在标记算法上,V8
和 JAVA
,Golang
,PHP
等编程语言一样,使用了三色标记。在性能优化上,多进程/多线程, 分步, 异步, 延迟 等方式也总能发挥作用。
参考资料