阅读 501

干货慎入 | JavaScript引擎中的内存管理

概述

像 C 这样的编程语言,具有低级内存管理原语,如malloc()和free()。开发人员使用这些原语显式地对操作系统的内存进行分配和释放。

而JavaScript在创建对象(对象、字符串等)时会为它们分配内存,不再使用对时会“自动”释放内存,这个过程称为垃圾收集。这种看“自动”似释放资源的的特性是造成混乱的根源,因为这给JavaScript(和其他高级语言)开发人员带来一种错觉,以为他们可以不关心内存管理的错误印象,这是想法一个大错误。

即使在使用高级语言时,开发人员也应该了解内存管理(或者至少懂得一些基础知识)。有时候,自动内存管理存在一些问题(例如垃圾收集器中的bug或实现限制等),开发人员必须理解这些问题,以便可以正确地处理它们(或者找到一个适当的解决方案,以最小代价来维护代码)。

内存是什么?

我们现在常用的计算机都属于 冯·诺依曼体系计算机, 计算机硬件由 控制器、运算器、存储器、输入设备、输出设备 五大部分组成。

我们通常所说的内存就是 存储器RAM随机存取存储器)。

常用的内存都是易失性存储器(需要通过不断加电刷新来保持数据,一旦断电就会导致数据丢失),所以需要一种容量大、低成本的非易失性存储器来进行数据的存储,这就是外存,例如磁带、软盘、硬盘、光盘、闪存卡、U盘等。可以将外存理解为输入输出设备,因为外存是需要通过I/O接口进行数据存取的,而内存是由CPU直接寻址的。外存中的程序需要通过I/O接口调入内存中才可以运行。

内存就是程序运行的地方,其实程序本质上就是指令和数据的集合。所以说内存是指令和数据的临时存储器,然后CPU对内存中的指令和数据进行处理。

它是如何工作的

硬件层面上,计算机内存由大量的触发器组成的。每个触发器包含几个晶体管,能够存储一位,单个触发器都可以通过唯一标识符寻址,因此我们可以读取和覆盖它们。因此,从概念上讲,可以把的整个计算机内存看作是一个可以读写的巨大位数组

作为人类,我们并不擅长用比特来思考和计算,所以我们把它们组织成更大的组,这些组一起可以用来表示数字。8位(比特)称为1字节(byte)。除了字节,还有字符(words)(有时是16位,有时是32位)。不同编码里,字符和字节的对应关系不同:

很多东西都存储在内存中:

  1. 程序使用的所有变量和其他数据。
  2. 程序的代码,包括操作系统的代码。

编译器和操作系统一起为你处理大部分内存管理,但是你还是需要了解一下底层的情况,对内在管理概念会有更深入的了解。

在编译代码时,编译器可以检查基本数据类型,并提前计算它们需要多少内存。然后将所需的大小分配给调用堆栈空间中的程序,分配这些变量的空间称为堆栈空间。因为当调用函数时,它们的内存将被添加到现有内存之上,当它们终止时,它们按照后进先出(LIFO)顺序被移除。例如:

int n; // 4字节
int x [4]; // 4个元素组成的数组,每个4个字节
double m;// 8个字节
复制代码

编译器能够立即知道所需的内存:4 + 4×4 + 8 = 28字节。

这段代码展示了整型和双精度浮点型变量所占内存的大小。但是大约20年前,整型变量通常占2个字节,而双精度浮点型变量占4个字节。你的代码不应该依赖于当前基本数据类型的大小。

编译器将插入与操作系统交互的代码,并申请存储变量所需的堆栈字节数。

在上面的例子中,编译器知道每个变量的确切内存地址。事实上,每当我们写入变量 n 时,它就会在内部被转换成类似“内存地址4127963”这样的信息。

注意,如果我们尝试访问 x[4],将访问与m关联的数据。这是因为访问数组中一个不存在的元素(它比数组中最后一个实际分配的元素x[3]多4字节),可能最终读取(或覆盖)一些 m 位。这肯定会对程序的其余部分产生不可预知的结果。

image.png

