V8引擎:图解JS内存分配和垃圾回收原理

935 阅读10分钟

前言

本文试图通过图解和实验的方式带领大家重新理解一遍JS内存分配和垃圾回收原理,希望能够加深你对这部分知识的理解和记忆。如果觉得本文对你有帮助,欢迎点赞转发收藏。

内存分配:几乎所有数据都在堆内存

我们知道JS有八种数据类型,如下: image.png

其中前七种为基本类型,最后一种为Object。按照通常的说法,基本类型存放在栈内存,Object存放在堆内存,如果你也是这么认为的,那么这部分内容值得你看一看。

首先推荐一篇文章JavaScript Memory Model Demystified,这篇文章把这个问题解释的很好,我在这里简要梳理一下。

我们知道,JS内存空间大致分为三块:代码空间、栈空间、堆空间,原来我们以为栈空间存放了上下文和所有基本类型的数据,堆空间存放了对象类型的数据,其实就当前最新的V8引擎而言,几乎所有的数据都存放在了堆空间。我画了一张图来描述内存的分配情况,接下来我们分别讨论。

image.png

代码空间主要就是存放代码数据,这里暂且不论。栈空间中主要存放的是执行上下文、变量和指针(pointer)。其中,指针分为有效指针无效指针,有效指针指向堆内存的内存地址,表示对一块数据的引用,而无效指针将被解析为number类型,即表示较小的整数(smi,small integer),具体就是-2³¹ ~ 2³¹-1之间的整数。

好了,除了smi之外,剩下的所有数据都存放在堆内存当中。接下来我们做两个实验,通过chrome控制台的内存信息来验证一下。

第一个实验,我们验证number,string,bigInt,symbol的情况,需要大家在html中写一段脚本,然后用chrome执行,在控制台的Memory部分查看堆内存情况。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="btn">btn</button>
    <script>
      const btn = document.querySelector('#btn');
      btn.onclick = () => {
        const number1 = 123; // smi -> stack
        const number2 = 1.23; // heap number -> heap
        const string1 = 'foo'; // string -> heap
        const string2 = 'foo'; // string -> heap(共享)
        const bigInt1 = '1000n'; // string -> heap
        const symbol1 = Symbol('symbol'); // symbol -> heap
        console.log(symbol1);
      };
    </script>
  </body>
</html>

这里我将操作过程录了下来,方便大家自己尝试。

Oct-24-2022 17-37-11.gif

分析一下,我们发现number部分只有一个heap number,指代的是number2 = 1.23,symbol部分指代symbol1 = Symbol('symbol'),而string中的'100n'意味着bigInt类型实际是以string的形式在存储,'foo'部分则指代string1 = 'foo'和string2 = 'foo',只不过对于同一个字符串,V8将直接复用来节约空间。

image.png

number部分的验证大家可以把1.23注释掉再看控制台,就会发现(number)不存在了。

我们继续分析undefined,null,bool三种类型,这三种类型由于值都是固定的,分别是undefined,null,true,false,V8在内存中将这四个值预先进行创建,这一组值称作Oddball,所有的变量本质都是在引用这组值,而不会重新创建。为了验证,我们再写一个脚本放在chrome中执行,脚本内容如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      function Oddballs() {
        this.true = true; // oddballs -> heap
        this.false = false; // oddballs -> heap
        this.undefined = undefined; // oddballs -> heap
        this.null = null; // oddballs -> heap
      }

      const obj1 = new Oddballs();
      const obj2 = new Oddballs();
    </script>
  </body>
</html>

Oct-25-2022 12-06-51.gif

我们可以发现,两个对象中这组值的内存序号都是一样的,由此可以印证上述结论。

image.png

到此,我们可以总结一下:V8引擎将几乎全部数据都放在了堆内存,栈内存只用于上下文、变量和指针的存放。

垃圾回收:垃圾数据如何被回收?

内存中的数据如果使用之后不再需要了,这些数据就成为了垃圾数据。这些数据如果一直保存在内存中,就会越来越多,占用内存空间,所以要及时清理。通常情况下,垃圾回收分为手动回收自动回收两种,比如C/C++使用的就是手动回收策略,完全由代码进行控制,而JS、Java、Python等使用自动回收策略,通过垃圾回收器自动进行回收,不需要代码进行释放。虽然自动回收很方便,但还是有必要了解其运行原理。

栈内存如何回收?

严格来说,垃圾回收主要指的是堆内存,不过栈内存的回收也值得一说。栈内存主要存储执行上下文,核心操作就是上下文的入栈和出栈,因而栈内存的垃圾回收就是看出栈的实现过程,接下来我们通过一个案例图解进行说明。

function bar() {
  var a = 1
  function foo() {
    var b = 2
  }
  function baz() {
    var c = 3
  }
  foo() // 图1、图2
  baz() // 图3、图4
}
bar()

image.png

从图中我们发现,栈内存中存在一个ESP指针指向当前的执行上下文。当执行foo()时,ESP指向foo函数上下文,当foo()执行完毕时,ESP从foo上下文退回到bar上下文,此时栈顶的foo上下文被标记为无效。当baz上下文加载时,直接覆盖了foo上下文,完成入栈的同时也实现了foo上下文的出栈,也就是垃圾回收。最后baz()执行,ESP指针指向baz上下文。所以,记住一个ESP指针就记住了栈内存的垃圾回收原理。

堆内存如何回收?

堆内存的垃圾回收就是垃圾回收器的主场了,在这里我们要记住两对关键词:1. 新生代和老生代 2. 副垃圾回收器和主垃圾回收器

