🎱
山风空林
EnglishCode
  • Notes
    • 写作学习笔记
  • Code
    • Web
      • HTTP 协议
      • Google V8 学习笔记
      • 浏览器工作原理与实践
    • Server
      • Start up Java
        • Basic
      • 系统架构学习笔记
        • 系统架构设计 P1
        • 系统架构设计 P2
        • 系统架构设计 P3
        • 系统架构设计 P4
      • Database
        • MySQL 基础
由 GitBook 提供支持
在本页
  1. Code
  2. Web

Google V8 学习笔记

V8 是 JavaScript 虚拟机的一种。我们可以简单地把 JavaScript 虚拟机理解成是一个翻译 程序,将人类能够理解的编程语言 JavaScript,翻译成机器能够理解的机器语言。

什么是 V8

V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器和 Node.js 中,其核心功能是执行易于人类理解的 JavaScript 代码。V8 是称为虚拟机,虚拟机通过模拟实际计算机的各种功能来实现代码的执行。

V8 怎么执行 JavaScript 代码

编译和执行两步。

  • 将 JavaScript 代码转换为低级中间代码或者机器能够理解的机器代码

  • 执行转换后的代码并输出执行结果

为什么需要先编译再执行

不同的 CPU 有着不同的指令集。在编写汇编代码时,我们还需要了解和处理器架构相关的硬件知识。因此我们需要一种屏蔽了计算机架构细节的语言。解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。

为了充分地利用解释执行和编译执行的优点,V8 采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 就会采用优化编译器将其编译成执行效率更加高效的机器代码。

执行JavaScript 代码主要流程

  • 准备执行 JavaScript 时所需要的一些基础环境;

    “堆空间”、“栈空间”、“全局执行上下文”、“全局作用域”、“消息循环系统”、“内置函数”

  • 解析源码生成 AST 和作用域;

    AST 是便于 V8 理解、作用域中存放相关变量。

  • 依据 AST 和作用域生成字节码;

    字节码是介于 AST 和机器代码的中间代码。但是与特定类型的机器代码无关,解释器可以直接解释执行字节码,或者通过编译器将其编译为二进制的机器代码再执行

  • 解释执行字节码;

  • 监听热点代码;

  • 优化热点代码为二进制的机器代码;

  • 反优化生成的二进制机器代码。

    JavaScript 是动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。

JavaScript的函数特点

JavaScript 中的对象

JavaScript 中的对象其核心本质都就是由一组组属性和值组成的集合。

对象的属性值有三种类型:

  • 原始类型 (primitive),所谓的原始类的数据,是指值本身无法被改变。

  • 对象类型 (Object),对象的属性值也可以是另外一个对象

  • 函数类型 (Function),如果对象中的属性值是函数,那么我们把这个属性称为方法。

函数的本质

函数也是由一组组值和属性组成的集合。但是函数和普通对象不同的是,函数可以被调用。

V8 会为函数对象添加了两个隐藏属性,分别是 name 属性和 code 属性。

因为函数是一种特殊的对象,所以在 JavaScript 中,函数可以赋值给一个变量,也可以作为函数的参数,还可以作为函数的返回值。

由于函数的“可被调用”的特性,使得实现函数的可赋值、可传参和可作为返回值等特性变得有一点麻烦。

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

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

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

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

  • 对象中的数字属性称为排序属性,称为 elements

  • 字符串属性就被称为常规属性,称为 properties。

ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。

V8 为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性。

V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为**对象内属性 (in-object properties)。**默认是 10 个,如果添加的属性超出了对象分配的空间,则它们将被保存在常规属性存储中。

我们将保存在线性数据结构中的属性称之为“快属性”。果一个对象的属性过多时,V8 为就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。

函数表达式

函数声明和函数表达式之间的区别

// 函数声明
function foo(){
    console.log('foo')
}

// 表达式
var foo = function (){
    console.log('foo')
}