当函数调用其他函数时,每个函数在调用堆栈时获得自己的块。它保存所有的局部变量,但也会有一个程序计数器来记住它在执行过程中的位置。当函数完成时,它的内存块将再次用于其他地方。

内存的生命周期

无论使用哪种编程语言,内存的生命周期都是一样的:

image.png 这里简单介绍一下内存生命周期中的每一个阶段:

  • 分配内存 —  内存是由操作系统分配的,它允许您的程序使用它。在低级语言(例如C语言)中,这是一个开发人员需要自己处理的显式执行的操作。然而,在高级语言中,系统会自动为你分配内在。
  • 使用内存 — 这是程序实际使用之前分配的内存,在代码中使用分配的变量时,就会发生读和写操作。
  • 释放内存 — 释放所有不再使用的内存,使之成为自由内存,并可以被重利用。与分配内存操作一样,这一操作在低级语言中也是需要显式地执行。

在JavaScript中,第一步和第三步由js引擎完成的。

JavaScript引擎的内存模型

以V8引擎为例。

一个运行中的程序总是与内存中的一部分空间相对应。这部分空间叫做 Resident Set (驻留集)。说到这个就有个Resident set size的概念引出,此处不详细解释,大家可以去了解下内存耗用VSS/RSS/PSS/USS这些概念。

image.png

各部分作用如下:

  • Code Segment : 存放正在被执行的代码
  • Stack : 栈内存,存放标识符、基本类型值及引用类型变量的堆地址
  • Heap : 堆内存,存放引用类型值

在多线程情况下,每个线程将拥有其自己的完全独立的stack,但它们将共享heap。stack是特定于线程的,而heap是特定于应用程序的。在异常处理和线程执行中,stack是重要的考虑因素。

JavaScript是一种单线程编程语言,这意味着它只有一个调用栈。

在 Node.js 中,我们可以通过调用process.memoryUsage() 方法来来查询内存使用情况。该函数返回值如下: memory usage { rss: 4935680, heapTotal: 1826816, heapUsed: 650472, external: 49879 } 以上数值以字节为单位 • rss:表示 Resident Set 的大小 • heapTotal:表示堆的总大小 • heapUsed:表示堆的实际使用大小 • external:表示 V8 管理的绑定到 JavaScript 对象的 C++ 对象的大小

堆栈

什么是堆和栈?

堆和栈本质上是两种数据结构。

栈(数据结构):一种先进后出的数据结构。

堆(数据结构):堆可以被看成是一棵树,如:堆排序。

栈(stack)

用于静态内存分配

栈是内存中一块用于存储局部变量和函数参数的线性结构,遵循着先进后出的原则。数据只能顺序的入栈,顺序的出栈。当然,栈只是内存中一片连续区域一种形式化的描述,数据入栈和出栈的操作仅仅是栈指针在内存地址上的上下移动而已。如下图所示(以 C 语言为例):

image.png

栈中的变量在函数调用结束后,就会消失。

栈是由操作系统自动管理的,而不是由V8本身进行管理的

堆(heap)

堆用于动态内存分配,与堆栈不同,程序需要使用指针在堆中查找数据(将其视为大型的多层库)。

堆是计算机内存中不会自动为您管理的区域,并且不受CPU严格管理。它是内存中更自由浮动的区域(并且更大)。要在堆上分配内存,必须使用malloc()calloc(),它们是内置的C函数。在堆上free()分配内存后,一旦不再需要该内存,您将负责重新分配该内存。如果您未能执行此操作,则程序将发生所谓的内存泄漏。也就是说,堆上的内存仍将被保留(并且其他进程将无法使用)。

与堆栈不同,堆对可变大小没有大小限制(除了计算机明显的物理限制之外)。堆内存的读取和写入速度稍慢,因为必须使用指针来访问堆上的内存。

与堆栈不同,可以在程序中的任何位置通过任何函数访问在堆上创建的变量。堆变量的作用域本质上是全局的。

Stack vs Heap