image.png

这么区分主要是因为对象的生存时间不同,新生代表示生存时间较短的对象,老生代表示生存时间较长的对象。考虑到性能和效率,垃圾回收器对这两类对象制定了不同的垃圾回收策略,对于新生代使用副垃圾回收器,对于老生代使用主垃圾回收器。接下来就分别看看这两种垃圾回收器的执行原理。

  1. 关于代际假说:新生代和老生代的区分是建立在这么一个设想,即大部分对象存活时间都很短,可一旦存活下来,就会存活更久。
  2. 其实新生代不仅存活时间短,而且比较小,如果对象占用空间较大,不论存活时间长短都会成为老生代。

新生代和副垃圾回收器

副垃圾回收器主要使用Scavenge算法实现新生代的垃圾回收,该算法的主要原理如下:

image.png

  1. 新生区被分为两个区域,对象区域和空闲区域,对象全部存放在对象区域。
  2. 当对象区域快存满时,将对象区域仍然存活的对象复制到空闲区域排列整齐。
  3. 反转对象区域和空闲区域的角色,空闲区域变成对象区域,对象区域变成空闲区域。
  4. 当新的对象区域满了以后,再来一次,新的空闲区域现存的对象成为无效对象被覆盖。

我们发现,这个算法的核心就在于一次性地对存活对象进行复制整理,既完成了垃圾的清理又整理了内存碎片。不过,这个算法需要复制对象区域所有的存活对象,考虑到性能,这个空间必然不能太大,因而很容易被对象占满。针对这个问题,V8引擎采用了对象晋升策略,即如果新生代经过两次回收还存活,则“晋升”为老生代,迁移至老生区。

如果结合新生代理解这个算法,对于我们就会发现正是因为新生代的存活时间短,占用空间较小,每次复制的数据量不会太大,因此才能使用这种简单粗暴的算法。为了便于记忆,不妨称这个算法为复制翻转算法

老生代和主垃圾回收器

老生代的主要特点是存活时间长、占用空间大,如果使用Scavenge算法效率就比较低。主垃圾回收器使用的算法主要是标记-清除(Mark-Sweep)算法标记-整理(Mark-Compact)算法。这两种算法的标记过程相同,主要区别在于如何清理垃圾数据。

首先是标记过程,这个过程就是遍历当前的调用栈及相关对象属性,如果一个对象被引用则标记为活动对象,没有被标记的就是垃圾数据。

image.png

在上图中,1001被引用则为活动对象,1003未被引用则为垃圾数据。

新生代副垃圾回收器的标记过程也是如此,目的都是为了标记出存活对象。

接下来是垃圾数据清理过程,先看标记-清除(Mark-Sweep)算法,该算法比较简单,就是将所有垃圾对象从内存中删除,但这种方法会产生大量不连续的内存碎片,因而产生了另一种标记-整理(Mark-Compact)算法,该算法将存活对象向一端移动,然后直接清理掉边界以外的内存空间。

image.png

全停顿和增量标记

现在我们已经理解了垃圾回收的基本原理,但存在一个问题,JS是单线程的,垃圾回收会暂停正在执行的JS脚本,这个现象叫全停顿(Stop-The-World)。如果垃圾回收时间过长,将会影响应用的性能和响应时间,这个现象在新生代不明显,因为新生代对象比较小,存活的对象也较少,垃圾回收很快,但老生代的垃圾回收很可能造成卡顿。

为了解决这个问题,V8将老生代的标记过程拆分为多次进行,在JS执行的间隙每次标记一点点,直到标记完成,这种算法称为增量标记(Incremental-Marking)算法

image.png

从内存角度重新理解闭包

我们知道闭包是外部函数中被内部函数引用的变量的集合,内部函数始终保持着对闭包的引用。接下来我们从内存的角度分析一下闭包的生成过程。

function bar() {
  var a = 1;
  var b = 2;
  function foo() {
    console.log(b);
  }
  return foo;
}
bar();

image.png 在案例中,bar函数内部有一个foo函数。当执行bar()时,JS引擎首先创建bar函数上下文,然后开始编译内部代码。在编译过程中,JS引擎发现还有一个内部函数foo,于是对内部函数foo执行一次预扫描,发现foo引用了外部变量b,于是在堆内存中创建一个对象,将b=2放入对象中,形成闭包。最后在返回的foo函数对象里添加对于闭包closure(bar)的引用。

因此,闭包的内存原理本质上就是通过内部函数的预扫描在堆内存中创建了一个对象

什么叫WeakSet、WeakMap不计入垃圾回收?

我们都知道WeakSet和WeakMap中的弱引用,即WeakSet中的成员和WeakMap中的键不计入垃圾回收,比如下面两个案例:

var a = {};
var ws = new WeakSet();
ws.add(a);
console.log(ws); /* WeakSet {{...}} */
a = null;
setTimeout(() => {
console.log(ws); // WeakSet {}
}, 10000);
var a = {};
var wm = new WeakMap();
wm.set(a, 123);
console.log(wm); /* WeakMap {{…} => 123} */
a = null;
setTimeout(() => {
console.log(wm); // WeakMap {}
}, 10000);

那什么叫不计入垃圾回收呢?结合本文对于堆内存垃圾回收算法的讲解不难发现,其实不计入垃圾回收就是这两种对象在标记阶段不纳入遍历范围。

后记

感谢读到这里,我觉得画图对于理解真的很有帮助,不知道你怎么看?如果有任何疑问,欢迎留言。