透过V8引擎理解JavaScript,风景不一样了

744 阅读16分钟

什么是V8

V8是一个由Google开发的开源Javascript引擎,目前用在Chrome浏览器和Node.js中,其核心功能是执行易于人类理解的JavaScript代码。它也称为虚拟机,会模拟计算机的CPU、堆栈、寄存器等,还具有自己的一套指令集系统,对于JavaScript来说,V8是它的整个世界,因此无需担心JavaScript所处计算机环境的差异。

其主要核心流程分为编译执行两步。首先需要将JavaScript代码转换为低级中间代码或者机器能够理解的机器代码,然后再执行转换后的代码,输出执行结果。

高级代码为什么要先编译再执行

你可以把CPU看成一个非常小的运行计算机器,我们可以通过二进制的指令和CPU进行沟通。为了能够完成复杂任务,工程师提供了一大堆指令,来实现各种功能,也就是机器语言。 注意,CPU只能识别二进制的指令,也就是机器指令。而人能看的是汇编指令

image.png

1000100101011010 机器指令
mov ax bx 汇编指令

汇编语言是需要考虑计算机架构细节的,而JavaScript,C,Python这些高级语言,则屏蔽这些差异。但是,高级语言还需要解析后,才能被执行。主要有以下两种方式:

  1. 解释执行

一段代码 --> 解析器 --> 中间代码 --> 解析器执行 --> 输出结果

  1. 编译执行

一段代码 --> 解析器 --> 中间代码 --> 编译器 --> 机器代码 --> 机器代码执行 --> 输出结果

V8是如何执行一段JavaScript代码的?

V8是混合编译执行和解释执行两种手段,称为JIT(JUST IN TIME)技术。解释执行的启动速度快,编译执行的执行时速度快,因此是结合了两者的优点。具体流程如下图:

image.png

  • 启动时初始化环境:堆栈空间、全局执行上下文、事件循环系统初始化
  • 对源代码进行AST解析,同时生成相关作用域,作用域中存放相关变量
  • 生成中间代码(字节码)。知识点:在不同架构的 CPU 上,编译出来的字节码是相同的,编译出来的机器代码是不同的
  • 解析器解释执行
  • 如果发现一段代码被执行多次,就会被标记为热点代码,丢给编译器编译为二进制优化代码执行。
  • 如果优化后代码,被修改结构,则生成反优化代码,下次执行还是交给解析器执行

V8调试工具:D8

  • 打印AST
pt --print-ast xxx.js
  • 查看作用域
pt --print-scopes xxx.js
  • 查看解释器生成的字节码
pt --print-bytecode xxx.js
  • 查看被优化的热点代码
pt --trace-opt xxx.js
  • 查看反优化代码
pt --trace-deopt xxx.js

JavaScript设计思想

快属性和慢属性

如下,定义一个对象:

let foo = {'10': 1,100: 'dds', 2: 'sda', 1: 'fdsf', 'ds': 'sda', '3': 'dfs'}

打印对象,可以看到,数字为key的属性会排在字母属性为key的前面,且数字为key的属性是按数字大小顺序排列的:

截屏2021-08-21 下午4.34.14.png

其中,数字为key的属性被称为快属性,字母为key的被称为慢属性。之所以有这样的结果是因为在ECMAScript规范中,定义了数字属性应该按照索引的大小升序排列,字符串属性根据创建时间的顺序升序排列。数字属性称为排序属性,在V8中被称为elements,字符串属性被称为常规属性,在V8中被称为properties

因为线性结构的查找速度快,而字典结构的查找速度慢,所以,10个以内的常规属性会被内置到对象中,便于快速查找,超过十个还是会放到properties中。

除了elements和properties属性,V8还为每个对象实现了map属性和__proto__属性。map是隐藏类,用于在内存中快速查找对象属性。

image.png

立即调用的函数表达式

函数表达式与函数声明的主要区别有以下三点:

  • 函数表达式是在表达式语句中使用function的,最典型的表达式是a=b这种形式,因为函数也是一个对象,我们把var a = function(){}称为函数表达式
  • 在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions)
  • 一个函数表达式可以被用作一个及时调用函数——IIFE JavaScript中有一个圆括号运算符,圆括号里面可以放一个表达式,如
(a=3)

因为小括号之间存放的必须是表达式,所以如果在小括号里面定义函数,那么V8也会把这个函数看成是函数表达式,执行时它会返回一个函数对象。

(function(){
    var a = 3
    return a + 3
})

我们直接在表达式后加上调用的括号,就称为立即调用函数表达式(IIFE)。

(function(){
    var a = 3
    return a + 3
})()

