Google V8 剖析之javascript设计思想

对于经常跟浏览器打交道的web开发同学来说,V8 是一个再熟悉不过的存在,下边咱们一起相对深入地聊聊V8底层的javascript设计。

一、什么是V8?

V8 是 JavaScript 虚拟机的一种。我们可以简单地把 JavaScript 虚拟机理解成是一个翻译程序,将人类能够理解的编程语言 JavaScript,翻译成机器能够理解的机器语言.。如下图所示:
在这里插入图片描述

目前市面上有很多种 JavaScript 引擎,诸如 SpiderMonkey、V8、JavaScriptCore 等。而由谷歌开发的开源项目 V8 是当下使用最广泛的 JavaScript 虚拟机,这和它许多革命性的设计有很大关系。
在 V8 出现之前,所有的 JavaScript 虚拟机所采用的都是解释执行的方式,这是 JavaScript 执行速度过慢的一个主要原因。而 V8 率先引入了即时编译(JIT)的双轮驱动的设计,这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。
另外,V8 也是早于其他虚拟机引入了惰性编译、内联缓存、隐藏类等机制,进一步优化了 JavaScript 代码的编译执行效率。
可以说,V8的出现,将Javascript虚拟机技术推向了一个全新的高度。

二、V8是如何执行javascript代码的?

V8 执行 JavaScript 代码主要核心流程分为编译执行两步。首先需要将 JavaScript 代码转换为低级中间代码或者机器能够理解的机器代码,然后再执行转换后的代码并输出执行结果。
在这里插入图片描述

为什么需要先编译再执行?

因为计算机只能识别二进制指令,所以要让计算机执行一段高级语言通常有两种手段,第一种是将高级代码转换为二进制代码,再让计算机去执行;另外一种方式是在计算机安装一个解释器,并由解释器来解释执行。

  • 编译执行流程图:在这里插入图片描述
  • 解释执行流程图:
    在这里插入图片描述
    解释执行编译执行都有各自的优缺点,解释执行启动速度快,但是执行时速度慢,而编译执行启动速度慢,但是执行速度快。为了充分地利用解释执行和编译执行的优点,规避其缺点,V8 采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 就会采用优化编译器将其编译成执行效率更加高效的机器代码。
    V8 执行一段 JavaScript 代码所经历的主要流程为:
  • 初始化基础环境;
  • 解析源码生成 AST 和作用域;
  • 依据 AST 和作用域生成字节码;
  • 解释执行字节码;
  • 监听热点代码;
  • 优化热点代码为二进制的机器代码;
  • 反优化生成的二进制机器代码。

需要注意的是,V8 是一门动态语言,在运行过程中,某些被优化的结构可能会被 JavaScript 动态修改了,比如对象的结构和属性被动态修改了,这会导致之前被优化的代码失效,这时编译器需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。

三、函数即对象

和其他主流语言不一样的是,JavaScript 是一门基于对象 (Object-Based)的语言,可以说 JavaScript 中大部分的内容都是由对象构成的,如函数、数组,也可以说 JavaScript 是建立在对象之上的语言,但它却不是一门面向对象的语言 (Object-Oriented Programming Language),因为它不天生支持封装、继承、多态

那什么是 JavaScript 中的对象?

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

那V8 内部是怎么实现函数可调用特性的呢?

其实在 V8 内部会为函数对象添加了name 和 code 两个隐藏属性,如下图所示:
在这里插入图片描述

该函数对象的默认的 name 属性值就是 anonymous,表示该函数对象没有被设置名称。另外一个隐藏属性是 code 属性,其值表示函数代码,以字符串的形式存储在内存中。当执行到一个函数调用语句时,V8 便会从函数对象中取出 code 属性值,也就是函数代码,然后再解释执行这段函数代码。

四、对象属性访问速度提升策略

常规属性 (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';
}

const bar = new Foo();

for(let key in bar) {
  console.log(`index:${key}, value:${bar[key]}`);
}

执行结果为:
在这里插入图片描述

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

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

之所以出现这样的结果,是因为在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列
把对象中的数字属性称为排序属性,在 V8 中被称为elements,字符串属性就被称为常规属性,在 V8 中被称为properties
在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性,具体结构如下图所示:
在这里插入图片描述

通过上图我们可以发现,bar 对象包含了两个隐藏属性:elements 属性和 properties 属性,elements 属性指向了 elements 对象,会按照顺序存放排序属性,properties 属性则指向了 properties 对象,会按照创建时的顺序保存了常规属性。
分解成这两种线性数据结构之后,如果执行索引操作,那么 V8 会先从 elements 属性中按照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。

快属性和慢属性

将不同的属性分别保存到 elements 属性和 properties 属性中,简化了程序的复杂度,但在查找元素时,却多了一步操作,为解决这个原因,V8 采取了一个将部分常规属性直接存储到对象本身策略,我们把这称为对象内属性 (in-object properties),比如上边的bar对象采用对象内置属性之后:
在这里插入图片描述

这样子查找常规属性就可以直接访问bar对象本身,提升查找效率。需要注意的是,对象内置属性的数量是固定的,默认是10个。
通常,我们将保存在线性数据结构中的属性称之为“快属性”,因为线性数据结构中只需要通过索引即可以访问到属性。需要注意的是,从线性结构中添加或者删除大量的属性时,执行效率会非常低,这主要因为会产生大量时间和内存开销。
如果一个对象的属性过多时,或者存在反复添加或者删除属性的操作,V8 就会采取“慢属性”策略,即属性采用非线性的字典存储模式,这样虽然降低了查找速度,但是可以提升修改对象的属性的速度。

五、函数表达式

先看下边两段代码:
在这里插入图片描述
可以看到上边非常像的两种定义函数的形式的函数声明函数表达式,同样是在定义函数之前调用函数,第一段代码就可以正确执行,而第二段代码会报错Uncaught TypeError: foo is not a function,这是为什么呢?
前边提过V8 执行 JavaScript 代码分为编译执行两步。在编译阶段,V8都会对其执行变量进行提升的操作,将它们提升到作用域中,在执行阶段,如果使用了某个变量,就可以直接去作用域中去查找。
不过V8 对于提升函数和提升变量(函数表达式)的策略是不同的,如果提升了一个变量(函数表达式),那么 V8 在将变量提升到作用域中时,设置默认值 为undefined;如果是函数声明,那么 V8 会在内存中创建该函数对象,并提升整个函数对象,再看回刚刚的例子:
在这里插入图片描述
那么为什么两者的默认值会不一样呢?
这是因为在变量提升阶段,V8 并不会执行赋值的表达式,该阶段只会分析基础的语句,比如变量的定义,函数的声明。
先来看这边提到的两个概念:表达式语句:
简单地理解,表达式就是表示值的式子,而语句是操作值的式子,比如执行X = 5,会返回一个值,这个是表达式,执行var x,不会返回任何值,这是个语句。
我们看回第一个例子:

function foo() {
  console.log("foo");
}

执行这段代码不会返回任何值。再来分析函数表达式,V8在编译阶段会先查找声明语句,可以把函数表达式拆分为下边两行代码看,可以发现第二行赋值操作在编译阶段是不会被执行的,这就是为什么作用域中的foo是undefined的原因。

var foo= undefined;
foo = function () {
	console.log("foo")
}

所以函数声明的本质是语句,而函数表达式的本质则是表达式。
一起来看两段有意思的代码:
代码一:

var n = 1;
(function() {
  n = 100;
  console.log(n);//100
}())
console.log(n);//100

代码二:

var n = 1;
function foo() {
  n = 100;
  console.log(n);//100
}
console.log(n);//1
foo();

关键点: 小括号之间存放的是表达式

六、原型链

继承就是一个对象可以访问另外一个对象中的属性和方法,不同的语言实现继承的方式是不同的,其中最典型的两种方式是基于类的设计基于原型继承的设计
虽然 JavaScript 标准委员会在 ES2015/ES6 中引入了 class 关键字,但那只是语法糖,JavaScript 的继承依然和基于类的继承没有一点关系。JavaScript 是在对象中引入了一个原型的属性,实现了语言的继承机制,简洁而优美。

原型继承是如何实现的呢?

V8 为每个对象都设置了一个 proto 隐藏属性, 称之为该对象的原型 (prototype), 该属性直接指向了内存中的另外一个对象,这个被指向的对象称为该对象的原型对象,原型对象也有自己的 proto 属性,指向它自己的原型对象。
定义A、B、C三个对象,看下图:
在这里插入图片描述
C 对象将它的 proto 属性指向了 B 对象,同样的方式,B 对象把自己的 proto 属性内存中另外一块对象 A。当通过 C.color 访问 color 属性时,V8 会先在 C 对象内部查找,没有查找到,接着继续在 C 对象的原型对象 B 中查找,还是没有查找到,那么继续去对象 B 的原型对象 A 中查找,因为 color 在对象 A 中, V8查找到了就返回该属性值。给人感觉color属性是对象C本身的属性,但实际上是属性位于原型对象上。我们把这个查找属性的路径称为原型链,在JavaScript 中,通过原型和原型链的方式来实现了继承特性。

利用 proto 实现继承
var animal = {
  type: 'def',
  color: 'def',
  getInfo: function() {
    return `Type: ${this.type},color: ${this.color}`;
  }
}

var dog = {
  type: 'Dog',
  color: 'Black'
};

dog.__proto__ = animal; //设置 dog 对象中的 __proto__ 属性指向 animal
dog.getInfo(); //"Type: Dog,color: Black"

在实际项目中,我们不应该直接通过__proto___ 来访问或者修改该属性,其主要原因有两个:

  • 首先,这是隐藏属性,并不是标准定义的 ;
  • 其次,使用该属性会造成严重的性能问题。

七、作用域链

原型链将一个个原型对象串起来,从而实现对象属性的查找,而作用域链则是将一个个作用域串起来,实现变量查找的路径。讨论作用域链,实际就是在讨论按照什么路径查找变量的问题。
我们知道,作用域就是存放变量和函数的地方,全局环境有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。
全局作用域是在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁的,直至 V8 退出。而函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之被销毁掉了。

作用域链是怎么工作的?

首先当 V8 启动时,会创建全局作用域,全局作用域中包括了 this、window 等变量,还有一些全局的 Web API 接口,创建的作用域如下图所示:
在这里插入图片描述
V8 会先编译顶层代码,在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中,全局作用域创建完成之后,V8 便进入了执行状态。
需要注意的是,JavaScript 是基于词法(静态)作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。了解这些作用域机制之后,来看看这段代码:

var name = 'global';
var type = 'global';

function a() {
  var name = 'a';
  var type = 'function';
  b();
}
function b() {
  var name = 'b';
  console.log(name); // 打印 'b', 查找路径: b作用域
  console.log(type); //打印 'global',查找路径: b作用域 → 全局作用域
}
a();

以上,如有理解不恰当之处,欢迎指正~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值