因此栈的特点:

  • 快速访问(后进先出LIFO

  • 存储在栈中的任何数据都必须是有限且静态的(数据大小在编译时是已知的)

  • 多线程应用程序每个线程可以有一个栈。

  • 栈的内存管理非常**简单明了,**并且由操作系统完成,内存不会碎片化

  • 存储的数据是局部变量:基本类型值,引用类型变量的堆地址,指针,函数帧(帧包含提供给函数的参数,函数的局部变量以及函数执行的地址)

    --每一个被调用的函数都有一个自己的栈帧结构,并且栈帧结构是由函数自己形成的。栈帧的最顶端以两个指针界定——帧指针(寄存器ebp)和栈指针(esp)---具体了解可以参看《深入理解计算机系统》)

  • 栈大小限制(取决于OS及架构:i386,x86_64 , PowerPC。多数架构上默认栈大小都在 2 ~ 4 MB 左右)

  • 变量无法调整大小

因此堆的特点:

  • (相对栈)访问速度较慢

  • 存储具有动态大小的数据

  • 堆在应用程序的线程之间共享

  • 您必须管理内存(您负责分配和释放变量)

  • 存储在堆中的典型数据是全局变量引用类型

  • 内存大小无限制(一般来讲在32位系统下,堆内存可以达到4G的空间从这个角度来看堆内存几乎是没有什么限制的)

  • 无法保证有效利用空间,随着时间的推移,内存块可能会随着内存块的分配而碎片化,然后释放

    JavaScript中使用的堆栈,对象存储在Heap中,并在需要时引用

7KpvEn1.gif

动态分配(基于堆的内存分配)

计算机科学中, 动态内存分配(Dynamic memory allocation)又称为堆内存分配,是指计算机程序运行期中分配使用内存。它可以当成是一种分配有限内存资源所有权的方法。

动态分配的内存在被程序员明确释放或被垃圾回收之前一直有效。与静态内存分配的区别在于没有一个固定的生存期。这样被分配的对象称之为有一个“动态生存期”

不幸的是,当编译时不知道一个变量需要多少内存时,事情就有点复杂了。假设我们想做如下的操作:

int n = readInput(); //读取用户的输入
...
//用“ n”个元素创建一个数组
复制代码

在编译时,编译器不知道数组需要使用多少内存,因为这是由用户提供的值决定的。

因此,它不能为堆栈上的变量分配空间。相反,我们的程序需要在运行时显式地向操作系统请求适当的空间,这个内存是从堆空间分配的。静态内存分配和动态内存分配的区别总结如下表所示:

静态内存分配动态内存分配
大小必须在编译时知道大小不需要在编译时知道
在编译时执行在运行时执行
分配给堆栈分配给堆
FILO (先进后出)没有特定的分配顺序

动态分配内存的原因和优势:

  1. 当我们不知道该程序需要多少内存时。
  2. 当我们想要没有任何存储空间上限的数据结构时。
  3. 当您想更有效地使用您的内存空间时。*示例:*如果您为一维数组分配了存储空间作为array [20],而最终只使用了10个存储空间,则剩余的10个存储空间将被浪费,而其他程序变量甚至无法利用此浪费的内存。
  4. 仅通过操纵地址就可以非常轻松地完成动态创建的列表插入和删除操作,而在静态分配的内存中,插入和删除操作会导致更多的移动和内存浪费。
  5. 如果要在编程中使用结构和链表的概念,则必须分配动态内存。

要完全理解动态内存分配是如何工作的,需要在c语言以及指针上花费更多的时间,这可能与本文的主题有太多的偏离,这里就不太详细介绍指针的相关的知识了。

en.wikipedia.org/wiki/C_dyna…

zh.wikipedia.org/wiki/%E5%8A…

moduscreate.com/blog/dynami…

在JavaScript中分配内存

现在将解释第一步:如何在JavaScript中分配内存。参考developer.mozilla.org/en-US/docs/…

与C/C++不同,JavaScript中并没有严格意义上区分栈内存与堆内存。因此我们可以简单粗暴的理解为JavaScript的所有数据都保存在堆内存中。但是在某些场景,我们仍然需要基于堆栈数据结构的思维来实现一些功能,比如JavaScript的执行上下文。执行上下文的执行顺序借用了栈数据结构的存取方式。因此理解栈数据结构的原理与特点十分重要。

