Chrome V8

学习JavaScript引擎V8

Posted by JXH on March 10, 2021

## 什么是V8? ### 总结

  1. V8 是由 Google 开发的开源 JavaScript 引擎,也被称为虚拟机,模拟实际计算机各种功能来实现代码的编译和执行。那么,要想搞清楚 V8 内部的工作流程和原理,我们可以从分析计算机对语言的编译和执行过程入手。
  2. 因为计算机只能识别二进制指令,所以要让计算机执行一段高级语言通常有两种手段,第一种是将高级代码转换为二进制代码,再让计算机去执行;另外一种方式是在计算机安装一个解释器,并由解释器来解释执行。
  3. 解释执行和编译执行都有各自的优缺点,解释执行启动速度快,但是执行时速度慢,而编译执行启动速度慢,但是执行速度快。为了充分地利用解释执行和编译执行的优点,规避其缺点,V8 采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 就会采用优化编译器将其编译成执行效率更加高效的机器代码。 理解了这一点,我们就可以来深入分析 V8 执行一段 JavaScript 代码所经历的主要流程了,这包括了:
    • 初始化基础环境;
    • 解析源码生成 AST 和作用域;
    • 依据 AST 和作用域生成字节码;
    • 解释执行字节码;监听热点代码;
    • 优化热点代码为二进制的机器代码;
    • 反优化生成的二进制机器代码。 这里你需要注意的是,JavaScript 是一门动态语言,在运行过程中,某些被优化的结构可能会被 V8 动态修改了,这会导致之前被优化的代码失效,如果某块优化之后的代码失效了,那么编译器需要执行反优化操作。

## 函数即对象:函数是一等公民 ### 总结

  1. 因为函数是一种特殊的对象,所以我们先介绍了 JavaScript 中的对象,JavaScript 中的对象就是由一组一组属性和值组成的集合,既然函数也是对象,那么函数也是由一组组值和属性组成的集合,我们还在文中使用了一段代码证明了这点。
  2. 函数作为一个对象,它有自己的属性和值,所以函数关联了基础的属性和值。
  3. 函数之所以成为特殊的对象,这个特殊的地方是函数可以“被调用”,所以一个函数被调用时,它还需要关联相关的执行上下文。

## 快属性和慢属性:V8是怎样提升对象属性访问速度的。 ### 总结

  1. JavaScript 中的对象是由一组组属性和值组成的,所以最简单的方式是使用一个字典来保存属性和值,但是由于字典是非线性结构,所以如果使用字典,读取效率会大大降低。
  2. 为了提升查找效率,V8 在对象中添加了两个隐藏属性,排序属性和常规属性,element 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性。properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存常规属性。
  3. 通过引入这两个属性,加速了 V8 查找属性的速度,为了更加进一步提升查找效率,V8 还实现了内置内属性的策略,当常规属性少于一定数量时,V8 就会将这些常规属性直接写进对象中,这样又节省了一个中间步骤。
  4. 但是如果对象中的属性过多时,或者存在反复添加或者删除属性的操作,那么 V8 就会将线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查找速度,但是却提升了修改对象的属性的速度。思考题
  5. 除了 elements 和 properties 属性,V8 还为每个对象实现了 map 属性和 proto 属性。

## 函数表达式 ### 总结

  1. 表达式是不会在编译阶段执行的。
  2. 函数声明和变量声明类似,V8 在编译阶段,都会对其执行变量提升的操作,将它们提升到作用域中,在执行阶段,如果使用了某个变量,就可以直接去作用域中去查找。
  3. 函数表达式也是表达式的一种,在编译阶段,V8 并不会将表达式中的函数对象提升到全局作用域中,所以无法在函数表达式之前使用该函数。函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用,因此在一些大的开源项目中有广泛的应用。

## 原型链:V8是如何实现对象继承的 ### 总结

  1. 实现继承的方式是不同的,其中最典型的两种方式是基于类的设计和基于原型继承的设计。js是基于原型继承的设计。
  2. 隐藏属性 proto 称之为该对象的原型 (prototype),proto 指向了内存中的另外一个对象,我们就把 proto 指向的对象称为该对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性
  3. 不要将原型链接和作用域链搞混淆了,作用域链是沿着函数的作用域一级一级来查找变量的,而原型链是沿着对象的原型一级一级来查找属性的,虽然它们的实现方式是类似的,但是它们的用途是不同的。
  4. 通常隐藏属性是不能使用 JavaScript 来直接与之交互的。虽然现代浏览器都开了一个口子,让 JavaScript 可以访问隐藏属性 proto,但是在实际项目中,我们不应该直接通过 proto 来访问或者修改该属性,其主要原因有两个:首先,这是隐藏属性,并不是标准定义的 ;其次,使用该属性会造成严重的性能问题。
  5. 使用构造函数来创建对象,去正确地设置对象的原型对象。
  6. new 关键字结合构造函数,就能生成一个对象。

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

