深入V8引擎
JavaScript代码是如何执行的
◼ JavaScript代码下载好之后,是如何一步步被执行的呢?
◼ 我们知道,浏览器内核是由两部分组成的,以webkit为例:
- WebCore:负责HTML解析、布局、渲染等等相关的工作;
- JavaScriptCore:解析、执行JavaScript代码;
V8引擎的执行原理
◼ 我们来看一下官方对V8引擎的定义:
- V8是用C ++编写的Google开源高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等。
- 它实现ECMAScript和WebAssembly,并在Windows 7或更高版本,macOS 10.12+和使用x64,IA-32,ARM或MIPS处理 器的Linux系统上运行。
- V8可以独立运行,也可以嵌入到任何C ++应用程序中。
V8引擎的架构
◼ V8引擎本身的源码非常复杂,大概有超过100w行C++代码,通过了解它的架构,我们可以知道它是如何对JavaScript执行的:
◼ Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;
- 如果函数没有被调用,那么是不会被转换成AST的;
- Parse的V8官方文档:https://v8.dev/blog/scanner
◼ Ignition是一个解释器,会将AST转换成ByteCode(字节码)
- 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
- 如果函数只调用一次,Ignition会解释执行ByteCode;
- Ignition的V8官方文档:https://v8.dev/blog/ignition-interpreter
◼ TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;
- 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
- 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执 行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
TurboFan的V8官方文档:https://v8.dev/blog/turbofan-jit
V8引擎的解析图
JavaScript代码执行过程
◼ 假如我们有下面一段代码,它在JavaScript中是如何被执行的呢?
初始化全局对象
◼ js引擎会在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)
该对象 所有的作用域(scope)都可以访问;
- 里面会包含Date、Array、String、Number、setTimeout、setInterval等等;
- 其中还有一个window属性指向自己;
JS执行上下文
◼ js引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈。
◼ 那么现在它要执行谁呢?执行的是全局的代码块:
- 全局的代码块为了执行会构建一个 Global Execution Context(GEC);
- GEC会 被放入到ECS中 执行;
◼ GEC被放入到ECS中里面包含两部分内容:
- 第一部分:在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会 赋值; ✓ 这个过程也称之为变量的作用域提升(hoisting)
- 第二部分:在代码执行中,对变量赋值,或者执行其他的函数;
认识VO对象(Variable Object)
◼ 每一个执行上下文会关联一个VO(Variable Object,变量对象),变量和函数声明会被添加到这个VO对象中。
◼ 当全局代码被执行的时候,VO就是GO对象了
全局代码执行过程
全局代码执行过程(执行前)
全局代码执行过程(执行后)
函数代码执行过程
◼ 在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称FEC), 并且压入到EC Stack中。
◼ 因为每个执行上下文都会关联一个VO,那么函数执行上下文关联的VO是什么呢?
- 当进入一个函数执行上下文时,会创建一个AO对象(Activation Object);
- 这个AO对象会使用arguments作为初始化,并且初始值是传入的参数;
- 这个AO对象会作为执行上下文的VO来存放变量的初始化;
函数执行过程(执行前)
函数执行过程(执行后)
作用域和作用域链
◼ 当进入到一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)
- 作用域链是一个对象列表,用于变量标识符的求值;
- 当进入一个执行上下文时,这个作用域链被创建,并且根据代码类型,添加一系列的对象;
作用域提升面试题
作业与总结
一. 整理JavaScript的代码的执行流程
- 首先在执行前会现在堆内存中开辟一块空间(GO) 存放一些初始的值 如Number String等等
- 还有代码中定义的一些变量 函数(在parser转成AST树的过程中存放在GO中的 )并没有赋值
- 同时在执行代码时在执行上下文栈(ECS)中存放一个全局执行上下文(GEC) 用于执行代码
- GO中对应的函数 也会在堆内存中开辟出空间 为 Function Object 初始一些数据(name length scope chain等)
- 开始执行代码
- 每个EC中有着三个重要的内容(VO scope chain 以及this)
- VO指向对应的作用域(全局作用域(GO) 函数作用域(AO))
-
二. 说说你对GO/AO/VO的理解以及作用域和作用域链的理解
GO
Global Object JS代码在执行前会现在堆内存中创建一个全局对象(GO)
- 用于存放一些定义好的变量方法等包含Date Array String Number setTimeout等
- 同时有一个window属性指向自己
- 同时在语法分析转成AST的过程中也会将一些变量 函数 存放在GO中 只是变量的初始值为undefined
AO
- 函数在执行前会先在堆内存中创建一个AO(Activation Object)对象 里面存放这arguments 对应函数的形参 以及在函数中定义的变量 初始值为undefined
VO
- Variable Object 在执行函数时 会在执行上下文栈(ECS)中进入一个函数执行上下文(FEC)其中有三个核心 核心之一是VO 指向的是该函数在内存中解析时创建的AO 而在全局执行上下文中指向的是GO
作用域,作用域链
- 当进入到一个执行上下文时 执行上下文会关联一个作用域链
通常作用域链在解析时就被确定 因此 作用域链域函数的定义位置有关 而与它的调用位置无关
三. 说说V8引擎的内存管理以及垃圾回收器
内存管理
JavaScript的内存管理是自动的
- 关于原始数据类型 直接在栈内存中分配
- 关于复杂数据类型 在堆内存中分配
垃圾回收(GC)
- 因为内存大小是有限的 所以在内存不需要的时候 需要进行释放 用于腾出空间
- GC对于内存管理有着对应的算法
- 常见的算法
- 引用计数(Reference Count)
- 当一个对象有引用指向它时 对应的引用计数+1
- 当没有对象指向它时 则为0 此时进行回收
- 但是有一个严重的问题 - 会产生循环引用
- 标记清除(Mark-Sweep)
- 核心思路: 可达性
- 有一个根对象 从该对象出发 开始引用到所用到的对象 对于根对象没有引用到的对象 认为是不可用的对象
- 对于不可用的对象 则进行回收
- 该算法有效的解决了循环引用的问题
- 目前V8引擎采用的就是该算法
- 引用计数(Reference Count)
- V8引擎为了优化 在采用标记清除的过程中也引用了其他的算法
- 标记整理
- 和标记清除相似 不同的是回收时 会将保留下来的存储对象整合到连续的内存空间 避免内存碎片化
- 分代收集(Generational Collection)
- 将内存中的对象分为两组 新的空间 旧的空间
- 对于长期存活的对象 会将该对象从新空间移到旧空间中 同时GC检查次数减少
- 将新空间分为from和to 对象的GC查找之后从from移动到to空间中 然后to变为from from变为to 循环几次 对于依然存在的对象 移动到旧空间中
- 增量收集(Increment Collection)
- 如果存在许多对象 则GC试图一次性遍历所有的对象 可能会对性能造成一定的影响
- 所以引擎试图将垃圾收集工作分成几部分 然后这几部分逐一处理 这样会造成微小的延迟 而不是很大的延迟
- 闲时收集(IdIe-time Collection)
- GC只会在CPU空闲的时候运行 减少可能对代码执行造成的影响
- 标记整理