ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

了解 Google V8

2022-02-10 10:34:30  阅读:223  来源: 互联网

标签:Google 函数 对象 作用域 了解 V8 执行 属性


V8如何执行一段JavaScript代码

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

函数即对象

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

什么是 JavaScript 中的对象

JavaScript 是一门基于对象 (Object-Based) 的语言,可以说 JavaScript 中大部分的内容都是由对象构成的,诸如函数、数组,也可以说 JavaScript 是建立在对象之上的语言。JavaScript 是基于对象设计的,但是它却不是一门面向对象的语言 (Object-Oriented Programming Language),因为面向对象语言天生支持封装、继承、多态,但是 JavaScript 并没有直接提供多态的支持,因此要在 JavaScript 中使用多态并不是一件容易的事。
JavaScript 中的对象非常简单,每个对象就是由一组组属性和值构成的集合

  • 第一种是原始类型 (primitive),所谓的原始类的数据,是指值本身无法被改变,比如 JavaScript 中的字符串就是原始类型,如果你修改了 JavaScript 中字符串的值,那么 V8 会返回给你一个新的字符串,原始字符串并没有被改变,我们称这些类型的值为“原始值”。
    JavaScript 中的原始值主要包括 null、undefined、boolean、number、string、bigint、symbol 这七种。
  • 第二种就是我们现在介绍的对象类型 (Object),对象的属性值也可以是另外一个对象,比如上图中的 info 属性值就是一个对象。
  • 第三种是函数类型 (Function),如果对象中的属性值是函数,那么我们把这个属性称为方法,所以我们又说对象具备属性和方法,那么上图中的 showinfo 就是 person 对象的一个方法。

函数的特殊性

在 JavaScript 中,函数是一种特殊的对象,它和对象一样可以拥有属性和值,但是函数和普通对象不同的是,函数可以被调用。
同时有隐藏属性:name、code、prototype
函数可以赋值给一个变量,也可以作为函数的参数,还可以作为函数的返回值。如果某个编程语言的函数可以和它的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民。

快属性和慢属性: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 为就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。

函数声明和函数表达式

函数声明

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')
}

如何处理函数声明

在编译阶段,如果解析到函数声明,那么 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 指向的对象称为该对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性。实际上这些属性都是位于原型对象上,我们把这个查找属性的路径称为原型链
在这里还要注意一点,不要将原型链接和作用域链搞混淆了,作用域链是沿着函数的作用域一级一级来查找变量的,而原型链是沿着对象的原型一级一级来查找属性的,虽然它们的实现方式是类似的,但是它们的用途是不同的。

__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

问题

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)用于定义如何将编程语言中的数值和表达式归类为许多不同的类型,如何操作这些类型,这些类型如何互相作用。
在执行加法操作的时候,V8 会通过 ToPrimitve 方法将对象类型转换为原生类型,(ToPrimitve 会优调用对象中的 valueOf 方法,返回Number类型,当不存在valueOf时,继续调用 toString 方法,返回String类型)最后就是两个原生类型相加,如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。在其他情况时,所有的值都会转换为数字类型值,然后做数字的相加。

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

运行JavaScript流程:运行时环境、解析源码、生成字节码、解释执行或者编译执行

宿主环境

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

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

栈空间主要是用来管理 JavaScript 函数调用的,在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,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
}

这段代码在执行时,就会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中。

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

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

构造事件循环系统

宿主提供事件循环系统运行V8程序

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

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

将源码编译成机器码

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

CPU 是怎么执行程序的

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

TODO

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

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

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

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

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

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

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

栈如何管理函数调用?

函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中,遇到函数在寄存器中保存一个永远指向当前栈顶的指针用于恢复现场

TODO

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

谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 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为什么又重新引入字节码?

将 JavaScript 源码直接编译成二进制代码存在两个致命的问题:

  • 时间问题:编译时间过久,影响代码启动速度;
  • 空间问题:缓存编译后的二进制代码占用更多的内存。
    引入中间的字节码:
  • 解决启动问题:生成字节码的时间很短;
  • 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
  • 代码架构清晰:采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易。

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

V8 的解释器就可以解释执行字节码了。通常有两种架构的解释器,基于栈的和基于寄存器的。基于栈的解释器会将一些中间数据存放到栈中,而基于寄存器的解释器会将一些中间数据存放到寄存器中。

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

因为静态语言中,可以直接通过偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因。

隐藏类

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

  • 对象创建好了之后就不会添加新的属性;
  • 对象创建好了之后也不会删除属性。
    具体地讲,V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:
  • 对象中所包含的所有的属性;
  • 每种类型相对于对象的偏移量。
    如果对象的形状没有发生改变,那么该对象就会一直使用该隐藏类;
    如果对象的形状发生了改变,那么 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 就可以缩短对象属性的查找路径,从而提升执行效率。

最佳实践

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

建议

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

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

什么是回调函数

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

UI 线程的宏观架构

消息队列+事件循环


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

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

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

V8是如何实现微任务的?

宏任务

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

微任务

微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。微任务可以在实时性和效率之间做一个有效的权衡

微任务执行时机

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

V8是如何实现async-await的?

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

生成器

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

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)

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)。

并行回收

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

增量回收

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

三色标记法

  • 黑色表示这个节点被 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》学习之后的自我笔记。原文

标签:Google,函数,对象,作用域,了解,V8,执行,属性
来源: https://www.cnblogs.com/-Neo/p/15867874.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有