This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

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())
```
你认为这两段代码,哪段的执行效率高,为什么?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。