JS数据类型和内存的关系

ECMAScript中 变量可能包含两种不同数据类型的值:基本类型值和引用类型值。

对于基本类型,数据本身是存在栈内,对于引用类型,在栈中存的只是一个堆内地址的引用。

为了更好的搞懂栈内存与堆内存,我们可以结合以下例子与图解进行理解。

var a1 = 0;   // 变量对象
var a2 = 'this is string'; // 变量对象
var a3 = null; // 变量对象

var b = { m: 20 }; // 变量b存在于变量对象中,{m: 20} 作为对象存在于堆内存中
var c = [1, 2, 3]; // 变量c存在于变量对象中,[1, 2, 3] 作为对象存在于堆内存中
复制代码

image.png

值的初始化

为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。

var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存

var o = {
  a: 1,
  b: null
}; // 给对象及其包含的值分配内存

// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"]; 

function f(a){
  return a + 2;
} // 给函数(可调用的对象)分配内存

// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);
复制代码

通过函数调用分配内存

某些函数调用也会导致对象的内存分配:

var d = new Date(); // 分配一个 Date 对象

var e = document.createElement('div'); // 分配一个 DOM 元素
复制代码

有些方法分配新变量或者新对象:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新数组有四个元素,是 a 连接 a2 的结果
复制代码

在JavaScript中使用内存

基本上,在JavaScript中使用分配的内存意味着对其进行读写。这可以通过读取或写入变量或对象属性的值,或者将参数传递给函数来实现。

当内存不再需要时进行释放

大多数内存管理的问题都在这个阶段。

这里最困难的地方是确定何时不再需要分配的内存,它通常要求开发人员确定程序中哪些地方不再需要内存的并释放它。

高级语言嵌入了一种称为垃圾收集器的机制,它的工作是跟踪内存分配和使用,以便发现任何时候一块不再需要已分配的内在。在这种情况下,它将自动释放这块内存。

不幸的是,这个过程只是进行粗略估计,因为知道是否需要一些一块内存的一般问题是不可判定 (不能通过算法来解决)。

大多数垃圾收集器通过收集不再被访问的内存来工作,例如,指向它的所有变量都超出了作用域。但是,这是可以收集的内存空间集合的一个不足估计值,因为在内存位置的任何一点上,仍然可能有一个变量在作用域中指向它,但是它将永远不会被再次访问。

垃圾回收

由于无法确定某些内存是否真的有用,因此,垃圾收集器想了一个办法来解决这个问题。

堆内存是**垃圾回收(GC)**发生的地方

什么是垃圾回收

我们首先先迅速而简单地介绍一下,到底什么是垃圾回收?其实顾名思义,主要是两点:垃圾、回收。

然后基于这两点有个 What/How/When,基本就把事儿讲明白了。翻译成官方术语就是:

  • 什么是垃圾?如何找到垃圾?何时找垃圾?
  • 什么是回收?怎么回收?何时回收?

什么是垃圾? 垃圾其实都是指已经没用的内存区域

如何找到垃圾? 主流的两类垃圾回收算法有两种,分别是追踪式垃圾回收算法和引用计数法

何时找垃圾? 垃圾收集器会定期(周期性)找出那些不在继续使用的变量

什么是回收? 回收就是指让这些区域可以被新的有用数据覆盖

怎么回收? 基本就是清扫(Sweep) 和整理(Compact) 这两种策略

何时回收? 找完了就清理,惰性清理(增量标记完成后)

对于所有的垃圾回收而言,垃圾其实都是指已经没用的内存区域,回收就是指让这些区域可以被新的有用数据覆盖。

内存引用

垃圾收集算法主要依赖的是引用。

在内存管理上下文中,如果对象具有对另一个对象的访问权(可以是隐式的,也可以是显式的),则称对象引用另一个对象。例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

在这种情况下,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

词法作用域定义了如何在嵌套函数中解析变量名:即使父函数已经返回,内部函数也包含父函数的作用

词法作用域 VS 动态作用域 介绍

www.jianshu.com/p/70b38c7ab…

www.cnblogs.com/lienhua34/a…

垃圾回收算算法

