当我们谈论V8引擎时,我们在谈论什么?

190 阅读11分钟

前言

V8使用C++开发,在运行JavaScript之前,相比其它的JavaScript的引擎转换成字节码或解释执行,V8将其编译成原生机器码(IA-32, x86-64, ARM, or MIPS CPUs),并且使用了如内联缓存(inline caching)等方法来提高性能。有了这些功能,JavaScript程序在V8引擎下的运行速度媲美二进制程序。V8支持众多操作系统,如windows、linux、android等,也支持其他硬件架构,如IA32,X64,ARM等,具有很好的可移植和跨平台特性。

image.png

JS相较于C++等语言为什么慢,V8做了哪些优化

  1. JS的问题:
    • 动态类型:导致每次存取属性/寻求方法时候,都需要先检查类型;此外动态类型也很难在编译阶段进行优化

    • 属性存取:C++/Java等语言中方法、属性是存储在数组中的,仅需数组位移就可以获取,而JS存储在对象中,每次获取都要进行哈希查询

  1. V8的优化:
    • 优化JIT(即时编译):相较于C++/Java这类编译型语言,JS一边解释一边执行,效率低。V8对这个过程进行了优化:如果一段代码被执行多次,那么V8会把这段代码转化为机器码缓存下来,下次运行时直接使用机器码。

    ps: v8 为了实现 字节码 + 即时编译(JIT) 的优化,sort 函数在 7.0 后使用谷歌自研的 Torque 语言(即 .tq)来开发,且排序算法变成了 TimSort。 该版本及之前使用 js 来开发,核心内容如下: 数组长度 <= 10 时,使用 插入排序 数组长度 > 10 时,使用 快速排序, 7.0后采用tq 来开发,核心内容如下: 数组长度较小 时,使用 二分插入排序 数组长度较大 时,使用 归并排序

    • 隐藏类:对于C++这类语言来说,仅需几个指令就能通过偏移量获取变量信息,而JS需要进行字符串匹配,效率低,V8借用了类和偏移位置的思想,将对象划分成不同的组,即隐藏类

    • 内嵌缓存:即缓存对象查询的结果。常规查询过程是:获取隐藏类地址 -> 根据属性名查找偏移值 -> 计算该属性地址,内嵌缓存就是对这一过程结果的缓存

    • 垃圾回收管理:V8 引擎采用分代式的垃圾回收策略,将堆内存分为新生代(Young Generation)和老生代(Old Generation)两部分: 新生代:存放生命周期较短的对象,采用 Scavenge 算法进行垃圾回收。 老生代:存放生命周期较长的对象,采用标记-清除-整理(Mark-Sweep-Compact)算法进行垃圾回收。

隐藏类

在 C++ 和 JavaScript 中,对象的属性存储方式确实有所不同,这也导致了它们在处理属性时的性能特点有所差异。

在 C++ 中,对象的属性通常是通过类的成员变量(member variables)来存储的,这些成员变量在对象被创建时就确定了,每个对象的属性在内存中的位置是固定的。C++ 对象的属性存储是比较静态的,对象的结构在编译时就已确定。

而在 JavaScript 中,对象的属性是以哈希表(hash table)的形式来存储的,对象的属性可以动态地添加、删除和修改,对象的结构是相对灵活的。JavaScript 对象的属性存储是比较动态和灵活的,对象的属性在运行时可以动态改变。

因此,在处理属性时,C++ 可以通过直接访问固定位置的成员变量来实现高效的属性访问,而 JavaScript 需要通过哈希表进行属性查找,这可能会引入一定的性能开销。不过,正如之前提到的隐藏类技术,V8 引擎会通过优化隐藏类来提高属性访问的性能,从而弥补了这种差异。

隐藏类(Hidden Classes)是V8引擎中用于优化对象属性访问的一种技术。在JavaScript中,对象的属性可以动态添加和删除,这导致对象的内部结构可能会发生变化,给属性访问带来一定的性能损失。隐藏类的作用就是为了在对象的属性访问中提高性能,并减少访问时的开销。

  • 当一个对象被创建时,V8引擎会根据对象的属性和方法的定义顺序来生成隐藏类。每个隐藏类都包含了对象的结构信息,如属性的名称、偏移量等。当对象的属性发生变化时(比如增加或删除属性),V8引擎会根据变化生成新的隐藏类,以适应对象结构的变化。

  • 通过隐藏类,V8引擎可以在属性访问时进行优化,避免动态查找属性的开销。当访问对象的属性时,引擎会根据隐藏类的信息直接定位到属性的偏移量,从而快速访问属性的值,而不需要进行动态查找。

  • 隐藏类的使用可以有效提高对象属性访问的性能,特别是在涉及大量对象属性访问的情况下。通过隐藏类技术,V8引擎可以更高效地处理对象属性的访问,从而提升JavaScript代码的执行效率。

隐藏类是V8引擎中用于优化JavaScript对象属性访问的机制。当创建一个对象时,V8会根据对象的属性和方法生成对应的隐藏类,通过隐藏类来跟踪对象的内部结构。这样可以实现相同隐藏类对象之间的属性布局一致,从而提高属性访问的性能。

示例:

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  
  getX() {
    return this.x;
  }
}

let point1 = new Point(3, 4);
let point2 = new Point(5, 6);

// point1 和 point2 会共享相同的隐藏类,隐藏类会优化属性 x 和 y 的访问

在上面的示例中,Point 类的实例 point1 和 point2 共享相同的隐藏类,这个隐藏类会帮助优化属性 x 和 y 的访问,提高代码执行效率。

内联缓存

