系统化学习前端之浏览器(V8)

V8

浏览器内核:内容排版引擎(HTML 和 CSS)和 JavaScript 引擎。

V8 是 Google 开源的 JavaScript 引擎,目前应用在 Chrome 浏览器和 NodeJs 中,其本质是 JavaScript 虚拟机,可以将 JavaScript 代码编译成机器语言(指令集)被浏览器运行。其他 JavaScript 引擎有:TraceMonkey(FireFox),JavaScriptCore(Safari) 等,而 V8 是应用最为广泛的 JavaScript 引擎。

V8 如何执行 JavaScript 代码

语言的演变

计算机处理编程语言是通过 CPU 来进行的,而 CPU 只能处理二进制指令,如 01011100。这种二进制指令,我们称之为机器语言。

计算机编程人员面对大量的二进制指令是难以阅读和记忆的,因此将二进制指令汇总编辑成指令集,如 MOV, JUMP TO 。这种汇编指令集,我们称之为汇编语言。

计算机的发展,CPU架构种类增多,如 Intel,ARM,MIPS 等。不同的 CPU 对应的汇编语言不同,而且编写汇编语言需要操作内存、寄存器等硬件相关,因此需要能够兼容多种 CPU 架构和屏蔽计算机硬件细节的语言,这种语言,我们称之为高级语言,如 Java,JavaScript,C,C++,Python等。

语言运行方式

计算机执行高级语言的方式:解释执行和编译执行。

  1. 解释执行

    高级语言的源代码通过解析器解析成中间代码,再通过解释器解释执行中间代码(字节码),输出结果。

  2. 编译执行

    高级语言的源代码通过解析器解析成中间代码(字节码),中间代码在通过编译器编译成机器指令,机器指令保存在内存或以二进制文件存储磁盘中,需要时,直接执行内存中代码或二进制文件即可。

V8 编译流程

V8 执行 JavaScript 代码是混合了编译执行和解释执行两种方式,这种混合两种方式的技术称为即时编译(JIT)技术。

V8编译具体流程:

  1. V8 在执行 JavaScript 代码之前,需要初始化编译环境,包括堆空间、栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数及对象、消息循环系统等。

    V8 借助宿主环境(浏览器)先开辟内存空间,如栈空间用来存储基本数据类型,堆空间存储引用类型数据。接着生成全局执行上文,如 全局作用域(window 对象)以及默认指向 window 对象的 this 关键字,还有一些 web API 等。之后构造消息循环系统,执行并调度消息队列中的任务,与浏览器页面共用一个线程,可能会造成线程阻塞。

  2. JavaScript 代码经过解析器会生成抽象语法树(AST)和作用域,依据 AST 和 作用域生成字节码。

    V8 解析 JavaScript 代码属于惰性解析,即解析过程中遇到函数声明,则跳过函数内部代码,不生成 AST 和字节码。但闭包不解析函数内部代码是无法得知内部函数引用外部函数变量的,因此,V8 引入预解析器,遇到函数声明,快速进行预解析,既可以发现内部函数引用外部函数变量,也可以检查函数内部语法错误。

    V8 解析字节码的主要原因:直接转换二进制代码占用空间会更大,使用字节码可以减少空间占用。其次对于不同架构 CPU,二进制指令也会不同,使用字节码进行兼容。

    注意:闭包中的内部函数引用的外部函数变量在外部函数结束时,无法被释放,该变量会存于堆空间中。

  3. 解释器(Ignition)执行字节码,生成结果。多次执行的重复代码会被优化,标记为“热点代码”。

    解释器分为基于栈存储的解释器和基于寄存器存储的解释器,V8 采用基于寄存器的解释器,执行字节码是对寄存器的操作,并返回寄存器中的数值。

  4. “热点代码”会通过编译器(TurboFan)编译成机器指令,保存在内存或二进制文件中,需要时被调用生成结果。JavaScript动态修改后,“热点代码”失效,会进行反优化,标记为“非热点代码”。

    V8 编译优化成二进制代码主要是因为 Chrome 浏览器引入二进制代码缓存,可以借助内存和二进制文件达到缓存效果,提高执行效率,典型以空间换时间的方法。

  5. “非热点代码”再次进入解释器,生成结果。

JavaScript 核心特点

JavaScript 对象

JavaScript并不是一门面向对象的语言,因为面向对象的语言天然具备封装、继承、多态,而 JavaScript 的继承是由构造函数和原型模拟出来的,JavaScript 也无法实现多态。但 JavaScript 是基于对象设计的,在 JavaScript 中 万物皆为对象。如 JavaScript 的函数也是对象,函数可以作为变量赋值,可以作为参数传递,也可以作为函数返回值返回。

JavaScript 内置对象 11 个:String,Number,Boolean,RegExp,Date,Error,Array,Function,Object,Global,Math。

  1. 对象

    JavaScript 对象是由一组属性和值构成的集合。

    var obj = new Object()
    obj.num = 1
    obj.str = 'v8'
    obj.obj = {
    	a: 1,
    	b: 'v8'
    }
    obj.arr = [1,2,3]
    obj.fun = function() {
    	console.log('v8')
    }
    
    console.log(obj.num)
    console.log(obj.str)
    console.log(obj.obj)
    console.log(obj.arr)
    console.log(obj.fun)
    

    JavaScript 对象的属性值可以为基本数据类型(Number,String,Boolean,Null,Undefined),也可以为引用类型(Function,Array,Object)。

  2. 对象属性

    对象的属性可以是基本类型也可以引用类型,引用类型数据是通过堆空间进行存储的,基本类型数据存储于栈空间。

    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]}`)
    }
    

    ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建顺序升序排列。V8 中,数字属性被称为排序属性(elements),字符串属性被称为常规属性(properties),两种属性是对象的隐藏属性,均通过栈空间存储。

    将不同属性分别保存在 elements 栈和 properties 栈中,访问 bar 对象中的 properties 的某一属性,需要增加一步去 properties 栈中查找,为此 V8 实行了快、慢属性策略。将 properties 属性分割成两部分,一部分存储于对象中,称为快属性,快属性默认最多是 10 个,另一部分存储在 properties 栈中,称之为慢属性。

    对象除了 elementsproperties 属性,还有 __proto__map 属性。

    function Foo(property_num,element_num) {
    
    	for (let i = 0; i < element_num; i++) {
    		this[i] = `element${i}`
    	}
    
    	for (let i = 0; i < property_num; i++) {
    		let ppt = `property${i}`
    		this[ppt] = ppt
    	}
    }
    
    var bar = new Foo(20,10)
    

    注意:V8 提高对象访问速率的策略:对象的 map 属性指向其对应的隐藏类,隐藏类包含对象的所有属性及属性偏移量,存储于内存中。访问对象属性时,可以访问其隐藏类,快速定位属性位置并取值,提高访问速率。

  3. 原型链

    A 对象可以访问 B 对象的属性和方法,则 A 对象继承了 B 对象,JavaScript 是通过原型链的方式实现继承的。

    JavaScript 的隐藏属性 __proto__ 为原型属性,A 对象 的原型属性指向 B 属性,则表示 A 对象继承了 B 对象,即 A 的原型对象为 B,A 可以访问 B 的属性及方法。

    var animal = {
    	type: 'default',
    	color: 'default',
    	getInfo() {
    		return `Type is ${this.type}, color is ${this.color}`
    	}
    }
    
    var dog = {
    	type: 'dog',
    	color: 'yellow'
    }
    
    dog.__proto__ = animal
    
    var res = dog.getInfo()
    console.log(res) // Type is dog, color is yellow
    

    __proto__ 为隐藏属性,显式设置可能会导致性能问题,JavaScript 中使用构造函数封装了对象的属性和构造函数的原型封装了对象的方法,使用 new 关键字隐藏了 __proto__ 的设置。

    function Animal(type, color) {
    	this.type = type
    	this.color = color
    }
    
    Animal.prototype.getInfo = function () {
    	return `Type is ${this.type}, color is ${this.color}`
    }
    
    var dog = new Animal('dog', 'yellow')
    
    var res = dog.getInfo() 
    console.log(res) // Type is dog, color is yellow
    

    构造函数是一种特殊的函数:声明构造函数的同时会为其创建一个原型对象,并使用 prototype 属性指向该对象,一般使用函数名首字母大写区分构造函数。

    new 关键字隐藏的操作

    var dog = {}
    dog.__proto__ = Animal.prototype
    dog.constructor = Animal // 对象的 constructor 属性指向构造函数。
    Animal.call(dog, 'dog', 'yellow') // 修改 this 的指向
    

    注意:并不一定是构造函数封装属性,原型对象封装方法。构造函数通过 new 实例化多个对象时,多个对象的 __proto__ 属性指向原型对象,共用原型对象的属性。因此,共同的属性和方法一般挂载在原型对象上,但属性一般为对象的特性,较少挂载在原型对象上。

    JavaScript 可以通过原型属性 __proto__ 串联多个对象,形成原型链,原型链也可以理解为对象查找其属性的一种方式

JavaScript 函数

  1. 函数

    JavaScript 函数的本质就是对象,相比于普通对象,函数是可以被调用的。

    function sum(x, y) {
    	return x + y
    }
    
    sum.num = 1
    sum.str = 'v8'
    sum.obj = {
    	a: 1,
    	b: 'v8'
    }
    sum.arr = [1,2,3]
    sum.fun = function() {
    	console.log('v8')
    }
    
    console.log(sum.num)
    console.log(sum.str)
    console.log(sum.obj)
    console.log(sum.arr)
    console.log(sum.fun)
    
    console.log(sum(1, 2))
    

    JavaScript 函数之所以特殊,是因为 V8 内部会为函数对象添加 name 只读的隐藏属性。

    function sum(x, y) {
    	return x + y
    }
    
    console.log(Object.getOwnPropertyNames(sum))
    console.log(sum.name)
    
    // name 属性无法通过 . 方式修改
    sum.name = 'add'
    console.log(sum.name)
    
    // 修改函数引用,但 name 属性为改变
    var res = function multi(x, y) { return x + y }
    console.log(res.name)
    console.log(multi.name)
    
    console.log((function () { console.log('匿名函数') }).name)
    

    注意:V8提高函数执行的策略为内联缓存(IC),其原理:IC会为每个函数创建一个反馈向量(表结构),反馈向量的每一行被称为一个插槽,函数执行过程中的一些中间数据被存于插槽中,但函数多次调用时,直接使用插槽中的数据。

  2. 函数表达式

    JavaScript 函数定义有两种方式:

    函数声明

    function sum() {
    	console.log('sum')
    }
    

    函数表达式

    var sum = function () {
    	console.log('sum')
    }
    

    之所以区分函数声明和函数表达式,是因为在 V8 中存在一个特性:声明提前,也被称之为变量提升。

    console.log(a) // undefined
    
    var a = 1
    
    console.log(a) // 1
    

    上面代码中,V8 编译过程中实际代码为

    var a // 声明提前,赋值滞后。
    
    console.log(a) // undefined,调用声明未赋值的变量,其结果为 undefined
    
    a = 1
    
    console.log(a) // 1
    

    函数表达式本质是变量声明,后将匿名函数赋值给变量,同样存在声明提前,赋值滞后的问题。因此,函数表达式之前调用函数一定会抛出错误 TypeError

  3. 作用域链

    作用域是指存放变量的地方。常用函数内外来区分作用域,如果变量定义函数之外,称之为全局作用域;如果变量定义在函数内部,称之为函数作用域。

    每个函数执行函数内部的变量,都需要查找自身函数作用域,如果能查找到变量赋值则调用,未找到则查找上一级作用域,依此查找到全局作用域为止,若还未找到则返回 undefined。

    var a = 1;
    
    function outer() {
    	var b = 2;
    	function inner() {
    		var c = 3;
    		console.log('inner',a, b, c); // 1,2,3
    	}
    	inner();
    	console.log('outer',a, b); // 1,2
    }
    
    outer();
    console.log(a); // 1
    

    变量 a 定义函数外,为全局变量,变量 b,c定义函数内,为局部变量。inner 函数可以访问所有变量,outer 函数只能访问 a 和 b 变量,函数外只能访问 a 变量。

    函数查找变量是由内向外逐级查找的,直至全局作用域结束,找到则调用, 未cons找到则返回 undefined,从 inner 函数作用域 -> outer 函数作用域 -> 全局作用域 形成了作用域链,作用域链不可逆。

    注意:全局作用域是 V8 启动过程中创建的,即 window 对象。函数作用域是在函数执行时创建,函数结束时销毁,因此函数作用域的变量在函数结束时无法调用。

Browser 到 V8 再到 JavaScript

浏览器的多进程架构

浏览器是多进程架构的,主要包含浏览器主进程、插件进程、GPU 进程以及浏览器渲染进程。

  1. 浏览器主进程

    负责管理浏览器,包括浏览器界面显示,与用户交互状态,TAB 页创建和销毁等,可对应 JavaScript 的 BOM 操作。

  2. 插件进程

    负责管理浏览器插件,如Tampermonkey,MetaMask 等。

  3. GPU 进程

    负责 3D 绘制。

  4. 浏览器渲染进程

    负责页面渲染,JavaScript 执行等,可对应 JavaScript 中的 DOM 和 ECMAScript。xuxu

注意:JavaScript 的三个组成(BOM,DOM,ECMAScript)对应浏览器的两个进程(浏览器主进程和浏览器渲染进程),其中重点是浏览器渲染进程。

浏览器渲染进程

浏览器渲染进程是多线程架构的,包含UI 线程、事件线程、定时器线程以及网络线程,其中 UI 线程又包含 JavaScript引擎线程(V8)和 GUI 渲染线程,分别对应 ECMAScript 和 DOM。

  1. UI 线程(V8 和 GUI)

    UI 线程 是浏览器渲染页面和执行 JavaScript 的主线程,渲染页面由 GUI 渲染线程负责,执行 JavaScript 由 JavaScript引擎线程负责。两个线程之所以统称 UI 线程,是因为 JavaScript 可以直接操作 DOM,而且 JavaScript引擎线程和 GUI 渲染线程是互斥的,同一时间只能一个线程运行。

    注意:JavaScript引擎线程和 GUI 渲染线程是互斥的,因此会存在 JavaScript 加载执行阻塞页面渲染的情况。

  2. 事件线程(Event)

    负责封装事件回调函数为宏任务,并添加到消息队列。

  3. 定时器线程(Time)

    负责封装定时器回调函数为宏任务,并添加到消息队列。

  4. 网络线程(Network)

    负责封装异步 XHR 的回调函数为宏任务,并添加消息队列。

JavaScript引擎线程

JavaScript引擎在 Chrome 浏览器中是 V8,V8 执行 JavaScript 的流程前面已经提到,函数作为 JavaScript 的一等公民,这里详细说一下函数在 V8 中的执行。

函数在 V8 中是栈调用的,即 V8 执行函数会通过执行环境栈(ECS),在函数调用时入栈,返回时出栈。

function A() {
	return B()
}

function B() {
	return C()
}

function C() {
	return 'result'
}

A()

异步回调

函数在 JavaScript 中可以作为参数和返回值,函数作为参数时被称为回调函数,被传参函数称为执行函数(高阶函数)。当回调函数在执行函数内执行时,称之为同步回调;在执行函数外执行时,称之为异步回调。

  1. 同步回调如何理解呢?

    var arr = [1,2,3]
    function handleArray(item, index) {
    	console.log(item, index)
    }
    
    arr.forEach(handleArray) // handleArray 在 forEach 函数中执行了
    
  2. 异步回调如何理解呢?

    个人理解异步回调只有一个字:等!如果回调函数是等执行函数执行完以后再执行,就可以称之为异步回调。JavaScript 中的可以接收异步回调函数的执行函数有:DOM 事件,定时器以及 XHR 异步请求等,好巧,是不是发现这三个异步回调的执行函数正对应浏览器渲染进程中三个线程:事件线程、定时器线程、网络线程。

V8 处理异步回调

  1. V8 如何处理异步回调呢?

    前面提到异步回调的本质问题:等。那对 UI 线程来说,不断执行 JavaScript 代码,不断渲染页面,面对 DOM 事件,定时器以及 XHR 异步请求等存在异步回调的任务,如何等呢?

    V8 为 UI 线程提供了一个消息队列,执行 JavaScript 代码时,遇到 DOM 事件通过事件线程将其异步回调封装成宏任务添加至消息队列,遇到定时器通过定时器线程将其异步回调封装成宏任务添加至消息队列,遇到 XHR 异步请求通过网络线程将其异步回调封装成宏任务添加至消息队列。当 ECS 执行完成以后,UI 线程再执行消息队列中的宏任务。

异步函数

  1. 如果实际工作中,如何实现异步函数?

    // 顺序执行 A,B,C 三个函数
    function A() {
    	console.log('A')
    	function B() {
    		console.log('B')
    		function C() {
    			console.log('C')
    		}
    		C()
    	}
    	B()
    }
    
    A()
    

    上述代码出现一个问题,3 个函数嵌套层级如此,那如果100 个呢?势必会有更多更深的嵌套,这种现象称之为回调地狱。回调地狱会导致代码耦合严重,维护难,阅读难等问题,如何解决呢?

    ES6 提出了新的规范 promise 和 async / awit 可以很好的解决 JavaScript 中的回调地狱问题,在 promise 和 async / awit 也使用了异步回调的方式。

  2. V8 中如何处理 promise 和 async / awit 的异步回调呢?

    V8 处理异步函数会在 ECS 栈中的全局执行上下文中创建一个微任务队列,当遇到 promise 和 async / awit 异步函数回调时,会将异步函数回调封装成微任务添加至微任务队列中,函数执行完成时,会从全局执行上下文中的微任务队列中取出任务继续执行。

JavaScript 函数执行顺序

  1. 执行 ESC 栈中的函数执行上下文,至全局执行上下文。

  2. 执行全局执行上下文中的微任务队列。

  3. 执行完成,清空 ECS 栈,继续执行宏任务队列。

V8 垃圾回收机制

JavaScript 内部具有垃圾回收机制,会自动进行无引用资源的回收工作。V8 在执行垃圾回收时,会占用主线程资源,频繁触发垃圾回收机制可能会导致主线程阻塞。

垃圾回收器

V8 依据代际假说,将堆内存划分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。

V8 设置了两个垃圾回收器,主垃圾回收器和副垃圾回收器。主垃圾回收器负责收集老生代中的垃圾数据,副垃圾回收器负责收集新生代中的垃圾数据。

  1. 主垃圾回收器

    主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作,会经历标记、清除和整理过程。

  2. 副垃圾回收器

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

垃圾回收方式

V8 为了提高垃圾回收效率,采取多种垃圾回收方式。

  1. 并行回收

    在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。

  2. 增量式回收

    垃圾回收器会将标记工作分解为更小的块,穿插在主线程不同任务间执行垃圾回收,垃圾回收器不必要执行一次完整的垃圾回收过程,每次只执行一小部分。

  3. 并发回收

    主线程采取增量回收的时候,辅助线程在后台同步完成垃圾回收操作。

posted @ 2023-03-14 16:33  深巷酒  阅读(46)  评论(0编辑  收藏  举报