主流的两类垃圾回收算法有两种,分别是追踪式垃圾回收算法和引用计数法( Reference counting )。

引用计数 (Reference-counting) 垃圾收集算法

这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。如下代码:

var o = { 
  a: {
    b:2
  }
}; 
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集


var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1;      // 现在,“这个对象”只有一个o2变量的引用了,“这个对象”的原始引用o已经没有

var oa = o2.a; // 引用“这个对象”的a属性
               // 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 虽然最初的对象现在已经是零引用了,可以被垃圾回收了
           // 但是它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
           // 它可以被垃圾回收了
复制代码

image.png

循环会产生问题

当涉及到循环时,会有一个限制。在下面的示例中,创建了两个对象,两个对象互相引用,从而创建了一个循环。在函数调用之后将超出作用域,因此它们实际上是无用的,应该回收分配的内存。然而,引用计数算法认为,由于每个对象至少被引用一次,导致它们都没有被标记为垃圾回收掉。循环引用是内存泄漏的常见原因。

function f(){
  var o1 = {};
  var o2 = {};
  o1.a = o2; // o1 引用 o2
  o2.a = o1; // o2 引用 o1  形成了一个循环

  return "azerty";
}

f();
复制代码

image.png

标记-清除(Mark-and-sweep)算法

该算法把“一个对象不再需要”简化定义为“对象不可访问”。

该算法由以下步骤组成:

  1. 垃圾收集器构建一个“根”列表,用于保存引用的全局变量。在JavaScript中,“window”对象是一个可作为根节点的全局变量。在Node.js中为“global”对象。
  2. 然后,算法检查所有根及其子节点,并将它们标记为活动的(这意味着它们不是垃圾)。任何根不能到达的地方都将被标记为垃圾。
  3. 最后,垃圾收集器释放所有未标记为活动的内存块,并将该内存返回给操作系统。

image.png

​ 运行中标记和清除算法的可视化动图

img

该算法是对先前算法的一种改进,因为“有零引用的对象”总是不可访问的,但是相反却不一定,正如我们在循环中看到的那样。

缺点:这种方法有几个缺点,最明显的是在收集过程中必须暂停整个系统。不允许更改工作集。这可能会导致程序定期(通常是不可预测的)“冻结”,从而使某些实时和时间紧迫的应用程序变得不可能。此外,必须检查整个工作内存,其中大部分都要检查两次,从而可能导致分页内存系统出现问题。

(在计算机 操作系统中页面调度是一种内存管理方案,计算机通过该方案来存储和从二级存储中检索数据,以供在主存储器中使用。在此方案中,操作系统从二级存储中以相同大小的块(称为*page)*检索数据。分页是现代操作系统中虚拟内存实现的重要组成部分,它使用二级存储来使程序超出可用物理内存的大小。

为简单起见,主存储器称为“ RAM”(“ 随机存取存储器 ” 的缩写),二级存储称为“磁盘”(“ 硬盘驱动器磁鼓存储器固态驱动器 ” 的简写),但是概念并不取决于这些术语是否从字面上适用于特定的计算机系统。)

截至2012年,所有现代浏览器都有标记-清除垃圾收集器。并且过去几年在JavaScript垃圾收集(分代/增量/并发/并行垃圾收集)领域所做的所有改进都是对标记-清除算法的改进,而不是对垃圾收集算法本身的改进,也没有简化“一个对象不再需要”的定义。

你以为到这就完了么?NO!以上都是比较简单的是符合逻辑容易理解的。

如何解决系统相当长的时间内停止的问题?

mark 阶段会导致应用程序挂起也就是:『全暂停式』(Stop-The-World,后面简称 STW)的,当突变(专业术语称 mutator,指能改变这个内存区域是否被程序引用的东西,比如程序本身。简单说就是可以修改 heap)执行,GC 等着;特定时机时(比如内存满了)GC 执行,mutator 等着。因此它的如何找和何时找都比较简单:内存满,STW 开始;而找垃圾就是一种图的遍历,从 Root 出发,对所有能访问的节点进行标记,访问不到的就是垃圾。

img