总结

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

类型转换: v8是怎么实现1+’2’的?

总结

  1. 在计算机科学中,类型系统(type system)用于定义如何将编程语言中的数值和表达式归类为许多不同的类型,如何操作这些类型,这些类型如何互相作用。
  2. 通俗地理解,V8 会提供了一个 ToPrimitive 方法,其作用是将 a 和 b 转换为原生数据类型,其转换流程如下:
    • 先检测该对象中是否存在 valueOf 方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换;
    • 如果 valueOf 没有返回原始类型,那么就使用 toString 方法的返回值;
    • 如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误

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

总结

  1. 其实在执行 JavaScript 代码之前,V8 就已经准备好了代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。准备好运行时环境之后,V8 才可以执行 JavaScript 代码,这包括解析源码、生成字节码、解释执行或者编译执行这一系列操作。 v8运行流程
  2. 基础的运行时环境: 基础的运行时环境
  3. 宿主环境:执行 V8,则需要有一个宿主环境,宿主环境可以是浏览器中的渲染进程,可以是 Node.js 进程, 也可以是其他的定制开发的环境,而这些宿主则提供了很多 V8 执行 JavaScript 时所需的基础功能部件。 宿主环境
  4. 由于 V8 是寄生在浏览器或者 Node.js 这些宿主中的,因此,V8 也是被这些宿主启动的。比如,在 Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间。。
  5. 栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层过深,那么 V8 就有可能抛出栈溢出的错误。
  6. 堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,在前面的课程中我们也讲过,JavaScript 中除了原生类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有 window 对象、document 对象等,这些都是存在堆空间的。
  7. V8 用执行上下文来维护执行当前代码所需要的变量声明、this 指向等。执行上下文中主要包含三部分,变量环境、词法环境和 this 关键字。比如在浏览器的环境中,全局执行上下文中就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数,诸如 setTimeout、XMLHttpRequest 等内容。
  8. V8 还需要有一个主线程,用来执行 JavaScript 和执行垃圾回收等工作。V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的。这时候就有一个事件循环系统来监听要执行的任务。如果主线程正在执行一个任务,这时候又来了一个新任务,比如 V8 正在操作 DOM,这时候浏览器的网络线程完成了一个页面下载的任务,而且 V8 注册监听下载完成的事件,那么这种情况下就需要引入一个消息队列,让下载完成的事件暂存到消息队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务。当执行完一个任务之后,我们的事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务。
  9. 作用域和上下文的关系:
  • 作用域是静态的,函数定义的时候就已经确定了。
  • 执行上下文是动态的,调用函数时候创建,结束后还会释放。 ___________________
  • 我一直觉得作用域是在执行上下文内的,变量环境感觉就是作用域的样子。但是又考虑到作用域在编译时就确定了,而执行上下文是运行时才创建的,所以是在运行的时候把作用域映射到变量环境的吗???? 疑问 ——————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————
  • 一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)。

机器代码:二进制机器码究竟是如何被CPU执行的?

总结

  1. 在执行指令的过程中,CPU 需要对数据执行读写操作,如果直接读写内存,那么会严重影响程序的执行性能,因此 CPU 就引入了寄存器,将一些中间数据存放在寄存器中,这样就能加速 CPU 的执行速度。
  2. 有了寄存器之后,CPU 执行指令的操作就变得复杂了一点,因为需要寄存器和内存之间传输数据,或者寄存器和寄存器之间传输数据。我们通常有以下几种方式来使用寄存器,这包括了加载指令、存储指令、更新指令。通过配合这几种类型的指令,我们就可以实现完整的程序功能了。

堆和栈:函数调用是如何影响到内存布局的?

  1. 因为现代语言都是基于函数的,每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为调用栈。

延迟解析:V8是如何实现闭包的?

总结

在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点:

  • 首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。因为有时候一个页面的 JavaScript 代码都有 10 多兆,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间;
  • 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源。
    基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

预解析:由于 JavaScript 是一门天生支持闭包的语言,由于闭包会引用当前函数作用域之外的变量,所以当 V8 解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后,也不会释放该变量。

字节码(一): V8为什么又重新引入字节码?

