手撕V8

151 阅读16分钟

想弄清楚V8是怎么工作的, 就是分析 编译流水线, 事件循环系统和垃圾回收机制

编译流水线

v8是虚构出来的计算机, 也称为虚拟机, 用来编译和执行javaScript代码。 CPU只能识别二进制的指令。 处理器不能直接识别由高级语言所编写的代码, 有两种方式执行这些代码。

第一种解释执行

需要先将输入的源代码通过解析器编译成中间代码,之后使用解释器解释执行中间代码,然后输出结果。

优点: 启动速度快; 缺点: 执行速度慢

(此处假装是图) 一段代码 --> 【解析器】 --> 中间代码 --> 【解释器】 --> 输出结果

第二种编译执行

这种方式也需要将源码转换成中间代码, 然后我们的编译器再将中间代码编译成机器代码, 通常编译成的机器代码是以二进制文件形式存储的, 需要执行这段程序的时候直接执行二进制文件就可以了, 还可以使用虚拟机将编译后的机器代码保存在内存中, 然后直接执行内存中的二进制的代码。

优点: 启动速度慢 缺点: 执行速度快

(此处假装是图) 一段代码 --> 【解析器】 --> 中间代码 --> 【编译器】 --> 二进制机器代码 --> 执行输出结果

javaScript虚拟机: safari中的JavaScriptCore虚拟机, Firefox使用了Tracemonkey虚拟机 Chrome使用了V8虚拟机。

V8 是怎么执行JavaScript代码的?

实际上, v8 并没有采用单一的技术, 而是混合编译执行解释执行这两种手段, 我们把这种混合使用编译器和解释器的技术称为JIT(just in time)技术。 即时编译器

主要流程:

  1. 初始化基础环境
  2. 解析源码生成AST 和 作用域
  3. 依据AST 和作用域 生成字节码
  4. 解释字节码; 过程会监听热点代码
  5. 优化热点代码为二进制的机器代码 , 可以使执行速度更快
  6. 因为JavaScript是一门动态语言, 在运行过程中, 某些被优化的热点代码结构被动态修改了, 此时编译器需要执行反优化生成二进制机器代码。

原型链

(此处有图) A B C

javaScript 中每个对象都包含了一个隐藏属性proto 称之为该对象的原型prototype, proto指向了内存中的另一个对象, 我们就把proto 指向的对象称为对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性。

原型链:看到使用C.name 和C.color时, 给人的感觉属性name 和 color都是对象C本身的属性, 但实际上这些属性都是位于原型对象上, 我们把这个查找属性的路径称为原型链, 它像一个链条一样, 将几个原型链接起来。

继承: 继承就是一个对象可以访问另一个对象中的属性和方法, 在javaScript中, 我们通过原型链的方式来实现了继承特性。

注意📢: 作用域链是沿着函数的作用域一级一级来查找变量的, 而原型链是沿着对象的原型一级一级来查找属性的。

在实际项目中, 我们不应该直接通过proto来访问或者修改属性, 其主要原因有两个:

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

构造函数

    function DogFactory(type, color) {
        this.type = type
        this.color = color
    }

使用关键词创建对象: var dog = new DogFactory('dog', 'black')

为什么通过new 关键字配合一个函数, 就会返回一个对象呢?

其实当V8执行这段代码时, V8在背后悄悄做了以下几件事情, 模拟代码如下所示:

    var dog = {}
    
    dog.__proto__ = DogFactory.prototype
    
    DogFactory.call(dog, 'Dog', 'Black')

垃圾回收

V8的内存分配

内存分为内存和内存

栈Stack

栈内存小且存储连续, 操作起来简单方便, 一般由系统自动分配, 自动回收, 所以文章内所说的垃圾回收, 都是基于堆内存。

堆Heap memory

堆中包含new Space 新生代old space 老生代large object space 大对象空间code space 代码空间cell spaceProperty cell spacemap space

负责垃圾回收机制的只有new Space 新生代old space 老生代

large object space 大对象空间: 大于其他空间大小限制对象所存放的位置。

code space 代码空间: 即时编译器JIT, 存储即时已编译的代码块, js代码会在这个环境编译且运行, 可执行的内存空间

new space新生代空间: 对半分为 semi space from 空间和 semi space to空间。

old space 老生代空间: 分为 old pointer space空间和 old data space空间, 是一个连续的空间。

如果一个对象它有指针引用指向其他对象,大多数会保存在old pointer space 空间下 , 如果一个对象是一个原始对象没有指针引用, 会保存在old data space空间下, 所有老生代的数据全部会由新生代数据晋升而来

内存大小

和操作系统有关

64位: 1.4G(1464MB) 32位: 0.7G(732MB)

64位新生代的空间为64MB, 老生代为 1400MB 32位新生代的空间为32MB, 老生代为700MB

最新版的node (v14)的内存为2GB nodejs深入浅出

垃圾回收算法

新生代简单来说就是copy(复制) Scavange算法(新生代互换)

老生代就是标记整理清除

Mark-Sweep(标记清除) Mark-Compact(标记整理)

广度扫描, 一次性扫描(全停顿标记)

