Published on

V8相关(一)

Authors
  • avatar
    Name
    Et cetera
    Twitter

V8 引擎:其实就是 JavaScript 的虚拟机

虚拟机:通过模拟实际计算机的各种功能来实现代码的执行,如模拟实际计算机的 CPU、堆栈、寄存器等,虚拟机还具有它自己的一套指令系统

  • V8 之前所有的 JavaScript 虚拟机都是采用解释执行的方式,所以那时解析 JavaScript 速度过慢
  • V8 则率先引入了即时编译(JIT)的双轮驱动的设计:混合编译执行解释执行这两种手段
  • V8 也是早于其他虚拟机引入了惰性编译内联缓存隐藏类等机制,进一步优化了 JavaScript 代码的编译执行效率

什么是 V8(宏观)

V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器Node.js 中,其核心功能是执行易于人类理解的 JavaScript 代码

为什么需要先编译后执行 JavaScript 这一高级语言

为了能够完成复杂的任务,工程师们为 CPU 提供了一大堆指令,来实现各种功能,我们就把这一大堆指令称为指令集(Instructions),也就是机器语言

CPU 只能识别二进制的指令,但是对程序员来说,二进制代码难以阅读和记忆,于是我们又将二进制指令集转换为人类可以识别和记忆的符号,这就是汇编指令集,如下:

1000100111011000 机器指令
mov ax,bx 汇编指令
  • 通常有两种方式可以让处理器直接执行高级语言的代码:

    • 第一种是解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果.具体流程如下图所示:

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

JavaScript 流行的虚拟机:苹果公司在 Safari 中就是用 JavaScriptCore 虚拟机,Firefox 使用了 TraceMonkey 虚拟机,而 Chrome 则使用了 V8 虚拟机

V8 编译完整过程如下:

上图可以看到,在 V8 启动执行 JavaScript 之前,它还需要准备执行 JavaScript 时所需要的一些基础环境,这些基础环境包括了 堆空间 栈空间 全局执行上下文 全局作用域 消息循环系统 内置函数 等,这些内容都是在执行 JavaScript 过程中需要使用到的

基础环境准备好之后,接下来就可以向 V8 提交要执行的 JavaScript 代码了。 首先 V8 会接收到要执行的 JavaScript 源代码,不过这对 V8 来说只是一堆字符串,V8 并不能直接理解这段字符串的含义,它需要结构化这段字符串。结构化,是指信息经过分析后可分解成多个互相关联的组成部分,各组成部分间有明确的层次结构,方便使用和维护,并有一定的操作规范。 V8 源代码的结构化之后,就生成了抽象语法树 (AST),我们称为 AST,AST 是便于 V8 理解的结构.同时,V8 还会生成相关的作用域,作用域中存放相关变量

  • 总结为:
    1. 初始化基础环境
    2. 解析源码生成 AST 和作用域
    3. 依据 AST 和作用域生成字节码
    4. 解释执行字节码
    5. 监听热点代码
    6. 优化热点代码为二进制的机器代码 7.反优化生成的二进制机器代码

JavaScript 是基于对象实现的语言(但是并不是一门面向对象语言,因为 JavaScript 实现多态很复杂)

  • 如图可以看出 JS 中对象属性类型有:
    • 原始类型 (primitive),所谓的原始类的数据,是指值本身无法被改变,比如 JavaScript 中的字符串就是原始类型,如果你修改了 JavaScript 中字符串的值,那么 V8 会返回给你一个新的字符串,原始字符串并没有被改变,我们称这些类型的值为“原始值” JavaScript 中的原始值主要包括 nullundefinedbooleannumberstringbigintsymbol 这七种
    • 对象类型 (Object),对象的属性值也可以是另外一个对象,比如上图中的 info 属性值就是一个对象
    • 函数类型 (Function),如果对象中的属性值是函数,那么我们把这个属性称为方法,所以我们又说对象具备属性和方法,那么上图中的 showinfo 就是 person 对象的一个方法

函数的本质

在 JavaScript 中,函数是一种特殊的对象,它和对象一样可以拥有属性和值,但是函数和普通对象不同的是,函数可以被调用

上图可以看出函数除了可以拥有常用类型的属性值之外,还拥有两个隐藏属性,分别是 name 属性和 code 属性,匿名函数对象的默认的 name 属性值就是 anonymous

函数是一等公民

官方一点解释这句话:如果某个编程语言的函数可以和它的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民

但是由于函数的可被调用的特性,使得实现函数的可赋值、可传参和可作为返回值等特性变得有一点麻烦.为什么?

在执行 JavaScript 函数的过程中,为了实现变量的查找,V8 会为其维护一个作用域链,如果函数中使用了某个变量,但是在函数内部又没有定义该变量,那么函数就会沿着作用域链去外部的作用域中查找该变量

function foo() {
  var num = 1
  function bar() {
    num++
    console.log(num)
  }

  return bar
}

var myBar = foo()
myBar()

如上代码中,我们在 foo 函数中定义了一个新的 bar 函数,并且 bar 函数引用了 foo 函数中的变量 number,当调用 foo 函数的时候,它会返回 bar 函数

那么所谓的函数是一等公民就体现在,如果要返回函数 bar 给外部,那么即便 foo 函数执行结束了,其内部定义的 number 变量也不能被销毁,因为 bar 函数依然引用了该变量

我们也把这种将外部变量和和函数绑定起来的技术称为闭包

