放大镜下的V8

流水不争先,争的是滔滔不绝

相信大部分同学肯定在面试的时候被问到过这个问题吧: "说一下V8垃圾回收机制吧"

不出意外,你肯定会回答一些比如引用计数标记清除标记整理新老生代对象晋升内存泄漏这些常见术语。说实话我也是这么说的,也没啥大问题,说清楚就行了。基于一些原因,我得写一篇文章来表述一下我理解的V8垃圾回收机制,各位看个热闹。

引言

作为目前最流行的JavaScript引擎,V8引擎从出现的那一刻起便广泛受到人们的关注,我们知道,JavaScript可以高效地运行在浏览器和Nodejs这两大宿主环境中,也是因为背后有强大的V8引擎在为其保驾护航,甚至成就了Chrome在浏览器中的霸主地位。不得不说,V8引擎为了追求极致的性能和更好的用户体验,为我们做了太多太多,从原始的Full-codegen(早期编译器,已移除)和Crankshaft(一代)编译器升级为Ignition解释器和TurboFan编译器的强强组合,到隐藏类,内联缓存和HotSpot热点代码收集等一系列强有力的优化策略,V8引擎正在努力降低整体的内存占用和提升到更高的运行性能。

本篇主要是从V8引擎的垃圾回收机制入手,讲解一下在JavaScript代码执行的整个生命周期中V8引擎是采取怎样的垃圾回收策略来减少内存占比的,当然这部分的知识并不太影响我们写代码的流程,毕竟在一般情况下我们很少会遇到浏览器端出现内存溢出而导致程序崩溃的情况,但是至少我们对这方面有一定的了解之后,能增强我们在写代码过程中对减少内存占用,避免内存泄漏的主观意识,也许能够帮助你写出更加健壮和对V8引擎更加友好的代码。

为什么要垃圾回收

好问,就跟为什么你家里要扫地清理垃圾一样。 说正经的,主要是因为在Chrome中,V8被限制了内存的使用(64位约1.4G/1464MB , 32位约0.7G/732MB),为什么要限制呢?

  • 表层原因:V8最初为浏览器而设计,不太可能遇到用大量内存的场景
  • 深层原因:V8的垃圾回收机制的限制(如果清理大量的内存垃圾是很耗时间,这样会引起JavaScript线程暂停执行的时间,那么性能和应用直线下降)

因为栈内的内存,操作系统会自动进行内存分配和内存释放,而堆中的内存,由JS引擎(如Chrome的V8)手动进行释放,当我们的代码没有按照正确的写法时,会使得JS引擎的垃圾回收机制无法正确的对内存进行释放(内存泄露),从而使得浏览器占用的内存不断增加,进而导致JavaScript和应用、操作系统性能下降。

关于V8

V8 主要涉及三个技术:编译流水线、事件循环系统、垃圾回收机制

1.V8 执行 JavaScript 完整流程称为:编译流水线

编译流水线涉及到的技术有:

  • JIT

    • V8 混合编译执行和解释执行
  • 惰性解析

    • 加快代码启动速度
  • 隐藏类(Hide Class)

    • 将动态类型转为静态类型,消除动态类型执行速度慢的问题
  • 内联缓存

  1. 事件循环系统

    • JavaScript 中的难点:异步编程
    • 调度排队任务,让 JavaScript 有序的执行
  2. 垃圾回收机制

    • 内存分配
    • 内存回收

函数即对象

函数除了可以拥有常用类型的属性值之外,V8 内部为函数对象添加了两个隐藏属性:namecode,name 属性的值就是函数名称,如果某个函数没有设置函数名,该函数对象的默认的 name ,属性值就是 anonymous(表示匿名函数)

  • name 为函数名

    • 如果是匿名函数,nameanonymous
  • code 为函数代码,以字符串的形式存储在内存中

当执行到一个函数调用语句时,V8 从函数对象中取出 code 属性值,然后解释执行这段函数代码

函数是一等公民

因为函数是一种特殊的对象,所以在 JavaScript 中,函数可以赋值给一个变量,也可以作为函数的参数,还可以作为函数的返回值。如果某个编程语言的函数,可以和这个语言的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民

对象的属性

常规属性和索引属性

  • 索引属性(elements):数字属性按照索引值的大小升序排列
  • 常规属性(properties):字符串根据创建时的顺序升序排列

它们都是线性数据结构,分别为 elements 对象和 properties 对象

执行一次查询:先从 elements 对象中按照顺序读出所有元素,然后再从 properties 对象中读取所有的元素

快属性和慢属性

将不同的属性分别保存到 elements 属性和 properties 属性中,无疑简化了程序的复杂度,但是在查找元素时,却多了一步操作,基于这个原因,V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties)。

比如在访问一个属性时,比如:foo.aV8 先查找出 properties,然后再从 properties 中查找出 a 属性。 V8 为了简化这一步操作,把部分 properties 存储到对象本身,默认是10个,这个就被称为对象内属性