因为函数立即调用表达式也是一个表达式,所以在V8编译阶段,V8并不会处理函数表达式,不会为该表达式创建函数对象。这样的一个好处就是不会污染环境,函数和函数内部变量都不会被其他部分的代码访问到。

原型继承

JavaScript每个对象都包含了一个隐藏属性__proto__,这就是对象的原型。__proto__指向的对象称为该对象的原型对象。如下就是原型链,用于对象查找属性,就像如下一样一级一级查找对象上的属性。

继承就是一个对象可以访问另外一个对象中的属性和方法,在JavaScript中,我们通过原型和原型链的方式实现了继承的特性。

image.png

继承的实现要通过构造函数来实现。每个函数对象中都有一个公开的prototype属性,当你将这个函数作为构造函数来创建一个新的对象时,新创建对象的原型对象就指向了该函数的 prototype属性。

image.png

作用域链

每个函数在执行时都需要查找自己的作用域,我们称为函数作用域。V8在函数的编译阶段会为函数创建一个作用域,在函数中定义的变量和声明的函数都会丢到该作用域中,另外系统还会另外添加一个隐藏变量this。

在执行阶段,如果一个变量该函数在本作用域无法找到,就会去该函数被定义时的所在的作用域中去查找,而不是被调用时所处的作用域,这就是作用域链。因为JavaScript时基于词法作用域的,词法作用域的顺序是按照函数定义时的位置来决定的,作用域在函数声明时已经确定好了,所以词法作用域称为静态作用域动态作用域才关心函数是何处调用的,作用域链基于调用栈而不是函数定义的位置。

看一道题:

var a = []
for (let i=0; i < 10; i++) {
    a[i] = function(){
        console.log(i)
    }
}
a[3]()

let定义的i会运行for的块级作用域中,每次执行一次循环,创建一个块级作用域。在这个块级作用域中,又定义了一个函数,该函数引用了外部变量i,就产生了闭包,因为a没有被销毁,所以所有块级作用域中的i都不会被销毁。因为闭包中每个i值都不同,所以可以打印出对应的i的值3。但是,如果把变量i的声明,由let 换成 var,此时i就是一个全局作用域中的变量,由于全局作用域只有一个,所以i的值是一直在改变的。最终打印出来的结果都会是10

类型转换

大家都知道JavaScript是弱类型的语言,在进行一些操作时,会自动进行类型转换,类型转换的执行流程如下:

  1. 先检测该对象中是否存在valueOf方法,如果有并返回了原始数据类型,那么就使用该值进行强制类型转换
  2. 如果valueOf没有返回原始类型,那没就使用toString方法的返回值
  3. 如果valueOftoString两个方法都没有返回基本数据类型,则会报错。

下面请看题:

let foo = {'10': 1,100: 'dds', 2: 'sda', 1: 'fdsf', 'ds': 'sda', '3': 'dfs'}
foo.valueOf() // 返回对象本身的值
foo.toString() // 返回[object Object]
foo + 2 // 返回结果:[object Object]2
// 如果改变foo的valueOf方法
foo.valueOf = function(){return '100'}
foo + 2 // 返回结果:1002
foo.valueOf = function(){return 100}
foo + 2 // 返回结果:102

编译流水线

运行时环境

在执行JavaScript代码之前,V8就已经准备好了代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。

image.png

什么是宿主环境

要执行V8需要一个宿主环境,可以是浏览器、Nodejs或其他定制开发的环境,这些宿主环境提供了V8执行JavaScript时所需的基础功能部件。下图是宿主环境和V8的功能关系图:

image.png

堆空间和栈空间

在Chrome中只要打开一个渲染进程,渲染进程便会初始化V8,同时初始化堆栈空间。栈空间是用来管理JavaScript的函数调用的。没调用一个函数,就把该函数压入栈中,执行中又遇到函数,就再压入栈中,执行完后就函数出栈,直到所有函数调用完成,栈也就被清空了。

栈空间是一个连续的空间,在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中很难分配到一块很大的连续空间,所以V8对栈空间的大小做了限制,如果函数调用过深,就可能会栈溢出。

堆空间是一种树形的存储结构,用来存储引用类型的离散的数据。堆空间可以存放很多数据,但是读取速度会比较慢。

function add (x, y) {
    return x + y
}
function main () {
    let num1 = 2
    let num2 = 3
    let num3 = add(num1, num2)
    let data = {
        sum: num3
    }
    return data
}
main()

下面我来解释一下,如上代码的执行过程:

  1. 创建main函数的栈帧指针
  2. 在栈中将num1初始化num1 = 2
  3. 在栈中将num2初始化num2 = 3
  4. 保存main函数的栈顶指针
  5. 创建add函数的栈帧指针
  6. 在栈中将x初始化x = 2
  7. 在栈中将y初始化y = 2
  8. x,y相加的值保存在寄存器中
  9. 销毁add函数
  10. 复活main函数的栈顶指针
  11. 在栈中将num3初始化num3 = 寄存器中的add函数的返回值
  12. 在堆空间新建对象,返回对象地址赋值给data
  13. 将返回值写入寄存器
  14. 销毁main函数

如下图,就表示一个栈:

截屏2021-08-22 下午6.13.46.png

截屏2021-08-22 下午6.19.43.png

截屏2021-08-22 下午6.20.25.png

如下图是寄存器和字节码堆栈的关系:

  • 使用内存中的一块区域来存放字节码
  • 使用通用寄存器r0,r1...这些寄存器来存放一些中间数据
  • PC寄存器用来指向下一条要执行的字节码
  • 栈顶寄存器来指向栈顶的位置
  • 累加器是一个非常特殊的寄存器,用来保存中间结果。比如:函数return结束当前函数的执行,并将控制权回传给调用方,返回的值是累加器中的值。 image.png

全局执行上下文

执行上下文中主要包含三部分,变量环境、词法环境和this关键字。比如在浏览器环境中,全局执行上下文包含了window对象,还有指向windowthis关键字,另外还有一些web api函数,setTimeoutXMLHttpRequest等。而词法环境中,则包含了let、const等变量内容。

构造事件循环系统

对于事件循环系统,因为所有的任务都是运行在主线程的,在浏览器的页面中,V8会和页面共用主线程,共用消息队列,所以如果一个函数执行过久,会影响页面的交互性能。

机器代码:CPU是如何操作二进制代码的

看一段代码:

int main() {
    int x = 1;
    int y = 2;
    int z = x + y;
    return z;
}

如上代码会被编译程汇编代码,以及二进制代码。 image.png 如下图,栈的存储是连续的空间,通过指针与寄存器实现了函数的入栈出栈: image.png

惰性解析

所谓惰性解释是指,解析器在解析过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成AST和字节码,而只生成一个函数对象。函数对象包含name,和code属性。name就是函数的名称,code是函数的源码。

function foo(a, b) {
    var d = 100
    var f = 10
    return d + f + a + b
}
var a = 1
var c = 4
foo(1, 5)

image.png 最终生成的是顶层代码的抽象语法数: image.png

当遇到闭包的状况时,在执行函数的阶段,虽然不会解析和执行其内部函数,但是预解析器还是要判断其内部函数是否引用了其变量。

预解析器会判断语法错误,和检查函数内部是否引用的外部变量。如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包外部变量不能销毁的问题

隐藏类

在V8中,隐藏类又称为map,每个对象都有一个map属性,其值指向内存中的隐藏类。隐藏类描述了对象的属性布局,主要包括了属性名称和每个属性所对应的偏移量。

var point = {x=200,y=400}

image.png 有了map之后,当你再次使用point.x访问属性x时,V8会查询point的map中x属性相对于point对象的偏移量,然后将point对象的起始位置加上偏移量,就得到了x属性的值在内存中的位置,省去了一个比较复杂的查找过程。

需要注意的是:

  • 结构相同值不同的对象可以共用同一个隐藏类
  • 如果对象结构发生变化,隐藏类就要重新创建,这会影响V8的执行效率 所以,在写代码时,要注意以下几点:
  1. 使用字面量初始化对象时,要保证属性的顺序是一致的。因为key初始化的顺序不一样,也会导致结构不一样。
  2. 尽量使用字面量一次性初始化完整的对象属性,不要一个一个添加。
  3. 尽量避免使用delete方法删除属性。 如下bad1案例,pointpoint3共用一个隐藏类,其他各自有一个隐藏类
// bad1
var point = {x=200,y=400}
var point2 = {y=200,x=400}
var point3 = {x=20,y=40}
var point4 = {x='200',y='400'}
// bad
var x = {}
x.a = 1
x.b = 2

事件循环系统

大家可以先做下这道题,然后,思考下,自己对事件循环机制到底理解了几分。答案,下面自己查看。

function a () {return Promise.resolve(99).then((data) => {
console.log(1)
setTimeout(() => {
    console.log(data)
    console.log(70)
}, 0) 
return 44   
})
}
async function b () {
    console.log(3)
    let c = await a()
    console.log(56)
    console.log(c)
    console.log(4)
}
setTimeout(() => {console.log('setTimeout')}, 0)
setImmediate(() => {console.log('setImmediate0')})
requestAnimationFrame(() => {console.log('requestAnimationFrame')})
setImmediate(() => {console.log('setImmediate2')})

console.log(5)
b()
console.log(6)
  • readFile:在读写线程执行
  • readFileSync:在主线程执行
  • XMLHttpRequest:在网络线程中执行
  • setTimeOut:有另外一个队列用来存放定时器中的回调,还有一个任务调度器,从一系列的事件队列中按一定规则取出下一个执行事件 宏任务是指,消息队列中等待主线程执行的事件 有哪些:
  • <script>标签中的运行代码
  • 事件触发的回调函数,例如DOM EventsI/OrequestAnimationFrame
  • setTimeoutsetIntervalsetImmediate的回调函数

微任务是指,异步函数,在主函数执行完之后,当前宏任务结束之前,执行的任务(promise、async/await、generator、协程(协程就是在主线程上执行的)

  • promisesPromise.thenPromise.catchPromise.finally
  • MutationObserver使用方式
  • queueMicrotask使用方式
  • process.nextTick:Node独有

创建 Promise 时,并不会生成微任务,而是需要等到 Promise 对象调用 resolve 或者 reject 函数时,才会产生微任务。产生的微任务并不会立即执行,而是等待当前宏任务快要执行结束时再执行。

答案公布,你做对了吗:

5 3 6 1 56 44 4 requestAnimationFrame setImmediate0 setTimeout  setImmediate2 99 70

垃圾回收机制

垃圾回收大致步骤如下:

  1. 通过GC Root 标记空间中活动对象和非活动对象。目前V8采用的可访问性算法来判断堆中的对象是否是活动对象。具体来说,这个算法是将一些GC Root作为初始存活的对象的集合,从CG Roots对象出发,遍历GC Root中的所有对象,能遍历到的就是可访问对象,遍历不到的就是不可访问对象。 GC Root有很多:
  • 全局的window对象
  • 文档DOM树,由可以通过遍历文档到达的所有原生DOM节点组成
  • 存放栈上变量
  1. 回收非活动对象所占据的内存
  2. 内存整理 V8中,会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代存放生存时间久的对象。新生代通常只支持1-8M的容量,而老生代就大了很多。
堆区域内存容量生存时间垃圾回收器垃圾回收频率
新生代区1-8M副垃圾回收器
老生代区大很多主垃圾回收器

新生代回收过程

新生代区又分为对象区和空闲区,新加入的对象会被放入对象区,对象区满了,就要开始垃圾回收了。把对象区中的存活对象复制到空闲区,然后,空闲区和对象区角色互换。如果,对象在新生区经过两次垃圾回收还依然存活,就将对象放到老生代区中,这就是对象晋升策略。

新生代回收过程

老生区则直接先标记对象,记下来就清除非活动对象,然后在整理内存空间。

V8是如何优化垃圾回收器执行效率的

由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。

一次完整的垃圾回收分为标记和清理两个阶段,垃圾数据标记之后,V8 会继续执行清理和整理操作,虽然主垃圾回收器和副垃圾回收器的处理方式稍微有些不同,但它们都是主线程上执行的,执行垃圾回收过程中,会暂停主线程上的其他任务,具体全停顿的执行效果如下图所示:

image.png 可以看到,执行垃圾回收时会占用主线程的时间,如果在执行垃圾回收的过程中,垃圾回收器占用主线程时间过久,就像上面图片展示的那样,花费了 200 毫秒,在这 200 毫秒内,主线程是不能做其他事情的。比如,页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行,造成页面的卡顿 ,用户体验不佳。

为了解决全停顿而造成的用户体验的问题,V8 团队经过了很多年的努力,向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,并且也已经取得了一些成效。这些技术主要是从两方面来解决垃圾回收效率问题的:

第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务;

第二,将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。

接下来,我们就来深入分析下,V8 是怎么向现有的垃圾回收器添加并行、并发和增量等技术,来提升垃圾回收执行效率的。

并行回收

所谓并行回收,是指垃圾回收器在主线程上执行的过程中,还会开启多个协助线程,同时执行同样的回收工作,其工作模式如下图所示:

image.png 采用并行回收时,垃圾回收所消耗的时间,等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间),再加上一些同步开销的时间。

V8 的副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。

增量回收

虽然并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是一种全停顿的垃圾回收方式,在主线程执行回收工作的时候才会开启辅助线程,这依然还会存在效率问题。

比如老生代存放的都是一些大的对象,如 window、DOM 这种,完整执行老生代的垃圾回收,时间依然会很久。这些大的对象都是主垃圾回收器的,所以在 2011 年,V8 又引入了增量标记的方式,我们把这种垃圾回收的方式称之为增量式垃圾回收。

所谓增量式垃圾回收,是指垃圾收集器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作,具体流程你可以参看下图:

image.png

并发 (concurrent) 回收

虽然通过三色标记法写屏障机制可以很好地实现增量垃圾回收,但是由于这些操作都是在主线程上执行的,如果主线程繁忙的时候,增量垃圾回收操作依然会增加降低主线程处理任务的吞吐量 (throughput)。

结合并行回收可以将一些任务分配给辅助线程,但是并行回收依然会阻塞主线程,那么,有没有办法在不阻塞主线程的情况下,执行垃圾回收操作呢?还真有,这就是我们要来重点研究的并发回收机制了。所谓并发回收,是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。

image.png 可以看出来,主垃圾回收器同时采用了这三种策略:

  • 首先主垃圾回收器主要使用并发标记,我们可以看到,在主线程执行 JavaScript,辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的。
  • 标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作。
  • 另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种 JavaScript 任务之间执行。

内存泄漏

内存泄漏的主要原因是不再需要的内存数据依然被其他对象引用着。

例子一 非严格模式下,全局对象,造成的内存泄漏

function foo () {
    this.memeryLeak = new Array(200000)
}
foo()

没想到吧,这么一个代码造成了内存泄漏!因为foo函数的作用域是全局作用域,所以在调用foo函数时,this指向了window对象。当foo函数调用结束后,memeryLeak依然被window对象引用,造成了内存泄漏。

解决方案:

  1. 在JavaScript文件头部加上use strict,使用严格模式避免意外的全局变量,此时上例中的this指向undefined

例子二 闭包造成的内存泄漏问题

function fooLizzy () {
    const leakObject = new Object()
    leakObject.a = 1
    leakObject.b = 3
    leakObject.c = new Array(200000)
    return function() {
        console.log(leakObject.a)
    }
}
const foo = fooLizzy()

由于闭包的原因,这个leakObject不会被垃圾回收,同时是整个leakObject对象不能被垃圾回收,虽然只是引用了leakObject.a。所以,更好的做法是:

function fooLizzy () {
    const leakObject = new Object()
    leakObject.a = 1
    leakObject.b = 3
    leakObject.c = new Array(200000)
    const needUse = leakObject.a
    return function() {
        console.log(needUse)
    }
}
const foo = fooLizzy()

此时,不会被垃圾回收的对象就只有needUse的数值1了。

例子三 DOM节点造成的内存泄漏问题

通常这种情况是js中引用了DOM节点,js一直没有被销毁,但是,DOM节点从页面被干掉了,此时,由于js保存了对DOM节点的引用,所以DOM节点的数据还是被保存在了堆中。这种DOM节点通常被称作是“detached”。detached节点是DOM内存泄漏的常见原因,我们需要非常小心谨慎。

频繁的垃圾回收触发

频繁使用大量的临时变量,导致新生代空间很快被装满,从而频繁触发垃圾回收。频繁的垃圾回收操作会让你感到页面卡顿。要解决这个问题,我们可以考虑将这些临时变量设置为全局变量。这样,变量就被保存在老生区了,老生区容量大,且垃圾回收机制与新生区不同。


function strToArray(str) {
  let i = 0
  const len = str.length
  let arr = new Uint16Array(str.length)
  for (; i < len; ++i) {
    arr[i] = str.charCodeAt(i)
  }
  return arr;
}


function foo() {
  let i = 0
  let str = 'test V8 GC'
  while (i++ < 1e5) {
    strToArray(str);
  }
}


foo()

场景一

Node.js v4.x ,BFF层服务端在js代码中写了一个lib模块 做lfu、lru的缓存,用于针对后端返回的数据进行缓存。把内存当缓存用的时候,由于线上qps较大的时候,缓存模块被频繁调用,造成了明显的gc stw现象,外部表现就是node对上游http返回逐渐变慢。由于当时上游是nginx,且nginx设置了timeout retry,因此这个内存gc问题当node返回时间超出nginx timeout阈值时 进而引起了nginx大量retry,迅速形成雪崩效应。后来不再使用这样的当时,改为使用node服务器端本地文件+redis/memcache的缓存方案,node做bff层时 确实不适合做内存当缓存这种事。

场景二

运行场景:K线行情列表 技术方案,websocket 推送二进制数据(2次/秒) -> 转换为 utf-8 格式 -> 检查数据是否相同 -> 渲染到 dom 中 出现问题:页面长时间运行后出现卡顿的现象 问题分析:将二进制数据转换为 utf-8 时,频繁触发了垃圾回收机制 解决方案:后端推送采取增量推送形式