不过随着移动设备的普及,V8 团队逐渐发现将 JavaScript 源码直接编译成二进制代码存在两个致命的问题:

  • 时间问题:编译时间过久,影响代码启动速度;
  • 空间问题:缓存编译后的二进制代码占用更多的内存。这 两个问题无疑会阻碍 V8 在移动设备上的普及,于是 V8 团队大规模重构代码,引入了中间的字节码。字节码的优势有如下三点:
  • 解决启动问题:生成字节码的时间很短;
  • 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
  • 代码架构清晰:采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易。

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

因为 JavaScript 是一门动态语言,对象属性在执行过程中是可以被修改的,这就导致了在运行时,V8 无法知道对象的完整形状,那么当查找对象中的属性时,V8 就需要经过一系列复杂的步骤才能获取到对象属性。

为了加速查找对象属性的速度,V8 在背后为每个对象提供了一个隐藏类,隐藏类描述了该对象的具体形状。有了隐藏类,V8 就可以根据隐藏类中描述的偏移地址获取对应的属性值,这样就省去了复杂的查找流程。

不过隐藏类是建立在两个假设基础之上的:

  • 对象创建好了之后就不会添加新的属性;
  • 对象创建好了之后也不会删除属性。

最后,关于隐藏类,我们记住以下几点。

  • 在 V8 中,每个对象都有一个隐藏类,隐藏类在 V8 中又被称为 map。
  • 在 V8 中,每个对象的第一个属性的指针都指向其 map 地址。
  • map 描述了其对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少?
  • 如果添加新的属性,那么需要重新构建隐藏类。
  • 如果删除了对象中的某个属性,通用也需要构建隐藏类。

## 答疑: V8是怎么通过内联缓存来提升函数执行效率的? 因此,V8 引入了 IC,IC 会监听每个函数的执行过程,并在一些关键的地方埋下监听点,这些包括了加载对象属性 (Load)、给对象属性赋值 (Store)、还有函数调用 (Call),V8 会将监听到的数据写入一个称为反馈向量 (FeedBack Vector) 的结构中,同时 V8 会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8 就可以缩短对象属性的查找路径,从而提升执行效率。

消息队列:V8是怎么实现回调函数的?

UI 线程提供一个消息队列,并将待执行的事件添加到消息队列中,然后 UI 线程会不断循环地从消息队列中取出事件、执行事件。

异步编程(一): V8是如何实现微任务的?

总结:

  • 宏任务很简单,就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。
  • 微任务稍微复杂一点,其实你可以把微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。 微任务技术栈
  • 从图中可以看出,微任务是基于消息队列、事件循环、UI 主线程还有堆栈而来的,然后基于微任务,又可以延伸出协程、Promise、Generator、await/async 等现代前端经常使用的一些技术。也就是说,如果对消息队列、主线程还有调用栈理解的不够深入,你在研究微任务时,就容易一头雾水。

  • 主线程和调用栈。由于栈空间在内存中是连续的,所以通常我们都会限制调用栈的大小,如果当函数嵌套层数过深时,过多的执行上下文堆积在栈中便会导致栈溢出 调用栈
  • 微任务其实是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

异步编程(二): V8是如何实现async/await.

总结

  • 背后的魔法就是协程,协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。比如,当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。
  • 由于生成器函数可以暂停,因此我们可以在生成器内部编写完整的异步逻辑代码,不过生成器依然需要使用额外的 co 函数来驱动生成器函数的执行,这一点非常不友好。基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。

垃圾回收(一): V8的两个垃圾回收器是如何工作的?

总结

  • V8 依据代际假说,将堆内存划分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。为了提升垃圾回收的效率,V8 设置了两个垃圾回收器,主垃圾回收器和副垃圾回收器。主垃圾回收器负责收集老生代中的垃圾数据,副垃圾回收器负责收集新生代中的垃圾数据。
  • 副垃圾回收器采用了 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换。主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作,会经历标记、清除和整理过程。

垃圾回收(二):V8是如何优化垃圾回收器执行效率的?

总结

V8 最开始的垃圾回收器有两个特点,第一个是垃圾回收在主线程上执行,第二个特点是一次执行一个完整的垃圾回收流程。
由于这两个原因,很容易造成主线程卡顿,所以 V8 采用了很多优化执行效率的方案。

  • 第一个方案是并行回收,在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。
  • 第二个方案是增量式垃圾回收,垃圾回收器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。
  • 第三个方案是并发回收,回收线程在执行 JavaScript 的过程,辅助线程能够在后台完成的执行垃圾回收的操作。主垃圾回收器就综合采用了所有的方案,副垃圾回收器也采用了部分方案。