This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -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=&quot;John&quot;;
person.lastname=&quot;Doe&quot;;
person.age=50;
person.eyecolor=&quot;blue&quot;;
```
这个对象里面有四个属性,为了直观理解,你可以参看下图:
<img src="https://static001.geekbang.org/resource/image/d0/23/d07e174001a29765a3575908e3704123.jpg" alt="" title="对象的构成">
上图展示了对象person的结构我们可以看到蓝色的属性在左边黄色的值在右边有多组属性和值组成这就是JavaScript中的对象虽然JavaScript对象用途非常广泛使用的方式也非常之多但是万变不离其宗其核心本质都就是由一组组属性和值组成的集合抓住了这一点当我们再分析对象时就会轻松很多。
之所以JavaScript中对象的用途这么广是因为对象的值可以是任意类型的数据我们可以改造下上面的那段代码来看看对象的值都有哪些类型改造后的代码如下所示
```
var person=new Object()
person.firstname=&quot;John&quot;
person.lastname=&quot;Doe&quot;
person.info = new Object()
person.info.age=50
person.info.eyecolor=&quot;blue&quot;
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中的函数就实现了“函数是一等公民”的特性。
## 思考题
本文我们从对象聊到了闭包,那么留给你的问题是,哪些语言天生支持“函数是一等公民”?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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[&quot;B&quot;] = 'bar-B'
this[50] = 'test-50'
this[9] = 'test-9'
this[8] = 'test-8'
this[3] = 'test-3'
this[5] = 'test-5'
this[&quot;A&quot;] = 'bar-A'
this[&quot;C&quot;] = '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 &lt; element_num; i++) {
this[i] = `element${i}`
}
//添加常规属性
for (let i = 0; i &lt; 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内存快照截图">
上图就是收集了当前内存快照的界面要想查找我们刚才创建的对象你可以在搜索框里面输入构造函数FooChrome会列出所有经过构造函数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的原因吗欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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 &lt;anonymous&gt;: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
- 一个函数表达式可以被用作一个即时调用的函数表达式——IIFEImmediately 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()
```
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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: &quot;Default&quot;,
color: &quot;Default&quot;,
getInfo: function () {
return `Type is: ${this.type}color is ${this.color}.`
}
}
var dog = {
type: &quot;Dog&quot;,
color: &quot;Black&quot;,
}
```
在这段代码中我创建了两个对象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**_”这两个属性之间有关联吗欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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另外系统还为我们添加了另外一个隐藏变量thisV8还会默认将隐藏变量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函数作用域&gt;bar函数作用域&gt;全局作用域;
- 还是沿着foo函数作用域—&gt;全局作用域?
因为JavaScript是基于词法作用域的词法作用域就是指查找作用域的顺序是按照函数定义时的位置来决定的。bar和foo函数的外部代码都是全局代码所以无论你是在bar函数中查找变量还是在foo函数中查找变量其查找顺序都是按照当前函数作用域&gt;全局作用域这个路径来的。
由于我们代码中的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()
```
那么执行这段代码之后,打印出来的内容是什么?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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虚拟机会直接返回一个执行错误错误提示是这样的
```
&gt;&gt;&gt; 1+'2'
Traceback (most recent call last):
File &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
TypeError: unsupported operand type(s) for +: 'int' and 'str'
```
这段错误代码提示了这是个类型错误表明Python并不支持数字类型和字符串类型相加的操作。
不过在JavaScript中执行这段表达式是可以返回一个结果的最终返回的结果是字符串“12”。
最终结果如下所示:
```
&gt;&gt;&gt; 1+'2'
&gt;&gt;&gt; &quot;12&quot;
```
为什么同样一条的表达式在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 = &quot;John&quot; # 字符串
```
C/C++编译器负责将这些数据片段转换为供CPU处理的正确命令通常是二进制的机器代码。
在某些更高级的语言中还可以根据数据推断出类型比如在Python或JavaScript中你就不必为数据指定专门的数据类型在Python中你可以这样定义变量
```
counter = 100 # 赋值整型变量
miles = 1000.0 # 浮点型
name = &quot;John&quot; # 字符串
```
在JavaScript中你可以这样定义变量
```
var counter = 100 # 赋值整型变量
let miles = 1000.0 # 浮点型
const name = &quot;John&quot; # 字符串
```
虽然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() + &quot;2&quot;
```
这里把数字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 &lt;anonymous&gt;: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 &quot;200&quot;
},
valueOf() {
return 100
}
}
Obj+&quot;3&quot;
```
你觉得执行这段代码会打印出什么内容呢?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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=&quot;x64&quot; v8_target_cpu=&quot;arm64&quot; 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核CPUssd硬盘整个编译过程大概花费了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 &lt; 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="">
观察上图,我们可以看到终端中出现了一段优化的提示:
```
&lt;JSFunction foo (sfi = 0x2c730824fe21)&gt; for optimized recompilation, reason: small function]
```
这就是告诉我们已经使用TurboFan优化编译器将函数foo优化成了二进制代码执行foo时实际上是执行优化过的二进制代码。
现在我们把foo函数中的循环加到10万再来查看优化信息最终效果如下图所示
<img src="https://static001.geekbang.org/resource/image/ab/f5/abb9ff93abc9b6e3f848a940d46dd3f5.png" alt="">
我们看到又出现了一条新的优化信息,新的提示信息如下:
```
&lt;JSFunction foo (sfi = 0xc9c0824fe21)&gt; 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 &lt; len; ++i) {
arr[i] = str.charCodeAt(i)
}
return arr;
}
function foo() {
let i = 0
let str = 'test V8 GC'
while (i++ &lt; 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) -&gt; 0.3 (3.4) MB, 0.9 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
```
这句话的意思是提示“Scavenge … 分配失败”,是因为垃圾回收器**Scavenge**所负责的空间已经满了Scavenge主要回收V8中“**新生代**”中的内存大多数对象都是分配在新生代内存中内存分配到新生代中是非常快速的但是新生代的空间却非常小通常在18 MB之间一旦空间被填满Scavenge就会进行“清理”操作。
上面这段代码之所以能频繁触发新生代的垃圾回收,是因为它频繁地去申请内存,而申请内存之后,这块内存就立马变得无效了,为了减少垃圾回收的频率,我们尽量避免申请不必要的内存,比如我们可以换种方式来实现上述代码,如下所示:
```
function strToArray(str, bufferView) {
let i = 0
const len = str.length
for (; i &lt; 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++ &lt; 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 &lt; element_num; i++) {
this[i] = `element${i}`
}
//添加常规属性
for (let i = 0; i &lt; 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是否对你的例子进行了优化操作。欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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或者RNAV8只提供了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的生命周期是一致的所以在实际项目中如果不经常使用的变量或者数据最好不要放到全局执行上下文中。
另外,宿主环境还需要构造事件循环系统,事件循环系统主要用来处理任务的排队和任务的调度。
## 思考题
你认为作用域和执行上下文是什么关系?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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中的值传给ALUALU对它们进行相加操纵并将计算的结果写回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是怎么执行一段二进制机器代码的吗欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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函数在消息队列后面的任务中执行所以不会影响到当前的栈结构。 也就不会导致栈溢出。关于消息队列和事件循环系统,我们会在最后一单元来介绍。
最后一段代码是PromisePromise的情况比较特别既不会造成栈溢出但是这种方式会导致主线的卡死这就涉及到了微任务关于微任务在这里我们先不展开介绍我会在微任务这一节来详细介绍。
## 既然有了栈,为什么还要堆?
好了,我们现在理解了栈是怎么管理函数调用的了,使用栈有非常多的优势:
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-&gt;y = 400;
pp-&gt;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++这种手动管理内存的语言如果没有手动销毁堆中的数据那么就会造成内存泄漏。不过JavaScriptJava使用了自动垃圾回收策略可以实现垃圾自动回收但是事情总有两面性垃圾自动回收也会给我们带来一些性能问题。所以不管是自动垃圾回收策略还是手动垃圾回收策略要想写出高效的代码我们都需要了解内存的底层工作机制。
## 总结
因为现代语言都是基于函数的,每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为**调用栈。**
因为栈在内存中连续的数据结构,所以在通常情况下,栈都有最大容量限制,这也就意味着,函数的嵌套调用次数过多,就会超出栈的最大使用范围,从而导致栈溢出。
为了解决栈溢出的问题我们可以使用setTimeout将要执行的函数放到其他的任务中去执行也可以使用Promise来改变栈的调用方式这涉及到了事件循环和微任务我们会在后续课程中再来介绍。
## 思考题
你可以分析一下,在浏览器中执行这段代码,为什么不会造成栈溢出,但是却会造成页面的卡死?欢迎你在留言区与我分享讨论。
```
function foo() {
return Promise.resolve().then(foo)
}
foo()
```
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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会分别分配到栈上还是堆上为什么欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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虚拟机中的机器代码和字节码有哪些异同欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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))
```
在控制台执行这段代码会返回数字3V8是如何得到这个结果的呢
刚刚我们提到了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 &quot;add&quot;
. PARAMS
. . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) &quot;x&quot;
. . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) &quot;y&quot;
. DECLS
. . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) &quot;x&quot;
. . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) &quot;y&quot;
. . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) &quot;z&quot;
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 31
. . . INIT at 31
. . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) &quot;z&quot;
. . . . ADD at 32
. . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) &quot;x&quot;
. . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) &quot;y&quot;
. RETURN at 37
. . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) &quot;z&quot;
```
同样,我们将其图形化:
<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如果是函数声明那么将指向实际的函数对象。
一旦生成了作用域和ASTV8就可以依据它们来生成字节码了。AST之后会被作为输入传到字节码生成器(BytecodeGenerator)这是Ignition解释器中的一部分用于生成以函数为单位的字节码。你可以通过print-bytecode命令查看生成的字节码。
```
[generated bytecode for function: add (0x079e0824fdc1 &lt;SharedFunctionInfo add&gt;)]
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执行二进制机器代码的模式是类似的
- 使用内存中的一块区域来存放字节码;
- 使用了通用寄存器 r0r1r2…… 这些寄存器用来存放一些中间数据;
- 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]
```
这是将小整数Smi2 加载到累加器寄存器中,操作流程你可以参看下图:
<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生成字节码然后分析字节码的执行流程欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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的偏移量是4y的偏移量是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 &lt;Map(HOLEY_ELEMENTS)&gt; [FastProperties]
- prototype: 0x19dc08241151 &lt;Object map = 0x19dc082801c1&gt;
- elements: 0x19dc080406e9 &lt;FixedArray[0]&gt; [HOLEY_ELEMENTS]
- properties: 0x19dc080406e9 &lt;FixedArray[0]&gt; {
#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 &lt;Map(HOLEY_ELEMENTS)&gt;
- prototype_validity cell: 0x19dc081c0451 &lt;Cell value= 1&gt;
- instance descriptors (own) #2: 0x19dc080c5b25 &lt;DescriptorArray[2]&gt;
- prototype: 0x19dc08241151 &lt;Object map = 0x19dc082801c1&gt;
- constructor: 0x19dc0824116d &lt;JSFunction Object (sfi = 0x19dc081c55ad)&gt;
- dependent code: 0x19dc080401ed &lt;Other heap object (WEAK_FIXED_ARRAY_TYPE)&gt;
- 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 &lt;Map(HOLEY_ELEMENTS)&gt; [FastProperties]
- ...
DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
- map: 0x098608284ce9 &lt;Map(HOLEY_ELEMENTS)&gt; [FastProperties]
- ...
- properties: 0x0986080406e9 &lt;FixedArray[0]&gt; {
#x: 100 (const data field 0)
}
DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
- map: 0x098608284d11 &lt;Map(HOLEY_ELEMENTS)&gt; [FastProperties]
- p
- ...
- properties: 0x0986080406e9 &lt;FixedArray[0]&gt; {
#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 &lt;Map(HOLEY_ELEMENTS)&gt; [FastProperties]
-...
- properties: 0x1c2f080406e9 &lt;FixedArray[0]&gt; {
#x: 100 (const data field 0)
#y: 200 (const data field 1)
}
DebugPrint: 0x1c2f080c5b1d: [JS_OBJECT_TYPE]
- map: 0x1c2f08284d11 &lt;Map(HOLEY_ELEMENTS)&gt; [FastProperties]
- ...
- properties: 0x1c2f08045567 &lt;FixedArray[0]&gt; {
#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可以通过隐藏类快速获取对象的属性值。不过这里还有另外一类问题需要考虑。
比如我定义了一个获取对象属性值的函数loadXloadX有一个参数然后返回该参数的x属性值
```
function loadX(o) {
return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3 ,y:6}
for (var i = 0; i &lt; 90000; i++) {
loadX(o)
loadX(o1)
}
```
当V8调用loadX的时候会先查找参数o的隐藏类然后利用隐藏类中的x属性的偏移量查找到x的属性值虽然利用隐藏类能够快速提升对象属性的查找速度但是依然有一个查找隐藏类和查找隐藏类中的偏移量两个操作如果loadX在代码中会被重复执行依然影响到了属性的查找效率。
那么留给你的问题是如果你是V8的设计者你会采用什么措施来提高loadX函数的执行效率欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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 &lt; 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.xreturn 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 &lt; 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)**
- 如果一个插槽中包含了24个隐藏类那我们称这种状态为**多态(polymorphic)**
- 如果一个插槽中超过4个隐藏类那我们称这种状态为**超态(magamorphic)。**
如果函数loadX的反馈向量中存在多态或者超态的情况其执行效率肯定要低于单态的比如当执行到o.x的时候V8会查询反馈向量的第一个插槽发现里面有多个map的记录那么V8就需要取出o的隐藏类来和插槽中记录的隐藏类一一比较如果记录的隐藏类越多那么比较的次数也就越多这就意味着执行效率越低。
比如插槽中包含了24个隐藏类那么可以使用线性结构来存储如果超过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引入了ICIC会监听每个函数的执行过程并在一些关键的地方埋下监听点这些包括了加载对象属性(Load)、给对象属性赋值(Store)、还有函数调用(Call)V8会将监听到的数据写入一个称为**反馈向量(FeedBack Vector)**的结构中同时V8会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据V8就可以缩短对象属性的查找路径从而提升执行效率。
但是针对函数中的同一段代码,如果对象的隐藏类是不同的,那么反馈向量也会记录这些不同的隐藏类,这就出现了多态和超态的情况。我们在实际项目中,要尽量避免出现多态或者超态的情况。
最后我还想强调一点虽然我们分析的隐藏类和IC能提升代码的执行速度但是在实际的项目中影响执行性能的因素非常多**找出那些影响性能瓶颈才是至关重要**的,**你不需要过度关注微优化你也不需要过度担忧你的代码是否破坏了隐藏类或者IC的机制**,因为相对于其他的性能瓶颈,它们对效率的影响可能是微不足道的。
## 思考题
观察下面两段代码:
```
let data = [1, 2, 3, 4]
data.forEach((item) =&gt; console.log(item.toString())
```
```
let data = ['1', 2, '3', 4]
data.forEach((item) =&gt; console.log(item.toString())
```
你认为这两段代码,哪段的执行效率高,为什么?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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 = [&quot;water&quot;, &quot;goods&quot;, &quot;123&quot;, &quot;like&quot;];
function handlerArray(indexName,index){
console.log(index + 1 + &quot;. &quot; + indexName);
}
myArray.forEach(handlerArray)
```
在这段代码中我们通过JavaScript自带的forEach方法来枚举数字中的每个项这里的逻辑很简单
- 调用forEach时需要使用回调函数handlerArray作为其参数
- 在forEach方法内部会遍历myArray数组每遍历一次都会调用一次回调函数handlerArray。
因为handlerArray是forEach的参数而且handlerArray是在forEach函数内部执行所以这是一个同步回调。
和同步回调函数不同的是异步回调函数并不是在它的执行函数内部被执行的而是在其他的位置和其他的时间点被执行的比如下面这段setTimeout代码
```
function foo() {
alert(&quot;Hello&quot;);
}
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()
})
```
那么请你分别分析下它们的执行流程。欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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) =&gt;console.log('micro-bar')
)
setTimeout((str) =&gt;console.log('macro-bar'),0)
}
function foo() {
console.log('foo')
Promise.resolve().then(
(str) =&gt;console.log('micro-foo')
)
setTimeout((str) =&gt;console.log('macro-foo'),0)
bar()
}
foo()
console.log('global')
Promise.resolve().then(
(str) =&gt;console.log('micro-global')
)
setTimeout((str) =&gt;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相关资料分析它是如何工作的其中微任务的作用是什么欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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 + &quot;?id=&quot;+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) =&gt; {
return response.text()
})
.then((response) =&gt; {
let new_name_url = name_url + &quot;?id=&quot; + response
return fetch(new_name_url)
}).then((response) =&gt; {
return response.text()
}).then((response) =&gt; {
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 + &quot;?id=&quot; + 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) =&gt; {
return result.next(response).value
}).then((response) =&gt; {
return result.next(response).value
}).then((response) =&gt; {
return result.next(response).value
}).then((response) =&gt; {
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 + &quot;?id=&quot; + 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+&quot;?id=&quot;+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) =&gt; {})
}
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) =&gt; {
setTimeout(() =&gt; {
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) =&gt; {
resolve(100)
})
}
async function getResult() {
let a = await ResolvePromise()
console.log(a)
}
getResult()
console.log(3)
```
## 总结
Callback模式的异步编程模型需要实现大量的回调函数大量的回调函数会打乱代码的正常逻辑使得代码变得不线性、不易阅读这就是我们所说的回调地狱问题。
使用Promise能很好地解决回调地狱的问题我们可以按照线性的思路来编写代码这个过程是线性的非常符合人的直觉。
但是这种方式充满了Promise的then()方法如果处理流程比较复杂的话那么整段代码将充斥着大量的then语义化不明显代码不能很好地表示执行流程。
我们想要通过线性的方式来编写异步代码,要实现这个理想,最关键的是要能实现函数暂停和恢复执行的功能。而生成器就可以实现函数暂停和恢复,我们可以在生成器中使用同步代码的逻辑来异步代码(实现该逻辑的核心是协程),但是在生成器之外,我们还需要一个触发器来驱动生成器的执行,因此这依然不是我们最终想要的方案。
我们的最终方案就是async/awaitasync是一个可以暂停和恢复执行的函数我们会在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的运行原理是什么欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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中会把堆分为新生代和老生代两个区域**新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象**。
新生代通常只支持18M的容量而老生代支持的容量就大很多了。对于这两块区域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 &lt; len; ++i) {
arr[i] = str.charCodeAt(i)
}
return arr;
}
function foo() {
let i = 0
let str = 'test V8 GC'
while (i++ &lt; 1e5) {
strToArray(str);
}
}
foo()
```
课后请你想一想V8执行这段代码的过程中产生了哪些垃圾数据以及V8又是如何回收这些垃圾的数据的 最后站在内存空间和主线程资源的角度来分析,如何优化这段代码。欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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时如何避免内存泄漏欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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_objecttemp_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 &lt; 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 &lt; len; ++i) {
arr[i] = str.charCodeAt(i)
}
return arr;
}
function foo() {
let i = 0
let str = 'test V8 GC'
while (i++ &lt; 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内存。要解决内存膨胀问题我们需要对项目有着透彻的理解也要熟悉各种能减少内存占用的技术方案。
如果频繁使用大的临时变量,那么就会导致频繁垃圾回收,频繁的垃圾回收操作会让你感觉到页面卡顿,要解决这个问题,我们可以考虑将这些临时变量设置为全局变量。
## 思考题
今天留给你的题目是,在实际的项目中,你还遇到过哪些具体的内存问题呢?这些问题都是怎么解决的?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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并没有采用某种单一的技术而是混合编译执行和解释执行这两种手段我们把这种混合使用编译器和解释器的技术称为JITJust 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)我们称为ASTAST是便于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 &quot;&quot;
. INFERRED NAME &quot;&quot;
. DECLS
. . VARIABLE (0x7ff0e3022298) (mode = VAR, assigned = true) &quot;test&quot;
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 11
. . . INIT at 11
. . . . VAR PROXY unallocated (0x7ff0e3022298) (mode = VAR, assigned = true) &quot;test&quot;
. . . . LITERAL &quot;GeekTime&quot;
```
上面这个结构就是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 &lt;SharedFunctionInfo&gt;)]
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 &lt;closure&gt;, 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 &lt;Map&gt;
- length: 3
0: 0x2b510824fd7d &lt;FixedArray[4]&gt;
1: 0x2b510824fd1d &lt;String[#8]: GeekTime&gt;
2: 0x2b51081c8549 &lt;String[#4]: test&gt;
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技术欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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&amp;utm_source=app&amp;utm_medium=geektime&amp;utm_campaign=216-end&amp;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的学习旅程吧

View 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),我和编辑大茹想收集更多的反馈来优化课程。今天虽然是结课,但我们的工作并没有结束,未来我还会回来加餐、回复留言。
再见。

View 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&amp;exam_id=452)