增量标记 & 三色标记法 运行主线程 -> 垃圾回收 -> 主线程 -> 回收 先整理后清除 将已标记内容覆盖未标记内容, 减少清除次数

新生代如何晋升到老生代

semi space From --> 经历过Scavenge回收 + To space 已经使用了25% --> 晋升老生代空间

变量处理

  • 内存主要就是存储变量等数据的
  • 局部变量当程序执行结束, 且没有引用的时候就会随着消失
  • 全局对象会始终存活到程序运行结束

运行时环境

对运行时环境有足够的了解, 能够帮助我们更好地理解V8 的执行流程, 比如事件循环系统可以让你清楚各种回调函数是怎么被执行的, 栈空间可以让你了解函数是怎么被调用的, 堆空间和栈空间让你了解为什么要有传值和传引用等。

宿主环境

可以把V8和浏览器的渲染进程关系看成病毒和细胞的关系, 浏览器为V8提供基础的消息循环系统, 全局变量, web API,而V8的核心是实现了ECMAScript标准, 这相当于病毒自己的DNA或者RNA, V8只提供了ECMAScript定义的一些对象和一些核心的函数, 这包括Object,Function,String。除此之外, V8还提供了垃圾回收器, 协程等基础内容, 不过这些功能依然需要宿主环境的配合才能完成执行。

如果V8使用不当, 比如不规范的代码触发了频繁的垃圾回收, 或者某个函数执行时间过久, 这些都会占用宿主的主线程, 从而影响到程序的执行效率, 甚至导致宿主环境的卡死

其次, 除了浏览器可以作为V8的宿主环境, Node也是V8的另一种宿主环境, 它提供了不同的宿主对象和宿主API, 但是整个流程依然是相同的, 比如Node也会提供一套消息循环系统, 也会提供一个运行时的主线程。

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

初始化V8, 同时就会初始化堆空间和栈空间

栈空间主要是用来管理JavaScript函数调用的, 栈是内存中连续的一块空间, 同时栈结构是先进后出的策略。在函数调用过程中, 涉及到上下文相关的内容都会存放在栈上, 比如原生类型, 引用到的对象地址, 函数的执行状态, this值等都会存在栈上。当一个函数执行结束, 那么该函数的执行上下文便会被销毁。

栈空间的最大特点就是空间连续, 所以在栈中每个元素的地址都是固定的, 因此栈空间的查找效率非常高, 但是通常在内存中, 很难分配到一块很大的连续空间, 因此, V8对栈空间的大小做了限制, 如果函数调用层过深, 那么V8就有可能抛出栈溢出的错误。

堆空间是一种树形的存储结构, 用来存储对象类型的离散的数据, 在前面的课程中我们也讲过, JavaScript中除了原生类型的数据, 其他的都是对象类型, 诸如函数, 数组, 在浏览器中还有window对象, document对象等, 这些都是存在堆空间的。

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

执行上下文中主要包含三个部分, 变量环境, 词法环境和this关键字。 比如在浏览器的环境中, 全局执行上下文就包括了window对象, 还有默认指向window的this关键字, 另外还有一些web Api函数, 诸如setTimeout、 XMLHttpRequest等内容。

而词法环境中, 则包含了使用let、const等变量的内容。

什么是执行上下文

全局执行上下文在V8的生存周期内是不会被销毁的, 它会一直保存在堆中, 这样当下次在需要使用函数或者全局变量时, 就不需要重新创建了, 另外, 当你执行了一段全局代码时,如果全局代码中有声明的函数或者定义的变量, 那么函数对象和声明的变量就会被添加到全局执行上下文中。

如果在浏览器中, JavaScript代码会频繁操作window(this默认指向window对象)、操作dom等内容, 如果在node中, JavaScript会频繁使用global(this默认指向global对象)、file api等内容, 这些内容都会在启动过程中准备好, 我们把这些内容称之为全局执行上下文。

全局执行上下文和函数的执行上下文生命周期不同, 函数执行上下文在函数执行结束之后,就会被销毁, 而全局执行上下文则和V8的生命周期一致, 所以在实际项目中, 如果不经常使用的变量或者数据, 最好不要放到全局执行上下文中

构造事件循环系统

因为V8还需要有一个主线程, 用来执行 JavaScript和执行垃圾回收等工作, V8是寄生在宿主环境中的, 它并没有自己的主线程, 而是使用宿主所提供的主线程,V8所执行的代码都是在宿主的主线程上执行的。

如果主线程正在执行一个任务, 这时候又来了一个新任务, 比如V8正在操作DOM,这时候浏览器的网络线程完成了一个页面下载的任务, 而且V8注册监听下载完成的事件, 那么这种情况下就需要引入一个消息队列, 让下载完成的事件暂存到消息对象队列中, 等当前的任务执行结束后, 继续从消息队列中取出并执行下个任务。

V8惰性解析

所谓惰性解析是指解析器在解析的过程中, 如果遇到函数声明, 那么会跳过函数内部的代码, 并不会为其生成AST和字节码, 而仅仅生成顶层代码的AST和字节码。