采取 STW 这样凶残的策略,主要还是防止 mutator 在 GC 的时候捣乱——这跟你用扫地机器人的时候把狗关屋子的道理是一样的;而增量标记,就等于赶着狗扫地,是一个跟 mutator 斗智斗勇的过程。

如果我们要解决系统长时间停止的问题需要引入增量式 GC 的概念。

增量式(incremental)GC 顾名思义,允许 collector 分多个小批次执行,每次造成的 mutator 停顿都很小,达到近似实时的效果。

img

img

​ STW vs 增量式 图1

引用计数类 GC 本身就具有增量式特性,但由于其算法自身的缺陷与效率问题,一般不会采用。而追踪类 GC 实现增量式的难点在于:在 collector 遍历引用关系图,mutator 可能会改变对象间的引用关系

为解决这个问题实现增量式 GC我们需要引入一种新的算法三色标记(Tri-color marking)算法

三色标记(Tri-color marking)算法

V8 官博在2018年新发了一篇博文介绍 V8 GC 新进展,讲到增量回收的话题。增量 GC 实际上早在 1975 年的一篇论文中,大宗师 Dijkstra 就已经提出了这个问题的解决方案——三色标记算法。(mutator这个词儿也是 Dijkstra 琢磨出来的)

因为增量回收是并发的(concurrent),因此它的过程像上面图1一样(可以想象一下 CPU 的时间片轮转),这就意味着 GC 可能被随时暂停、重启,因此暂停时需要保存当时的扫描结果,等下一波 GC 来之后还能继续启动。而双色标记实际上仅仅是对扫描结果的描述:非黑即白,但忽略了对扫描进行状态的描述:这个点的子节点扫完了没有?假如我上次停在这样一个图上,重新启动的时候我就不仅要问:到底 A、B 点要不要扫子节点?

img

为了处理这种情况,Dijkstra 引入了另外一种颜色:灰色,它表示这个节点被 Root 引用到,但子节点我还没处理;而黑色的意思就变为:这个节点被 Root 引用到,而且子节点都已经标记完成。这样在恢复扫码时,只需要处理灰色节点即可。

img

引入灰色标记还有一个好处,就是当图中没有灰色节点时,便是整个图标记完成之时,就可以进行清理工作了。

对象只能从白色移动到灰色,从灰色移动到黑色,因此该算法保留了一个重要的不变性-没有黑色对象引用白色对象。这确保了一旦灰色组为空,则可以释放白色对象。这称为三色不变式

违反三色不变式的情况

解决了三色的问题,就可以增量回收了么?其实没有这么简单。什么是失败的垃圾回收?无非就是两点:

  1. 把有用的东西扔了;
  2. 把没用的东西留着;

其实只要有手段,没用的垃圾还是可以忍它留几轮;但是有用的被干掉是无法忍受的:我刚声明了一个变量你就告诉我 Reference Error,WTF!

对于传统的 STW 而言,通过根节点标记引用,能力立刻区分当前状态下的有用和没用,再做操作的时候便游刃有余;但是对于增量回收而言就不同了,Dijkstra 在论文里举了一个很顽皮的 mutator:

  1. 三个节点 ABC,C 在 AB 之间反复横跳,一会儿只有 A 指向 C,一会儿只有 B 指向 C;
  2. 开始扫 A 时,C 的爸爸是 B,扫完了 A 节点是黑的, C 是白的;
  3. 开始扫 B 时,C 的爸爸是 A,扫完了 B 没有子节点,B 节点是黑的,C 还是白的;
  4. 由于 A 节点已经标黑,无法扫描其子节点,只好继续向后扫描;
  5. 一顿蛇皮操作之后,C 被当成孤儿干掉了,C 的爸爸们留下了无奈的泪水。

标记遗漏

如何解决违反三色不变性的问题

为了解决上面的问题,一般有两类方式来协调 mutator 与 collector 的行为:

  1. 读屏障(read barrier),它会禁止 mutator 访问白色对象,当检测到 mutator 即将要访问白色对象时,collector 会立刻访问该对象并将之标为灰色。由于 mutator 不能访问指向白色对象的指针,也就无法使黑色对象指向它们了
  2. 写屏障(write barrier),它会记录下 mutator 新增的由黑色–>白色对象的指针,并把该对象标为灰色,这样 collector 就又能访问有问题的对象了