隐藏类主要用于优化对象属性的访问,而内联缓存(Inline Caching)则是一种更高级的优化技术,用于优化方法调用。尽管它们都是为了提高代码的执行效率,但它们针对的是不同的优化对象,具有不同的优化方式和机制。

内联缓存主要用于优化方法调用,它通过缓存对象和方法之间的关联关系,以便在下一次调用同一个方法时能够直接使用缓存,而不需要再进行查找和解析。这样可以避免每次方法调用时的动态查找和解析开销,提高了方法调用的性能。

内联缓存是一种优化技术,用于加速方法调用过程。当执行某个方法时,引擎会将方法的实现缓存起来,下次再次调用相同方法时,可以直接使用缓存结果,避免重复的查找和解析过程,提高方法调用的性能。

示例:

function add(a, b) {
  return a + b;
}

let result1 = add(3, 4);
let result2 = add(5, 6);

// add 方法的实现会被缓存起来,在下次调用 add 方法时可以直接使用缓存结果

在上面的示例中,add 方法的实现会被内联缓存起来,这样在下次调用 add 方法时就可以直接使用缓存结果,而不需要进行重复的查找和解析,从而提高方法调用的性能。

隐藏类和内联缓存联合

综上所述,隐藏类用于优化属性访问,内联缓存用于优化方法调用,它们都是V8引擎中重要的优化技术,能够提高JavaScript代码的执行效率。希望这次解释更详细清晰了!

内联缓存和隐藏类可以相互配合,一起用于优化 JavaScript 代码的执行效率。当一个对象的方法被调用时,内联缓存可以快速定位到正确的方法实现,而隐藏类则可以帮助快速访问方法中的属性。这样,通过内联缓存和隐藏类的优化,JavaScript 引擎可以更加高效地执行代码,提高整体性能。

综上所述,尽管已经有隐藏类用于优化属性访问,但内联缓存仍然是另一种重要的优化技术,用于优化方法调用,两者可以相互配合,共同提高 JavaScript 代码的执行效率。

垃圾回收

垃圾回收的触发通常是由特定的条件或者阈值来决定的,比如新生代的 Scavenge 回收会在新生代对象达到一定数量时触发,而老生代的垃圾回收则会针对老生代内存的使用情况和碎片化程度来触发。 V8 引擎会根据实际的内存使用情况和算法的设计来动态地进行垃圾回收,以确保内存的合理利用和程序的正常执行。

新生代垃圾回收算法:Scavenge
  • 算法原理

    • Scavenge 算法基于复制算法,将新生代内存空间划分为两块:From 空间和 To 空间。
    • 当对象在 From 空间经历一次垃圾回收后仍然存活,会被复制到 To 空间。
    • 每次回收时将 From 空间和 To 空间互换角色,以确保存活对象不断被复制。
  • 示例说明

    let array = [];
    function createObjects() {
        for (let i = 0; i < 1000; i++) {
            array.push({ index: i });
        }
    }
    createObjects();
    
    • 在这个示例中,array 数组中存放了大量对象,在新生代内存中被分配空间。
    • 当 createObjects 函数执行完毕后,新生代内存可能需要进行垃圾回收,触发 Scavenge 算法。
老生代垃圾回收算法:Mark-Sweep-Compact
  • 算法原理

    • Mark-Sweep-Compact 算法主要包括三个阶段:标记、清除和整理。
    • 首先标记所有可达对象,然后清除未标记对象并回收内存,最后整理内存空间以减少碎片化。
  • 示例说明

    假设在老生代的堆内存中有一系列对象,它们的分布如下:

[Object1][Object2][Unreachable][Object3][Unreachable][Object4]
  1. 标记阶段(Mark)

    • 首先,垃圾回收器会从堆内存中的根对象(通常是全局对象、执行上下文等)开始,逐步遍历所有可达的对象,并给它们打上标记,表示这些对象是活跃的,依然被引用着。
  2. 清除阶段(Sweep)

    • 在标记完成后,垃圾回收器会扫描整个堆内存,清除没有标记的对象,即那些不可达的对象。在我们的例子中,Unreachable 标识的对象将被清除,释放它们所占用的内存。
  3. 整理阶段(Compact)

    • 清除阶段之后,可能会出现内存空间的碎片化情况,即一些空闲内存间隔在活跃对象之间。为了优化内存利用,垃圾回收器会对存活的对象进行整理,将它们向堆内存的一端移动,从而使得空闲内存空间连续起来,减少内存碎片化。

经过 Mark-Sweep-Compact 算法的处理后,堆内存的分布可能变为:

[Object1][Object2][Object3][Object4][][][]

通过这个例子可以清楚地看到 Mark-Sweep-Compact 算法的工作流程,它通过标记活跃对象、清除不可达对象和整理内存空间来实现老生代的垃圾回收和内存优化。

增量标记
  1. 增量标记(Incremental Marking)

    • 为了减少垃圾回收造成的长时间停顿,V8 引擎还实现了增量标记算法。在执行 JavaScript 代码的同时,会分阶段逐步标记活跃对象,从而将垃圾回收的工作分摊到多个时间段。
全停顿
  1. 全停顿(Full Stop-The-World Garbage Collection)

    • 在某些情况下(如老生代空间不足或手动触发),V8 会执行全停顿的垃圾回收,即暂停 JavaScript 执行线程,进行完整的垃圾回收操作。

以上是 V8 引擎的垃圾回收整体流程,包括新生代和老生代的内存管理机制,以及为了提高性能而实现的增量标记和全停顿策略。这些算法和策略的结合,使得 V8 引擎能够高效地管理内存,确保程序的运行稳定性和性能表现。