利用惰性解析可以加速JavaScript代码的启动速度, 如果要将所有的代码一次性解析编译完成, 那么会大大增加用户的等待时间。

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

字节码

早期V8为了提升代码的执行速度, 直接将JavaScript源代码编译成没有优化的二进制的机器码, 如果某一段二进制代码执行频率过高, 那么V8会将其标记为热点代码, 热点代码会被优化编译器优化, 优化后的机器代码执行效率更高。

不过源码直接编译成二进制代码存在两个致命的问题:

  1. 时间问题: 编译时间过久, 影响代码启动速度;
  2. 空间问题: 缓存编译后的二进制代码占用更多的内存。

这两个问题阻碍了V8在移动设备上的普及, 引入了中间的字节码。字节码的优势有如下三点:

  • 解决启动问题: 生成字节码的时间很短;
  • 解决空间问题: 字节码占用内存不多, 缓存字节码会大大降低内存的使用;
  • 代码架构清晰: 采用字节码, 可以简化程序的复杂度, 使得V8移植到不同的CPU架构平台更加容易。

回调函数

回调函数有两种不同的形式, 同步回调和异步回调, 通常, 我们需要将回调函数传入给另外一个执行函数, 回调函数和异步回调的最大区别在于, 同步回调函数是在执行函数内部被执行的, 而异步回调函数是在执行函数外部被执行的

异步回调函数在什么时机被执行

通过UI线程宏观架构分析, UI线程提供了一个消息队列, 并将执行的事件添加到消息队列中, 然后UI线程会不断循环地从消息队列中取出事件, 执行事件。

异步回调也有两种不同的类型, 其典型代表是setTimeout 和 XMLHttpRequest

setTimeout的执行流程其实是比较简单的, 在setTimeout函数内部封装回调消息, 并将回调消息添加进消息队列, 然后主线程从消息队列中取出回调事件, 并执行回调函数。

XMLHttpRequest复杂一点, 因为下载过程需要放到单独的一个线程中去执行, 所以执行XMLHttpRequest.send的时候, 宿主会将实际请求发给网络线程, 然后send函数退出, 主线程继续执行下面的任务。网络线程在执行下载的过程中, 会将一些中间信息和回调函数封装成新的消息, 并将其添加进消息队列中, 然后主线程从消息队列中取出回调事件, 并执行回调函数。

宏任务和微任务

宏任务就是指消息队列中等待被主线程执行的事件。每个宏任务执行的时, V8都会重新创建栈, 然后随着宏任务中函数调用, 栈也随着变化。最终, 当该宏任务执行结束时, 整个栈又会被清空, 接着主线程继续执行下一个宏任务。

微任务可以把微任务看成一个需要异步执行的函数, 执行时机是在主函数执行结束之后, 当前宏任务结束之前

主要从调用栈主线程消息队列这三者关联的角度来分析了微任务。

调用栈是一个种数据结构, 用来管理在主线程上执行的函数的调用关系。朱线在执行任务的过程中,如果函数的调用层次过深, 可能造成栈溢出的错误, 我们可以使用setTimeout来解决栈溢出的问题。

setTimeout的本质是将同步函数调用改成异步函数调用, 这里的异步调用是将回调函数封装成宏任务, 并将其添加进消息队列中, 然后主线程再按照一定规则循环地从消息队列中读取下一个宏任务。

消息队列中事件又被称为宏任务, 不过宏任务的时间颗粒度太粗了, 无法胜任一些精度和实时性要求高的场景, 而微任务可以在实时性和效率之间做有效的权衡

微任务之所以能实现这样的效果, 主要取决于微任务的执行时机,微任务其实是一个需要异步执行的函数, 执行时机是在主函数执行结束之后、当前宏任务结束之前

因为微任务依然是在当前的任务中执行的, 所以如果在微任务中循环触发新的微任务, 那么将导致消息队列中的其他任务没有机会被执行(导致死机)。

async/await

回调地狱问题

callback模式的异步编程模型需要实现大量的回调函数, 大量的回调函数会打乱代码的正常逻辑, 使得代码变得不线性, 不易阅读, 这就是我们所说的回调地狱问题

Promise

使用promise能很好地解决回调地狱的问题, 我们可以按照线性的思路来编写代码, 这个过程是线性的, 非常符合人的直觉。

但是这是方式充满了Promise的then()方法, 如果处理流程比较复杂的话, 那么整段代码将充斥着大量的then, 语义化不明显, 代码不能很好地表示执行流程。

生成器

我们想要通过线性的方式来编写异步代码, 要实现这个理想, 最关键的是要能实现函数暂停和恢复执行的功能。 而生成器就可以实现暂停和恢复。 我们可以在生成器中使用同步代码的逻辑来异步代码(实现该逻辑的核心是协程), 但是在生成器之外, 我们还需要一个触发器来驱动生成器的执行, 因此这依然不是我们最终想要的方案。

async/await

async是一个可以暂停和恢复执行的函数, 我们会在async函数内部使用await来暂停函数的执行, await等待的是一个Promise对象, 如果Promise的状态变成resolve或者reject, 那么async函数会恢复执行。因此, 使用async/await可以实现以同步的方式编写异步代码这一目标