读/写屏障本质是一些同步操作——在 mutator 进行某些操作前,它必须激活 collector 进行一些操作。

写屏障

刚才的案例其实就是说了一个问题:在 mutator 瞎操作的情况下,很有可能将已经标记为扫描完事儿的节点(黑色节点)续上一个当时还未被扫描的白色节点。而一旦这个白色节点后续又被其他已经扫描过的节点引用到,也没有什么机制能够再收集它了。

在思考完这个案例之后,Dijkstra 提出了一个要求:不能让黑色节点指向白色节点!每当发生引用变化时,需要立刻对被引用节点进行着色:即白的立刻染灰,灰的和黑的不变。

比如上面 C 的例子,当 C 的父节点发送变化时,一定会出现类似这样的代码:A.C = C,发生之后,立刻给 C 节点着色并推入灰色栈,这样就解决了不小心清理掉有用节点的问题。

写屏障

读屏障

不介绍了 参看下面文章

liujiacai.net/blog/2018/0…

《Barrier Methods for Garbage Collection》 Benjamin Zorn 1990

总结

总而言之,三色标记主要是为了解决增量标记中传统双色标记过程无法分片的问题,有了三色标记,传统的双色标记便可以暂停重启,因此就可以划分成小段,变成跟 mutator 并发的方式来运行;写屏障则是用来解决并发中 mutator 变化,导致有用内存被清理的问题。三色标记只是垃圾回收众多技术方案之中的一个,其他如分代假设、清道夫算法等都有其精妙之处,可以深入研究。

GC 衍化图

​ GC 衍化图

在这篇文章中,你可以更详细地阅读到有关跟踪垃圾收集的详细信息。

也可阅读《垃圾回收的算法与实现》这本书。

资料:www.cs.cmu.edu/~fp/courses…

liujiacai.net/blog/2018/0…

liujiacai.net/blog/2018/0…

liujiacai.net/blog/2018/0…

目前没有一种垃圾自动回收算法适用于所有场景。v8的内部采用的其实是多种垃圾回收算法。他们回收的对象分别是生存周期较短和生存周期较长的两种对象。 上文说的是基本垃圾回收的方法和原理。

循环不再是问题

在上面循环会产生问题的第例子中,在函数调用返回后,这两个对象不再被从全局对象中可访问的对象引用。因此,垃圾收集器将发现它们不可访问。

image.png

即使在对象之间存在引用,也已经无法从根目录访问它们。

反直觉行为的垃圾回收器

尽管垃圾收集器很方便,但它们有一套自己的折衷方案,其中之一就是非决定论,换句话说,GC是不可预测的,你无法真正判断何时进行垃圾收集。这意味着在某些情况下,程序会使用更多的内存,这实际上是必需的。在对速度特别敏感的应用程序中,可能会很明显的感受到短时间的停顿。如果没有分配内存,则大多数GC将处于空闲状态。看看以下场景:

  1. 执行大量分配。
  2. 这些元素中的大多数(或全部)被标记为不可访问(假设引用指向一个不再需要的缓存)(假设我们空引用指向我们不再需要的缓存)(假设我们空一个指向我们不再需要的缓存的引用)。
  3. 不再执行任何分配。

在这些场景中,大多数GCs 将不再继续收集。换句话说,即使有不可访问的引用可供收集,收集器也不会声明这些引用。这些并不是严格意义上的泄漏,但仍然会导致比通常更高的内存使用。

想了解V8垃圾回收器相关: v8.dev/blog/trash-…

Node.js

Node.js提供了用于配置和调试内存问题的其他选项和工具,这些问题和工具可能不适用于在浏览器环境中执行的JavaScript。

V8引擎标志

可用标志可以增加可用堆内存的最大数量:

node --*max-old-space-size=6000* index.js
复制代码

我们还可以使用标志和Chrome Debugger公开垃圾收集器以调试内存问题:

node --expose-gc --inspect index.js
复制代码
文章分类
前端
文章标签