Loading

了解 Google V8

V8如何执行一段JavaScript代码

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

指令,指令集,也就是机器语言。CPU 只能识别二进制的指令。
汇编指令集->汇编编译器->机器代码(二进制)

  • 解释执行:先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果
  • 编译执行:需要先将源代码转换为中间代码,编译器再将中间代码编译成机器代码。

V8 是怎么执行 JavaScript 代码的?

混合使用编译器和解释器的技术称为 JIT(Just In Time)技术。

  • 初始化基础环境,这些基础环境包括了“堆空间”“栈空间”“全局执行上下文”“全局作用域”“消息循环系统”“内置函数”等;
  • 结构化源码字符串。结构化,是指信息经过分析后可分解成多个互相关联的组成部分,各组成部分间有明确的层次结构,方便使用和维护,并有一定的操作规范。
  • 解析器解析源码生成作用域和 AST;
  • 依据 AST 和作用域生成字节码(中间代码);
  • 解释器执行字节码(可生成结果);
  • 监听热点代码;
  • 编译器优化热点代码为二进制的机器代码(可生成结果);
  • 如果数据改动编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。

函数即对象

JavaScript 中的函数就是一种特殊的对象,称为一等公民 (First Class Function)。

什么是 JavaScript 中的对象

  • JavaScript和面向对象语言
    JavaScript 是一门基于对象 (Object-Based) 的语言,可以说 JavaScript 中大部分的内容都是由对象构成的,诸如函数、数组,也可以说 JavaScript 是建立在对象之上的语言。JavaScript 是基于对象设计的,但是它却不是一门面向对象的语言 (Object-Oriented Programming Language),因为面向对象语言天生支持封装、继承、多态,但是 JavaScript 并没有直接提供多态的支持,因此要在 JavaScript 中使用多态并不是一件容易的事。
  • 简单理解JavaScript 中的对象
    JavaScript 中的对象非常简单,每个对象就是由一组组属性和值构成的集合,值是任意类型的数据
  • 第一种是原始类型 (primitive),所谓的原始类的数据,是指值本身无法被改变,比如 JavaScript 中的字符串就是原始类型,如果你修改了 JavaScript 中字符串的值,那么 V8 会返回给你一个新的字符串,原始字符串并没有被改变,我们称这些类型的值为“原始值”。
    JavaScript 中的原始值主要包括 null、undefined、boolean、number、string、bigint、symbol 这七种。
  • 第二种就是我们现在介绍的对象类型 (Object),对象的属性值也可以是另外一个对象,比如上图中的 info 属性值就是一个对象。
  • 第三种是函数类型 (Function),如果对象中的属性值是函数,那么我们把这个属性称为方法,所以我们又说对象具备属性和方法,那么上图中的 showinfo 就是 person 对象的一个方法。

函数的本质

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

同时有隐藏属性:name、code
如果一个函数没有设置函数名,即

(function (){
    var test = 1
    console.log(test)
})()

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

函数是一等公民

如果某个编程语言的函数可以和它的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民。如:函数可以赋值给一个变量,也可以作为函数的参数,还可以作为函数的返回值。如果某个编程语言的函数可以和它的数据类型做一样的事情,就把这个语言中的函数称为一等公民。

快属性和慢属性:V8采用了哪些策略提升了对象属性的访问速度

常规属性 (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'
}
var bar = new Foo()
for(key in bar){
    console.log(`index:${key}  value:${bar[key]}`)
}

index:1  value:test-1
index:3  value:test-3
index:5  value:test-5
index:8  value:test-8
index:9  value:test-9
index:50  value:test-50
index:100  value:test-100
index:B  value:bar-B
index:A  value:bar-A
index:C  value:bar-C

之所以出现这样的结果,是因为在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。
在这里把对象中的数字属性称为排序属性,在 V8 中被称为 elements,字符串属性就被称为常规属性,在 V8 中被称为 properties。(对象中的隐藏属性:elements和properties,无法访问)
在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性(属性较多时会使用非线性结构)

快属性和慢属性


将不同的属性分别保存到 elements 属性和 properties 属性中,无疑简化了程序的复杂度,但是在查找元素时,却多了一步操作,比如执行 bar.B这个语句来查找 B 的属性值,那么在 V8 会先查找出 properties 属性所指向的对象 properties,然后再在 properties 对象中查找 B 属性,这种方式在查找过程中增加了一步操作,因此会影响到元素的查找效率。
基于这个原因,V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties)。
不过对象内属性的数量是固定的,默认是 10 个,如果添加的属性超出了对象分配的空间,则它们将被保存在常规属性存储中。虽然属性存储多了一层间接层,但可以自由地扩容。
通常,将保存在线性数据结构中的属性称之为“快属性”,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果从线性结构中添加或者删除大量的属性时,则执行效率会非常低,这主要因为会产生大量时间和内存开销。
如果一个对象的属性过多时,V8 为就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。

隐藏属性:elements(排序属性)、properties(常规属性)、map(隐藏类)、proto(原型)

函数表达式:涉及大量概念,函数表达式到底该怎么学?

![](https://static001.geekbang.org/resource/image/51/31/51ae06e8a9dc4a589958065429bec231.jpg 函数声明和函数表达式)

函数声明

foo()
function foo(){
  console.log('foo')
}
// 正确执行

函数表达式

  • 函数表达式是在表达式语句中使用 function 的,最典型的表达式是“a=b”这种形式,因为函数也是一个对象,我们把“a = function (){}”这种方式称为函数表达式;
  • 在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions);
  • 一个函数表达式可以被用作一个即时调用的函数表达式——IIFE(Immediately Invoked Function Expression)。
foo()
var foo = function (){
    console.log('foo')
}
// 报错,原因foo是undefined

如何处理函数声明

在编译阶段,如果解析到函数声明,那么 V8 会将这个函数声明转换为内存中的函数对象,并将其放到作用域中。同样,如果解析到了某个变量声明,也会将其放到作用域中,但是会将其值设置为 undefined,表示该变量还未被使用。然后在 V8 执行阶段,如果使用了某个变量,或者调用了某个函数,那么 V8 便会去作用域查找相关内容。
如下:

// test.js
var x = 5
function foo(){
    console.log('Foo')
}

使用“d8 --print-scopes test.js”命令即可查看作用域的状态

Global scope:
global { // (0x7fb62281ca48) (0, 50)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0x7fb62281cfe8) local[0]
  // local vars:
  VAR x;  // (0x7fb62281cc98)
  VAR foo;  // (0x7fb62281cf40)
  function foo () { // (0x7fb62281cd50) (22, 50)
    // lazily parsed
    // 2 heap slots
  }
}

如图:
执行上述代码流程示意图
编译阶段,将所有的变量提升到作用域的过程称为变量提升
上述代码,变量提升后,普通变量 x 的值就是 undefined,而函数对象 foo 的值则是完整的对象。

表达式和语句

简单地理解,表达式就是表示值的式子,而语句是操作值的式子

// 表达式,因为执行这段代码,它会返回一个值
x = 5

表达式是不会在编译阶段执行的

// 这就是一个语句,执行该语句时,并不会返回任何值
var x
// 语句
function foo(){
  return 1
}

函数声明是语句,在变量提升阶段会特殊对待:V8 就会将整个函数对象提升到作用域中,并不是给该函数名称赋一个 undefined

表达式和语句

V8解析

在 V8 执行var x = 5这段代码时,会认为它是两段代码,一段是定义变量的语句,一段是赋值的表达式。var x 是在编译阶段完成的,也可以说是在变量提升阶段完成的,而x = 5是表达式,所有的表达式都是在执行阶段完成的,在变量提升阶段,V8 并不会执行赋值的表达式,该阶段只会分析基础的语句。

如何处理函数表达式

与声明变量处理方式相同

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

JavaScript 中有一个圆括号运算符,圆括号里面可以放一个表达式(a=3),
如果在小括号里面放上一段函数的定义


(function () {
    //statements
})

因为小括号之间存放的必须是表达式,所以如果在小阔号里面定义一个函数,那么 V8 就会把这个函数看成是函数表达式,执行时它会返回一个函数对象。
存放在括号里面的函数便是一个函数表达式,它会返回一个函数对象,如果我直接在表达式后面加上调用的括号,这就称立即调用函数表达式(IIFE)。

(function () {
    //statements
})()

好处:不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到

函数声明和函数表达式的本质区别

函数声明的本质是语句,而函数表达式的本质则是表达式

原型链: V8是如何实现对象继承的

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

原型继承

JavaScript 的每个对象都包含了一个隐藏属性 __proto__ ,就把该隐藏属性 __proto__ 称之为该对象的原型 (prototype),__proto__ 指向了内存中的另外一个对象,就把 __proto__ 指向的对象称为该对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性。实际上这些属性都是位于原型对象上,我们把这个查找属性的路径称为原型链
在这里还要注意一点,不要将原型链接和作用域链搞混淆了,作用域链是沿着函数的作用域一级一级来查找变量的,而原型链是沿着对象的原型一级一级来查找属性的,虽然它们的实现方式是类似的,但是它们的用途是不同的。

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

__proto__实现继承(不推荐)

var animal = {
    type: "Default",
    color: "Default",
    getInfo: function () {
        return `Type is: ${this.type},color is ${this.color}.`
    }
}
var dog = {
    type: "Dog",
    color: "Black",
}
// 继承
dog.__proto__ = animal
dog.getInfo()

注意:通常隐藏属性是不能使用 JavaScript 来直接与之交互的。虽然现代浏览器都开了一个口子,让 JavaScript 可以访问隐藏属性 proto,但是在实际项目中,不应该直接通过 proto 来访问或者修改该属性,其主要原因有两个:

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

构造函数是怎么创建对象的

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

模拟上述代码如下:

var dog = {}
dog.__proto__ = DogFactory.prototype
DogFactory.call(dog,'Dog','Black')
  • 首先,创建了一个空白对象 dog;
  • 然后,将 DogFactory 的 prototype 属性设置为 dog 的原型对象,这就是给 dog 对象设置原型对象的关键一步;
  • 最后,再使用 dog 来调用 DogFactory,这时候 DogFactory 函数中的 this 就指向了对象 dog,然后在 DogFactory 函数中,利用 this 对对象 dog 执行属性填充操作,最终就创建了对象 dog。

构造函数怎么实现继承

函数的隐藏属性prototype,每个函数对象中都有一个公开的 prototype 属性,当你将这个函数作为构造函数来创建一个新的对象时,新创建对象的原型对象就指向了该函数的 prototype 属性。当然了,如果你只是正常调用该函数,那么 prototype 属性将不起作用。当你通过一个构造函数(new关键字)创建多个对象的时候,这几个对象的原型都指向了该函数的 prototype 属性

function DogFactory(type,color){
    this.type = type
    this.color = color
}
// 这一行代码继承了name属性
DogFactory.prototype.name = 'dog'
var dog = new DogFactory('Dog','Black')
dog.name

对象的实例没有prototype属性

问题

DogFactory 是一个函数,那么DogFactory.prototypeDogFactory.__proto__这两个属性之间有关联吗?
DogFactory 是 Function 构造函数的一个实例,所以 DogFactory.__proto__ === Function.prototype
DogFactory.prototype 是调用 Object 构造函数的一个实例,所以 DogFactory.prototype.__proto__ === Object.prototype

作用域链:V8是如何查找变量的

作用域就是存放变量和函数,作用域链,实际就是按照什么路径查找变量。

函数作用域和全局作用域

每个函数在执行时都需要查找自己的作用域,称为函数作用域,在执行阶段,在执行一个函数时,当该函数需要使用某个变量或者调用了某个函数时,便会优先在该函数作用域中查找相关内容。
全局作用域和函数作用域类似,也是存放变量和函数的地方,但是还是有点不一样: 全局作用域是在 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()

test_scope 函数作用域中包含:name、type变量,另外一个隐藏变量 this(默认存放在作用域中),test=1,并没有采用 var 等关键字来声明,所以 test=1 并不会出现在 test_scope 函数的作用域中,而是属于 this 所指向的对象。如果在当前函数作用域中没有查找到变量,那么 V8 会去全局作用域中去查找,这个查找的线路就称为作用域链。

作用域链是怎么工作的

avaScript 是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。因为词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以也将词法作用域称为静态作用域。

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

类型转换:V8是怎么实现1+“2”的

什么是类型系统 (Type System)?

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

类型系统:在计算机科学中,类型系统(type system)用于定义如何将编程语言中的数值和表达式归类为许多不同的类型,如何操作这些类型,这些类型如何互相作用。
直观地理解,一门语言的类型系统定义了各种类型之间应该如何相互操作

V8 执行加法操作

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

ECMAScript 加法参考规范(翻译)
1. 把第一个表达式 (AdditiveExpression) 的值赋值给左引用 (lref)。
2. 使用 GetValue(lref) 获取左引用 (lref) 的计算结果,并赋值给左值。
3. 使用ReturnIfAbrupt(lval) 如果报错就返回错误。
4. 把第二个表达式 (MultiplicativeExpression) 的值赋值给右引用 (rref)。
5. 使用 GetValue(rref) 获取右引用 (rref) 的计算结果,并赋值给 rval。
6. 使用ReturnIfAbrupt(rval) 如果报错就返回错误。
7. 使用 ToPrimitive(lval) 获取左值 (lval) 的计算结果,并将其赋值给左原生值 (lprim)。
8. 使用 ToPrimitive(rval) 获取右值 (rval) 的计算结果,并将其赋值给右原生值 (rprim)。
9. 如果 Type(lprim) 和 Type(rprim) 中有一个是 String,则:
	a. 把 ToString(lprim) 的结果赋给左字符串 (lstr);
	b. 把 ToString(rprim) 的结果赋给右字符串 (rstr);
	c. 返回左字符串 (lstr) 和右字符串 (rstr) 拼接的字符串。
10. 把 ToNumber(lprim) 的结果赋给左数字 (lnum)。
11. 把 ToNumber(rprim) 的结果赋给右数字 (rnum)。
12. 返回左数字 (lnum) 和右数字 (rnum) 相加的数值。


在执行加法操作的时候,V8 会通过 ToPrimitve 方法将对象类型转换为原生类型,(ToPrimitve 会优调用对象中的 valueOf 方法,返回Number类型,当不存在valueOf时,继续调用 toString 方法,(如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误)返回String类型)最后就是两个原生类型相加,如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。

运行时环境:运行JavaScript代码的基石


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

宿主环境

浏览器为 V8 提供基础的消息循环系统、全局变量、Web API,而 V8 的核心是实现了 ECMAScript 标准。除此之外,V8 还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要宿主环境的配合才能完整执行。Node.js 也是 V8 的另外一种宿主环境,它提供了不同的宿主对象和宿主的 API,但是整个流程依然是相同的。

构造数据存储空间:堆空间和栈空间

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

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

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

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

当 V8 开始执行一段可执行代码时,会生成一个执行上下文。V8 用执行上下文来维护执行当前代码所需要的变量声明、this 指向等。
执行上下文中主要包含了三部分,变量环境、词法环境、和 this 关键字。比如在浏览器的环境中,全局执行上下文中就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数,诸如 setTimeout、XMLHttpRequest 等内容。
而词法环境中,则包含了使用 let、const 等变量的内容。

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

var x = 5
{
    let y = 2
    const z = 3
}

这段代码在执行时,就会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中。可以把作用域看成是一个抽象的概念,比如在 ES6 中,同一个全局执行上下文中,都能存在多个作用域。

var x = 1
function show_x(){
    console.log(x)
}
function bar(){
  show_x()
}
bar()

当 V8 调用了一个函数时,就会进入函数的执行上下文,这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构。(全局执行上下文->bar执行上下文->show_x执行上下文)

构造事件循环系统

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

this:从JavaScript执行上下文的视角讲清楚this

在对象内部的方法中使用对象内部的属性是一个非常普遍的需求->this 机制

JavaScript 中的 this 是什么

this 是和执行上下文绑定的,执行上下文主要分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文,所以对应的 this 也只有这三种——全局执行上下文中的 this、函数中的 this 和 eval 中的 this。

全局执行上下文中的 this

this 是指向 window 对象

函数执行上下文中的 this

默认情况下,函数中的 this 也是指向 window 对象

  1. 通过函数的 call bind apply方法设置
  2. 通过对象调用方法设置
    • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
    • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。
  3. 通过构造函数中设置

this 的设计缺陷以及应对方案

  1. 嵌套函数中的 this 不会从外层函数中继承
var myObj = {
  name : " 极客时间 ",
  showThis: function(){
    console.log(this) // 此时的this指向myObj
    let self = this
    function bar(){
      console.log(this) // 此时的this指向window
      console.log(self) // 此时的self指向myObj
    }
    bar()
  }
}
myObj.showThis()

也可以使用 ES6 中的箭头函数来解决这个问题

// 替换上述代码bar函数
function bar=()=>{
      console.log(this) // 此时的this指向myObj
    }
  1. 普通函数中的 this 默认指向全局对象 window
    如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。或者使用“严格模式”, this 值是 undefined

机器代码:二进制机器码究竟是如何被CPU执行的


V8 首先需要将 JavaScript编译成字节码或者二进制代码,然后再执行

将源码编译成机器码

通常我们将汇编语言编写的程序转换为机器语言的过程称为“汇编”;反之,机器语言转化为汇编语言的过程称为“反汇编”。这一大堆指令按照顺序集合在一起就组成了程序,所以程序的执行,本质上就是 CPU 按照顺序执行这一大堆指令的过程。

CPU 是怎么执行程序的


首先,在程序执行之前,我们的程序需要被装进内存,CPU 可以通过指定内存地址,从内存中读取数据,或者往内存中写入数据,内存中的每个存储空间都有其对应的独一无二的地址,一旦二进制代码被装载进内存,CPU 便可以从内存中取出一条指令,然后分析该指令,最后执行该指令,我们把取出指令、分析指令、执行指令这三个过程称为一个 CPU 时钟周期。

  • 寄存器:CPU 中有一个 PC 寄存器,它保存了将要执行的指令地址,当二进制代码被装载进了内存之后,系统会将二进制代码中的第一条指令的地址写入到 PC 寄存器中,到了下一个时钟周期时,CPU 便会根据 PC 寄存器中的地址,从内存中取出指令。
  • 取出指令后立即更新下一个内存地址,
  • 更新了 PC 寄存器之后,CPU 就会立即做第二件事,那就是分析该指令,并识别出不同的类型的指令,以及各种获取操作数的方法。在指令分析完成之后,就要执行指令了。
  • 通用寄存器是 CPU 中用来存放数据的设备,不同处理器中寄存器的个数也是不一样的,之所要通用寄存器,是因为 CPU 访问内存的速度很慢,所以 CPU 就在内部添加了一些存储设备,这些设备就是通用寄存器。通用寄存器通常用来存放数据或者内存中某块数据的地址,把这个地址又称为指针。rbp 寄存器通常用来存放栈帧指针的,rsp 寄存器用来存放栈顶指针的,PC 寄存器用来存放下一条要执行的指令
  • 执行指令
    • 指令类型
      • 第一种是加载的指令,其作用是从内存中复制指定长度的内容到通用寄存器中,并覆盖寄存器中原来的内容
      • 第二种存储的指令,和加载类型的指令相反,其作用是将寄存器中的内容复制内存某个位置,并覆盖掉内存中的这个位置上原来的内容。
      • 第三种是更新指令,其作用是复制两个寄存器中的内容到 ALU 中,也可以是一块寄存器和一块内存中的内容到 ALU 中,ALU 将两个字相加,并将结果存放在其中的一个寄存器中,并覆盖该寄存器中的内容。
      • 跳转指令,从指令本身抽取出一个字,这个字是下一条要执行的指令的地址,并将该字复制到 PC 寄存器中,并覆盖掉 PC 寄存器中原来的值。那么当执行下一条指令时,便会跳转到对应的指令了。

通用寄存器容量小,读写速度快,内存容量大,读写速度慢。

堆和栈:函数调用是如何影响到内存布局的

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

function foo() {
  setTimeout(foo, 0) // 是否存在堆栈溢出错误?
}
// 不报错 正常执行

function foo() {
    return Promise.resolve().then(foo)
}
// 不报错 页面卡死

三段代码执行逻辑:
第一段代码是在同一个任务中重复调用嵌套的 foo 函数;foo 会不断生成不会销毁
第二段代码是使用 setTimeout 让 foo 函数在不同的任务中执行;foo 会一直在栈中销毁再生成
第三段代码是在同一个任务中执行 foo 函数,但是却不是嵌套执行。foo 函数会维护一个微任务队列,即先入队先执行(销毁),那么会一直入队销毁,页面卡死
V8 执行这三种不同代码时,它们的内存布局是不同的,而不同的内存布局又会影响到代码的执行逻辑。
解析执行字节码时使用了堆栈和CPU执行二进制代码时使用了堆栈。

为什么使用栈结构来管理函数调用?

  • 可以被调用,可以在一个函数中调用另外一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,又会将代码执行控制权返还给父函数;
  • 具有作用域机制,所谓作用域机制,是指函数在执行的时候可以将定义在函数内部的变量和外部环境隔离,在函数内部定义的变量我们也称为临时变量,临时变量只能在该函数中被访问,外部函数通常无权访问,当函数执行结束之后,存放在内存中的临时变量也随之被销毁。
  • 函数执行特点:所以站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数 (后进),而函数资源的释放则总是先于调用函数 (先出)。

栈如何管理函数调用?

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


需要将栈的状态恢复到 main 函数上次执行时的状态,这个过程叫恢复现场
寄存器中保存一个永远指向当前栈顶的指针,栈顶指针的作用就是应该往哪个位置添加新元素,这个指针通常存放在 esp 寄存器中。
![add函数即将执行结束的状态](https://static001.geekbang.org/resource/image/68/bd/68b9d297cc48864ad49c1915766fa6bd.jpg add函数即将执行结束的状态)
![恢复mian函数执行现场](https://static001.geekbang.org/resource/image/f2/71/f2a1ba351552440f44ca9698e8fe1971.jpg 恢复mian函数执行现场)
CPU 想要移动到这个地址还需要 ebp 寄存器,用来保存当前函数的起始位置,把一个函数的起始位置也称为栈帧指针,ebp 寄存器中保存的就是当前函数的栈帧指针。
![ebp寄存器保存了栈帧指针](https://static001.geekbang.org/resource/image/89/d2/89180f0674a92df96ce6f25813020ed2.jpg ebp寄存器保存了栈帧指针)
接下来还需要恢复 main 函数的栈帧指针,通常的方法是在 main 函数中调用 add 函数时,CPU 会将当前 main 函数的栈帧指针保存在栈中

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

详细分析了 C 函数的执行过程,在 JavaScript 中,函数的执行过程也是类似的,如果调用一个新函数,那么 V8 会为该函数创建栈帧,等函数执行结束之后,销毁该栈帧,而栈结构的容量是固定的,所有如果重复嵌套执行一个函数,那么就会导致栈会栈溢出。

管理函数调用过程的栈结构称之为调用栈

延迟解析:V8是如何实现闭包的

V8 执行 JavaScript 代码,需要经过编译和执行两个阶段,其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字节码,或者是 CPU 直接执行二进制机器代码的阶段。
惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

拆解闭包——JavaScript 的三个特性

  • JavaScript 语言允许在函数内部定义新的函数
  • 内部函数中访问父函数中定义的变量
  • 因为函数是一等公民,所以函数可以作为返回值

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

function foo() {
    var d = 20
    return function inner(a, b) {
        const c = a + b + d
        return c
    }
}
const f = foo()
  • foo函数执行上下文销毁,但变量d不能销毁
  • 惰性解析如何得知变量d是否在inner函数中并且不能销毁呢?

预解析器如何解决闭包所带来的问题?

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

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

V8为什么又重新引入字节码?

v8字节码的作用

  • 第一个是解释器可以直接解释执行字节码 ;
  • 第二个是优化编译器可以将字节码编译为二进制代码,然后再执行二进制机器代码。
    早期的v8执行流程:

    第一个是基线编译器,它负责将 JavaScript 代码编译为没有优化过的机器代码。
    第二个是优化编译器,它负责将一些热点代码(执行频繁的代码)优化为执行效率更高的机器代码。
    次方式已经抛弃,目前使用字节码 + 解释器 + 编译器方式

字节码降低了内存占用

在早期,Chrome 做了两件事来提升 JavaScript 代码的执行速度:

  • 第一,将运行时将二进制机器代码缓存在内存中;
  • 第二,当浏览器退出时,缓存编译之后二进制代码到磁盘上。
    ![二进制代码缓存](https://static001.geekbang.org/resource/image/a6/60/a6f2ea6df895eb6940a9db95f54fa360.jpg 二进制代码缓存)
    将 JavaScript 源码直接编译成二进制代码存在两个致命的问题:
  • 时间问题:编译时间过久,影响代码启动速度;
  • 空间问题:缓存编译后的二进制代码占用更多的内存。
    引入中间的字节码:
  • 解决启动问题:生成字节码的时间很短;
  • 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
  • 代码架构清晰:采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易。

解释器是如何解释执行字节码的?

生成字节码

function add(x, y) {
  var z = x+y
  return z
}
console.log(add(1, 2))
  • 解析成AST
    • 第一部分为参数的声明 (PARAMS)
    • 第二部分是变量声明节点 (DECLS)
    • 第三部分是 x+y 的表达式节点
    • 第四部分是 RETURN 节点
  • 同时生成作用域
    作用域中的变量都是未使用的,默认值都是 undefined,在执行阶段,作用域中的变量会指向堆和栈中相应的数据,作用域和实际数据的关系如下图所示:
  • 生成字节码
    AST 之后会被作为输入传到字节码生成器 (BytecodeGenerator),这是 Ignition 解释器中的一部分,用于生成以函数为单位的字节码。

理解字节码:解释器的架构设计

通常有两种架构的解释器,基于栈的和基于寄存器的。基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等,基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果。
通常,基于栈的虚拟机也定义了少量的寄存器,基于寄存器的虚拟机也有堆栈,其区别体现在它们提供的指令集体系。大多数解释器都是基于栈的,比如 Java 虚拟机,.Net 虚拟机,还有早期的 V8 虚拟机。基于堆栈的虚拟机在处理函数调用、解决递归问题和切换上下文时简单明快。
而现在的 V8 虚拟机则采用了基于寄存器的设计,它将一些中间数据保存到寄存器中。

TODO

隐藏类:如何在内存中快速查找对象属性?

静态语言的效率更高

动态语言可以修改,不确定对象中的属性
因为静态语言中,代码被遍以后其结构不可修改,访问对象属性,可以根据该对象的内存地址的偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因。

隐藏类

目前所采用的一个思路就是将 JavaScript 中的对象静态化,也就是 V8 在运行 JavaScript 的过程中,会假设 JavaScript 中的对象是静态的,具体地讲,V8 对每个对象做如下两点假设:

  • 对象创建好了之后就不会添加新的属性;
  • 对象创建好了之后也不会删除属性。
    具体地讲,V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:
  • 对象中所包含的所有的属性;
  • 每种类型相对于对象的偏移量。
    如果对象的形状没有发生改变,那么该对象就会一直使用该隐藏类;
    如果对象的形状发生了改变,那么 V8 会重建一个新的隐藏类给该对象。

隐藏类名称 map

let point = {x:100,y:200}


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

多个对象共用一个隐藏类

如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类:

  • 减少隐藏类的创建次数,也间接加速了代码的执行速度;
  • 减少了隐藏类的存储空间。
    在什么情况下共用一个隐藏类?
  • 相同的属性名称;
  • 相等的属性个数。
let point = {x:100,y:200};
let point2 = {x:3,y:4};
%DebugPrint(point);
%DebugPrint(point2);

重新构建隐藏类

  • 对象创建好了之后添加新的属性;
  • 对象创建好了之后删除属性。
    重新构建隐藏类,会增大v8的运行开销。

最佳实践

一,使用字面量初始化对象时,要保证属性的顺序是一致的。

// 不推荐,会创建两个隐藏类,分别对应point和point2
let point = {x:100,y:200};
let point2 = {y:100,x:200};
// 推荐,会使用同一个隐藏类
let point = {x:100,y:200};
let point2 = {x:10,y:20};

二,尽量使用字面量一次性初始化完整对象属性。因为每次为对象添加一个属性时,V8 都会为该对象重新设置隐藏类。
三,尽量避免使用 delete 方法。delete 方法会破坏对象的形状,同样会导致 V8 为该对象重新生成新的隐藏类。

隐藏类总结

在 V8 中,每个对象都有一个隐藏类,隐藏类在 V8 中又被称为 map。
在 V8 中,每个对象的第一个属性的指针都指向其 map 地址。
map 描述了其对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少?
如果添加新的属性,那么需要重新构建隐藏类。
如果删除了对象中的某个属性,通用也需要构建隐藏类。

V8是怎么通过内联缓存来提升函数执行效率的

一个函数在一个 for 循环里面被重复执行了很多次,V8 会想尽一切办法来压缩这个查找过程,以提升对象的查找效率。这个加速函数执行的策略就是内联缓存 (Inline Cache),简称为 IC。

内联缓存

V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键的中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率。比如:IC 会监听每个函数的执行过程,并在一些关键的地方埋下监听点,这些包括了加载对象属性 (Load)、给对象属性赋值 (Store)、还有函数调用 (Call),V8 会将监听到的数据写入一个称为反馈向量 (FeedBack Vector) 的结构中,同时 V8 会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8 就可以缩短对象属性的查找路径,从而提升执行效率。

反馈向量其实就是一个表结构,它由很多项组成的,每一项称为一个插槽 (Slot),V8 会依次将执行函数的中间数据写入到反馈向量的插槽中。

每个调用点对应一个插槽

function loadX(o) {
 o.y = 4
 return o.x
}

o.y = 4 和 return o.x 这两段是调用点 (CallSite),它们使用了对象和属性, V8 会在 loadX 函数的反馈向量中为每个调用点分配一个插槽。
每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量。

多态和超态

  • 如果一个插槽中只包含 1 个隐藏类,那么我们称这种状态为单态 (monomorphic);
  • 如果一个插槽中包含了 2~4 个隐藏类,那我们称这种状态为多态 (polymorphic);
  • 如果一个插槽中超过 4 个隐藏类,那我们称这种状态为超态 (magamorphic)。

多态代码示意:

function loadX(o) {
    return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3, y:6,z:4}
for (var i = 0; i < 90000; i++) {
    loadX(o)
    loadX(o1)
}

多态示意图:

当执行到 o.x 的时候,V8 会查询反馈向量的第一个插槽,发现里面有多个 map 的记录,那么 V8 就需要取出 o 的隐藏类,来和插槽中记录的隐藏类一一比较,如果记录的隐藏类越多,那么比较的次数也就越多,这就意味着执行效率越低。
单态、多态、超态等三种情况的执行性能如下图所示:

最佳实践

尽量保持单态,要避免多态和超态,那么就尽量默认所有的对象属性是不变的,比如你写了一个 loadX(o) 的函数,那么当传递参数时,尽量不要使用多个不同形状的 o 对象。

建议

虽然我们分析的隐藏类和 IC 能提升代码的执行速度,但是在实际的项目中,影响执行性能的因素非常多,找出那些影响性能瓶颈才是至关重要的,你不需要过度关注微优化,你也不需要过度担忧你的代码是否破坏了隐藏类或者 IC 的机制,因为相对于其他的性能瓶颈,它们对效率的影响可能是微不足道的。

消息队列:V8是怎么实现回调函数的

什么是回调函数

回调函数区别于普通函数,在于它的调用方式。只有当某个函数被作为参数,传递给另外一个函数,或者传递给宿主环境,然后该函数在函数内部或者在宿主环境中被调用,我们才称为回调函数。
回调函数的两种形式:同步回调和异步回调。最大区别在于:同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。

UI 线程的宏观架构


消息队列+事件循环


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

异步回调函数的调用时机(两种类型)

  • 在 setTimeout 函数内部封装回调消息,并将回调消息添加进消息队列,然后主线程从消息队列中取出回调事件,并执行。
  • XMLHttpRequest函数使用:
    • UI 线程会从消息队列中取出一个任务,并分析该任务。
    • 分析过程中发现该任务是一个下载请求,那么主线程就会将该任务交给网络进程去执行。
    • 网络进程接到请求之后,便会和服务器端建立连接,并发出下载请求;
    • 网络进程不断地收到服务器端传过来的数据;
    • 网络进程每次接收到数据时,都会将设置的回调函数和返回的数据信息,如大小、返回了多少字节、返回的数据在内存中存放的位置等信息封装成一个新的事件,并将该事件放到消息队列中 ;
    • UI 线程继续循环地读取消息队列中的事件,如果是下载状态的事件,那么 UI 线程会执行回调函数,程序员便可以在回调函数内部编写更新下载进度的状态的代码;
    • 直到最后接收到下载结束事件,UI 线程会显示该页面下载完成。
      ![处理下载事件](https://static001.geekbang.org/resource/image/94/42/942abef74c09cb43c0ffc94d0e836142.jpg 处理下载事件)

页面使用单线程的缺点

  • 第一个问题是如何处理高优先级的任务。
  • 第二个是如何解决单个任务执行时长过久的问题。

WebAPI:setTimeout是如何实现的?

浏览器怎么实现 setTimeout

在 Chrome 中除了正常使用的消息队列之外,还有延迟消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。(也不仅仅只有两个消息队列详情请看chrome)

使用 setTimeout 的一些注意事项

  1. 如果当前任务执行时间过久,会影延迟到期定时器任务的执行
  2. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒
  3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
  4. 延时执行时间有最大值
  5. 使用 setTimeout 设置的回调函数中的 this 不符合直觉(指向window)

WebAPI:XMLHttpRequest是怎么实现的?

XMLHttpRequest 运作机制

  • 第一步:创建 XMLHttpRequest 对象。
  • 第二步:为 xhr 对象注册回调函数。
  • 第三步:配置基础的请求信息。
  • 第四步:发起请求。

XMLHttpRequest 使用过程中的“坑”

  1. 跨域问题
  2. HTTPS 混合内容的问题:是在https页面中混合了http的请求,会发出警告,但会正常加载

V8是如何实现微任务的?

宏任务

指消息队列中的等待被主线程执行的事件

微任务

微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。(每次执行微任务时,都会退出当前函数的调用栈)微任务可以在实时性和效率之间做一个有效的权衡。每个宏任务都包含一个微任务队列。

主线程、调用栈、消息队列

主线程和调用栈之间的关系

调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。

function bar() {
}
foo(fun){
  fun()
}
foo(bar)

以上述代码为例:

  • 当 V8 准备执行这段代码时,会先将全局执行上下文压入到调用栈中
  • 创建 foo 函数的执行上下文,并将其压入栈中
  • 创建 bar 函数的执行上下文,并将其压入栈中
  • bar 函数执行结束,V8 就会从栈中弹出 bar 函数的执行上下文
  • foo 函数执行结束,V8 会将 foo 函数的执行上下文从栈中弹出

栈溢出

由于栈空间在内存中是连续的,通常都会限制调用栈的大小,如果当函数嵌套层数过深时,过多的执行上下文堆积在栈中便会导致栈溢出

三者之间的关系

function foo() {
  setTimeout(foo, 0)
}
foo()
  • 执行foo函数,创建宏任务,并添加到消息队列
  • 执行 foo 函数中的 setTimeout 时,setTimeout 会将 foo 函数封装成一个新的宏任务,并将其添加到消息队列中
  • 等 foo 函数执行结束,V8 就会结束当前的宏任务,调用栈也会被清空
  • 继续执行宏任务foo函数

微任务解决了宏任务执行时机不可控的问题

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

function bar(){
  console.log('bar')
  Promise.resolve().then(
    (str) =>console.log('micro-bar')
  )
  setTimeout((str) =>console.log('macro-bar'),0)
}
function foo() {
  console.log('foo')
  Promise.resolve().then(
    (str) =>console.log('micro-foo')
  )
  setTimeout((str) =>console.log('macro-foo'),0)

  bar()
}
foo() // 属于同步调用,说明全局(主)函数还没有执行完成,所以宏任务并没有将要销毁,微任务包含在宏任务中,当前宏任务也就是全局函数
console.log('global')
Promise.resolve().then(
  (str) =>console.log('micro-global')
)
setTimeout((str) =>console.log('macro-global'),0)

// 执行顺序
foo
bar
global
micro-foo
micro-bar
micro-global
macro-foo
macro-bar
macro-global

执行上述代码示意图:

在此主线程运行的代码是一个宏任务

微任务执行时机

  • 首先,如果当前的任务中产生了一个微任务,通过 Promise.resolve() 或者 Promise.reject() 都会触发微任务,触发的微任务不会在当前的函数中被执行,所以执行微任务时,不会导致栈的无限扩张;
  • 其次,和异步调用不同,微任务依然会在当前任务执行结束之前被执行,这也就意味着在当前微任务执行结束之前,消息队列中的其他任务是不可能被执行的。
  • 因此在函数内部触发的微任务,一定比在函数内部触发的宏任务要优先执行。
  • 通俗地理解,V8 会为每个宏任务维护一个微任务队列。
  • 因为微任务依然是在当前的任务中执行的,所以如果在微任务中循环触发新的微任务,那么将导致消息队列中的其他任务没有机会被执行。

在微任务中循环地触发新的微任务

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

微任务队列中有一个微任务,于是先执行微任务。由于这个微任务就是调用 foo 函数本身,所以在执行微任务的过程中,需要继续调用 foo 函数,在执行 foo 函数的过程中,又会触发了同样的微任务。这个循环就会一直持续下去,当前的宏任务无法退出,也就意味着消息队列中其他的宏任务是无法被执行的。不过,由于 V8 每次执行微任务时,都会退出当前 foo 函数的调用栈,所以这段代码是不会造成栈溢出的。

Promise:使用Promise,告别回调函数

  • 异步编程的问题:代码逻辑不连续
  • 封装异步代码,让处理流程变得线性
  • 新的问题:回调地狱

Promise:消灭嵌套调用和多次错误处理

  • Promise 实现了回调函数的延时绑定
  • 回调函数返回值穿透

V8是如何实现async-await的


回调地域->Promise->Generator->async/await

生成器

执行到异步请求的时候,暂停当前函数,等异步请求返回了结果,再恢复该函数。生成器函数是一个带星号函数,配合 yield 就可以实现函数的暂停和恢复。这背后的魔法就是协程,协程是一种比线程更加轻量级的存在。如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。
generator缺点:由于生成器函数可以暂停,因此我们可以在生成器内部编写完整的异步逻辑代码,不过生成器依然需要使用额外的执行器(可参考著名的 co 框架)来驱动生成器函数的执行。

协程函数不止是为了解决JavaScript语言的异步问题,只是用协程试图解决这个问题。

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)

上述代码示意图:

// 生成器函数配合promise使用,实现异步代码的同步逻辑方式
function* getResult() {
    let id_res = yield fetch(id_url);
    console.log(id_res)
    let id_text = yield id_res.text();
    console.log(id_text)
    let new_name_url = name_url + "?id=" + id_text
    console.log(new_name_url)
    let name_res = yield fetch(new_name_url)
    console.log(name_res)
    let name_text = yield name_res.text()
    console.log(name_text)
}
let result = getResult()
result.next().value.then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value

// co框架
// co(getResult())

async/await:异步编程的“终极”方案

ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。其实 async/await 技术背后的秘密就是 Promise 和生成器应用,往底层说,就是微任务和协程应用。
async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。
await 可以等待两种类型的表达式:

  • 可以是任何普通表达式 ;
  • 也可以是一个 Promise 对象的表达式。
    如果 await 等待的是一个 Promise 对象,它就会暂停执行生成器函数,直到 Promise 对象的状态变成 resolve,才会恢复执行,然后得到 resolve 的值,作为 await 表达式的运算结果。和生成器函数一样,使用了 async 声明的函数在执行时,也是一个单独的协程,我们可以使用 await 来暂停该协程,由于 await 等待的是一个 Promise 对象,我们可以 resolve 来恢复该协程。
// 这一段代码,使用 await 等待一个没有 resolve 的 Promise,getResult 函数会一直等待下去
function NeverResolvePromise(){
    return new Promise((resolve, reject) => {})
}
async function getResult() {
    let a = await NeverResolvePromise()
    console.log(a)
}
getResult()
console.log(0)

下图可以清晰解析出Promise(微任务)和生成器执行下述代码的方式:

function HaveResolvePromise(){
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(100)
          }, 0);
      })
}
async function getResult() {
    console.log(1)
    let a = await NoResolvePromise()
    console.log(a)
    console.log(2)
}
console.log(0)
getResult()
console.log(3)


在微任务中遇到微任务,会暂停当前微任务执行,执行遇到的微任务,:

new Promise(resolve=>{
    console.log(1);
    new Promise(resolve=>{
        console.log(2);
    })
    console.log(3);
})
// 1->2->3

V8的两个垃圾回收器是如何工作的?

垃圾数据是怎么产生的?

无论是使用什么语言,我们都会频繁地使用数据,这些数据会被存放到栈和堆中,通常的方式是在内存中创建一块空间,使用这块空间,在不需要的时候回收这块空间。

垃圾回收算法(大致的垃圾回收的流程)

  • 通过 GC Root 标记空间中活动对象和非活动对象。目前 V8 采用的可访问性(reachability)算法来判断堆中的对象是否是活动对象。通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable),那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象;通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable),那么这些不可访问的对象就可能被回收,我们称不可访问的对象为非活动对象。
  • 回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  • 做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况,所以最后一步需要整理这些内存碎片。但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。

代际假说

第一个是大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;
第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。
V8 依据代际假说,将堆内存划分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。

副垃圾回收器 -Minor GC (Scavenger)

副垃圾回收器主要负责新生代的垃圾回收(1~8M),采用了 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换。

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

对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域

主垃圾回收器 -Major GC

主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作。

分配到老生代对象特点:

  • 一个是对象占用空间大;
  • 另一个是对象存活时间长。

标记 - 清除算法

  • 首先是标记过程阶段。
  • 接下来就是垃圾的清除过程。

标记 - 整理

标记可回收对象,让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存

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

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

并行回收

主线程在执行垃圾回收的任务时,暂停主线程的执行,引入多个辅助线程来并行处理,加速垃圾回收的执行速度。

增量回收

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

增量标记的算法,比全停顿的算法要稍微复杂,这主要是因为增量回收是并发的(concurrent),要实现增量执行,需要满足两点要求:

  • 垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收来了之后,才能继续启动。
  • 在暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理。

三色标记法

  • 黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了 ;
  • 灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点;
  • 白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。

写屏障机制

写屏障 (Write-barrier) 机制实现不能让黑色节点指向白色节点的约束,也被称为强三色不变性。

并发 (concurrent) 回收

是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。

V8融合三种回收机制

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

几种常见内存问题的解决策略

内存泄漏(Memory leak)

会导致页面的性能越来越差,在 JavaScript 中,造成内存泄漏 (Memory leak) 的主要原因是不再需要 (没有作用) 的内存数据依然被其他对象引用着。

造成内存泄漏的几种情况

  • 在浏览器中 this 是指向 window 对象的,而 window 对象是常驻内存的,没有被 var、let、const 这些关键字声明的变量会挂载到window中造成内存泄漏。
    • use strict 关键字,this指向 undefind 可解决
  • 闭包会引用父级函数中定义的变量,如果引用了不被需要的变量,那么也会造成内存泄漏。
// 虽然只引用了父级的 temp_object.x ,但整个 temp_object 对象都会保留在内存中
function foo(){
    var temp_object = new Object()
    temp_object.x = 1
    temp_object.y = 2
    temp_object.array = new Array(200000)
    /**
    *   使用temp_object
    */
    return function(){
        console.log(temp_object.x);
    }
}
  • JavaScript 引用 DOM 节点造成的内存泄漏,只有同时满足 DOM 树和 JavaScript 代码都不引用某个 DOM 节点,节点才会被作为垃圾进行回收。如果某个节点已从 DOM 树移除,但 JavaScript 仍然引用它,我们称此节点为“detached ”。

内存膨胀

内存膨胀和内存泄漏有一些差异,内存膨胀主要表现在程序员对内存管理的不科学,比如只需要 50M 内存就可以搞定的,有些程序员却花费了 500M 内存。

内存膨胀和内存泄差异

内存膨胀是快速增长,然后达到一个平衡的位置,而内存泄漏是内存一直在缓慢增长。

频繁的垃圾回收

频繁使用大的临时变量,导致了新生代空间很快被装满,从而频繁触发垃圾回收。频繁的垃圾回收操作会让你感觉到页面卡顿。(可以把频繁使用大的临时变量设置为全局变量)

我的前端学习踩坑史

李兵老师给学习者的一些建议:
开发一个新项目或者学习一门手艺之前,应该将其所涉及到的知识做一个全方位的了解。“技术栈”非常形象地表达了学习一门手艺所需要的是哪些知识,以及应该按照什么顺序来学。比如学习前端这门手艺,栈底到栈顶依次是浏览器架构、Web 网络、事件循环机制、JavaScript 核心、V8 的内存管理、浏览器的渲染流程、Web 安全、CSS、React、Vue、Node、构建工具链等,我们可以从栈底往栈顶一步步循序渐进地学习。
系统性学习一门技术,花费的时间也是最短的,也可以说是性价比最高的,因为系统性地、循序渐进地学习,那么学习到每个知识点时,其实并没有其他的知识盲区,这样学习起来是最轻松、简单的。

关于文章

此文章是购买极客时间《图解Google V8》学习之后的自我笔记。原文

posted @ 2022-02-10 10:18  _尼欧`  阅读(792)  评论(0编辑  收藏  举报