1.运行时环境
宿主环境
- 浏览器
- 消息循环系统
- 全局变量
- window
- dom
- webAPI
- 堆栈
- node
- 宿主是细胞,v8是病毒
- 提供给运行时的各种资源
v8环境
- 实现ECMAScript标准的
- 标准对象
- Function
- Object
- Array
- String
- 标准函数
- Math
- 标准对象
- 垃圾回收
- 协程
执行流程
- 打开一个tab页面
- 创建一个渲染进程
- 初始化v8
- 宿主初始化堆栈
- 栈
- 线性结构,连续空间
- 存放东西
- 原生类型
- 引用对象地址
- 函数执行状态
- this
- 每个函数执行完都会自动销毁
- 堆
- 树形结构,非连续空间,可以存储更大的数据
- 执行过程的数据都会存储在 栈和堆中
- 栈
- 初始化全局作用域
- 通过代码结构,生成词法作用域scope,把关键的变量,函数声明的对象先保存到 堆栈里
- 例子
var x = 5 //全局作用域
{
let y = 2 //局部作用域
const z = 3 //局部作用域
}
//执行代码时候,依赖与当前的执行上下信息规则顺序查找
// 1. 词法环境
// 2. 语法环境,
// 3. 最后 this.outer指向外部对象 函数定义时的关系
- 初始化全局执行上下文
- 变量环境
- webAPI
- setTimeout
- XMLHttpRequest
- webAPI
- 词法环境
- this
- window
- 在V8中不会被销毁,一直存储在堆里
- 函数直接执行完就销毁。
- 执行代码如果包含 全局的赋值也会保存在全局执行上下文
- 变量环境
- 构建事件循环系统
- 优势 :正常进程执行完后就要销毁,会浪费很多已经初始化的资源
- 初始化主线程,宿主主线程 执行代码
- 通过一个死循环,一直读取任务并执行
2.机器代码
内存
- 每个地址都是唯一
- 一个内存地址可能对应很长的内存块
- 每段二进制代码都对应有内存地址
cpu
包含的寄存器
- PC寄存器
- 记录当前要执行的内存地址
- 寄存器组
- ecx
- 通用寄存器,用于对应内存端
- rbp寄存器
- rsp寄存器
- 代替直接访问内存,提高读写的效率
- 容量小但读写快
执行二进制
- c语言编译生成机器代码
- 机器代码反编译可得到 汇编代码
- 48 89 e5 movq %resp,%rbp
- 左边为机器代码16进制, 右边为汇编代码
执行流程
- 二进制代码加载到内存,同步把第一条内存地址写入PC寄存器
- cpu一行行执行,cpu生命钟
- 取出一条指令
- 根据PC寄存读取内存地址
- 同时把下一个内存地址更新到 PC寄存器
- 分析指令
- 识别不同的指令
- 取出一条指令
movl %ecx , -8(%rpb) //加载指令 内存数据加载cpu
movl -8(%rpb) , %ecx //存储指令 cpu数据存储到内存
addl %eax ,%ecx //更新指令 寄存器 eax 和 ecx 中的值传给 ALU,ALU在返回到CPU
jmp -33 <_main+0x36 //跳转指令 直接修改 PC寄存器,下次cpu执行时候就会按最新地址执行
// IO读写指令
outb %0 , %1 // 写数据
inb %0 , %1 // 读数据
pushq //数据压到栈顶
%ecx //属于cpu 的寄存器组
3. 执行指令
3. cpu执行一条指令
3.堆和栈
函数调用的特点
- 可以被调用
- 互相调用
- 数据隔离
- 函数作用域
- 执行完自动销毁
- 先进后出
- 最早调用拥有最长的生命时长
栈
- 满足函数调用的特点
- 连续空间
- 创建和销毁都很快
- 执行流程
- 入栈
- 变量会依次自底向上入栈
- 出栈
- 自顶往下 出栈
- 入栈
- 寄存器
- esp
- 永远指向当前栈顶的指针
- 实现方法跳转,恢复现场
- epb
- 记录上一次调用函数是的栈顶指针
- esp
例子
int add(num1,num2){
int x = num1;
int y = num2;
int ret = x + y;
return ret;
}
int main()
{
int x = 5;
int y = 6;
x = 100;
int z = add(x+y);
return z;
}
// 栈执行顺序
// 0000f101 add
// 0000f81 main栈帧 0000f71
// 0000f71 main
- 当main要调用 add的时候,main的栈顶插一条到内存栈顶
- 把新插的数据保存到epb
- 当main要调用 add的时候,eps上移到0000f101
- 当add方法结束的时候,把栈顶指针esp地址改为 之前保存的epb,即0000f81
- 同时epb 的写入0000f81对应的0000f71
- 栈帧
- 对应为执行完成的函数
- 保留函数返回地址和局部变量
堆
- 不连续但支持更大的空间
- 分配一块区域,并返回引用地址,地址保存的栈中
4.惰性解析
js不一次性编译的原因
- 如果js文件太大,需要较长时间去编译
- 执行完的机器或中间代码会放到内存里,占用内存空间
惰性解析
- 遇到函数声明,跳过内部代码的编译
- 只生成顶层代码的AST和字节码
例子
function foo(a,b) {
var d = 100
var f = 10
return d + f + a + b;
}
var a = 1
var c = 4
foo(1, 5)
// 这里的只是将 声明foo函数转为函数对象,内部的不做处理
// 只有当执行foo的时候,才编译foo方法
闭包
大三特征
- 函数内运行定义函数
- 函数访问父函数的变量
- 使用词法作用域和作用域链访问变量
- 词法作用域是v8在扫描代码的时候,根据函数的位置已经确定的
- 函数可以做返回参数
例子
function foo() {
var d = 20
return function inner(a, b) {
const c = a + b + d
return c
}
}
const f = foo()
预解析器
提前扫描代码,只处理当前一个层级
- 检查代码语法
- 检查函数内部是否引用外部变量,会把变量复制到堆closeure里
5.字节码
早期V8
编译器
- 基线编译器:把代码直接编译成没有优化过的机器代码
- 优化编译器:将一部分频繁重复执行的代码编译成优化过的机器代码
流程
- 代码转化成AST
- 使用基线编译器编译成没有优化过的机器代码
- 执行机器代码
- 当遇到重复执行的代码,会把代码标记为HOT,并会触发优化编译器,把,把代码编译成优化过的机器代码
- 执行优化的机器代码
- 如果优化代码变更了,会还原为基线编译器进行编译和执行
机器代码缓存
- 内存缓存
- 每次执行,通过源文件字符串做索引缓存到内存里,下次直接复用
- 硬盘缓存
- 关闭浏览器下次也能复用
- 缺点
- 机器代码文件过大,占用内存过高,
- 所以早期只缓存了顶层的代码,但是自执行函数产生的闭包数据,无法被执行而提前缓存
最新V8
使用字节码的优势
- 解决跨平台不同cpu指令要需要生成不同二进制代码的复杂度,统一一个字节码入口,再针对不同平台编写命令即可
- 文件体积更小,占用内存也小
- 启动速度更快,因为生成字节码速度比较快
生成字节码流程
例子
function add(x, y) {
var z = x+y
return z
}
console.log(add(1, 2))
- 代码解析parser
- 参数的声明 (PARAMS)
- 变量声明节点 (DECLS)
- x+y 的表达式节点
- RETURN 节点
- 生成AST和作用域信息
- 执行阶段,作用域中的变量会指向堆和栈中相应的数据
- 通过Ignition 的解释器中的BytecodeGenerator(字节码生成器) 把AST 转化成 字节码
- 通过 Ignition解释器,解释执行字节码,同事配合当前的作用域信息
解释器
定义
模拟物理机器来执行字节码的,比如可以实现如取指令、解析指令、执行指令、存储数据等
类型
- 基于栈
- 使用栈来处理中间数据,参数,运算结果,变量等
- 也包含少量寄存器
- 使用语言
- java虚拟机
- .net虚拟机
- 早期的V8虚拟机
- 基于寄存器
- 使用寄存器来处理中间数据,参数,运算结果,变量等
- 也包含少量堆栈
- 使用语言
- 现代的js虚拟器
- 差异
- 指令集
字节码执行
例子
function add(x, y) {
var z = x+y
return z
}
console.log(add(1, 2))
字节码使用4个模块
- 内存中存储字节码
- 通用寄存器
- PC寄存器
- 累加器
- 寄存器r1,r,2,r3,r4
- 栈,当前栈顶指针,参数
- a1,a2,a3,a4
- 堆,闭包,对象信息
字节码指令
- Ldar
- LoaD Accumulator from Register
- 寄存器保存到累加器
- Star
- Store Accumulator Register
- 累加器中的值保存到某个寄存器
- Add
- Add a1 [0]
- 从 a1 寄存器加载值并将其与累加器中的值相加
- [0] 为反馈向量的信息
- LdaSmi
- LdaSmi [5]
- 加载小整数5到累加器上
- Return
- 结束函数执行
- 返回控制权给主程序
- 返回累加器的值
- StackCheck
- 检查内存栈是否溢出
6.隐藏类
静态语言
- 对象结构是提前确定的
- 使用相对的偏移量快速索引属性
//c++
strut Obj {
int a;
int b;
}
Obj obj;
obj.a = 1;
obj.b = 2;
js是动态语言
- 对象的属性可以随时新增删除,无法提前确定
var obj = {}
obj.x = 1
obj.y = 2
- 可以通过hidden Class 隐藏类模拟实现
- 通过在每个对象中新增map属性实现
隐藏类Hidden Class
特点
- 包含所有属性
- 各个类型相对于对象的偏移量
条件
对象的属性创建后,不能新增属性,删除属性
访问属性流程
- 通过先查找map隐藏类
- 根据通过属性找到对应的偏移值对应的偏移量,快速定位出属性
- 通过原对象的地址 + 偏移值 定位出属性值
例子
// 属性 - 偏移
// obj map -> x - 1
// y - 2
//调试
let obj= {a:1,b:2};
%DebugPrint(obj);
d8 --allow-natives-syntax test.js
//相同的对象实例会共用同一个隐藏类
let o1= {a:1,b:2};
let o2= {a:3,b:4};
//属性顺序不一致也会创建不同的map
let o1 = {a:1,b:2}
let o1 = {b:2,a:1}
//构建变化将会重新构建map
7.内联缓存
定义
- Inline Cache
- 当一个函数引用的对象属性被多次使用,v8会将其缓存,下次直接使用缓存提高加载速度
原理
- 在函数执行过程中,把每个调用点都保存在反馈向量表里,第二次再执行的,使用上一次缓存好的信息直接使用
- 一个函数对应一个反馈向量表
例子
function fun(o){
return o.a
}
var obj = {a:1}
fun(obj)
//这里的字节码
StackCheck
LdaNamedProperty a0, [0], [0]
Return
//插入一条 LOAD 00001 offset 5 的反馈向量数据
类型
- 单态
- 1个隐藏类
- state:MONO
- 直接存值
- 1个隐藏类
- 多态
- 2-4个隐藏类
- state:PLOY
- 线性存储
- 2-4个隐藏类
- 超态
- 4个以上
- state:MAGA
- 字典存储
- 4个以上
反馈向量表
- 参考: www.processon.com/diagraming/…
- feedback vector
多台/超态 执行逻辑
- 当方法使用的对象值变化了,会在同一个反馈向量插入多条map和offset信息,类型变为PLOY或者 MAGA
- 每次会比较传入对象的map 和 反馈向量的map是否一致,再取对应的offset