《图解 Google V8》编译流水篇——学习笔记(二)

这是《图解 Google V8》第二篇/共三篇:编译流水线

学习下来最大的收获有两点:

  1. V8 如何提升 JavaScript 执行速度
    • 早期缓存机器码,之后重构为缓存字节码
  2. JavaScript 中访问一个属性时,V8 做了哪些优化
    • 隐藏类
    • 内联缓存

特别是第二点,让我看到了使用 TypeScript 的好处,动态语言存在的问题,静态语言都可以解决

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

运行时环境包括:堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统

宿主

浏览器为 V8 提供基础的消息循环系统、全局变量、Web API

V8 的核心是实现 ECMAScript 标准,比如:ObjectFunctionString,还提供垃圾回收、协程等

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

Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间。

栈是内存中连续的一块空间,采用“先进后出”的策略。

在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用的对象的地址、函数的执行状态、this 值等都会存在栈上

当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。

堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,比如:函数、数组,在浏览器中还有 windowdocument

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

执行上下文中主要包含三部分,变量环境、词法环境和 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 寄存器中的指令取出来之后,系统要做两件事:

  1. 将下一条指令的地址更新到 PC 寄存器中
  2. 分析该指令,识别出不同类型的指令,以及各种获取操作数的方法

因为 CPU 访问内存的速度很慢,所以需要通用寄存器,用来存放 CPU 中数据的(通用寄存器容量小,读写速度快,内存容量大,读写速度慢。)

通用寄存器通常用来存放数据或者内存中某块数据的地址,我们把这个地址又称为指针

  • ebp 寄存器通常是用来存放栈帧指针
  • esp 寄存器用来存放栈顶指针
  • PC 寄存器用来存放下一条要执行的指令

常用的指令类型:

  1. 加载指令:从内存中复制指定长度的内容到通用寄存器中,并覆盖寄存器中原来的内容
  2. 存储指令:和加载类型的指令相反,作用是将寄存器中的内容复制到内存中的某个位置,并覆盖掉内存中的这个位置上原来的内容
  3. 更新指令:作用是复制两个寄存器中的内容到 ALU
  4. 跳转指令:从指令本身抽取出一个字,这个字是下一条要执行的指令地址,并将该字复制到 PC 寄存器中,并覆盖掉 PC 寄存器中原来的值

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

函数有两个主要的特性:

  1. 可以被调用
  2. 具有作用域机制

所以:

  • 函数调用者的生命周期比被调用者的长(后进),被调用者的生命周期先结束 (先出)
  • 从函数资源分配和回收角度来看,
    • 被调用函数的资源分配晚于调用函数 (后进),
    • 被调用函数资源的释放先于调用函数 (先出)

栈的状态从 add 中恢复到 main 函数的上次执行时的状态,这个过程称为恢复现场

function main() {
   
  add();
}
function add(num1, num2) {
   
  return num1 + num2;
}

怎么恢复 main 函数的执行现场呢:

  1. esp 寄存器中保存一个永远指向当前栈顶的指针
    • 告诉你往哪个位置添加新元素
  2. ebp 寄存器,保存当前函数的起始位置(也叫栈帧指针
    • 告诉 CPU 移动到这个地址

栈帧:每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。

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

在编译阶段,V8 不会对所有代码进行编译,采用一种“惰性编译”或者“惰性解析”,也就是说 V8 默认不会对函数内部的代码进行编译,只有当函数被执行前,才会进行编译。

闭包的问题指的是:由于子函数使用到了父函数的变量,导致父函数在执行完成以后,它内部被子函数引用的变量无法及时在内存中被释放。

而闭包问题产生的根本原因是 JavaScript 中本身的特性:

  1. 可以在函数内部定义新的函数
  2. 内部函数可以访问父函数的变量
  3. 函数是一等公民,所以函数可以作为返回值

既然由于 JavaScript 的这种特性就会出现闭包的问题,那么就需要解决闭包问题,“预编译“ 或者 “预解析” 就出现了

预编译具体方案: 在编译阶段,V8 会对函数函数进行预解析

  1. 判断函数内语法是否正确
  2. 子函数是否引用父函数中的变量,如果有的话,将这个变量复制一份到堆中,同时子函数本身也是一个对象,也会被放到堆中
    • 父函数执行完成后,内存会被释放
    • 子函数在执行时,依然可以从堆内存中访问复制过来的变量

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

V8 中,字节码有两个作用:

  1. 解释器可以直接执行字节码
  2. 优化编译器可以将字节码编译为机器码,然后再执行机器码

早期的 V8

V8 团队认为“先生成字节码再执行字节码”,会牺牲代码的执行速度,便直接将 JavaScript 代码编译成机器码

使用了两个编译器:

  1. 基线编译器:将 JavaScript 代码编译为没有优化过的机器码
  2. 优化编译器:将一些热点代码(执行频繁的代码)优化为执行效率更高的机器码

执行 JavaScript

  1. JavaScript 代码转换为抽象语法树(AST
  2. 基线编译器将 AST 编译为未优化过的机器码,然后 V8 执行这些未优化过的机器代码
  3. 在执行未优化的机器代码时,将一些热点代码优化为执行效率更高的机器代码,然后执行优化过的机器码
  4. 如果优化过的机器码不满足当前代码的执行,V8 会进行反优化操作

问题

1. 机器码缓存

V8 执行一段 JavaScript 代码,编译时间和执行时间差不多

如果再 JavaScript 没有改变的情况下,每次都编译这段代码,就会浪费 CPU 资源

所以 V8 引入机器码缓存:

  1. 将源代码编译成机器码后,放在内存中(内存缓存)
  2. 下次再执行这段代码,就先去内存中查找是否存在这段代码的机器码,有的话就执行这段机器码
  3. 将编译后的机器码存入硬盘中,关闭浏览器后,下次重新打开,可以直接用编译好的机器码

时间缩短了 20% ~ 40%

这是用空间换时间的策略,在移动端非常吃内存

2. 惰性编译
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值