这是《图解 Google V8》第二篇/共三篇:编译流水线
学习下来最大的收获有两点:
V8
如何提升JavaScript
执行速度- 早期缓存机器码,之后重构为缓存字节码
- 在
JavaScript
中访问一个属性时,V8
做了哪些优化- 隐藏类
- 内联缓存
特别是第二点,让我看到了使用 TypeScript
的好处,动态语言存在的问题,静态语言都可以解决
09 | 运行时环境:运行 JavaScript 代码的基石
运行时环境包括:堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统
宿主
浏览器为 V8
提供基础的消息循环系统、全局变量、Web API
V8
的核心是实现 ECMAScript
标准,比如:Object
、Function
、String
,还提供垃圾回收、协程等
构造数据存储空间:堆空间和栈空间
在 Chrome
中,只要打开一个渲染进程,渲染进程便会初始化 V8
,同时初始化堆空间和栈空间。
栈是内存中连续的一块空间,采用“先进后出”的策略。
在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用的对象的地址、函数的执行状态、this
值等都会存在栈上
当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,比如:函数、数组,在浏览器中还有 window
、document
等
全局执行上下文和全局作用域
执行上下文中主要包含三部分,变量环境、词法环境和 this
关键字
全局执行上下文在 V8
的生存周期内是不会被销毁的,它会一直保存在堆中
在 ES6
中,同一个全局执行上下文中,都能存在多个作用域:
var x = 5;
{
let y = 2;
const z = 3;
}
构造事件循环系统
V8
需要一个主线程,用来执行 JavaScript
和执行垃圾回收等工作
V8
是寄生在宿主环境中的,V8
所执行的代码都是在宿主的主线程上执行的
如果主线程正在执行一个任务,这时候又来了一个新任务,把新任务放到消息队列中,等待当前任务执行结束后,再从消息队列中取出正在排列的任务,执行完这个任务之后,再重复这个过程
10 | 机器代码:二进制机器码究竟是如何被 CPU 执行的?
将汇编语言转换为机器语言的过程称为“汇编”;反之,机器语言转化为汇编语言的过程称为“反汇编”
在程序执行之前,需要将程序装进内存中(内存中的每个存储空间都有独一无二的地址)
二进制代码被装载进内存后,CPU
便可以从内存中取出一条指令,然后分析该指令,最后执行该指令。
把取出指令、分析指令、执行指令这三个过程称为一个 CPU
时钟周期
CPU
中有一个 PC
寄存器,它保存了将要执行的指令地址,到下一个时钟周期时,CPU
便会根据 PC
寄存器中的地址,从内存中取出指令。
PC
寄存器中的指令取出来之后,系统要做两件事:
- 将下一条指令的地址更新到
PC
寄存器中 - 分析该指令,识别出不同类型的指令,以及各种获取操作数的方法
因为 CPU
访问内存的速度很慢,所以需要通用寄存器,用来存放 CPU
中数据的(通用寄存器容量小,读写速度快,内存容量大,读写速度慢。)
通用寄存器通常用来存放数据或者内存中某块数据的地址,我们把这个地址又称为指针
ebp
寄存器通常是用来存放栈帧指针esp
寄存器用来存放栈顶指针PC
寄存器用来存放下一条要执行的指令
常用的指令类型:
- 加载指令:从内存中复制指定长度的内容到通用寄存器中,并覆盖寄存器中原来的内容
- 存储指令:和加载类型的指令相反,作用是将寄存器中的内容复制到内存中的某个位置,并覆盖掉内存中的这个位置上原来的内容
- 更新指令:作用是复制两个寄存器中的内容到
ALU
中 - 跳转指令:从指令本身抽取出一个字,这个字是下一条要执行的指令地址,并将该字复制到
PC
寄存器中,并覆盖掉PC
寄存器中原来的值
11 | 堆和栈:函数调用是如何影响到内存布局的?
函数有两个主要的特性:
- 可以被调用
- 具有作用域机制
所以:
- 函数调用者的生命周期比被调用者的长(后进),被调用者的生命周期先结束 (先出)
- 从函数资源分配和回收角度来看,
- 被调用函数的资源分配晚于调用函数 (后进),
- 被调用函数资源的释放先于调用函数 (先出)
栈的状态从 add
中恢复到 main
函数的上次执行时的状态,这个过程称为恢复现场
function main() {
add();
}
function add(num1, num2) {
return num1 + num2;
}
怎么恢复 main
函数的执行现场呢:
- 在
esp
寄存器中保存一个永远指向当前栈顶的指针- 告诉你往哪个位置添加新元素
ebp
寄存器,保存当前函数的起始位置(也叫栈帧指针)- 告诉
CPU
移动到这个地址
- 告诉
栈帧:每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。
12 | 延迟解析:V8 是如何实现闭包的?
在编译阶段,V8
不会对所有代码进行编译,采用一种“惰性编译”或者“惰性解析”,也就是说 V8
默认不会对函数内部的代码进行编译,只有当函数被执行前,才会进行编译。
闭包的问题指的是:由于子函数使用到了父函数的变量,导致父函数在执行完成以后,它内部被子函数引用的变量无法及时在内存中被释放。
而闭包问题产生的根本原因是 JavaScript
中本身的特性:
- 可以在函数内部定义新的函数
- 内部函数可以访问父函数的变量
- 函数是一等公民,所以函数可以作为返回值
既然由于 JavaScript
的这种特性就会出现闭包的问题,那么就需要解决闭包问题,“预编译“ 或者 “预解析” 就出现了
预编译具体方案: 在编译阶段,V8
会对函数函数进行预解析
- 判断函数内语法是否正确
- 子函数是否引用父函数中的变量,如果有的话,将这个变量复制一份到堆中,同时子函数本身也是一个对象,也会被放到堆中
- 父函数执行完成后,内存会被释放
- 子函数在执行时,依然可以从堆内存中访问复制过来的变量
13 | 字节码(一):V8 为什么又重新引入字节码?
在 V8
中,字节码有两个作用:
- 解释器可以直接执行字节码
- 优化编译器可以将字节码编译为机器码,然后再执行机器码
早期的 V8
V8
团队认为“先生成字节码再执行字节码”,会牺牲代码的执行速度,便直接将 JavaScript
代码编译成机器码
使用了两个编译器:
- 基线编译器:将
JavaScript
代码编译为没有优化过的机器码 - 优化编译器:将一些热点代码(执行频繁的代码)优化为执行效率更高的机器码
执行 JavaScript
:
- 将
JavaScript
代码转换为抽象语法树(AST
) - 基线编译器将
AST
编译为未优化过的机器码,然后V8
执行这些未优化过的机器代码 - 在执行未优化的机器代码时,将一些热点代码优化为执行效率更高的机器代码,然后执行优化过的机器码
- 如果优化过的机器码不满足当前代码的执行,
V8
会进行反优化操作
问题
1. 机器码缓存
V8
执行一段 JavaScript
代码,编译时间和执行时间差不多
如果再 JavaScript
没有改变的情况下,每次都编译这段代码,就会浪费 CPU
资源
所以 V8
引入机器码缓存:
- 将源代码编译成机器码后,放在内存中(内存缓存)
- 下次再执行这段代码,就先去内存中查找是否存在这段代码的机器码,有的话就执行这段机器码
- 将编译后的机器码存入硬盘中,关闭浏览器后,下次重新打开,可以直接用编译好的机器码
时间缩短了 20% ~ 40%
这是用空间换时间的策略,在移动端非常吃内存