在编译阶段,如果解析到函数声明,那么 V8 会将这个函数声明转换为内存中的函数对象,并将其放到作用域中。同样,如果解析到了某个变量声明,也会将其放到作用域中,但是会将其值设置为 undefined,表示该变量还未被使用。

然后在 V8 执行阶段,如果使用了某个变量,或者调用了某个函数,那么 V8 便会去作用域查找相关内容。

我们把这种在编译阶段,将所有的变量提升到作用域的过程称为变量提升。

表达式和语句的区别: 表达式就是表示值的式子,而语句是操作值的式子。函数声明的本质是语句,而函数表达式的本质则是表达式。表达式执行代码会返回一个值。函数声明也是一个语句,执行语句并没有返回任何的值。

函数声明并不是一个表达式,而是一个语句。V8 在变量提升阶段,如果遇到函数声明,那么 V8 会对该函数声明执行变量提升操作。

总的来说,在 V8 解析 JavaScript 源码的过程中,如果遇到普通的变量声明,那么便会将其提升到作用域中,并给该变量赋值为 undefined,如果遇到的是函数声明,那么 V8 会在内存中为声明生成函数对象,并将该对象提升到作用域中。

函数表达式与函数声明的主要区别:

  • 函数表达式在**表达式语句中使用 function **,最典型的表达式是“a=b”这种形式,因为函数也是一个对象,我们把“a = function (){}”这种方式称为函数表达式;

  • 在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions);

  • 一个函数表达式可以被用作一个即时调用的函数表达式——IIFE(Immediately Invoked Function Expression)。

立即调用的函数表达式(IIFE)

在编译阶段,V8 并不会处理函数表达式,而 JavaScript 中的立即函数调用表达式正是使用了这个特性来实现了非常广泛的应用。

// 小括号之间存放的必须是表达式,所以如果在小阔号里面定义一个函数,那么 V8 就会把这个函数看成是函数表达式。 V8 在编译阶段,并不会为该表达式创建函数对象。这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。
(function () {
    //statements
})()

JavaScript 中没有私有作用域的概念,如果在多人开发的项目中,你模块中的变量可能覆盖掉别人的变量,所以使用函数立即表达式就可以将我们内部变量封装起来,避免了相互之间的变量污染。

V8是如何实现对象继承

V8 如何实现 JavaScript 对象继承

继承就是一个对象可以访问另外一个对象中的属性和方法。最典型的两种方式是基于类的设计和基于原型继承的设计。JavaScript 通过原型和原型链的方式来实现了继承特性。

JavaScript 的每个对象都包含了一个隐藏属性 __proto__ ,我们就把该隐藏属性 __proto__ 称之为该对象的原型 (prototype),__proto__ 指向了内存中的另外一个对象,我们就把 __proto__ 指向的对象称为该对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性。

我让 C 对象的原型指向 B 对象,那么便可以利用 C 对象来直接访问 B 对象中的属性或者方法了。

通过对象 C 来访问对象 B 中的 name 属性时,V8 会先从对象 C 中查找,但是并没有查找到,接下来 V8 继续在其原型对象 B 中查找,因为对象 B 中包含了 name 属性,那么 V8 就直接返回对象 B 中的 name 属性值,虽然 C 和 B 是两个不同的对象,但是使用的时候,B 的属性看上就像是 C 的属性一样。

同样的方式,B 也是一个对象,它也有自己的 proto 属性,比如它的属性指向了内存中另外一块对象 A。

V8 会先在 C 对象内部查找,但是没有查找到,接着继续在 C 对象的原型对象 B 中查找,但是依然没有查找到,那么继续去对象 B 的原型对象 A 中查找。因为属性 在对象 A 中,那么 V8 就返回该属性值。

我们把这个查找属性的路径称为**原型链,**它像一个链条一样,将几个原型链接了起来。

构造函数是怎么创建对象

function DogFactory(type,color){
    this.type = type
    this.color = color
}
var dog = new DogFactory('Dog','Black')

