深入理解JS内存管理机制

0

一、前言

在相当长一段时间里,JS运行时的内存问题都不被前端开发人员所关注。
一方面,日常开发中基本不会遇上需要对内存精准控制的场景,另一方面,写JS不需要像写 C/C++ 那样在开发过程中随时关注内存的分配和释放问题。

随着生态的逐渐完善,JS的执行环境也不再局限于浏览器中。目前,JS主要的执行场景包括服务端(NodeJSDeno)、桌面端(Electron)、浏览器(ChromeMicrosoft Edge)。

其中,JS执行引擎 V8 因其优异的性能表现,已经成为主流。因此,本文对JS内存管理模型的研究也将基于V8展开。

二、内存结构

1

上图展示了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-spacefrom-space。新加入的对象都会存放到from-space,当from-space被填满时,会触发次要GC。

GC过程

  1. 标记 从堆栈指针开始递归遍历 from-space 中的对象图查找 活跃对象
  2. 复制 将这些对象复制到 to-space 中(包括被这些对象引用的所有对象)。
    重复此操作,直到扫描 from-space 中的所有对象。另外,to-space 会分配连续的内存块,以减少碎片。
  3. 清除 清空 from-space,因为此时剩余的对象都是可回收的。
  4. 交换to-spacefrom-space 互换,即 to-space 变成 from-space。
image

回收的最后一步是更新引用已移动的原始对象的指针。每个被复制的对象都会留下一个新地址,用于更新原始指针以指向新位置。

此时还存在一个问题:随着活跃对象的累积,from-space 很快会被填满。

这时就轮到老生代出场了,在新生代中经历两次GC并存活下来的对象,会被转移到老生代,这个过程被称为晋升。如下图:

image

至此,新生代一次完整的垃圾回收就完成了。

3.2 老生代

在老生代中,垃圾回收为主要GC(Major GC),包含了 标记清除(Mark-Sweep)标记整理(Mark-Compact)

GC过程

  1. 标记 垃圾收集器识别哪些对象正在使用,哪些对象未使用。正在使用或可从GC根域递归访问的对象被标记为活跃对象
  2. 清除 清除未被标记为活跃对象的数据。
  3. 整理 如果碎片较多,会将存活的对象移动到一起,减少碎片提高内存使用率。
2
3.3 三色标记

之前提到,垃圾回收时会先标记 活跃对象 来区分对象是否应该被回收,这里就涉及到了三色标记算法。

标记位有三种颜色:

  • 白色 对象未被标记。
  • 灰色 对象已经被标记,但对象内属性还未遍历完成。
  • 黑色 对象已经被标记,且对象内的属性也已完成遍历(活跃对象)。

标记过程:

  1. 开始标记
    初始所有对象都是白色,当收集器发现白色对象并将其推送到标记工作列表时,会将其标记成灰色。
    01.jpg
  1. 标记完成对象
    当收集器访问目标对象的所有字段后,会将对象的颜色由灰色变为黑色。
    02.jpg
  1. 标记结束
    当没有灰色对象时,代表标记结束。此时剩余的白色对象表示无法访问,可以被回收。
    03.jpg

经历过以上三步,一次完整的标记过程就完成了。

3.4 回收优化

现在我们知道,一次垃圾回收总需要经历 标记回收整理 等阶段。

事实上,整个垃圾回收的过程是非常耗时的,比如光是标记整个堆内存的活跃对象可能就要花费数百毫秒,并且期间会阻塞程序的正常的执行。

[图片上传失败...(image-ad8178-1632313567745)]

对此,V8也在持续优化,目前主要的优化方式有:

  • 增量标记
    通过将标记任务拆分成一系列小任务,确保每次标记任务的持续时间在 5~10 毫秒。
    当堆的占用空间达到某个阈值大小时,开始激活标记任务,此后每分配一定量的内存,就会执行增量标记。
    增量标记与常规标记一样,本质上都是深度优先搜索,同样使用的是三色标记算法。

  • 并行 & 并发

    • 并行 V8会创建辅助线程,与主线程同时处理GC任务。这样GC时间就约等于总时间除以协同的线程数了。
    • 并发 这里的并发是指主线程不再处理GC任务,而是由辅助线程来执行。这样的好处是垃圾回收不再阻塞正常任务的执行。
  • 惰性清除
    当所有对象都被标记完成,此时已经可以进行垃圾清除的工作。但实际上这部分工作可以延迟执行,尤其是当内存足够的时候。
    V8会在合适的时间点执行清除工作,比如工作线程空闲,或者内存不足的时候。

总结

内存管理是一件非常复杂的事情,本文主要从 内存结构内存回收 两个方面进行了介绍,并且隐藏了其中的一些细节。

在V8的内存管理模型中,其实能学习到一些通用的内存管理思想和性能优化方法。
比如,垃圾回收总是会围绕标记清除整理展开。
在标记算法上,V8JAVA,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

你可能感兴趣的:(深入理解JS内存管理机制)