通常,我们将保存在线性数据结构中的属性称之为"快属性",因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果从线性结构中添加或者删除大量的属性时,则执行效率会非常低,这主要因为会产生大量时间和内存开销。不过对象内属性的数量是固定的,默认是 10 个,

如果一个对象的属性过多时,执行效率是非常低的,V8 就会采取另外一种存储策略,那就是"慢属性 "策略,但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。

内存查看:

ini 复制代码
function Foo(property_num, element_num) {
  //添加可索引属性
  for (let i = 0; i < element_num; i++) {
    this[i] = `element${i}`;
  }
  //添加常规属性
  for (let i = 0; i < property_num; i++) {
    let ppt = `property${i}`;
    this[ppt] = ppt;
  }
}
var bar = new Foo(10, 10);

Chrome 开发者工具切换到 Memory 标签,然后点击左侧的小圆圈就可以捕获当前的内存快照,查看创建不同数量属性的内存存储区别。

V8 是怎么实现数组的

数组是什么? 数组的两个关键字是相同类型连续内存 !

所以 Js 的数组根本不算一个真数组,那么他是怎么实现的呢?

没错就是 对象,大致意思就是用快慢数组进行的实现,V8使用不同的策略来实现数组,以平衡性能和内存利用的需求。

  1. 快数组(Packed Array):

    • 快数组是V8中数组的默认实现方式,用于存储连续的、密集的元素。
    • 当数组中的元素都是连续的、没有空洞(holes),并且类型相同时,V8会使用快数组。
    • 快数组是基于连续的内存块实现的,它允许通过索引来快速访问元素,因为索引可以直接映射到内存中的特定位置。
    • 快数组的存储方式针对不同的元素类型进行了优化,例如整数数组(Smi Array)、双精度浮点数数组等。
  2. 慢数组(Holey Array):

    • 当数组中包含空洞时(即存在未定义或被删除的元素),V8会使用慢数组。
    • 慢数组是基于稀疏存储的方式实现的,它通过跳过空洞来存储非空洞的元素,以节省内存空间。
    • 慢数组虽然在内存利用上更高效,但在访问速度上相对较慢,因为访问元素时需要额外的逻辑来检查和跳过空洞。

V8会根据数组的使用情况自动选择适当的数组实现方式,以获得最佳的性能和内存利用率。在数组的操作中,如果出现了空洞,例如使用delete操作移除元素或使用稀疏数组的语法,V8会将快数组转换为慢数组。反之,当数组不再包含空洞时,V8会将慢数组转换回快数组,以提高访问速度和内存利用率。

总的来说,V8通过快数组和慢数组的组合实现了数组的高效存储和访问。对于连续的、稠密的元素,使用快数组能够获得更好的性能;对于非连续、稀疏的元素,使用慢数组可以节省内存空间。根据数组的使用方式和数据特征,V8会动态地选择最适合的数组实现方式。

差不多就这意思,具体的可以看看这个文章: 从 V8 引擎来看 JS 中这个"假数组"(opens new window)

原型链:V8 是如何实现对象继承的?

  • 作用域链是沿着函数的作用域一级一级来查找变量的
  • 原型链是沿着对象的原型一级一级来查找属性的

js 中实现继承,是将 __proto__ 指向对象,但是不推荐使用,主要原因是:

  • 这是隐藏属性,不是标准定义的
  • 使用该属性会造成严重的性能问题

继承

  1. 用构造函数实现继承:

    ini 复制代码
    function DogFactory(color) {
      this.color = color;
    }
    DogFactory.prototype.type = "dog";
    const dog = new DogFactory("Black");
    dog.hasOwnProperty("type"); // false
  2. ES6 之后可以通过 Object.create 实现继承

    ini 复制代码
    const animalType = { type: "dog" };
    const dog = Object.create(animalType);
    dog.hasOwnProperty("type"); // false

new 背后做了这些事情

  1. 帮你在内部创建一个临时对象
  2. 将临时对象的 __proto__ 设置为构造函数的原型,构造函数的原型统一叫做 prototype
  3. return 临时对象
javascript 复制代码
function NEW(fn) {
  return function () {
    var o = { __proto__: fn.prototype };
    fn.apply(o, arguments);
    return o;
  };
}

__proto__prototypeconstructor 区别

prototype 是函数的独有的;__proto__constructor 是对象独有的

由于函数也是对象,所以函数也有 __proto__constructor

constructor 是函数;prototype__proto__ 是对象

javascript 复制代码
typeof Object.__proto__; // "object"
typeof Object.prototype; // "object"
typeof Object.constructor; // "function"
ini 复制代码
let obj = new Object();
obj.__proto__ === Object.prototype;
obj.constructor === Object;

objObject 的实例,所以 obj.constructor === Object

obj 的是对象,Object 是函数,所以 obj.__proto__ === Object.prototype

作用域链:V8 是如何查找变量的?

全局作用域是在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁的,直至 V8 退出

而函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之被销毁掉了

因为 JavaScript 是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。