简单总结函数:

  • 因为函数是一种特殊的对象,所以先介绍了 JavaScript 中的对象,JavaScript 中的对象就是由一组一组属性和值组成的集合,既然函数也是对象,那么函数也是由一组组值和属性组成的集合,最后使用了一段代码证明了这点
  • 因为函数作为一个对象,是可以被赋值、作为参数,还可以作为返回值的,那么如果一个函数返回了另外一个函数,那么就应该返回该函数所有相关的内容

函数作为一等公民的特性:

  • 函数作为一个对象,它有自己的属性和值,所以函数关联了基础的属性和值
  • 函数之所以成为特殊的对象,这个特殊的地方是函数可以“被调用”,所以一个函数被调用时,它还需要关联相关的执行上下文

JavaScript 中对象存储

JavaScript 中的对象是由一组组属性和值的集合,从 JavaScript 语言的角度来看,JavaScript 对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值

然而在 V8 实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典非线性的数据结构,查询效率会低于线性的数据结构,V8 为了提升存储和查找效率,采用了一套复杂的存储策略

常规属性 (properties) 和排序属性 (element)

function Foo() {
  this[100] = 'test-100'
  this[1] = 'test-1'
  this['B'] = 'bar-B'
  this[50] = 'test-50'
  this[9] = 'test-9'
  this[8] = 'test-8'
  this[3] = 'test-3'
  this[5] = 'test-5'
  this['A'] = 'bar-A'
  this['C'] = 'bar-C'
}
var bar = new Foo()
for (key in bar) {
  console.log(`index:${key} value:${bar[key]}`)
}
// 打印结果
// index:1 value:test-1
// index:3 value:test-3
// index:5 value:test-5
// index:8 value:test-8
// index:9 value:test-9
// index:50 value:test-50
// index:100 value:test-100
// index:B value:bar-B
// index:A value:bar-A
// index:C value:bar-C

观察上面代码打印出来的数据,我们发现打印出来的属性顺序并不是我们设置的顺序,我们设置属性的时候是乱序设置的,比如开始先设置 100,然后有设置了 1,但是输出的内容却非常规律,总的来说体现在以下两点

  • 设置的数字属性被最先打印出来了,并且按照数字大小的顺序打印的
  • 设置的字符串属性依然是按照之前的设置顺序打印的,比如我们是按照 B、A、C 的顺序设置的,打印出来依然是这个顺序

之所以出现这样的结果,是因为在 ECMAScript 规范 中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。

在这里我们把对象中的数字属性称为排序属性,在 V8 中被称为 elements,字符串属性就被称为常规属性,在 V8 中被称为 properties

在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性常规属性,具体结构如下图所示

通过上图我们可以发现,bar 对象包含了两个隐藏属性:elements 属性properties 属性,elements 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性,properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存了常规属性

分解成这两种线性数据结构之后如果执行索引操作那么 V8 会先从 elements 属性中按照顺序读取所有的元素然后再在 properties 属性中读取所有的元素这样就完成一次索引操作

快属性和慢属性

将不同的属性分别保存到 elements 属性properties 属性中无疑简化了程序的复杂度但是在查找元素时却多了一步操作比如执行 bar.B 这个语句来查找 B 的属性值那么在 V8 会先查找出 properties 属性所指向的对象 properties然后再在 properties 对象中查找 B 属性这种方式在查找过程中增加了一步操作因此会影响到元素的查找效率

基于这个原因 V8 采取了一个权衡的策略以加快查找属性的效率这个策略是将部分常规属性直接存储到对象本身我们把这称为对象内属性 (in-object properties).对象在内存中的展现形式你可以参看下图:

采用对象内属性之后常规属性就被保存到 bar 对象本身了这样当再次使用 bar.B 来查找 B 的属性值时 V8 就可以直接从 bar 对象本身去获取该值就可以了这种方式减少查找属性值的步骤增加了查找效率

不过对象内属性的数量是固定的默认是 10 个如果添加的属性超出了对象分配的空间则它们将被保存在常规属性存储中.虽然属性存储多了一层间接层但可以自由地扩容

我们将保存在线性数据结构中的属性称之为快属性因为线性数据结构中只需要通过索引即可以访问到属性虽然访问线性结构的速度快但是如果从线性结构中添加或者删除大量的属性时则执行效率会非常低这主要因为会产生大量时间和内存开销

因此如果一个对象的属性过多时 V8 为就会采取另外一种存储策略那就是慢属性策略但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的而是直接保存在属性字典中.这样虽然降低了查找速度,但是却提升了修改对象的属性的速度

对于使用 delete 删除对象属性的看法

为什么不建议使用 delete 删除对象属性

首先 MDN 文档中是这样描述 delete 的: delete 操作符用于删除对象的某个属性;如果没有指向这个属性的引用,那它最终会被释放。

举个 🌰:

function Creator() {
  this.prop = 'prop'
}
creator.prototype.prop = 'prop'
let creator = new Creator()
delete creator.prop
console.log(creator.prop) // 仍然会通过原型链取到 prop 值,从而打印出来

同时因为 V8 引擎中 elemens(key 为数字)properties(key 为字符串) 的不同存储方式,properties 中默认十个(内属性)仍然在对象内存下存放,然后数量少由线性排列存放在 properties 属性下,过多占用则以字典形式存储

快属性:elements 和内属性 慢属性:properties

通过开头引用 MDN 描述,可以预想,若是对于在线性表存放的对象属性,使用 delete 操作后,不会释放该内存,从而会破坏线性表结构.影响查询性能

建议使用赋值为 undefined 删除对象属性

不仅可以做到同使用 delete 操作符下的同样结果,还能阻止通过原型链查找