- Published on
V8相关(四)
- Authors
- Name
- Et cetera
什么是类型系统 (Type System)?
console.log(1 + '2') // 12
首先上面这段代码涉及JavaScript
操作类型的策略:在 JavaScript
这一动态弱类型语言中,会输出 12
,而不是如 Rust
、Python
中一样报错非同类型操作
对机器语言
来说,所有的数据都是一堆二进制代码,CPU 处理这些数据的时候,并没有类型的概念,CPU 所做的仅仅是移动数据,比如对其进行移位,相加或相乘
而在高级语言
中,我们都会为操作的数据赋予指定的类型,类型可以确认一个值或者一组值具有特定的意义和目的.所以,类型是高级语言中的概念
// 需要为要处理的每条数据指定类型
int counter = 100 # 赋值整型变量
float miles = 1000.0 # 浮点型
char* name = "John" # 字符串
counter = 100 # 赋值整型变量
miles = 1000.0 # 浮点型
name = "John" # 字符串
var counter = 100 # 赋值整型变量
let miles = 1000.0 # 浮点型
const name = "John" # 字符串
虽然 Python
和 JavaScript
定义变量的方式不同,但是它们都不需要直接指定变量的类型,因为虚拟机
会根据数据自动推导出类型
每种语言都定义了自己的类型,还定义了如何操作这些类型,另外还定义了这些类型应该如何相互作用,我们就把这称为类型系统
V8 是怎么执行加法操作的?
V8 会严格根据 ECMAScript
规范来执行操作.ECMAScript
是一个语言标准,JavaScript
就是 ECMAScript
的一个实现,比如在 ECMAScript 就定义了怎么执行加法操作,如下所示:
把第一个表达式 (AdditiveExpression) 的值赋值给左引用 (lref)
使用
GetValue(lref)
获取左引用 (lref) 的计算结果,并赋值给左值使用
ReturnIfAbrupt(lval)
如果报错就返回错误把第二个表达式 (MultiplicativeExpression) 的值赋值给右引用 (rref)
使用
GetValue(rref)
获取右引用 (rref) 的计算结果,并赋值给 rval使用
ReturnIfAbrupt(rval)
如果报错就返回错误使用
ToPrimitive(lval)
获取左值 (lval) 的计算结果,并将其赋值给左原生值 (lprim)使用
ToPrimitive(rval)
获取右值 (rval) 的计算结果,并将其赋值给右原生值 (rprim)如果
Type(lprim)
和 Type(rprim) 中有一个是 String,则:- 把
ToString(lprim)
的结果赋给左字符串 (lstr) - 把
ToString(rprim)
的结果赋给右字符串 (rstr) - 返回左字符串 (lstr) 和右字符串 (rstr) 拼接的字符串
- 把
把
ToNumber(lprim)
的结果赋给左数字 (lnum)把
ToNumber(rprim)
的结果赋给右数字 (rnum)返回左数字 (lnum) 和右数字 (rnum) 相加的数值
通俗地理解,V8 会提供了一个 ToPrimitve
方法,其作用是将 a 和 b 转换为原生数据类型,其转换流程如下:
- 先检测该对象中是否存在
valueOf
方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换 - 如果
valueOf
没有返回原始类型,那么就使用toString
方法的返回值 - 如果
valueOf
和toString
两个方法都不返回基本类型值,便会触发一个TypeError
的错误
当 V8 执行 1 + “2”
时,因为这是两个原始值相加,原始值相加的时候,如果其中一项是字符串,那么 V8 会默认将另外一个值也转换为字符串,相当于执行了下面的操作:
Number(1).toString() + '2'
这里,把数字 1
偷偷转换为字符串“1”
的过程也称为强制类型转换
,因为这种转换是隐式
的,所以如果我们不熟悉语义,那么就很容易判断错误
var Obj = {
toString() {
return '200'
},
valueOf() {
return 100
},
}
Obj + 3 // 103
由于需要先使用 ToPrimitve 方法
将 Obj 转换为原生类型,而ToPrimitve
会优调用对象中的 valueOf 方法
,由于 valueOf
返回了 100,那么 Obj 就会被转换为数字 100
,那么数字 100 加数字 3,那么结果当然是 103 了
var Obj = {
toString() {
return '200'
},
valueOf() {
return 100
},
}
console.log(Obj + '3') // 1003
// 因为有valueOf方法所以调用,然后因为数字和字符串相加,所以V8隐式强制转换成两个字符串相加,即把valueOf返回值100转换成字符串
运行时环境
其实在执行 JavaScript 代码之前,V8 就已经准备好了代码的运行时环境
,这个环境包括了堆空间
和栈空间
、全局执行上下文
、全局作用域
、内置的内建函数
、宿主环境提供的扩展函数和对象
,还有消息循环系统
.准备好运行时环境之后,V8 才可以执行 JavaScript 代码,这包括解析源码
、生成字节码
、解释执行或者编译执行
这一系列操作
宿主环境
要聊运行 V8 的运行时环境,我们不得不聊 V8 的宿主环境,什么是 V8 的宿主环境呢?
你可以把 V8 和浏览器的渲染进程的关系看成病毒和细胞的关系,浏览器为 V8 提供基础的消息循环系统
、全局变量
、Web API
,而 V8 的核心是实现了 ECMAScript 标准,这相当于病毒自己的 DNA 或者 RNA,V8 只提供了 ECMAScript 定义的一些对象和一些核心的函数,这包括了 Object
、Function
、String
.除此之外,V8 还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要宿主环境的配合才能完整执行
如果 V8 使用不当,比如不规范的代码触发了频繁的垃圾回收,或者某个函数执行时间过久,这些都会占用宿主环境的主线程,从而影响到程序的执行效率,甚至导致宿主环境的卡死
除了浏览器
可以作为 V8 的宿主环境,Node.js
也是 V8 的另外一种宿主环境,它提供了不同的宿主对象和宿主的 API,但是整个流程依然是相同的,比如 Node.js 也会提供一套消息循环系统,也会提供一个运行时的主线程
构造数据存储空间:堆空间和栈空间
由于 V8 是寄生在浏览器或者 Node.js 这些宿主中的,因此,V8 也是被这些宿主启动的。比如,在 Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间
栈空间
主要是用来管理 JavaScript 函数调用
的,栈是内存中连续的一块空间
,同时栈结构是先进后出
的策略.在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型
、引用到的对象的地址
、函数的执行状态
、this 值
等都会存在在栈上.当一个函数执行结束,那么该函数的执行上下文便会被销毁掉
栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高
,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层过深,那么 V8 就有可能抛出栈溢出的错误.你可以在控制台执行下面这样一段代码:
function factorial(num) {
if (n === 1) return 1
return n * factorial(n - 1)
}
console.log(factorial(50000))
// 报错栈溢出,因为这个阶乘函数嵌套调用5万次,超出了栈的最大空间
如果有一些占用内存比较大的数据,或者不需要存储在连续空间中的数据,使用栈空间就显得不是太合适了,所以 V8 又使用了堆空间
堆空间
是一种树形的存储结构
,用来存储对象类型的离散的数据,在前面的课程中我们也讲过,JavaScript 中除了原生类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有 window 对象
、document 对象
等,这些都是存在堆空间的
全局执行上下文和全局作用域
V8 初始化了基础的存储空间之后,接下来就需要初始化全局执行上下文
和全局作用域
了,这两个内容是 V8 执行后续流程的基础
当 V8 开始执行一段可执行代码时,会生成一个执行上下文.V8 用执行上下文来维护执行当前代码所需要的变量声明
、this 指向
等
执行上下文
中主要包含了三部分,变量环境
、词法环境
、和 this 关键字
.比如在浏览器的环境中,全局执行上下文
中就包括了 window 对象,还有默认指向 window 的 this 关键字
,另外还有一些 Web API 函数
,诸如 setTimeout
、XMLHttpRequest
等内容
词法环境
中,则包含了使用 let、const 等变量的内容
全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了.另外,当你执行了一段全局代码时,如果全局代码中有声明的函数或者定义的变量,那么函数对象和声明的变量都会被添加到全局执行上下文中.比如下面这段代码:
var x = 1
function show_x() {
console.log(x)
}
V8 在执行上面这段代码的过程中,会在全局执行上下文中添加变量 x
和函数 show_x
在这里还有一点需要注意下,全局作用域和全局执行上下文的关系
,其实你可以把作用域看成是一个抽象的概念,比如在 ES6 中,同一个全局执行上下文中,都能存在多个作用域
,你可以看下面这段代码:
var x = 5
{
let y = 2
const z = 3
}
这段代码在执行时,就会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中.具体你可以参看下图:
当 V8 调用了一个函数时,就会进入函数的执行上下文,这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构.比如执行下面这段代码:
var x = 1
function show_x() {
console.log(x)
}
function bar() {
show_x()
}
bar()
当执行到 show_x 的时候,其栈状态如下图所示:
构造事件循环系统
有了堆空间和栈空间,生成了全局执行上下文和全局作用域,接下来就可以执行 JavaScript 代码了吗?
答案是不行,因为 V8 还需要有一个主线程,用来执行 JavaScript 和执行垃圾回收
等工作.V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的
只有一个主线程依然不行,因为如果你开启一个线程,在该线程执行一段代码,那么当该线程执行完这段代码之后,就会自动退出了,执行过程中的一些栈上的数据也随之被销毁,下次再执行另外一个段代码时,你还需要重新启动一个线程,重新初始化栈数据,这会严重影响到程序执行时的性能
为了在执行完代码之后,让线程继续运行,通常的做法是在代码中添加一个循环语句,在循环语句中监听下个事件,比如你要执行另外一个语句,那么激活该循环就可以执行了.比如下面的模拟代码:
while(1){
Task task = GetNewTask()
RunTash(task)
}
这段代码使用了一个循环,不同地获取新的任务,一旦有新的任务,便立即执行该任务
如果主线程正在执行一个任务,这时候又来了一个新任务,比如 V8 正在操作 DOM,这时候浏览器的网络线程完成了一个页面下载的任务,而且 V8 注册监听下载完成的事件,那么这种情况下就需要引入一个消息队列,让下载完成的事件暂存到消息队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务.当执行完一个任务之后,我们的事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务
因为所有的任务都是运行在主线程的,在浏览器的页面中,V8 会和页面共用主线程,共用消息队列,所以如果 V8 执行一个函数过久,会影响到浏览器页面的交互性能