词法作用域是静态作用域,根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好了

动态作用域链是基于调用栈的,不是基于函数定义的位置的,可以认为 this 是用来弥补 JavaScript 没有动态作用域特性的

闭包

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包

如何回收

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

V8 是怎么执行加法操作的?

V8 会提供了一个 ToPrimitve 方法,其作用是将 a 和 b 转换为原生数据类型,其转换流程如下:

  • 先检测该对象中是否存在 valueOf方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换;

  • 如果 valueOf 没有返回原始类型,那么就使用 toString 方法的返回值;

  • 如果 valueOftoString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误。 在执行加法操作的时候,V8 会通过 ToPrimitve 方法将对象类型转换为原生类型,最后就是两个原生类型相加,

  • 如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。

  • 在其他情况时,所有的值都会转换为数字类型值,然后做数字的相加。

V8 是如何执行一段 JavaScript 代码的?

  1. 预编译阶段 变量提升 并且赋值 undefined
  2. 编译阶段 生成两部分代码 执行上下文(Execution context)可执行代码
  3. 执行阶段

其实在执行 JavaScript 代码之前,V8 就已经准备好了代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。

生成抽象语法树(AST)和执行上下文

将源代码转换为抽象语法树 ,并生成执行上下文。 AST 看成代码的结构化的表示,编译器或者解释器后续的工作都需要依赖于 AST,而不是源代码。AST 应用中最著名的一个项目是 Babel。Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

  1. 分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个 token。
ini 复制代码
var myName = "xiaopang";
// token : var 、 myName 、= 、 'xiaopang'
  1. 解析(parse),又称为语法分析,其作用是将上一步生成的 token 数据,根据语法规则转为 AST。

生成字节码

由于执行机器码的效率是非常高效的,所以早期 chorme 直接将 AST 转化成机器码进行保存。

但机器码占用内存过大,为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码。

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

执行代码

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码 ,那么后台的编译器 TurboFan 就会把该段热点的字节码 编译为高效的机器码 ,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样可以大大提升了代码的执行效率。我们把这种技术称之为即时编译(JIT)

运行时环境:运行 JavaScript 代码的基石

运行时环境包括:堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统

宿主

浏览器为 V8 提供基础的消息循环系统、全局变量、Web API

V8 的核心是实现 ECMAScript 标准,比如:ObjectFunctionString,还提供垃圾回收、协程等

构造数据存储空间:堆空间和栈空间

Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间。

栈是内存中连续的一块空间,采用"先进后出"的策略。

在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用的对象的地址、函数的执行状态、this 值等都会存在栈上

当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。

堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,比如:函数、数组,在浏览器中还有 windowdocument

全局执行上下文和全局作用域

执行上下文中主要包含三部分,变量环境、词法环境和 this 关键字

全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中

ES6 中,同一个全局执行上下文中,都能存在多个作用域:

ini 复制代码
var x = 5;
{
  let y = 2;
  const z = 3;
}

构造事件循环系统

V8 需要一个主线程,用来执行 JavaScript 和执行垃圾回收等工作

V8 是寄生在宿主环境中的,V8 所执行的代码都是在宿主的主线程上执行的

如果主线程正在执行一个任务,这时候又来了一个新任务,把新任务放到消息队列中,等待当前任务执行结束后,再从消息队列中取出正在排列的任务,执行完这个任务之后,再重复这个过程

隐藏类:如何在内存中快速查找对象属性?

  • 为了提升对象属性访问速度,引入隐藏类
  • 为了加速运算引入内联缓存

为什么静态语言效率高

JavaScript 在运行时,对象的属性可以被修改,所以 V8 在解析对象时,比如:解析 start.x 时,它不知道 start 中是否有 x,也不知道 x 相对于 start 的偏移量是多少,简单说 V8 不知道 start 对象的具体行状

所以当 JavaScript 查询 start.x 时,过程非常慢

静态语言,比如 C++ 在声明对象之前需要定义该对象的结构(行状),执行之前会被编译,编译的时候,行状是固定的,也就是说在执行过程中,对象的行政是无法改变的

所以当 C++ 查询 start.x 使,编译器在编译的时候,会直接将 x 相对于 start 对象的地址写进汇编指令中,查询时直接读取 x 的地址,没有查找环节

隐藏类

V8 为了做到这点,做了两个假设:

  1. 对象创建好了之后不会添加新的属性
  2. 对象创建好了之后也不会删除属性

然后 V8 为每个对象创建一个隐藏类,记录基础的信息

  1. 对象中所包含的所有属性
  2. 每个属性相对于对象的偏移量。

V8 中隐藏类有称为 map,即每个对象都有一个 map 属性,指向内存中的隐藏类

有了 map 之后,当访问 start.x 时,V8 会先去 start.map 中查询 x 相对 start 的偏移量,然后将 point 对象的地址加上偏移量就得到了 x 属性的值在内存中的地址了

