mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 06:33:48 +08:00
del
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
<audio id="audio" title="02 | 函数即对象:一篇文章彻底搞懂JavaScript的函数特点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/6f/199ab3bc83790a513f0e8a57359b5f6f.mp3"></audio>
|
||||
|
||||
你好,我是李兵。这是我们专栏的第二讲,我们来看下“函数是一等公民”背后的含义。
|
||||
|
||||
如果你熟悉了一门其他流行语言,再来使用JavaScript,那么JavaScript中的函数可能会给你造成一些误解,比如在JavaScript中,你可以将一个函数赋值给一个变量,还可以将函数作为一个参数传递给另外一个函数,甚至可以使得一个函数返回另外一个函数,这在一些主流语言中都很难实现。
|
||||
|
||||
JavaScript中的函数非常灵活,其根本原因在于**JavaScript中的函数就是一种特殊的对象**,我们把JavaScript中的函数称为**一等公民(First Class Function)。**
|
||||
|
||||
基于函数是一等公民的设计,使得JavaScript非常容易实现一些特性,比如闭包,还有函数式编程等,而其他语言要实现这些特性就显得比较困难,比如要在C++中实现闭包需要实现大量复杂的代码,而且使用起来也异常复杂。
|
||||
|
||||
函数式编程和闭包在实际的项目中会经常遇到,如果不了解这些特性,那么在你使用第三方代码时就会非常吃力,同时自己也很难使用这些特性写出优雅的代码,因此我们很有必要了解这些特性的底层机制。
|
||||
|
||||
另外,在我们后续课程介绍V8工作机制时,会学习V8是怎么实现闭包的,还会学习V8是如何将JavaScript的动态特性静态化以加快代码的执行速度,这些内容都涉及到JavaScript中的函数底层特性。
|
||||
|
||||
今天,我们就来深入分析下,JavaScript中的“函数”到底有怎样的特点。
|
||||
|
||||
## 什么是JavaScript中的对象?
|
||||
|
||||
既然在JavaScript中,函数就是一种特殊的对象,那我们首先要明白,什么是JavaScript中的“对象”?它和面向对象语言中的“对象”有什么区别?
|
||||
|
||||
和其他主流语言不一样的是,JavaScript是一门**基于对象(****Object-Based)**的语言,可以说JavaScript中大部分的内容都是由对象构成的,诸如函数、数组,也可以说JavaScript是建立在对象之上的语言。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/f8/9e946bbdc54f5e1347f7b593f8f6fff8.jpg" alt="" title="基于对象的设计">
|
||||
|
||||
而这些对象在运行时可以动态修改其内容,这造就了JavaScript的超级灵活特性。不过,因为JavaScript太灵活了,也加大了理解和使用这门语言的难度。
|
||||
|
||||
虽然JavaScript是基于对象设计的,但是它却不是一门**面向对象的语言(Object—Oriented Programming Language)**,因为面向对象语言天生支持**封装、继承、多态**,但是JavaScript并没有直接提供多态的支持,因此要在JavaScript中使用多态并不是一件容易的事。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/00/eff1c1c773835b79ce597a84b2f94a00.jpg" alt="" title="面向对象的语言">
|
||||
|
||||
除了对多态支持的不好,JavaScript实现继承的方式和面向对象的语言实现继承的方式同样存在很大的差异。
|
||||
|
||||
面向对象语言是由语言本身对继承做了充分的支持,并提供了大量的关键字,如public、protected、friend、interface等,众多的关键字使得面向对象语言的继承变得异常繁琐和复杂,而JavaScript中实现继承的方式却非常简单清爽,**只是在对象中添加了一个称为原型的属性,把继承的对象通过原型链接起来,就实现了继承,我们把这种继承方式称为基于原型链继承。**关于V8是如何支持原型的,我们会在《[05 | 原型链:V8是如何实现对象继承的?](https://time.geekbang.org/column/article/215425)》这节课做具体介绍。
|
||||
|
||||
既然“JavaScript中的对象”和“面向对象语言中的对象”存在巨大差异,那么在JavaScript中,我们所谈论的对象到底是指什么呢?
|
||||
|
||||
其实JavaScript中的对象非常简单,每个对象就是由一组组属性和值构成的集合,比如我使用下面代码创建了一个person对象:
|
||||
|
||||
```
|
||||
var person=new Object();
|
||||
person.firstname="John";
|
||||
person.lastname="Doe";
|
||||
person.age=50;
|
||||
person.eyecolor="blue";
|
||||
|
||||
```
|
||||
|
||||
这个对象里面有四个属性,为了直观理解,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/23/d07e174001a29765a3575908e3704123.jpg" alt="" title="对象的构成">
|
||||
|
||||
上图展示了对象person的结构,我们可以看到蓝色的属性在左边,黄色的值在右边,有多组属性和值组成,这就是JavaScript中的对象,虽然JavaScript对象用途非常广泛,使用的方式也非常之多,但是万变不离其宗,其核心本质都就是由一组组属性和值组成的集合,抓住了这一点,当我们再分析对象时,就会轻松很多。
|
||||
|
||||
之所以JavaScript中对象的用途这么广,是因为对象的值可以是任意类型的数据,我们可以改造下上面的那段代码,来看看对象的值都有哪些类型?改造后的代码如下所示:
|
||||
|
||||
```
|
||||
var person=new Object()
|
||||
person.firstname="John"
|
||||
person.lastname="Doe"
|
||||
person.info = new Object()
|
||||
person.info.age=50
|
||||
person.info.eyecolor="blue"
|
||||
person.showinfo = function (){
|
||||
console.log(/*...*/)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以先画出这段代码的内存布局,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/17/f73524e4cae884747ae528d999fc1117.jpg" alt="" title="属性值类型">
|
||||
|
||||
观察上图,我们可以看出来,对象的属性值有三种类型:
|
||||
|
||||
第一种是**原始类型(primitive)**,所谓的原始类的数据,是指值本身无法被改变,比如JavaScript中的字符串就是原始类型,如果你修改了JavaScript中字符串的值,那么V8会返回给你一个新的字符串,原始字符串并没有被改变,我们称这些类型的值为“原始值”。
|
||||
|
||||
JavaScript中的原始值主要包括null、undefined、boolean、number、string、bigint、symbol 这七种。
|
||||
|
||||
第二种就是我们现在介绍的**对象类型(Object)**,对象的属性值也可以是另外一个对象,比如上图中的info属性值就是一个对象。
|
||||
|
||||
第三种是**函数类型(Function)**,如果对象中的属性值是函数,那么我们把这个属性称为方法,所以我们又说对象具备属性和方法,那么上图中的showinfo就是person对象的一个方法。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/a6/8c33fa6c6e0cef5795292f0a21ee36a6.jpg" alt="" title="对象属性值的三种类型">
|
||||
|
||||
## 函数的本质
|
||||
|
||||
分析完对象,现在我们就能更好地理解JavaScript中函数的概念了。
|
||||
|
||||
在这节课开始我就提到,在JavaScript中,函数是一种特殊的对象,它和对象一样可以拥有属性和值,但是函数和普通对象不同的是,函数可以被调用。
|
||||
|
||||
我们先来看一段JavaScript代码,在这段代码中,我们定义了一个函数foo,接下来我们给foo函数设置了myName和uName的属性。
|
||||
|
||||
```
|
||||
function foo(){
|
||||
var test = 1
|
||||
}
|
||||
foo.myName = 1
|
||||
foo.uName = 2
|
||||
console.log(foo.myName)
|
||||
|
||||
```
|
||||
|
||||
既然是函数,那么它也可以被调用。比如你定义了一个函数,便可以通过函数名称加小括号来实现函数的调用,代码如下所示:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
var test = 1
|
||||
console.log(test)
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
除了使用函数名称来实现函数的调用,还可以直接调用一个匿名函数,代码如下所示:
|
||||
|
||||
```
|
||||
(function (){
|
||||
var test = 1
|
||||
console.log(test)
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
那么,V8内部是怎么实现函数可调用特性的呢?
|
||||
|
||||
其实在V8内部,会为函数对象添加了两个隐藏属性,具体属性如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/e2/9e274227d637ce8abc4a098587613de2.jpg" alt="" title="函数对象具有隐藏属性">
|
||||
|
||||
也就是说,函数除了可以拥有常用类型的属性值之外,还拥有两个隐藏属性,分别是name属性和code属性。
|
||||
|
||||
隐藏name属性的值就是函数名称,如果某个函数没有设置函数名,如下面这段函数:
|
||||
|
||||
```
|
||||
(function (){
|
||||
var test = 1
|
||||
console.log(test)
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
该函数对象的默认的name属性值就是anonymous,表示该函数对象没有被设置名称。另外一个隐藏属性是code属性,其值表示函数代码,以字符串的形式存储在内存中。当执行到一个函数调用语句时,V8便会从函数对象中取出code属性值,也就是函数代码,然后再解释执行这段函数代码。
|
||||
|
||||
## 函数是一等公民
|
||||
|
||||
因为函数是一种特殊的对象,所以在JavaScript中,函数可以赋值给一个变量,也可以作为函数的参数,还可以作为函数的返回值。**如果某个编程语言的函数,可以和这个语言的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民。**支持函数是一等公民的语言可以使得代码逻辑更加清晰,代码更加简洁。
|
||||
|
||||
但是由于函数的“可被调用”的特性,使得实现函数的可赋值、可传参和可作为返回值等特性变得有一点麻烦。为什么?
|
||||
|
||||
我们知道,在执行JavaScript函数的过程中,为了实现变量的查找,V8会为其维护一个作用域链,如果函数中使用了某个变量,但是在函数内部又没有定义该变量,那么函数就会沿着作用域链去外部的作用域中查找该变量,具体流程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/fd/8bb90b190362e3a00e5a260bad6829fd.jpg" alt="" title="查找变量">
|
||||
|
||||
从图中可以看出,当函数内部引用了外部的变量时,使用这个函数进行赋值、传参或作为返回值,你还需要保证这些被引用的外部变量是确定存在的,这就是让函数作为一等公民麻烦的地方,因为虚拟机还需要处理函数引用的外部变量。我们来看一段简单的代码:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
var number = 1
|
||||
function bar(){
|
||||
number++
|
||||
console.log(number)
|
||||
}
|
||||
return bar
|
||||
}
|
||||
var mybar = foo()
|
||||
mybar()
|
||||
|
||||
```
|
||||
|
||||
观察上段代码可以看到,我们在foo函数中定义了一个新的bar函数,并且bar函数引用了foo函数中的变量number,当调用foo函数的时候,它会返回bar函数。
|
||||
|
||||
那么所谓的“函数是一等公民”就体现在,如果要返回函数bar给外部,那么即便foo函数执行结束了,其内部定义的number变量也不能被销毁,因为bar函数依然引用了该变量。
|
||||
|
||||
我们也把这种将外部变量和和函数绑定起来的技术称为闭包。V8在实现闭包的特性时也做了大量的额外的工作,关于闭包的详细实现,我们会在《[12 | 延迟解析:V8是如何实现闭包的?](https://time.geekbang.org/column/article/223168)》这节课再介绍。
|
||||
|
||||
另外基于函数是一等公民,我们可以轻松使用JavaScript来实现目前比较流行的函数式编程,函数式编程规则很少,非常优美,不过这并不是本专栏的重点,所以我们先略开不讲。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的内容就介绍到这里,下面我来总结下本文的主要内容。
|
||||
|
||||
本文我们围绕JavaScript中的函数来展开介绍,JavaScript中的函数非常灵活,既可以被调用,还可以作为变量、参数和返回值,这些特性使得函数的用法非常多,这也导致了函数变得有些复杂,因此本文的目的就是要讲清楚函数到底是什么?
|
||||
|
||||
因为函数是一种特殊的对象,所以我们先介绍了JavaScript中的对象,JavaScript中的对象就是由一组一组属性和值组成的集合,既然函数也是对象,那么函数也是由一组组值和属性组成的集合,我们还在文中使用了一段代码证明了这点。
|
||||
|
||||
因为函数作为一个对象,是可以被赋值、作为参数,还可以作为返回值的,那么如果一个函数返回了另外一个函数,那么就应该返回该函数所有相关的内容。
|
||||
|
||||
接下来,我们又介绍了一个函数到底关联了哪些内容:
|
||||
|
||||
- 函数作为一个对象,它有自己的属性和值,所以函数关联了基础的属性和值;
|
||||
- 函数之所以成为特殊的对象,这个特殊的地方是函数可以“被调用”,所以一个函数被调用时,它还需要关联相关的执行上下文。
|
||||
|
||||
结合以上两点,JavaScript中的函数就实现了“函数是一等公民”的特性。
|
||||
|
||||
## 思考题
|
||||
|
||||
本文我们从对象聊到了闭包,那么留给你的问题是,哪些语言天生支持“函数是一等公民”?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,195 @@
|
||||
<audio id="audio" title="03 | 快属性和慢属性:V8是怎样提升对象属性访问速度的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/bb/fb3e7b801528c255e041f54249c75cbb.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在前面的课程中,我们介绍了JavaScript中的对象是由一组组属性和值的集合,从JavaScript语言的角度来看,JavaScript对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值。
|
||||
|
||||
然而在V8实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典是非线性的数据结构,查询效率会低于线性的数据结构,V8为了提升存储和查找效率,采用了一套复杂的存储策略。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/ef/c970cdc7b89bfe0a12e560fe94fcdfef.jpg" alt="" title="线性结构和非线性结构">
|
||||
|
||||
今天这节课我们就来分析下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]}`)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这段代码中,我们利用构造函数Foo创建了一个bar对象,在构造函数中,我们给bar对象设置了很多属性,包括了数字属性和字符串属性,然后我们枚举出来了bar对象中所有的属性,并将其一一打印出来,下面就是执行这段代码所打印出来的结果:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
观察这段打印出来的数据,我们发现打印出来的属性顺序并不是我们设置的顺序,我们设置属性的时候是乱序设置的,比如开始先设置100,然后又设置了1,但是输出的内容却非常规律,总的来说体现在以下两点:
|
||||
|
||||
- 设置的数字属性被最先打印出来了,并且是按照数字大小的顺序打印的;
|
||||
- 设置的字符串属性依然是按照之前的设置顺序打印的,比如我们是按照B、A、C的顺序设置的,打印出来依然是这个顺序。
|
||||
|
||||
之所以出现这样的结果,是因为在ECMAScript规范中定义了**数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。**
|
||||
|
||||
在这里我们把对象中的数字属性称为**排序属性**,在V8中被称为**elements**,字符串属性就被称为**常规属性**,在V8中被称为**properties**。
|
||||
|
||||
在V8内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个**线性数据结构**来分别保存排序属性和常规属性,具体结构如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/75/af2654db3d3a2e0b9a9eaa25e862cc75.jpg" alt="" title="V8内部的对象构造">
|
||||
|
||||
通过上图我们可以发现,bar对象包含了两个隐藏属性:elements属性和properties属性,elements属性指向了elements对象,在elements对象中,会按照顺序存放排序属性,properties属性则指向了properties对象,在properties对象中,会按照创建时的顺序保存了常规属性。
|
||||
|
||||
分解成这两种线性数据结构之后,如果执行索引操作,那么V8会先从elements属性中按照顺序读取所有的元素,然后再在properties属性中读取所有的元素,这样就完成一次索引操作。
|
||||
|
||||
## 快属性和慢属性
|
||||
|
||||
将不同的属性分别保存到elements属性和properties属性中,无疑简化了程序的复杂度,但是在查找元素时,却多了一步操作,比如执行 `bar.B`这个语句来查找B的属性值,那么在V8会先查找出properties属性所指向的对象properties,然后再在properties对象中查找B属性,这种方式在查找过程中增加了一步操作,因此会影响到元素的查找效率。
|
||||
|
||||
基于这个原因,V8采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为**对象内属性(in-object properties)。**对象在内存中的展现形式你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/3e/f12b4c6f6e631ce51d5b4f288dbfb13e.jpg" alt="" title="对象内属性">
|
||||
|
||||
采用对象内属性之后,常规属性就被保存到bar对象本身了,这样当再次使用`bar.B`来查找B的属性值时,V8就可以直接从bar对象本身去获取该值就可以了,这种方式减少查找属性值的步骤,增加了查找效率。
|
||||
|
||||
不过对象内属性的数量是固定的,默认是10个,如果添加的属性超出了对象分配的空间,则它们将被保存在常规属性存储中。虽然属性存储多了一层间接层,但可以自由地扩容。
|
||||
|
||||
通常,我们将保存在线性数据结构中的属性称之为“快属性”,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果从线性结构中添加或者删除大量的属性时,则执行效率会非常低,这主要因为会产生大量时间和内存开销。
|
||||
|
||||
因此,如果一个对象的属性过多时,V8就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构(词典)作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/17/e8ce990dce53295a414ce79e38149917.jpg" alt="" title="慢属性是如何存储的">
|
||||
|
||||
## 实践:在Chrome中查看对象布局
|
||||
|
||||
现在我们知道了V8是怎么存储对象的了,接下来我们来结合Chrome中的内存快照,来看看对象在内存中是如何布局的?
|
||||
|
||||
你可以打开Chrome开发者工具,先选择控制台标签,然后在控制台中执行以下代码查看内存快照:
|
||||
|
||||
```
|
||||
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(10,10)
|
||||
|
||||
```
|
||||
|
||||
上面我们创建了一个构造函数,可以利用该构造函数创建了新的对象,我给该构造函数设置了两个参数property_num、element_num,分别代表创建常规属性的个数和排序属性的个数,我们先将这两种类型的个数都设置为10个,然后利用该构造函数创建了一个新的bar对象。
|
||||
|
||||
创建了函数对象,接下来我们就来看看构造函数和对象在内存中的状态。你可以将Chrome开发者工具切换到Memory标签,然后点击左侧的小圆圈就可以捕获当前的内存快照,最终截图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/d3/d2a123d127a2895d9f0d09be61cc55d3.png" alt="" title="V8内存快照截图">
|
||||
|
||||
上图就是收集了当前内存快照的界面,要想查找我们刚才创建的对象,你可以在搜索框里面输入构造函数Foo,Chrome会列出所有经过构造函数Foo创建的对象,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/89/2b4ee447d061f72026ca38d6dfc25389.png" alt="" title="从内存快照搜索构造函数">
|
||||
|
||||
观察上图,我们搜索出来了所有经过构造函数Foo创建的对象,点开Foo的那个下拉列表,第一个就是刚才创建的bar对象,我们可以看到bar对象有一个elements属性,这里面就包含我们创造的所有的排序属性,那么怎么没有常规属性对象呢?
|
||||
|
||||
这是因为只创建了10个常规属性,所以V8将这些常规属性直接做成了bar对象的对象内属性。
|
||||
|
||||
所以这时候的数据内存布局是这样的:
|
||||
|
||||
- 10个常规属性作为对象内属性,存放在bar函数内部;
|
||||
- 10个排序属性存放在elements中。
|
||||
|
||||
接下来我们可以将创建的对象属性的个数调整到20个,你可以在控制台执行下面这段代码:
|
||||
|
||||
```
|
||||
var bar2 = new Foo(20,10)
|
||||
|
||||
```
|
||||
|
||||
然后我们再重新生成内存快照,再来看看生成的图片:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/6e/ef117bbc99504f1daea52a0831a5756e.png" alt="" title=" 利用构造函数生成的对象">
|
||||
|
||||
我们可以看到,构造函数Foo下面已经有了两个对象了,其中一个bar,另外一个是bar2,我们点开第一个bar2对象,内容如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/86/49c1f8e735e5b7772f3d54fb53eae386.png" alt="" title="查看对象属性">
|
||||
|
||||
由于创建的常用属性超过了10个,所以另外10个常用属性就被保存到properties中了,注意因为properties中只有10个属性,所以依然是线性的数据结构,我们可以看其都是按照创建时的顺序来排列的。
|
||||
|
||||
所以这时候属性的内存布局是这样的:
|
||||
|
||||
- 10属性直接存放在bar2的对象内;
|
||||
- 10个常规属性以线性数据结构的方式存放在properties属性里面;
|
||||
- 10个数字属性存放在elements属性里面。
|
||||
|
||||
如果常用属性太多了,比如创建了100个,那么我们再来看看其内存分布,你可以执行下面这段代码:
|
||||
|
||||
```
|
||||
var bar3 = new Foo(100,10)
|
||||
|
||||
```
|
||||
|
||||
然后以同样的方式打开bar3,查看其内存布局,最终如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/69/dab6d6e2291117781e4294f27113d469.png" alt="" title="利用字典存放常规元素">
|
||||
|
||||
结合上图,我们可以看到,这时候的properties属性里面的数据并不是线性存储的,而是以非线性的字典形式存储的,所以这时候属性的内存布局是这样的:
|
||||
|
||||
- 10属性直接存放在bar3的对象内;
|
||||
- 90个常规属性以非线性字典的这种数据结构方式存放在properties属性里面;
|
||||
- 10个数字属性存放在elements属性里面。
|
||||
|
||||
## 其他属性
|
||||
|
||||
好了,现在我们知道V8是怎么存储对象的了,不过这里还有几个重要的隐藏属性我还没有介绍,下面我们就来简单地看下。你可以先看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/4d/82463f4c1a4bf5fb8920b0099284e84d.png" alt="" title="其他隐藏属性">
|
||||
|
||||
观察上图,除了elements和properties属性,V8还为每个对象实现了map属性和__proto__属性。__proto__属性就是原型,是用来实现JavaScript继承的,我们会在下一节来介绍;而map则是隐藏类,我们会在《[15 | 隐藏类:如何在内存中快速查找对象属性?](https://time.geekbang.org/column/article/226417)》这一节中介绍其工作机制。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,本节的内容就介绍到这里,下面我来总结下本文的主要内容:
|
||||
|
||||
本文我们的主要目标是介绍V8内部是如何存储对象的,因为JavaScript中的对象是由一组组属性和值组成的,所以最简单的方式是使用一个字典来保存属性和值,但是由于字典是非线性结构,所以如果使用字典,读取效率会大大降低。
|
||||
|
||||
为了提升查找效率,V8在对象中添加了两个隐藏属性,排序属性和常规属性,element属性指向了elements对象,在elements对象中,会按照顺序存放排序属性。properties属性则指向了properties对象,在properties对象中,会按照创建时的顺序保存常规属性。
|
||||
|
||||
通过引入这两个属性,加速了V8查找属性的速度,为了更加进一步提升查找效率,V8还实现了内置内属性的策略,当常规属性少于一定数量时,V8就会将这些常规属性直接写进对象中,这样又节省了一个中间步骤。
|
||||
|
||||
但是如果对象中的属性过多时,或者存在反复添加或者删除属性的操作,那么V8就会将线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查找速度,但是却提升了修改对象的属性的速度。
|
||||
|
||||
## 思考题
|
||||
|
||||
通常,我们不建议使用delete来删除属性,你能结合文中介绍的快属性和慢属性,给出不建议使用delete的原因吗?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,329 @@
|
||||
<audio id="audio" title="04 | 函数表达式:涉及大量概念,函数表达式到底该怎么学?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c6/18/c6fc2973e24ba918316f707b783c4e18.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
前面几节我们聊了V8中的对象和函数,并介绍了函数为什么会被称为是一等公民,了解这些之后,我们就可以来学习函数表达式了。
|
||||
|
||||
函数表达式在JavaScript中非常基础也非常重要,使用函数表达式可以用来实现代码隐藏,还可以实现变量隔离,所以函数表达式被广泛地应用在各个项目中,了解函数表达式的底层工作机制,可以帮助我们更加深刻地理解项目。
|
||||
|
||||
但是,学好函数表达式并不容易。因为它涉及到了很多底层概念,比如表达式、语句、函数即对象(在JavaScript中)等,而且函数表达式和函数声明看起来类似,都是定义一个函数,然后再调用该函数,很容易把二者搞混淆了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/31/51ae06e8a9dc4a589958065429bec231.jpg" alt="" title="你知道两者的区别吗?">
|
||||
|
||||
实际上,函数表达式和函数声明有着本质上的差异。理解了这种差异,你对函数表达式的理解也就加深了。
|
||||
|
||||
## 函数声明与函数表达式的差异
|
||||
|
||||
那么它们具体有什么差异呢?我们先来看一段代码:
|
||||
|
||||
```
|
||||
foo()
|
||||
function foo(){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我声明了一个foo函数,然后在foo函数之前调用了foo函数,执行这段代码,我们看到foo函数被正确执行了。(你可能会好奇,代码不是自上而下执行吗,为什么在函数声明之前就可以调用该函数了呢?这个问题我们先留一边,后文中会进行解答。)
|
||||
|
||||
再来看另外一段代码:
|
||||
|
||||
```
|
||||
foo()
|
||||
var foo = function (){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我定义了一个变量foo,然后将一个函数赋值给了变量foo,同样在源码中,我们也是在foo函数的前面调用foo,执行这段代码,我们发现报错了,提示的错误信息如下所示:
|
||||
|
||||
```
|
||||
VM130:1 Uncaught TypeError: foo is not a function
|
||||
at <anonymous>:1:1
|
||||
|
||||
```
|
||||
|
||||
这是告诉我们,变量foo并不是一个函数,所以无法被调用。
|
||||
|
||||
同样是在定义的函数之前调用函数,第一段代码就可以正确执行,而第二段代码却报错,这是为什么呢?
|
||||
|
||||
其主要原因是这两种定义函数的方式具有不同语义,不同的语义触发了不同的行为。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/10/a74668eb5bf183538ce9b47a20eb0610.jpg" alt="" title="不同的语义,触发不同的行为">
|
||||
|
||||
因为语义不同,所以我们给这两种定义函数的方式使用了不同的名称,第一种称之为**函数声明**,第二种称之为**函数表达式。**
|
||||
|
||||
下面我们就来分别分析下,函数声明和函数表达式的语义,以及V8是怎么处理函数声明和函数表达式的。
|
||||
|
||||
## V8是怎么处理函数声明的?
|
||||
|
||||
我们先来看函数声明,**函数声明**定义了一个具有指定参数的函数,其声明语法如下所示:
|
||||
|
||||
```
|
||||
function name([param,[, param,[..., param]]]) {
|
||||
[statements]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来我们来看看V8是怎么处理函数声明的。
|
||||
|
||||
我们知道,V8在执行JavaScript的过程中,会先对其进行编译,然后再执行,比如下面这段代码:
|
||||
|
||||
```
|
||||
var x = 5
|
||||
function foo(){
|
||||
console.log('Foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
V8执行这段代码的流程大致如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/32/49eb14dd3c00438988595896c348c732.jpg" alt="">
|
||||
|
||||
在编译阶段,如果解析到函数声明,那么V8会将这个函数声明转换为内存中的函数对象,并将其放到作用域中。同样,如果解析到了某个变量声明,也会将其放到作用域中,但是会将其值设置为undefined,表示该变量还未被使用。
|
||||
|
||||
然后在V8执行阶段,如果使用了某个变量,或者调用了某个函数,那么V8便会去作用域查找相关内容。
|
||||
|
||||
关于作用域的数据,你也可以使用D8来查看,具体操作方式如下:
|
||||
|
||||
- 将这段代码保存到test.js中;
|
||||
- 使用“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
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段就是V8生成的作用域,我们可以看到,作用域中包含了变量x和foo,变量x的默认值是undefined,变量foo指向了foo函数对象,foo函数对象被V8存放在内存中的堆空间了,这些变量都是在编译阶段被装进作用域中的。
|
||||
|
||||
因为在执行之前,这些变量都被提升到作用域中了,所以在执行阶段,V8当然就能获取到所有的定义变量了。我们把这种在编译阶段,将所有的变量提升到作用域的过程称为**变量提升**。
|
||||
|
||||
了解了变量提升,我们就能解释,为什么可以在函数声明之前调用该函数了,这是因为声明的函数在编译阶段就被提升到作用域中,在执行阶段,只要是在作用域中存在的变量或者对象,都是可以使用的。
|
||||
|
||||
对于变量提升,函数和普通的对象还是存在一些差异的,通过上面的分析我们知道,如果是一个普通变量,变量提升之后的值都是undefined,如果是声明的函数,那么变量提升之后的值则是函数对象,我们可以通过下面的代码来实践下:
|
||||
|
||||
```
|
||||
console.log(x)
|
||||
console.log(foo)
|
||||
var x = 5
|
||||
function foo(){
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行上面这段代码,我们可以看到,普通变量x的值就是undefined,而函数对象foo的值则是完整的对象,那这又是为什么呢?这就是涉及到表达式和语句的区别了。
|
||||
|
||||
简单地理解,表达式就是表示值的式子,而语句是操作值的式子。
|
||||
|
||||
比如:
|
||||
|
||||
```
|
||||
x = 5
|
||||
|
||||
```
|
||||
|
||||
就是表达式,因为执行这段代码,它会返回一个值。同样,`6 === 5` 也是一个表达式,因为它会返回False。
|
||||
|
||||
而语句则不同了,比如你定义了一个变量:
|
||||
|
||||
```
|
||||
var x
|
||||
|
||||
```
|
||||
|
||||
这就是一个语句,执行该语句时,V8并不会返回任何值给你。
|
||||
|
||||
同样,当我声明了一个函数时,这个函数声明也是一个语句,比如下面这段函数声明:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
return 1
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当执行到这段代码时,V8并没有返回任何的值,它只是解析foo函数,并将函数对象存储到内存中。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/24/43/244971073e6e41d10cefbb1de13bb343.jpg" alt="" title="表达式和语句">
|
||||
|
||||
了解了表达式和语句的区别,接下来我们继续分析上面的问题。我们知道,在V8执行`var x = 5`这段代码时,会认为它是两段代码,一段是定义变量的语句,一段是赋值的表达式,如下所示:
|
||||
|
||||
```
|
||||
var x = undefined
|
||||
x = 5
|
||||
|
||||
```
|
||||
|
||||
首先,在变量提升阶段,V8并不会执行赋值的表达式,该阶段只会分析基础的语句,比如变量的定义,函数的声明。
|
||||
|
||||
而这两行代码是在不同的阶段完成的,`var x` 是在编译阶段完成的,也可以说是在变量提升阶段完成的,而`x = 5`是表达式,所有的表达式都是在执行阶段完成的。
|
||||
|
||||
在变量提升阶段,V8将这些变量存放在作用域时,还会给它们赋一个默认的undefined值,所以在定义一个普通的变量之前,使用该变量,那么该变量的值就是undefined。
|
||||
|
||||
现在我们知道,**表达式是不会在编译阶段执行的**,那么函数声明是表达式还是语句呢?你可以看下面这段函数声明:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
console.log('Foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行上面这段代码,它并没有输出任何内容,所以可以肯定,函数声明并不是一个表达式,而是一个语句。V8在变量提升阶段,如果遇到函数声明,那么V8同样会对该函数声明执行变量提升操作。
|
||||
|
||||
函数也是一个对象,所以在编译阶段,V8就会将整个函数对象提升到作用域中,并不是给该函数名称赋一个undefined,理解这一点尤为重要。
|
||||
|
||||
总的来说,在V8解析JavaScript源码的过程中,如果遇到普通的变量声明,那么便会将其提升到作用域中,并给该变量赋值为undefined,如果遇到的是函数声明,那么V8会在内存中为声明生成函数对象,并将该对象提升到作用域中。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/e6/ec7dc43a09baf57985b1cefda1caf4e6.jpg" alt="">
|
||||
|
||||
## V8是怎么处理函数表达式的?
|
||||
|
||||
了解了函数声明,我们再来看看函数表达式。**我们在一个表达式中使用function来定义一个函数,那么就把该函数称为函数表达式。**
|
||||
|
||||
比如:
|
||||
|
||||
```
|
||||
foo = 1
|
||||
|
||||
```
|
||||
|
||||
它是一个表达式,这时候我们把右边的数字1替换成函数定义,那么这就变成了函数表达式,如下所示:
|
||||
|
||||
```
|
||||
foo = function (){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
函数表达式与函数声明的最主要区别有以下三点:
|
||||
|
||||
- 函数表达式是在表达式语句中使用function的,最典型的表达式是“a=b”这种形式,因为函数也是一个对象,我们把“a = function (){}”这种方式称为函数表达式;
|
||||
- 在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions);
|
||||
- 一个函数表达式可以被用作一个即时调用的函数表达式——IIFE(Immediately Invoked Function Expression)。
|
||||
|
||||
了解了函数表达式,我们就来分析这段代码:
|
||||
|
||||
```
|
||||
foo()
|
||||
var foo = function (){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当执行这段代码的时候,V8在编译阶段会先查找声明语句,你可以把这段代码拆分为下面两行代码:
|
||||
|
||||
```
|
||||
var foo = undefined
|
||||
foo = function (){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第一行是声明语句,所以V8在解析阶段,就会在作用域中创建该对象,并将该对象设置为undefined,第二行是函数表达式,在编译阶段,V8并不会处理函数表达式,所以也就不会将该函数表达式提升到作用域中了。
|
||||
|
||||
那么在函数表达式之前调用该函数foo,此时的foo只是指向了undefined,所以就相当于调用一个undefined,而undefined只是一个原生对象,并不是函数,所以当然会报错了。
|
||||
|
||||
## 立即调用的函数表达式(IIFE)
|
||||
|
||||
现在我们知道了,在编译阶段,V8并不会处理函数表达式,而JavaScript中的**立即函数调用表达式**正是使用了这个特性来实现了非常广泛的应用,下面我们就来一起看看立即函数调用表达式。
|
||||
|
||||
JavaScript中有一个圆括号运算符,圆括号里面可以放一个表达式,比如下面的代码:
|
||||
|
||||
```
|
||||
(a=3)
|
||||
|
||||
```
|
||||
|
||||
括号里面是一个表达式,整个语句也是一个表达式,最终输出3。
|
||||
|
||||
如果在小括号里面放上一段函数的定义,如下所示:
|
||||
|
||||
```
|
||||
(function () {
|
||||
//statements
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
因为小括号之间存放的必须是表达式,所以如果在小阔号里面定义一个函数,那么V8就会把这个函数看成是函数表达式,执行时它会返回一个函数对象。
|
||||
|
||||
存放在括号里面的函数便是一个函数表达式,它会返回一个函数对象,如果我直接在表达式后面加上调用的括号,这就称为**立即调用函数表达式**(IIFE),比如下面代码:
|
||||
|
||||
```
|
||||
(function () {
|
||||
//statements
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
因为函数立即表达式也是一个表达式,所以V8在编译阶段,并不会为该表达式创建函数对象。**这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。**
|
||||
|
||||
在ES6之前,JavaScript中没有私有作用域的概念,如果在多人开发的项目中,你模块中的变量可能覆盖掉别人的变量,所以使用函数立即表达式就可以将我们内部变量封装起来,避免了相互之间的变量污染。
|
||||
|
||||
另外,因为函数立即表达式是立即执行的,所以将一个函数立即表达式赋给一个变量时,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。如下所示:
|
||||
|
||||
```
|
||||
var a = (function () {
|
||||
return 1
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们主要学习V8是如何处理函数表达式的。函数表达式在实际的项目应用中非常广,不过由于函数声明和函数表达式之间非常类似,非常容易引起人们的误解,所以我们先从通过两段容易让人误解的代码,分析了函数声明和函数表达式之间的区别。函数声明的本质是语句,而函数表达式的本质则是表达式。
|
||||
|
||||
函数声明和变量声明类似,V8在编译阶段,都会对其执行变量提升的操作,将它们提升到作用域中,在执行阶段,如果使用了某个变量,就可以直接去作用域中去查找。
|
||||
|
||||
不过V8对于提升函数和提升变量的策略是不同的,如果提升了一个变量,那么V8在将变量提升到作用域中时,还会为其设置默认值undefined,如果是函数声明,那么V8会在内存中创建该函数对象,并提升整个函数对象。
|
||||
|
||||
函数表达式也是表达式的一种,在编译阶段,V8并不会将表达式中的函数对象提升到全局作用域中,所以无法在函数表达式之前使用该函数。函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用,因此在一些大的开源项目中有广泛的应用。
|
||||
|
||||
## 思考题
|
||||
|
||||
留给你一道经典面试题,看看下面这两段代码打印出来的结果是什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
```
|
||||
var n = 1;
|
||||
(function foo(){
|
||||
n = 100;
|
||||
console.log(n);
|
||||
}())
|
||||
console.log(n);
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
var n = 1;
|
||||
function foo(){
|
||||
n = 100;
|
||||
console.log(n);
|
||||
}
|
||||
console.log(n);
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
248
极客时间专栏/geek/图解 Google V8/JavaScript设计思想篇/05|原型链:V8是如何实现对象继承的?.md
Normal file
248
极客时间专栏/geek/图解 Google V8/JavaScript设计思想篇/05|原型链:V8是如何实现对象继承的?.md
Normal file
@@ -0,0 +1,248 @@
|
||||
<audio id="audio" title="05|原型链:V8是如何实现对象继承的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/26/85/268824a5ccb33a5e0d1a27c31369e485.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在前面两节中,我们分析了什么是JavaScript中的对象,以及V8内部是怎么存储对象的,本节我们继续深入学习对象,一起来聊聊V8是如何实现JavaScript中对象继承的。
|
||||
|
||||
简单地理解,**继承就是一个对象可以访问另外一个对象中的属性和方法**,比如我有一个B对象,该对象继承了A对象,那么B对象便可以直接访问A对象中的属性和方法,你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/7b/c91e103f535679a4f6901d0b4ff8cb7b.jpg" alt="" title="什么是继承">
|
||||
|
||||
观察上图,因为B继承了A,那么B可以直接使用A中的color属性,就像这个属性是B自带的一样。
|
||||
|
||||
不同的语言实现继承的方式是不同的,其中最典型的两种方式是**基于类的设计**和**基于原型继承的设计**。
|
||||
|
||||
C++、Java、C#这些语言都是基于经典的类继承的设计模式,这种模式最大的特点就是提供了非常复杂的规则,并提供了非常多的关键字,诸如class、friend、protected、private、interface等,通过组合使用这些关键字,就可以实现继承。
|
||||
|
||||
使用基于类的继承时,如果业务复杂,那么你需要创建大量的对象,然后需要维护非常复杂的继承关系,这会导致代码过度复杂和臃肿,另外引入了这么多关键字也给设计带来了更大的复杂度。
|
||||
|
||||
而JavaScript的继承方式和其他面向对象的继承方式有着很大差别,JavaScript本身不提供一个class 实现。虽然标准委员会在 ES2015/ES6 中引入了 class 关键字,但那只是语法糖,JavaScript 的继承依然和基于类的继承没有一点关系。所以当你看到JavaScript出现了class关键字时,不要以为JavaScript也是面向对象语言了。
|
||||
|
||||
JavaScript仅仅在对象中引入了一个原型的属性,就实现了语言的继承机制,基于原型的继承省去了很多基于类继承时的繁文缛节,简洁而优美。
|
||||
|
||||
## 原型继承是如何实现的?
|
||||
|
||||
那么,基于原型继承是如何实现的呢?我们参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/ca/687740eecf5aad32403cc00a751233ca.jpg" alt="">
|
||||
|
||||
有一个对象C,它包含了一个属性“type”,那么对象C是可以直接访问它自己的属性type的,这点毫无疑问。
|
||||
|
||||
怎样让C对象像访问自己的属性一样,访问B对象呢?
|
||||
|
||||
上节我们从V8的内存快照看到,JavaScript的每个对象都包含了一个隐藏属性__proto__ ,我们就把该隐藏属性__proto__称之为该**对象的原型(prototype)**,__proto__指向了内存中的另外一个对象,我们就把__proto__指向的对象称为该对象的**原型对象**,那么该对象就可以直接访问其原型对象的方法或者属性。
|
||||
|
||||
比如我让C对象的原型指向B对象,那么便可以利用C对象来直接访问B对象中的属性或者方法了,最终的效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/ac/6e0edf92883d97be06a94dc5431967ac.jpg" alt="">
|
||||
|
||||
观察上图,当C对象将它的__proto__属性指向了B对象后,那么通过对象C来访问对象B中的name属性时,V8会先从对象C中查找,但是并没有查找到,接下来V8继续在其原型对象B中查找,因为对象B中包含了name属性,那么V8就直接返回对象B中的name属性值,虽然C和B是两个不同的对象,但是使用的时候,B的属性看上去就像是C的属性一样。
|
||||
|
||||
同样的方式,B也是一个对象,它也有自己的__proto__属性,比如它的属性指向了内存中另外一块对象A,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/88/63bd704eb45646e2f46af426196a3d88.jpg" alt="">
|
||||
|
||||
从图中可以看到,对象A有个属性是color,那么通过C.color访问color属性时,V8会先在C对象内部查找,但是没有查找到,接着继续在C对象的原型对象B中查找,但是依然没有查找到,那么继续去对象B的原型对象A中查找,因为color在对象A中,那么V8就返回该属性值。
|
||||
|
||||
我们看到使用C.name和C.color时,给人的感觉属性name和color都是对象C本身的属性,但实际上这些属性都是位于原型对象上,我们把这个查找属性的路径称为**原型链,**它像一个链条一样,将几个原型链接了起来。
|
||||
|
||||
在这里还要注意一点,不要将原型链接和作用域链搞混淆了,作用域链是沿着函数的作用域一级一级来查找变量的,而原型链是沿着对象的原型一级一级来查找属性的,虽然它们的实现方式是类似的,但是它们的用途是不同的,关于作用域链,我会在《06 | 作用域链:V8是如何查找变量的?》这节课来介绍。
|
||||
|
||||
关于继承,还有一种情况,如果我有另外一个对象D,它可以和C共同拥有同一个原型对象B,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/4a/44a91019e752ae2e7d6b709562d2554a.jpg" alt="">
|
||||
|
||||
因为对象C和对象D的原型都指向了对象B,所以它们共同拥有同一个原型对象,当我通过D去访问name属性或者color属性时,返回的值和使用对象C访问name属性和color属性是一样的,因为它们是同一个数据。
|
||||
|
||||
我们再来回顾下继承的概念:**继承就是一个对象可以访问另外一个对象中的属性和方法,<strong>在**JavaScript中,我们通过原型和原型链的方式来实现了继承特性。</strong>
|
||||
|
||||
通过上面的分析,你可以看到在JavaScript中的继承非常简洁,就是每个对象都有一个原型属性,该属性指向了原型对象,查找属性的时候,JavaScript虚拟机会沿着原型一层一层向上查找,直至找到正确的属性。所以对于JavaScript中的原型继承,你不需要把它想得过度复杂。
|
||||
|
||||
## 实践:利用__proto__实现继承
|
||||
|
||||
了解了JavaScript中的原型和原型链继承之后,下面我们就可以通过一个例子,看看原型是怎么应用在JavaScript中的,你可以先看下面这段代码:
|
||||
|
||||
```
|
||||
var animal = {
|
||||
type: "Default",
|
||||
color: "Default",
|
||||
getInfo: function () {
|
||||
return `Type is: ${this.type},color is ${this.color}.`
|
||||
}
|
||||
}
|
||||
var dog = {
|
||||
type: "Dog",
|
||||
color: "Black",
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我创建了两个对象animal和dog,我想让dog对象继承于animal对象,那么最直接的方式就是将dog的原型指向对象animal,应该怎么操作呢?
|
||||
|
||||
我们可以通过设置dog对象中的__proto__属性,将其指向animal,代码是这样的:
|
||||
|
||||
```
|
||||
dog.__proto__ = animal
|
||||
|
||||
```
|
||||
|
||||
设置之后,我们就可以使用dog来调用animal中的getInfo方法了。
|
||||
|
||||
```
|
||||
dog.getInfo()
|
||||
|
||||
```
|
||||
|
||||
你可以尝试调用下,看看输出的内容。在这里留给你一个关于“this”的小思考题:调用dog.getInfo()时,getInfo函数中的this.type和this.color都是什么值?为什么?
|
||||
|
||||
还有一点我们要注意,通常隐藏属性是不能使用JavaScript来直接与之交互的。虽然现代浏览器都开了一个口子,让JavaScript可以访问隐藏属性 _**proto**_,但是在实际项目中,我们不应该直接通过_**proto**_ 来访问或者修改该属性,其主要原因有两个:
|
||||
|
||||
- 首先,这是隐藏属性,并不是标准定义的;
|
||||
- 其次,使用该属性会造成严重的性能问题。
|
||||
|
||||
我们之所以在课程中使用 _**proto**_ 属性,主要是为了方便教学,将其他的一些复杂的概念先抛到一边,这样有利于你循序渐进地掌握我们的课程内容,但是我并不推荐你这么做。那应该怎么去正确地设置对象的原型对象呢?
|
||||
|
||||
答案是使用构造函数来创建对象,下面我们就来详细解释这个过程。
|
||||
|
||||
## 构造函数是怎么创建对象的?
|
||||
|
||||
比如我们要创建一个dog对象,我可以先创建一个DogFactory的函数,属性通过参数进行传递,在函数体内,通过this设置属性值。代码如下所示:
|
||||
|
||||
```
|
||||
function DogFactory(type,color){
|
||||
this.type = type
|
||||
this.color = color
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后再结合关键字“new”就可以创建对象了,创建对象的代码如下所示:
|
||||
|
||||
```
|
||||
var dog = new DogFactory('Dog','Black')
|
||||
|
||||
```
|
||||
|
||||
通过这种方式,我们就把后面的函数称为构造函数,因为通过执行new配合一个函数,JavaScript虚拟机便会返回一个对象。如果你没有详细研究过这个问题,很可能对这种操作感到迷惑,为什么通过new关键字配合一个函数,就会返回一个对象呢?
|
||||
|
||||
关于JavaScript为什么要采用这种怪异的写法,我们文章最后再来介绍,先来看看这段代码的深层含义。
|
||||
|
||||
其实当V8执行上面这段代码时,V8会在背后悄悄地做了以下几件事情,模拟代码如下所示:
|
||||
|
||||
```
|
||||
var dog = {}
|
||||
dog.__proto__ = DogFactory.prototype
|
||||
DogFactory.call(dog,'Dog','Black')
|
||||
|
||||
```
|
||||
|
||||
为了加深你的理解,我画了上面这段代码的执行流程图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/8c/19c63a16ec6b6bb67f0a7e74b284398c.jpg" alt="">
|
||||
|
||||
观察上图,我们可以看到执行流程分为三步:
|
||||
|
||||
- 首先,创建了一个空白对象dog;
|
||||
- 然后,将DogFactory的prototype属性设置为dog的原型对象,这就是给dog对象设置原型对象的关键一步,我们后面来介绍;
|
||||
- 最后,再使用dog来调用DogFactory,这时候DogFactory函数中的this就指向了对象dog,然后在DogFactory函数中,利用this对对象dog执行属性填充操作,最终就创建了对象dog。
|
||||
|
||||
## 构造函数怎么实现继承?
|
||||
|
||||
好了,现在我们可以通过构造函数来创建对象了,接下来我们就看看构造函数是如何实现继承的?你可以先看下面这段代码:
|
||||
|
||||
```
|
||||
function DogFactory(type,color){
|
||||
this.type = type
|
||||
this.color = color
|
||||
//Mammalia
|
||||
//恒温
|
||||
this.constant_temperature = 1
|
||||
}
|
||||
var dog1 = new DogFactory('Dog','Black')
|
||||
var dog2 = new DogFactory('Dog','Black')
|
||||
var dog3 = new DogFactory('Dog','Black')
|
||||
|
||||
```
|
||||
|
||||
我利用上面这段代码创建了三个dog对象,每个对象都占用了一块空间,占用空间示意图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/2b/9aff57c8992de8b11b70439797a3862b.jpg" alt="">
|
||||
|
||||
从图中可以看出来,对象dog1到dog3中的constant_temperature属性都占用了一块空间,但是这是一个通用的属性,表示所有的dog对象都是恒温动物,所以没有必要在每个对象中都为该属性分配一块空间,我们可以将该属性设置公用的。
|
||||
|
||||
怎么设置呢?
|
||||
|
||||
还记得我们介绍函数时提到关于函数有两个隐藏属性吗?这两个隐藏属性就是name和code,其实函数还有另外一个隐藏属性,那就是prototype,刚才介绍构造函数时我们也提到过。一个函数有以下几个隐藏属性:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/e7/ec19366c204bcc0b30b9b46448cbbee7.jpg" alt=""><br>
|
||||
每个函数对象中都有一个公开的prototype属性,当你将这个函数作为构造函数来创建一个新的对象时,新创建对象的原型对象就指向了该函数的prototype属性。当然了,如果你只是正常调用该函数,那么prototype属性将不起作用。
|
||||
|
||||
现在我们知道了新对象的原型对象指向了构造函数的prototype属性,当你通过一个构造函数创建多个对象的时候,这几个对象的原型都指向了该函数的prototype属性,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/4d/1d5e7c1f7006974aec657e8a3e9e864d.jpg" alt="">
|
||||
|
||||
这时候我们可以将constant_temperature属性添加到DogFactory的prototype属性上,代码如下所示:
|
||||
|
||||
```
|
||||
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')
|
||||
|
||||
```
|
||||
|
||||
这样我们三个dog对象的原型对象都指向了prototype,而prototype又包含了constant_temperature属性,这就是我们实现继承的正确方式。
|
||||
|
||||
## 一段关于new的历史
|
||||
|
||||
现在我们知道new关键字结合构造函数,就能生成一个对象,不过这种方式很怪异,为什么要这样呢?要了解这背后的原因,我们需要了解一段关于关于JavaScript的历史。
|
||||
|
||||
JavaScript是Brendan Eich发明的,那是个“战乱”的时代,各种大公司相互争霸,有Sun、微软、网景、甲骨文等公司,它们都有推出自己的语言,其中最炙手可热的编程语言是Sun的Java,而JavaScript就是这个时候诞生的。当时创造JavaScript的目的仅仅是为了让浏览器页面可以动起来,所以尽可能采用简化的方式来设计JavaScript,所以本质上来说,Java和JavaScript的关系就像雷锋和雷峰塔的关系。
|
||||
|
||||
那么之所以叫JavaScript是出于市场原因考量的,因为一门新的语言需要吸引新的开发者,而当时最大的开发者群体就是Java,于是JavaScript就蹭了Java的热度,事后,这一招被证明的确有效果。
|
||||
|
||||
虽然叫JavaScript,但是其编程方式和Java比起来,依然存在着非常大的差异,其中Java中使用最频繁的代码就是创建一个对象,如下所示:
|
||||
|
||||
```
|
||||
CreateInstance instance = new CreateInstance();
|
||||
|
||||
```
|
||||
|
||||
当时JavaScript并没有使用这种方式来创建对象,因为JavaScript中的对象和Java中的对象是完全不一样的,因此,完全没有必要使用关键字new来创建一个新对象的,但是为了进一步吸引Java程序员,依然需要在语法层面去蹭Java热点,所以JavaScript中就被硬生生地强制加入了非常不协调的关键字new,然后使用new来创造对象就变成这样了:
|
||||
|
||||
```
|
||||
var bar = new Foo()
|
||||
|
||||
```
|
||||
|
||||
Java程序员看到这段代码时,当然会感到倍感亲切,觉得Java和JavaScript非常相似,那么使用JavaScript也就天经地义了。不过代码形式只是表象,其背后原理是完全不同的。
|
||||
|
||||
了解了这段历史之后,我们知道JavaScript的new关键字设计并不合理,但是站在市场角度来说,它的出现又是非常成功的,成功地推广了JavaScript。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的主要内容就介绍到这里,下面我们来回顾下。
|
||||
|
||||
今天我们的主要目的是介绍清楚JavaScript中的继承机制,这涉及到了原型继承机制,虽然基于原型的继承机制本身比较简单,但是在JavaScript中,这是通过关键字new加上构造函数来体现的。这种方式非常绕,且不符合人的直觉,如果直接上来就介绍new加构造函数是怎么工作的,可能会把你给绕晕了。
|
||||
|
||||
于是我先通过每个对象中都有的隐含属性__proto__,来介绍了什么是原型和原型链。V8为每个对象都设置了一个__proto__属性,该属性直接指向了该对象的原型对象,原型对象也有自己的__proto__属性,这些属性串连在一起就成了原型链。
|
||||
|
||||
不过在JavaScript中,并不建议直接使用__proto__属性,主要有两个原因。
|
||||
|
||||
- 一,这是隐藏属性,并不是标准定义的;
|
||||
- 二,使用该属性会造成严重的性能问题。
|
||||
|
||||
所以,在JavaScript中,是使用new加上构造函数的这种组合来创建对象和实现对象的继承。不过使用这种方式隐含的语义过于隐晦,所以理解起来有点难度。
|
||||
|
||||
为什么JavaScript中要使用这种怪异的方式来创建对象?为了理解这个问题,我们回顾了一段JavaScript的历史。由于当前的Java非常流行,基于市场推广的考虑,JavaScript采取了蹭Java热度的策略,在语言命名上使用了Java字样,在语法形式上也模仿了Java。事实上通过这些策略,确实为JavaScript带来了市场上的成功。不过你依然要记住,JavaScript和Java是完全两种不同的语言。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们知道函数也是一个对象,所以函数也有自己的__proto__属性,那么今天留给你的思考题是:DogFactory是一个函数,那么“DogFactory.prototype”和“DogFactory._**proto**_”这两个属性之间有关联吗?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
209
极客时间专栏/geek/图解 Google V8/JavaScript设计思想篇/06|作用域链:V8是如何查找变量的?.md
Normal file
209
极客时间专栏/geek/图解 Google V8/JavaScript设计思想篇/06|作用域链:V8是如何查找变量的?.md
Normal file
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="06|作用域链:V8是如何查找变量的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/49/24/4928edb48a30bde6ab96cab327763824.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在前面我们介绍了JavaScript的继承是基于原型链的,原型链将一个个原型对象串起来,从而实现对象属性的查找,今天我们要聊一个和原型链类似的话题,那就是作用域链。
|
||||
|
||||
作用域链就是将一个个作用域串起来,实现变量查找的路径。讨论作用域链,实际就是在讨论按照什么路径查找变量的问题。
|
||||
|
||||
我们知道,作用域就是存放变量和函数的地方,全局环境有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。
|
||||
|
||||
当在函数内部使用一个变量的时候,V8便会去作用域中去查找。我们通过一段在函数内部查找变量的代码来具体看一下:
|
||||
|
||||
```
|
||||
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()
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我们在全局环境中声明了变量name和type,同时还定义了bar函数和foo函数,在bar函数中又再次定义了变量name和type,在foo函数中再次定义了变量name。
|
||||
|
||||
函数的调用关系是:在全局环境中调用bar函数,在bar函数中调用foo函数,在foo函数中打印出来变量name和type的值。
|
||||
|
||||
当执行到foo函数时,首先需要打印出变量name的值,而我们在三个地方都定义了变量name,那么究竟应该使用哪个变量呢?
|
||||
|
||||
在foo函数中使用了变量name,那么V8就应该先使用foo函数内部定义的变量name,最终的结果确实如此,也符合我们的直觉。
|
||||
|
||||
接下来,foo函数继续打印变量type,但是在foo函数内部并没有定义变量type,而是在全局环境中和调用foo函数的bar函数中分别定义了变量type,那么这时候的问题来了,你觉得foo函数中打印出来的变量type是bar函数中的,还是全局环境中的呢?
|
||||
|
||||
## 什么是函数作用域和全局作用域?
|
||||
|
||||
要解释清楚这个问题,我们需要从作用域的工作原理讲起。
|
||||
|
||||
每个函数在执行时都需要查找自己的作用域,我们称为函数作用域,在执行阶段,在执行一个函数时,当该函数需要使用某个变量或者调用了某个函数时,便会优先在该函数作用域中查找相关内容。
|
||||
|
||||
我们再来看一段代码:
|
||||
|
||||
```
|
||||
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函数,那么在V8执行test_scope函数的时候,在编译阶段会为test_scope函数创建一个作用域,在test_scope函数中定义的变量和声明的函数都会丢到该作用域中,因为我们在test_scope函数中定了三个变量,那么常见的作用域就包含有这三个变量。
|
||||
|
||||
你可以通过Chrome的控制台来直观感受下test_scope函数的作用域,先打开包含这段代码的页面,然后打开开发者工具,接着在test_scope函数中的第二段代码加上断点,然后刷新该页面。当执行到该断点时,V8会暂停整个执行流程,这时候我们就可以通过右边的区域面板来查看当前函数的执行状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/5a/e346a7664d91a80e5d694d20ea21275a.png" alt="" title="观察作用域">
|
||||
|
||||
你可以参考图中右侧的Scope项,然后点击展开该项,这个Local就是当前函数test_scope的作用域。在test_scope函数中定义的变量都包含到了Local中,如变量name、type,另外系统还为我们添加了另外一个隐藏变量this,V8还会默认将隐藏变量this存放到作用域中。
|
||||
|
||||
另外你还需要注意下,第一个test1,我并没有采用var等关键字来声明,所以test1并不会出现在test_scope函数的作用域中,而是属于this所指向的对象。(this的工作机制不是本文讨论的重点,不展开介绍。如果你感兴趣,可以在《浏览器工作原理与实践》专栏中《[11 | this:从JavaScript执行上下文的视角讲清楚this](https://time.geekbang.org/column/article/128427)》这一讲查看。)
|
||||
|
||||
那么另一个问题来了,我在test_scope函数使用了变量x,但是在test_scope函数的作用域中,并没有定义变量x,那么V8应该如何获取变量x?
|
||||
|
||||
如果在当前函数作用域中没有查找到变量,那么V8会去全局作用域中去查找,这个查找的线路就称为作用域链。
|
||||
|
||||
全局作用域和函数作用域类似,也是存放变量和函数的地方,但是它们还是有点不一样: **全局作用域是在V8启动过程中就创建了,且一直保存在内存中不会被销毁的,直至V8退出。** **而函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之被销毁掉了**。
|
||||
|
||||
全局作用域中包含了很多全局变量,比如全局的this值,如果是浏览器,全局作用域中还有window、document、opener等非常多的方法和对象,如果是node环境,那么会有Global、File等内容。
|
||||
|
||||
V8启动之后就进入正常的消息循环状态,这时候就可以执行代码了,比如执行到上面那段脚本时,V8会先解析顶层(Top Level)代码,我们可以看到,在顶层代码中定义了变量x,这时候V8就会将变量x添加到全局作用域中。
|
||||
|
||||
## 作用域链是怎么工作的?
|
||||
|
||||
理解了作用域和作用域链,我们再回过头来看文章开头的那道思考题: “foo函数中打印出来的变量type是bar函数中的呢,还是全局环境中的呢?”我把这段代码复制到下面:
|
||||
|
||||
```
|
||||
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()
|
||||
|
||||
```
|
||||
|
||||
现在,我们结合V8执行这段代码的流程来具体分析下。首先当V8启动时,会创建全局作用域,全局作用域中包括了this、window等变量,还有一些全局的Web API接口,创建的作用域如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/77/589622b2f517ce06487d3edbe28cf277.jpg" alt="" title="全局作用域">
|
||||
|
||||
V8启动之后,消息循环系统便开始工作了,这时候,我输入了这段代码,让其执行。
|
||||
|
||||
V8会先编译顶层代码,在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中,最终的全局作用域如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/e8/532151cb2713a229550af215962deee8.jpg" alt="" title="全局作用域">
|
||||
|
||||
全局作用域创建完成之后,V8便进入了执行状态。前面我们介绍了变量提升,因为变量提升的原因,你可以把上面这段代码分解为如下两个部分:
|
||||
|
||||
```
|
||||
//======解析阶段--实现变量提升=======
|
||||
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 = '极客时间'
|
||||
type = 'global'
|
||||
bar()
|
||||
|
||||
```
|
||||
|
||||
第一部分是在编译过程中完成的,此时全局作用中两个变量的值依然是undefined,然后进入执行阶段;第二部代码就是执行时的顺序,首先全局作用域中的两个变量赋值“极客时间”和“global”,然后就开始执行函数bar的调用了。
|
||||
|
||||
当V8执行bar函数的时候,同样需要经历两个阶段:编译和执行。在编译阶段,V8会为bar函数创建函数作用域,最终效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/ce/e2958bebf2ef52023c5e514259ae2cce.jpg" alt="" title="bar函数作用域">
|
||||
|
||||
然后进入了bar函数的执行阶段。在bar函数中,只是简单地调用foo函数,因此V8又开始执行foo函数了。
|
||||
|
||||
同样,在编译foo函数的过程中,会创建foo函数的作用域,最终创建效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/d5/9dc20e0f38d04ae96296787c7190cad5.jpg" alt="" title="foo函数作用域">
|
||||
|
||||
好了,这时候我们就有了三个作用域了,分别是全局作用域、bar的函数作用域、foo的函数作用域。
|
||||
|
||||
现在我们就可以将刚才提到的问题转换为作用域链的问题了:foo函数查找变量的路径到底是什么?
|
||||
|
||||
- 沿着foo函数作用域–>bar函数作用域–>全局作用域;
|
||||
- 还是,沿着foo函数作用域—>全局作用域?
|
||||
|
||||
因为JavaScript是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。bar和foo函数的外部代码都是全局代码,所以无论你是在bar函数中查找变量,还是在foo函数中查找变量,其查找顺序都是按照当前函数作用域–>全局作用域这个路径来的。
|
||||
|
||||
由于我们代码中的foo函数和bar函数都是在全局下面定义的,所以在foo函数中使用了type,最终打印出来的值就是全局作用域中的type。
|
||||
|
||||
你可以参考下面这张图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/8c/82c84c81f8c94915d4965ce38d285e8c.jpg" alt="">
|
||||
|
||||
另外,我再展开说一些。因为词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以我们也将词法作用域称为静态作用域。
|
||||
|
||||
和静态作用域相对的是动态作用域,动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从**何处调用**。换句话说,作用域链是基于调用栈的,而不是基于函数定义的位置的。(动态作用域不是本文讨论的重点,如果你感兴趣,可以参考《浏览器工作原理与实践》专栏中的《[10 | 作用域链和闭包 :代码中出现相同的变量,JavaScript引擎是如何选择的?](https://time.geekbang.org/column/article/127495)》这一节。)
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我们主要解释了一个问题,那就是在一个函数中,如果使用了一个变量,或者调用了另外一个函数,V8将会怎么去查找该变量或者函数。
|
||||
|
||||
为了解释清楚这个问题,我们引入了作用域的概念。作用域就是用来存放变量和函数的地方,全局作用域中存放了全局环境中声明的变量和函数,函数作用域中存放了函数中声明的变量和函数。当在某个函数中使用某个变量时,V8就会去这些作用域中查找相关变量。沿着这些作用域查找的路径,我们就称为作用域链。
|
||||
|
||||
要了解查找路径,我们需要明白词法作用域,词法作用域是按照代码定义时的位置决定的,而JavaScript所采用的作用域机制就是词法作用域,所以作用域链的路径就是按照词法作用域来实现的。
|
||||
|
||||
## 思考题
|
||||
|
||||
我将文章开头那段代码稍微调整了下,foo函数并不是在全局环境中声明的,而是在bar函数中声明的,改造后的代码如下所示:
|
||||
|
||||
```
|
||||
var name = '极客时间'
|
||||
var type = 'global'
|
||||
function bar() {
|
||||
var type = 'function'
|
||||
function foo() {
|
||||
console.log(type)
|
||||
}
|
||||
foo()
|
||||
}
|
||||
bar()
|
||||
|
||||
```
|
||||
|
||||
那么执行这段代码之后,打印出来的内容是什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,225 @@
|
||||
<audio id="audio" title="07|类型转换:V8是怎么实现1+“2”的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/5c/90577bbf77f238c7a7ac2a56bb048a5c.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
前面我们花了很多篇幅聊了JavaScript中最基础却很容易被忽略的“对象”,以及V8是怎么处理“对象”的,今天我们继续来聊另一个非常基础,同时也是很容易被大家忽略的问题,那就是JavaScript中的“类型系统”。
|
||||
|
||||
在理解这个概念之前,你可以先思考一个简单的表达式,那就是在JavaScript中,“1+‘2’等于多少?”
|
||||
|
||||
其实这相当于是在问,在JavaScript中,让数字和字符串相加是会报错,还是可以正确执行。如果能正确执行,那么结果是等于数字3,还是字符串“3”,还是字符串“12”呢?
|
||||
|
||||
如果你尝试在Python中使用数字和字符串进行相加操作,那么Python虚拟机会直接返回一个执行错误,错误提示是这样的:
|
||||
|
||||
```
|
||||
>>> 1+'2'
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
TypeError: unsupported operand type(s) for +: 'int' and 'str'
|
||||
|
||||
```
|
||||
|
||||
这段错误代码提示了这是个类型错误,表明Python并不支持数字类型和字符串类型相加的操作。
|
||||
|
||||
不过在JavaScript中执行这段表达式,是可以返回一个结果的,最终返回的结果是字符串“12”。
|
||||
|
||||
最终结果如下所示:
|
||||
|
||||
```
|
||||
>>> 1+'2'
|
||||
>>> "12"
|
||||
|
||||
```
|
||||
|
||||
为什么同样一条的表达式,在Python和JavaScript中执行会有不同的结果?为什么在JavaScript中执行,输出的是字符串“12”,不是数字3或者字符串“3”呢?
|
||||
|
||||
## 什么是类型系统(Type System)?
|
||||
|
||||
在这个简单的表达式中,涉及到了两种不同类型的数据的相加。要想理清以上两个问题,我们就需要知道类型的概念,以及JavaScript操作类型的策略。
|
||||
|
||||
对机器语言来说,所有的数据都是一堆二进制代码,CPU处理这些数据的时候,并没有类型的概念,CPU所做的仅仅是移动数据,比如对其进行移位,相加或相乘。
|
||||
|
||||
而在高级语言中,我们都会为操作的数据赋予指定的类型,类型可以确认一个值或者一组值具有特定的意义和目的。所以,类型是高级语言中的概念。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/3f/e0dfa246212ec1070ac8aac6bc0f1a3f.jpg" alt="">
|
||||
|
||||
比如在C/C++中,你需要为要处理的每条数据指定类型,这样定义变量:
|
||||
|
||||
```
|
||||
int counter = 100 # 赋值整型变量
|
||||
float miles = 1000.0 # 浮点型
|
||||
char* name = "John" # 字符串
|
||||
|
||||
```
|
||||
|
||||
C/C++编译器负责将这些数据片段转换为供CPU处理的正确命令,通常是二进制的机器代码。
|
||||
|
||||
在某些更高级的语言中,还可以根据数据推断出类型,比如在Python或JavaScript中,你就不必为数据指定专门的数据类型,在Python中,你可以这样定义变量:
|
||||
|
||||
```
|
||||
counter = 100 # 赋值整型变量
|
||||
miles = 1000.0 # 浮点型
|
||||
name = "John" # 字符串
|
||||
|
||||
```
|
||||
|
||||
在JavaScript中,你可以这样定义变量:
|
||||
|
||||
```
|
||||
var counter = 100 # 赋值整型变量
|
||||
let miles = 1000.0 # 浮点型
|
||||
const name = "John" # 字符串
|
||||
|
||||
```
|
||||
|
||||
虽然Python和JavaScript定义变量的方式不同,但是它们都不需要直接指定变量的类型,因为虚拟机会根据数据自动推导出类型。
|
||||
|
||||
通用的类型有数字类型、字符串、Boolean类型等等,引入了这些类型之后,编译器或者解释器就可以根据类型来限制一些有害的或者没有意义的操作。
|
||||
|
||||
比如在Python语言中,如果使用字符串和数字相加就会报错,因为Python觉得这是没有意义的。而在JavaScript中,字符串和数字相加是有意义的,可以使用字符串和数字进行相加的。再比如,你让一个字符串和一个字符串相乘,这个操作是没有意义的,所有语言几乎都会禁止该操作。
|
||||
|
||||
每种语言都定义了自己的类型,还定义了如何操作这些类型,另外还定义了这些类型应该如何相互作用,我们就把这称为**类型系统**。
|
||||
|
||||
关于类型系统,[wiki百科](https://zh.wikipedia.org/zh-cn/%E9%A1%9E%E5%9E%8B%E7%B3%BB%E7%B5%B1)上是这样解释的:
|
||||
|
||||
>
|
||||
在计算机科学中,类型系统(type system)用于定义如何将编程语言中的数值和表达式归类为许多不同的类型,如何操作这些类型,这些类型如何互相作用。
|
||||
|
||||
|
||||
直观地理解,一门语言的类型系统定义了各种类型之间应该如何相互操作,比如,两种不同类型相加应该如何处理,两种相同的类型相加又应该如何处理等。还规定了各种不同类型应该如何相互转换,比如字符串类型如何转换为数字类型。
|
||||
|
||||
一个语言的类型系统越强大,那编译器能帮程序员检查的东西就越多,程序员定义“检查规则”的方式就越灵活。
|
||||
|
||||
## V8是怎么执行加法操作的?
|
||||
|
||||
了解了JavaScript中的类型系统,接下来我们就可以来看看V8是怎么处理1+“2”的了。
|
||||
|
||||
当有两个值相加的时候,比如:
|
||||
|
||||
```
|
||||
a+b
|
||||
|
||||
```
|
||||
|
||||
V8会严格根据ECMAScript规范来执行操作。ECMAScript是一个语言标准,JavaScript就是ECMAScript的一个实现,比如在ECMAScript就定义了怎么执行加法操作,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/98/2d483835d08d2a9d5143d26e09ad4a98.png" alt="" title="ECMAScript定义加法语义">
|
||||
|
||||
具体细节你也可以[参考规范](https://www.ecma-international.org/ecma-262/6.0/index.html#sec-addition-operator-plus-runtime-semantics-evaluation),我将标准定义的内容翻译如下:
|
||||
|
||||
>
|
||||
AdditiveExpression : AdditiveExpression + MultiplicativeExpression
|
||||
|
||||
|
||||
1. 把第一个表达式(AdditiveExpression)的值赋值给左引用(lref)。
|
||||
1. 使用GetValue(lref)获取左引用(lref)的计算结果,并赋值给左值。
|
||||
1. 使用[ReturnIfAbrupt](https://www.ecma-international.org/ecma-262/6.0/index.html#sec-returnifabrupt)(lval)如果报错就返回错误。
|
||||
1. 把第二个表达式(MultiplicativeExpression)的值赋值给右引用(rref)。
|
||||
1. 使用GetValue(rref)获取右引用(rref)的计算结果,并赋值给rval。
|
||||
1. 使用[ReturnIfAbrupt](https://www.ecma-international.org/ecma-262/6.0/index.html#sec-returnifabrupt)(rval)如果报错就返回错误。
|
||||
1. 使用ToPrimitive(lval)获取左值(lval)的计算结果,并将其赋值给左原生值(lprim)。
|
||||
1. 使用ToPrimitive(rval)获取右值(rval)的计算结果,并将其赋值给右原生值(rprim)。
|
||||
<li>如果Type(lprim)和Type(rprim)中有一个是String,则:<br>
|
||||
a. 把ToString(lprim)的结果赋给左字符串(lstr);<br>
|
||||
b. 把ToString(rprim)的结果赋给右字符串(rstr);<br>
|
||||
c. 返回左字符串(lstr)和右字符串(rstr)拼接的字符串。</li>
|
||||
1. 把ToNumber(lprim)的结果赋给左数字(lnum)。
|
||||
1. 把ToNumber(rprim)的结果赋给右数字(rnum)。
|
||||
1. 返回左数字(lnum)和右数字(rnum)相加的数值。
|
||||
|
||||
通俗地理解,V8会提供了一个ToPrimitive方法,其作用是将a和b转换为原生数据类型,其转换流程如下:
|
||||
|
||||
- 先检测该对象中是否存在valueOf方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换;
|
||||
- 如果valueOf没有返回原始类型,那么就使用toString方法的返回值;
|
||||
- 如果vauleOf和toString两个方法都不返回基本类型值,便会触发一个TypeError的错误。
|
||||
|
||||
将对象转换为原生类型的流程图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/aa/d150309b74f2c06e66011cf3e177dbaa.jpg" alt="">
|
||||
|
||||
当V8执行1+“2”时,因为这是两个原始值相加,原始值相加的时候,如果其中一项是字符串,那么V8会默认将另外一个值也转换为字符串,相当于执行了下面的操作:
|
||||
|
||||
```
|
||||
Number(1).toString() + "2"
|
||||
|
||||
```
|
||||
|
||||
这里,把数字1偷偷转换为字符串“1”的过程也称为强制类型转换,因为这种转换是隐式的,所以如果我们不熟悉语义,那么就很容易判断错误。
|
||||
|
||||
我们还可以再看一个例子来验证上面流程,你可以看下面的代码:
|
||||
|
||||
```
|
||||
var Obj = {
|
||||
toString() {
|
||||
return '200'
|
||||
},
|
||||
valueOf() {
|
||||
return 100
|
||||
}
|
||||
}
|
||||
Obj+3
|
||||
|
||||
```
|
||||
|
||||
执行这段代码,你觉得应该返回什么内容呢?
|
||||
|
||||
上面我们介绍过了,由于需要先使用ToPrimitive方法将Obj转换为原生类型,而ToPrimitive会优先调用对象中的valueOf方法,由于valueOf返回了100,那么Obj就会被转换为数字100,那么数字100加数字3,那么结果当然是103了。
|
||||
|
||||
如果我改造下代码,让valueOf方法和toString方法都返回对象,其改造后的代码如下:
|
||||
|
||||
```
|
||||
var Obj = {
|
||||
toString() {
|
||||
return new Object()
|
||||
},
|
||||
valueOf() {
|
||||
return new Object()
|
||||
}
|
||||
}
|
||||
Obj+3
|
||||
|
||||
```
|
||||
|
||||
再执行这段代码,你觉得应该返回什么内容呢?
|
||||
|
||||
因为ToPrimitive会先调用valueOf方法,发现返回的是一个对象,并不是原生类型,当ToPrimitive继续调用toString方法时,发现toString返回的也是一个对象,都是对象,就无法执行相加运算了,这时候虚拟机就会抛出一个异常,异常如下所示:
|
||||
|
||||
```
|
||||
VM263:9 Uncaught TypeError: Cannot convert object to primitive value
|
||||
at <anonymous>:9:6
|
||||
|
||||
```
|
||||
|
||||
提示的是类型错误,错误原因是无法将对象类型转换为原生类型。
|
||||
|
||||
所以说,在执行加法操作的时候,V8会通过ToPrimitive方法将对象类型转换为原生类型,最后就是两个原生类型相加,如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。在其他情况时,所有的值都会转换为数字类型值,然后做数字的相加。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们主要学习了JavaScript中的类型系统是怎么工作的。类型系统定义了语言应当如何操作类型,以及这些类型如何互相作用。因为Python和JavaScript的类型系统差异,所以当处理同样的表达式时,返回的结果是不同的。
|
||||
|
||||
在Python中,数字和字符串相加会抛出异常,这是因为Python认为字符串和数字相加是无意义的,所以限制其操作。
|
||||
|
||||
在JavaScript中,数字和字符串相加会返回一个新的字符串,这是因为JavaScript认为字符串和数字相加是有意义的,V8会将其中的数字转换为字符,然后执行两个字符串的相加操作,最终得到的是一个新的字符串。
|
||||
|
||||
在JavaScript中,类型系统是依据ECMAScript标准来实现的,所以V8会严格根据ECMAScript标准来执行。在执行加法过程中,V8会先通过ToPrimitive函数,将对象转换为原生的字符串或者是数字类型,在转换过程中,ToPrimitive会先调用对象的valueOf方法,如果没有valueOf方法,则调用toString方法,如果vauleOf和toString两个方法都不返回基本类型值,便会触发一个TypeError的错误。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们一起来分析一段代码:
|
||||
|
||||
```
|
||||
var Obj = {
|
||||
toString() {
|
||||
return "200"
|
||||
},
|
||||
valueOf() {
|
||||
return 100
|
||||
}
|
||||
}
|
||||
Obj+"3"
|
||||
|
||||
```
|
||||
|
||||
你觉得执行这段代码会打印出什么内容呢?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,397 @@
|
||||
<audio id="audio" title="08|答疑:如何构建和使用V8的调试工具d8?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/22/b37372984fef17e8f13b8313b43eff22.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
今天是我们第一单元的答疑环节,课后有很多同学留言问我关于d8的问题,所以今天我们就来专门讲讲,如何构建和使用V8的调试工具d8。
|
||||
|
||||
d8是一个非常有用的调试工具,你可以把它看成是debug for V8的缩写。我们可以使用d8来查看V8在执行JavaScript过程中的各种中间数据,比如作用域、AST、字节码、优化的二进制代码、垃圾回收的状态,还可以使用d8提供的私有API查看一些内部信息。
|
||||
|
||||
## 如何通过V8的源码构建D8?
|
||||
|
||||
通常,我们没有直接获取d8的途径,而是需要通过编译V8的源码来生成d8,接下来,我们就先来看看如何构建d8。
|
||||
|
||||
其实并不难,总的来说,大体分为三部分。首先我们需要先下载V8的源码,然后再生成工程文件,最后编译V8的工程并生成d8。
|
||||
|
||||
接下来我们就来具体操作一下。考虑到使用Windows系统的同学比较多,所以下面的操作,我们的默认环境是Windows系统,Mac OS和Liunx的配置会简单一些。
|
||||
|
||||
### 安装VPN
|
||||
|
||||
V8并不是一个单一的版本库,它还引用了很多第三方的版本库,大多是版本库我们都无法直接访问,所以,在下载代码过程中,你得先准备一个VPN。
|
||||
|
||||
### 下载编译工具链:depot_tools
|
||||
|
||||
有了VPN,接下来我们需要下载编译工具链:**depot_tools**,后续V8源码的下载、配置和编译都是由depot_tools来完成的,你可以直接点击下载:[depot_tools bundle](https://storage.googleapis.com/chrome-infra/depot_tools.zip)。
|
||||
|
||||
depot_tools压缩包下载到本地之后,解压压缩包,比如你解压到以下这个路径中:
|
||||
|
||||
```
|
||||
C:\src\depot_tools
|
||||
|
||||
```
|
||||
|
||||
然后需要将这个路径添加到环境变量中,这样我们就可以在控制台中使用gclient了。
|
||||
|
||||
### 设置环境变量
|
||||
|
||||
接下来,还需要往系统环境变量中添加变量 DEPOT_TOOLS_WIN_TOOLCHAIN ,值设为 0。
|
||||
|
||||
```
|
||||
DEPOT_TOOLS_WIN_TOOLCHAIN = 0
|
||||
|
||||
```
|
||||
|
||||
这个环境变量的作用是告诉 deppt_tools,使用本地已安装的默认的 Visual Studio 版本去编译,否则 depot_tools 会使用 Google 内部默认的版本。
|
||||
|
||||
然后你可以在命令行中测试下是否可以使用:
|
||||
|
||||
```
|
||||
gclient sync
|
||||
|
||||
```
|
||||
|
||||
### 安装VS2019
|
||||
|
||||
在Windows系统下面,depot_tools使用了VS2019,因为VS2019自带了编译V8的编译器,所以需要安装VS2019时,安装时,你需要选择以下两项内容:
|
||||
|
||||
- Desktop development with C++;
|
||||
- MFC/ATL support。
|
||||
|
||||
因为编译V8时,使用了这两项所提供的基础开发环境。
|
||||
|
||||
### 下载V8源码
|
||||
|
||||
安装了VS2019,接下来就可以使用depot_tools来下载V8源码了,具体下载命令如下所示:
|
||||
|
||||
```
|
||||
d:
|
||||
mkdir v8
|
||||
cd v8
|
||||
fetch v8
|
||||
cd v8
|
||||
|
||||
```
|
||||
|
||||
执行这个命令就会开始下载V8源码,这个过程可能比较漫长,下载时间主要取决于你的网速和VPN的速度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/19/f229112fb9b09b1e21311bde117e2d19.png" alt="">
|
||||
|
||||
### 配置工程
|
||||
|
||||
代码下载完成之后,就需要配置工程了,我们使用gn来配置。
|
||||
|
||||
```
|
||||
cd v8
|
||||
|
||||
|
||||
gn gen out.gn/x64.release --args='is_debug=false target_cpu="x64" v8_target_cpu="arm64" use_goma=true'
|
||||
|
||||
```
|
||||
|
||||
如果是Mac系统,你可以使用:
|
||||
|
||||
```
|
||||
gn gen out/gn --ide=xcode
|
||||
|
||||
```
|
||||
|
||||
用它来生成工程。
|
||||
|
||||
gn是一个跨平台的构建系统,用来构建Ninja工程,Ninja是一个跨平台的编译系统,比如可以通过gn构建Chromium还有V8的工程文件,然后使用Ninja来执行编译,可以使用gn和Ninja来配合使用构建跨平台的工程,这些工程可以在MacOS、Linux、Windows等平台上进行编译。在gn之前,Google使用了gyp来构建,由于gn的效率更高,所以现在都在使用gn。
|
||||
|
||||
下面我们来看下生成V8工程的一些基础配置项:
|
||||
|
||||
- is_debug = false 编译成release版本;
|
||||
- is_component_build = true 编译成动态链接库而不是很大的可执行文件;
|
||||
- symbol_level = 0 将所有的debug符号放在一起,可以加速二次编译,并加速链接过程;
|
||||
- ide = vs2019 ide=xcode。
|
||||
|
||||
工程生成好之后,你就可以去 v8\out.gn\x64.release 这个目录下查看生成的工程文件。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/2b/787e04fce091f53725de70e751c26a2b.png" alt="">
|
||||
|
||||
### 编译d8
|
||||
|
||||
生成了d8的工程配置文件,接下来就可以编译d8了,你可以使用下面的命令:
|
||||
|
||||
```
|
||||
ninja -C out.gn/x64.release
|
||||
|
||||
```
|
||||
|
||||
如果想编译特定目标,比如d8,可以使用下面的命令:
|
||||
|
||||
```
|
||||
ninja -C out.gn/x64.release d8
|
||||
|
||||
```
|
||||
|
||||
这个命令只会编译和d8所依赖的工程,然后就开始执行编译流程了。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/a7/6de500efbc98810829b3721e1450cca7.png" alt="">
|
||||
|
||||
编译时间取决于你硬盘读写速度和CPU的个数,比如我的电脑是10核CPU,ssd硬盘,整个编译过程大概花费了15分钟。
|
||||
|
||||
最终编译结束之后,你就可以去 v8\out.gn\x64.release 查看生成的文件,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/65/675b203ea4764f1f46c71efc523be765.png" alt="">
|
||||
|
||||
我们可以看到d8也在其中。
|
||||
|
||||
我将编译出来的d8也放到了网上,如果你不想编译,也可以点击[这里](https://github.com/binaryacademy/geektime-v8/blob/master/out.gn.rar)直接下载使用。
|
||||
|
||||
## 如何使用d8?
|
||||
|
||||
好了,现在我们编译出来了d8 ,接下来我们将d8所在的目录,v8\out.gn\x64.release添加到环境变量“PATH”的路径中,这样我们就可以在控制台中使用d8了。
|
||||
|
||||
我们先来测试下能不能使用d8,你可以使用下面这个命令,在控制台中执行d8:
|
||||
|
||||
```
|
||||
d8 --help
|
||||
|
||||
```
|
||||
|
||||
最终显示出来了一大堆命令,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/db/ee/db12c94880607238b0c7bb2815f5feee.png" alt="">
|
||||
|
||||
我们可以看到上图中打印出来了很多行,每一行其实都对应着一个命令,比如`print-bytecode`就是查看生成的字节码,`print-opt-code`是要查看优化后的代码,`turbofan-stats`是打印出来优化编译器的一些统计数据的命令,每个命令后面都有一个括号,括号里面是介绍这个命令的具体用途。
|
||||
|
||||
使用d8进行调试方式如下:
|
||||
|
||||
```
|
||||
d8 test.js --print-bytecode
|
||||
|
||||
```
|
||||
|
||||
d8后面跟上文件名和要执行的命令,如果执行上面这行命令,就会打印出test.js文件所生成的字节码。
|
||||
|
||||
不过,通过d8 --help打印出来的列表非常长,如果过滤特定的命令,你可以使用下面的命令来查看:
|
||||
|
||||
```
|
||||
d8 --help |grep print
|
||||
|
||||
```
|
||||
|
||||
这样我们就能查看d8有多少关于print的命令,如果你使用了Windows系统,可能缺少grep程序,你可以去[这里](https://sourceforge.net/projects/gnuwin32/files/grep/2.5.4/grep-2.5.4-setup.exe/download?use_mirror=managedway)下载。
|
||||
|
||||
安装完成之后,记得手动将grep 程序所在的目录添加到环境变量PATH中,这样才能在控制台使用grep 命令。
|
||||
|
||||
最终打印出来带有print字样的命令,包含以下内容:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/4d/f3aac9c42b46bffec88f564f461f2a4d.png" alt="">
|
||||
|
||||
d8的命令很多,如果有时间,你可以逐一试下。接下来下面我们挑其中一些重点的命令来介绍下,比如`trace-gc`,`trace-opt-verbose`。这些命令涉及到了编译流水线的中间数据,垃圾回收器执行状态等,熟悉使用这些命令可以帮助我们更加深刻理解编译流水线和垃圾回收器的执行状态。
|
||||
|
||||
在使用d8执行一段代码之前,你需要将你的JavaScript源码保存到一个js文件中,我们把所需要需要观察的代码都存放到test.js这个文件中。
|
||||
|
||||
### 打印优化数据
|
||||
|
||||
你可以使用`--print-ast`来查看中间生成的AST,使用`---print-scope`来查看中间生成的作用域,`--print-bytecode`来查看中间生成的字节码。除了这些数据之外,**我们还可以使用d8来打印一些优化的数据**,比如下面这样一段代码:
|
||||
|
||||
```
|
||||
let a = {x:1}
|
||||
function bar(obj) {
|
||||
return obj.x
|
||||
}
|
||||
|
||||
|
||||
function foo () {
|
||||
let ret = 0
|
||||
for(let i = 1; i < 7049; i++) {
|
||||
ret += bar(a)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
当V8先执行到这段代码的时候,监控到while循环会一直被执行,于是判断这是一块热点代码,于是,V8就会将热点代码编译为优化后的二进制代码,你可以通过下面的命令来查看:
|
||||
|
||||
```
|
||||
d8 --trace-opt-verbose test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段命令之后,提示如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/49/a10dab15be233aab9cc17c328c163f49.png" alt="">
|
||||
|
||||
观察上图,我们可以看到终端中出现了一段优化的提示:
|
||||
|
||||
```
|
||||
<JSFunction foo (sfi = 0x2c730824fe21)> for optimized recompilation, reason: small function]
|
||||
|
||||
```
|
||||
|
||||
这就是告诉我们,已经使用TurboFan优化编译器将函数foo优化成了二进制代码,执行foo时,实际上是执行优化过的二进制代码。
|
||||
|
||||
现在我们把foo函数中的循环加到10万,再来查看优化信息,最终效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/f5/abb9ff93abc9b6e3f848a940d46dd3f5.png" alt="">
|
||||
|
||||
我们看到又出现了一条新的优化信息,新的提示信息如下:
|
||||
|
||||
```
|
||||
<JSFunction foo (sfi = 0xc9c0824fe21)> using TurboFan OSR]
|
||||
|
||||
```
|
||||
|
||||
这段提示是说,由于循环次数过多,V8采取了TurboFan 的OSR优化,OSR全称是**On-Stack Replacement,**它是一种在运行时替换正在运行的函数的栈帧的技术,如果在foo函数中,每次调用bar函数时,都要创建bar函数的栈帧,等bar函数执行结束之后,又要销毁bar函数的栈帧。
|
||||
|
||||
通常情况下,这没有问题,但是在foo函数中,采用了大量的循环来重复调用bar函数,这就意味着V8需要不断为bar函数创建栈帧,销毁栈帧,那么这样势必会影响到foo函数的执行效率。
|
||||
|
||||
于是,V8采用了OSR技术,将bar函数和foo函数合并成一个新的函数,具体你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/50/879e5b6582d03a34d1668e33fd5f9a50.png" alt="">
|
||||
|
||||
如果我在foo函数里面执行了10万次循环,在循环体内调用了10万次bar函数,那么V8会实现两次优化,第一次是将foo函数编译成优化的二进制代码,第二次是将foo函数和bar函数合成为一个新的函数。
|
||||
|
||||
网上有一篇介绍OSR的文章也不错,叫[on-stack replacement in v8](https://wingolog.org/archives/2011/06/20/on-stack-replacement-in-v8),如果你感兴趣可以查看下。
|
||||
|
||||
### 查看垃圾回收
|
||||
|
||||
我们还可以通过d8来查看垃圾回收的状态,你可以参看下面这段代码:
|
||||
|
||||
```
|
||||
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()
|
||||
|
||||
```
|
||||
|
||||
上面这段代码,我们重复将一段字符串转换为数组,并重复在堆中申请内存,将转换后的数组存放在内存中。我们可以通过`trace-gc`来查看这段代码的内存回收状态,执行下面这段命令:
|
||||
|
||||
```
|
||||
d8 --trace-gc test.js
|
||||
|
||||
```
|
||||
|
||||
最终打印出来的结果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/26/ec85beff05ec4d5429aa9f80ecb33b26.png" alt="">
|
||||
|
||||
你会看到一堆提示,如下:
|
||||
|
||||
```
|
||||
Scavenge 1.2 (2.4) -> 0.3 (3.4) MB, 0.9 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
|
||||
|
||||
```
|
||||
|
||||
这句话的意思是提示“Scavenge … 分配失败”,是因为垃圾回收器**Scavenge**所负责的空间已经满了,Scavenge主要回收V8中“**新生代**”中的内存,大多数对象都是分配在新生代内存中,内存分配到新生代中是非常快速的,但是新生代的空间却非常小,通常在1~8 MB之间,一旦空间被填满,Scavenge就会进行“清理”操作。
|
||||
|
||||
上面这段代码之所以能频繁触发新生代的垃圾回收,是因为它频繁地去申请内存,而申请内存之后,这块内存就立马变得无效了,为了减少垃圾回收的频率,我们尽量避免申请不必要的内存,比如我们可以换种方式来实现上述代码,如下所示:
|
||||
|
||||
```
|
||||
function strToArray(str, bufferView) {
|
||||
let i = 0
|
||||
const len = str.length
|
||||
for (; i < len; ++i) {
|
||||
bufferView[i] = str.charCodeAt(i);
|
||||
}
|
||||
return bufferView;
|
||||
}
|
||||
function foo() {
|
||||
let i = 0
|
||||
let str = 'test V8 GC'
|
||||
let buffer = new ArrayBuffer(str.length * 2)
|
||||
let bufferView = new Uint16Array(buffer);
|
||||
while (i++ < 1e5) {
|
||||
strToArray(str,bufferView);
|
||||
}
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
我们将strToArray中分配的内存块,提前到了foo函数中分配,这样我们就不需要每次在strToArray函数分配内存了,再次执行`trace-gc`的命令:
|
||||
|
||||
```
|
||||
d8 --trace-gc test.js
|
||||
|
||||
```
|
||||
|
||||
我们就会看到,这时候没有任何垃圾回收的提示了,这也意味着这时没有任何垃圾分配的操作了。
|
||||
|
||||
### 内部方法
|
||||
|
||||
另外,你还可以使用V8所提供的一些内部方法,只需要在启动V8时传入`allow-natives-syntax`命令,具体使用方式如下所示:
|
||||
|
||||
```
|
||||
d8 --allow-natives-syntax test.js
|
||||
|
||||
```
|
||||
|
||||
还记得我们在《[03|快属性和慢属性:V8采用了哪些策略提升了对象属性的访问速度?](https://time.geekbang.org/column/article/213250)》讲到的快属性和慢属性吗?
|
||||
|
||||
我们可以通过内部方法HasFastProperties来检查一个对象是否拥有快属性,比如下面这段代码:
|
||||
|
||||
```
|
||||
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(10,10)
|
||||
console.log(%HasFastProperties(bar));
|
||||
delete bar.property2
|
||||
console.log(%HasFastProperties(bar));
|
||||
|
||||
```
|
||||
|
||||
我们执行下面这个命令:
|
||||
|
||||
```
|
||||
d8 test.js --allow-natives-syntax
|
||||
|
||||
```
|
||||
|
||||
通过传入`allow-natives-syntax`命令,就能使用`HasFastProperties`这一类内部接口,默认情况下,我们知道V8中的对象都提供了快属性,不过使用了`delete bar.property2`之后,就没有快属性了,我们可以通过`HasFastProperties`来判断。
|
||||
|
||||
所以可以得出,使用delete时候,我们查找属性的速度就会变慢,这也是我们尽量不要使用delete的原因。
|
||||
|
||||
除了`HasFastProperties`方法之外,V8提供的内部方法还有很多,比如你可以使用`GetHeapUsage`来查看堆的使用状态,可以使用`CollectGarbage`来主动触发垃圾回收,诸如`HaveSameMap`、`HasDoubleElements`等,具体命令细节你可以参考[这里](https://github.com/v8/v8/blob/4b9b23521e6fd42373ebbcb20ebe03bf445494f9/src/runtime/runtime.h)。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的内容就介绍到这里,我们来简单总结一下。
|
||||
|
||||
d8是个非常有用的调试工具,能够帮助我们发现我们的代码是否可以被V8高效地执行,比如通过d8查看代码有没有被JIT编译器优化,还可以通过d8内置的一些接口查看更多的代码内部信息,而且通过使用d8,我们会接触各种实际的优化策略,学习这些策略并结合V8的工作原理,可以让我们更加接地气地了解V8的工作机制。
|
||||
|
||||
通过源码来构建d8的流程比较简单,首先下载V8的编译工具链:depot_tools,然后再利用depot_tools下载源码、生成工程、编译工程,这就实现了通过源码编译d8。这个过程不难,但涉及到了许多工具,在配置过程中可能会遇到一些坑,不过按照流程操作应该能顺利编译出来d8。
|
||||
|
||||
接下来我们重点讨论了如何使用d8,我们可以通过传入不同的命令,让d8来分析V8在执行JavaScript过程中的一些中间数据。你应该熟练掌握d8的使用方式,在后续课程中我们应该还会反复引用本节的一些内容。
|
||||
|
||||
## 思考题
|
||||
|
||||
c/c++中有内联(Inline)函数,和我们文中分析的OSR类似,内联函数和V8中所采用的OSR优化手段类似,都是在执行过程中将两个函数合并成一个,这样在执行代码的过程中,就减少了栈帧切换操作,增加了执行效率,那么今天留给你的思考题是,你认为在什么情况下,V8会将多个函数合成一个函数?
|
||||
|
||||
你可以列举一个实际的例子,并使用d8来分析V8是否对你的例子进行了优化操作。欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
170
极客时间专栏/geek/图解 Google V8/V8编译流水线/09 | 运行时环境:运行JavaScript代码的基石.md
Normal file
170
极客时间专栏/geek/图解 Google V8/V8编译流水线/09 | 运行时环境:运行JavaScript代码的基石.md
Normal file
@@ -0,0 +1,170 @@
|
||||
<audio id="audio" title="09 | 运行时环境:运行JavaScript代码的基石" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/29/15eb6d774122be87b49b2f6ab7e1af29.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
通过前面几节课的学习,我们理解了JavaScript是一门基于对象的语言,它能实现非常多的特性,诸如函数是一等公民、闭包、函数式编程、原型继承等,搞懂了这些特性,我们就可以来打开V8这个黑盒,深入了解它的编译流水线了。
|
||||
|
||||
我们知道,当你想执行一段JavaScript代码时,只需要将代码丢给V8虚拟机,V8便会执行并返回给你结果。
|
||||
|
||||
其实在执行JavaScript代码之前,V8就已经准备好了代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。准备好运行时环境之后,V8才可以执行JavaScript代码,这包括解析源码、生成字节码、解释执行或者编译执行这一系列操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/54/a89d747fb614a17e08b1a6b7dce62b54.jpg" alt="">
|
||||
|
||||
对运行时环境有足够的了解,能够帮助我们更好地理解V8的执行流程。比如事件循环系统可以让你清楚各种回调函数是怎么被执行的,栈空间可以让你了解函数是怎么被调用的,堆空间和栈空间让你了解为什么要有传值和传引用,等等。
|
||||
|
||||
运行时环境涉及到的知识都是非常基础,但又非常容易被忽视的。今天这节课,我们就来分析下这些基础的运行时环境。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/49/9ad5d32bce98aad219c9f73513ac6349.jpg" alt="">
|
||||
|
||||
## 什么是宿主环境?
|
||||
|
||||
要聊运行V8的运行时环境,我们不得不聊V8的宿主环境,什么是V8的宿主环境呢?
|
||||
|
||||
在生物学上,宿主是指为病毒等寄生物提供生存环境的生物,宿主有自己的完整的代谢系统,而病毒则没有自己的代谢系统,也没有自己的酶系统,它只是由核酸长链和蛋白质外壳构成。
|
||||
|
||||
因此,病毒想要完成自我复制,则会和宿主共同使用一套代谢系统,当病毒离开了宿主细胞,就成了没有任何生命活动,也不能独立自我繁殖的化学物质。同时,如果病毒利用了太多的宿主细胞资源,也会影响到细胞的正常活动。
|
||||
|
||||
同样,你可以把V8和浏览器的渲染进程的关系看成病毒和细胞的关系,浏览器为V8提供基础的消息循环系统、全局变量、Web API,而V8的核心是实现了ECMAScript标准,这相当于病毒自己的DNA或者RNA,V8只提供了ECMAScript定义的一些对象和一些核心的函数,这包括了Object、Function、String。除此之外,V8还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要宿主环境的配合才能完整执行。
|
||||
|
||||
如果V8使用不当,比如不规范的代码触发了频繁的垃圾回收,或者某个函数执行时间过久,这些都会占用宿主环境的主线程,从而影响到程序的执行效率,甚至导致宿主环境的卡死。
|
||||
|
||||
其实,除了浏览器可以作为V8的宿主环境,Node.js也是V8的另外一种宿主环境,它提供了不同的宿主对象和宿主的API,但是整个流程依然是相同的,比如Node.js也会提供一套消息循环系统,也会提供一个运行时的主线程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/2f/e541d8611b725001509bfcd6797f492f.jpg" alt="" title="宿主环境和V8的关系">
|
||||
|
||||
好了,现在我们知道,要执行V8,则需要有一个宿主环境,宿主环境可以是浏览器中的渲染进程,可以是Node.js进程,也可以是其他的定制开发的环境,而这些宿主则提供了很多V8执行JavaScript时所需的基础功能部件,接下来我们就来一一分析下这些部件。
|
||||
|
||||
## 构造数据存储空间:堆空间和栈空间
|
||||
|
||||
由于V8是寄生在浏览器或者Node.js这些宿主中的,因此,V8也是被这些宿主启动的。比如,在Chrome中,只要打开一个渲染进程,渲染进程便会初始化V8,同时初始化堆空间和栈空间。
|
||||
|
||||
栈空间主要是用来管理JavaScript函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。
|
||||
|
||||
栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8对栈空间的大小做了限制,如果函数调用层过深,那么V8就有可能抛出栈溢出的错误。你可以在控制台执行下面这样一段代码:
|
||||
|
||||
```
|
||||
function factorial(n){
|
||||
if(n === 1) {return 1;}
|
||||
return n*factorial(n-1);
|
||||
}
|
||||
console.log(factorial(50000))
|
||||
|
||||
```
|
||||
|
||||
执行这段代码,便会报出这样的错误:
|
||||
|
||||
```
|
||||
VM68:1 Uncaught RangeError: Maximum call stack size exceeded
|
||||
|
||||
```
|
||||
|
||||
这段提示是说,调用栈超出了最大范围,因为我们这里求阶乘的函数需要嵌套调用5万层,而栈提供不了这么大的空间,所以就抛出了栈溢出的错误。
|
||||
|
||||
如果有一些占用内存比较大的数据,或者不需要存储在连续空间中的数据,使用栈空间就显得不是太合适了,所以V8又使用了堆空间。
|
||||
|
||||
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,在前面的课程中我们也讲过,JavaScript中除了原生类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有window对象、document对象等,这些都是存在堆空间的。
|
||||
|
||||
宿主在启动V8的过程中,会同时创建堆空间和栈空间,再继续往下执行,产生的新数据都会存放在这两个空间中。
|
||||
|
||||
## 全局执行上下文和全局作用域
|
||||
|
||||
V8初始化了基础的存储空间之后,接下来就需要初始化全局执行上下文和全局作用域了,这两个内容是V8执行后续流程的基础。
|
||||
|
||||
当 V8开始执行一段可执行代码时,会生成一个执行上下文。V8用执行上下文来维护执行当前代码所需要的变量声明、this指向等。
|
||||
|
||||
执行上下文中主要包含三部分,变量环境、词法环境和this关键字。比如在浏览器的环境中,全局执行上下文中就包括了window对象,还有默认指向window的this关键字,另外还有一些Web API函数,诸如setTimeout、XMLHttpRequest等内容。
|
||||
|
||||
而词法环境中,则包含了使用let、const等变量的内容。
|
||||
|
||||
执行上下文所包含的具体内容,你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/f5/0b4929e11b49856037ffdcf00508d4f5.jpg" alt="" title="什么是执行上下文">
|
||||
|
||||
全局执行上下文在V8的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了。另外,当你执行了一段全局代码时,如果全局代码中有声明的函数或者定义的变量,那么函数对象和声明的变量都会被添加到全局执行上下文中。比如下面这段代码:
|
||||
|
||||
```
|
||||
var x = 1
|
||||
function show_x(){
|
||||
console.log(x)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
V8在执行这段代码的过程中,会在全局执行上下文中添加变量x和函数show_x。
|
||||
|
||||
在这里还有一点需要注意下,全局作用域和全局执行上下文的关系,其实你可以把作用域看成是一个抽象的概念,比如在ES6中,同一个全局执行上下文中,都能存在多个作用域,你可以看下面这段代码:
|
||||
|
||||
```
|
||||
var x = 5
|
||||
{
|
||||
let y = 2
|
||||
const z = 3
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码在执行时,就会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中。具体你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/4d/2400b1d780a7abfabdf48b39607f244d.jpg" alt="" title="全局作用域和子作用域关系">
|
||||
|
||||
当V8调用了一个函数时,就会进入函数的执行上下文,这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构。比如执行下面这段代码:
|
||||
|
||||
```
|
||||
var x = 1
|
||||
function show_x(){
|
||||
console.log(x)
|
||||
}
|
||||
function bar(){
|
||||
show_x()
|
||||
}
|
||||
bar()
|
||||
|
||||
```
|
||||
|
||||
当执行到show_x的时候,其栈状态如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/e1/86ee3d0f4b2a8e8cc6c2b3fc320dcce1.jpg" alt="" title="函数调用栈">
|
||||
|
||||
## 构造事件循环系统
|
||||
|
||||
有了堆空间和栈空间,生成了全局执行上下文和全局作用域,接下来就可以执行JavaScript代码了吗?
|
||||
|
||||
答案是不行,因为V8还需要有一个主线程,用来执行JavaScript和执行垃圾回收等工作。V8是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8所执行的代码都是在宿主的主线程上执行的。
|
||||
|
||||
只有一个主线程依然不行,因为如果你开启一个线程,在该线程执行一段代码,那么当该线程执行完这段代码之后,就会自动退出了,执行过程中的一些栈上的数据也随之被销毁,下次再执行另外一个段代码时,你还需要重新启动一个线程,重新初始化栈数据,这会严重影响到程序执行时的性能。
|
||||
|
||||
为了在执行完代码之后,让线程继续运行,通常的做法是在代码中添加一个循环语句,在循环语句中监听下个事件,比如你要执行另外一个语句,那么激活该循环就可以执行了。比如下面的模拟代码:
|
||||
|
||||
```
|
||||
while(1){
|
||||
Task task = GetNewTask();
|
||||
RunTask(task);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码使用了一个循环,不同地获取新的任务,一旦有新的任务,便立即执行该任务。
|
||||
|
||||
如果主线程正在执行一个任务,这时候又来了一个新任务,比如V8正在操作DOM,这时候浏览器的网络线程完成了一个页面下载的任务,而且V8注册监听下载完成的事件,那么这种情况下就需要引入一个消息队列,让下载完成的事件暂存到消息队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务。当执行完一个任务之后,我们的事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务。
|
||||
|
||||
有一点你需要注意一下,因为所有的任务都是运行在主线程的,在浏览器的页面中,V8会和页面共用主线程,共用消息队列,所以如果V8执行一个函数过久,会影响到浏览器页面的交互性能。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,这节课的内容就介绍到这里,下面我们来总结一下:
|
||||
|
||||
今天我们介绍了V8执行JavaScript代码时所需要的基础环境,因为V8并不是一个完整的系统,所以在执行时,它的一部分基础环境是由宿主提供的,这包括了全局执行上下文、事件循环系统,堆空间和栈空间。除了需要宿主提供的一些基础环境之外,V8自身会提供JavaScript的核心功能和垃圾回收系统。
|
||||
|
||||
宿主环境在启动过程中,会构造堆空间,用来存放一些对象数据,还会构造栈空间,用来存放原生数据。由于堆空间中的数据不是线性存储的,所以堆空间可以存放很多数据,但是读取的速度会比较慢,而栈空间是连续的,所以堆空间中的查找速度非常快,但是要在内存中找到一块连续的区域却显得有点难度,于是所有的程序都限制栈空间的大小,这就是我们经常容易出现栈溢出的一个主要原因。
|
||||
|
||||
如果在浏览器中,JavaScript代码会频繁操作window(this默认指向window对象)、操作dom等内容,如果在node中,JavaScript会频繁使用global(this默认指向global对象)、File API等内容,这些内容都会在启动过程中准备好,我们把这些内容称之为全局执行上下文。
|
||||
|
||||
全局执行上下文中和函数的执行上下文生命周期是不同的,函数执行上下文在函数执行结束之后,就会被销毁,而全局执行上下文则和V8的生命周期是一致的,所以在实际项目中,如果不经常使用的变量或者数据,最好不要放到全局执行上下文中。
|
||||
|
||||
另外,宿主环境还需要构造事件循环系统,事件循环系统主要用来处理任务的排队和任务的调度。
|
||||
|
||||
## 思考题
|
||||
|
||||
你认为作用域和执行上下文是什么关系?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,244 @@
|
||||
<audio id="audio" title="10 | 机器代码:二进制机器码究竟是如何被CPU执行的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/5c/cce2322ca3e0f357f0e66c3a5fb05f5c.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在上一节我们分析了V8的运行时环境,准备好了运行时环境,V8就可以执行JavaScript代码了。在执行代码时,V8需要先将JavaScript编译成字节码,然后再解释执行字节码,或者将需要优化的字节码编译成二进制,并直接执行二进制代码。
|
||||
|
||||
也就是说,V8首先需要将JavaScript**编译**成字节码或者二进制代码,然后再**执行**。
|
||||
|
||||
在后续课程中,我们会分析V8如何解释执行字节码,以及执行编译好的二进制代码,不过在分析这些过程之前,我们需要了解最基础的知识,那就是CPU如何执行二进制代码。
|
||||
|
||||
因为字节码的执行模式和CPU直接执行二进制代码的模式是类似的,了解CPU执行二进制代码的过程,后续我们分析字节码的执行流程就会显得比较轻松,而且也能加深我们对计算机底层工作原理的理解。
|
||||
|
||||
今天我们就要来分析下二进制代码是怎么被CPU执行的,在编译流水线中的位置你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/a2/a20dec9ec8a84c8519dd1c4a18c2dda2.jpg" alt="" title="CPU执行二进制代码">
|
||||
|
||||
## 将源码编译成机器码
|
||||
|
||||
我们以一段C代码为例,来看一下代码被编译成二进制可执行程序之后,是如何被CPU执行的。
|
||||
|
||||
在这段代码中,只是做了非常简单的加法操作,将x和y两个数字相加得到z,并返回结果z。
|
||||
|
||||
```
|
||||
int main()
|
||||
{
|
||||
int x = 1;
|
||||
int y = 2;
|
||||
int z = x + y;
|
||||
return z;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们知道,CPU并不能直接执行这段C代码,而是需要对其进行编译,将其转换为二进制的机器码,然后CPU才能按照顺序执行编译后的机器码。
|
||||
|
||||
那么我们先通过**GCC编译器**将这段C代码编译成二进制文件,你可以输入以下命令让其编译成目的文件:
|
||||
|
||||
```
|
||||
gcc -O0 -o code_prog code.c
|
||||
|
||||
```
|
||||
|
||||
输入上面的命令之后回车,就可以在文件夹中生成名为code_prog的可执行程序,接下来我们再将编译出来的code_prog程序进行反汇编,这样我们就可以看到二进制代码和对应的汇编代码。你可以使用objdump的完成该任务,命令如下所示:
|
||||
|
||||
```
|
||||
objdump -d code_prog
|
||||
|
||||
```
|
||||
|
||||
最后编译出来的机器码如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/09/45a51ccfeba212d1ccda8c669d317509.png" alt="">
|
||||
|
||||
观察上图,左边就是编译生成的机器码,在这里它是使用十六进制来展示的,这主要是因为十六进制比较容易阅读,所以我们通常使用十六进制来展示二进制代码。你可以观察到上图是由很多行组成的,每一行其实都是一个指令,该指令可以让CPU执行指定的任务。
|
||||
|
||||
中间的部分是汇编代码,汇编代码采用**助记符(memonic)**来编写程序,例如原本是二进制表示的指令,在汇编代码中可以使用单词来表示,比如mov、add就分别表示数据的存储和相加。汇编语言和机器语言是一一对应的,这一点和高级语言有很大的不同。
|
||||
|
||||
通常我们将汇编语言编写的程序转换为机器语言的过程称为“**汇编**”;反之,机器语言转化为汇编语言的过程称为“**反汇编**”,比如上图就是对code_prog进程进行了反汇编操作。
|
||||
|
||||
另外,右边是我添加的注释,表示每条指令的具体含义,你可以对照着阅读。
|
||||
|
||||
这一大堆指令按照顺序集合在一起就组成了程序,所以程序的执行,本质上就是CPU按照顺序执行这一大堆指令的过程。
|
||||
|
||||
## CPU是怎么执行程序的?
|
||||
|
||||
现在我们知道了编译后的程序是由一堆二进制代码组成的,也知道二进制代码是由一条条指令构成的,那么接下来我们就可以来分析CPU是如何执行这些指令的了。
|
||||
|
||||
不过为了分析程序的执行过程,我们还需要理解典型的计算机系统的硬件组织结构,具体你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/6d/880dc63d333d8d18d8be9a473b15e06d.jpg" alt="" title="计算机系统的硬件组织结构">
|
||||
|
||||
这张图是比较通用的系统硬件组织模型图,从图中我们可以看出,它主要是由CPU、主存储器、各种IO总线,还有一些外部设备,诸如硬盘、显示器、USB等设备组成的。
|
||||
|
||||
有了这张图,接下来我们就可以分析程序到底是怎么被执行的了。
|
||||
|
||||
**首先,在程序执行之前,我们的程序需要被装进内存**,比如在Windows下面,你可以通过鼠标点击一个可执行文件,当你点击该文件的时候,系统中的程序加载器会将该文件加载到内存中。
|
||||
|
||||
那么到底什么是内存呢?
|
||||
|
||||
你可以把内存看成是一个快递柜,比如当你需要寄件的时候,你可以打开快递柜中的第100号单元格,并存放你的物品,有时候你会收到快递,提示你在快递柜的105号单元格中,你就可以打开105号单元格取出的你的快递。
|
||||
|
||||
这里有三个重要的内容,分别是**快递柜**、**快递柜中的每个单元格的编号**、**操作快递柜的人**,你可以把它们对比成计算机中的**内存**、**内存地址**和**CPU**。
|
||||
|
||||
也就是说,CPU可以通过指定内存地址,从内存中读取数据,或者往内存中写入数据,有了内存地址,CPU和内存就可以有序地交互。同时,从内存的角度理解地址也是非常重要的,这能帮助我们理解后续很多有深度的内容。
|
||||
|
||||
另外,内存还是一个临时存储数据的设备,之所以是临时的存储器,是因为断电之后,内存中的数据都会消失。
|
||||
|
||||
**内存中的每个存储空间都有其对应的独一无二的地址,**你也可以通过下图来直观地理解下内存中两个重要的概念,内存和地址:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/e6/87bfd9f3cd9a3e120e9e51a47fb4afe6.jpg" alt="" title="内存中的存储空间都有唯一地址">
|
||||
|
||||
在内存中,每个存放字节的空间都有其唯一的地址,而且地址是按照顺序排放的,理解了内存和内存地址,接下来我们就可以继续往下分析了。
|
||||
|
||||
我们还是分析这节课开头的那段C代码,这段代码会被编译成可执行文件,可执行文件中包含了二进制的机器码,当二进制代码被加载进了内存后,那么内存中的每条二进制代码便都有了自己对应的地址,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/df/99bc9f08d975daf9b86bba72b22ccddf.jpg" alt="" title="加载到内存中的程序">
|
||||
|
||||
有时候一条指令只需要一个字节就可以了,但是有时候一条指令却需要多个字节。在上图中,对于同一条指令,我使用了相同的颜色来标记,我们可以把上面这个一堆二进制数据反汇编成一条条指令的形式,这样可以方便我们的阅读,效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/ee/34fb571ceb09f9d2cba60fcac11a75ee.png" alt="">
|
||||
|
||||
好了,一旦二进制代码被装载进内存,CPU便可以从内存中**取出一条指令**,然后**分析该指令**,最后**执行该指令**。
|
||||
|
||||
我们把取出指令、分析指令、执行指令这三个过程称为一个**CPU时钟周期**。CPU是永不停歇的,当它执行完成一条指令之后,会立即从内存中取出下一条指令,接着分析该指令,执行该指令,CPU一直重复执行该过程,直至所有的指令执行完成。
|
||||
|
||||
也许你有这样的疑问,CPU是怎么知道要取出内存中的哪条指令呢?要解答这个问题,我们先看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/b3/81f37939dc9920c1e0e261c7f345ceb3.jpg" alt="" title="将混乱的二进制代码转换为有序的指令形式">
|
||||
|
||||
观察上图,我们可以看到CPU中有一个PC寄存器,它保存了将要执行的指令地址,当二进制代码被装载进了内存之后,系统会将二进制代码中的第一条指令的地址写入到PC寄存器中,到了下一个时钟周期时,CPU便会根据**PC寄存器**中的地址,从内存中取出指令。
|
||||
|
||||
PC寄存器中的指令取出来之后,系统要做两件事:
|
||||
|
||||
第一件事是将下一条指令的地址更新到PC寄存器中,比如上图中,CPU将第一个指令55取出来之后,系统会立即将下一个指令的地址填写到PC寄存器中,上个寄存器的地址是100000f90,那么下一条指令的地址就是100000f91了,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/42/10e900db99f77fa780ef4652b8302f42.jpg" alt="" title="将第一条指令写入PC中">
|
||||
|
||||
更新了PC寄存器之后,CPU就会立即做第二件事,那就是**分析该指令**,并识别出不同的类型的指令,以及各种获取操作数的方法。在指令分析完成之后,就要执行指令了。不过要了解CPU是如何执行指令的,我们还需要了解CPU中的一个重要部件:**通用寄存器。**
|
||||
|
||||
通用寄存器是CPU中用来存放数据的设备,不同处理器中寄存器的个数也是不一样的,之所以要通用寄存器,是因为CPU访问内存的速度很慢,所以CPU就在内部添加了一些存储设备,这些设备就是通用寄存器。
|
||||
|
||||
你可以把通用寄存器比喻成是你身上的口袋,内存就是你的背包,而硬盘则是你的行李箱,要从背包里面拿物品会比较不方便,所以你会将常用的物品放进口袋。你身上口袋的个数通常不会太多,容量也不会太大,而背包就不同了,它的容量会非常大。
|
||||
|
||||
我们可以这样总结通用寄存器和内存的关系:**通用寄存器容量小,读写速度快,内存容量大,读写速度慢。**
|
||||
|
||||
通用寄存器通常用来存放数据或者内存中某块数据的地址,我们把这个地址又称为指针,通常情况下寄存器对存放的数据是没有特别的限制的,比如某个通用寄存器既可以存储数据,也可以存储指针。
|
||||
|
||||
不过由于历史原因,我们还会将某些专用的数据或者指针存储在专用的通用寄存器中 ,比如rbp寄存器通常是用来存放栈帧指针的,rsp寄存器用来存放栈顶指针的,PC寄存器用来存放下一条要执行的指令等。
|
||||
|
||||
现在我们理解了什么是通用寄存器了,接下来我们就可以分析CPU是如何执行指令的了,我们先来了解下几种常用的指令类型:
|
||||
|
||||
第一种是**加载的指令**,其作用是从内存中复制指定长度的内容到通用寄存器中,并覆盖寄存器中原来的内容。你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/ed/c058013ef04fae7c1d5ff24cf0911fed.jpg" alt="" title="更新PC寄存器">
|
||||
|
||||
比如上图使用了**movl**指令,指令后面跟着的第一个参数是要拷贝数据的内存的位置,第二个参数是要拷贝到ecx这个寄存器。
|
||||
|
||||
第二种**存储的指令**,和加载类型的指令相反,其作用是将寄存器中的内容复制内存某个位置,并覆盖掉内存中的这个位置上原来的内容。你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5d/1e/5dc3e0cf2ffba709280bb852ea37891e.jpg" alt="">
|
||||
|
||||
上图也是使用movl指令,movl 指令后面的%ecx就是寄存器地址,-8(%rbp)是内存中的地址,这条指令的作用是将寄存器中的值拷贝到内存中。
|
||||
|
||||
第三种是**更新指令**,其作用是复制两个寄存器中的内容到ALU中,也可以是一块寄存器和一块内存中的内容到ALU中,ALU将两个字相加,并将结果存放在其中的一个寄存器中,并覆盖该寄存器中的内容。具体流程如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/fb/8fde0c5d8d139060849531e5537111fb.jpg" alt="">
|
||||
|
||||
参看上图,我们可以发现addl指令,将寄存器eax和ecx中的值传给ALU,ALU对它们进行相加操纵,并将计算的结果写回ecx。
|
||||
|
||||
还有一个非常重要的指令,是跳转指令,从指令本身抽取出一个字,这个字是下一条要执行的指令的地址,并将该字复制到PC寄存器中,并覆盖掉PC寄存器中原来的值。那么当执行下一条指令时,便会跳转到对应的指令了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/ca/a69affcd27b2646fff920a0c0ab08aca.jpg" alt="">
|
||||
|
||||
观察上图,上图是通过jmp来实现的,jmp后面跟着要跳转的内存中的指令地址。
|
||||
|
||||
除了以上指令之外,还有IO读/写指令,这些指令可以从一个IO设备中复制指定长度的数据到寄存器中,也可以将一个寄存器中的数据复制到指定的IO设备。
|
||||
|
||||
以上就是一些基础的指令类型,这些指令像积木,利用它们可以搭建我们现在复杂的软件大厦。
|
||||
|
||||
## 分析一段汇编代码的执行流程
|
||||
|
||||
好了,了解指令的类型,接下来我们就可以分析上面那段简单的程序的执行过程了,不过在这里还有一些前置的知识没有介绍,比如内存中的栈、栈帧的概念,这些内容我会在下一节详细介绍。本节中如果提到了栈和栈帧,你可以将它们看成是内存中的一块区域即可。
|
||||
|
||||
在C程序中,CPU会首先执行调用main函数,在调用main函数时,CPU会保存上个栈帧上下文信息和创建当前栈帧的上下文信息,主要是通过下面这两条指令实现的:
|
||||
|
||||
```
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp
|
||||
|
||||
```
|
||||
|
||||
第一条指令pushq %rbp,是将rbp寄存器中的值写到内存中的栈区域。第二条指令是将rsp寄存器中的值写到rbp寄存器中。
|
||||
|
||||
然后将0写到栈帧的第一个位置,对应的汇编代码如下:
|
||||
|
||||
```
|
||||
movl $0, -4(%rbp)
|
||||
|
||||
```
|
||||
|
||||
接下来给x和y赋值,对应的代码是下面两行:
|
||||
|
||||
```
|
||||
movl $1, -8(%rbp)
|
||||
movl $2, -12(%rbp)
|
||||
|
||||
```
|
||||
|
||||
第一行指令是将常数值1压入到栈中,然后再将常数值2压入到栈中,这两个值分别对应着x和y。
|
||||
|
||||
接下来,x的值从栈中复制到eax寄存器中,对应的指令如下所示:
|
||||
|
||||
```
|
||||
movl -8(%rbp), %eax
|
||||
|
||||
```
|
||||
|
||||
现在eax寄存器中保存了x的值,那么接下来,再将内存中的y和eax中的x相加,相加的结果再保存在eax中,对应的指令如下所示:
|
||||
|
||||
```
|
||||
addl -12(%rbp), %eax
|
||||
|
||||
```
|
||||
|
||||
现在x+y的结果保存在了eax中了,接下来CPU会将结果保存中内存中,执行如下指令:
|
||||
|
||||
```
|
||||
movl %eax, -16(%rbp)
|
||||
|
||||
```
|
||||
|
||||
最后又将结果z加载到eax寄存器中,代码如下所示:
|
||||
|
||||
```
|
||||
movl -16(%rbp), %eax
|
||||
|
||||
```
|
||||
|
||||
注意这里的eax寄存器中的内容就被默认作为返回值了,执行到这里函数基本就执行结束了,然后需要继续执行一些恢复现场的操作,代码如下所示:
|
||||
|
||||
```
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
到了这里,我们整个程序就执行结束了。
|
||||
|
||||
## 总结
|
||||
|
||||
今天这节课,我们的主要目的是讲清楚CPU是怎么执行一段二进制代码的,这涉及到了CPU、寄存器、运算器、编译、汇编等一系列的知识。
|
||||
|
||||
我们从如何执行一段C代码讲起,由于CPU只能执行机器代码,所以我们需要将C代码转换为机器代码,这个转换过程就是由C编译器完成的。
|
||||
|
||||
CPU执行机器代码的逻辑非常简单,首先编译之后的二进制代码被加载进内存,然后CPU就按照指令的顺序,一行一行地执行。
|
||||
|
||||
在执行指令的过程中,CPU需要对数据执行读写操作,如果直接读写内存,那么会严重影响程序的执行性能,因此CPU就引入了寄存器,将一些中间数据存放在寄存器中,这样就能加速CPU的执行速度。
|
||||
|
||||
有了寄存器之后,CPU执行指令的操作就变得复杂了一点,因为需要寄存器和内存之间传输数据,或者寄存器和寄存器之间传输数据。我们通常有以下几种方式来使用寄存器,这包括了**加载指令、存储指令、更新指令。**通过配合这几种类型的指令,我们就可以实现完整的程序功能了。
|
||||
|
||||
## 思考题
|
||||
|
||||
你能用自己的语言复述下CPU是怎么执行一段二进制机器代码的吗?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
279
极客时间专栏/geek/图解 Google V8/V8编译流水线/11 | 堆和栈:函数调用是如何影响到内存布局的?.md
Normal file
279
极客时间专栏/geek/图解 Google V8/V8编译流水线/11 | 堆和栈:函数调用是如何影响到内存布局的?.md
Normal file
@@ -0,0 +1,279 @@
|
||||
<audio id="audio" title="11 | 堆和栈:函数调用是如何影响到内存布局的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/f0/e7d1a7bf75dc5d4fed322581b806c2f0.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
相信你在使用JavaScript的过程中,经常会遇到栈溢出的错误,比如执行下面这样一段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
foo() // 是否存在堆栈溢出错误?
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
V8就会报告**栈溢出**的错误,为了解决栈溢出的问题,我们可以在foo函数内部使用setTimeout来触发foo函数的调用,改造之后的程序就可以正确执行 。
|
||||
|
||||
```
|
||||
function foo() {
|
||||
setTimeout(foo, 0) // 是否存在堆栈溢出错误?
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果使用Promise来代替setTimeout,在Promise的then方法中调用foo函数,改造的代码如下:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
return Promise.resolve().then(foo)
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
在浏览器中执行这段代码,并没有报告栈溢出的错误,但是你会发现,执行这段代码会让整个页面卡住了。
|
||||
|
||||
为什么这三段代码,第一段造成栈溢出的错误,第二段能够正确执行,而第三段没有栈溢出的错误,却会造成页面的卡死呢?
|
||||
|
||||
其主要原因是这三段代码的底层执行逻辑是完全不同的:
|
||||
|
||||
- 第一段代码是在同一个任务中重复调用嵌套的foo函数;
|
||||
- 第二段代码是使用setTimeout让foo函数在不同的任务中执行;
|
||||
- 第三段代码是在同一个任务中执行foo函数,但是却不是嵌套执行。
|
||||
|
||||
这是因为,V8执行这三种不同代码时,它们的内存布局是不同的,而不同的内存布局又会影响到代码的执行逻辑,因此我们需要了解JavaScript执行时的内存布局。
|
||||
|
||||
这节课,我们从函数特性入手,来一步步延伸出通用的函数调用模型,进而来分析不同的函数调用方式是如何影响到运行时内存布局的。
|
||||
|
||||
下图是本文的主要内容在编译流水线中的位置,因为解释执行和直接执行二进制代码都使用了堆和栈,虽然它们在执行细节上存在着一定的差异,但是整体的执行架构是类似的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/09/d540183ab23341f568b992881edaa709.jpg" alt="">
|
||||
|
||||
## 为什么使用栈结构来管理函数调用?
|
||||
|
||||
我们知道,大部分高级语言都不约而同地采用栈这种结构来管理函数调用,为什么呢?这与函数的特性有关。通常函数有两个主要的特性:
|
||||
|
||||
1. 第一个特点是函数**可以被调用**,你可以在一个函数中调用另外一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,又会将代码执行控制权返还给父函数;
|
||||
1. 第二个特点是函数**具有作用域机制**,所谓作用域机制,是指函数在执行的时候可以将定义在函数内部的变量和外部环境隔离,在函数内部定义的变量我们也称为**临时变量**,临时变量只能在该函数中被访问,外部函数通常无权访问,当函数执行结束之后,存放在内存中的临时变量也随之被销毁。
|
||||
|
||||
我们可以看下面这段C代码:
|
||||
|
||||
```
|
||||
int getZ()
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
int add(int x, int y)
|
||||
{
|
||||
int z = getZ();
|
||||
return x + y + z;
|
||||
}
|
||||
int main()
|
||||
{
|
||||
int x = 5;
|
||||
int y = 6;
|
||||
int ret = add(x, y);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,我们发现其中包含了多层函数嵌套调用,其实这个过程很简单,执行流程是这样的:
|
||||
|
||||
1. 当main函数调用add函数时,需要将代码执行控制权交给add函数;
|
||||
1. 然后add函数又调用了getZ函数,于是又将代码控制权转交给getZ函数;
|
||||
1. 接下来getZ函数执行完成,需要将控制权返回给add函数;
|
||||
1. 同样当add函数执行结束之后,需要将控制权返还给main函数;
|
||||
1. 然后main函数继续向下执行。
|
||||
|
||||
具体的函数调用示意图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/c7/c39b2edd6a8c209d0f579a4e5e0e49c7.jpg" alt="" title="函数调用示意图">
|
||||
|
||||
通过上述分析,我们可以得出,**函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束(先出)。**
|
||||
|
||||
在执行上述流程时,各个函数的生命周期如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/db/a3cfa1ad1d6eb6be321355c191b76fdb.jpg" alt="" title="嵌套调用时函数的生命周期">
|
||||
|
||||
因为函数是有作用域机制的,作用域机制通常表现在函数执行时,会在内存中分配函数内部的变量、上下文等数据,在函数执行完成之后,这些内部数据会被销毁掉。**所以站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数(后进),而函数资源的释放则总是先于调用函数(先出)。**如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/99/f8aab2c91e5f29c275317cbfc02a4e99.jpg" alt="" title="函数资源分配流程">
|
||||
|
||||
通过观察函数的生命周期和函数的资源分配情况,我们发现,它们都符合**后进先出(LIFO)**的策略,而栈结构正好满足这种后进先出(LIFO)的需求,所以我们选择栈来管理函数调用关系是一种很自然的选择。
|
||||
|
||||
关于栈,你可以结合这么一个贴切的例子来理解,一条单车道的单行线,一端被堵住了,而另一端入口处没有任何提示信息,堵住之后就只能后进去的车子先出来(后进先出),这时这个堵住的单行线就可以被看作是一个**栈容器**,车子开进单行线的操作叫做**入栈**,车子倒出去的操作叫做**出栈**。
|
||||
|
||||
在车流量较大的场景中,就会发生反复地入栈、栈满、出栈、空栈和再次入栈,一直循环。你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/fa/cb286b244c88a88a1fbd0a41f93265fa.jpg" alt="" title="数据结构层面的栈">
|
||||
|
||||
## 栈如何管理函数调用?
|
||||
|
||||
了解了栈的特性之后,我们就来看看栈是如何管理函数调用的。
|
||||
|
||||
首先我们来分析最简单的场景:当执行一个函数的时候,栈怎么变化?
|
||||
|
||||
当一个函数被执行时,函数的参数、函数内部定义变量都会依次压入到栈中,我们结合实际的代码来分析下这个过程,你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/78/27f1a623219737f376deddfefb865478.jpg" alt="" title="函数内部变量压栈状态">
|
||||
|
||||
上图展示的是一段简单的C代码的执行过程,可以看到:
|
||||
|
||||
- 当执行到函数的第一段代码的时候,变量x第一次被赋值,且值为5,这时5会被压入到栈中。
|
||||
- 然后,执行第二段代码,变量y第一次被赋值,且值为6,这时6会被压入到栈中。
|
||||
- 接着,执行到第三段代码,注意这里变量x是第二次被赋值,且新的值为100,那么这时并不是将100压入到栈中,而是替换之前压入栈的内容,也就是将栈中的5替换成100。
|
||||
- 最后,执行第四段代码,这段代码是int z = x + y,我们会先计算出来x+y的值,然后再将x+y的值赋值给z,由于z是第一次被赋值,所以z的值也会被压入到栈中。
|
||||
|
||||
你会发现,**函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中。**
|
||||
|
||||
了解了这一点,接下来我们就可以分析更加复杂一点的场景了:当一个函数调用另外一个函数时,栈的变化情况是怎样的?我们还是先看下面这段代码:
|
||||
|
||||
```
|
||||
int add(num1,num2){
|
||||
int x = num1;
|
||||
int y = num2;
|
||||
int ret = x + y;
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int main()
|
||||
{
|
||||
int x = 5;
|
||||
int y = 6;
|
||||
x = 100;
|
||||
int z = add(x,y);
|
||||
return z;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,我们把上段代码中的x+y改造成了一个add函数,当执行到int z = add(x,y)时,当前栈的状态如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/ba/c39ca61afc6eaa78fe394e060028fdba.jpg" alt="">
|
||||
|
||||
接下来,就要调用add函数了,理想状态下,执行add函数的过程是下面这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/b1/27407a5f9089c4a8b09c0d2b775b50b1.jpg" alt="">
|
||||
|
||||
当执行到add函数时,会先把参数num1和num2压栈,接着我们再把变量x、y、ret的值依次压栈,不过执行这里,会遇到一个问题,那就是当add函数执行完成之后,需要将执行代码的控制权转交给main函数,这意味着需要将栈的状态恢复到main函数上次执行时的状态,我们把这个过程叫**恢复现场**。那么应该怎么恢复main函数的执行现场呢?
|
||||
|
||||
其实方法很简单,只要在寄存器中保存一个永远指向当前栈顶的指针,栈顶指针的作用就是告诉你应该往哪个位置添加新元素,这个指针通常存放在esp寄存器中。如果你想往栈中添加一个元素,那么你需要先根据esp寄存器找到当前栈顶的位置,然后在栈顶上方添加新元素,新元素添加之后,还需要将新元素的地址更新到esp寄存器中。
|
||||
|
||||
有了栈顶指针,就很容易恢复main函数的执行现场了,当add函数执行结束时,只需要将栈顶指针向下移动就可以了,具体你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/bd/68b9d297cc48864ad49c1915766fa6bd.jpg" alt="" title="add函数即将执行结束的状态">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/d2/89180f0674a92df96ce6f25813020ed2.jpg" alt="" title="恢复mian函数执行现场">
|
||||
|
||||
观察上图,将esp的指针向下移动到之前main函数执行时的地方就可以,不过新的问题又来了,CPU是怎么知道要移动到这个地址呢?
|
||||
|
||||
CPU的解决方法是增加了另外一个ebp寄存器,用来保存当前函数的起始位置,我们把一个函数的起始位置也称为栈帧指针,ebp寄存器中保存的就是当前函数的栈帧指针,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/91/94e1333f053d4dbbb41eb00aaf869a91.jpg" alt="" title="ebp寄存器保存了栈帧指针">
|
||||
|
||||
在main函数调用add函数的时候,main函数的栈顶指针就变成了add函数的栈帧指针,所以需要将main函数的栈顶指针保存到ebp中,当add函数执行结束之后,我需要销毁add函数的栈帧,并恢复main函数的栈帧,那么只需要取出main函数的栈顶指针写到esp中即可(main函数的栈顶指针是保存在ebp中的),这就相当于将栈顶指针移动到main函数的区域。
|
||||
|
||||
那么现在,我们可以执行main函数了吗?
|
||||
|
||||
答案依然是“不能”,这主要是因为main函数也有它自己的栈帧指针,在执行main函数之前,我们还需恢复它的栈帧指针。如何恢复main函数的栈帧指针呢?
|
||||
|
||||
通常的方法是在main函数中调用add函数时,CPU会将当前main函数的栈帧指针保存在栈中,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/9c/30dc7c253b8d0ffb332cfb7a878ebe9c.jpg" alt="">
|
||||
|
||||
当函数调用结束之后,就需要恢复main函数的执行现场了,首先取出ebp中的指针,写入esp中,然后从栈中取出之前保留的main的栈帧地址,将其写入ebp中,到了这里ebp和esp就都恢复了,可以继续执行main函数了。
|
||||
|
||||
另外在这里,我们还需要补充下**栈帧**的概念,因为在很多文章中我们会看到这个概念,每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。
|
||||
|
||||
以上我们详细分析了C函数的执行过程,在JavaScript中,函数的执行过程也是类似的,如果调用一个新函数,那么V8会为该函数创建栈帧,等函数执行结束之后,销毁该栈帧,而栈结构的容量是固定的,所有如果重复嵌套执行一个函数,那么就会导致栈会栈溢出。
|
||||
|
||||
了解了这些,现在我们再回过头来看下这节课开头提到的三段代码。
|
||||
|
||||
第一段代码由于循环嵌套调用了foo,所以当函数运行时,就会导致foo函数会不断地调用foo函数自身,这样就会导致栈无限增,进而导致栈溢出的错误。
|
||||
|
||||
第二段代码是在函数内部使用了setTimeout来启动foo函数,这段代码之所以不会导致栈溢出,是因为setTimeout会使得foo函数在消息队列后面的任务中执行,所以不会影响到当前的栈结构。 也就不会导致栈溢出。关于消息队列和事件循环系统,我们会在最后一单元来介绍。
|
||||
|
||||
最后一段代码是Promise,Promise的情况比较特别,既不会造成栈溢出,但是这种方式会导致主线的卡死,这就涉及到了微任务,关于微任务在这里我们先不展开介绍,我会在微任务这一节来详细介绍。
|
||||
|
||||
## 既然有了栈,为什么还要堆?
|
||||
|
||||
好了,我们现在理解了栈是怎么管理函数调用的了,使用栈有非常多的优势:
|
||||
|
||||
1. 栈的结构和非常适合函数调用过程。
|
||||
1. 在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。
|
||||
|
||||
虽然操作速度非常快,但是栈也是有缺点的,其中最大的缺点也是它的优点所造成的,那就是栈是连续的,所以要想在内存中分配一块连续的大空间是非常难的,因此栈空间是有限的。
|
||||
|
||||
因为栈空间是有限的,这就导致我们在编写程序的时候,经常一不小心就会导致栈溢出,比如函数循环嵌套层次太多,或者在栈上分配的数据过大,都会导致栈溢出,基于栈不方便存放大的数据,因此我们使用了另外一种数据结构用来保存一些大数据,这就是**堆**。
|
||||
|
||||
和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,你可以在任何时候分配和释放它,为了更好地理解堆,我们看下面这段代码是怎么执行的:
|
||||
|
||||
```
|
||||
struct Point
|
||||
{
|
||||
int x;
|
||||
int y;
|
||||
};
|
||||
|
||||
|
||||
int main()
|
||||
{
|
||||
int x = 5;
|
||||
int y = 6;
|
||||
int *z = new int;
|
||||
*z = 20;
|
||||
|
||||
|
||||
Point p;
|
||||
p.x = 100;
|
||||
p.y = 200;
|
||||
|
||||
|
||||
Point *pp = new Point();
|
||||
pp->y = 400;
|
||||
pp->x = 500;
|
||||
delete z;
|
||||
delete pp;
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,你可以看到代码中有new int、new Point这种语句,当执行这些语句时,表示要在堆中分配一块数据,然后返回指针,通常返回的指针会被保存到栈中,下面我们来看看当main函数快执行结束时,堆和栈的状态,具体内容你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/55/139edffd0fb7e2b58f0e03c7d1240755.jpg" alt="">
|
||||
|
||||
观察上图,我们可以发现,当使用new时,我们会在堆中分配一块空间,在堆中分配空间之后,会返回分配后的地址,我们会把该地址保存在栈中,如上图中p和pp都是地址,它们保存在栈中,指向了在堆中分配的空间。
|
||||
|
||||
通常,当堆中的数据不再需要的时候,需要对其进行销毁,在C语言中可以使用free,在C++语言中可以使用delete来进行操作,比如可以通过:
|
||||
|
||||
```
|
||||
delete p;
|
||||
delete pp;
|
||||
|
||||
```
|
||||
|
||||
来销毁堆中的数据,像C/C++这种手动管理内存的语言,如果没有手动销毁堆中的数据,那么就会造成内存泄漏。不过JavaScript,Java使用了自动垃圾回收策略,可以实现垃圾自动回收,但是事情总有两面性,垃圾自动回收也会给我们带来一些性能问题。所以不管是自动垃圾回收策略,还是手动垃圾回收策略,要想写出高效的代码,我们都需要了解内存的底层工作机制。
|
||||
|
||||
## 总结
|
||||
|
||||
因为现代语言都是基于函数的,每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为**调用栈。**
|
||||
|
||||
因为栈在内存中连续的数据结构,所以在通常情况下,栈都有最大容量限制,这也就意味着,函数的嵌套调用次数过多,就会超出栈的最大使用范围,从而导致栈溢出。
|
||||
|
||||
为了解决栈溢出的问题,我们可以使用setTimeout将要执行的函数放到其他的任务中去执行,也可以使用Promise来改变栈的调用方式,这涉及到了事件循环和微任务,我们会在后续课程中再来介绍。
|
||||
|
||||
## 思考题
|
||||
|
||||
你可以分析一下,在浏览器中执行这段代码,为什么不会造成栈溢出,但是却会造成页面的卡死?欢迎你在留言区与我分享讨论。
|
||||
|
||||
```
|
||||
function foo() {
|
||||
return Promise.resolve().then(foo)
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
211
极客时间专栏/geek/图解 Google V8/V8编译流水线/12 | 延迟解析:V8是如何实现闭包的?.md
Normal file
211
极客时间专栏/geek/图解 Google V8/V8编译流水线/12 | 延迟解析:V8是如何实现闭包的?.md
Normal file
@@ -0,0 +1,211 @@
|
||||
<audio id="audio" title="12 | 延迟解析:V8是如何实现闭包的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/b1/654fab4d8962acd19f11d4bc98f906b1.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在第一节我们介绍过V8执行JavaScript代码,需要经过**编译**和**执行**两个阶段,其中**编译过程**是指V8将JavaScript代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字节码,或者是CPU直接执行二进制机器代码的阶段。总的流程你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/db/fe3d39715d28a833883df6702930a0db.jpg" alt="" title="代码执行">
|
||||
|
||||
在编译JavaScript代码的过程中,V8并不会一次性将所有的JavaScript解析为中间代码,这主要是基于以下两点:
|
||||
|
||||
- 首先,如果一次解析和编译所有的JavaScript代码,过多的代码会增加编译时间,这会严重影响到首次执行JavaScript代码的速度,让用户感觉到卡顿。因为有时候一个页面的JavaScript代码都有10多兆,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间;
|
||||
- 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有JavaScript代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源。
|
||||
|
||||
基于以上的原因,所有主流的JavaScript虚拟机都实现了**惰性解析**。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成AST和字节码,而仅仅生成顶层代码的AST和字节码。
|
||||
|
||||
## 惰性解析的过程
|
||||
|
||||
关于惰性解析,我们可以结合下面这个例子来分析下:
|
||||
|
||||
```
|
||||
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处理时,V8会至上而下解析这段代码,在解析过程中首先会遇到foo函数,由于这只是一个函数声明语句,V8在这个阶段只需要将该函数转换为函数对象,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/35/4a/35ce3f6469a7024ca14d81b6c804044a.jpg" alt="">
|
||||
|
||||
注意,这里只是将该函数声明转换为函数对象,但是并没有解析和编译函数内部的代码,所以也不会为foo函数的内部代码生成抽象语法树。
|
||||
|
||||
然后继续往下解析,由于后续的代码都是顶层代码,所以V8会为它们生成抽象语法树,最终生成的结果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/62/e52476efb6ef924e74f470ead4970262.jpg" alt="" title="生成顶层代码的抽象语法树">
|
||||
|
||||
代码解析完成之后,V8便会按照顺序自上而下执行代码,首先会先执行“a=1”和“c=4”这两个赋值表达式,接下来执行foo函数的调用,过程是从foo函数对象中取出函数代码,然后和编译顶层代码一样,V8会先编译foo函数的代码,编译时同样需要先将其编译为抽象语法树和字节码,然后再解释执行。
|
||||
|
||||
好了,上面就是惰性解析的一个大致过程,看上去是不是很简单,不过在V8实现惰性解析的过程中,需要支持JavaScript中的闭包特性,这会使得V8的解析过程变得异常复杂。
|
||||
|
||||
为什么闭包会让V8解析代码的过程变得复杂呢?要解答这个问题,我们先来拆解闭包的特性,然后再来分析为什么闭包影响到了V8的解析流程。
|
||||
|
||||
## 拆解闭包——JavaScript的三个特性
|
||||
|
||||
JavaScript中的闭包有三个基础特性。
|
||||
|
||||
第一,**JavaScript语言允许在函数内部定义新的函数**,代码如下所示:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
function inner() {
|
||||
}
|
||||
inner()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这和其他的流行语言有点差异,在其他的大部分语言中,函数只能声明在顶层代码中,而JavaScript中之所以可以在函数中声明另外一个函数,主要是因为JavaScript中的函数即对象,你可以在函数中声明一个变量,当然你也可以在函数中声明一个函数。
|
||||
|
||||
第二,**可以在内部函数中访问父函数中定义的变量**,代码如下所示:
|
||||
|
||||
```
|
||||
var d = 20
|
||||
//inner函数的父函数,词法作用域
|
||||
function foo() {
|
||||
var d = 55
|
||||
//foo的内部函数
|
||||
function inner() {
|
||||
return d+2
|
||||
}
|
||||
inner()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于可以在函数中定义新的函数,所以很自然的,内部的函数可以使用外部函数中定义的变量,注意上面代码中的inner函数和foo函数,inner是在foo函数内部定义的,我们就称inner函数是foo函数的子函数,foo函数是inner函数的父函数。这里的父子关系是针对词法作用域而言的,因为词法作用域在函数声明时就决定了,比如inner函数是在foo函数内部声明的,所以inner函数可以访问foo函数内部的变量,比如inner就可以访问foo函数中的变量d。
|
||||
|
||||
但是如果在foo函数外部,也定义了一个变量d,那么当inner函数访问该变量时,到底是该访问哪个变量呢?
|
||||
|
||||
在《[06|作用域链:V8是如何查找变量的?](https://time.geekbang.org/column/article/217027)》这节课,我介绍了词法作用域和词法作用域链,每个函数有自己的词法作用域,该函数中定义的变量都存在于该作用域中,然后V8会将这些作用域按照词法的位置,也就是代码位置关系,将这些作用域串成一个链,这就是词法作用域链,查找变量的时候会沿着词法作用域链的途径来查找。
|
||||
|
||||
所以,inner函数在自己的作用域中没有查找到变量d,就接着在foo函数的作用域中查找,再查找不到才会查找顶层作用域中的变量。所以inner函数中使用的变量d就是foo函数中的变量d。
|
||||
|
||||
第三,**因为函数是一等公民,所以函数可以作为返回值**,我们可以看下面这段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
return function inner(a, b) {
|
||||
const c = a + b
|
||||
return c
|
||||
}
|
||||
}
|
||||
const f = foo()
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,我们将inner函数作为了foo函数的返回值,也就是说,当调用foo函数时,最终会返回inner函数给调用者,比如上面我们将inner函数返回给了全局变量f,接下来就可以在外部像调用inner函数一样调用f了。
|
||||
|
||||
以上就是和JavaScript闭包相关的三个重要特性:
|
||||
|
||||
- 可以在JavaScript函数内部定义新的函数;
|
||||
- 内部函数中访问父函数中定义的变量;
|
||||
- 因为JavaScript中的函数是一等公民,所以函数可以作为另外一个函数的返回值。
|
||||
|
||||
这也是JavaScript过于灵活的一个原因,比如在C/C++中,你就不可以在一个函数中定义另外一个函数,所以也就没了内部函数访问外部函数中变量的问题了。
|
||||
|
||||
## 闭包给惰性解析带来的问题
|
||||
|
||||
好了,了解了JavaScript的这三个特性之后,下面我们就来使用这三个特性组装的一段经典的闭包代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var d = 20
|
||||
return function inner(a, b) {
|
||||
const c = a + b + d
|
||||
return c
|
||||
}
|
||||
}
|
||||
const f = foo()
|
||||
|
||||
```
|
||||
|
||||
观察上面上面这段代码,我们在foo函数中定义了inner函数,并返回inner函数,同时在inner函数中访问了foo函数中的变量d。
|
||||
|
||||
我们可以分析下上面这段代码的执行过程:
|
||||
|
||||
- 当调用foo函数时,foo函数会将它的内部函数inner返回给全局变量f;
|
||||
- 然后foo函数执行结束,执行上下文被V8销毁;
|
||||
- 虽然foo函数的执行上下文被销毁了,但是依然存活的inner函数引用了foo函数作用域中的变量d。
|
||||
|
||||
按照通用的做法,d已经被v8销毁了,但是由于存活的函数inner依然引用了foo函数中的变量d,这样就会带来两个问题:
|
||||
|
||||
- 当foo执行结束时,变量d该不该被销毁?如果不应该被销毁,那么应该采用什么策略?
|
||||
- 如果采用了惰性解析,那么当执行到foo函数时,V8只会解析foo函数,并不会解析内部的inner函数,那么这时候V8就不知道inner函数中是否引用了foo函数的变量d。
|
||||
|
||||
这么讲可能有点抽象,下面我们就来看一下上面这段代码的执行流程,我们上节分析过了,JavaScript是一门基于堆和栈的语言,当执行foo函数的时候,堆栈的变化如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/10/deaa69d414571516a1debd9712860110.jpg" alt="">
|
||||
|
||||
从上图可以看出来,在执行全局代码时,V8会将全局执行上下文压入到调用栈中,然后进入执行foo函数的调用过程,这时候V8会为foo函数创建执行上下文,执行上下文中包括了变量d,然后将foo函数的执行上下文压入栈中,foo函数执行结束之后,foo函数执行上下文从栈中弹出,这时候foo执行上下文中的变量d也随之被销毁。
|
||||
|
||||
但是这时候,由于inner函数被保存到全局变量中了,所以inner函数依然存在,最关键的地方在于inner函数使用了foo函数中的变量d,按照正常执行流程,变量d在foo函数执行结束之后就被销毁了。
|
||||
|
||||
所以正常的处理方式应该是foo函数的执行上下文虽然被销毁了,但是inner函数引用的foo函数中的变量却不能被销毁,那么V8就需要为这种情况做特殊处理,需要保证即便foo函数执行结束,但是foo函数中的d变量依然保持在内存中,不能随着foo函数的执行上下文被销毁掉。
|
||||
|
||||
那么怎么处理呢?
|
||||
|
||||
在执行foo函数的阶段,虽然采取了惰性解析,不会解析和执行foo函数中的inner函数,但是V8还是需要判断inner函数是否引用了foo函数中的变量,负责处理这个任务的模块叫做预解析器。
|
||||
|
||||
## 预解析器如何解决闭包所带来的问题?
|
||||
|
||||
V8引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。
|
||||
|
||||
第一,是判断当前函数是不是存在一些语法上的错误,如下面这段代码:
|
||||
|
||||
```
|
||||
function foo(a, b) {
|
||||
{/} //语法错误
|
||||
}
|
||||
var a = 1
|
||||
var c = 4
|
||||
foo(1, 5)
|
||||
|
||||
```
|
||||
|
||||
在预解析过程中,预解析器发现了语法错误,那么就会向V8抛出语法错误,比如上面这段代码的语法错误是这样的:
|
||||
|
||||
```
|
||||
Uncaught SyntaxError: Invalid regular expression: missing /
|
||||
|
||||
```
|
||||
|
||||
第二,除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们主要介绍了V8的惰性解析,所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成AST和字节码,而仅仅生成顶层代码的AST和字节码。
|
||||
|
||||
利用惰性解析可以加速JavaScript代码的启动速度,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。
|
||||
|
||||
由于JavaScript是一门天生支持闭包的语言,由于闭包会引用当前函数作用域之外的变量,所以当V8解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后,也不会释放该变量。
|
||||
|
||||
## 思考题
|
||||
|
||||
观察下面两段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var a = 0
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var a = 0
|
||||
return function inner() {
|
||||
return a++
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
请你思考下,当调用foo函数时,foo函数内部的变量a会分别分配到栈上?还是堆上?为什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
161
极客时间专栏/geek/图解 Google V8/V8编译流水线/13 | 字节码(一):V8为什么又重新引入字节码?.md
Normal file
161
极客时间专栏/geek/图解 Google V8/V8编译流水线/13 | 字节码(一):V8为什么又重新引入字节码?.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<audio id="audio" title="13 | 字节码(一):V8为什么又重新引入字节码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/d6/3f438efc1ee81b61819805366e7e64d6.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在第一节课我们就介绍了V8的编译流水线,我们知道V8在执行一段JavaScript代码之前,需要将其编译为字节码,然后再解释执行字节码或者将字节码编译为二进制代码然后再执行。
|
||||
|
||||
所谓字节码,是指编译过程中的中间代码,你可以把字节码看成是机器代码的抽象,在V8中,字节码有两个作用:
|
||||
|
||||
- 第一个是解释器可以直接解释执行字节码;
|
||||
- 第二个是优化编译器可以将字节码编译为二进制代码,然后再执行二进制机器代码。
|
||||
|
||||
虽然目前的架构使用了字节码,不过早期的V8并不是这样设计的,那时候V8团队认为这种“先生成字节码再执行字节码”的方式,多了个中间环节,多出来的中间环节会牺牲代码的执行速度。
|
||||
|
||||
于是在早期,V8团队采取了非常激进的策略,直接将JavaScript代码编译成机器代码。其执行流程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/68/6a9f1a826b924eb74f0ab08a18528a68.jpg" alt="" title="早期V8执行流水线">
|
||||
|
||||
观察上面的执行流程图,我们可以发现,早期的V8也使用了两个编译器:
|
||||
|
||||
1. 第一个是**基线编译器**,它负责将JavaScript代码编译为**没有优化**过的机器代码。
|
||||
1. 第二个是**优化编译器**,它负责将一些热点代码(执行频繁的代码)**优化**为执行效率更高的机器代码。
|
||||
|
||||
了解这两个编译器之后,接下来我们再来看看早期的V8是怎么执行一段JavaScript代码的。
|
||||
|
||||
1. 首先,V8会将一段JavaScript代码转换为抽象语法树(AST)。
|
||||
1. 接下来基线编译器会将抽象语法树编译为未优化过的机器代码,然后V8直接执行这些未优化过的机器代码。
|
||||
1. 在执行未优化的二进制代码过程中,如果V8检测到某段代码重复执行的概率过高,那么V8会将该段代码标记为HOT,标记为HOT的代码会被优化编译器优化成执行效率高的二进制代码,然后就执行该段优化过的二进制代码。
|
||||
1. 不过如果优化过的二进制代码并不能满足当前代码的执行,这也就意味着优化失败,V8则会执行反优化操作。
|
||||
|
||||
以上就是早期的V8执行一段JavaScript代码的流程,不过最近发布的V8已经抛弃了直接将JavaScript代码编译为二进制代码的方式,也抛弃了这两个编译器,进而使用了字节码+解释器+编译器方式,也就是我们在第一节课介绍的形式。
|
||||
|
||||
早期的V8之所以抛弃中间形式的代码,直接将JavaScript代码编译成机器代码,是因为机器代码的执行性能非常高效,但是最新版本却朝着执行性能相反的方向进化,那么这是出于什么原因呢?
|
||||
|
||||
## 机器代码缓存
|
||||
|
||||
当JavaScript代码在浏览器中被执行的时候,需要先被V8编译,早期的V8会将JavaScript编译成未经优化的二进制机器代码,然后再执行这些未优化的二进制代码,通常情况下,编译占用了很大一部分时间,下面是一段代码的编译和执行时间图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/bb/d5b8e781606efa91362c856656de3ebb.jpg" alt="">
|
||||
|
||||
从图中可以看出,编译所消耗的时间和执行所消耗的时间是差不多的,试想一下,如果在浏览器中再次打开相同的页面,当页面中的JavaScript文件没有被修改,那么再次编译之后的二进制代码也会保持不变, 这意味着编译这一步白白浪费了CPU资源,因为之前已经编译过一次了。
|
||||
|
||||
这就是Chrome浏览器引入二进制代码缓存的原因,通过把二进制代码保存在内存中来消除冗余的编译,重用它们完成后续的调用,这样就省去了再次编译的时间。
|
||||
|
||||
V8 使用两种代码缓存策略来缓存生成的代码。
|
||||
|
||||
- 首先,是V8第一次执行一段代码时,会编译源JavaScript代码,并将编译后的二进制代码缓存在内存中,我们把这种方式称为内存缓存(in-memory cache)。然后通过JavaScript源文件的字符串在内存中查找对应的编译后的二进制代码。这样当再次执行到这段代码时,V8就可以直接去内存中查找是否编译过这段代码。如果内存缓存中存在这段代码所对应的二进制代码,那么就直接执行编译好的二进制代码。
|
||||
- 其次,V8除了采用将代码缓存在内存中策略之外,还会将代码缓存到硬盘上,这样即便关闭了浏览器,下次重新打开浏览器再次执行相同代码时,也可以直接重复使用编译好的二进制代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/60/a6f2ea6df895eb6940a9db95f54fa360.jpg" alt="" title="二进制代码缓存">
|
||||
|
||||
实践表明,在浏览器中采用了二进制代码缓存的方式,初始加载时分析和编译的时间缩短了20%~40%。
|
||||
|
||||
## 字节码降低了内存占用
|
||||
|
||||
所以在早期,Chrome做了两件事来提升JavaScript代码的执行速度:
|
||||
|
||||
- 第一,将运行时将二进制机器代码缓存在内存中;
|
||||
- 第二,当浏览器退出时,缓存编译之后二进制代码到磁盘上。
|
||||
|
||||
很明显,采用缓存是一种典型的以空间换时间的策略,以牺牲存储空间来换取执行速度,我们知道Chrome的多进程架构已经非常吃内存了,而Chrome中每个页面进程都运行了一份V8实例,V8在执行JavaScript代码的过程中,会将JavaScript代码转换为未经优化的二进制代码,你可以对照下图中的JavaScript代码和二进制代码的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/cb/214d4c793543d08e16f86abd82a9accb.jpg" alt="">
|
||||
|
||||
从上图我们可以看出,二进制代码所占用的内存空间是JavaScript代码的几千倍,通常一个页面的JavaScript几M大小,转换为二进制代码就变成几十M了,如果是PC应用,多占用一些内存,也不会太影响性能,但是在移动设备流行起来之后,V8过度占用内存的问题就充分暴露出来了。因为通常一部手机的内存不会太大,如果过度占用内存,那么会导致Web应用的速度大大降低。
|
||||
|
||||
在上一节我们介绍过,V8团队为了提升V8的启动速度,采用了惰性编译,其实惰性编译除了能提升JavaScript启动速度,还可以解决部分内存占用的问题。你可以先参看下面的代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/58/a197b9a6f9136adf7724e8f528ca3158.jpg" alt="">
|
||||
|
||||
根据惰性编译的原则,当V8首次执行上面这段代码的过程中,开始只是编译最外层的代码,那些函数内部的代码,如下图中的黄色的部分,会推迟到第一次调用时再编译。
|
||||
|
||||
为了解决缓存的二进制机器代码占用过多内存的问题,早期的Chrome并没有缓存函数内部的二进制代码,只是缓存了顶层次的二进制代码,比如上图中红色的区域。
|
||||
|
||||
但是这种方式却存在很大的不确定性,比如我们多人开发的项目,通常喜欢将自己的代码封装成模块,在JavaScript中,由于没有块级作用域(ES6之前),所以我们习惯使用立即调用函数表达式(IIFEs),比如下面这样的代码:
|
||||
|
||||
- **test_module.js**
|
||||
|
||||
```
|
||||
var test_module = (function () {
|
||||
var count_
|
||||
function init_(){count_ = 0}
|
||||
function add_(){count_ = count_+1}
|
||||
function show_(){console.log(count_)}
|
||||
return {
|
||||
init: init_,
|
||||
add: add_,
|
||||
show:show_
|
||||
}
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
- **app.js**
|
||||
|
||||
```
|
||||
test_module.init()
|
||||
test_module.add()
|
||||
test_module.show()
|
||||
test_module.add()
|
||||
test_module.show()
|
||||
|
||||
```
|
||||
|
||||
上面就是典型的闭包代码,它将和模块相关的所有信息都封装在一个匿名立即执行函数表达式中,并将需要暴漏的接口数据返回给变量test_module。如果浏览器只缓存顶层代码,那么闭包模块中的代码将无法被缓存,而对于高度工程化的模块来说,这种模块式的处理方式到处都是,这就导致了一些关键代码没有办法被缓存。
|
||||
|
||||
所以采取只缓存顶层代码的方式是不完美的,没办法适应多种不同的情况,因此,V8团队对早期的V8架构进行了非常大的重构,具体地讲,抛弃之前的基线编译器和优化编译器,引入了字节码、解释器和新的优化编译器。
|
||||
|
||||
那么为什么通过引入字节码就能降低V8在执行时的内存占用呢?要解释这个问题,我们不妨看下面这张图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/4b/27d30dbb95e3bb3e55b9bc2a56e14d4b.jpg" alt="">
|
||||
|
||||
从图中可以看出,字节码虽然占用的空间比原始的JavaScript多,但是相较于机器代码,字节码还是小了太多。
|
||||
|
||||
有了字节码,无论是解释器的解释执行,还是优化编译器的编译执行,都可以直接针对字节来进行操作。由于字节码占用的空间远小于二进制代码,所以浏览器就可以实现缓存所有的字节码,而不是仅仅缓存顶层的字节码。
|
||||
|
||||
虽然采用字节码在执行速度上稍慢于机器代码,但是整体上权衡利弊,采用字节码也许是最优解。之所以说是最优解,是因为采用字节码除了降低内存之外,还提升了代码的启动速度,并降低了代码的复杂度,而牺牲的仅仅是一点执行效率。接下来我们继续来分析下,采用字节码是怎么提升代码启动速度和降低复杂度的。
|
||||
|
||||
## 字节码如何提升代码启动速度?
|
||||
|
||||
我们先看引入字节码是怎么提升代码启动速度的。下面是启动JavaScript代码的流程图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/5b/9e441845eb4af12642fe5385cdd1b05b.jpg" alt="">
|
||||
|
||||
从图中可以看出,生成机器代码比生成字节码需要花费更久的时间,但是直接执行机器代码却比解释执行字节码要更高效,所以在快速启动JavaScript代码与花费更多时间获得最优运行性能的代码之间,我们需要找到一个平衡点。
|
||||
|
||||
解释器可以快速生成字节码,但字节码通常效率不高。 相比之下,优化编译器虽然需要更长的时间进行处理,但最终会产生更高效的机器码,这正是 V8 在使用的模型。它的解释器叫 Ignition,(就原始字节码执行速度而言)是所有引擎中最快的解释器。V8 的优化编译器名为 TurboFan,最终由它生成高度优化的机器码。
|
||||
|
||||
## 字节码如何降低代码的复杂度?
|
||||
|
||||
早期的V8代码,无论是基线编译器还是优化编译器,它们都是基于AST抽象语法树来将代码转换为机器码的,我们知道,不同架构的机器码是不一样的,而市面上存在不同架构的处理器又是非常之多,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/e9/bc8ede549e0572689cadd6f2c21f31e9.jpg" alt="">
|
||||
|
||||
这意味着基线编译器和优化编译器要针对不同的体系的CPU编写不同的代码,这会大大增加代码量。
|
||||
|
||||
引入了字节码,就可以统一将字节码转换为不同平台的二进制代码,你可以对比下执行流程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/5d/0b207ca6b427bf6281dce67d4f96835d.jpg" alt="">
|
||||
|
||||
因为字节码的执行过程和CPU执行二进制代码的过程类似,相似的执行流程,那么将字节码转换为不同架构的二进制代码的工作量也会大大降低,这就降低了转换底层代码的工作量。
|
||||
|
||||
## 总结
|
||||
|
||||
这节课我们介绍了V8为什么要引入字节码。早期的V8为了提升代码的执行速度,直接将JavaScript源代码编译成了没有优化的二进制的机器代码,如果某一段二进制代码执行频率过高,那么V8会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高。
|
||||
|
||||
不过随着移动设备的普及,V8团队逐渐发现将JavaScript源码直接编译成二进制代码存在两个致命的问题:
|
||||
|
||||
- 时间问题:编译时间过久,影响代码启动速度;
|
||||
- 空间问题:缓存编译后的二进制代码占用更多的内存。
|
||||
|
||||
这两个问题无疑会阻碍V8在移动设备上的普及,于是V8团队大规模重构代码,引入了中间的字节码。字节码的优势有如下三点:
|
||||
|
||||
- 解决启动问题:生成字节码的时间很短;
|
||||
- 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
|
||||
- 代码架构清晰:采用字节码,可以简化程序的复杂度,使得V8移植到不同的CPU架构平台更加容易。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天留给你一个开放的思考题:你认为V8虚拟机中的机器代码和字节码有哪些异同?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
286
极客时间专栏/geek/图解 Google V8/V8编译流水线/14|字节码(二):解释器是如何解释执行字节码的?.md
Normal file
286
极客时间专栏/geek/图解 Google V8/V8编译流水线/14|字节码(二):解释器是如何解释执行字节码的?.md
Normal file
@@ -0,0 +1,286 @@
|
||||
<audio id="audio" title="14|字节码(二):解释器是如何解释执行字节码的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/78/018ece453e505462a059b8dfaf8fd278.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在上节我们介绍了V8为什么要引入字节码,这节课我们来聊聊解释器是如何解释执行字节码的。学习字节码如何被执行,可以让我们理解解释器的工作机制,同时还能帮助我们搞懂JavaScript运行时的内存结构,特别是闭包的结构和非闭包数据的区别。
|
||||
|
||||
字节码的解释执行在编译流水线中的位置你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/01/e4735f5bb848120b5fd931acae5eb101.jpg" alt="">
|
||||
|
||||
## 如何生成字节码?
|
||||
|
||||
我们知道当V8执行一段JavaScript代码时,会先对JavaScript代码进行解析(Parser),并生成为AST和作用域信息,之后AST和作用域信息被输入到一个称为Ignition 的解释器中,并将其转化为字节码,之后字节码再由Ignition解释器来解释执行。
|
||||
|
||||
接下来,我们就结合一段代码来看看执行解释器是怎么解释执行字节码的。你可以参看下面这段代码:
|
||||
|
||||
```
|
||||
function add(x, y) {
|
||||
var z = x+y
|
||||
return z
|
||||
}
|
||||
console.log(add(1, 2))
|
||||
|
||||
```
|
||||
|
||||
在控制台执行这段代码,会返回数字3,V8是如何得到这个结果的呢?
|
||||
|
||||
刚刚我们提到了,V8首先会将函数的源码解析为AST,这一步由解析器(Parser)完成,你可以在d8中通过–print-ast 命令来查看V8内部生成的AST。
|
||||
|
||||
```
|
||||
[generating bytecode for function: add]
|
||||
--- AST ---
|
||||
FUNC at 12
|
||||
. KIND 0
|
||||
. LITERAL ID 1
|
||||
. SUSPEND COUNT 0
|
||||
. NAME "add"
|
||||
. PARAMS
|
||||
. . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
|
||||
. . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
|
||||
. DECLS
|
||||
. . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
|
||||
. . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
|
||||
. . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
|
||||
. BLOCK NOCOMPLETIONS at -1
|
||||
. . EXPRESSION STATEMENT at 31
|
||||
. . . INIT at 31
|
||||
. . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
|
||||
. . . . ADD at 32
|
||||
. . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
|
||||
. . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
|
||||
. RETURN at 37
|
||||
. . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
|
||||
|
||||
```
|
||||
|
||||
同样,我们将其图形化:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/aa/94b31db22a69f95b2d211ccedbbfa6aa.jpg" alt="">
|
||||
|
||||
从图中可以看出,函数的字面量被解析为AST树的形态,这个函数主要拆分成四部分。
|
||||
|
||||
- 第一部分为参数的声明(PARAMS),参数声明中包括了所有的参数,在这里主要是参数x和参数y,你可以在函数体中使用arguments来使用对应的参数。
|
||||
- 第二部分是变量声明节点(DECLS),参数部分你可以使用arguments来调用,同样,你也可以将这些参数作为变量来直接使用,这体现在DECLS节点下面也出现了变量x和变量y,除了可以直接使用x和y之外,我们还有一个z变量也在DECLS节点下。你可以注意一下,在上面生成的AST数据中,参数声明节点中的x和变量声明节点中的x的地址是相同的,都是0x7fa7bf8048e8,同样y也是相同的,都是0x7fa7bf804990,这说明它们指向的是同一块数据。
|
||||
- 第三部分是x+y的表达式节点,我们可以看到,节点add下面使用了var proxy x和var proxy x的语法,它们指向了实际x和y的值。
|
||||
- 第四部分是RETURN节点,它指向了z的值,在这里是local[0]。
|
||||
|
||||
V8在生成AST的同时,还生成了add函数的作用域,你可以使用–print-scopes命令来查看:
|
||||
|
||||
```
|
||||
Global scope:
|
||||
function add (x, y) { // (0x7f9ed7849468) (12, 47)
|
||||
// will be compiled
|
||||
// 1 stack slots
|
||||
// local vars:
|
||||
VAR y; // (0x7f9ed7849790) parameter[1], never assigned
|
||||
VAR z; // (0x7f9ed7849838) local[0], never assigned
|
||||
VAR x; // (0x7f9ed78496e8) parameter[0], never assigned
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
作用域中的变量都是未使用的,默认值都是undefined,在执行阶段,作用域中的变量会指向堆和栈中相应的数据,作用域和实际数据的关系如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/c1/9ed15891d8145f59a20fa23cf33d5bc1.jpg" alt="">
|
||||
|
||||
在解析期间,所有函数体中声明的变量和函数参数,都被放进作用域中,如果是普通变量,那么默认值是undefined,如果是函数声明,那么将指向实际的函数对象。
|
||||
|
||||
一旦生成了作用域和AST,V8就可以依据它们来生成字节码了。AST之后会被作为输入传到字节码生成器(BytecodeGenerator),这是Ignition解释器中的一部分,用于生成以函数为单位的字节码。你可以通过–print-bytecode命令查看生成的字节码。
|
||||
|
||||
```
|
||||
[generated bytecode for function: add (0x079e0824fdc1 <SharedFunctionInfo add>)]
|
||||
Parameter count 3
|
||||
Register count 2
|
||||
Frame size 16
|
||||
0x79e0824ff7a @ 0 : a7 StackCheck
|
||||
0x79e0824ff7b @ 1 : 25 02 Ldar a1
|
||||
0x79e0824ff7d @ 3 : 34 03 00 Add a0, [0]
|
||||
0x79e0824ff80 @ 6 : 26 fb Star r0
|
||||
0x79e0824ff82 @ 8 : 0c 02 LdaSmi [2]
|
||||
0x79e0824ff84 @ 10 : 26 fa Star r1
|
||||
0x79e0824ff86 @ 12 : 25 fb Ldar r0
|
||||
0x79e0824ff88 @ 14 : ab Return
|
||||
Constant pool (size = 0)
|
||||
Handler Table (size = 0)
|
||||
Source Position Table (size = 0)
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,生成的字节码第一行提示了“Parameter count 3”,这是告诉我们这里有三个参数,包括了显式地传入了x 和 y,还有一个隐式地传入了this。下面是字节码的详细信息:
|
||||
|
||||
```
|
||||
StackCheck
|
||||
Ldar a1
|
||||
Add a0, [0]
|
||||
Star r0
|
||||
LdaSmi [2]
|
||||
Star r1
|
||||
Ldar r0
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
将JavaScript函数转换为字节码之后,我们看到只有8行,接下来我们的任务就是要分析这8行字节码是怎么工作的,理解了这8行字节码是怎么工作的,就可以学习其他字节码的工作方式了。
|
||||
|
||||
## 理解字节码:解释器的架构设计
|
||||
|
||||
通过上面的一段字节码我们可以看到,字节码似乎和汇编代码有点像,这些字节码看起来似乎难以理解,但实际上它们非常简单,每一行表示一个特定的功能,把这些功能拼凑在一起就构成完整的程序。
|
||||
|
||||
通俗地讲,你可以把这一行行字节码看成是一个个积木块,每个积木块块负责实现特定的功能,有实现运算的,有实现跳转的,有实现返回的,有实现内存读取的。一段JavaScript代码最终被V8还原成一个个积木块,将这些积木搭建在一起就实现了JavaScript的功能,现在我们大致了解了字节码就是一些基础的功能模块,接下来我们就来认识下这些构建块。
|
||||
|
||||
下图是一些常用的“积木块”,我们又称为字节码的指令集:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/80/d65e0df8275c2d351764a57f2b42a880.png" alt="" title="V8中定义的部分字节码指令集">
|
||||
|
||||
你也可以去[V8的源码中](https://github.com/v8/v8/blob/master/src/interpreter/bytecodes.h)查看这些字节码,V8字节码的指令非常多,如果要掌握所有指令的含义,需要花费一段时间的学习和实践,这节课我们不需要了解所有字节码的含义,但我们需要知道,怎样阅读字节码。
|
||||
|
||||
我们阅读汇编代码,需要先理解CPU的体系架构,然后再分析特定汇编指令的具体含义,同样,要了解怎么阅读字节码,我们就需要理解V8解释器的整体设计架构,然后再来分析特定的字节码指令的含义。接下来,我们就依次介绍这两部分内容。
|
||||
|
||||
因为解释器就是模拟物理机器来执行字节码的,比如可以实现如取指令、解析指令、执行指令、存储数据等,所以解释器的执行架构和CPU处理机器代码的架构类似(关于CPU是如何执行机器代码的,你可以参看《[10|机器代码:二进制机器码究竟是如何被CPU执行的?](https://time.geekbang.org/column/article/221211)》这节课)。
|
||||
|
||||
通常有两种类型的解释器,**基于栈(Stack-based)<strong>和**基于寄存器(Register-based)</strong>,基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等,基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果。
|
||||
|
||||
通常,基于栈的虚拟机也定义了少量的寄存器,基于寄存器的虚拟机也有堆栈,其区别体现在它们提供的指令集体系。
|
||||
|
||||
大多数解释器都是基于栈的,比如Java虚拟机,.Net虚拟机,还有早期的V8虚拟机。基于堆栈的虚拟机在处理函数调用、解决递归问题和切换上下文时简单明快。
|
||||
|
||||
而现在的V8虚拟机则采用了基于寄存器的设计,它将一些中间数据保存到寄存器中,了解这点对于我们分析字节码的执行过程非常重要。
|
||||
|
||||
接下来我们就来看看基于寄存器的解释器架构,具体你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/8f/471685cc7aa107fdd967c02467daf08f.jpg" alt="">
|
||||
|
||||
解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆。
|
||||
|
||||
这和我们介绍过的CPU执行二进制机器代码的模式是类似的:
|
||||
|
||||
- 使用内存中的一块区域来存放字节码;
|
||||
- 使用了通用寄存器 r0,r1,r2,…… 这些寄存器用来存放一些中间数据;
|
||||
- PC寄存器用来指向下一条要执行的字节码;
|
||||
- 栈顶寄存器用来指向当前的栈顶的位置。
|
||||
|
||||
但是我们需要重点注意这里的**累加器**,它是一个非常特殊的寄存器,用来保存中间的结果,这体现在很多V8字节码的语义上面,我们来看下面这个字节码的指令:
|
||||
|
||||
```
|
||||
Ldar a1
|
||||
|
||||
```
|
||||
|
||||
Ldar表示将寄存器中的值加载到累加器中,你可以把它理解为**LoaD Accumulator from Register**,就是把某个寄存器中的值,加载到累加器中。那么上面这个指令的意思就是把a1寄存器中的值,加载到累加器中,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/7f/383f390081d055a52eaaab00bc11657f.jpg" alt="">
|
||||
|
||||
我们再来看另外一个段字节码指令:
|
||||
|
||||
```
|
||||
Star r0
|
||||
|
||||
```
|
||||
|
||||
Star 表示 Store Accumulator Register, 你可以把它理解为Store Accumulator to Register,就是把累加器中的值保存到某个寄存器中,上面这段代码的意思就是将累加器中的数值保存到r0寄存器中,具体流程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/39/d2f74d6b9d7d683c5b10543cc5aa0139.jpg" alt="">
|
||||
|
||||
我们再来看一个执行加法的字节码:
|
||||
|
||||
```
|
||||
Add a0, [0]
|
||||
|
||||
```
|
||||
|
||||
Add a0, [0]是从a0寄存器加载值并将其与累加器中的值相加,然后将结果再次放入累加器,最终操作如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/35/ca75316e6fbf04267392a91f66aa9e35.jpg" alt="">
|
||||
|
||||
你可能会注意到,add a0 后面还跟了一个[0],这个符号是做什么的呢?
|
||||
|
||||
这个称之为feedback vector slot,中文我们可以称为反馈向量槽,它是一个数组,解释器将解释执行过程中的一些数据类型的分析信息都保存在这个反馈向量槽中了,目的是为了给TurboFan优化编译器提供优化信息,很多字节码都会为反馈向量槽提供运行时信息,这块内容我们会在下一节来介绍。
|
||||
|
||||
在上面的字节码中,还有一个:
|
||||
|
||||
```
|
||||
LdaSmi [2]
|
||||
|
||||
```
|
||||
|
||||
这是将小整数(Smi)2 加载到累加器寄存器中,操作流程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/45/232b4c97b686c06008ebf4b4cd0f1a45.jpg" alt="">
|
||||
|
||||
我们再来看一个字节码:
|
||||
|
||||
```
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
Return 结束当前函数的执行,并将控制权传回给调用方。返回的值是累加器中的值。
|
||||
|
||||
好了,上面我们分析了几个常见的字节码的含义,相信你已经发现了,大部分字节码都间接地使用了累加器,认识到累加器在字节码指令中的使用方式之后,再去认识V8中的字节码就会非常轻松了。
|
||||
|
||||
## 完整分析一段字节码
|
||||
|
||||
接下来,我们完整地分析一段字节码是怎么执行的:
|
||||
|
||||
```
|
||||
StackCheck
|
||||
Ldar a1
|
||||
Add a0, [0]
|
||||
Star r0
|
||||
LdaSmi [2]
|
||||
Star r1
|
||||
Ldar r0
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
执行这段代码时,整体的状态如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/56/b3a3e88341d762bb7467ca2941e4c356.jpg" alt="">
|
||||
|
||||
我们可以看到:
|
||||
|
||||
- 参数对象parameter保存在栈中,包含了a0和a1两个值,在上面的代码中,这两个值分别是1和2;
|
||||
- PC寄存器指向了第一个字节码StackCheck,我们知道,V8在执行一个函数之前,会判断栈是否会溢出,这里的StackCheck字节码指令就是检查栈是否达到了溢出的上限,如果栈增长超过某个阈值,我们将中止该函数的执行并抛出一个RangeError,表示栈已溢出。
|
||||
|
||||
然后继续执行下一条字节码,Ldar a1,这是将a1寄存器中的参数值加载到累加器中,这时候第一个参数就保存到累加器中了。
|
||||
|
||||
接下来执行加法操作,Add a0, [0],因为a0是第一个寄存器,存放了第一个参数,Add a0就是将第一个寄存器中的值和累加器中的值相加,也就是将累加器中的2和通用寄存器中a0中的1进行相加,同时将相加后的结果3保存到累加器中。
|
||||
|
||||
现在累加器中就保存了相加后的结果,然后执行第四段字节码,Star r0,这是将累加器中的值,也就是1+2的结果3保存到寄存器r0中,那么现在寄存器r0中的值就是3了。
|
||||
|
||||
然后将常数2加载到累加器中,又将累加器中的2加载到寄存器r1中,我们发现这里两段代码可能没实际的用途,不过V8生成的字节码就是这样。
|
||||
|
||||
接下来V8将寄存器r0中的值加载到累加器中,然后执行最后一句Return指令,Return指令会中断当前函数的执行,并将累加器中的值作为返回值。
|
||||
|
||||
这样V8就执行完成了add函数。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们先分析了V8是如何生成字节码的,有了字节码,V8的解释器就可以解释执行字节码了。通常有两种架构的解释器,基于栈的和基于寄存器的。基于栈的解释器会将一些中间数据存放到栈中,而基于寄存器的解释器会将一些中间数据存放到寄存器中。由于采用了不同的模式,所以字节码的指令形式是不同的。
|
||||
|
||||
而目前版本的V8是基于寄存器的,所以我们又重点分析了基于寄存器的解释器的架构,这些寄存器和CPU中的寄存器类似,不过这里有一个特别的寄存器,那就是累加器。在操作过程中,一些中间结果都默认放到累加器中,比如Ldar a1就是将第二个参数加载到累加器中,Star r0是将累加器中的值写入到r0寄存器中,Return就是返回累加器中的数值。
|
||||
|
||||
理解了累加器的重要性,我们又分析了一些常用字节码指令,这包括了Ldar、Star、Add、LdaSmi、Return,了解了这些指令是怎么工作的之后,我们就可以完整地分析一段字节码的工作流程了。
|
||||
|
||||
## 思考题
|
||||
|
||||
观察下面这段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var d = 20
|
||||
return function inner(a, b) {
|
||||
const c = a + b + d
|
||||
return c
|
||||
}
|
||||
}
|
||||
const f = foo()
|
||||
f(1,2)
|
||||
|
||||
```
|
||||
|
||||
请你课后利用d8生成字节码,然后分析字节码的执行流程,欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
315
极客时间专栏/geek/图解 Google V8/V8编译流水线/15 | 隐藏类:如何在内存中快速查找对象属性?.md
Normal file
315
极客时间专栏/geek/图解 Google V8/V8编译流水线/15 | 隐藏类:如何在内存中快速查找对象属性?.md
Normal file
@@ -0,0 +1,315 @@
|
||||
<audio id="audio" title="15 | 隐藏类:如何在内存中快速查找对象属性?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2f/49/2f8a8e9e76f408a7a87b75bcd514b749.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
我们知道JavaScript是一门动态语言,其执行效率要低于静态语言,V8为了提升JavaScript的执行速度,借鉴了很多静态语言的特性,比如实现了JIT机制,为了提升对象的属性访问速度而引入了隐藏类,为了加速运算而引入了内联缓存。
|
||||
|
||||
今天我们来重点分析下V8中的隐藏类,看看它是怎么提升访问对象属性值速度的。
|
||||
|
||||
## 为什么静态语言的效率更高?
|
||||
|
||||
由于隐藏类借鉴了部分静态语言的特性,因此要解释清楚这个问题,我们就先来分析下为什么静态语言比动态语言的执行效率更高。
|
||||
|
||||
我们通过下面两段代码,来对比一下动态语言和静态语言在运行时的一些特征,一段是动态语言的JavaScript,另外一段静态语言的C++的源码,具体源码你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/d7/205a2fa05c6aba57ade25f3a1df2bad7.jpg" alt="">
|
||||
|
||||
那么在运行时,这两段代码的执行过程有什么区别呢?
|
||||
|
||||
我们知道,JavaScript在运行时,对象的属性是可以被修改的,所以当V8使用了一个对象时,比如使用了 start.x的时候,它并不知道该对象中是否有x,也不知道x相对于对象的偏移量是多少,也可以说V8并不知道该对象的具体的形状。
|
||||
|
||||
那么,当在JavaScript中要查询对象start中的x属性时,V8会按照具体的规则一步一步来查询,这个过程非常的慢且耗时(具体查找过程你可以参考《[03|快属性和慢属性:V8是怎样提升对象属性访问速度的?](https://time.geekbang.org/column/article/213250)》这节课程中的内容)。
|
||||
|
||||
这种动态查询对象属性的方式和C++这种静态语言不同,C++在声明一个对象之前需要定义该对象的结构,我们也可以称为形状,比如Point结构体就是一种形状,我们可以使用这个形状来定义具体的对象。
|
||||
|
||||
C++代码在执行之前需要先被编译,编译的时候,每个对象的形状都是固定的,也就是说,在代码的执行过程中,Point的形状是无法被改变的。
|
||||
|
||||
那么在C++中访问一个对象的属性时,自然就知道该属性相对于该对象地址的偏移值了,比如在C++中使用start.x的时候,编译器会直接将x相对于start的地址写进汇编指令中,那么当使用了对象start中的x属性时,CPU就可以直接去内存地址中取出该内容即可,没有任何中间的查找环节。
|
||||
|
||||
因为静态语言中,可以直接通过偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因。
|
||||
|
||||
## 什么是隐藏类(Hidden Class)?
|
||||
|
||||
既然静态语言的查询效率这么高,那么是否能将这种静态的特性引入到V8中呢?
|
||||
|
||||
答案是**可行**的。
|
||||
|
||||
目前所采用的一个思路就是将JavaScript中的对象静态化,也就是V8在运行JavaScript的过程中,会假设JavaScript中的对象是静态的,具体地讲,V8对每个对象做如下两点假设:
|
||||
|
||||
- 对象创建好了之后就不会添加新的属性;
|
||||
- 对象创建好了之后也不会删除属性。
|
||||
|
||||
符合这两个假设之后,V8就可以对JavaScript中的对象做深度优化了,那么怎么优化呢?
|
||||
|
||||
具体地讲,V8会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:
|
||||
|
||||
- 对象中所包含的所有的属性;
|
||||
- 每个属性相对于对象的偏移量。
|
||||
|
||||
有了隐藏类之后,那么当V8访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8就可以直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了V8查找对象的效率。
|
||||
|
||||
我们可以结合一段代码来分析下隐藏类是怎么工作的:
|
||||
|
||||
```
|
||||
let point = {x:100,y:200}
|
||||
|
||||
```
|
||||
|
||||
当V8执行到这段代码时,会先为point对象创建一个隐藏类,在V8中,把隐藏类又称为**map**,每个对象都有一个map属性,其值指向内存中的隐藏类。
|
||||
|
||||
隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量,比如point对象的隐藏类就包括了x和y属性,x的偏移量是4,y的偏移量是8。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/6d/4eab311ab4ab94693325a0ca24618b6d.jpg" alt="">
|
||||
|
||||
注意,这是point对象的map,它不是point对象本身。关于point对象和map之间的关系,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/f8/51f5034a7f80e4e5684d5a301178c2f8.jpg" alt="">
|
||||
|
||||
在这张图中,左边的是point对象在内存中的布局,右边是point对象的map,我们可以看到,point对象的第一个属性就指向了它的map,关于如何通过浏览器查看对象的map,我们在《[03|快属性和慢属性:V8是怎样提升对象属性访问速度的?](https://time.geekbang.org/column/article/213250)》这节课也做过简单的分析,你可以回顾下这节内容。
|
||||
|
||||
有了map之后,当你再次使用point.x访问x属性时,V8会查询point的map中x属性相对point对象的偏移量,然后将point对象的起始位置加上偏移量,就得到了x属性的值在内存中的位置,有了这个位置也就拿到了x的值,这样我们就省去了一个比较复杂的查找过程。
|
||||
|
||||
这就是将动态语言静态化的一个操作,V8通过引入隐藏类,模拟C++这种静态语言的机制,从而达到静态语言的执行效率。
|
||||
|
||||
## 实践:通过d8查看隐藏类
|
||||
|
||||
了解了隐藏类的工作机制,我们可以使用d8提供的API DebugPrint来查看point对象中的隐藏类。
|
||||
|
||||
```
|
||||
let point = {x:100,y:200};
|
||||
%DebugPrint(point);
|
||||
|
||||
```
|
||||
|
||||
这里你需要注意,在使用d8内部API时,有一点很容易出错,就是需要为JavaScript代码加上分号,不然d8会报错,所以这段代码里面我都加上了分号。
|
||||
|
||||
然后将下面这段代码保存test.js文件中,再执行:
|
||||
|
||||
```
|
||||
d8 --allow-natives-syntax test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段命令,就可以打印出point对象的基础结构了,打印出来的结果如下所示:
|
||||
|
||||
```
|
||||
DebugPrint: 0x19dc080c5af5: [JS_OBJECT_TYPE]
|
||||
- map: 0x19dc08284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- prototype: 0x19dc08241151 <Object map = 0x19dc082801c1>
|
||||
- elements: 0x19dc080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
|
||||
- properties: 0x19dc080406e9 <FixedArray[0]> {
|
||||
#x: 100 (const data field 0)
|
||||
#y: 200 (const data field 1)
|
||||
}
|
||||
0x19dc08284d11: [Map]
|
||||
- type: JS_OBJECT_TYPE
|
||||
- instance size: 20
|
||||
- inobject properties: 2
|
||||
- elements kind: HOLEY_ELEMENTS
|
||||
- unused property fields: 0
|
||||
- enum length: invalid
|
||||
- stable_map
|
||||
- back pointer: 0x19dc08284ce9 <Map(HOLEY_ELEMENTS)>
|
||||
- prototype_validity cell: 0x19dc081c0451 <Cell value= 1>
|
||||
- instance descriptors (own) #2: 0x19dc080c5b25 <DescriptorArray[2]>
|
||||
- prototype: 0x19dc08241151 <Object map = 0x19dc082801c1>
|
||||
- constructor: 0x19dc0824116d <JSFunction Object (sfi = 0x19dc081c55ad)>
|
||||
- dependent code: 0x19dc080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
|
||||
- construction counter: 0
|
||||
|
||||
```
|
||||
|
||||
从这段point的内存结构中,我们可以看到,point对象的第一个属性就是map,它指向了0x19dc08284d11这个地址,这个地址就是V8为point对象创建的隐藏类,除了map属性之外,还有我们之前介绍过的prototype属性,elements属性和properties属性(关于这些属性的函数,你可以参看《[03|快属性和慢属性:V8是怎样提升对象属性访问速度的?](https://time.geekbang.org/column/article/213250)》和《[05|原型链:V8是如何实现对象继承的?](https://time.geekbang.org/column/article/215425)》这两节的内容)。
|
||||
|
||||
## 多个对象共用一个隐藏类
|
||||
|
||||
现在我们知道了在V8中,每个对象都有一个map属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8就会为其复用同一个隐藏类,这样有两个好处:
|
||||
|
||||
1. 减少隐藏类的创建次数,也间接加速了代码的执行速度;
|
||||
1. 减少了隐藏类的存储空间。
|
||||
|
||||
那么,什么情况下两个对象的形状是相同的,要满足以下两点:
|
||||
|
||||
- 相同的属性名称;
|
||||
- 相等的属性个数。
|
||||
|
||||
接下来我们就来创建两个形状一样的对象,然后看看它们的map属性是不是指向了同一个隐藏类,你可以参看下面的代码:
|
||||
|
||||
```
|
||||
let point = {x:100,y:200};
|
||||
let point2 = {x:3,y:4};
|
||||
%DebugPrint(point);
|
||||
%DebugPrint(point2);
|
||||
|
||||
```
|
||||
|
||||
当V8执行到这段代码时,首先会为point对象创建一个隐藏类,然后继续创建point2对象。在创建point2对象的过程中,发现它的形状和point是一样的。这时候,V8就会将point的隐藏类给point2复用,具体效果你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/78/9f0de55e75463406fbbff452dcef2178.jpg" alt="">
|
||||
|
||||
你也可以使用d8来证实下,同样使用这个命令:
|
||||
|
||||
```
|
||||
d8 --allow-natives-syntax test.js
|
||||
|
||||
```
|
||||
|
||||
打印出来的point和point2对象,你会发现它们的map属性都指向了同一个地址,这也就意味着它们共用了同一个map。
|
||||
|
||||
## 重新构建隐藏类
|
||||
|
||||
关于隐藏类,还有一个问题你需要注意一下。在这节课开头我们提到了,V8为了实现隐藏类,需要两个假设条件:
|
||||
|
||||
- 对象创建好了之后就不会添加新的属性;
|
||||
- 对象创建好了之后也不会删除属性。
|
||||
|
||||
但是,JavaScript依然是动态语言,在执行过程中,对象的形状是可以被改变的,如果某个对象的形状改变了,隐藏类也会随着改变,这意味着V8要为新改变的对象重新构建新的隐藏类,这对于V8的执行效率来说,是一笔大的开销。
|
||||
|
||||
通俗地理解,给一个对象添加新的属性,删除新的属性,或者改变某个属性的数据类型都会改变这个对象的形状,那么势必也就会触发V8为改变形状后的对象重建新的隐藏类。
|
||||
|
||||
我们可以看一个简单的例子:
|
||||
|
||||
```
|
||||
let point = {};
|
||||
%DebugPrint(point);
|
||||
point.x = 100;
|
||||
%DebugPrint(point);
|
||||
point.y = 200;
|
||||
%DebugPrint(point);
|
||||
|
||||
```
|
||||
|
||||
将这段代码保存到test.js文件中,然后执行:
|
||||
|
||||
```
|
||||
d8 --allow-natives-syntax test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段命令,d8会打印出来不同阶段的point对象所指向的隐藏类,在这里我们只关心point对象map的指向,所以我将其他的一些信息都省略了,最终打印出来的结果如下所示:
|
||||
|
||||
```
|
||||
DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
|
||||
- map: 0x0986082802d9 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- ...
|
||||
|
||||
|
||||
DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
|
||||
- map: 0x098608284ce9 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- ...
|
||||
- properties: 0x0986080406e9 <FixedArray[0]> {
|
||||
#x: 100 (const data field 0)
|
||||
}
|
||||
|
||||
|
||||
DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
|
||||
- map: 0x098608284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- p
|
||||
- ...
|
||||
- properties: 0x0986080406e9 <FixedArray[0]> {
|
||||
#x: 100 (const data field 0)
|
||||
#y: 200 (const data field 1)
|
||||
|
||||
```
|
||||
|
||||
根据这个打印出来的结果,我们可以明显看到,每次给对象添加了一个新属性之后,该对象的隐藏类的地址都会改变,这也就意味着隐藏类也随着改变了,改变过程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/11/84048c09badc17ef896023ec30f45111.jpg" alt="">
|
||||
|
||||
同样,如果你删除了对象的某个属性,那么对象的形状也就随着发生了改变,这时V8也会重建该对象的隐藏类,我们可以看下面这样的一个例子:
|
||||
|
||||
```
|
||||
let point = {x:100,y:200};
|
||||
delete point.x
|
||||
|
||||
```
|
||||
|
||||
我们再次使用d8来打印这段代码中不同阶段的point对象属性,移除多余的信息,最终打印出来的结果如下所示
|
||||
|
||||
```
|
||||
DebugPrint: 0x1c2f080c5b1d: [JS_OBJECT_TYPE]
|
||||
- map: 0x1c2f08284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
-...
|
||||
- properties: 0x1c2f080406e9 <FixedArray[0]> {
|
||||
#x: 100 (const data field 0)
|
||||
#y: 200 (const data field 1)
|
||||
}
|
||||
|
||||
|
||||
DebugPrint: 0x1c2f080c5b1d: [JS_OBJECT_TYPE]
|
||||
- map: 0x1c2f08284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- ...
|
||||
- properties: 0x1c2f08045567 <FixedArray[0]> {
|
||||
#y: 200 (const data field 1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
好了,现在我们知道了V8会为每个对象分配一个隐藏类,在执行过程中:
|
||||
|
||||
- 如果对象的形状没有发生改变,那么该对象就会一直使用该隐藏类;
|
||||
- 如果对象的形状发生了改变,那么V8会重建一个新的隐藏类给该对象。
|
||||
|
||||
我们当然希望对象中的隐藏类不要随便被改变,因为这样会触发V8重构该对象的隐藏类,直接影响到了程序的执行性能。那么在实际工作中,我们应该尽量注意以下几点:
|
||||
|
||||
**一,使用字面量初始化对象时,要保证属性的顺序是一致的。**比如先通过字面量x、y的顺序创建了一个point对象,然后通过字面量y、x的顺序创建一个对象point2,代码如下所示:
|
||||
|
||||
```
|
||||
let point = {x:100,y:200};
|
||||
let point2 = {y:100,x:200};
|
||||
|
||||
```
|
||||
|
||||
虽然创建时的对象属性一样,但是它们初始化的顺序不一样,这也会导致形状不同,所以它们会有不同的隐藏类,所以我们要尽量避免这种情况。
|
||||
|
||||
**二,尽量使用字面量一次性初始化完整对象属性。**因为每次为对象添加一个属性时,V8都会为该对象重新设置隐藏类。
|
||||
|
||||
**三,尽量避免使用delete方法。**delete方法会破坏对象的形状,同样会导致V8为该对象重新生成新的隐藏类。
|
||||
|
||||
## 总结
|
||||
|
||||
这节课我们介绍了V8中隐藏类的工作机制,我们先分析了V8引入隐藏类的动机。因为JavaScript是一门动态语言,对象属性在执行过程中是可以被修改的,这就导致了在运行时,V8无法知道对象的完整形状,那么当查找对象中的属性时,V8就需要经过一系列复杂的步骤才能获取到对象属性。
|
||||
|
||||
为了加速查找对象属性的速度,V8在背后为每个对象提供了一个隐藏类,隐藏类描述了该对象的具体形状。有了隐藏类,V8就可以根据隐藏类中描述的偏移地址获取对应的属性值,这样就省去了复杂的查找流程。
|
||||
|
||||
不过隐藏类是建立在两个假设基础之上的:
|
||||
|
||||
- 对象创建好了之后就不会添加新的属性;
|
||||
- 对象创建好了之后也不会删除属性。
|
||||
|
||||
一旦对象的形状发生了改变,这意味着V8需要为对象重建新的隐藏类,这就会带来效率问题。为了避免一些不必要的性能问题,我们在程序中尽量不要随意改变对象的形状。我在这节课中也给你列举了几个最佳实践的策略。
|
||||
|
||||
最后,关于隐藏类,我们记住以下几点。
|
||||
|
||||
- 在V8中,每个对象都有一个隐藏类,隐藏类在V8中又被称为map。
|
||||
- 在V8中,每个对象的第一个属性的指针都指向其map地址。
|
||||
- map描述了其对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少?
|
||||
- 如果添加新的属性,那么需要重新构建隐藏类。
|
||||
- 如果删除了对象中的某个属性,同样也需要构建隐藏类。
|
||||
|
||||
## 思考题
|
||||
|
||||
现在我们知道了V8为每个对象配置了一个隐藏类,隐藏类描述了该对象的形状,V8可以通过隐藏类快速获取对象的属性值。不过这里还有另外一类问题需要考虑。
|
||||
|
||||
比如我定义了一个获取对象属性值的函数loadX,loadX有一个参数,然后返回该参数的x属性值:
|
||||
|
||||
```
|
||||
function loadX(o) {
|
||||
return o.x
|
||||
}
|
||||
var o = { x: 1,y:3}
|
||||
var o1 = { x: 3 ,y:6}
|
||||
for (var i = 0; i < 90000; i++) {
|
||||
loadX(o)
|
||||
loadX(o1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当V8调用loadX的时候,会先查找参数o的隐藏类,然后利用隐藏类中的x属性的偏移量查找到x的属性值,虽然利用隐藏类能够快速提升对象属性的查找速度,但是依然有一个查找隐藏类和查找隐藏类中的偏移量两个操作,如果loadX在代码中会被重复执行,依然影响到了属性的查找效率。
|
||||
|
||||
那么留给你的问题是:如果你是V8的设计者,你会采用什么措施来提高loadX函数的执行效率?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,238 @@
|
||||
<audio id="audio" title="16 | 答疑: V8是怎么通过内联缓存来提升函数执行效率的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/a0/8c152cd55281ca40e5b31fa62aa8fda0.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
上节我们留了个思考题,提到了一段代码是这样的:
|
||||
|
||||
```
|
||||
function loadX(o) {
|
||||
return o.x
|
||||
}
|
||||
var o = { x: 1,y:3}
|
||||
var o1 = { x: 3 ,y:6}
|
||||
for (var i = 0; i < 90000; i++) {
|
||||
loadX(o)
|
||||
loadX(o1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们定义了一个loadX函数,它有一个参数o,该函数只是返回了o.x。
|
||||
|
||||
通常V8获取o.x的流程是这样的:**查找对象o的隐藏类,再通过隐藏类查找x属性偏移量,然后根据偏移量获取属性值**,在这段代码中loadX函数会被反复执行,那么获取o.x流程也需要反复被执行。我们有没有办法再度简化这个查找过程,最好能一步到位查找到x的属性值呢?答案是,有的。
|
||||
|
||||
其实这是一个关于内联缓存的思考题。我们可以看到,函数loadX在一个for循环里面被重复执行了很多次,因此V8会想尽一切办法来压缩这个查找过程,以提升对象的查找效率。这个加速函数执行的策略就是**内联缓存(Inline Cache)**,简称为**IC。**
|
||||
|
||||
这节课我们就来解答下,V8是怎么通过IC,来加速函数loadX的执行效率的。
|
||||
|
||||
## 什么是内联缓存?
|
||||
|
||||
要回答这个问题,我们需要知道IC的工作原理。其实IC的原理很简单,直观地理解,就是在V8执行函数的过程中,会观察函数中一些**调用点(CallSite)上的关键的中间数据**,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此V8利用IC,可以有效提升一些重复代码的执行效率。
|
||||
|
||||
接下来,我们就深入分析一下这个过程。
|
||||
|
||||
IC会为每个函数维护一个**反馈向量(FeedBack Vector)**,反馈向量记录了函数在执行过程中的一些关键的中间数据。关于函数和反馈向量的关系你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0f/d3/0f49d225b1ed71aaccd3cca2d1226dd3.jpg" alt="">
|
||||
|
||||
反馈向量其实就是一个表结构,它由很多项组成的,每一项称为一个**插槽(Slot)**,V8会依次将执行loadX函数的中间数据写入到反馈向量的插槽中。
|
||||
|
||||
比如下面这段函数:
|
||||
|
||||
```
|
||||
function loadX(o) {
|
||||
o.y = 4
|
||||
return o.x
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当V8执行这段函数的时候,它会判断 o.y = 4和 return o.x这两段是**调用点(CallSite)**,因为它们使用了对象和属性,那么V8会在loadX函数的反馈向量中为每个调用点分配一个插槽。
|
||||
|
||||
每个插槽中包括了插槽的索引(slot index)、插槽的类型(type)、插槽的状态(state)、隐藏类(map)的地址、还有属性的偏移量,比如上面这个函数中的两个调用点都使用了对象o,那么反馈向量两个插槽中的map属性也都是指向同一个隐藏类的,因此这两个插槽的map地址是一样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/49/609490b948c4a085e8f992de08a44549.jpg" alt="">
|
||||
|
||||
了解了反馈向量的大致结构,我们再来看下当V8执行loadX函数时,loadX函数中的关键数据是如何被写入到反馈向量中。
|
||||
|
||||
loadX的代码如下所示:
|
||||
|
||||
```
|
||||
function loadX(o) {
|
||||
return o.x
|
||||
}
|
||||
loadX({x:1})
|
||||
|
||||
```
|
||||
|
||||
我们将loadX转换为字节码:
|
||||
|
||||
```
|
||||
StackCheck
|
||||
LdaNamedProperty a0, [0], [0]
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
loadX函数的这段字节码很简单,就三句:
|
||||
|
||||
- 第一句是检查栈是否溢出;
|
||||
- 第二句是LdaNamedProperty,它的作用是取出参数a0的第一个属性值,并将属性值放到累加器中;
|
||||
- 第三句是返回累加器中的属性值。
|
||||
|
||||
这里我们重点关注LdaNamedProperty这句字节码,我们看到它有三个参数。a0就是loadX的第一个参数;第二个参数[0]表示取出对象a0的第一个属性值,这两个参数很好理解。第三个参数就和反馈向量有关了,它表示将LdaNamedProperty操作的中间数据写入到反馈向量中,方括号中间的0表示写入反馈向量的第一个插槽中。具体你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/64/a170f18653cea4b02bc9afb96b9f3764.jpg" alt="">
|
||||
|
||||
观察上图,我们可以看出,在函数loadX的反馈向量中,已经缓存了数据:
|
||||
|
||||
- 在map栏,缓存了o的隐藏类的地址;
|
||||
- 在offset一栏,缓存了属性x的偏移量;
|
||||
- 在type一栏,缓存了操作类型,这里是LOAD类型。在反馈向量中,我们把这种通过o.x来访问对象属性值的操作称为LOAD类型。
|
||||
|
||||
V8除了缓存o.x这种LOAD类型的操作以外,还会缓存**存储(STORE)类型**和**函数调用(CALL)类型**的中间数据。
|
||||
|
||||
为了分析后面两种存储形式,我们再来看下面这段代码:
|
||||
|
||||
```
|
||||
function foo(){}
|
||||
function loadX(o) {
|
||||
o.y = 4
|
||||
foo()
|
||||
return o.x
|
||||
}
|
||||
loadX({x:1,y:4})
|
||||
|
||||
```
|
||||
|
||||
相应的字节码如下所示:
|
||||
|
||||
```
|
||||
StackCheck
|
||||
LdaSmi [4]
|
||||
StaNamedProperty a0, [0], [0]
|
||||
LdaGlobal [1], [2]
|
||||
Star r0
|
||||
CallUndefinedReceiver0 r0, [4]
|
||||
LdaNamedProperty a0, [2], [6]
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
下图是我画的这段字节码的执行流程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/b4/ab7b91aea94d35ff2e6023aef05b56b4.jpg" alt="">
|
||||
|
||||
从图中可以看出,`o.y = 4` 对应的字节码是:
|
||||
|
||||
```
|
||||
LdaSmi [4]
|
||||
StaNamedProperty a0, [0], [0]
|
||||
|
||||
```
|
||||
|
||||
这段代码是先使用LdaSmi [4],将常数4加载到累加器中,然后通过StaNamedProperty的字节码指令,将累加器中的4赋给o.y,这是一个**存储(STORE)类型**的操作,V8会将操作的中间结果存放到反馈向量中的第一个插槽中。
|
||||
|
||||
调用foo函数的字节码是:
|
||||
|
||||
```
|
||||
LdaGlobal [1], [2]
|
||||
Star r0
|
||||
CallUndefinedReceiver0 r0, [4]
|
||||
|
||||
```
|
||||
|
||||
解释器首先加载foo函数对象的地址到累加器中,这是通过LdaGlobal来完成的,然后V8会将加载的中间结果存放到反馈向量的第3个插槽中,这是一个存储类型的操作。接下来执行CallUndefinedReceiver0,来实现foo函数的调用,并将执行的中间结果放到反馈向量的第5个插槽中,这是一个**调用(CALL)类型**的操作。
|
||||
|
||||
最后就是返回o.x,return o.x仅仅是加载对象中的x属性,所以这是一个**加载(LOAD)类型**的操作,我们在上面介绍过的。最终生成的反馈向量如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/cb/ba826723b58509527fd2f316214092cb.jpg" alt="">
|
||||
|
||||
现在有了反馈向量缓存的数据,那V8是如何利用这些数据的呢?
|
||||
|
||||
当V8再次调用loadX函数时,比如执行到loadX函数中的return o.x语句时,它就会在对应的插槽中查找x属性的偏移量,之后V8就能直接去内存中获取o.x的属性值了。这样就大大提升了V8的执行效率。
|
||||
|
||||
## 多态和超态
|
||||
|
||||
好了,通过缓存执行过程中的基础信息,就能够提升下次执行函数时的效率,但是这有一个前提,那就是多次执行时,对象的形状是固定的,如果对象的形状不是固定的,那V8会怎么处理呢?
|
||||
|
||||
我们调整一下上面这段loadX函数的代码,调整后的代码如下所示:
|
||||
|
||||
```
|
||||
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和o1的形状是不同的,这意味着V8为它们创建的隐藏类也是不同的。
|
||||
|
||||
第一次执行时loadX时,V8会将o的隐藏类记录在反馈向量中,并记录属性x的偏移量。那么当再次调用loadX函数时,V8会取出反馈向量中记录的隐藏类,并和新的o1的隐藏类进行比较,发现不是一个隐藏类,那么此时V8就无法使用反馈向量中记录的偏移量信息了。
|
||||
|
||||
面对这种情况,V8会选择将新的隐藏类也记录在反馈向量中,同时记录属性值的偏移量,这时,反馈向量中的第一个槽里就包含了两个隐藏类和偏移量。具体你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/b6/63f3caf97413881481bc6a86cdf065b6.jpg" alt="">
|
||||
|
||||
当V8再次执行loadX函数中的o.x语句时,同样会查找反馈向量表,发现第一个槽中记录了两个隐藏类。这时,V8需要额外做一件事,那就是拿这个新的隐藏类和第一个插槽中的两个隐藏类来一一比较,如果新的隐藏类和第一个插槽中某个隐藏类相同,那么就使用该命中的隐藏类的偏移量。如果没有相同的呢?同样将新的信息添加到反馈向量的第一个插槽中。
|
||||
|
||||
现在我们知道了,一个反馈向量的一个插槽中可以包含多个隐藏类的信息,那么:
|
||||
|
||||
- 如果一个插槽中只包含1个隐藏类,那么我们称这种状态为**单态(monomorphic);**
|
||||
- 如果一个插槽中包含了2~4个隐藏类,那我们称这种状态为**多态(polymorphic);**
|
||||
- 如果一个插槽中超过4个隐藏类,那我们称这种状态为**超态(magamorphic)。**
|
||||
|
||||
如果函数loadX的反馈向量中存在多态或者超态的情况,其执行效率肯定要低于单态的,比如当执行到o.x的时候,V8会查询反馈向量的第一个插槽,发现里面有多个map的记录,那么V8就需要取出o的隐藏类,来和插槽中记录的隐藏类一一比较,如果记录的隐藏类越多,那么比较的次数也就越多,这就意味着执行效率越低。
|
||||
|
||||
比如插槽中包含了2~4个隐藏类,那么可以使用线性结构来存储,如果超过4个,那么V8会采取hash表的结构来存储,这无疑会拖慢执行效率。单态、多态、超态等三种情况的执行性能如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/dd/900adb91196e4be3ad5388a15069d2dd.jpg" alt="">
|
||||
|
||||
## 尽量保持单态
|
||||
|
||||
这就是IC的一些基础情况,非常简单,只是为每个函数添加了一个缓存,当第一次执行该函数时,V8会将函数中的存储、加载和调用相关的中间结果保存到反馈向量中。当再次执行时,V8就要去反馈向量中查找相关中间信息,如果命中了,那么就直接使用中间信息。
|
||||
|
||||
了解了IC的基础执行原理,我们就能理解一些最佳实践背后的道理,这样你并不需要去刻意记住这些最佳实践了,因为你已经从内部理解了它。
|
||||
|
||||
总的来说,我们只需要记住一条就足够了,那就是**单态的性能优于多态和超态,**所以我们需要稍微避免多态和超态的情况。
|
||||
|
||||
要避免多态和超态,那么就尽量默认所有的对象属性是不变的,比如你写了一个loadX(o)的函数,那么当传递参数时,尽量不要使用多个不同形状的o对象。
|
||||
|
||||
## 总结
|
||||
|
||||
这节课我们通过分析IC的工作原理,来介绍了它是如何提升代码执行速度的。
|
||||
|
||||
虽然隐藏类能够加速查找对象的速度,但是在V8查找对象属性值的过程中,依然有查找对象的隐藏类和根据隐藏类来查找对象属性值的过程。
|
||||
|
||||
如果一个函数中利用了对象的属性,并且这个函数会被多次执行,那么V8就会考虑,怎么将这个查找过程再度简化,最好能将属性的查找过程能一步到位。
|
||||
|
||||
因此,V8引入了IC,IC会监听每个函数的执行过程,并在一些关键的地方埋下监听点,这些包括了加载对象属性(Load)、给对象属性赋值(Store)、还有函数调用(Call),V8会将监听到的数据写入一个称为**反馈向量(FeedBack Vector)**的结构中,同时V8会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8就可以缩短对象属性的查找路径,从而提升执行效率。
|
||||
|
||||
但是针对函数中的同一段代码,如果对象的隐藏类是不同的,那么反馈向量也会记录这些不同的隐藏类,这就出现了多态和超态的情况。我们在实际项目中,要尽量避免出现多态或者超态的情况。
|
||||
|
||||
最后我还想强调一点,虽然我们分析的隐藏类和IC能提升代码的执行速度,但是在实际的项目中,影响执行性能的因素非常多,**找出那些影响性能瓶颈才是至关重要**的,**你不需要过度关注微优化,你也不需要过度担忧你的代码是否破坏了隐藏类或者IC的机制**,因为相对于其他的性能瓶颈,它们对效率的影响可能是微不足道的。
|
||||
|
||||
## 思考题
|
||||
|
||||
观察下面两段代码:
|
||||
|
||||
```
|
||||
let data = [1, 2, 3, 4]
|
||||
data.forEach((item) => console.log(item.toString())
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
let data = ['1', 2, '3', 4]
|
||||
data.forEach((item) => console.log(item.toString())
|
||||
|
||||
```
|
||||
|
||||
你认为这两段代码,哪段的执行效率高,为什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
148
极客时间专栏/geek/图解 Google V8/事件循环和垃圾回收/17 | 消息队列:V8是怎么实现回调函数的?.md
Normal file
148
极客时间专栏/geek/图解 Google V8/事件循环和垃圾回收/17 | 消息队列:V8是怎么实现回调函数的?.md
Normal file
@@ -0,0 +1,148 @@
|
||||
<audio id="audio" title="17 | 消息队列:V8是怎么实现回调函数的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f5/b1/f50b4a477b5853ab436f287c0004b5b1.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
我们在使用JavaScript时,经常要用到大量的回调函数,比如在浏览器中可以使用setTimeout来设置定时器,使用XMLHTTPRequest来异步下载资源文件,在Node中可以使用readFile来读取文件,这些操作都有一个共同的特点,那就是需要给调用API传入回调函数,然后浏览器或者Node会将执行处理的结果通过回调函数来触发。
|
||||
|
||||
从内部了解回调函数,可以帮助我们梳理清楚很多问题:
|
||||
|
||||
- 有助于我们理解浏览器中的Web API到底是怎么工作的;
|
||||
- 有助于我们理解宏任务和微任务到底有哪些区别;
|
||||
- 理解回调函数,是理解异步编程模型async/await的基础。
|
||||
|
||||
这些内容在我们实际的项目中都会频繁使用到,所以理解V8是怎么实现回调函数的就显得至关重要了。
|
||||
|
||||
## 什么是回调函数?
|
||||
|
||||
那究竟什么是回调函数呢?其实回调函数也是个函数,就像白马也是马一样。它具有函数的所有特征,它可以有参数和返回值。如果单独给出一个函数,你是看不出来它是不是回调函数的。回调函数区别于普通函数,在于它的调用方式。只有当某个函数被作为参数,传递给另外一个函数,或者传递给宿主环境,然后该函数在函数内部或者在宿主环境中被调用,我们才称为回调函数。
|
||||
|
||||
具体地讲,回调函数有两种不同的形式,同步回调和异步回调。通常,我们需要将回调函数传入给另外一个执行函数,那么同步回调和异步回调的最大区别在于**同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。**
|
||||
|
||||
我们先看一个同步回调的例子,你可以先看下面这段代码:
|
||||
|
||||
```
|
||||
var myArray = ["water", "goods", "123", "like"];
|
||||
function handlerArray(indexName,index){
|
||||
console.log(index + 1 + ". " + indexName);
|
||||
}
|
||||
myArray.forEach(handlerArray)
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我们通过JavaScript自带的forEach方法来枚举数字中的每个项,这里的逻辑很简单:
|
||||
|
||||
- 调用forEach时,需要使用回调函数handlerArray作为其参数;
|
||||
- 在forEach方法内部,会遍历myArray数组,每遍历一次都会调用一次回调函数handlerArray。
|
||||
|
||||
因为handlerArray是forEach的参数,而且handlerArray是在forEach函数内部执行,所以这是一个同步回调。
|
||||
|
||||
和同步回调函数不同的是,异步回调函数并不是在它的执行函数内部被执行的,而是在其他的位置和其他的时间点被执行的,比如下面这段setTimeout代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
alert("Hello");
|
||||
}
|
||||
setTimeout(foo, 3000)
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我们使用了setTimeout函数,setTimeout的第一个参数foo就是一个回调函数,V8执行setTimeout时,会立即返回,等待3000毫秒之后,foo函数才会被V8调用,foo函数并不是在setTimeout函数内部被执行的,所以这是一个异步回调。
|
||||
|
||||
对于同步回调函数的执行时机,我们理解起来比较简单,就是回调函数在执行函数内部被执行,那么异步回调函数在什么时机和什么位置被调用的呢?
|
||||
|
||||
要解释清楚这个问题,我们就需要了解V8在运行时的线程模型,因为这涉及到了消息队列,事件循环等概念,这些概念都和线程模型是直接相关的,所以接下来我们就先来分析下V8的线程架构模型。
|
||||
|
||||
## UI线程的宏观架构
|
||||
|
||||
早期浏览器的页面是运行在一个单独的UI线程中的,所以要在页面中引入JavaScript,那么JavaScript也必须要运行在和页面相同的线程上,这样才能方便使用JavaScript来操纵DOM,所以从一开始,JavaScript就被设计成了运行在UI线程中。
|
||||
|
||||
所谓UI线程,是指运行窗口的线程,当你运行一个窗口时,无论该页面是Windows上的窗口系统,还是Android或者iOS上的窗口系统,它们都需要处理各种事件,诸如有触发绘制页面的事件,有鼠标点击、拖拽、放大缩小的事件,有资源下载、文件读写的事件,等等。
|
||||
|
||||
在页面线程中,当一个事件被触发时,比如用户使用鼠标点击了页面,系统需要将该事件提交给UI线程来处理。
|
||||
|
||||
在大部分情况下,UI线程并不能立即响应和处理这些事件,比如在你在移动鼠标的过程中,每移动一个像素都会产生一个事件,所以鼠标移动的事件会频繁地被触发。在这种情况下,页面线程可能正在处理前一个事件,那么最新的事件就无法被立即执行。
|
||||
|
||||
针对这种情况,我们为UI线程提供一个消息队列,并将这些待执行的事件添加到消息队列中,然后UI线程会不断循环地从消息队列中取出事件、执行事件。**我们把UI线程每次从消息队列中取出事件,执行事件的过程称为一个任务。**整个流程大致如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b5/e1/b5c6a4cd613d262047a4339adb4eb8e1.jpg" alt="" title="通用UI线程架构">
|
||||
|
||||
我们可以用一段JavaScript代码来模拟下这个过程:
|
||||
|
||||
```
|
||||
function UIMainThread() {
|
||||
while (queue.waitForMessage()) {
|
||||
Task task = queue.getNext()
|
||||
processNextMessage(task)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,queue是消息队列,queue.waitForMessage()会同步地等待消息队列中的消息到达,如果当前没有任何消息等待被处理,则这个函数会将UI线程挂起。如果消息队列中有消息,则使用queue.getNext()取出下一个要执行的消息,并交由processNextMessage函数来处理消息。
|
||||
|
||||
这就是通用的UI线程的结构,有消息队列,通过鼠标、键盘、触控板等产生的消息都会被添加进消息队列,主线程会循环地从消息队列中取出消息并执行。
|
||||
|
||||
## 异步回调函数的调用时机
|
||||
|
||||
理解了UI线程的基础架构模型,下面我们就可以来解释下异步函数的执行时机了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/44/874d4d0f49645dc211899b224f146644.jpg" alt="">
|
||||
|
||||
比如在页面主线程中正在执行A任务,在执行A任务的过程中调用setTimeout(foo, 3000),在执行setTimeout函数的过程中,宿主就会将foo函数封装成一个事件,并添加到消息队列中,然后setTimeout函数执行结束。
|
||||
|
||||
主线程会不间断地从消息队列中取出新的任务,执行新的任务,等到时机合适,便取出setTimeout设置的foo函数的回调的任务,然后就可以直接执行foo函数的调用了。
|
||||
|
||||
通过分析,相信你已经发现了,通过setTimeout的执行流程其实是比较简单的,在setTimeout函数内部封装回调消息,并将回调消息添加进消息队列,然后主线程从消息队列中取出回调事件,并执行。
|
||||
|
||||
还有一类比较复杂一点的流程,最典型的是通过XMLHttpRequest所触发的回调,它和setTimeout有一些区别。
|
||||
|
||||
因为XMLHttpRequest是用来下载网络资源的,但是实际的下载过程却并不适合在主线程上执行,因为下载任务会消耗比较久的时间,如果在UI线程上执行,那么会阻塞UI线程,这就会拖慢UI界面的交互和绘制的效果。所以当主线程从消息队列中取出来了这类下载任务之后,会将其分配给网络线程,让其在网络线程上执行下载过程,这样就不会影响到主线程的执行了。
|
||||
|
||||
那么下面我们就来分析下XMLHttpRequest是怎么触发回调函数的?具体流程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/42/942abef74c09cb43c0ffc94d0e836142.jpg" alt="" title="处理下载事件">
|
||||
|
||||
结合上图,我们就可以来分析下通用的UI线程是如何处理下载事件的,大致可以分为以下几步:
|
||||
|
||||
1. UI线程会从消息队列中取出一个任务,并分析该任务。
|
||||
1. 分析过程中发现该任务是一个下载请求,那么主线程就会将该任务交给网络线程去执行。
|
||||
1. 网络线程接到请求之后,便会和服务器端建立连接,并发出下载请求;
|
||||
1. 网络线程不断地收到服务器端传过来的数据;
|
||||
1. 网络线程每次接收到数据时,都会将设置的回调函数和返回的数据信息,如大小、返回了多少字节、返回的数据在内存中存放的位置等信息封装成一个新的事件,并将该事件放到消息队列中;
|
||||
1. UI线程继续循环地读取消息队列中的事件,如果是下载状态的事件,那么UI线程会执行回调函数,程序员便可以在回调函数内部编写更新下载进度的状态的代码;
|
||||
1. 直到最后接收到下载结束事件,UI线程会显示该页面下载完成。
|
||||
|
||||
这就是XMLHttpRequest所触发的回调流程,除了下载以外,JavaScript中获取系统设备信息、文件读取等都是采用了类似的方式来实现的,因此,理解了XMLHttpRequest的执行流程,你也就理解了这一类异步API的执行流程了。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们介绍了V8是如何执行回调函数的。回调函数有两种类型:同步回调和异步回调,同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。
|
||||
|
||||
那么,搞清楚异步回调函数在什么时机被执行就非常关键了。为了理清楚这个问题,我们分析了通用UI线程宏观架构。UI线程提供一个消息队列,并将待执行的事件添加到消息队列中,然后UI线程会不断循环地从消息队列中取出事件、执行事件。
|
||||
|
||||
关于异步回调,这里也有两种不同的类型,其典型代表是setTimeout和XMLHttpRequest。
|
||||
|
||||
setTimeout的执行流程其实是比较简单的,在setTimeout函数内部封装回调消息,并将回调消息添加进消息队列,然后主线程从消息队列中取出回调事件,并执行回调函数。
|
||||
|
||||
XMLHttpRequest稍微复杂一点,因为下载过程需要放到单独的一个线程中去执行,所以执行XMLHttpRequest.send的时候,宿主会将实际请求转发给网络线程,然后send函数退出,主线程继续执行下面的任务。网络线程在执行下载的过程中,会将一些中间信息和回调函数封装成新的消息,并将其添加进消息队列中,然后主线程从消息队列中取出回调事件,并执行回调函数。
|
||||
|
||||
## 思考题
|
||||
|
||||
分析Node中的readFileSync和readFile函数,其中一个是同步读文件操作,另外一个是异步读文件操作,这两段代码如下所示:
|
||||
|
||||
```
|
||||
var fs = require('fs')
|
||||
var data = fs.readFileSync('test.js')
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
fs.readFile('test.txt', function(err, data){
|
||||
data.toString()
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
那么请你分别分析下它们的执行流程。欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
270
极客时间专栏/geek/图解 Google V8/事件循环和垃圾回收/18 | 异步编程(一):V8是如何实现微任务的?.md
Normal file
270
极客时间专栏/geek/图解 Google V8/事件循环和垃圾回收/18 | 异步编程(一):V8是如何实现微任务的?.md
Normal file
@@ -0,0 +1,270 @@
|
||||
<audio id="audio" title="18 | 异步编程(一):V8是如何实现微任务的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/f0/c02adb1ab8eaf240c9ba53d0ee3ac0f0.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
上节我们介绍了通用的UI线程架构,每个UI线程都拥有一个消息队列,所有的待执行的事件都会被添加进消息队列中,UI线程会按照一定规则,循环地取出消息队列中的事件,并执行事件。而JavaScript最初也是运行在UI线程中的。换句话说,JavaScript语言就是基于这套通用的UI线程架构而设计的。
|
||||
|
||||
基于这套基础UI框架,JavaScript又延伸出很多新的技术,其中应用最广泛的当属**宏任务**和**微任务**。
|
||||
|
||||
**宏任务**很简单,**就是指消息队列中的等待被主线程执行的事件。**每个宏任务在执行时,V8都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。
|
||||
|
||||
**微任务**稍微复杂一点,其实你可以把**微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。**
|
||||
|
||||
JavaScript中之所以要引入微任务,主要是由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,那么**微任务可以在实时性和效率之间做一个有效的权衡**。另外使用微任务,可以改变我们现在的异步编程模型,使得我们可以使用同步形式的代码来编写异步调用。
|
||||
|
||||
虽然微任务如此重要,但是理解起来并不是太容易。我们先看下和微任务相关的知识栈,具体内容如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/46/1b0ea2180dd2406b988b424cc2933746.jpg" alt="">
|
||||
|
||||
从图中可以看出,微任务是基于消息队列、事件循环、UI主线程还有堆栈而来的,然后基于微任务,又可以延伸出协程、Promise、Generator、await/async等现代前端经常使用的一些技术。也就是说,如果对**消息队列、主线程**还有**调用栈**理解的不够深入,你在研究微任务时,就容易一头雾水。
|
||||
|
||||
今天,我们就先来打通微任务的底层技术,搞懂消息队列、主线程、调用栈的关联,然后抽丝剥茧地剖析微任务的实现机制。
|
||||
|
||||
## 主线程、调用栈、消息队列
|
||||
|
||||
我们先从**主线程**和**调用栈**开始分析。我们知道,**调用栈是一种数据结构,****用来管理在主线程上执行的函数的调用关系。**接下来我们通过执行下面这段代码,来分析下调用栈是如何管理主线程上函数调用的。
|
||||
|
||||
```
|
||||
function bar() {
|
||||
}
|
||||
foo(fun){
|
||||
fun()
|
||||
}
|
||||
foo(bar)
|
||||
|
||||
```
|
||||
|
||||
当V8准备执行这段代码时,会先将全局执行上下文压入到调用栈中,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/62/82360525fb2bb064eb6a9916e4a81062.jpg" alt="">
|
||||
|
||||
然后V8便开始在主线程上执行foo函数,首先它会创建foo函数的执行上下文,并将其压入栈中,那么此时调用栈、主线程的关系如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/1e/6ba964d00e3528b4040a4ae12cc91e1e.jpg" alt="" title="准备执行">
|
||||
|
||||
然后,foo函数又调用了bar函数,那么当V8执行bar函数时,同样要创建bar函数的执行上下文,并将其压入栈中,最终效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/fa/288d80945985a1cf0c0a4d4be9674ffa.jpg" alt="" title="调用bar函数">
|
||||
|
||||
等bar函数执行结束,V8就会从栈中弹出bar函数的执行上下文,此时的效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/a6/9b7474d3e2d5ca840f2119ca64d40da6.jpg" alt="" title="bar函数执行结束">
|
||||
|
||||
最后,foo函数执行结束,V8会将foo函数的执行上下文从栈中弹出,效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/ab/e9e2e01d56b9546745eaa24ac00d74ab.jpg" alt="" title="foo函数执行结束">
|
||||
|
||||
以上就是调用栈管理主线程上函数调用的方式,不过,这种方式会带来一种问题,那就是栈溢出。比如下面这段代码:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
foo()
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
由于foo函数内部嵌套调用它自己,所以在调用foo函数的时候,它的栈会一直向上增长,但是由于栈空间在内存中是连续的,所以通常我们都会限制调用栈的大小,如果当函数嵌套层数过深时,过多的执行上下文堆积在栈中便会导致栈溢出,最终如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/25/814b88dc157ef6f43403f46e271ae625.jpg" alt="" title="栈溢出">
|
||||
|
||||
我们可以使用setTimeout来解决栈溢出的问题,setTimeout的本质是将同步函数调用改成异步函数调用,这里的异步调用是将foo封装成事件,并将其添加进**消息队列**中,然后主线程再按照一定规则循环地从消息队列中读取下一个任务。使用setTimeout改造后代码代码如下所示:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
setTimeout(foo, 0)
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
那么现在我们就可以从**调用栈**、**主线程**、**消息队列**这三者的角度来分析这段代码的执行流程了。
|
||||
|
||||
首先,主线程会从消息队列中取出需要执行的宏任务,假设当前取出的任务就是要执行的这段代码,这时候主线程便会进入代码的执行状态。这时关于主线程、消息队列、调用栈的关系如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/65/bfea2ecc835f8324e034db33339ed965.jpg" alt="">
|
||||
|
||||
接下来V8就要执行foo函数了,同样执行foo函数时,会创建foo函数的执行上下文,并将其压入栈中,最终效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/dc/48706d55e9c490d84d676b5000d56bdc.jpg" alt="">
|
||||
|
||||
当V8执行执行foo函数中的setTimeout时,setTimeout会将foo函数封装成一个新的宏任务,并将其添加到消息队列中,在V8执行setTimeout函数时的状态图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/e1/dc84bd1ee456789905e734415eecdce1.jpg" alt="">
|
||||
|
||||
等foo函数执行结束,V8就会结束当前的宏任务,调用栈也会被清空,调用栈被清空后状态如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/89/2aeacb651b6aec591105ce2e4a8ed889.jpg" alt="">
|
||||
|
||||
当一个宏任务执行结束之后,忙碌的主线程依然不会闲下来,它会一直重复这个取宏任务、执行宏任务的过程。刚才通过setTimeout封装的回调宏任务,也会在某一时刻被主线取出并执行,这个执行过程,就是foo函数的调用过程。具体示意图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/bc/260d8a7294472f4ee7b194bdb7d513bc.jpg" alt="">
|
||||
|
||||
因为foo函数并不是在当前的父函数内部被执行的,而是封装成了宏任务,并丢进了消息队列中,然后等待主线程从消息队列中取出该任务,再执行该回调函数foo,这样就解决了栈溢出的问题。
|
||||
|
||||
## 微任务解决了宏任务执行时机不可控的问题
|
||||
|
||||
不过,对于栈溢出问题,虽然我们可以通过将某些函数封装成宏任务的方式来解决,但是宏任务需要先被放到消息队列中,如果某些宏任务的执行时间过久,那么就会影响到消息队列后面的宏任务的执行,而且这个影响是不可控的,因为你无法知道前面的宏任务需要多久才能执行完成。
|
||||
|
||||
于是JavaScript中又引入了微任务,微任务会在当前的任务快要执行结束时执行,利用微任务,你就能比较精准地控制你的回调函数的执行时机。
|
||||
|
||||
通俗地理解,V8会为每个宏任务维护一个微任务队列。当V8执行一段JavaScript时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。当你通过Promise.resolve生成一个微任务,该微任务会被V8自动添加进微任务队列,等整段代码快要执行结束时,该环境对象也随之被销毁,但是在销毁之前,V8会先处理微任务队列中的微任务。
|
||||
|
||||
理解微任务的执行时机,你只需要记住以下两点:
|
||||
|
||||
- 首先,如果当前的任务中产生了一个微任务,通过Promise.resolve()或者Promise.reject()都会触发微任务,触发的微任务不会在当前的函数中被执行,所以执行微任务时,不会导致栈的无限扩张;
|
||||
- 其次,和异步调用不同,微任务依然会在当前任务执行结束之前被执行,这也就意味着在当前微任务执行结束之前,消息队列中的其他任务是不可能被执行的。
|
||||
|
||||
因此在函数内部触发的微任务,一定比在函数内部触发的宏任务要优先执行。为了验证这个观点,我们来分析一段代码:
|
||||
|
||||
```
|
||||
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)
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,包含了通过setTimeout宏任务和通过Promise.resolve创建的微任务,你认为最终打印出来的顺序是什么?
|
||||
|
||||
执行这段代码,我们发现最终打印出来的顺序是:
|
||||
|
||||
```
|
||||
foo
|
||||
bar
|
||||
global
|
||||
micro-foo
|
||||
micro-bar
|
||||
micro-global
|
||||
macro-foo
|
||||
macro-bar
|
||||
macro-global
|
||||
|
||||
```
|
||||
|
||||
我们可以清晰地看出,微任务是处于宏任务之前执行的。接下来,我们就来详细分析下V8是怎么执行这段JavaScript代码的。
|
||||
|
||||
首先,当V8执行这段代码时,会将全局执行上下文压入调用栈中,并在执行上下文中创建一个空的微任务队列。那么此时:
|
||||
|
||||
- 调用栈中包含了全局执行上下文;
|
||||
- 微任务队列为空。
|
||||
|
||||
此时的消息队列、主线程、调用栈的状态图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/2a/b9bf0027185405e762b46cd4b77c892a.jpg" alt="">
|
||||
|
||||
然后,执行foo函数的调用,V8会先创建foo函数的执行上下文,并将其压入到栈中。接着执行Promise.resolve,这会触发一个micro-foo1微任务,V8会将该微任务添加进微任务队列。然后执行setTimeout方法。该方法会触发了一个macro-foo1宏任务,V8会将该宏任务添加进消息队列。那么此时:
|
||||
|
||||
- 调用栈中包含了**全局执行上下文**、**foo函数的执行上下文**;
|
||||
- 微任务队列有了一个微任务,**micro-foo**;
|
||||
- 消息队列中存放了一个通过setTimeout设置的宏任务,**macro-foo。**
|
||||
|
||||
此时的消息队列、主线程和调用栈的状态图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/2d/f85b1b316f669316cef23c4714d4ce2d.jpg" alt="">
|
||||
|
||||
接下来,foo函数调用了bar函数,那么V8需要再创建bar函数的执行上下文,并将其压入栈中,接着执行Promise.resolve,这会触发一个micro-bar微任务,该微任务会被添加进微任务队列。然后执行setTimeout方法,这也会触发一个macro-bar宏任务,宏任务同样也会被添加进消息队列。那么此时:
|
||||
|
||||
- 调用栈中包含了**全局执行上下文**、**foo函数的执行上下文、bar的执行上下文**;
|
||||
- 微任务队列中的微任务是**micro-foo、micro-bar**;
|
||||
- 消息队列中,宏任务的状态是**macro-foo、macro-bar。**
|
||||
|
||||
此时的消息队列、主线程和调用栈的状态图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/1c/338875c3ff58e389af86cf2acab5bd1c.jpg" alt="">
|
||||
|
||||
接下来,bar函数执行结束并退出,bar函数的执行上下文也会从栈中弹出,紧接着foo函数执行结束并退出,foo函数的执行上下文也随之从栈中被弹出。那么此时:
|
||||
|
||||
- 调用栈中包含了**全局执行上下文,**因为bar函数和foo函数都执行结束了,所以它们的执行上下文都被弹出调用栈了;
|
||||
- 微任务队列中的微任务同样还是**micro-foo、micro-bar**;
|
||||
- 消息队列中宏任务的状态同样还是**macro-foo、macro-bar。**
|
||||
|
||||
此时的消息队列、主线程和调用栈的状态图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/e0/d24acef2cc39b1688dff9f19b9cdb9e0.jpg" alt="">
|
||||
|
||||
主线程执行完了foo函数,紧接着就要执行全局环境中的代码Promise.resolve了,这会触发一个micro-global微任务,V8会将该微任务添加进微任务队列。接着又执行setTimeout方法,该方法会触发了一个macro-global宏任务,V8会将该宏任务添加进消息队列。那么此时:
|
||||
|
||||
- 调用栈中包含的是**全局执行上下文**;
|
||||
- 微任务队列中的微任务同样还是**micro-foo、micro-bar、micro-global**;
|
||||
- 消息队列中宏任务的状态同样还是**macro-foo、macro-bar、macro-global。**
|
||||
|
||||
此时的消息队列、主线程和调用栈的状态图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/db/34fb1a481b60708360b48ba04821f6db.jpg" alt="">
|
||||
|
||||
等到这段代码即将执行完成时,V8便要销毁这段代码的环境对象,此时环境对象的析构函数被调用(注意,这里的析构函数是C++中的概念),这里就是V8执行微任务的一个检查点,这时候V8会检查微任务队列,如果微任务队列中存在微任务,那么V8会依次取出微任务,并按照顺行执行。因为微任务队列中的任务分别是:micro-foo、micro-bar、micro-global,所以执行的顺序也是如此。
|
||||
|
||||
此时的消息队列、主线程和调用栈的状态图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/c9/267e549592913e25bdb6dfe716eeddc9.jpg" alt="">
|
||||
|
||||
等微任务队列中的所有微任务都执行完成之后,当前的宏任务也就执行结束了,接下来主线程会继续重复执行取出任务、执行任务的过程。由于正常情况下,取出宏任务的顺序是按照先进先出的顺序,所有最后打印出来的顺序是:macro-foo、macro-bar、macro-global。
|
||||
|
||||
等所有的任务执行完成之后,消息队列、主线程和调用栈的状态图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/4a/6caa4a24f1ddd918af62f6dbcb1c464a.jpg" alt="">
|
||||
|
||||
以上就是完整的执行流程的分析,到这里,相信你已经了解微任务和宏任务的执行时机是不同的了,微任务是在当前的任务快要执行结束之前执行的,宏任务是消息队列中的任务,主线程执行完一个宏任务之后,便会接着从消息队列中取出下一个宏任务并执行。
|
||||
|
||||
## 能否在微任务中循环地触发新的微任务?
|
||||
|
||||
既然宏任务和微任务都是异步调用,只是执行的时机不同,那能不能在setTimeout解决栈溢出的问题时,把触发宏任务改成是触发微任务呢?
|
||||
|
||||
比如,我们将代码改为:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
return Promise.resolve().then(foo)
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
当执行foo函数时,由于foo函数中调用了Promise.resolve(),这会触发一个微任务,那么此时,V8会将该微任务添加进微任务队列中,退出当前foo函数的执行。
|
||||
|
||||
然后,V8在准备退出当前的宏任务之前,会检查微任务队列,发现微任务队列中有一个微任务,于是先执行微任务。由于这个微任务就是调用foo函数本身,所以在执行微任务的过程中,需要继续调用foo函数,在执行foo函数的过程中,又会触发了同样的微任务。
|
||||
|
||||
那么这个循环就会一直持续下去,当前的宏任务无法退出,也就意味着消息队列中其他的宏任务是无法被执行的,比如通过鼠标、键盘所产生的事件。这些事件会一直保存在消息队列中,页面无法响应这些事件,具体的体现就是页面的卡死。
|
||||
|
||||
不过,由于V8每次执行微任务时,都会退出当前foo函数的调用栈,所以这段代码是不会造成栈溢出的。
|
||||
|
||||
## 总结
|
||||
|
||||
这节课我们主要从**调用栈**、**主线程**、**消息队列**这三者关联的角度来分析了微任务。
|
||||
|
||||
调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。主线在执行任务的过程中,如果函数的调用层次过深,可能造成栈溢出的错误,我们可以使用setTimeout来解决栈溢出的问题。
|
||||
|
||||
setTimeout的本质是将同步函数调用改成异步函数调用,这里的异步调用是将回调函数封装成宏任务,并将其添加进**消息队列**中,然后主线程再按照一定规则循环地从消息队列中读取下一个宏任务。
|
||||
|
||||
消息队列中事件又被称为宏任务,不过,宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,而**微任务可以在实时性和效率之间做有效的权衡**。
|
||||
|
||||
微任务之所以能实现这样的效果,主要取决于微任务的执行时机,**微任务其实是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。**
|
||||
|
||||
因为微任务依然是在当前的任务中执行的,所以如果在微任务中循环触发新的微任务,那么将导致消息队列中的其他任务没有机会被执行。
|
||||
|
||||
## 思考题
|
||||
|
||||
浏览器中的MutationObserver接口提供了监视对DOM树所做更改的能力,它在内部也使用了微任务的技术,那么今天留给你的作业是,查找MutationObserver相关资料,分析它是如何工作的,其中微任务的作用是什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,374 @@
|
||||
<audio id="audio" title="19|异步编程(二):V8是如何实现async/await的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5e/06/5e6dd6ed7516d84fee3f623c993b4a06.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
上一节我们介绍了JavaScript是基于单线程设计的,最终造成了JavaScript中出现大量回调的场景。当JavaScript中有大量的异步操作时,会降低代码的可读性, 其中最容易造成的就是回调地狱的问题。
|
||||
|
||||
JavaScript社区探索并推出了一系列的方案,从“Promise加then”到“generator加co”方案,再到最近推出“终极”的async/await方案,完美地解决了回调地狱所造成的问题。
|
||||
|
||||
今天我们就来分析下回调地狱问题是如何被一步步解决的,在这个过程中,你也就理解了V8 实现async/await的机制。
|
||||
|
||||
## 什么是回调地狱?
|
||||
|
||||
我们先来看什么是回调地狱。
|
||||
|
||||
假设你们老板给了你一个小需求,要求你从网络获取某个用户的用户名,获取用户名称的步骤是先通过一个id_url来获取用户ID,然后再使用获取到的用户ID作为另外一个name_url的参数,以获取用户名。
|
||||
|
||||
我做了两个DEMO URL,如下所示:
|
||||
|
||||
```
|
||||
const id_url = 'https://raw.githubusercontent.com/binaryacademy/geektime-v8/master/id'
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
const name_url = 'https://raw.githubusercontent.com/binaryacademy/geektime-v8/master/name'
|
||||
|
||||
```
|
||||
|
||||
那么你会怎么实现这个小小的需求呢?
|
||||
|
||||
其中最容易想到的方案是使用XMLHttpRequest,并按照前后顺序异步请求这两个URL。具体地讲,你可以先定义一个GetUrlContent函数,这个函数负责封装XMLHttpRequest来下载URL文件内容,由于下载过程是异步执行的,所以需要通过回调函数来触发返回结果。那么我们需要给GetUrlContent传递一个回调函数result_callback,来触发异步下载的结果。
|
||||
|
||||
最终实现的业务代码如下所示:
|
||||
|
||||
```
|
||||
//result_callback:下载结果的回调函数
|
||||
//url:需要获取URL的内容
|
||||
function GetUrlContent(result_callback,url) {
|
||||
let request = new XMLHttpRequest()
|
||||
|
||||
request.open('GET', url)
|
||||
|
||||
request.responseType = 'text'
|
||||
|
||||
request.onload = function () {
|
||||
|
||||
result_callback(request.response)
|
||||
|
||||
}
|
||||
|
||||
request.send()
|
||||
|
||||
}
|
||||
|
||||
function IDCallback(id) {
|
||||
|
||||
console.log(id)
|
||||
|
||||
let new_name_url = name_url + "?id="+id
|
||||
|
||||
GetUrlContent(NameCallback,new_name_url)
|
||||
|
||||
}
|
||||
|
||||
function NameCallback(name) {
|
||||
|
||||
console.log(name)
|
||||
|
||||
}
|
||||
|
||||
GetUrlContent(IDCallback,id_url)
|
||||
|
||||
```
|
||||
|
||||
在这段代码中:
|
||||
|
||||
- 我们先使用GetUrlContent函数来异步下载用户ID,之后再通过IDCallback回调函数来获取到请求的ID;
|
||||
- 有了ID之后,我们再在IDCallback函数内部,使用获取到的ID和name_url合并成新的获取用户名称的URL地址;
|
||||
- 然后,再次使用GetUrlContent来获取用户名称,返回的用户名称会触发NameCallback回调函数,我们可以在NameCallback函数内部处理最终的返回结果。
|
||||
|
||||
可以看到,我们每次请求网络内容,都需要设置一个回调函数,用来返回异步请求的结果,这些穿插在代码之间的回调函数打乱了代码原有的顺序,比如正常的代码顺序是先获取ID,再获取用户名。但是由于使用了异步回调函数,获取用户名代码的位置,反而在获取用户ID的代码之上了,这就直接导致了我们代码逻辑的不连贯、不线性,非常不符合人的直觉。
|
||||
|
||||
因此,异步回调模式影响到我们的编码方式,如果在代码中过多地使用异步回调函数,会将你的整个代码逻辑打乱,从而让代码变得难以理解,这也就是我们经常所说的**回调地狱**问题。
|
||||
|
||||
## 使用Promise解决回调地狱问题
|
||||
|
||||
为了解决回调地狱的问题,JavaScript做了大量探索,最开始引入了Promise来解决部分回调地狱的问题,比如最新的fetch就使用Promise的技术,我们可以使用fetch来改造上面这段代码,改造后的代码如下所示:
|
||||
|
||||
```
|
||||
fetch(id_url)
|
||||
|
||||
.then((response) => {
|
||||
|
||||
return response.text()
|
||||
|
||||
})
|
||||
|
||||
.then((response) => {
|
||||
|
||||
let new_name_url = name_url + "?id=" + response
|
||||
|
||||
return fetch(new_name_url)
|
||||
|
||||
}).then((response) => {
|
||||
|
||||
return response.text()
|
||||
|
||||
}).then((response) => {
|
||||
|
||||
console.log(response)//输出最终的结果
|
||||
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,改造后的代码是先获取用户ID,等到返回了结果之后,再利用用户ID生成新的获取用户名称的URL,然后再获取用户名,最终返回用户名。使用Promise,我们就可以按照线性的思路来编写代码,非常符合人的直觉。所以说,使用Promise可以解决回调地狱中编码不线性的问题。
|
||||
|
||||
## 使用Generator函数实现更加线性化逻辑
|
||||
|
||||
虽然使用Promise可以解决回调地狱中编码不线性的问题,但这种方式充满了Promise的then()方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的then,异步逻辑之间依然被then方法打断了,因此这种方式的语义化不明显,代码不能很好地表示执行流程。
|
||||
|
||||
那么我们就需要思考,能不能更进一步,像编写同步代码的方式来编写异步代码,比如:
|
||||
|
||||
```
|
||||
function getResult(){
|
||||
let id = getUserID()
|
||||
let name = getUserName(id)
|
||||
return name
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于getUserID()和getUserName()都是异步请求,如果要实现这种线性的编码方式,那么一个可行的方案就是**执行到异步请求的时候,暂停当前函数,等异步请求返回了结果,再恢复该函数。**
|
||||
|
||||
具体地讲,执行到getUserID()时暂停GetResult函数,然后浏览器在后台处理实际的请求过程,待ID数据返回时,再来恢复GetResult函数。接下来再执行getUserName来获取到用户名,由于getUserName()也是一个异步请求,所以在使用getUserName()的同时,依然需要暂停GetResult函数的执行,等到getUserName()返回了用户名数据,再恢复GetResult函数的执行,最终getUserName()函数返回了name信息。
|
||||
|
||||
这个思维模型大致如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/65/485d9de2097107f818c622a83a953665.jpg" alt="">
|
||||
|
||||
我们可以看出,这个模型的关键就是实现**函数暂停执行**和**函数恢复执行**,而生成器就是为了实现暂停函数和恢复函数而设计的。
|
||||
|
||||
**生成器函数是一个带星号函数,配合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并不是一次执行完的,而是全局代码和getResult函数交替执行。
|
||||
|
||||
其实这就是生成器函数的特性,在生成器内部,如果遇到yield关键字,那么V8将返回关键字后面的内容给外部,并暂停该生成器函数的执行。生成器暂停执行后,外部的代码便开始执行,外部代码如果想要恢复生成器的执行,可以使用result.next方法。
|
||||
|
||||
那么,V8是怎么实现生成器函数的暂停执行和恢复执行的呢?
|
||||
|
||||
这背后的魔法就是**协程**,**协程是一种比线程更加轻量级的存在。**你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。比如,当前执行的是A协程,要启动B协程,那么A协程就需要将主线程的控制权交给B协程,这就体现在A协程暂停执行,B协程恢复执行;同样,也可以从B协程中启动A协程。通常,**如果从A协程启动B协程,我们就把A协程称为B协程的父协程**。
|
||||
|
||||
正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。每一时刻,该线程只能执行其中某一个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
|
||||
|
||||
为了让你更好地理解协程是怎么执行的,我结合上面那段代码的执行过程,画出了下面的“协程执行流程图”,你可以对照着代码来分析:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/41/f0e0800ab9ec2f559fbb58ecabc76c41.jpg" alt="">
|
||||
|
||||
到这里,相信你已经弄清楚协程是怎么工作的了,其实在JavaScript中,生成器就是协程的一种实现方式,这样,你也就理解什么是生成器了。
|
||||
|
||||
因为生成器可以暂停函数的执行,所以,我们将所有异步调用的方式,写成同步调用的方式,比如我们使用生成器来实现上面的需求,代码如下所示:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
这样,我们可以将同步、异步逻辑全部写进生成器函数getResult的内部,然后,我们在外面依次使用一段代码来控制生成器的暂停和恢复执行。以上,就是协程和Promise相互配合执行的大致流程。
|
||||
|
||||
通常,我们把执行生成器的代码封装成一个函数,这个函数驱动了getResult函数继续往下执行,我们把这个执行生成器代码的函数称为**执行器(<strong>可参考著名的co框架**)</strong>,如下面这种方式:
|
||||
|
||||
```
|
||||
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)
|
||||
}
|
||||
co(getResult())
|
||||
|
||||
```
|
||||
|
||||
## async/await:异步编程的“终极”方案
|
||||
|
||||
由于生成器函数可以暂停,因此我们可以在生成器内部编写完整的异步逻辑代码,不过生成器依然需要使用额外的co函数来驱动生成器函数的执行,这一点非常不友好。
|
||||
|
||||
基于这个原因,**ES7 引入了async/await,这是JavaScript异步编程的一个重大改进,它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力**。你可以参考下面这段使用async/await改造后的代码:
|
||||
|
||||
```
|
||||
async function getResult() {
|
||||
try {
|
||||
let id_res = await fetch(id_url)
|
||||
let id_text = await id_res.text()
|
||||
console.log(id_text)
|
||||
|
||||
let new_name_url = name_url+"?id="+id_text
|
||||
console.log(new_name_url)
|
||||
|
||||
|
||||
let name_res = await fetch(new_name_url)
|
||||
let name_text = await name_res.text()
|
||||
console.log(name_text)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
getResult()
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,你会发现整个异步处理的逻辑都是使用同步代码的方式来实现的,而且还支持try catch来捕获异常,这就是完全在写同步代码,所以非常符合人的线性思维。
|
||||
|
||||
虽然这种方式看起来像是同步代码,但是实际上它又是异步执行的,也就是说,在执行到await fetch的时候,整个函数会暂停等待fetch的执行结果,等到函数返回时,再恢复该函数,然后继续往下执行。
|
||||
|
||||
其实async/await技术背后的秘密就是Promise和生成器应用,往底层说,就是微任务和协程应用。要搞清楚async和await的工作原理,我们就得对async和await分开分析。
|
||||
|
||||
我们先来看看async到底是什么。根据MDN定义,async是一个通过**异步执行并隐式返回 Promise** 作为结果的函数。
|
||||
|
||||
这里需要重点关注异步执行这个词,简单地理解,如果在async函数里面使用了await,那么此时async函数就会暂停执行,并等待合适的时机来恢复执行,所以说async是一个异步执行的函数**。**
|
||||
|
||||
那么暂停之后,什么时机恢复async函数的执行呢?
|
||||
|
||||
要解释这个问题,我们先来看看,V8是如何处理await后面的内容的。
|
||||
|
||||
通常,await 可以等待两种类型的表达式:
|
||||
|
||||
- 可以是任何普通表达式;
|
||||
- 也可以是一个Promise 对象的表达式。
|
||||
|
||||
如果 await 等待的是一个 Promise对象,它就会暂停执行生成器函数,直到Promise对象的状态变成resolve,才会恢复执行,然后得到 resolve 的值,作为 await 表达式的运算结果。
|
||||
|
||||
我们看下面这样一段代码:
|
||||
|
||||
```
|
||||
function NeverResolvePromise(){
|
||||
return new Promise((resolve, reject) => {})
|
||||
}
|
||||
async function getResult() {
|
||||
let a = await NeverResolvePromise()
|
||||
console.log(a)
|
||||
}
|
||||
getResult()
|
||||
console.log(0)
|
||||
|
||||
```
|
||||
|
||||
这一段代码,我们使用await 等待一个没有resolve的Promise,那么这也就意味着,getResult函数会一直等待下去。
|
||||
|
||||
和生成器函数一样,使用了async声明的函数在执行时,也是一个单独的协程,我们可以使用await来暂停该协程,由于await等待的是一个Promise对象,我们可以resolve来恢复该协程。
|
||||
|
||||
下面是我从协程的视角,画的这段代码的执行流程图,你可以对照参考下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/f6/5fd7a95e6dd2ee6dda588641e2eecaf6.jpg" alt="">
|
||||
|
||||
如果await等待的对象已经变成了resolve状态,那么V8就会恢复该协程的执行,我们可以修改下上面的代码,来证明下这个过程:
|
||||
|
||||
```
|
||||
function HaveResolvePromise(){
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
resolve(100)
|
||||
}, 0);
|
||||
})
|
||||
}
|
||||
async function getResult() {
|
||||
console.log(1)
|
||||
let a = await HaveResolvePromise()
|
||||
console.log(a)
|
||||
console.log(2)
|
||||
}
|
||||
console.log(0)
|
||||
getResult()
|
||||
console.log(3)
|
||||
|
||||
```
|
||||
|
||||
现在,这段代码的执行流程就非常清晰了,具体执行流程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/7f/1c7bc077282dfa996746e5c403c42f7f.jpg" alt="">
|
||||
|
||||
如果await等待的是一个非Promise对象,比如await 100,那么V8会**隐式**地将await后面的100包装成一个已经resolve的对象,其效果等价于下面这段代码:
|
||||
|
||||
```
|
||||
function ResolvePromise(){
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(100)
|
||||
})
|
||||
}
|
||||
async function getResult() {
|
||||
let a = await ResolvePromise()
|
||||
console.log(a)
|
||||
}
|
||||
getResult()
|
||||
console.log(3)
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Callback模式的异步编程模型需要实现大量的回调函数,大量的回调函数会打乱代码的正常逻辑,使得代码变得不线性、不易阅读,这就是我们所说的回调地狱问题。
|
||||
|
||||
使用Promise能很好地解决回调地狱的问题,我们可以按照线性的思路来编写代码,这个过程是线性的,非常符合人的直觉。
|
||||
|
||||
但是这种方式充满了Promise的then()方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的then,语义化不明显,代码不能很好地表示执行流程。
|
||||
|
||||
我们想要通过线性的方式来编写异步代码,要实现这个理想,最关键的是要能实现函数暂停和恢复执行的功能。而生成器就可以实现函数暂停和恢复,我们可以在生成器中使用同步代码的逻辑来异步代码(实现该逻辑的核心是协程),但是在生成器之外,我们还需要一个触发器来驱动生成器的执行,因此这依然不是我们最终想要的方案。
|
||||
|
||||
我们的最终方案就是async/await,async是一个可以暂停和恢复执行的函数,我们会在async函数内部使用await来暂停async函数的执行,await等待的是一个Promise对象,如果Promise的状态变成resolve或者reject,那么async函数会恢复执行。因此,使用async/await可以实现以同步的方式编写异步代码这一目标。
|
||||
|
||||
你会发现,这节课我们讲的也是前端异步编程的方案史,我把这一过程也画了一张图供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/f3/6e0508be2a2444cba8ade6230610f4f3.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
了解async/await的演化过程,对于理解async/await至关重要,在进化过程中,co+generator是比较优秀的一个设计。今天留给你的思考题是,co的运行原理是什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,171 @@
|
||||
<audio id="audio" title="20 | 垃圾回收(一):V8的两个垃圾回收器是如何工作的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ac/39/ac072bce8fe9e80e256d4046121a9039.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
我们都知道,JavaScript是一门自动垃圾回收的语言,也就是说,我们不需要去手动回收垃圾数据,这一切都交给V8的垃圾回收器来完成。V8为了更高效地回收垃圾,引入了两个垃圾回收器,它们分别针对着不同的场景。
|
||||
|
||||
那这两个回收器究竟是如何工作的呢,这节课我们就来分析这个问题。
|
||||
|
||||
## 垃圾数据是怎么产生的?
|
||||
|
||||
首先,我们看看垃圾数据是怎么产生的。
|
||||
|
||||
无论是使用什么语言,我们都会频繁地使用数据,这些数据会被存放到栈和堆中,通常的方式是在内存中创建一块空间,使用这块空间,在不需要的时候回收这块空间。
|
||||
|
||||
比如下面这样一句代码:
|
||||
|
||||
```
|
||||
window.test = new Object()
|
||||
window.test.a = new Uint16Array(100)
|
||||
|
||||
```
|
||||
|
||||
当JavaScript执行这段代码的时候,会先为window对象添加一个test属性,并在堆中创建了一个空对象,并将该对象的地址指向了window.test属性。随后又创建一个大小为100的数组,并将属性地址指向了test.a的属性值。此时的内存布局图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/b1/42b70203c6da641831d778ce08a7a5b1.jpg" alt="">
|
||||
|
||||
我们可以看到,栈中保存了指向window对象的指针,通过栈中window的地址,我们可以到达window对象,通过window对象可以到达test对象,通过test对象还可以到达a对象。
|
||||
|
||||
如果此时,我将另外一个对象赋给了a属性,代码如下所示:
|
||||
|
||||
```
|
||||
window.test.a = new Object()
|
||||
|
||||
```
|
||||
|
||||
那么此时的内存布局如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/dc/9c44e8bb2a75a8da72877c8a192967dc.jpg" alt="">
|
||||
|
||||
我们可以看到,a属性之前是指向堆中数组对象的,现在已经指向了另外一个空对象,那么此时堆中的数组对象就成为了垃圾数据,因为我们无法从一个根对象遍历到这个Array对象。
|
||||
|
||||
不过,你不用担心这个数组对象会一直占用内存空间,因为V8虚拟机中的垃圾回收器会帮你自动清理。
|
||||
|
||||
## 垃圾回收算法
|
||||
|
||||
那么垃圾回收是怎么实现的呢?大致可以分为以下几个步骤:
|
||||
|
||||
第一步,通过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等对象。
|
||||
|
||||
其实这两个特点不仅仅适用于JavaScript,同样适用于大多数的编程语言,如Java、Python等。
|
||||
|
||||
V8的垃圾回收策略,就是建立在该假说的基础之上的。接下来,我们来分析下V8是如何实现垃圾回收的。
|
||||
|
||||
如果我们只使用一个垃圾回收器,在优化大多数新对象的同时,就很难优化到那些老对象,因此你需要权衡各种场景,根据对象生存周期的不同,而使用不同的算法,以便达到最好的效果。
|
||||
|
||||
所以,在V8中,会把堆分为新生代和老生代两个区域,**新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象**。
|
||||
|
||||
新生代通常只支持1~8M的容量,而老生代支持的容量就大很多了。对于这两块区域,V8分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。
|
||||
|
||||
- **副垃圾回收器-Minor GC (Scavenger),主要负责新生代的垃圾回收。**
|
||||
- **主垃圾回收器-Major GC,主要负责老生代的垃圾回收。**
|
||||
|
||||
## 副垃圾回收器
|
||||
|
||||
副垃圾回收器主要负责新生代的垃圾回收。通常情况下,大多数小的对象都会被分配到新生代,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。
|
||||
|
||||
新生代中的垃圾数据用**Scavenge算法**来处理。所谓Scavenge算法,是把新生代空间对半划分为两个区域,一半是**对象区域(from-space)**,一半是**空闲区域(to-space)**,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/9d/75329eceafd88573097f8d073430bc9d.jpg" alt="" title="新生区要划分为对象区域和空闲区域">
|
||||
|
||||
新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
|
||||
|
||||
在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/87/12519a0d1f2484cd24297e821f2f1887.jpg" alt="">
|
||||
|
||||
完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时,**这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/bd/797db43b27c8a6add1ffa540910c7ebd.jpg" alt="">
|
||||
|
||||
不过,副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以**为了执行效率,一般新生区的空间会被设置得比较小**。
|
||||
|
||||
也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器还会采用**对象晋升策略**,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。
|
||||
|
||||
## 主垃圾回收器
|
||||
|
||||
主垃圾回收器主要负责老生代中的垃圾回收。除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。因此,老生代中的对象有两个特点:
|
||||
|
||||
- 一个是对象占用空间大;
|
||||
- 另一个是对象存活时间长。
|
||||
|
||||
由于老生代的对象比较大,若要在老生代中使用Scavenge算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。所以,主垃圾回收器是采用**标记-清除(Mark-Sweep)**的算法进行垃圾回收的。
|
||||
|
||||
那么,标记-清除算法是如何工作的呢?
|
||||
|
||||
**首先是标记过程阶段。**标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
|
||||
|
||||
**接下来就是垃圾的清除过程。**它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉。
|
||||
|
||||
你可以理解这个过程是清除掉下图中红色标记数据的过程,你可参考下图大致理解下其清除过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/9b/c70cdb85c0b656061e4cc420efdaf59b.jpg" alt="" title="标记清除过程">
|
||||
|
||||
对垃圾数据进行标记,然后清除,这就是**标记-清除算法**,不过对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法——**标记-整理(Mark-Compact)**。
|
||||
|
||||
这个算法的标记过程仍然与标记-清除算法里的是一样的,先标记可回收对象,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存。你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/d8/6a558a6731fd68757e1a43c1dbc27ed8.jpg" alt="" title="标记整理过程">
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我们先分析了什么是垃圾数据,从“GC Roots”对象出发,遍历GC Root中的所有对象,如果通过GC Roots没有遍历到的对象,则这些对象便是垃圾数据。V8会有专门的垃圾回收器来回收这些垃圾数据。
|
||||
|
||||
V8依据代际假说,将堆内存划分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。为了提升垃圾回收的效率,V8设置了两个垃圾回收器,主垃圾回收器和副垃圾回收器。主垃圾回收器负责收集老生代中的垃圾数据,副垃圾回收器负责收集新生代中的垃圾数据。
|
||||
|
||||
副垃圾回收器采用了**Scavenge算法**,是把新生代空间对半划分为两个区域,一半是**对象区域**,一半是**空闲区域**。新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换。主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作,会经历标记、清除和整理过程。
|
||||
|
||||
## 思考题
|
||||
|
||||
观察下面这段代码:
|
||||
|
||||
```
|
||||
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()
|
||||
|
||||
```
|
||||
|
||||
课后请你想一想,V8执行这段代码的过程中,产生了哪些垃圾数据,以及V8又是如何回收这些垃圾的数据的, 最后站在内存空间和主线程资源的角度来分析,如何优化这段代码。欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,150 @@
|
||||
<audio id="audio" title="21 | 垃圾回收(二):V8是如何优化垃圾回收器执行效率的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/af/dc484dc76531ba44b271fc5a20b286af.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
上节我们介绍了V8使用副垃圾回收器和主垃圾回收器来处理垃圾回收,这节课我们看看V8是如何优化垃圾回收器的执行效率的。
|
||||
|
||||
由于JavaScript是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的JavaScript脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做**全停顿(Stop-The-World)**。
|
||||
|
||||
一次完整的垃圾回收分为标记和清理两个阶段,垃圾数据标记之后,V8会继续执行清理和整理操作,虽然主垃圾回收器和副垃圾回收器的处理方式稍微有些不同,但它们都是主线程上执行的,执行垃圾回收过程中,会暂停主线程上的其他任务,具体全停顿的执行效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/23/9004196c53f2f381a1321bcbc346fc23.jpg" alt="">
|
||||
|
||||
可以看到,执行垃圾回收时会占用主线程的时间,如果在执行垃圾回收的过程中,垃圾回收器占用主线程时间过久,就像上面图片展示的那样,花费了200毫秒,在这200毫秒内,主线程是不能做其他事情的。比如,页面正在执行一个JavaScript动画,因为垃圾回收器在工作,就会导致这个动画在这200毫秒内无法执行,造成页面的**卡顿(Jank)**,用户体验不佳。
|
||||
|
||||
为了解决全停顿而造成的用户体验的问题,V8团队经过了很多年的努力,向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,并且也已经取得了一些成效。这些技术主要是从两方面来解决垃圾回收效率问题的:
|
||||
|
||||
- 第一,**将一个完整的垃圾回收的任务拆分成多个小的任务**,这样就消灭了单个长的垃圾回收任务;
|
||||
- 第二,**将标记对象、移动对象等任务转移到后台线程进行**,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。
|
||||
|
||||
接下来,我们就来深入分析下,V8是怎么向现有的垃圾回收器添加并行、并发和增量等技术,来提升垃圾回收执行效率的。
|
||||
|
||||
## 并行回收
|
||||
|
||||
既然执行一次完整的垃圾回收过程比较耗时,那么解决效率问题,第一个思路就是主线程在执行垃圾回收的任务时,引入多个辅助线程来并行处理,这样就会加速垃圾回收的执行速度,因此V8团队引入了并行回收机制。
|
||||
|
||||
所谓并行回收,是指垃圾回收器在主线程上执行的过程中,还会开启多个协助线程,同时执行同样的回收工作,其工作模式如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/1f/00537bdadac433a57c77c56c5cc33c1f.jpg" alt=""><br>
|
||||
采用并行回收时,垃圾回收所消耗的时间,等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间),再加上一些同步开销的时间。这种方式比较简单,因为在执行垃圾标记的过程中,主线程并不会同时执行JavaScript代码,因此JavaScript代码也不会改变回收的过程。所以我们可以假定内存状态是静态的,因此只要确保同时只有一个协助线程在访问对象就好了。
|
||||
|
||||
V8的副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。
|
||||
|
||||
## 增量回收
|
||||
|
||||
虽然并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这**仍然是一种全停顿**的垃圾回收方式,在主线程执行回收工作的时候才会开启辅助线程,这依然还会存在效率问题。比如老生代存放的都是一些大的对象,如window、DOM这种,完整执行老生代的垃圾回收,时间依然会很久。这些大的对象都是主垃圾回收器的,所以在2011年,V8又引入了增量标记的方式,我们把这种垃圾回收的方式称之为**增量式垃圾回收**。
|
||||
|
||||
所谓增量式垃圾回收,是指垃圾收集器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作,具体流程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/6f/be18e6dc6c93e761a37d50aed48f246f.jpg" alt=""><br>
|
||||
增量标记的算法,比全停顿的算法要稍微复杂,这主要是因为**增量回收是并发的(concurrent)**,要实现增量执行,需要满足两点要求:
|
||||
|
||||
1. 垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收来了之后,才能继续启动。
|
||||
1. 在暂停期间,被标记好的垃圾数据如果被JavaScript代码修改了,那么垃圾回收器需要能够正确地处理。
|
||||
|
||||
我们先来看看第一点,V8是如何实现垃圾回收器的暂停和恢复执行的。
|
||||
|
||||
这里我们需要知道,在没有采用增量算法之前,V8使用黑色和白色来标记数据。在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots出发,将所有能访问到的数据标记为黑色。遍历结束之后,被标记为黑色的数据就是活动数据,那些白色数据就是垃圾数据。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/0c/e1409de965aaab9bbf401249b1e02d0c.jpg" alt="">
|
||||
|
||||
如果内存中的数据只有两种状态,非黑即白,那么当你暂停了当前的垃圾回收器之后,再次恢复垃圾回收器,那么垃圾回收器就不知道从哪个位置继续开始执行了。
|
||||
|
||||
比如垃圾回收器执行了一小段增量回收后,被V8暂停了,然后主线程执行了一段JavaScript代码,然后垃圾回收器又被恢复了,那么恢复时内存状态就如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/4d/2cd1ef856522ad0c8176c60b75e63c4d.jpg" alt="">
|
||||
|
||||
那么,当垃圾回收器再次被启动的时候,它到底是从A节点开始标记,还是从B节点开始执行标注过程呢?因为没有其他额外的信息,所以垃圾回收器也不知道该如何处理了。
|
||||
|
||||
为了解决这个问题,V8采用了**三色标记法,**除了黑色和白色,还额外引入了灰色:
|
||||
|
||||
1. 黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了;
|
||||
1. 灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点;
|
||||
1. 白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。
|
||||
|
||||
引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。
|
||||
|
||||
因此采用三色标记,可以很好地支持增量式垃圾回收。
|
||||
|
||||
接下来,我们再来分析下,标记好的垃圾数据被JavaScript修改了,V8是如何处理的。我们看下面这样的一个例子:
|
||||
|
||||
```
|
||||
window.a = Object()
|
||||
window.a.b = Object()
|
||||
window.a.b.c=Object()
|
||||
|
||||
```
|
||||
|
||||
执行到这段代码时,垃圾回收器标记的结果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/17/19bbfb35d274064b253814b58413bc17.jpg" alt="">
|
||||
|
||||
然后又执行了另外一个代码,这段代码如下所示:
|
||||
|
||||
```
|
||||
window.a.b = Object() //d
|
||||
|
||||
```
|
||||
|
||||
执行完之后,垃圾回收器又恢复执行了增量标记过程,由于b重新指向了d对象,所以b和c对象的连接就断开了。这时候代码的应用如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/15/759f6a8105d64d3aebdc16f81a2b5e15.jpg" alt="">
|
||||
|
||||
这就说明一个问题,当垃圾回收器将某个节点标记成了黑色,然后这个黑色的节点被续上了一个白色节点,那么垃圾回收器不会再次将这个白色节点标记为黑色节点了,因为它已经走过这个路径了。
|
||||
|
||||
但是这个新的白色节点的确被引用了,所以我们还是需要想办法将其标记为黑色。
|
||||
|
||||
为了解决这个问题,增量垃圾回收器添加了一个约束条件:**不能让黑色节点指向白色节点**。
|
||||
|
||||
通常我们使用**写屏障(Write-barrier)机制**实现这个约束条件,也就是说,当发生了黑色的节点引用了白色的节点,写屏障机制会强制将被引用的白色节点变成灰色的,这样就保证了黑色节点不能指向白色节点的约束条件。这个方法也被称为**强三色不变性**,它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是不可到达的,可以安全释放。
|
||||
|
||||
所以在V8中,每次执行如 `window.a.b = value`的写操作之后,V8 会插入写屏障代码,强制将value这块内存标记为灰色。
|
||||
|
||||
## 并发(concurrent)回收
|
||||
|
||||
虽然通过**三色标记法和写屏障**机制可以很好地实现增量垃圾回收,但是由于这些操作都是在主线程上执行的,如果主线程繁忙的时候,增量垃圾回收操作依然会增加降低主线程处理任务的**吞吐量(throughput)**。
|
||||
|
||||
结合并行回收可以将一些任务分配给辅助线程,但是并行回收依然会阻塞主线程,那么,有没有办法在不阻塞主线程的情况下,执行垃圾回收操作呢?
|
||||
|
||||
还真有,这就是我们要来重点研究的**并发回收机制**了。
|
||||
|
||||
**所谓并发回收,是指主线程在执行JavaScript的过程中,辅助线程能够在后台完成执行垃圾回收的操作。**并发标记的流程大致如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/c2/157052aa087c840f5f58a7708f30bdc2.jpg" alt=""><br>
|
||||
并发回收的优势非常明显,主线程不会被挂起,JavaScript 可以自由地执行 ,在执行的同时,辅助线程可以执行垃圾回收操作。
|
||||
|
||||
但是并发回收却是这三种技术中最难的一种,这主要由以下两个原因导致的:
|
||||
|
||||
- 第一,当主线程执行JavaScript时,堆中的内容随时都有可能发生变化,从而使得辅助线程之前做的工作完全无效;
|
||||
- 第二,主线程和辅助线程极有可能在同一时间去更改同一个对象,这就需要额外实现读写锁的一些功能了。
|
||||
|
||||
尽管并行回收要额外解决以上两个问题,但是权衡利弊,并行回收这种方式的效率还是远高于其他方式的。
|
||||
|
||||
不过,这三种技术在实际使用中,并不是单独的存在,通常会将其融合在一起使用,V8的主垃圾回收器就融合了这三种机制,来实现垃圾回收,那它具体是怎么工作的呢?你可以先看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/42/7b8b901cb2eb575bb8907e1ad7dc1842.jpg" alt=""><br>
|
||||
可以看出来,主垃圾回收器同时采用了这三种策略:
|
||||
|
||||
- 首先主垃圾回收器主要使用并发标记,我们可以看到,在主线程执行JavaScript,辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的。
|
||||
- 标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作。
|
||||
- 另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种JavaScript任务之间执行。
|
||||
|
||||
## 总结
|
||||
|
||||
V8最开始的垃圾回收器有两个特点,第一个是垃圾回收在主线程上执行,第二个特点是一次执行一个完整的垃圾回收流程。
|
||||
|
||||
由于这两个原因,很容易造成主线程卡顿,所以V8采用了很多优化执行效率的方案。
|
||||
|
||||
第一个方案是并行回收,在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。
|
||||
|
||||
第二个方案是增量式垃圾回收,垃圾回收器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。
|
||||
|
||||
第三个方案是并发回收,回收线程在执行JavaScript的过程,辅助线程能够在后台完成的执行垃圾回收的操作。
|
||||
|
||||
主垃圾回收器就综合采用了所有的方案,副垃圾回收器也采用了部分方案。
|
||||
|
||||
## 思考题
|
||||
|
||||
虽然V8为执行垃圾回收的效率做了大量的优化,但是在实际项目中我们依然要关心内存问题,那么今天我留给你的思考题是:在使用JavaScript时,如何避免内存泄漏?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
243
极客时间专栏/geek/图解 Google V8/事件循环和垃圾回收/22|答疑:几种常见内存问题的解决策略.md
Normal file
243
极客时间专栏/geek/图解 Google V8/事件循环和垃圾回收/22|答疑:几种常见内存问题的解决策略.md
Normal file
@@ -0,0 +1,243 @@
|
||||
<audio id="audio" title="22|答疑:几种常见内存问题的解决策略" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0a/ba/0a69f4cac554d7a6b6700137566247ba.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
这是我们“事件循环和垃圾回收”这个模块的最后一讲。在这个模块中,我们讲了消息循环系统和垃圾回收机制,这两块内容涉及到了比较底层的知识,但是这些知识对实际的项目有着非常重要的指导作用,很多同学也比较关注这两部分内容。
|
||||
|
||||
今天这节答疑课,我们来结合Node中的读文件操作,分析下消息循环系统是怎么影响到异步编程的,然后我们再来结合JavaScript中的几种常见的内存问题,来分析下内存问题出现的原因和解决方法。
|
||||
|
||||
## Node中的readFile API工作机制
|
||||
|
||||
Node中很多API都提供了同步和异步两种形式,下面我们来看下《[17 | 消息队列:V8是怎么实现回调函数的?](https://time.geekbang.org/column/article/227926)》这节课留的思考题。思考题中有两段代码,我们通过这两段代码来分析下同步和异步读文件API的区别。
|
||||
|
||||
```
|
||||
var fs = require('fs')
|
||||
|
||||
var data = fs.readFileSync('test.js')
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
function fileHanlder(err, data){
|
||||
data.toString()
|
||||
}
|
||||
|
||||
fs.readFile('test.txt', fileHanlder)
|
||||
|
||||
```
|
||||
|
||||
在解答这个问题之前,我们来看看Node的体系架构。你可以先参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/eb/b2894f2297a23a9d706d0517610deeeb.jpg" alt="">
|
||||
|
||||
Node是V8的宿主,它会给V8提供事件循环和消息队列。在Node中,事件循环是由libuv提供的,libuv工作在主线程中,它会从消息队列中取出事件,并在主线程上执行事件。
|
||||
|
||||
同样,对于一些主线程上不适合处理的事件,比如消耗时间过久的网络资源下载、文件读写、设备访问等,Node会提供很多线程来处理这些事件,我们把这些线程称为线程池。
|
||||
|
||||
通常,在Node中,我们认为读写文件是一个非常耗时的工作,因此主线程会将回调函数和读文件的操作一道发送给文件读写线程,并让实际的读写操作运行在读写线程中。
|
||||
|
||||
比如当在Node的主线程上执行readFile的时候,主线程会将readFile的文件名称和回调函数,提交给文件读写线程来处理,具体过程如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/ff/654cccf962dccd2797bd1267ab82b9ff.jpg" alt="">
|
||||
|
||||
文件读写线程完成了文件读取之后,会将结果和回调函数封装成新的事件,并将其添加进消息队列中。比如文件线程将读取的文件内容存放在内存中,并将data指针指向了该内存,然后文件读写线程会将data和回调函数封装成新的事件,并将其丢进消息队列中,具体过程如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/99/daaad54f06e7bb25dbb3b8174f55bf99.jpg" alt="">
|
||||
|
||||
等到libuv从消息队列中读取该事件后,主线程就可以着手来处理该事件了。在主线程处理该事件的过程中,主线程调用事件中的回调函数,并将data结果数据作为参数,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/c2/b9e3c603cfa7d3f47178c06ffd945fc2.jpg" alt="">
|
||||
|
||||
然后在回调函数中,我们就可以拿到读取的结果来实现一些业务逻辑了。
|
||||
|
||||
不过,总有些人觉得异步读写文件操作过于复杂了,如果读取的文件体积不大或者项目瓶颈不在文件读写,那么依然使用异步调用和回调函数的模式就显得有点过度复杂了。
|
||||
|
||||
因此Node还提供了一套同步读写的API。第一段代码中的readFileSync就是同步实现的,同步代码非常简单,当libuv读取到readFileSync的任务后,就直接在主线程上执行读写操作,等待读写结束,直接返回读写的结果,这也是同步回调的一种应用。当然在读写过程中,消息队列中的其他任务是无法被执行的。
|
||||
|
||||
所以在选择使用同步API还是异步API时,我们要看实际的场景,并不是非A即B。
|
||||
|
||||
## 几种内存问题
|
||||
|
||||
分析了异步API,接下来我们再来看看JavaScript中的内存问题,内存问题至关重要,因为通过内存而造成的问题很容易被用户察觉。总的来说,内存问题可以定义为下面这三类:
|
||||
|
||||
- **内存泄漏(<strong><strong>Memory leak**</strong>)</strong>,它会导致页面的性能越来越差;
|
||||
- **内存膨胀(<strong><strong>Memory bloat**</strong>)</strong>,它会导致页面的性能会一直很差;
|
||||
- **频繁垃圾回收**,它会导致页面出现延迟或者经常暂停。
|
||||
|
||||
### 内存泄漏
|
||||
|
||||
我们先看内存泄漏。本质上,内存泄漏可以定义为:当进程不再需要某些内存的时候,这些不再被需要的内存依然没有被进程回收。
|
||||
|
||||
在JavaScript中,造成内存泄漏(**Memory leak)**的主要原因是不再需要(没有作用)的内存数据依然被其他对象引用着。
|
||||
|
||||
下面我们就来看几种实际的例子:
|
||||
|
||||
我们知道,JavaScript是一门非常宽松的语言,你甚至可以使用一个未定义的变量,比如下面这样一段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
//创建一个临时的temp_array
|
||||
temp_array = new Array(200000)
|
||||
/**
|
||||
* 使用temp_array
|
||||
*/
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当执行这段代码时,由于函数体内的对象没有被var、let、const这些关键字声明,那么V8就会使用this.temp_array替换temp_array。
|
||||
|
||||
```
|
||||
function foo() {
|
||||
//创建一个临时的temp_array
|
||||
this.temp_array = new Array(200000)
|
||||
/**
|
||||
* this.temp_array
|
||||
*/
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在浏览器,默认情况下,this是指向window对象的,而window对象是常驻内存的,所以即便foo函数退出了,但是temp_array依然被window对象引用了, 所以temp_array依然也会和window对象一样,会常驻内存。因为temp_array已经是不再被使用的对象了,但是依然被window对象引用了,这就造成了temp_array的泄漏。
|
||||
|
||||
为了解决这个问题,我们可以在 JavaScript 文件头部加上`use strict`,使用严格模式避免意外的全局变量,此时上例中的this指向undefined。
|
||||
|
||||
另外,我们还要时刻警惕闭包这种情况,因为闭包会引用父级函数中定义的变量,如果引用了不被需要的变量,那么也会造成内存泄漏。比如你可以看下面这样一段代码:
|
||||
|
||||
```
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,foo函数使用了一个局部临时变量temp_object,temp_object对象有三个属性,x、y,还有一个非常占用内存的array属性。最后foo函数返回了一个匿名函数,该匿名函数引用了temp_object.x。那么当调用完foo函数之后,由于返回的匿名函数引用了foo函数中的temp_object.x,这会造成temp_object 无法被销毁,即便只是引用了temp_object.x,也会造成整个temp_object 对象依然保留在内存中。我们可以通过Chrome调试工具查看下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/10/ff81eec387d021a3b4a3d019c09cbb10.jpg" alt="">
|
||||
|
||||
从上图可以看出,我们仅仅是需要temp_object.x的值,V8却保留了整个temp_object对象。
|
||||
|
||||
要解决这个问题,我就需要根据实际情况,来判断闭包中返回的函数到底需要引用什么数据,不需要引用的数据就绝不引用,因为上面例子中,返回函数中只需要temp_object.x的值,因此我们可以这样改造下这段代码:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
var temp_object = new Object()
|
||||
temp_object.x = 1
|
||||
temp_object.y = 2
|
||||
temp_object.array = new Array(200000)
|
||||
/**
|
||||
* 使用temp_object
|
||||
*/
|
||||
let closure = temp_object.x
|
||||
return function(){
|
||||
console.log(closure);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当再次执行这段代码时,我们就可以看到闭包引用的仅仅是一个closure的变量,最终如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/ca/8c3309fd82201bf67d5c92b58d58e6ca.jpg" alt="">
|
||||
|
||||
我们再来看看由于JavaScript引用了DOM节点而造成的内存泄漏的问题,只有同时满足DOM树和JavaScript代码都不引用某个DOM节点,该节点才会被作为垃圾进行回收。 如果某个节点已从 DOM 树移除,但JavaScript 仍然引用它,我们称此节点为“**detached** ”。“**detached ”**节点是DOM内存泄漏的常见原因。比如下面这段代码:
|
||||
|
||||
```
|
||||
let detachedTree;
|
||||
function create() {
|
||||
|
||||
var ul = document.createElement('ul');
|
||||
|
||||
for (var i = 0; i < 100; i++) {
|
||||
|
||||
var li = document.createElement('li');
|
||||
|
||||
ul.appendChild(li);
|
||||
|
||||
}
|
||||
|
||||
detachedTree = ul;
|
||||
|
||||
}
|
||||
|
||||
create()
|
||||
|
||||
```
|
||||
|
||||
我们通过JavaScript创建了一些DOM元素,有了这些内存中的DOM元素,当有需要的时候,我们就快速地将这些DOM元素关联到DOM树上,一旦这些DOM元素从DOM上被移除后,它们并不会立即销毁,这主要是由于JavaScript代码中保留了这些元素的引用,导致这些DOM元素依然会呆在内存中。所以在保存 DOM 元素引用的时候,我们需要非常小心谨慎。
|
||||
|
||||
### 内存膨胀
|
||||
|
||||
了解几种可能造成内存泄漏的问题之后,接下来,我们再来看看另外一个和内存泄漏类似的问题:**内存膨胀(<strong><strong>Memory bloat**</strong>)</strong>。
|
||||
|
||||
内存膨胀和内存泄漏有一些差异,内存膨胀主要表现在程序员对内存管理的不科学,比如只需要50M内存就可以搞定的,有些程序员却花费了500M内存。
|
||||
|
||||
额外使用过多的内存有可能是没有充分地利用好缓存,也有可能加载了一些不必要的资源。通常表现为内存在某一段时间内快速增长,然后达到一个平稳的峰值继续运行。
|
||||
|
||||
比如一次性加载了大量的资源,内存会快速达到一个峰值。内存膨胀和内存泄漏的关系你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/10/992872337410ff5915e288e68f2c2e10.jpg" alt="">
|
||||
|
||||
我们可以看到,内存膨胀是快速增长,然后达到一个平衡的位置,而内存泄漏是内存一直在缓慢增长。要避免内存膨胀,我们需要合理规划项目,充分利用缓存等技术来减轻项目中不必要的内存占用。
|
||||
|
||||
### 频繁的垃圾回收
|
||||
|
||||
除了内存泄漏和内存膨胀,还有另外一类内存问题,那就是频繁使用大的临时变量,导致了新生代空间很快被装满,从而频繁触发垃圾回收。频繁的垃圾回收操作会让你感觉到页面卡顿。比如下面这段代码:
|
||||
|
||||
```
|
||||
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()
|
||||
|
||||
```
|
||||
|
||||
这段代码就会频繁创建临时变量,这种方式很快就会造成新生代内存内装满,从而频繁触发垃圾回收。为了解决频繁的垃圾回收的问题,你可以考虑将这些临时变量设置为全局变量。
|
||||
|
||||
## 总结
|
||||
|
||||
这篇答疑主要分析了两个问题,第一个是异步API和同步API的底层差异,第二个是JavaScript的主要内存问题的产生原因和解决方法。
|
||||
|
||||
Node为读写文件提供了两套API,一套是默认的异步API,另外一套是同步API。
|
||||
|
||||
readFile就是异步API,主线程在执行readFile的时候,会将实际读写操作丢给文件读写线程,文件读写线程处理完成之后,会将回调函数读取的结果封装成新的消息,添加到消息队列中,然后等主线执行该消息的时候,就会执行readFile设置的回调函数,这就是Node中的异步处理过程。readFileSync是同步API,同步API很简单,直接在主线程上执行,执行完成直接返回结果给它的调用函数。使用同步API会比较方便简单,但是你需要考虑项目能否接受读取文件而造成的暂停。
|
||||
|
||||
内存问题对于前端开发者来说也是至关重要的,通常有三种内存问题:内存泄漏(Memory leak)、内存膨胀(Memory bloat)、频繁垃圾回收。
|
||||
|
||||
在JavaScript中,造成内存泄漏(Memory leak)的主要原因,是不再需要(没有作用)的内存数据依然被其他对象引用着。所以要避免内存泄漏,我们需要避免引用那些已经没有用途的数据。
|
||||
|
||||
内存膨胀和内存泄漏有一些差异,内存膨胀主要是由于程序员对内存管理不科学导致的,比如只需要50M内存就可以搞定的,有些程序员却花费了500M内存。要解决内存膨胀问题,我们需要对项目有着透彻的理解,也要熟悉各种能减少内存占用的技术方案。
|
||||
|
||||
如果频繁使用大的临时变量,那么就会导致频繁垃圾回收,频繁的垃圾回收操作会让你感觉到页面卡顿,要解决这个问题,我们可以考虑将这些临时变量设置为全局变量。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天留给你的题目是,在实际的项目中,你还遇到过哪些具体的内存问题呢?这些问题都是怎么解决的?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
256
极客时间专栏/geek/图解 Google V8/宏观视角/01 | V8是如何执行一段JavaScript代码的?.md
Normal file
256
极客时间专栏/geek/图解 Google V8/宏观视角/01 | V8是如何执行一段JavaScript代码的?.md
Normal file
@@ -0,0 +1,256 @@
|
||||
<audio id="audio" title="01 | V8是如何执行一段JavaScript代码的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1f/d5/1fa9a6c625f9bc7a440b7ddd655338d5.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
今天是我们整个课程的第一讲,我会从一个高层的宏观视角来解释什么是V8,以及V8又是怎么执行一段JavaScript代码的。在这个过程中,我会引入一些核心概念,诸如JIT、作用域、词法环境、执行上下文等,理解了这些概念,能够帮助你更好地理解V8是如何工作的,同时也能帮助你写出更加高效的JavaScript代码。
|
||||
|
||||
由于本节的目的是对V8做一个宏观的、全面的介绍,其目的是让你对V8的执行流程有个整体上的认识,所以涉及到的概念会比较多,如果你对其中一些概念不太理解也没有关系,在后面的章节中我会展开了详细地介绍。
|
||||
|
||||
## 什么是V8?
|
||||
|
||||
首先我们来看看什么是V8。
|
||||
|
||||
V8是一个由Google开发的开源JavaScript引擎,目前用在Chrome浏览器和Node.js中,其核心功能是执行易于人类理解的JavaScript代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/4d/ca2cf22c8b2b322022666a3183db1b4d.jpg" alt="" title="V8执行JavaScript">
|
||||
|
||||
那么V8又是怎么执行JavaScript代码的呢?
|
||||
|
||||
其主要核心流程分为编译和执行两步。首先需要将JavaScript代码转换为低级中间代码或者机器能够理解的机器代码,然后再执行转换后的代码并输出执行结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/bf/b77593de2fc7754d146e1218c45ef2bf.jpg" alt="" title="转换为中间代码">
|
||||
|
||||
你可以把V8看成是一个虚构出来的计算机,也称为虚拟机,虚拟机通过模拟实际计算机的各种功能来实现代码的执行,如模拟实际计算机的CPU、堆栈、寄存器等,虚拟机还具有它自己的一套指令系统。
|
||||
|
||||
所以对于JavaScript代码来说,V8就是它的整个世界,当V8执行JavaScript代码时,你并不需要担心现实中不同操作系统的差异,也不需要担心不同体系结构计算机的差异,你只需要按照虚拟机的规范写好代码就可以了。
|
||||
|
||||
既然V8是虚构出来的计算机,用来编译和执行JavaScript代码,那么接下来我们就看看,为什么计算机需要对JavaScript这样的高级语言进行编译,以及编译完成后又是如何执行的。
|
||||
|
||||
## 高级代码为什么需要先编译再执行?
|
||||
|
||||
我们先从CPU是怎么执行机器代码讲起,你可以把CPU看成是一个非常小的运算机器,我们可以通过二进制的指令和CPU进行沟通,比如我们给CPU发出“1000100111011000”的二进制指令,这条指令的意思是将一个寄存器中的数据移动到另外一个寄存器中,当处理器执行到这条指令的时候,便会按照指令的意思去实现相关的操作。
|
||||
|
||||
为了能够完成复杂的任务,工程师们为CPU提供了一大堆指令,来实现各种功能,我们就把这一大堆指令称为**指令集(Instructions)**,也就是**机器语言**。
|
||||
|
||||
注意,CPU只能识别二进制的指令,但是对程序员来说,二进制代码难以阅读和记忆,于是我们又将二进制指令集转换为人类可以识别和记忆的符号,这就是**汇编指令集**,你可以参考下面的代码:
|
||||
|
||||
```
|
||||
1000100111011000 机器指令
|
||||
mov ax,bx 汇编指令
|
||||
|
||||
```
|
||||
|
||||
那么你可能会问,CPU能直接识别汇编语言吗?
|
||||
|
||||
答案是“不能”,所以如果你使用汇编编写了一段程序,你还需要一个汇编编译器,其作用是将汇编代码编程成机器代码,具体流程你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/1f/6bb6d19ec37ea1a7d2cab2a25ea62b1f.jpg" alt="" title="汇编编译器">
|
||||
|
||||
虽然汇编语言对机器语言做了一层抽象,减少了程序员理解机器语言的复杂度,但是汇编语言依然是复杂且繁琐的,即便你写一个非常简单的功能,也需要实现大量的汇编代码,这主要表现在以下两点。
|
||||
|
||||
首先,**不同的CPU有着不同的指令集**,如果要使用机器语言或者汇编语言来实现一个功能,那么你需要为每种架构的CPU编写特定的汇编代码,这会带来巨大的、枯燥繁琐的操作,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/70/75f4f88099f82bec62def94541189b70.jpg" alt="" title="需要为每种架构的CPU编写特定的汇编代码">
|
||||
|
||||
其次,**在编写汇编代码时,我们还需要了解和处理器架构相关的硬件知识**,比如你需要使用寄存器、内存、操作CPU等。大部分程序员在编写应用的时候,只想专心处理业务逻辑,并不想要过多地理会这些处理器架构相关的细节。
|
||||
|
||||
因此我们需要一种屏蔽了计算机架构细节的语言,能适应多种不同CPU架构的语言,能专心处理业务逻辑的语言,诸如C、C++、Java、C#、Python、JavaScript等,这些“高级语言”就应运而生了。
|
||||
|
||||
和汇编语言一样,处理器也不能直接识别由高级语言所编写的代码,那怎么办呢?通常,要有两种方式来执行这些代码。
|
||||
|
||||
第一种是解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。具体流程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/5e/330ad69589d898f6609dfc083bfbe95e.jpg" alt="" title="解释执行流程图">
|
||||
|
||||
第二种是编译执行。采用这种方式时,也需要先将源代码转换为中间代码,然后我们的编译器再将中间代码编译成机器代码。通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1f/d3/1f933e42e81dacc8f4f2d86e01a914d3.jpg" alt="" title="编译执行流程图"><br>
|
||||
以上就是计算机执行高级语言的两种基本的方式:解释执行和编译执行。但是针对不同的高级语言,这个实现方式还是有很大差异的,比如要执行C语言编写的代码,你需要将其编译为二进制代码的文件,然后再直接执行二进制代码。而对于像Java语言、JavaScript语言等,则需要不同虚拟机,模拟计算机的这个编译执行流程。执行Java语言,需要经过Java虚拟机的转换,执行JavaScript需要经过JavaScript虚拟机的转换。
|
||||
|
||||
即便是JavaScript一门语言,也有好几种流行的虚拟机,它们之间的实现方式也存在着一部分差异,比如苹果公司在Safari中就是用JavaScriptCore虚拟机,Firefox使用了TraceMonkey虚拟机,而Chrome则使用了V8虚拟机。
|
||||
|
||||
## V8是怎么执行JavaScript代码的?
|
||||
|
||||
那么,V8作为JavaScript的虚拟机的一种,它到底是怎么执行JavaScript代码的呢?是解释执行,还是编译执行呢?
|
||||
|
||||
实际上,V8并没有采用某种单一的技术,而是混合编译执行和解释执行这两种手段,我们把这种混合使用编译器和解释器的技术称为JIT(Just In Time)技术。
|
||||
|
||||
这是一种权衡策略,因为这两种方法都各自有各自的优缺点,解释执行的启动速度快,但是执行时的速度慢,而编译执行的启动速度慢,但是执行时的速度快。你可以参考下面完整的V8执行JavaScript的流程图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/54/8a34ae8c1a7a0f87e19b1384a025e354.jpg" alt="" title="V8执行一段JavaScript流程图">
|
||||
|
||||
我们先看上图中的最左边的部分,在V8启动执行JavaScript之前,它还需要准备执行JavaScript时所需要的一些基础环境,这些基础环境包括了“堆空间”“栈空间”“全局执行上下文”“全局作用域”“消息循环系统”“内置函数”等,这些内容都是在执行JavaScript过程中需要使用到的,比如:
|
||||
|
||||
- JavaScript全局执行上下文就包含了执行过程中的全局信息,比如一些内置函数,全局变量等信息;
|
||||
- 全局作用域包含了一些全局变量,在执行过程中的数据都需要存放在内存中;
|
||||
- 而V8是采用了经典的堆和栈的内存管理模式,所以V8还需要初始化内存中的堆和栈结构;
|
||||
- 另外,想要我们的V8系统活起来,还需要初始化消息循环系统,消息循环系统包含了消息驱动器和消息队列,它如同V8的心脏,不断接受消息并决策如何处理消息。
|
||||
|
||||
基础环境准备好之后,接下来就可以向V8提交要执行的JavaScript代码了。
|
||||
|
||||
首先V8会接收到要执行的JavaScript源代码,不过这对V8来说只是一堆字符串,V8并不能直接理解这段字符串的含义,它需要**结构化**这段字符串。结构化,是指信息经过分析后可分解成多个互相关联的组成部分,各组成部分间有明确的层次结构,方便使用和维护,并有一定的操作规范。
|
||||
|
||||
V8源代码的结构化之后,就生成了抽象语法树(AST),我们称为AST,AST是便于V8理解的结构。
|
||||
|
||||
这里还需要注意一点,在生成AST的同时,V8还会生成相关的作用域,作用域中存放相关变量,我们会在《[ 06 | 作用域链:V8是如何查找变量的?](https://time.geekbang.org/column/article/217027)》和《[12 | 延迟解析:V8是如何实现闭包的?](https://time.geekbang.org/column/article/223168)》这两节课中详细分析。
|
||||
|
||||
有了AST和作用域之后,接下来就可以生成字节码了,字节码是介于AST和机器代码的中间代码。但是与特定类型的机器代码无关,解释器可以直接解释执行字节码,或者通过编译器将其编译为二进制的机器代码再执行。我们会在 《[13|字节码(一):V8为什么又重新引入字节码?](https://time.geekbang.org/column/article/224206)》这节课中详细介绍字节码的前世今生。
|
||||
|
||||
好了,生成了字节码之后,解释器就登场了,它会按照顺序解释执行字节码,并输出执行结果。
|
||||
|
||||
相信你注意到了,我们在解释器附近画了个监控机器人,这是一个监控解释器执行状态的模块,在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码。
|
||||
|
||||
当某段代码被标记为热点代码后,V8就会将这段字节码丢给优化编译器,优化编译器会在后台将字节码编译为二进制代码,然后再对编译后的二进制代码执行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。如果下面再执行到这段代码时,那么V8会优先选择优化之后的二进制代码,这样代码的执行速度就会大幅提升。
|
||||
|
||||
不过,和静态语言不同的是,JavaScript是一种非常灵活的动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。
|
||||
|
||||
## 跟踪一段实际代码的执行流程
|
||||
|
||||
我们以一段最简单的JavaScript代码为例,如果将这段非常简单的代码提交给V8引擎,V8在处理过程中,中间所产生的结果是怎样的呢?下面我们就一步一步详细“追踪”下。
|
||||
|
||||
代码如下所示:
|
||||
|
||||
```
|
||||
var test = 'GeekTime'
|
||||
|
||||
```
|
||||
|
||||
我们知道,首先这段代码会被解析器结构化成AST,下面我们就来看看第一阶段生成的AST是什么样子的?
|
||||
|
||||
要查看V8中间生成的一些结构,可以使用V8提供的调试工具D8来查看,你可以将上面那段代码保存到test.js的文件中,然后执行下面命令:
|
||||
|
||||
```
|
||||
d8 --print-ast test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段命令之后,D8会打印出如下内容:
|
||||
|
||||
```
|
||||
--- AST ---
|
||||
FUNC at 0
|
||||
. KIND 0
|
||||
. LITERAL ID 0
|
||||
. SUSPEND COUNT 0
|
||||
. NAME ""
|
||||
. INFERRED NAME ""
|
||||
. DECLS
|
||||
. . VARIABLE (0x7ff0e3022298) (mode = VAR, assigned = true) "test"
|
||||
. BLOCK NOCOMPLETIONS at -1
|
||||
. . EXPRESSION STATEMENT at 11
|
||||
. . . INIT at 11
|
||||
. . . . VAR PROXY unallocated (0x7ff0e3022298) (mode = VAR, assigned = true) "test"
|
||||
. . . . LITERAL "GeekTime"
|
||||
|
||||
```
|
||||
|
||||
上面这个结构就是AST,它就是JS源代码的结构化表述,AST是个树状结构,直观地理解,你可以将其转换为一个图形树,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/57/87/57018772d9a93d367c07ce51b91e1f87.jpg" alt="" title="将源码解析为AST">
|
||||
|
||||
从图中可以看出,AST和代码结构也是一一对应关系,并且后续所有的操作都会直接或者间接基于它。
|
||||
|
||||
上面我们还提到了,在生成AST的同时,还会生成作用域,同样我们使用D8来看看它生成的作用域是什么样子,你可以使用下面的命令来查看作用域:
|
||||
|
||||
```
|
||||
d8 --print-scopes test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段命令之后,D8会打印出如下内容:
|
||||
|
||||
```
|
||||
Global scope:
|
||||
global { // (0x7fd974022048) (0, 24)
|
||||
// will be compiled
|
||||
// 1 stack slots
|
||||
// temporary vars:
|
||||
TEMPORARY .result; // (0x7fd9740223c8) local[0]
|
||||
// local vars:
|
||||
VAR test; // (0x7fd974022298)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这行代码生成了一个全局作用域,我们可以看到test变量被添加进了这个全局作用域中。
|
||||
|
||||
生成了AST和作用域之后,就可以使用解释器生成字节码了,同样你可以使用D8来打印生成后的字节码,打印的命令如下所示:
|
||||
|
||||
```
|
||||
d8 --print-bytecode test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段语句,最终打印出来的结果如下所示:
|
||||
|
||||
```
|
||||
[generated bytecode for function: (0x2b510824fd55 <SharedFunctionInfo>)]
|
||||
Parameter count 1
|
||||
Register count 4
|
||||
Frame size 32
|
||||
0x2b510824fdd2 @ 0 : a7 StackCheck
|
||||
0x2b510824fdd3 @ 1 : 12 00 LdaConstant [0]
|
||||
0x2b510824fdd5 @ 3 : 26 fa Star r1
|
||||
0x2b510824fdd7 @ 5 : 0b LdaZero
|
||||
0x2b510824fdd8 @ 6 : 26 f9 Star r2
|
||||
0x2b510824fdda @ 8 : 27 fe f8 Mov <closure>, r3
|
||||
0x2b510824fddd @ 11 : 61 32 01 fa 03 CallRuntime [DeclareGlobals], r1-r3
|
||||
0x2b510824fde2 @ 16 : 12 01 LdaConstant [1]
|
||||
0x2b510824fde4 @ 18 : 15 02 02 StaGlobal [2], [2]
|
||||
0x2b510824fde7 @ 21 : 0d LdaUndefined
|
||||
0x2b510824fde8 @ 22 : ab Return
|
||||
Constant pool (size = 3)
|
||||
0x2b510824fd9d: [FixedArray] in OldSpace
|
||||
- map: 0x2b51080404b1 <Map>
|
||||
- length: 3
|
||||
0: 0x2b510824fd7d <FixedArray[4]>
|
||||
1: 0x2b510824fd1d <String[#8]: GeekTime>
|
||||
2: 0x2b51081c8549 <String[#4]: test>
|
||||
Handler Table (size = 0)
|
||||
Source Position Table (size = 0)
|
||||
|
||||
```
|
||||
|
||||
上面就是这段代码生成的中间字节码,关于字节码,我们会在后续课程《[14 | 字节码(二):解释器是如何解释执行字节码的?](https://time.geekbang.org/column/article/224908)》来介绍,在这里我们先有一个大致认知就可以了。
|
||||
|
||||
生成字节码之后,解释器会解释执行这段字节码,如果重复执行了某段代码,监控器就会将其标记为热点代码,并提交给编译器优化执行,如果你想要查看那些代码被优化了,可以使用下面的命令:
|
||||
|
||||
```
|
||||
d8 --trace-opt test.js
|
||||
|
||||
```
|
||||
|
||||
如果要查看那些代码被反优化了,可以使用如下命令行来查看:
|
||||
|
||||
```
|
||||
pt --trace-deopt test.js
|
||||
|
||||
```
|
||||
|
||||
由于我们这段代码过于简单,没有触发V8的优化机制,在这里我们也就不展开介绍优化机制了,具体的流程。我会在《[15 | 隐藏类:如何在内存中快速查找对象属性?](https://time.geekbang.org/column/article/226417)》这一节展开详细介绍。
|
||||
|
||||
## 总结
|
||||
|
||||
V8是由Google开发的开源JavaScript引擎,也被称为虚拟机,模拟实际计算机各种功能来实现代码的编译和执行。那么,要想搞清楚V8内部的工作流程和原理,我们可以从分析计算机对语言的编译和执行过程入手。
|
||||
|
||||
因为计算机只能识别二进制指令,所以要让计算机执行一段高级语言通常有两种手段,第一种是将高级代码转换为二进制代码,再让计算机去执行;另外一种方式是在计算机安装一个解释器,并由解释器来解释执行。
|
||||
|
||||
解释执行和编译执行都有各自的优缺点,解释执行启动速度快,但是执行时速度慢,而编译执行启动速度慢,但是执行速度快。为了充分地利用解释执行和编译执行的优点,规避其缺点,V8采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么V8就会采用优化编译器将其编译成执行效率更加高效的机器代码。
|
||||
|
||||
理解了这一点,我们就可以来深入分析V8执行一段JavaScript代码所经历的主要流程了,这包括了:
|
||||
|
||||
- 初始化基础环境;
|
||||
- 解析源码生成AST和作用域;
|
||||
- 依据AST和作用域生成字节码;
|
||||
- 解释执行字节码;
|
||||
- 监听热点代码;
|
||||
- 优化热点代码为二进制的机器代码;
|
||||
- 反优化生成的二进制机器代码。
|
||||
|
||||
这里你需要注意的是,JavaScript是一门动态语言,在运行过程中,某些被优化的结构可能会被V8动态修改了,这会导致之前被优化的代码失效,如果某块优化之后的代码失效了,那么编译器需要执行反优化操作。
|
||||
|
||||
## 课后思考
|
||||
|
||||
最后,给你留一道思考题:除了V8采用了JIT技术,还有哪些虚拟机采用了JIT技术?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,73 @@
|
||||
<audio id="audio" title="开篇词 | 如何学习谷歌高性能 JavaScript 引擎V8?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/b1/ef76d1d1048d55598e0b0b8b761891b1.mp3"></audio>
|
||||
|
||||
你好,我是李兵,《[浏览器工作原理与实践](https://time.geekbang.org/column/intro/216?utm_term=zeusQYFJN&utm_source=app&utm_medium=geektime&utm_campaign=216-end&utm_content=v8zhuanlankaipianci0316)》专栏的作者。在浏览器专栏中,我们对浏览器的工作原理进行了详细的介绍,其中也提到了V8是如何执行JavaScript代码的。很多朋友对这部分的学习意犹未尽,因此我又回到了极客时间,与你深入聊聊V8。
|
||||
|
||||
## 什么是V8?
|
||||
|
||||
V8是JavaScript虚拟机的一种。我们可以简单地把JavaScript虚拟机理解成是一个翻译程序,将人类能够理解的**编程语言JavaScript**,翻译成机器能够理解的**机器语言**。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/a1/8a40fd003baa9be179fe2e55a1be5fa1.jpg" alt="" title="JavaScript引擎">
|
||||
|
||||
上图中,中间的“黑盒”就是JavaScript引擎V8。目前市面上有很多种JavaScript引擎,诸如SpiderMonkey、V8、JavaScriptCore等。而由谷歌开发的开源项目V8是当下使用最广泛的JavaScript虚拟机,全球有超过25亿台安卓设备,而这些设备中都使用了Chrome浏览器,所以我们写的JavaScript应用,大都跑在V8上。
|
||||
|
||||
V8之所以拥有如此庞大的生态圈,也和它许多革命性的设计是分不开的。
|
||||
|
||||
在V8出现之前,所有的JavaScript虚拟机所采用的都是解释执行的方式,这是JavaScript执行速度过慢的一个主要原因。而V8率先引入了即时编译(JIT)的双轮驱动的设计,这是一种权衡策略,混合编译执行和解释执行这两种手段,给JavaScript的执行速度带来了极大的提升。
|
||||
|
||||
V8出现之后,各大厂商也都在自己的JavaScript虚拟机中引入了JIT机制,所以你会看到目前市面上JavaScript虚拟机都有着类似的架构。另外,V8也是早于其他虚拟机引入了惰性编译、内联缓存、隐藏类等机制,进一步优化了JavaScript代码的编译执行效率。
|
||||
|
||||
可以说,V8的出现,将JavaScript虚拟机技术推向了一个全新的高度。
|
||||
|
||||
即便V8具有诸多优点,但我相信对于大部分同学来说,V8虚拟机还只是一个黑盒,我们将一段代码丢给这个黑盒,它便会返回结果,并没有深入了解过它的工作原理。
|
||||
|
||||
如果只是单纯使用JavaScript和调用 Web API,并不了解虚拟机内部是怎样工作的,在项目中遇到的很多问题很可能找不到解决的途径。比如,有时项目的占用内存过高,或者页面响应速度过慢,又或者使用Node.js的时候导致任务被阻塞等问题,都与V8的基本运行机制有关。如果你熟悉V8的工作机制,就会有系统性的思路来解决这些问题。
|
||||
|
||||
另外,V8的主要功能,就是结合JavaScript语言的特性和本质来编译执行它。通过深入地学习V8,你对JavaScript语言本质和设计思想会有很直观的感受。这些设计思想像是更加高级的工具,你掌握了它,就可以提升你的语言使用和架构设计水平。
|
||||
|
||||
## 如何学习V8?
|
||||
|
||||
那么,我们应该如何来学习V8呢?
|
||||
|
||||
刚刚我们也说过,V8的主要职责是用来执行JavaScript代码的,那我们首先需要先了解JavaScript这门语言的基本特性和设计思想。
|
||||
|
||||
JavaScript借鉴了很多语言的特性,比如C语言的基本语法、Java的类型系统和内存管理、Scheme的函数作为一等公民,还有Self基于原型(prototype)的继承机制。毫无疑问,JavaScript是一门非常优秀的语言,特别是“原型继承机制”和“函数是一等公民”这两个设计。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/7a/f8fb9e3570b88152f9ab7b6b8d385c7a.jpg" alt="" title="JavaScript的设计思想">
|
||||
|
||||
不过JavaScript也是一门处处是坑的语言,由于历史原因,很多错误的或者不合理的设计都被延续至今,比如使用new加构造函数来创建对象,这种方式的背后隐藏了太多的细节,非常容易增加代码出错概率,而且也大大增加了新手的学习成本;再比如初期的JavaScript没有块级作用域机制,使得JavaScript需要采取变量提升的策略,而变量提升又是非常反人性的设计。
|
||||
|
||||
V8是JavaScript的实现,在学习V8工作原理时,我们就要格外关注JavaScript这些独特的设计思想和特性背后的实现。比如,为了实现函数是一等公民的特性,JavaScript采取了基于对象的策略;再比如为了实现原型继承,V8为每个对象引入了__proto__属性。
|
||||
|
||||
**深入分析过JavaScript语言之后,我们就可以学习V8执行JavaScript代码的完整流程了**。我们把这套流程称之为V8的编译流水线,其完整流程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/54/8a34ae8c1a7a0f87e19b1384a025e354.jpg" alt="" title="V8编译流水线">
|
||||
|
||||
编译流水线本身并不复杂,但是其中涉及到了很多技术,诸如JIT、延迟解析、隐藏类、内联缓存等等。这些技术决定着一段JavaScript代码能否正常执行,以及代码的执行效率。
|
||||
|
||||
比如V8中使用的隐藏类(Hide Class),这是将JavaScript中动态类型转换为静态类型的一种技术,可以消除动态类型的语言执行速度过慢的问题,如果你熟悉V8的工作机制,在你编写JavaScript时,就能充分利用好隐藏类这种强大的优化特性,写出更加高效的代码。
|
||||
|
||||
再比如,V8实现了JavaScript代码的惰性解析,目的是为了加速代码的启动速度,通过对惰性解析机制的学习,你可以优化你的代码更加适应这个机制,从而提高程序性能。
|
||||
|
||||
要想充分了解V8是怎么工作的,除了要分析编译流水线,我们还需要了解另外两个非常重要的特性,那就是**事件循环系统**和**垃圾回收机制。**
|
||||
|
||||
事件循环系统和JavaScript中的难点——异步编程特性紧密相关。我们知道,JavaScript是单线程的,JavaScript代码都是在一个线程上执行,如果同一时间发送了多个JavaScript执行的请求,就需要排队,也就是进行异步编程。
|
||||
|
||||
V8的事件循环系统会调度这些排队任务,保证JavaScript代码被V8有序地执行。因此也可以说,事件循环系统就是V8的心脏,它驱动了V8的持续工作。
|
||||
|
||||
另外,JavaScript是一种自动垃圾回收的语言,V8在执行垃圾回收时,会占用主线程的资源,如果我们编写的程序频繁触发垃圾回收,那么无疑会阻塞主线程,这也是我们经常会遇到的一个问题。你需要知道V8是如何分配内存数据的,以及这些数据是如何被回收的,打通整个链路,建立完整的系统,当下次遇到内存问题时,就知道如何去排查了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/43/90228d5cc0afbaaa4cca3fbdb1349243.jpg" alt="" title="V8知识图谱,可以点击放大查看">
|
||||
|
||||
以上,就是系统学习V8的路径。在我们这一季的课程中,也会按照这样的思路来设计课程,来帮助你学习到V8的完整的知识体系。
|
||||
|
||||
- 首先,我们会从JavaScript的设计思想讲起,讨论它背后的一些主要特性,以及V8是怎么实现这些特性的。
|
||||
- 然后,我们再来分析V8的编译流水线,在课程中间我们还会穿插介绍一些内存分配相关的内容,因为函数调用、变量声明、参数传递或者函数返回数值都涉及到了内存分配。
|
||||
- 最后,我们会介绍事件循环系统和垃圾回收系统的工作机制。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/e3/2684822c6cb6b453c6f4abb3d89822e3.jpg" alt="" title="专栏目录">
|
||||
|
||||
虽然本课程的篇幅不多,但是也具有一定的深度和广度。不过你并不需要担心内容太难,我会尽量将每节内容做到深入浅出,有什么问题你可以在留言区提问,我看到后都会第一时间来解答。
|
||||
|
||||
另外,在每个模块结束后,我会做一次热点问题的答疑,尽量帮你扫清学习V8的障碍。你需要做的是持之以恒地学习、反思与实践。
|
||||
|
||||
加油,从今天起,就让我们一起开始V8的学习旅程吧!
|
||||
41
极客时间专栏/geek/图解 Google V8/结束语/结束语 | 我的前端学习踩坑史.md
Normal file
41
极客时间专栏/geek/图解 Google V8/结束语/结束语 | 我的前端学习踩坑史.md
Normal file
@@ -0,0 +1,41 @@
|
||||
<audio id="audio" title="结束语 | 我的前端学习踩坑史" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9e/20/9e138217ad214b84c021ca32fc860a20.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
这是我们这个系列课程的最后一讲,今天我们不谈技术,我想跟你聊聊这么多年我学习前端踩过的一些坑和学习心得。
|
||||
|
||||
其实我早些年主要是做C/C++开发的,到了2009年,接触到了浏览器研发,这个时候我对前端并不是太熟悉,但是在开发浏览器的过程中,需要了解大量的前端知识。我当时的学习策略是,只有当遇到了与前端有关的问题时,才会去学习相应的知识来解决。这样做本意是想要节省时间,结果却反而让我花费了更多不必要的时间。
|
||||
|
||||
比如在开发双核浏览器的 Cookie 共享的过程中,我发现有一个例外,那就是设置了 HttpOnly 属性的 Cookie 是无法被共享的。
|
||||
|
||||
为了解决这个问题,我就去查 HttpOnly 相关概念,发现这和 Web 安全体系有关系,然后我就去补安全相关的知识。之后我又发现,浏览器多进程架构和安全系统息息相关,也就是说,这些知识是环环相扣的。
|
||||
|
||||
因为我当时对前端并没有做整体的了解,所以对浏览器中涉及到的安全相关的设计也没有过多关注。每次遇到一些和前端相关的细节问题时,我都需要花费很多时间去补充相关知识,然后再去解决问题,这种方式白白地浪费了我非常多的时间。更加遗憾的是,我之前积累的很多前端知识,都是这么缝缝补补学习过来的。
|
||||
|
||||
所以,当你要开发一个新项目或者学习一门手艺之前,应该将其所涉及到的知识做一个全方位的了解。我就觉得发明“技术栈”这个词的人是一个天才,“技术栈”非常形象地表达了学习一门手艺所需要的是哪些知识,以及应该按照什么顺序来学。
|
||||
|
||||
比如学习前端这门手艺,栈底到栈顶依次是浏览器架构、Web 网络、事件循环机制、JavaScript 核心、V8 的内存管理、浏览器的渲染流程、Web 安全、CSS、React、Vue、Node、构建工具链等,我们可以从栈底往栈顶一步步循序渐进地学习。
|
||||
|
||||
学习技术栈的每一层都不难,难的是当你抛开底层栈的内容去理解上层栈的内容,那么就有可能陷入知识点的盲区。
|
||||
|
||||
其实,我们在学习一门手艺的时候,即便没有完善的技术栈资料,只要肯花更多的时间,也可以克服困难。甚至我们的学习过程可以像一个无头苍蝇,横冲直撞,某一天也会突然顿悟整个知识体系 ,俗话说,在一个领域耕耘十年,必定能成为该领域的专家。
|
||||
|
||||
但是,三年可以搞定的事,为什么非要花上十年呢?
|
||||
|
||||
系统性学习一门技术,花费的时间也是最短的,也可以说是性价比最高的,因为系统性地、循序渐进地学习,那么学习到每个知识点时,其实并没有其他的知识盲区,这样学习起来是最轻松、简单的。
|
||||
|
||||
虽然技术栈的概念已经提出来很久了,但是我发现大家对技术栈的理解却不同。比如我们在招聘前端工程师时,经常会看到有人在简历上提到自己的技术栈包含了 Vue、React、Node 等这些偏应用的知识,在我看来,这并不是技术栈,这只不过是栈顶函数的上下文而已,而对于那些在技术栈底层上下文相关的知识,在简历中却很少有提及。所谓技术栈,应该是在某一领域,从底层的基础知识到上层的应用技术有一个完整体系。
|
||||
|
||||
那么当我们进入一个领域时,应该如何建立适合自己的技术栈呢?
|
||||
|
||||
如果你进入的是一个成熟的领域,那么一般都有比较完整的技术栈的资料,我们需要花些时间分析资料,系统性地了解这一领域知识的宏观架构、它的过往历史、它的优缺点,然后结合现有资料和我们自身的特点来建立我们自己的技术栈。如果你所在的领域还在高速发展中,并没有人总结出完整的技术栈,那么为了更好地理解技术的发展脉络,我们需要花更多一些的时间去整理出该领域的技术栈。
|
||||
|
||||
所以后面我就吸取了经验,比如我学习神经网络的过程中,就采取了先构建技术栈的方式,先系统性复习了微积分、概率论、线性代数,然后结合实际项目完成了数据清洗、构建模型、模型训练,最后到实际应用。整个过程我只花了半年时间,最后我对神经网络也有了非常深的认知,现在我们正在打算将神经网络运用到我们的新项目中。
|
||||
|
||||
采用了同样的的方法,我在学习区块链和数字货币时,整个过程也是非常轻松的,先分析区块链要解决什么问题,然后整理技术栈,再通过技术栈来一步步学习,不到两周,我基本就把整个数字货币的来龙去脉分析的非常清楚了。
|
||||
|
||||
好了,到了这里我们的课程也要告一段落了。最后我想跟你说的是,学习一门手艺其实并不难,难的是如何充分利用时间来高效学习,因为这世界最值钱的就是我们的专注力和时间,花三年干成一件事和花十年干成一件事的代价是不一样的,希望你都能够找到适合自己的方法来高效学习,也都能学有所成。
|
||||
|
||||
另外,如果你有时间,也请帮我填个[课程问卷](https://jinshuju.net/f/79b5xo),我和编辑大茹想收集更多的反馈来优化课程。今天虽然是结课,但我们的工作并没有结束,未来我还会回来加餐、回复留言。
|
||||
|
||||
再见。
|
||||
10
极客时间专栏/geek/图解 Google V8/结束语/结课测试 | 这些V8的知识你都掌握了吗?.md
Normal file
10
极客时间专栏/geek/图解 Google V8/结束语/结课测试 | 这些V8的知识你都掌握了吗?.md
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
到这里,《图解Google V8》这门课程已经全部结束了。我给你准备了一个结课小测试,来帮助你检验自己的学习效果。
|
||||
|
||||
这套测试题共有 20 道题目,包括16道单选题和4道多选题,满分 100 分,系统自动评分。
|
||||
|
||||
还等什么,点击下面按钮开始测试吧!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=190&exam_id=452)
|
||||
Reference in New Issue
Block a user