// 等同以下代码
var dog = {}  // 首先,创建了一个空白对象 dog
dog.__proto__ = DogFactory.prototype // 然后,将 DogFactory 的 prototype 属性设置为 dog 的原型对象
DogFactory.call(dog,'Dog','Black') // 再使用 dog 来调用 DogFactory,这时候 DogFactory 函数中的 this 就指向了对象 dog

我们就把后面的函数称为构造函数,因为通过执行 new 配合一个函数,JavaScript 虚拟机便会返回一个对象。

function DogFactory(type,color){
    this.type = type
    this.color = color
    //Mammalia
}
// 继承
DogFactory. prototype.constant_temperature = 1
var dog1 = new DogFactory('Dog','Black')
var dog2 = new DogFactory('Dog','Black')
var dog3 = new DogFactory('Dog','Black')

作用域链

函数作用域和全局作用域

全局作用域是在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁的,直至 V8 退出。而函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之被销毁掉了。

每个函数在执行时都需要查找自己的作用域,我们称为函数作用域,在执行阶段,在执行一个函数时,当该函数需要使用某个变量或者调用了某个函数时,便会优先在该函数作用域中查找相关内容。

var x = 4
var test
function test_scope() {
    var name = 'foo'
    console.log(name)
    console.log(type)
    console.log(test)
    var type = 'function'
    test = 1
    console.log(x)
}
test_scope()  

如果在当前函数作用域中没有查找到变量,那么 V8 会去全局作用域中去查找,这个查找的线路就称为作用域链。

作用域链是怎么工作

首先当 V8 启动时,会创建全局作用域,全局作用域中包括了 this、window 等变量,还有一些全局的 Web API 接口。V8 启动之后,消息循环系统便开始工作了,这时候,我输入了代码,让其执行。

V8 会先编译顶层代码,在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中。

全局作用域创建完成之后,V8 便进入了执行状态。前面我们介绍了变量提升,因为变量提升的原因,你可以把这段代码分解为如下两个部分。

当 V8 执行 bar 函数的时候,同样需要经历两个阶段:编译和执行。在编译阶段,V8 会为 bar 函数创建函数作用域。同样,在编译 foo 函数的过程中,会创建 foo 函数的作用域。

这时候我们就有了三个作用域了,分别是全局作用域、bar 的函数作用域、foo 的函数作用域。

var name = '极客时间'
var type = 'global'
function foo(){
    var name = 'foo'
    console.log(name)
    console.log(type)
}
function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}
bar()


//======解析阶段--实现变量提升=======
var name = undefined
var type = undefined
function foo(){
    var name = 'foo'
    console.log(name)
    console.log(type)
}
function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}
//====执行阶段========
name = 'name'
type = 'global'
bar()

因为 JavaScript 是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。bar 和 foo 函数的外部代码都是全局代码,所以无论你是在 bar 函数中查找变量,还是在 foo 函数中查找变量,其查找顺序都是按照当前函数作用域–> 全局作用域这个路径来的。

因为词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以我们也将词法作用域称为静态作用域。

和静态作用域相对的是动态作用域,动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是基于函数定义的位置的。

JavaScript 类型系统是怎么工作

什么是类型系统 (Type System)

对机器语言来说,所有的数据都是一堆二进制代码,CPU 处理这些数据的时候,并没有类型的概念。而在高级语言中,我们都会为操作的数据赋予指定的类型,类型可以确认一个值或者一组值具有特定的意义和目的。所以,类型是高级语言中的概念。

每种语言都定义了自己的类型,还定义了如何操作这些类型,另外还定义了这些类型应该如何相互作用,我们就把这称为类型系统。

V8 会严格根据 ECMAScript 规范来执行操作。

执行加法操作的时候,V8 会通过 ToPrimitve 方法将对象类型转换为原生类型,最后就是两个原生类型相加,如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。在其他情况时,所有的值都会转换为数字类型值,然后做数字的相加。

V8 执行 JavaScript 代码时所需要的基础环境

在执行 JavaScript 代码之前,V8 就已经准备好了代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。准备好运行时环境之后,V8 才可以执行 JavaScript 代码,这包括解析源码、生成字节码、解释执行或者编译执行这一系列操作。

宿主环境

浏览器为 V8 提供基础的消息循环系统、全局变量、Web API,而 V8 的核心是实现了 ECMAScript 标准。V8 只提供了 ECMAScript 定义的一些对象和一些核心的函数,这包括了 Object、Function、String。V8 还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要宿主环境的配合才能完整执行。

堆空间和栈空间

栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。

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

堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,在前面的课程中我们也讲过,JavaScript 中除了原生类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有 window 对象、document 对象等,这些都是存在堆空间的。

全局执行上下文和全局作用域

执行上下文中主要包含了三部分,变量环境、词法环境、和 this 关键字。比如在浏览器的环境中,全局执行上下文中就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数,诸如 setTimeout、XMLHttpRequest 等内容。

而词法环境中,则包含了使用 let、const 等变量的内容。

全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了。

事件循环

V8需要有一个主线程,用来执行 JavaScript 和执行垃圾回收等工作。V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的。

只有一个主线程依然不行,因为如果你开启一个线程,在该线程执行一段代码,那么当该线程执行完这段代码之后,就会自动退出了,执行过程中的一些栈上的数据也随之被销毁,下次再执行另外一个段代码时,你还需要重新启动一个线程,重新初始化栈数据,这会严重影响到程序执行时的性能。

为了在执行完代码之后,让线程继续运行,通常的做法是在代码中添加一个循环语句,在循环语句中监听下个事件,比如你要执行另外一个语句,那么激活该循环就可以执行了。比如下面的模拟代码:

while(1){
  Task task = GetNewTask();
  RunTask(task);
}

这段代码使用了一个循环,不同地获取新的任务,一旦有新的任务,便立即执行该任务。

如果主线程正在执行一个任务,这时候又来了一个新任务,比如 V8 正在操作 DOM,这时候浏览器的网络线程完成了一个页面下载的任务,而且 V8 注册监听下载完成的事件,那么这种情况下就需要引入一个消息队列,让下载完成的事件暂存到消息队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务。当执行完一个任务之后,我们的事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务。

因为现代语言都是基于函数的,每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为调用栈。

因为栈在内存中连续的数据结构,所以在通常情况下,栈都有最大容量限制的,这也就意味着,函数的嵌套调用次数过多,就会超出栈的最大使用范围,从而导致栈溢出。

为了解决栈溢出的问题,我们可以使用 setTimeout 将要执行的函数放到其他的任务中去执行,也可以使用 Promise 来改变栈的调用方式,这涉及到了事件循环和微任务,我们会在后续课程中再来介绍。

函数调用是如何影响到内存布局

function foo() {
 foo() // 是否存在堆栈溢出错误?
}
foo()

function foo() {
  setTimeout(foo, 0) // 是否存在堆栈溢出错误?
}

function foo() {
    return Promise.resolve().then(foo)
}
foo()

第一段代第一段造成栈溢出的错误,第二段能够正确执行,而第三段没有栈溢出的错误,会造成页面的卡死。第一段代码是在同一个任务中重复调用嵌套的 foo 函数;第二段代码是使用 setTimeout 让 foo 函数在不同的任务中执行;第三段代码是在同一个任务中执行 foo 函数,但是却不是嵌套执行。

大部分高级语言都不约而同地采用栈这种结构来管理函数调用,这与函数的特性有关。通常函数有两个主要的特性:

  • 第一个特点是函数可以被调用,你可以在一个函数中调用另外一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,又会将代码执行控制权返还给父函数;

  • 第二个特点是函数具有作用域机制,所谓作用域机制,是指函数在执行的时候可以将定义在函数内部的变量和外部环境隔离,在函数内部定义的变量我们也称为临时变量,临时变量只能在该函数中被访问,外部函数通常无权访问,当函数执行结束之后,存放在内存中的临时变量也随之被销毁。

函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束 (先出)。

站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数 (后进),而函数资源的释放则总是先于调用函数 (先出)。

它们都符合后进先出 (LIFO) 的策略,而栈结构正好满足这种后进先出 (LIFO) 的需求,所以我们选择栈来管理函数调用关系是一种很自然的选择。

函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中。

栈帧,每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。

V8是如何实现闭包

惰性解析

惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

主要是基于以下两点:

  • 如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度。

  • 解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存

惰性解析的过程

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

V8 会至上而下解析这段代码,在解析过程中首先会遇到 foo 函数,由于这只是一个函数声明语句,V8 在这个阶段只需要将该函数转换为函数对象。

这里只是将该函数声明转换为函数对象,但是并有没有解析和编译函数内部的代码,所以也不会为 foo 函数的内部代码生成抽象语法树。

然后继续往下解析,由于后续的代码都是顶层代码,所以 V8 会为它们生成抽象语法树。

代码解析完成之后,V8 便会按照顺序自上而下执行代码。

...

foo 函数的调用,过程是从 foo 函数对象中取出函数代码,然后和编译顶层代码一样,V8 会先编译 foo 函数的代码,编译时同样需要先将其编译为抽象语法树和字节码,然后再解释执行。

闭包

JavaScript 的三个特性

  • JavaScript 允许在函数内部定义新的函数

  • 在内部函数中访问父函数中定义的变量

  • 函数可以作为返回值

每个函数有自己的词法作用域,该函数中定义的变量都存在于该作用域中,然后 V8 会将这些作用域按照词法的位置,也就是代码位置关系,将这些作用域串成一个链,这就是词法作用域链,查找变量的时候会沿着词法作用域链的途径来查找。

闭包给惰性解析带来的问题

function foo() {
    var d = 20
    return function inner(a, b) {
        const c = a + b + d
        return c
    }
}
const f = foo()

执行过程:

  1. 当调用 foo 函数时,foo 函数会将它的内部函数 inner 返回给全局变量 f;

  2. 然后 foo 函数执行结束,执行上下文被 V8 销毁了;

  3. 虽然 foo 函数的执行上下文被销毁了,但是依然存活的 inner 函数引用了 foo 函数作用域中的变量 d。

当 foo 执行结束时,变量 d 该不该被销毁?如果采用了惰性解析,那么当执行到 foo 函数时,V8 只会解析 foo 函数,并不会解析内部的 inner 函数,那么这时候 V8 就不知道 inner 函数中是否引用了 foo 函数的变量 d。

在执行 foo 函数的阶段,虽然采取了惰性解析,不会解析和执行 foo 函数中的 inner 函数,但是 V8 还是需要判断 inner 函数是否引用了 foo 函数中的变量,负责处理这个任务的模块叫着预解析器。

预解析器

V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。

  • 是判断当前函数是不是存在一些语法上的错误。

  • 除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。

V8 是如何执行回调函数的

回调函数

其实回调函数也是个函数。回调函数区别于普通函数,在于它的调用方式。只有当某个函数被作为参数,传递给另外一个函数,或者传递给宿主环境,然后该函数在函数内部或者在宿主环境中被调用,我们才称为回调函数。

回调函数有两种类型,同步回调和异步回调。同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。

UI 线程的宏观架构

早期浏览器的页面是运行在一个单独的 UI 线程中的,所以要在页面中引入 JavaScript,那么 JavaScript 也必须要运行在和页面相同的线程上,这样才能方便使用 JavaScript 来操纵 DOM,所以从一开始,JavaScript 就被设计成了运行在 UI 线程中。

UI 线程是指运行窗口的线程,当你运行一个窗口时,无论该页面是 Windows 上的窗口系统,还是 Android 或者 iOS 上的窗口系统,它们都需要处理各种事件,诸如有触发绘制页面的事件,有鼠标点击、拖拽、放大缩小的事件,有资源下载、文件读写的事件,等等。

在大部分情况下,UI 线程并不能立即响应和处理这些事件,比如在你在移动鼠标的过程中,每移动一个像素都会产生一个事件,所以鼠标移动的事件会频繁地被触发。在这种情况下,页面线程可能正在处理前一个事件,那么最新的事件就无法被立即执行。

针对这种情况,我们为 UI 线程提供一个消息队列,并将这些待执行的事件添加到消息队列中,然后 UI 线程会不断循环地从消息队列中取出事件、执行事件。我们把 UI 线程每次从消息队列中取出事件,执行事件的过程称为一个任务。

function UIMainThread() {
    while (queue.waitForMessage()) {
        Task task = queue.getNext()
        processNextMessage(task)
    }
}

异步回调函数的调用时机

异步回调函数并不是在它的执行函数内部被执行的,而是在其他的位置和其他的时间点被执行的,比如下面这段 setTimeout 代码:

function foo() {
    alert("Hello");
}
setTimeout(foo, 3000)

主线程会不间断地从消息队列中取出新的任务,执行新的任务,等到时机合适,便取出 setTimeout 设置的foo 函数的回调的任务,然后就可以直接执行 foo 函数的调用了。

微任务

宏任务

宏任务是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。

微任务

微任务稍微复杂一点,其实你可以把微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

主线程执行消息队列中宏任务的时间颗粒度太粗,无法胜任一些对精度和实时性要求较高的场景,微任务可以在实时性和效率之间做一个有效的权衡。另外,使用微任务,可以改变我们现在的异步编程模型,使得我们可以使用同步形式的代码来编写异步调用。

V8 会为每个宏任务维护一个微任务队列。当 V8 执行一段 JavaScript 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。当你通过 Promise.resolve 生成一个微任务,该微任务会被 V8 自动添加进微任务队列,等整段代码快要执行结束时,该环境对象也随之被销毁,但是在销毁之前,V8 会先处理微任务队列中的微任务。

如果当前的任务中产生了一个微任务,通过 Promise.resolve() 或者 Promise.reject() 都会触发微任务,触发的微任务不会在当前的函数中被执行,所以执行微任务时,不会导致栈的无限扩张;

和异步调用不同,微任务依然会在当前任务执行结束之前被执行,这也就意味着在当前微任务执行结束之前,消息队列中的其他任务是不可能被执行的。

这节课我们主要从调用栈、主线程、消息队列这三者关联的角度来分析了微任务。

调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。主线在执行任务的过程中,如果函数的调用层次过深,可能造成栈溢出的错误,我们可以使用 setTimeout 来解决栈溢出的问题。

setTimeout 的本质是将同步函数调用改成异步函数调用,这里的异步调用是将回调函数封装成宏任务,并将其添加进消息队列中,然后主线程再按照一定规则循环地从消息队列中读取下一个宏任务。

消息队列中事件又被称为宏任务,不过,宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,而微任务可以在实时性和效率之间做有效的权衡。

微任务之所以能实现这样的效果,主要取决于微任务的执行时机,微任务其实是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

因为微任务依然是在当前的任务中执行的,所以如果在微任务中循环触发新的微任务,那么将导致消息队列中的其他任务没有机会被执行。

function foo() {
  return Promise.resolve().then(foo)
}
foo()

这个循环就会一直持续下去,当前的宏任务无法退出,也就意味着消息队列中其他的宏任务是无法被执行的,比如通过鼠标、键盘所产生的事件。这些事件会一直保存在消息队列中,页面无法响应这些事件,具体的体现就是页面的卡死。

async/await

生成器函数的暂停执行和恢复

生成器函数是一个带星号函数,配合 yield 就可以实现函数的暂停和恢复

function* getResult() {
    yield 'getUserID'
    yield 'getUserName'
    return 'name'
}
let result = getResult()
console.log(result.next().value)
console.log(result.next().value)
console.log(result.next().value)

你会发现函数 getResult 并不是一次执行完的,而是全局代码和 生成器函数交替执行。生成器函数的特性,在生成器内部,如果遇到 yield 关键字,那么 V8 将返回关键字后面的内容给外部,并暂停该生成器函数的执行。生成器暂停执行后,外部的代码便开始执行,外部代码如果想要恢复生成器的执行,可以使用 result.next 方法。

这背后的魔法就是协程,协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。

比如,当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。

最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

JavaScript 中,生成器就是协程的一种实现方式,这样,你也就理解什么是生成器了。

由于生成器函数可以暂停,因此我们可以在生成器内部编写完整的异步逻辑代码,这一点非常不友好。ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。

async 是一个通过异步执行并隐式返回 Promise作为结果的函数。

垃圾回收

垃圾回收算法

第一步,通过 GC Root 标记空间中活动对象和非活动对象。

目前 V8 采用的可访问性(reachability)算法来判断堆中的对象是否是活动对象。具体地讲,这个算法是将一些 GC Root 作为初始存活的对象的集合,从 GC Roots 对象出发,遍历 GC Root 中的所有对象:

  • 通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable),那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象;

  • 通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable),那么这些不可访问的对象就可能被回收,我们称不可访问的对象为非活动对象。

在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种):

  • 全局的 window 对象(位于每个 iframe 中);

  • 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;

  • 存放栈上变量。

第二步,回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。

第三步,做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况,所以最后一步需要整理这些内存碎片。但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。

代际假说

目前 V8 采用了两个垃圾回收器,主垃圾回收器 -Major GC 和副垃圾回收器 -Minor GC (Scavenger)。V8 之所以使用了两个垃圾回收器,主要是受到了**代际假说(The Generational Hypothesis)**的影响。

代际假说以下两个特点:

  • 第一个是大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;

  • 第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。

如果我们只使用一个垃圾回收器,在优化大多数新对象的同时,就很难优化到那些老对象,因此你需要权衡各种场景,根据对象生存周期的不同,而使用不同的算法,以便达到最好的效果。在 V8 中,会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。

  • 副垃圾回收器 -Minor GC (Scavenger),主要负责新生代的垃圾回收。

  • 主垃圾回收器 -Major GC,主要负责老生代的垃圾回收。

副垃圾回收器

副垃圾回收器主要负责新生代的垃圾回收。通常情况下,大多数小的对象都会被分配到新生代,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。

Scavenge 算法

新生代中的垃圾数据用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域 (from-space),一半是空闲区域 (to-space)

  1. 新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。在垃圾回收过程中,首先要对对象区域中的垃圾做标记;

  2. 标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

  3. 完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时,这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。

主垃圾回收器

主垃圾回收器主要负责老生代中的垃圾回收。除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。

老生代中的对象有两个特点:

  • 一个是对象占用空间大;

  • 另一个是对象存活时间长。

由于老生代的对象比较大,若要在老生代中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。所以,主垃圾回收器是采用**标记 - 清除(Mark-Sweep)**的算法进行垃圾回收的。

标记 - 清除算法

  1. 标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

  2. 接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉。

对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法——标记 - 整理(Mark-Compact)。

标记 - 整理(Mark-Compact)

这个算法的标记过程仍然与标记 - 清除算法里的是一样的,先标记可回收对象,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存。

全停顿(Stop-The-World)

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

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

现有的垃圾回收器添加并行、并发和增量等垃圾回收技术。这些技术主要是从两方面来解决垃圾回收效率问题的:

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

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

所谓并行回收,是指垃圾回收器在主线程上执行的过程中,还会开启多个协助线程,同时执行同样的回收工作。

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

在没有采用增量算法之前,V8 使用黑色和白色来标记数据。在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots 出发,将所有能访问到的数据标记为黑色。

如果内存中的数据只有两种状态,非黑即白,那么当你暂停了当前的垃圾回收器之后,再次恢复垃圾回收器,那么垃圾回收器就不知道从哪个位置继续开始执行了。

为了解决这个问题,V8 采用了**三色标记法,**除了黑色和白色,还额外引入了灰色:

  • 黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了 ;

  • 灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点;

  • 白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。

引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。

上一页HTTP 协议下一页浏览器工作原理与实践

最后更新于1年前