如果两个对象行状相同,V8 会为其复用同一个隐藏类:

  1. 减少隐藏类的创建次数,也间接加速了代码的执行速度
  2. 减少了隐藏类的存储空间

两个对象的形状相同,要满足:

  1. 相同的属性名称
  2. 相同的属性顺序
  3. 相同的属性类型
  4. 相等的属性个数

如果动态改变了对象的行状,V8 就会重新构建新的隐藏类

V8 是怎么通过内联缓存来提升函数执行效率的?

ini 复制代码
function loadX(o) {
  return o.x;
}
var o = { x: 1, y: 3 };
var o1 = { x: 3, y: 6 };
for (var i = 0; i < 90000; i++) {
  loadX(o);
  loadX(o1);
}

V8 获取 o.x 的流程:查找对象 o 的隐藏类,再通过隐藏类查找 x 属性偏移量,然后根据偏移量获取属性值

这段代码里 o.x 会被反复执行,那么查找流程也会被反复执行,那么 V8 有没有做这优化呢

内联缓存(Inline Cache,简称 IC

V8 在执行函数的过程中,会观察函数中的一些调用点(CallSite)上的关键数据(中间数据),然后将它们缓存起来,当下次再执行该函数时,V8 可以利用这些中间数据,节省再次获取这些数据的过程

IC 会为每个函数维护一个反馈向量(FeedBack Vector),反馈向量记录了函数在执行过程中的一些关键的中间数据

反馈向量是一个表结构,有很多项,每一项称为一个插槽 (Slot)

ini 复制代码
function loadX(o) {
  o.y = 4;
  return o.x;
}

V8 执行这段函数时,它判断 o.y = 4return o.x 是调用点 (CallSite),因为它们使用了对象和属性,那么 V8 会在 loadX 函数的反馈向量中为每个调用点分配一个插槽。

插槽中包括了:

  • 插槽的索引 (slot index)
  • 插槽的类型 (type)
  • 插槽的状态 (state)
  • 隐藏类 (map) 的地址
  • 属性的偏移量
scss 复制代码
function loadX(o) {
  return o.x;
}
loadX({ x: 1 });

// 字节码
StackCheck // 检查是否溢出
LdaNamedProperty a0, [0], [0] // 取出参数 a0 的第一个属性值,并将属性值放到累加器中
Return // 返回累加器中的属性

LdaNameProperty 有三个参数:

  • a0loadX 的第一参数
  • 第一个 [0] 表示取出对象 a0 的第一个属性值
  • 第二个 [0] 和反馈向量有关,表示将 LdaNameProperty 操作的中间数据写到反馈向量中,这里 0 表示第一个插槽
  • map:缓存了 o 的隐藏类的地址
  • offset:缓存了属性 x 的偏移量
  • type:缓存了操作类型,这里是 LOAD 类型。在反馈向量中,我们把这种通过 o.x 来访问对象属性值的操作称为 LOAD 类型。还有一些比如STORE 类型(对对象属性进行赋值操作),CALL 类型(对函数或方法进行调用操作),NEW 类型(创建一个新的对象实例),DELETE 类型(删除对象的属性或数组中的元素)等
scss 复制代码
function foo() {}
function loadX(o) {
  o.y = 4;
  foo();
  return o.x;
}
loadX({ x: 1, y: 4 });

// 字节码
StackCheck
// 下面两行是 o.y = 4,STORE 类型
LdaSmi [4]
StaNamedProperty a0, [0], [0]
// 下面三行是 调用 foo 函数,CALL
LdaGlobal [1], [2]
Star r0
CallUndefinedReceiver0 r0, [4]
// 下面一行是 o.x
LdaNamedProperty a0, [2], [6]
Return

V8 是如何实现微任务的?

宏任务是消息队列中等待被主线程执行的事件,每个宏任务在执行的时候都会创建栈,宏任务结束,栈也会被清空

微任务是一个需要异步执行的函数,执行时机是在主函数执行结束之后,当前宏任务结束之前,微任务通常用于执行一些较短、较轻的操作,例如DOM更新、Promise回调等。

微任务执行的时机:

  1. 如果当前任务中产生了一个微任务,不会在当前的函数中被执行,所以执行微任务时,不会导致栈的无限扩张
  2. 微任务会在当前任务执行结束之前被执行
  3. 微任务结束执行之前,不会执行其他的任务

MutationObserver和IntersectionObserver是两种比较常见的微任务,两个性质应该差不多。

  1. MutationObserver(变动观察者):

    • MutationObserver是一种用于监听DOM树变化的API。它可以观察到DOM的增、删、改等操作,并在这些操作发生时触发回调函数。
    • MutationObserver通过观察指定的DOM节点及其子节点,可以捕获到所观察元素的任何变化,并将变化的详细信息传递给回调函数进行处理。
    • MutationObserver的回调函数是在主线程空闲时异步执行的微任务,因此它不会阻塞主线程的执行。
  2. IntersectionObserver(交叉观察者):

    • IntersectionObserver是一种用于观察元素与其祖先容器或根视窗交叉状态的API。它可以用来判断元素是否进入、离开或与另一个元素重叠等交叉状态。
    • IntersectionObserver会跟踪目标元素与指定容器或视口之间的交叉状态的变化,并在发生变化时触发回调。
    • IntersectionObserver的回调函数也是在主线程空闲时异步执行的微任务,因此它不会阻塞主线程的执行。

在微任务中,浏览器使用requestIdleCallback来调度微任务的执行,它会在浏览器的空闲时间执行微任务队列中的任务。如果100ms内主线程一直处于未空闲状态(没有足够的空闲时间执行微任务),浏览器可能会强制执行微任务队列的任务,以确保及时响应用户的交互。

V8 是如何实现 async/await 的?

协程

协程是一种比线程更加轻量级的存在,是在单个线程中实现的并发任务。一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。协程通过协作式的方式在同一个线程中切换执行,可以避免多线程之间的竞争条件和锁的使用,减少了线程之间的切换开销和资源消耗(本质就是性能)。 具体的可以看我上一篇文章

生成器 Generator

js提供了一种称为生成器 Generator 的功能可以用于实现协程。生成器函数可以暂停执行,并且在需要的时候可以从上一个暂停的位置恢复执行。通过调用生成器的 next() 方法,可以逐步执行生成器函数的代码并获取生成的值。此外,还可以使用 yield 关键字来指定生成器函数的暂停点,并将值传递给调用者。 为了更好的理解协程,可以和普通函数一起来看, 以Generator为例:

普通函数执行的过程中无法被中断和恢复

scss 复制代码
const tasks = []   
function run() {   
let task   
while (task = tasks.shift()) {   
execute(task)  
}   
}  

再来看看 Generator :

scss 复制代码
// 任务列表  
const tasks = []  
function * run () {  
let task  
while(task = task.shift()) {  
// 如果有高优先级的任务  
if (hasHighPriorityTask()) {  
// 中断  
yield  
}  
}  
}  
// 中断后恢复  
const iterator = run()  
// 这样就能恢复了  
iterator.next()  

在生成器内部,如果遇到 yield 关键词,那么 V8yield 后面的内容返回给外部,并暂停函数的执行

生成器暂停后,外面代码开始执行,如果想要继续恢复生成器的执行,就可以使用 next() 方法

async/await

async 是异步执行并隐式返回 Promise 作为结果的函数。

await 后面可以接两种类型的表达式:

  • 任何普通表达式
  • Promise 对象的表达式

如果 await 等待的是一个 Promise 对象,它会暂停执行生成器函数,直到 Promise 对象变成 resolve 才会恢复执行,然后 resolve 的值作为 await 表达式的运算结果

javascript 复制代码
function NeverResolvePromise() {
  return new Promise((resolve, reject) => {});
}
function ResolvePromise() {
  return new Promise((resolve, reject) => resolve("resolve"));
}
async function getResult() {
  let a = await NeverResolvePromise();
  console.log(a); // 不会输出
}
async function getResult2() {
  let b = await ResolvePromise();
  console.log(b); // "resolve"
}
getResult();
getResult2();
console.log(0);

async 是一个异步执行的函数,不会阻塞主线程的执行

async 函数在执行时,是一个单独的协程,可以用 await 来暂停,由于等待的是一个 Promise 对象,就可以用 resolve 来恢复该协程

V8垃圾回收机制

当谈到谷歌V8的垃圾回收机制时,我们可以用一个简单的比喻来解释。假设你的家里存在着很多垃圾,不久后你会发现房间变得杂乱不堪。为了清理房间,你需要分别将地板上的垃圾扫掉并把垃圾倒进垃圾桶。

在V8引擎中,垃圾回收机制也是这样运作的。V8将内存分为两个主要区域,分别是"新生代"和"老生代"。新生代是用于存储临时变量和短期对象的地方,而老生代则是用于存储长期存活的对象。

对于新生代的垃圾回收,V8采用了一个称为"Scavenger"(扫除者)的回收器。这个回收器的原理就像你在房间里扫地一样。它会检查新生代中的对象,将不再有用的对象标记为垃圾,并清理掉它们。同时,它会将仍然有用的对象移动到老生代。

而老生代的垃圾回收则采用了两个回收器,分别是"Mark-Sweep"(标记-清除)和"Mark-Compact"(标记-整理)。这个过程就像你将垃圾桶里的垃圾倒掉一样。

"Mark-Sweep"回收器首先会对整个老生代进行遍历,标记所有存活的对象,然后清除那些没有被标记的对象,释放它们占据的内存空间。但是,这个过程可能会导致内存空间的碎片化,所以接下来就有了"Mark-Compact"回收器的工作。

"Mark-Compact"回收器会进行整理,它会将存活的对象往一端移动,然后释放掉多余的空间,使内存空间变得连续。这就像你将倒垃圾桶里的垃圾集中放置一样。

这样,通过这些垃圾回收机制,V8能够自动管理内存,清理掉不再使用的对象,并优化内存空间的利用效率。

回收规则

常用的两种垃圾回收规则是:标记清除引用计数

  1. 标记清除(Mark and Sweep):

    • 标记清除是一种基于对象可达性的垃圾回收算法。

    • 当一个对象无法从根对象(如全局变量、活动函数的局部变量等)访问到时,表示该对象不再被使用,即为垃圾对象。

    • 标记清除算法通过两个阶段进行:

      • 标记阶段:从根对象出发,遍历所有可达对象,将其标记为活动对象。
      • 清除阶段:遍历整个内存空间,清除未被标记的对象,即为垃圾对象。
    • 标记清除算法能够有效地回收不可达对象内存,但会产生内存碎片。

  2. 引用计数(Reference Counting):

    • 引用计数是一种跟踪对象被引用次数的垃圾回收算法。
    • 引用计数算法为每个对象维护一个计数器,记录该对象当前被引用的次数。
    • 当一个对象被引用时,其计数器加1;当一个对象的引用被释放时,计数器减1。
    • 当一个对象的计数器为0时,表示该对象不再被使用,即为垃圾对象。
    • 引用计数算法具有实时性,当引用计数为0时,立即回收内存空间。
    • 然而,引用计数算法无法处理循环引用的情况,即两个或多个对象互相引用,导致它们的计数器永远不会归零,即使它们已经不可达,会导致内存泄漏。

总结而言,标记清除是通过标记可达对象并清除未标记的垃圾对象来回收内存空间,而引用计数是通过计数对象被引用的次数来判断垃圾对象并回收内存空间。标记清除算法可以处理循环引用,但会产生内存碎片,而引用计数算法实时回收内存,但无法处理循环引用的情况。

Javascript 引擎基础 GC 方案是:标记清除

调用栈中的数据回收

通常情况下,垃圾回收分为 手动回收自动回收 两种策略,js属于后者,他通过垃圾回收器来释放垃圾。

ini 复制代码
function foo() {
  var a = 1;
  var b = { name: " 测试 " };
  function showName() {
    var c = " 测试一下 ";
    var d = { name: " 测试一下 " };
  }
  showName();
}
foo();

js 引擎 将 showName 函数 的执行上下文 压入 调用栈的同时,还有一个记录当前执行状态的指针(称为 ESP) ,指向调用栈中 showName 函数的执行上下文。

当函数执行完毕后,JavaScript 就会将指针下移,这个 下移操作 就是销毁执行上下文的过程。也就在这一步进行 showName 函数执行上下文的销毁。

所以说,当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文

堆中的数据回收

当上面那段代码的 foo 函数执行结束之后,ESP 应该是指向全局执行上下文的,那这样的话,showName 函数和 foo 函数的执行上下文就处于无效状态了,不过保存在堆中的两个对象依然占用着空间。

代际假说

代际假说有以下两个特点:

  • 第一个是大部分对象都是"朝生夕死"的,在内存中存在的时间很短,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问.
  • 第二个是不死的对象,会活得更久。比如windowDOMWeb API

在 V8 中会把堆分为 新生代老生代 两个区域,新生代中存放的是 生存时间短 的对象,老生代中存放的 生存时间久 的对象。新生区通常只支持 1 ~ 8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。具体介绍如下:

  • 新生代:存放生存时间短的对象

    • 容量小,1~8M

    • 使用副垃圾回收器(Minor GC

    • 使用 Scavenge 算法,将新生代区域分成两部分

      • 对象区域 (from-space)

      • 空闲区域 (to-space)

        1. 对象区域放新加入的对象
        2. 对象区域快满的时候,执行垃圾清理(先标记,再清理)
        3. 清理的把活动对象复制到空闲区域,并且排序(空闲区域就没有内存碎片了)
        4. 复制完之后,把对象区域和空闲区域进行翻转
        5. 重复执行上面的步骤
        6. 经过两次垃圾回收后还存在的对象,移动到老生代中
  • 老生代:存放生存时间久的对象

    • 容量大

      • 对象占用空间大
      • 对象存活时间长
    • 使用主垃圾回收器(Major GC

    • 使用标记 - 清除算法(Mark-Sweep

      • 标记:从根元素开始,找到活动对象,找不到的就是垃圾
      • 清理:直接清理垃圾(会产生垃圾碎片)
    • 或者使用标记 - 整理算法(Mark-Compact

      • 标记:从根元素开始,找到活动对象,找不到的就是垃圾
      • 整理:把活动对象向同一端移动,另一端直接清理(不会产生垃圾碎片)

工作流程

不论什么类型的垃圾回收器,它们都有一套共同的执行流程:

  1. 第一步是通过标记空间中 活动对象非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
  2. 第二步是回收 非活动对象 所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  3. 第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为 内存碎片 。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现 内存不足 的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如副垃圾回收器。

副垃圾回收器

副垃圾回收器主要负责新生区的垃圾回收。通常情况下,大多数小的对象都会被分配到新生区,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。

新生代中用 Scavenge 算法来处理。所谓 Scavenge 算法(一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用),就是把新生代空间 对半划分 为两个区域,一半是 对象区域 ,一半是 空闲区域 。新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次 垃圾清理 操作。

在垃圾回收过程中,首先要对对象区域中的垃圾做标记。标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些 活动对象 复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域与空闲区域进行角色翻转,这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域 复制 到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了 执行效率 ,一般新生区的空间会被设置得比较小。也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了 对象晋升策略 ,当一个对象在经过多次复制之后依旧存活(一般是两次),那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升

对象晋升的条件主要有以下两个:

  • 对象是否经历过一次Scavenge算法
  • 空闲区域空间的内存占比是否已经超过25%

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

主垃圾回收器是采用 标记 - 清除(Mark-Sweep) 的算法进行垃圾回收的。首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。接下来就是垃圾的清除过程。

不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法------标记 - 整理(Mark-Compact) ,这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

优化策略

由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行 。我们把这种行为叫做 全停顿(Stop-The-World)。

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

V8 团队向现有的垃圾回收器添加并行、并发、增量等垃圾回收技术

这些技术主要从两方面解决垃圾回收效率的问题:

  1. 将一个完整的垃圾回收任务拆分成多个小的任务
  2. 将标记对象、移动对象等任务转移到后端线程进行

并行回收(在主线程执行,全停顿)

所谓并行回收,是指垃圾回收器在主线程上执行的过程中,还会开启多个协助线程,同时执行同样的回收工作,其工作模式如下图所示:

采用并行回收,垃圾回收所消耗的时间 = 辅助线程数 * 单个线程所消耗的时间

并行回收可以利用多核处理器的优势,将垃圾回收的时间分摊到多个线程上,降低单个线程的工作量,提高垃圾回收的吞吐量和响应性能。通过并行执行多个子任务,可以减少垃圾回收对应用程序的影响,缩短停顿时间。

增量回收(在主线程执行,穿插在各个任务之间)

javascript 的 GC 策略无法避免一个问题: GC 时,停止响应其他操作 。这种行为叫做全停顿(Stop-The-World)

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

这有和 React 通过 Fiber 更新策略有着异曲同工之妙。

如何实现?

增量标记的算法,比全停顿的算法要稍微复杂,这主要是因为增量回收是并发的(concurrent),要实现增量执行,需要满足两点要求:

  1. 垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一次垃圾回收来了之后,才能继续启动。
  2. 在暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理。

在没有采用增量算法之前,V8 使用黑色和白色来标记数据。在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots 出发,将所有能访问到的数据标记为黑色。遍历结束之后,被标记为黑色的数据就是活动数据 ,那些白色数据就是垃圾数据

如果内存中的数据只有两种状态,非黑即白,那么当你暂停了当前的垃圾回收器之后,再次恢复垃圾回收器,那么垃圾回收器就不知道从哪个位置继续开始执行了。

为了解决这个问题,V8 采用了三色标记法,除了黑色和白色,还额外引入了灰色:

  • 黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了 ;
  • 白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。
  • 灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点;

垃圾回收器会根据有没有灰色的节点来判断这一轮遍历有没有结束:

  • 没有灰色:一轮遍历结束,可以清理垃圾
  • 有灰色:一轮遍历还没结束,从灰色的节点继续执行 如果标记为黑色的数据被修改了,也就是说黑色的节点引用了一个白色的节点,但是黑色的节点是已经完成标记的,这时它后面还有一个白色的节点是不会被标记为黑色的。为什么?因为这就表示黑色节点引用了一个潜在的垃圾对象,此时如果不采取任何措施,就会导致那个白色节点及其子节点无法被垃圾收集,并造成内存泄漏。

这就需要一个约束条件:不能让黑色节点指向白色节点。也就是说,只有当一个节点已经被标记为黑色,并且它后面的节点也已经被标记为黑色,才能保证整个对象图中所有的节点都是可达的,确保不会遗漏任何垃圾对象。

这个约束条件是:写屏障机制:

  • 当发生黑色节点引用白色节点,写屏障机制会强制将这个白色节点变为灰色的,从而保证黑色节点不能指向白色节点。被标记为灰色的白色节点会在后续的垃圾回收过程中被遍历和处理。

这种方法被称为强三色不变性。

并发回收(不在主线程执行)

在主线程执行 JavaScript 时,辅助线程在后台执行垃圾回收操作

优点:主线程不会被挂起(JavaScript 可以自由执行,同时辅助线程可以执行垃圾回收)

但有两点导致它很难实现:

  1. 主线程执行 JavaScript 时,堆中的内容随时会变化,就会使得辅助线程之前的工作白做
  2. 主线程和辅助线程可能会在同一时间去修改同一个对象,这就需要额外实现读写锁的功能 不过,这三种技术在实际使用中,并不是单独的存在,通常会将其融合在一起使用,V8 的主垃圾回收器就融合了这三种机制,来实现垃圾回收,那它具体是怎么工作的呢?你可以先看下图: 可以看出来,主垃圾回收器同时采用了这三种策略:
  • 首先主垃圾回收器主要使用并发标记,我们可以看到,在主线程执行 JavaScript,辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的。
  • 标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作。
  • 另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种 JavaScript 任务之间执行。

如何避免内存泄漏

既然是讲垃圾回收,那最有可能跟实际挂钩的就是内存泄漏了。其实说浏览器和大部分的前端框架在底层已经帮助我们处理了常见的内存泄漏问题,不过我们还是有必要了解一下常见的几种避免内存泄漏的方式。

尽可能少地创建全局变量

在ES5中以var声明的方式在全局作用域中创建一个变量时,或者在函数作用域中不以任何声明的方式创建一个变量时,都会无形地挂载到window全局对象上,如下所示:

ini 复制代码
var a = 1; // 等价于 window.a = 1;
ini 复制代码
function foo() {
    a = 1;
}

等价于

ini 复制代码
function foo() {
    window.a = 1;
}

我们在foo函数中创建了一个变量a但是忘记使用var来声明,此时会意想不到地创建一个全局变量并挂载到window对象上,另外还有一种比较隐蔽的方式来创建全局变量:

scss 复制代码
function foo() {
    this.a = 1;
}
foo(); // 相当于 window.foo()

foo函数在调用时,它所指向的运行上下文环境为window全局对象,因此函数中的this指向的其实是window,也就无意创建了一个全局变量。当进行垃圾回收时,在标记阶段因为window对象可以作为根节点,在window上挂载的属性均可以被访问到,并将其标记为活动的从而常驻内存,因此也就不会被垃圾回收,只有在整个进程退出时全局作用域才会被销毁。如果你遇到需要必须使用全局变量的场景,那么请保证一定要在全局变量使用完毕后将其设置为null从而触发回收机制。

手动清除定时器

在我们的应用中经常会有使用setTimeout或者setInterval等定时器的场景,定时器本身是一个非常有用的功能,但是如果我们稍不注意,忘记在适当的时间手动清除定时器,那么很有可能就会导致内存泄漏,示例如下:

ini 复制代码
const numbers = [];
const foo = function() {
    for(let i = 0;i < 100000;i++) {
        numbers.push(i);
    }
};
window.setInterval(foo, 1000);

在这个示例中,由于我们没有手动清除定时器,导致回调任务会不断地执行下去,回调中所引用的numbers变量也不会被垃圾回收,最终导致numbers数组长度无限递增,从而引发内存泄漏。

少用闭包

闭包是JS中的一个高级特性,巧妙地利用闭包可以帮助我们实现很多高级功能。一般来说,我们在查找变量时,在本地作用域中查找不到就会沿着作用域链从内向外单向查找,但是闭包的特性可以让我们在外部作用域访问内部作用域中的变量,示例如下:

javascript 复制代码
function foo() {
    let local = 123;
    return function() {
        return local;
    }
}
const bar = foo();
console.log(bar()); // -> 123

在这个示例中,foo函数执行完毕后会返回一个匿名函数,该函数内部引用了foo函数中的局部变量local,并且通过变量bar来引用这个匿名的函数定义,通过这种闭包的方式我们就可以在foo函数的外部作用域中访问到它的局部变量local。一般情况下,当foo函数执行完毕后,它的作用域会被销毁,但是由于存在变量引用其返回的匿名函数,导致作用域无法得到释放,也就导致local变量无法回收,只有当我们取消掉对匿名函数的引用才会进入垃圾回收阶段。

清除DOM引用

以往我们在操作DOM元素时,为了避免多次获取DOM元素,我们会将DOM元素存储在一个数据字典中,示例如下:

javascript 复制代码
const elements = {
    button: document.getElementById('button')
};

function removeButton() {
    document.body.removeChild(document.getElementById('button'));
}

在这个示例中,我们想调用removeButton方法来清除button元素,但是由于在elements字典中存在对button元素的引用,所以即使我们通过removeChild方法移除了button元素,它其实还是依旧存储在内存中无法得到释放,只有我们手动清除对button元素的引用才会被垃圾回收。

弱引用

通过前几个示例我们会发现如果我们一旦疏忽,就会容易地引发内存泄漏的问题,为此,在ES6中为我们新增了两个有效的数据结构WeakMapWeakSet,就是为了解决内存泄漏的问题而诞生的。其表示弱引用,它的键名所引用的对象均是弱引用,弱引用是指垃圾回收的过程中不会将键名对该对象的引用考虑进去,只要所引用的对象没有其他的引用了,垃圾回收机制就会释放该对象所占用的内存。这也就意味着我们不需要关心WeakMap中键名对其他对象的引用,也不需要手动地进行引用清除(具体参考阮一峰ES6标准入门)。

总结

也没啥好总结的,我觉得虽然是一个八股文的概念性的东西吧,主要的还是理解以及应用,趋利避害罢了。

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、5 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui