什么是v8?
V8 是 JavaScript 虚拟机的一种。将人类能够理解的编程语言 JavaScript,翻译成机器能够理解的机器语言。
在 V8 出现之前,所有的 JavaScript 虚拟机所采用的都是解释执行的方式,这是 JavaScript 执行速度过慢的一个主要原因。(执行过程中要先编译源码,再执行,多了个编译阶段 大部分解释性语言是动态语言,动态语言的数据类型是在运行时检查的,这又耗费了额外的时间)。
JavaScript 借鉴了很多语言的特性,比如 C 语言的基本语法、Java 的类型系统和内存管理、Scheme 的函数作为一等公民,还有 Self 基于原型(prototype)的继承机制。
V8 中使用的隐藏类(Hide Class),这是将 JavaScript 中动态类型转换为静态类型的一种技术,可以消除动态类型的语言执行速度过慢的问题,如果你熟悉 V8 的工作机制,在你编写 JavaScript 时,就能充分利用好隐藏类这种强大的优化特性,写出更加高效的代码。
JavaScript 是一种自动垃圾回收的语言,V8 在执行垃圾回收时,会占用主线程的资源,如果我们编写的程序频繁触发垃圾回收,那么无疑会阻塞主线程。
v8执行js代码
V8 又是怎么执行 JavaScript 代码的呢?
其主要核心流程分为编译和执行两步。首先需要将 JavaScript 代码转换为低级中间代码或者机器能够理解的机器代码,然后再执行转换后的代码并输出执行结果。
计算机执行高级语言的方式
- 解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。
- 编译执行。采用这种方式时,也需要先将源代码转换为中间代码,然后我们的编译器再将中间代码编译成机器代码。通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码。
V8 率先引入了即时编译(JIT)的双轮驱动的设计,这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。
通过一些列转换后生成字节码(即中间代码),然后解释器将直接执行输出结果。在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码。 V8 就会将这段字节码丢给优化编译器,优化编译器会在后台将字节码编译为二进制代码,然后再对编译后的二进制代码执行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。
V8 执行一段 JavaScript 代码所经历的主要流程了,这包括了:
- 初始化基础环境。
- 解析源码生成 AST 和作用域。
- 依据 AST 和作用域生成字节码。
- 解释执行字节码。(解释执行)
- 监听热点代码。
- 优化热点代码为二进制的机器代码。(编译执行)
- 反优化生成的二进制机器代码。
v8是如何调用函数的呢
其实在 V8 内部,会为函数对象添加了两个隐藏属性,具体属性如下图所示
如果某个函数没有设置函数名,该函数对象的默认的 name 属性值就是 anonymous。
如果一个函数赋值给一个变量,那么该函数的函数名将不能使用。
const bar = function foo() { } // console.log(foo.name) // 报错foo is not defined console.log(bar.name) // foo foo() // foo is not defined
code 属性,其值表示函数代码,以字符串的形式存储在内存中。当执行到一个函数调用语句时,V8 便会从函数对象中取出 code 属性值,也就是函数代码,然后再解释执行这段函数代码。
V8是怎样提升对象属性访问速度的?
JavaScript 对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值。
然而在 V8 实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典是非线性的数据结构,查询效率会低于线性的数据结构,V8 为了提升存储和查找效率,采用了一套复杂的存储策略。
常规属性和排序属性
在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。 前者被称为排序属性,elements。后者被称为常规属性,properties。
对象包含了两个隐藏属性:elements 属性和 properties 属性,elements 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性,properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存了常规属性。
分解成这两种线性数据结构之后,如果执行索引操作,那么 V8 会先从 elements 属性中按照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。
但是如果我们读取一个属性值时,他就需要查找对应的elements或者properties对象,然后又在elements或者properties对象中查找对应的属性。这无疑增加了一步查询操作。所以 V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties)。
但是对象内属性的数量是固定的,默认是10个,如果查出对象分配的空间,那么他们依旧保存在常规属性中。
如果一个对象的属性过多时,V8 就会采取另外一种存储策略,那就是慢属性策略,但慢属性的对象内部会有独立的非线性数据结构(词典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。
总之,就是如果在对象中有两个隐藏属性(elements, properties)。
- 如果向对象中添加数字属性,那么它将通过数字大小进行排序放进elements中存储。
- 如果向对象中添加非数字属性,
- 个数小于10个,那么它将直接存储在对象本身上。
- 如果大于10个,并且不是很多的话,那么它将多余10个的属性放在properties属性中。并且按照创建属性的顺序存入。
- 如果添加特别多的属性或者存在反复添加或者删除属性的操作,那么它将properties存储的数据结构,改为非线性进行存储。这样虽然降低了查找速度,但是却提升了修改对象的属性的速度。 可以通过这个例子查看,运行代码,通过浏览器的memory查看。
function Foo(property_num,element_num) { //添加可索引属性 for (let i = 0; i < element_num; i++) { this[i] = `element${i}` } //添加常规属性 for (let i = 0; i < property_num; i++) { let ppt = `property${i}` this[ppt] = ppt } } // 分别传入索引属性和非属性属性个创建个数。 var bar = new Foo(10,10)
var bar = new Foo(20,10);
var bar = new Foo(100,10);
立即执行函数
因为小括号之间存放的必须是表达式,所以如果在小阔号里面定义一个函数,那么 V8 就会把这个函数看成是函数表达式,执行时它会返回一个函数对象。
因为函数立即表达式也是一个表达式,所以 V8 在编译阶段,并不会为该表达式创建函数对象。这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。
表达式是不会在编译阶段执行的。函数声明的本质是语句,而函数表达式的本质则是表达式。
V8是如何实现对象继承的
对象的原型:每个对象都有一个隐藏的__proto__的属性。
原型对象:__proto__指向的对象。并且指向构造函数的prototype。
继承就是一个对象可以访问另外一个对象中的属性和方法,在JavaScript 中,我们通过原型和原型链的方式来实现了继承特性。
通过new创建对象时。V8 会在背后悄悄地做了以下几件事情。
var dog = {} dog.__proto__ = Constructor.prototype Constructor.call(dog) return dog
v8是如何查找变量的
作用域链就是将一个个作用域串起来,实现变量查找的路径。
作用域是在编译阶段就确定的。
全局作用域是在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁的,直至 V8 退出。 而函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之被销毁掉了。
因为 JavaScript 是基于词法作用域(静态作用域) 的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。
var name = '极客时间' var type = 'global' function foo(){ var name = 'foo' console.log(name) // foo console.log(type) // global } function bar(){ var name = 'bar' var type = 'function' foo() } bar()
V8是怎么实现1+“2”的
对机器语言来说,所有的数据都是一堆二进制代码,CPU 处理这些数据的时候,并没有类型的概念,CPU 所做的仅仅是移动数据,比如对其进行移位,相加或相乘。
每种语言都定义了自己的类型,还定义了如何操作这些类型,另外还定义了这些类型应该如何相互作用,我们就把这称为类型系统。
v8会严格根据ecmaScript规范来执行操作。
在两个类型的变量进行相加时,V8 会提供了一个 ToPrimitive 方法,其作用是将 a 和 b 转换为原生数据类型,其转换流程如下:
- 先检测该对象中是否存在 valueOf 方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换。
- 如果 valueOf 没有返回原始类型,那么就使用 toString 方法的返回值。
- 如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误。
两个原生类型相加,如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。在其他情况时,所有的值都会转换为数字类型值,然后做数字的相加。
js代码运行时环境
在执行 JavaScript 代码之前,V8 就已经准备好了代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。
浏览器为 V8 提供基础的消息循环系统、全局变量、Web API,而 V8 的核心是实现了 ECMAScript 标准。V8 还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要浏览器(nodejs)环境的配合才能完整执行。
堆空间和栈空间
在 Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间。
栈
栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。
栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层过深,那么 V8 就有可能抛出栈溢出的错误。
如果有一些占用内存比较大的数据,或者不需要存储在连续空间中的数据,使用栈空间就显得不是太合适了,所以 V8 又使用了堆空间。
堆
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据。
和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,你可以在任何时候分配和释放它。
全局执行上下文和全局作用域
V8 初始化了基础的存储空间之后,接下来就需要初始化全局执行上下文和全局作用域了,这两个内容是 V8 执行后续流程的基础。
当 V8 开始执行一段可执行代码时,会生成一个执行上下文。V8 用执行上下文来维护执行当前代码所需要的变量声明、this 指向等。
执行上下文中主要包含三部分,变量环境、词法环境和 this 关键字。而词法环境中,则包含了使用 let、const 等变量的内容。
全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中。
全局作用域和全局执行上下文的关系,其实你可以把作用域看成是一个抽象的概念。全局上下文中包含全局作用域和块级作用域,函数作用域。
构造事件循环系统
有了堆栈空间,生成了全局上下文和全局作用域后,V8 还需要有一个主线程,用来执行 JavaScript 和执行垃圾回收等工作。
只有一个主线程依然不行,因为如果你开启一个线程,在该线程执行一段代码,那么当该线程执行完这段代码之后,就会自动退出了,执行过程中的一些栈上的数据也随之被销毁,下次再执行另外一个段代码时,你还需要重新启动一个线程,重新初始化栈数据,这会严重影响到程序执行时的性能。
这就需要利用浏览器的事件循环了。
渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息。
例如XMLHttpRequest
JavaScript 是如何支持块级作用域
他将var声明的变量放在当前执行上下文的变量环境中。将let, const声明的变量放在词法环境中。
function foo(){ var a = 1 let b = 2 { let b = 3 var c = 4 let d = 5 console.log(a) console.log(b) } console.log(b) console.log(c) console.log(d) } foo()
当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量。
在词法环境内部, 维护了一个小型栈结构。栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。
当查找变量时,先查找词法环境中的,如果没找到,再去变量环境中查找。
我们需要好好理解这句话:在块作用域内,let声明的变量被提升,但变量只是创建被提升,初始化并没有被提升,在初始化之前使用变量,就会形成一个暂时性死区。
一些思考
- 为什么不建议使用delete删除属性。结合快属性和慢属性。 快属性: 保存在线性数据结构中的属性。 慢属性: 保存在非线性数据结构中的属性。
如果删除属性在线性结构中,删除后需要移动元素,开销较大,而且可能需要将慢属性重排到快属性。 如果删除属性在properties对象中,查找开销较大。
- function fn(){setTimeOut(fn,0)}这种调用会不会导致内存溢出? 不会,因为这是异步调用,下次执行fn函数时,已经在新的栈中执行了,所以当前栈不会发生溢出!
- 作用域和执行上下文是什么关系?
- 作用域是静态的,函数定义的时候就已经确定了。
- 执行上下文是动态的,调用函数时候创建,结束后还会释放。
该文章总结自极客时间李兵老师的图解 Google V8课程。