mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-02 07:13:45 +08:00
mod
This commit is contained in:
170
极客时间专栏/图解 Google V8/V8编译流水线/09 | 运行时环境:运行JavaScript代码的基石.md
Normal file
170
极客时间专栏/图解 Google V8/V8编译流水线/09 | 运行时环境:运行JavaScript代码的基石.md
Normal file
@@ -0,0 +1,170 @@
|
||||
<audio id="audio" title="09 | 运行时环境:运行JavaScript代码的基石" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/29/15eb6d774122be87b49b2f6ab7e1af29.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
通过前面几节课的学习,我们理解了JavaScript是一门基于对象的语言,它能实现非常多的特性,诸如函数是一等公民、闭包、函数式编程、原型继承等,搞懂了这些特性,我们就可以来打开V8这个黑盒,深入了解它的编译流水线了。
|
||||
|
||||
我们知道,当你想执行一段JavaScript代码时,只需要将代码丢给V8虚拟机,V8便会执行并返回给你结果。
|
||||
|
||||
其实在执行JavaScript代码之前,V8就已经准备好了代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。准备好运行时环境之后,V8才可以执行JavaScript代码,这包括解析源码、生成字节码、解释执行或者编译执行这一系列操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/54/a89d747fb614a17e08b1a6b7dce62b54.jpg" alt="">
|
||||
|
||||
对运行时环境有足够的了解,能够帮助我们更好地理解V8的执行流程。比如事件循环系统可以让你清楚各种回调函数是怎么被执行的,栈空间可以让你了解函数是怎么被调用的,堆空间和栈空间让你了解为什么要有传值和传引用,等等。
|
||||
|
||||
运行时环境涉及到的知识都是非常基础,但又非常容易被忽视的。今天这节课,我们就来分析下这些基础的运行时环境。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/49/9ad5d32bce98aad219c9f73513ac6349.jpg" alt="">
|
||||
|
||||
## 什么是宿主环境?
|
||||
|
||||
要聊运行V8的运行时环境,我们不得不聊V8的宿主环境,什么是V8的宿主环境呢?
|
||||
|
||||
在生物学上,宿主是指为病毒等寄生物提供生存环境的生物,宿主有自己的完整的代谢系统,而病毒则没有自己的代谢系统,也没有自己的酶系统,它只是由核酸长链和蛋白质外壳构成。
|
||||
|
||||
因此,病毒想要完成自我复制,则会和宿主共同使用一套代谢系统,当病毒离开了宿主细胞,就成了没有任何生命活动,也不能独立自我繁殖的化学物质。同时,如果病毒利用了太多的宿主细胞资源,也会影响到细胞的正常活动。
|
||||
|
||||
同样,你可以把V8和浏览器的渲染进程的关系看成病毒和细胞的关系,浏览器为V8提供基础的消息循环系统、全局变量、Web API,而V8的核心是实现了ECMAScript标准,这相当于病毒自己的DNA或者RNA,V8只提供了ECMAScript定义的一些对象和一些核心的函数,这包括了Object、Function、String。除此之外,V8还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要宿主环境的配合才能完整执行。
|
||||
|
||||
如果V8使用不当,比如不规范的代码触发了频繁的垃圾回收,或者某个函数执行时间过久,这些都会占用宿主环境的主线程,从而影响到程序的执行效率,甚至导致宿主环境的卡死。
|
||||
|
||||
其实,除了浏览器可以作为V8的宿主环境,Node.js也是V8的另外一种宿主环境,它提供了不同的宿主对象和宿主的API,但是整个流程依然是相同的,比如Node.js也会提供一套消息循环系统,也会提供一个运行时的主线程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/2f/e541d8611b725001509bfcd6797f492f.jpg" alt="" title="宿主环境和V8的关系">
|
||||
|
||||
好了,现在我们知道,要执行V8,则需要有一个宿主环境,宿主环境可以是浏览器中的渲染进程,可以是Node.js进程,也可以是其他的定制开发的环境,而这些宿主则提供了很多V8执行JavaScript时所需的基础功能部件,接下来我们就来一一分析下这些部件。
|
||||
|
||||
## 构造数据存储空间:堆空间和栈空间
|
||||
|
||||
由于V8是寄生在浏览器或者Node.js这些宿主中的,因此,V8也是被这些宿主启动的。比如,在Chrome中,只要打开一个渲染进程,渲染进程便会初始化V8,同时初始化堆空间和栈空间。
|
||||
|
||||
栈空间主要是用来管理JavaScript函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。
|
||||
|
||||
栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8对栈空间的大小做了限制,如果函数调用层过深,那么V8就有可能抛出栈溢出的错误。你可以在控制台执行下面这样一段代码:
|
||||
|
||||
```
|
||||
function factorial(n){
|
||||
if(n === 1) {return 1;}
|
||||
return n*factorial(n-1);
|
||||
}
|
||||
console.log(factorial(50000))
|
||||
|
||||
```
|
||||
|
||||
执行这段代码,便会报出这样的错误:
|
||||
|
||||
```
|
||||
VM68:1 Uncaught RangeError: Maximum call stack size exceeded
|
||||
|
||||
```
|
||||
|
||||
这段提示是说,调用栈超出了最大范围,因为我们这里求阶乘的函数需要嵌套调用5万层,而栈提供不了这么大的空间,所以就抛出了栈溢出的错误。
|
||||
|
||||
如果有一些占用内存比较大的数据,或者不需要存储在连续空间中的数据,使用栈空间就显得不是太合适了,所以V8又使用了堆空间。
|
||||
|
||||
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,在前面的课程中我们也讲过,JavaScript中除了原生类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有window对象、document对象等,这些都是存在堆空间的。
|
||||
|
||||
宿主在启动V8的过程中,会同时创建堆空间和栈空间,再继续往下执行,产生的新数据都会存放在这两个空间中。
|
||||
|
||||
## 全局执行上下文和全局作用域
|
||||
|
||||
V8初始化了基础的存储空间之后,接下来就需要初始化全局执行上下文和全局作用域了,这两个内容是V8执行后续流程的基础。
|
||||
|
||||
当 V8开始执行一段可执行代码时,会生成一个执行上下文。V8用执行上下文来维护执行当前代码所需要的变量声明、this指向等。
|
||||
|
||||
执行上下文中主要包含三部分,变量环境、词法环境和this关键字。比如在浏览器的环境中,全局执行上下文中就包括了window对象,还有默认指向window的this关键字,另外还有一些Web API函数,诸如setTimeout、XMLHttpRequest等内容。
|
||||
|
||||
而词法环境中,则包含了使用let、const等变量的内容。
|
||||
|
||||
执行上下文所包含的具体内容,你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/f5/0b4929e11b49856037ffdcf00508d4f5.jpg" alt="" title="什么是执行上下文">
|
||||
|
||||
全局执行上下文在V8的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了。另外,当你执行了一段全局代码时,如果全局代码中有声明的函数或者定义的变量,那么函数对象和声明的变量都会被添加到全局执行上下文中。比如下面这段代码:
|
||||
|
||||
```
|
||||
var x = 1
|
||||
function show_x(){
|
||||
console.log(x)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
V8在执行这段代码的过程中,会在全局执行上下文中添加变量x和函数show_x。
|
||||
|
||||
在这里还有一点需要注意下,全局作用域和全局执行上下文的关系,其实你可以把作用域看成是一个抽象的概念,比如在ES6中,同一个全局执行上下文中,都能存在多个作用域,你可以看下面这段代码:
|
||||
|
||||
```
|
||||
var x = 5
|
||||
{
|
||||
let y = 2
|
||||
const z = 3
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码在执行时,就会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中。具体你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/4d/2400b1d780a7abfabdf48b39607f244d.jpg" alt="" title="全局作用域和子作用域关系">
|
||||
|
||||
当V8调用了一个函数时,就会进入函数的执行上下文,这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构。比如执行下面这段代码:
|
||||
|
||||
```
|
||||
var x = 1
|
||||
function show_x(){
|
||||
console.log(x)
|
||||
}
|
||||
function bar(){
|
||||
show_x()
|
||||
}
|
||||
bar()
|
||||
|
||||
```
|
||||
|
||||
当执行到show_x的时候,其栈状态如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/e1/86ee3d0f4b2a8e8cc6c2b3fc320dcce1.jpg" alt="" title="函数调用栈">
|
||||
|
||||
## 构造事件循环系统
|
||||
|
||||
有了堆空间和栈空间,生成了全局执行上下文和全局作用域,接下来就可以执行JavaScript代码了吗?
|
||||
|
||||
答案是不行,因为V8还需要有一个主线程,用来执行JavaScript和执行垃圾回收等工作。V8是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8所执行的代码都是在宿主的主线程上执行的。
|
||||
|
||||
只有一个主线程依然不行,因为如果你开启一个线程,在该线程执行一段代码,那么当该线程执行完这段代码之后,就会自动退出了,执行过程中的一些栈上的数据也随之被销毁,下次再执行另外一个段代码时,你还需要重新启动一个线程,重新初始化栈数据,这会严重影响到程序执行时的性能。
|
||||
|
||||
为了在执行完代码之后,让线程继续运行,通常的做法是在代码中添加一个循环语句,在循环语句中监听下个事件,比如你要执行另外一个语句,那么激活该循环就可以执行了。比如下面的模拟代码:
|
||||
|
||||
```
|
||||
while(1){
|
||||
Task task = GetNewTask();
|
||||
RunTask(task);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码使用了一个循环,不同地获取新的任务,一旦有新的任务,便立即执行该任务。
|
||||
|
||||
如果主线程正在执行一个任务,这时候又来了一个新任务,比如V8正在操作DOM,这时候浏览器的网络线程完成了一个页面下载的任务,而且V8注册监听下载完成的事件,那么这种情况下就需要引入一个消息队列,让下载完成的事件暂存到消息队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务。当执行完一个任务之后,我们的事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务。
|
||||
|
||||
有一点你需要注意一下,因为所有的任务都是运行在主线程的,在浏览器的页面中,V8会和页面共用主线程,共用消息队列,所以如果V8执行一个函数过久,会影响到浏览器页面的交互性能。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,这节课的内容就介绍到这里,下面我们来总结一下:
|
||||
|
||||
今天我们介绍了V8执行JavaScript代码时所需要的基础环境,因为V8并不是一个完整的系统,所以在执行时,它的一部分基础环境是由宿主提供的,这包括了全局执行上下文、事件循环系统,堆空间和栈空间。除了需要宿主提供的一些基础环境之外,V8自身会提供JavaScript的核心功能和垃圾回收系统。
|
||||
|
||||
宿主环境在启动过程中,会构造堆空间,用来存放一些对象数据,还会构造栈空间,用来存放原生数据。由于堆空间中的数据不是线性存储的,所以堆空间可以存放很多数据,但是读取的速度会比较慢,而栈空间是连续的,所以堆空间中的查找速度非常快,但是要在内存中找到一块连续的区域却显得有点难度,于是所有的程序都限制栈空间的大小,这就是我们经常容易出现栈溢出的一个主要原因。
|
||||
|
||||
如果在浏览器中,JavaScript代码会频繁操作window(this默认指向window对象)、操作dom等内容,如果在node中,JavaScript会频繁使用global(this默认指向global对象)、File API等内容,这些内容都会在启动过程中准备好,我们把这些内容称之为全局执行上下文。
|
||||
|
||||
全局执行上下文中和函数的执行上下文生命周期是不同的,函数执行上下文在函数执行结束之后,就会被销毁,而全局执行上下文则和V8的生命周期是一致的,所以在实际项目中,如果不经常使用的变量或者数据,最好不要放到全局执行上下文中。
|
||||
|
||||
另外,宿主环境还需要构造事件循环系统,事件循环系统主要用来处理任务的排队和任务的调度。
|
||||
|
||||
## 思考题
|
||||
|
||||
你认为作用域和执行上下文是什么关系?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
244
极客时间专栏/图解 Google V8/V8编译流水线/10 | 机器代码:二进制机器码究竟是如何被CPU执行的?.md
Normal file
244
极客时间专栏/图解 Google V8/V8编译流水线/10 | 机器代码:二进制机器码究竟是如何被CPU执行的?.md
Normal 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中的值传给ALU,ALU对它们进行相加操纵,并将计算的结果写回ecx。
|
||||
|
||||
还有一个非常重要的指令,是跳转指令,从指令本身抽取出一个字,这个字是下一条要执行的指令的地址,并将该字复制到PC寄存器中,并覆盖掉PC寄存器中原来的值。那么当执行下一条指令时,便会跳转到对应的指令了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/ca/a69affcd27b2646fff920a0c0ab08aca.jpg" alt="">
|
||||
|
||||
观察上图,上图是通过jmp来实现的,jmp后面跟着要跳转的内存中的指令地址。
|
||||
|
||||
除了以上指令之外,还有IO读/写指令,这些指令可以从一个IO设备中复制指定长度的数据到寄存器中,也可以将一个寄存器中的数据复制到指定的IO设备。
|
||||
|
||||
以上就是一些基础的指令类型,这些指令像积木,利用它们可以搭建我们现在复杂的软件大厦。
|
||||
|
||||
## 分析一段汇编代码的执行流程
|
||||
|
||||
好了,了解指令的类型,接下来我们就可以分析上面那段简单的程序的执行过程了,不过在这里还有一些前置的知识没有介绍,比如内存中的栈、栈帧的概念,这些内容我会在下一节详细介绍。本节中如果提到了栈和栈帧,你可以将它们看成是内存中的一块区域即可。
|
||||
|
||||
在C程序中,CPU会首先执行调用main函数,在调用main函数时,CPU会保存上个栈帧上下文信息和创建当前栈帧的上下文信息,主要是通过下面这两条指令实现的:
|
||||
|
||||
```
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp
|
||||
|
||||
```
|
||||
|
||||
第一条指令pushq %rbp,是将rbp寄存器中的值写到内存中的栈区域。第二条指令是将rsp寄存器中的值写到rbp寄存器中。
|
||||
|
||||
然后将0写到栈帧的第一个位置,对应的汇编代码如下:
|
||||
|
||||
```
|
||||
movl $0, -4(%rbp)
|
||||
|
||||
```
|
||||
|
||||
接下来给x和y赋值,对应的代码是下面两行:
|
||||
|
||||
```
|
||||
movl $1, -8(%rbp)
|
||||
movl $2, -12(%rbp)
|
||||
|
||||
```
|
||||
|
||||
第一行指令是将常数值1压入到栈中,然后再将常数值2压入到栈中,这两个值分别对应着x和y。
|
||||
|
||||
接下来,x的值从栈中复制到eax寄存器中,对应的指令如下所示:
|
||||
|
||||
```
|
||||
movl -8(%rbp), %eax
|
||||
|
||||
```
|
||||
|
||||
现在eax寄存器中保存了x的值,那么接下来,再将内存中的y和eax中的x相加,相加的结果再保存在eax中,对应的指令如下所示:
|
||||
|
||||
```
|
||||
addl -12(%rbp), %eax
|
||||
|
||||
```
|
||||
|
||||
现在x+y的结果保存在了eax中了,接下来CPU会将结果保存中内存中,执行如下指令:
|
||||
|
||||
```
|
||||
movl %eax, -16(%rbp)
|
||||
|
||||
```
|
||||
|
||||
最后又将结果z加载到eax寄存器中,代码如下所示:
|
||||
|
||||
```
|
||||
movl -16(%rbp), %eax
|
||||
|
||||
```
|
||||
|
||||
注意这里的eax寄存器中的内容就被默认作为返回值了,执行到这里函数基本就执行结束了,然后需要继续执行一些恢复现场的操作,代码如下所示:
|
||||
|
||||
```
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
到了这里,我们整个程序就执行结束了。
|
||||
|
||||
## 总结
|
||||
|
||||
今天这节课,我们的主要目的是讲清楚CPU是怎么执行一段二进制代码的,这涉及到了CPU、寄存器、运算器、编译、汇编等一系列的知识。
|
||||
|
||||
我们从如何执行一段C代码讲起,由于CPU只能执行机器代码,所以我们需要将C代码转换为机器代码,这个转换过程就是由C编译器完成的。
|
||||
|
||||
CPU执行机器代码的逻辑非常简单,首先编译之后的二进制代码被加载进内存,然后CPU就按照指令的顺序,一行一行地执行。
|
||||
|
||||
在执行指令的过程中,CPU需要对数据执行读写操作,如果直接读写内存,那么会严重影响程序的执行性能,因此CPU就引入了寄存器,将一些中间数据存放在寄存器中,这样就能加速CPU的执行速度。
|
||||
|
||||
有了寄存器之后,CPU执行指令的操作就变得复杂了一点,因为需要寄存器和内存之间传输数据,或者寄存器和寄存器之间传输数据。我们通常有以下几种方式来使用寄存器,这包括了**加载指令、存储指令、更新指令。**通过配合这几种类型的指令,我们就可以实现完整的程序功能了。
|
||||
|
||||
## 思考题
|
||||
|
||||
你能用自己的语言复述下CPU是怎么执行一段二进制机器代码的吗?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
279
极客时间专栏/图解 Google V8/V8编译流水线/11 | 堆和栈:函数调用是如何影响到内存布局的?.md
Normal file
279
极客时间专栏/图解 Google V8/V8编译流水线/11 | 堆和栈:函数调用是如何影响到内存布局的?.md
Normal file
@@ -0,0 +1,279 @@
|
||||
<audio id="audio" title="11 | 堆和栈:函数调用是如何影响到内存布局的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/f0/e7d1a7bf75dc5d4fed322581b806c2f0.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
相信你在使用JavaScript的过程中,经常会遇到栈溢出的错误,比如执行下面这样一段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
foo() // 是否存在堆栈溢出错误?
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
V8就会报告**栈溢出**的错误,为了解决栈溢出的问题,我们可以在foo函数内部使用setTimeout来触发foo函数的调用,改造之后的程序就可以正确执行 。
|
||||
|
||||
```
|
||||
function foo() {
|
||||
setTimeout(foo, 0) // 是否存在堆栈溢出错误?
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果使用Promise来代替setTimeout,在Promise的then方法中调用foo函数,改造的代码如下:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
return Promise.resolve().then(foo)
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
在浏览器中执行这段代码,并没有报告栈溢出的错误,但是你会发现,执行这段代码会让整个页面卡住了。
|
||||
|
||||
为什么这三段代码,第一段造成栈溢出的错误,第二段能够正确执行,而第三段没有栈溢出的错误,却会造成页面的卡死呢?
|
||||
|
||||
其主要原因是这三段代码的底层执行逻辑是完全不同的:
|
||||
|
||||
- 第一段代码是在同一个任务中重复调用嵌套的foo函数;
|
||||
- 第二段代码是使用setTimeout让foo函数在不同的任务中执行;
|
||||
- 第三段代码是在同一个任务中执行foo函数,但是却不是嵌套执行。
|
||||
|
||||
这是因为,V8执行这三种不同代码时,它们的内存布局是不同的,而不同的内存布局又会影响到代码的执行逻辑,因此我们需要了解JavaScript执行时的内存布局。
|
||||
|
||||
这节课,我们从函数特性入手,来一步步延伸出通用的函数调用模型,进而来分析不同的函数调用方式是如何影响到运行时内存布局的。
|
||||
|
||||
下图是本文的主要内容在编译流水线中的位置,因为解释执行和直接执行二进制代码都使用了堆和栈,虽然它们在执行细节上存在着一定的差异,但是整体的执行架构是类似的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/09/d540183ab23341f568b992881edaa709.jpg" alt="">
|
||||
|
||||
## 为什么使用栈结构来管理函数调用?
|
||||
|
||||
我们知道,大部分高级语言都不约而同地采用栈这种结构来管理函数调用,为什么呢?这与函数的特性有关。通常函数有两个主要的特性:
|
||||
|
||||
1. 第一个特点是函数**可以被调用**,你可以在一个函数中调用另外一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,又会将代码执行控制权返还给父函数;
|
||||
1. 第二个特点是函数**具有作用域机制**,所谓作用域机制,是指函数在执行的时候可以将定义在函数内部的变量和外部环境隔离,在函数内部定义的变量我们也称为**临时变量**,临时变量只能在该函数中被访问,外部函数通常无权访问,当函数执行结束之后,存放在内存中的临时变量也随之被销毁。
|
||||
|
||||
我们可以看下面这段C代码:
|
||||
|
||||
```
|
||||
int getZ()
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
int add(int x, int y)
|
||||
{
|
||||
int z = getZ();
|
||||
return x + y + z;
|
||||
}
|
||||
int main()
|
||||
{
|
||||
int x = 5;
|
||||
int y = 6;
|
||||
int ret = add(x, y);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,我们发现其中包含了多层函数嵌套调用,其实这个过程很简单,执行流程是这样的:
|
||||
|
||||
1. 当main函数调用add函数时,需要将代码执行控制权交给add函数;
|
||||
1. 然后add函数又调用了getZ函数,于是又将代码控制权转交给getZ函数;
|
||||
1. 接下来getZ函数执行完成,需要将控制权返回给add函数;
|
||||
1. 同样当add函数执行结束之后,需要将控制权返还给main函数;
|
||||
1. 然后main函数继续向下执行。
|
||||
|
||||
具体的函数调用示意图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/c7/c39b2edd6a8c209d0f579a4e5e0e49c7.jpg" alt="" title="函数调用示意图">
|
||||
|
||||
通过上述分析,我们可以得出,**函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束(先出)。**
|
||||
|
||||
在执行上述流程时,各个函数的生命周期如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/db/a3cfa1ad1d6eb6be321355c191b76fdb.jpg" alt="" title="嵌套调用时函数的生命周期">
|
||||
|
||||
因为函数是有作用域机制的,作用域机制通常表现在函数执行时,会在内存中分配函数内部的变量、上下文等数据,在函数执行完成之后,这些内部数据会被销毁掉。**所以站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数(后进),而函数资源的释放则总是先于调用函数(先出)。**如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/99/f8aab2c91e5f29c275317cbfc02a4e99.jpg" alt="" title="函数资源分配流程">
|
||||
|
||||
通过观察函数的生命周期和函数的资源分配情况,我们发现,它们都符合**后进先出(LIFO)**的策略,而栈结构正好满足这种后进先出(LIFO)的需求,所以我们选择栈来管理函数调用关系是一种很自然的选择。
|
||||
|
||||
关于栈,你可以结合这么一个贴切的例子来理解,一条单车道的单行线,一端被堵住了,而另一端入口处没有任何提示信息,堵住之后就只能后进去的车子先出来(后进先出),这时这个堵住的单行线就可以被看作是一个**栈容器**,车子开进单行线的操作叫做**入栈**,车子倒出去的操作叫做**出栈**。
|
||||
|
||||
在车流量较大的场景中,就会发生反复地入栈、栈满、出栈、空栈和再次入栈,一直循环。你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/fa/cb286b244c88a88a1fbd0a41f93265fa.jpg" alt="" title="数据结构层面的栈">
|
||||
|
||||
## 栈如何管理函数调用?
|
||||
|
||||
了解了栈的特性之后,我们就来看看栈是如何管理函数调用的。
|
||||
|
||||
首先我们来分析最简单的场景:当执行一个函数的时候,栈怎么变化?
|
||||
|
||||
当一个函数被执行时,函数的参数、函数内部定义变量都会依次压入到栈中,我们结合实际的代码来分析下这个过程,你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/78/27f1a623219737f376deddfefb865478.jpg" alt="" title="函数内部变量压栈状态">
|
||||
|
||||
上图展示的是一段简单的C代码的执行过程,可以看到:
|
||||
|
||||
- 当执行到函数的第一段代码的时候,变量x第一次被赋值,且值为5,这时5会被压入到栈中。
|
||||
- 然后,执行第二段代码,变量y第一次被赋值,且值为6,这时6会被压入到栈中。
|
||||
- 接着,执行到第三段代码,注意这里变量x是第二次被赋值,且新的值为100,那么这时并不是将100压入到栈中,而是替换之前压入栈的内容,也就是将栈中的5替换成100。
|
||||
- 最后,执行第四段代码,这段代码是int z = x + y,我们会先计算出来x+y的值,然后再将x+y的值赋值给z,由于z是第一次被赋值,所以z的值也会被压入到栈中。
|
||||
|
||||
你会发现,**函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中。**
|
||||
|
||||
了解了这一点,接下来我们就可以分析更加复杂一点的场景了:当一个函数调用另外一个函数时,栈的变化情况是怎样的?我们还是先看下面这段代码:
|
||||
|
||||
```
|
||||
int add(num1,num2){
|
||||
int x = num1;
|
||||
int y = num2;
|
||||
int ret = x + y;
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int main()
|
||||
{
|
||||
int x = 5;
|
||||
int y = 6;
|
||||
x = 100;
|
||||
int z = add(x,y);
|
||||
return z;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,我们把上段代码中的x+y改造成了一个add函数,当执行到int z = add(x,y)时,当前栈的状态如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/ba/c39ca61afc6eaa78fe394e060028fdba.jpg" alt="">
|
||||
|
||||
接下来,就要调用add函数了,理想状态下,执行add函数的过程是下面这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/b1/27407a5f9089c4a8b09c0d2b775b50b1.jpg" alt="">
|
||||
|
||||
当执行到add函数时,会先把参数num1和num2压栈,接着我们再把变量x、y、ret的值依次压栈,不过执行这里,会遇到一个问题,那就是当add函数执行完成之后,需要将执行代码的控制权转交给main函数,这意味着需要将栈的状态恢复到main函数上次执行时的状态,我们把这个过程叫**恢复现场**。那么应该怎么恢复main函数的执行现场呢?
|
||||
|
||||
其实方法很简单,只要在寄存器中保存一个永远指向当前栈顶的指针,栈顶指针的作用就是告诉你应该往哪个位置添加新元素,这个指针通常存放在esp寄存器中。如果你想往栈中添加一个元素,那么你需要先根据esp寄存器找到当前栈顶的位置,然后在栈顶上方添加新元素,新元素添加之后,还需要将新元素的地址更新到esp寄存器中。
|
||||
|
||||
有了栈顶指针,就很容易恢复main函数的执行现场了,当add函数执行结束时,只需要将栈顶指针向下移动就可以了,具体你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/bd/68b9d297cc48864ad49c1915766fa6bd.jpg" alt="" title="add函数即将执行结束的状态">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/d2/89180f0674a92df96ce6f25813020ed2.jpg" alt="" title="恢复mian函数执行现场">
|
||||
|
||||
观察上图,将esp的指针向下移动到之前main函数执行时的地方就可以,不过新的问题又来了,CPU是怎么知道要移动到这个地址呢?
|
||||
|
||||
CPU的解决方法是增加了另外一个ebp寄存器,用来保存当前函数的起始位置,我们把一个函数的起始位置也称为栈帧指针,ebp寄存器中保存的就是当前函数的栈帧指针,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/91/94e1333f053d4dbbb41eb00aaf869a91.jpg" alt="" title="ebp寄存器保存了栈帧指针">
|
||||
|
||||
在main函数调用add函数的时候,main函数的栈顶指针就变成了add函数的栈帧指针,所以需要将main函数的栈顶指针保存到ebp中,当add函数执行结束之后,我需要销毁add函数的栈帧,并恢复main函数的栈帧,那么只需要取出main函数的栈顶指针写到esp中即可(main函数的栈顶指针是保存在ebp中的),这就相当于将栈顶指针移动到main函数的区域。
|
||||
|
||||
那么现在,我们可以执行main函数了吗?
|
||||
|
||||
答案依然是“不能”,这主要是因为main函数也有它自己的栈帧指针,在执行main函数之前,我们还需恢复它的栈帧指针。如何恢复main函数的栈帧指针呢?
|
||||
|
||||
通常的方法是在main函数中调用add函数时,CPU会将当前main函数的栈帧指针保存在栈中,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/9c/30dc7c253b8d0ffb332cfb7a878ebe9c.jpg" alt="">
|
||||
|
||||
当函数调用结束之后,就需要恢复main函数的执行现场了,首先取出ebp中的指针,写入esp中,然后从栈中取出之前保留的main的栈帧地址,将其写入ebp中,到了这里ebp和esp就都恢复了,可以继续执行main函数了。
|
||||
|
||||
另外在这里,我们还需要补充下**栈帧**的概念,因为在很多文章中我们会看到这个概念,每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。
|
||||
|
||||
以上我们详细分析了C函数的执行过程,在JavaScript中,函数的执行过程也是类似的,如果调用一个新函数,那么V8会为该函数创建栈帧,等函数执行结束之后,销毁该栈帧,而栈结构的容量是固定的,所有如果重复嵌套执行一个函数,那么就会导致栈会栈溢出。
|
||||
|
||||
了解了这些,现在我们再回过头来看下这节课开头提到的三段代码。
|
||||
|
||||
第一段代码由于循环嵌套调用了foo,所以当函数运行时,就会导致foo函数会不断地调用foo函数自身,这样就会导致栈无限增,进而导致栈溢出的错误。
|
||||
|
||||
第二段代码是在函数内部使用了setTimeout来启动foo函数,这段代码之所以不会导致栈溢出,是因为setTimeout会使得foo函数在消息队列后面的任务中执行,所以不会影响到当前的栈结构。 也就不会导致栈溢出。关于消息队列和事件循环系统,我们会在最后一单元来介绍。
|
||||
|
||||
最后一段代码是Promise,Promise的情况比较特别,既不会造成栈溢出,但是这种方式会导致主线的卡死,这就涉及到了微任务,关于微任务在这里我们先不展开介绍,我会在微任务这一节来详细介绍。
|
||||
|
||||
## 既然有了栈,为什么还要堆?
|
||||
|
||||
好了,我们现在理解了栈是怎么管理函数调用的了,使用栈有非常多的优势:
|
||||
|
||||
1. 栈的结构和非常适合函数调用过程。
|
||||
1. 在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。
|
||||
|
||||
虽然操作速度非常快,但是栈也是有缺点的,其中最大的缺点也是它的优点所造成的,那就是栈是连续的,所以要想在内存中分配一块连续的大空间是非常难的,因此栈空间是有限的。
|
||||
|
||||
因为栈空间是有限的,这就导致我们在编写程序的时候,经常一不小心就会导致栈溢出,比如函数循环嵌套层次太多,或者在栈上分配的数据过大,都会导致栈溢出,基于栈不方便存放大的数据,因此我们使用了另外一种数据结构用来保存一些大数据,这就是**堆**。
|
||||
|
||||
和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,你可以在任何时候分配和释放它,为了更好地理解堆,我们看下面这段代码是怎么执行的:
|
||||
|
||||
```
|
||||
struct Point
|
||||
{
|
||||
int x;
|
||||
int y;
|
||||
};
|
||||
|
||||
|
||||
int main()
|
||||
{
|
||||
int x = 5;
|
||||
int y = 6;
|
||||
int *z = new int;
|
||||
*z = 20;
|
||||
|
||||
|
||||
Point p;
|
||||
p.x = 100;
|
||||
p.y = 200;
|
||||
|
||||
|
||||
Point *pp = new Point();
|
||||
pp->y = 400;
|
||||
pp->x = 500;
|
||||
delete z;
|
||||
delete pp;
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,你可以看到代码中有new int、new Point这种语句,当执行这些语句时,表示要在堆中分配一块数据,然后返回指针,通常返回的指针会被保存到栈中,下面我们来看看当main函数快执行结束时,堆和栈的状态,具体内容你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/55/139edffd0fb7e2b58f0e03c7d1240755.jpg" alt="">
|
||||
|
||||
观察上图,我们可以发现,当使用new时,我们会在堆中分配一块空间,在堆中分配空间之后,会返回分配后的地址,我们会把该地址保存在栈中,如上图中p和pp都是地址,它们保存在栈中,指向了在堆中分配的空间。
|
||||
|
||||
通常,当堆中的数据不再需要的时候,需要对其进行销毁,在C语言中可以使用free,在C++语言中可以使用delete来进行操作,比如可以通过:
|
||||
|
||||
```
|
||||
delete p;
|
||||
delete pp;
|
||||
|
||||
```
|
||||
|
||||
来销毁堆中的数据,像C/C++这种手动管理内存的语言,如果没有手动销毁堆中的数据,那么就会造成内存泄漏。不过JavaScript,Java使用了自动垃圾回收策略,可以实现垃圾自动回收,但是事情总有两面性,垃圾自动回收也会给我们带来一些性能问题。所以不管是自动垃圾回收策略,还是手动垃圾回收策略,要想写出高效的代码,我们都需要了解内存的底层工作机制。
|
||||
|
||||
## 总结
|
||||
|
||||
因为现代语言都是基于函数的,每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为**调用栈。**
|
||||
|
||||
因为栈在内存中连续的数据结构,所以在通常情况下,栈都有最大容量限制,这也就意味着,函数的嵌套调用次数过多,就会超出栈的最大使用范围,从而导致栈溢出。
|
||||
|
||||
为了解决栈溢出的问题,我们可以使用setTimeout将要执行的函数放到其他的任务中去执行,也可以使用Promise来改变栈的调用方式,这涉及到了事件循环和微任务,我们会在后续课程中再来介绍。
|
||||
|
||||
## 思考题
|
||||
|
||||
你可以分析一下,在浏览器中执行这段代码,为什么不会造成栈溢出,但是却会造成页面的卡死?欢迎你在留言区与我分享讨论。
|
||||
|
||||
```
|
||||
function foo() {
|
||||
return Promise.resolve().then(foo)
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
211
极客时间专栏/图解 Google V8/V8编译流水线/12 | 延迟解析:V8是如何实现闭包的?.md
Normal file
211
极客时间专栏/图解 Google V8/V8编译流水线/12 | 延迟解析:V8是如何实现闭包的?.md
Normal file
@@ -0,0 +1,211 @@
|
||||
<audio id="audio" title="12 | 延迟解析:V8是如何实现闭包的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/b1/654fab4d8962acd19f11d4bc98f906b1.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在第一节我们介绍过V8执行JavaScript代码,需要经过**编译**和**执行**两个阶段,其中**编译过程**是指V8将JavaScript代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字节码,或者是CPU直接执行二进制机器代码的阶段。总的流程你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/db/fe3d39715d28a833883df6702930a0db.jpg" alt="" title="代码执行">
|
||||
|
||||
在编译JavaScript代码的过程中,V8并不会一次性将所有的JavaScript解析为中间代码,这主要是基于以下两点:
|
||||
|
||||
- 首先,如果一次解析和编译所有的JavaScript代码,过多的代码会增加编译时间,这会严重影响到首次执行JavaScript代码的速度,让用户感觉到卡顿。因为有时候一个页面的JavaScript代码都有10多兆,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间;
|
||||
- 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有JavaScript代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源。
|
||||
|
||||
基于以上的原因,所有主流的JavaScript虚拟机都实现了**惰性解析**。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成AST和字节码,而仅仅生成顶层代码的AST和字节码。
|
||||
|
||||
## 惰性解析的过程
|
||||
|
||||
关于惰性解析,我们可以结合下面这个例子来分析下:
|
||||
|
||||
```
|
||||
function foo(a,b) {
|
||||
var d = 100
|
||||
var f = 10
|
||||
return d + f + a + b;
|
||||
}
|
||||
var a = 1
|
||||
var c = 4
|
||||
foo(1, 5)
|
||||
|
||||
```
|
||||
|
||||
当把这段代码交给V8处理时,V8会至上而下解析这段代码,在解析过程中首先会遇到foo函数,由于这只是一个函数声明语句,V8在这个阶段只需要将该函数转换为函数对象,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/35/4a/35ce3f6469a7024ca14d81b6c804044a.jpg" alt="">
|
||||
|
||||
注意,这里只是将该函数声明转换为函数对象,但是并没有解析和编译函数内部的代码,所以也不会为foo函数的内部代码生成抽象语法树。
|
||||
|
||||
然后继续往下解析,由于后续的代码都是顶层代码,所以V8会为它们生成抽象语法树,最终生成的结果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/62/e52476efb6ef924e74f470ead4970262.jpg" alt="" title="生成顶层代码的抽象语法树">
|
||||
|
||||
代码解析完成之后,V8便会按照顺序自上而下执行代码,首先会先执行“a=1”和“c=4”这两个赋值表达式,接下来执行foo函数的调用,过程是从foo函数对象中取出函数代码,然后和编译顶层代码一样,V8会先编译foo函数的代码,编译时同样需要先将其编译为抽象语法树和字节码,然后再解释执行。
|
||||
|
||||
好了,上面就是惰性解析的一个大致过程,看上去是不是很简单,不过在V8实现惰性解析的过程中,需要支持JavaScript中的闭包特性,这会使得V8的解析过程变得异常复杂。
|
||||
|
||||
为什么闭包会让V8解析代码的过程变得复杂呢?要解答这个问题,我们先来拆解闭包的特性,然后再来分析为什么闭包影响到了V8的解析流程。
|
||||
|
||||
## 拆解闭包——JavaScript的三个特性
|
||||
|
||||
JavaScript中的闭包有三个基础特性。
|
||||
|
||||
第一,**JavaScript语言允许在函数内部定义新的函数**,代码如下所示:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
function inner() {
|
||||
}
|
||||
inner()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这和其他的流行语言有点差异,在其他的大部分语言中,函数只能声明在顶层代码中,而JavaScript中之所以可以在函数中声明另外一个函数,主要是因为JavaScript中的函数即对象,你可以在函数中声明一个变量,当然你也可以在函数中声明一个函数。
|
||||
|
||||
第二,**可以在内部函数中访问父函数中定义的变量**,代码如下所示:
|
||||
|
||||
```
|
||||
var d = 20
|
||||
//inner函数的父函数,词法作用域
|
||||
function foo() {
|
||||
var d = 55
|
||||
//foo的内部函数
|
||||
function inner() {
|
||||
return d+2
|
||||
}
|
||||
inner()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于可以在函数中定义新的函数,所以很自然的,内部的函数可以使用外部函数中定义的变量,注意上面代码中的inner函数和foo函数,inner是在foo函数内部定义的,我们就称inner函数是foo函数的子函数,foo函数是inner函数的父函数。这里的父子关系是针对词法作用域而言的,因为词法作用域在函数声明时就决定了,比如inner函数是在foo函数内部声明的,所以inner函数可以访问foo函数内部的变量,比如inner就可以访问foo函数中的变量d。
|
||||
|
||||
但是如果在foo函数外部,也定义了一个变量d,那么当inner函数访问该变量时,到底是该访问哪个变量呢?
|
||||
|
||||
在《[06|作用域链:V8是如何查找变量的?](https://time.geekbang.org/column/article/217027)》这节课,我介绍了词法作用域和词法作用域链,每个函数有自己的词法作用域,该函数中定义的变量都存在于该作用域中,然后V8会将这些作用域按照词法的位置,也就是代码位置关系,将这些作用域串成一个链,这就是词法作用域链,查找变量的时候会沿着词法作用域链的途径来查找。
|
||||
|
||||
所以,inner函数在自己的作用域中没有查找到变量d,就接着在foo函数的作用域中查找,再查找不到才会查找顶层作用域中的变量。所以inner函数中使用的变量d就是foo函数中的变量d。
|
||||
|
||||
第三,**因为函数是一等公民,所以函数可以作为返回值**,我们可以看下面这段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
return function inner(a, b) {
|
||||
const c = a + b
|
||||
return c
|
||||
}
|
||||
}
|
||||
const f = foo()
|
||||
|
||||
```
|
||||
|
||||
观察上面这段代码,我们将inner函数作为了foo函数的返回值,也就是说,当调用foo函数时,最终会返回inner函数给调用者,比如上面我们将inner函数返回给了全局变量f,接下来就可以在外部像调用inner函数一样调用f了。
|
||||
|
||||
以上就是和JavaScript闭包相关的三个重要特性:
|
||||
|
||||
- 可以在JavaScript函数内部定义新的函数;
|
||||
- 内部函数中访问父函数中定义的变量;
|
||||
- 因为JavaScript中的函数是一等公民,所以函数可以作为另外一个函数的返回值。
|
||||
|
||||
这也是JavaScript过于灵活的一个原因,比如在C/C++中,你就不可以在一个函数中定义另外一个函数,所以也就没了内部函数访问外部函数中变量的问题了。
|
||||
|
||||
## 闭包给惰性解析带来的问题
|
||||
|
||||
好了,了解了JavaScript的这三个特性之后,下面我们就来使用这三个特性组装的一段经典的闭包代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var d = 20
|
||||
return function inner(a, b) {
|
||||
const c = a + b + d
|
||||
return c
|
||||
}
|
||||
}
|
||||
const f = foo()
|
||||
|
||||
```
|
||||
|
||||
观察上面上面这段代码,我们在foo函数中定义了inner函数,并返回inner函数,同时在inner函数中访问了foo函数中的变量d。
|
||||
|
||||
我们可以分析下上面这段代码的执行过程:
|
||||
|
||||
- 当调用foo函数时,foo函数会将它的内部函数inner返回给全局变量f;
|
||||
- 然后foo函数执行结束,执行上下文被V8销毁;
|
||||
- 虽然foo函数的执行上下文被销毁了,但是依然存活的inner函数引用了foo函数作用域中的变量d。
|
||||
|
||||
按照通用的做法,d已经被v8销毁了,但是由于存活的函数inner依然引用了foo函数中的变量d,这样就会带来两个问题:
|
||||
|
||||
- 当foo执行结束时,变量d该不该被销毁?如果不应该被销毁,那么应该采用什么策略?
|
||||
- 如果采用了惰性解析,那么当执行到foo函数时,V8只会解析foo函数,并不会解析内部的inner函数,那么这时候V8就不知道inner函数中是否引用了foo函数的变量d。
|
||||
|
||||
这么讲可能有点抽象,下面我们就来看一下上面这段代码的执行流程,我们上节分析过了,JavaScript是一门基于堆和栈的语言,当执行foo函数的时候,堆栈的变化如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/10/deaa69d414571516a1debd9712860110.jpg" alt="">
|
||||
|
||||
从上图可以看出来,在执行全局代码时,V8会将全局执行上下文压入到调用栈中,然后进入执行foo函数的调用过程,这时候V8会为foo函数创建执行上下文,执行上下文中包括了变量d,然后将foo函数的执行上下文压入栈中,foo函数执行结束之后,foo函数执行上下文从栈中弹出,这时候foo执行上下文中的变量d也随之被销毁。
|
||||
|
||||
但是这时候,由于inner函数被保存到全局变量中了,所以inner函数依然存在,最关键的地方在于inner函数使用了foo函数中的变量d,按照正常执行流程,变量d在foo函数执行结束之后就被销毁了。
|
||||
|
||||
所以正常的处理方式应该是foo函数的执行上下文虽然被销毁了,但是inner函数引用的foo函数中的变量却不能被销毁,那么V8就需要为这种情况做特殊处理,需要保证即便foo函数执行结束,但是foo函数中的d变量依然保持在内存中,不能随着foo函数的执行上下文被销毁掉。
|
||||
|
||||
那么怎么处理呢?
|
||||
|
||||
在执行foo函数的阶段,虽然采取了惰性解析,不会解析和执行foo函数中的inner函数,但是V8还是需要判断inner函数是否引用了foo函数中的变量,负责处理这个任务的模块叫做预解析器。
|
||||
|
||||
## 预解析器如何解决闭包所带来的问题?
|
||||
|
||||
V8引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。
|
||||
|
||||
第一,是判断当前函数是不是存在一些语法上的错误,如下面这段代码:
|
||||
|
||||
```
|
||||
function foo(a, b) {
|
||||
{/} //语法错误
|
||||
}
|
||||
var a = 1
|
||||
var c = 4
|
||||
foo(1, 5)
|
||||
|
||||
```
|
||||
|
||||
在预解析过程中,预解析器发现了语法错误,那么就会向V8抛出语法错误,比如上面这段代码的语法错误是这样的:
|
||||
|
||||
```
|
||||
Uncaught SyntaxError: Invalid regular expression: missing /
|
||||
|
||||
```
|
||||
|
||||
第二,除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们主要介绍了V8的惰性解析,所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成AST和字节码,而仅仅生成顶层代码的AST和字节码。
|
||||
|
||||
利用惰性解析可以加速JavaScript代码的启动速度,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。
|
||||
|
||||
由于JavaScript是一门天生支持闭包的语言,由于闭包会引用当前函数作用域之外的变量,所以当V8解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后,也不会释放该变量。
|
||||
|
||||
## 思考题
|
||||
|
||||
观察下面两段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var a = 0
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var a = 0
|
||||
return function inner() {
|
||||
return a++
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
请你思考下,当调用foo函数时,foo函数内部的变量a会分别分配到栈上?还是堆上?为什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
161
极客时间专栏/图解 Google V8/V8编译流水线/13 | 字节码(一):V8为什么又重新引入字节码?.md
Normal file
161
极客时间专栏/图解 Google V8/V8编译流水线/13 | 字节码(一):V8为什么又重新引入字节码?.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<audio id="audio" title="13 | 字节码(一):V8为什么又重新引入字节码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/d6/3f438efc1ee81b61819805366e7e64d6.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在第一节课我们就介绍了V8的编译流水线,我们知道V8在执行一段JavaScript代码之前,需要将其编译为字节码,然后再解释执行字节码或者将字节码编译为二进制代码然后再执行。
|
||||
|
||||
所谓字节码,是指编译过程中的中间代码,你可以把字节码看成是机器代码的抽象,在V8中,字节码有两个作用:
|
||||
|
||||
- 第一个是解释器可以直接解释执行字节码;
|
||||
- 第二个是优化编译器可以将字节码编译为二进制代码,然后再执行二进制机器代码。
|
||||
|
||||
虽然目前的架构使用了字节码,不过早期的V8并不是这样设计的,那时候V8团队认为这种“先生成字节码再执行字节码”的方式,多了个中间环节,多出来的中间环节会牺牲代码的执行速度。
|
||||
|
||||
于是在早期,V8团队采取了非常激进的策略,直接将JavaScript代码编译成机器代码。其执行流程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/68/6a9f1a826b924eb74f0ab08a18528a68.jpg" alt="" title="早期V8执行流水线">
|
||||
|
||||
观察上面的执行流程图,我们可以发现,早期的V8也使用了两个编译器:
|
||||
|
||||
1. 第一个是**基线编译器**,它负责将JavaScript代码编译为**没有优化**过的机器代码。
|
||||
1. 第二个是**优化编译器**,它负责将一些热点代码(执行频繁的代码)**优化**为执行效率更高的机器代码。
|
||||
|
||||
了解这两个编译器之后,接下来我们再来看看早期的V8是怎么执行一段JavaScript代码的。
|
||||
|
||||
1. 首先,V8会将一段JavaScript代码转换为抽象语法树(AST)。
|
||||
1. 接下来基线编译器会将抽象语法树编译为未优化过的机器代码,然后V8直接执行这些未优化过的机器代码。
|
||||
1. 在执行未优化的二进制代码过程中,如果V8检测到某段代码重复执行的概率过高,那么V8会将该段代码标记为HOT,标记为HOT的代码会被优化编译器优化成执行效率高的二进制代码,然后就执行该段优化过的二进制代码。
|
||||
1. 不过如果优化过的二进制代码并不能满足当前代码的执行,这也就意味着优化失败,V8则会执行反优化操作。
|
||||
|
||||
以上就是早期的V8执行一段JavaScript代码的流程,不过最近发布的V8已经抛弃了直接将JavaScript代码编译为二进制代码的方式,也抛弃了这两个编译器,进而使用了字节码+解释器+编译器方式,也就是我们在第一节课介绍的形式。
|
||||
|
||||
早期的V8之所以抛弃中间形式的代码,直接将JavaScript代码编译成机器代码,是因为机器代码的执行性能非常高效,但是最新版本却朝着执行性能相反的方向进化,那么这是出于什么原因呢?
|
||||
|
||||
## 机器代码缓存
|
||||
|
||||
当JavaScript代码在浏览器中被执行的时候,需要先被V8编译,早期的V8会将JavaScript编译成未经优化的二进制机器代码,然后再执行这些未优化的二进制代码,通常情况下,编译占用了很大一部分时间,下面是一段代码的编译和执行时间图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/bb/d5b8e781606efa91362c856656de3ebb.jpg" alt="">
|
||||
|
||||
从图中可以看出,编译所消耗的时间和执行所消耗的时间是差不多的,试想一下,如果在浏览器中再次打开相同的页面,当页面中的JavaScript文件没有被修改,那么再次编译之后的二进制代码也会保持不变, 这意味着编译这一步白白浪费了CPU资源,因为之前已经编译过一次了。
|
||||
|
||||
这就是Chrome浏览器引入二进制代码缓存的原因,通过把二进制代码保存在内存中来消除冗余的编译,重用它们完成后续的调用,这样就省去了再次编译的时间。
|
||||
|
||||
V8 使用两种代码缓存策略来缓存生成的代码。
|
||||
|
||||
- 首先,是V8第一次执行一段代码时,会编译源JavaScript代码,并将编译后的二进制代码缓存在内存中,我们把这种方式称为内存缓存(in-memory cache)。然后通过JavaScript源文件的字符串在内存中查找对应的编译后的二进制代码。这样当再次执行到这段代码时,V8就可以直接去内存中查找是否编译过这段代码。如果内存缓存中存在这段代码所对应的二进制代码,那么就直接执行编译好的二进制代码。
|
||||
- 其次,V8除了采用将代码缓存在内存中策略之外,还会将代码缓存到硬盘上,这样即便关闭了浏览器,下次重新打开浏览器再次执行相同代码时,也可以直接重复使用编译好的二进制代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/60/a6f2ea6df895eb6940a9db95f54fa360.jpg" alt="" title="二进制代码缓存">
|
||||
|
||||
实践表明,在浏览器中采用了二进制代码缓存的方式,初始加载时分析和编译的时间缩短了20%~40%。
|
||||
|
||||
## 字节码降低了内存占用
|
||||
|
||||
所以在早期,Chrome做了两件事来提升JavaScript代码的执行速度:
|
||||
|
||||
- 第一,将运行时将二进制机器代码缓存在内存中;
|
||||
- 第二,当浏览器退出时,缓存编译之后二进制代码到磁盘上。
|
||||
|
||||
很明显,采用缓存是一种典型的以空间换时间的策略,以牺牲存储空间来换取执行速度,我们知道Chrome的多进程架构已经非常吃内存了,而Chrome中每个页面进程都运行了一份V8实例,V8在执行JavaScript代码的过程中,会将JavaScript代码转换为未经优化的二进制代码,你可以对照下图中的JavaScript代码和二进制代码的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/cb/214d4c793543d08e16f86abd82a9accb.jpg" alt="">
|
||||
|
||||
从上图我们可以看出,二进制代码所占用的内存空间是JavaScript代码的几千倍,通常一个页面的JavaScript几M大小,转换为二进制代码就变成几十M了,如果是PC应用,多占用一些内存,也不会太影响性能,但是在移动设备流行起来之后,V8过度占用内存的问题就充分暴露出来了。因为通常一部手机的内存不会太大,如果过度占用内存,那么会导致Web应用的速度大大降低。
|
||||
|
||||
在上一节我们介绍过,V8团队为了提升V8的启动速度,采用了惰性编译,其实惰性编译除了能提升JavaScript启动速度,还可以解决部分内存占用的问题。你可以先参看下面的代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/58/a197b9a6f9136adf7724e8f528ca3158.jpg" alt="">
|
||||
|
||||
根据惰性编译的原则,当V8首次执行上面这段代码的过程中,开始只是编译最外层的代码,那些函数内部的代码,如下图中的黄色的部分,会推迟到第一次调用时再编译。
|
||||
|
||||
为了解决缓存的二进制机器代码占用过多内存的问题,早期的Chrome并没有缓存函数内部的二进制代码,只是缓存了顶层次的二进制代码,比如上图中红色的区域。
|
||||
|
||||
但是这种方式却存在很大的不确定性,比如我们多人开发的项目,通常喜欢将自己的代码封装成模块,在JavaScript中,由于没有块级作用域(ES6之前),所以我们习惯使用立即调用函数表达式(IIFEs),比如下面这样的代码:
|
||||
|
||||
- **test_module.js**
|
||||
|
||||
```
|
||||
var test_module = (function () {
|
||||
var count_
|
||||
function init_(){count_ = 0}
|
||||
function add_(){count_ = count_+1}
|
||||
function show_(){console.log(count_)}
|
||||
return {
|
||||
init: init_,
|
||||
add: add_,
|
||||
show:show_
|
||||
}
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
- **app.js**
|
||||
|
||||
```
|
||||
test_module.init()
|
||||
test_module.add()
|
||||
test_module.show()
|
||||
test_module.add()
|
||||
test_module.show()
|
||||
|
||||
```
|
||||
|
||||
上面就是典型的闭包代码,它将和模块相关的所有信息都封装在一个匿名立即执行函数表达式中,并将需要暴漏的接口数据返回给变量test_module。如果浏览器只缓存顶层代码,那么闭包模块中的代码将无法被缓存,而对于高度工程化的模块来说,这种模块式的处理方式到处都是,这就导致了一些关键代码没有办法被缓存。
|
||||
|
||||
所以采取只缓存顶层代码的方式是不完美的,没办法适应多种不同的情况,因此,V8团队对早期的V8架构进行了非常大的重构,具体地讲,抛弃之前的基线编译器和优化编译器,引入了字节码、解释器和新的优化编译器。
|
||||
|
||||
那么为什么通过引入字节码就能降低V8在执行时的内存占用呢?要解释这个问题,我们不妨看下面这张图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/4b/27d30dbb95e3bb3e55b9bc2a56e14d4b.jpg" alt="">
|
||||
|
||||
从图中可以看出,字节码虽然占用的空间比原始的JavaScript多,但是相较于机器代码,字节码还是小了太多。
|
||||
|
||||
有了字节码,无论是解释器的解释执行,还是优化编译器的编译执行,都可以直接针对字节来进行操作。由于字节码占用的空间远小于二进制代码,所以浏览器就可以实现缓存所有的字节码,而不是仅仅缓存顶层的字节码。
|
||||
|
||||
虽然采用字节码在执行速度上稍慢于机器代码,但是整体上权衡利弊,采用字节码也许是最优解。之所以说是最优解,是因为采用字节码除了降低内存之外,还提升了代码的启动速度,并降低了代码的复杂度,而牺牲的仅仅是一点执行效率。接下来我们继续来分析下,采用字节码是怎么提升代码启动速度和降低复杂度的。
|
||||
|
||||
## 字节码如何提升代码启动速度?
|
||||
|
||||
我们先看引入字节码是怎么提升代码启动速度的。下面是启动JavaScript代码的流程图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/5b/9e441845eb4af12642fe5385cdd1b05b.jpg" alt="">
|
||||
|
||||
从图中可以看出,生成机器代码比生成字节码需要花费更久的时间,但是直接执行机器代码却比解释执行字节码要更高效,所以在快速启动JavaScript代码与花费更多时间获得最优运行性能的代码之间,我们需要找到一个平衡点。
|
||||
|
||||
解释器可以快速生成字节码,但字节码通常效率不高。 相比之下,优化编译器虽然需要更长的时间进行处理,但最终会产生更高效的机器码,这正是 V8 在使用的模型。它的解释器叫 Ignition,(就原始字节码执行速度而言)是所有引擎中最快的解释器。V8 的优化编译器名为 TurboFan,最终由它生成高度优化的机器码。
|
||||
|
||||
## 字节码如何降低代码的复杂度?
|
||||
|
||||
早期的V8代码,无论是基线编译器还是优化编译器,它们都是基于AST抽象语法树来将代码转换为机器码的,我们知道,不同架构的机器码是不一样的,而市面上存在不同架构的处理器又是非常之多,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/e9/bc8ede549e0572689cadd6f2c21f31e9.jpg" alt="">
|
||||
|
||||
这意味着基线编译器和优化编译器要针对不同的体系的CPU编写不同的代码,这会大大增加代码量。
|
||||
|
||||
引入了字节码,就可以统一将字节码转换为不同平台的二进制代码,你可以对比下执行流程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/5d/0b207ca6b427bf6281dce67d4f96835d.jpg" alt="">
|
||||
|
||||
因为字节码的执行过程和CPU执行二进制代码的过程类似,相似的执行流程,那么将字节码转换为不同架构的二进制代码的工作量也会大大降低,这就降低了转换底层代码的工作量。
|
||||
|
||||
## 总结
|
||||
|
||||
这节课我们介绍了V8为什么要引入字节码。早期的V8为了提升代码的执行速度,直接将JavaScript源代码编译成了没有优化的二进制的机器代码,如果某一段二进制代码执行频率过高,那么V8会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高。
|
||||
|
||||
不过随着移动设备的普及,V8团队逐渐发现将JavaScript源码直接编译成二进制代码存在两个致命的问题:
|
||||
|
||||
- 时间问题:编译时间过久,影响代码启动速度;
|
||||
- 空间问题:缓存编译后的二进制代码占用更多的内存。
|
||||
|
||||
这两个问题无疑会阻碍V8在移动设备上的普及,于是V8团队大规模重构代码,引入了中间的字节码。字节码的优势有如下三点:
|
||||
|
||||
- 解决启动问题:生成字节码的时间很短;
|
||||
- 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
|
||||
- 代码架构清晰:采用字节码,可以简化程序的复杂度,使得V8移植到不同的CPU架构平台更加容易。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天留给你一个开放的思考题:你认为V8虚拟机中的机器代码和字节码有哪些异同?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
286
极客时间专栏/图解 Google V8/V8编译流水线/14|字节码(二):解释器是如何解释执行字节码的?.md
Normal file
286
极客时间专栏/图解 Google V8/V8编译流水线/14|字节码(二):解释器是如何解释执行字节码的?.md
Normal file
@@ -0,0 +1,286 @@
|
||||
<audio id="audio" title="14|字节码(二):解释器是如何解释执行字节码的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/78/018ece453e505462a059b8dfaf8fd278.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在上节我们介绍了V8为什么要引入字节码,这节课我们来聊聊解释器是如何解释执行字节码的。学习字节码如何被执行,可以让我们理解解释器的工作机制,同时还能帮助我们搞懂JavaScript运行时的内存结构,特别是闭包的结构和非闭包数据的区别。
|
||||
|
||||
字节码的解释执行在编译流水线中的位置你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/01/e4735f5bb848120b5fd931acae5eb101.jpg" alt="">
|
||||
|
||||
## 如何生成字节码?
|
||||
|
||||
我们知道当V8执行一段JavaScript代码时,会先对JavaScript代码进行解析(Parser),并生成为AST和作用域信息,之后AST和作用域信息被输入到一个称为Ignition 的解释器中,并将其转化为字节码,之后字节码再由Ignition解释器来解释执行。
|
||||
|
||||
接下来,我们就结合一段代码来看看执行解释器是怎么解释执行字节码的。你可以参看下面这段代码:
|
||||
|
||||
```
|
||||
function add(x, y) {
|
||||
var z = x+y
|
||||
return z
|
||||
}
|
||||
console.log(add(1, 2))
|
||||
|
||||
```
|
||||
|
||||
在控制台执行这段代码,会返回数字3,V8是如何得到这个结果的呢?
|
||||
|
||||
刚刚我们提到了,V8首先会将函数的源码解析为AST,这一步由解析器(Parser)完成,你可以在d8中通过–print-ast 命令来查看V8内部生成的AST。
|
||||
|
||||
```
|
||||
[generating bytecode for function: add]
|
||||
--- AST ---
|
||||
FUNC at 12
|
||||
. KIND 0
|
||||
. LITERAL ID 1
|
||||
. SUSPEND COUNT 0
|
||||
. NAME "add"
|
||||
. PARAMS
|
||||
. . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
|
||||
. . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
|
||||
. DECLS
|
||||
. . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
|
||||
. . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
|
||||
. . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
|
||||
. BLOCK NOCOMPLETIONS at -1
|
||||
. . EXPRESSION STATEMENT at 31
|
||||
. . . INIT at 31
|
||||
. . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
|
||||
. . . . ADD at 32
|
||||
. . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
|
||||
. . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
|
||||
. RETURN at 37
|
||||
. . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
|
||||
|
||||
```
|
||||
|
||||
同样,我们将其图形化:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/aa/94b31db22a69f95b2d211ccedbbfa6aa.jpg" alt="">
|
||||
|
||||
从图中可以看出,函数的字面量被解析为AST树的形态,这个函数主要拆分成四部分。
|
||||
|
||||
- 第一部分为参数的声明(PARAMS),参数声明中包括了所有的参数,在这里主要是参数x和参数y,你可以在函数体中使用arguments来使用对应的参数。
|
||||
- 第二部分是变量声明节点(DECLS),参数部分你可以使用arguments来调用,同样,你也可以将这些参数作为变量来直接使用,这体现在DECLS节点下面也出现了变量x和变量y,除了可以直接使用x和y之外,我们还有一个z变量也在DECLS节点下。你可以注意一下,在上面生成的AST数据中,参数声明节点中的x和变量声明节点中的x的地址是相同的,都是0x7fa7bf8048e8,同样y也是相同的,都是0x7fa7bf804990,这说明它们指向的是同一块数据。
|
||||
- 第三部分是x+y的表达式节点,我们可以看到,节点add下面使用了var proxy x和var proxy x的语法,它们指向了实际x和y的值。
|
||||
- 第四部分是RETURN节点,它指向了z的值,在这里是local[0]。
|
||||
|
||||
V8在生成AST的同时,还生成了add函数的作用域,你可以使用–print-scopes命令来查看:
|
||||
|
||||
```
|
||||
Global scope:
|
||||
function add (x, y) { // (0x7f9ed7849468) (12, 47)
|
||||
// will be compiled
|
||||
// 1 stack slots
|
||||
// local vars:
|
||||
VAR y; // (0x7f9ed7849790) parameter[1], never assigned
|
||||
VAR z; // (0x7f9ed7849838) local[0], never assigned
|
||||
VAR x; // (0x7f9ed78496e8) parameter[0], never assigned
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
作用域中的变量都是未使用的,默认值都是undefined,在执行阶段,作用域中的变量会指向堆和栈中相应的数据,作用域和实际数据的关系如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/c1/9ed15891d8145f59a20fa23cf33d5bc1.jpg" alt="">
|
||||
|
||||
在解析期间,所有函数体中声明的变量和函数参数,都被放进作用域中,如果是普通变量,那么默认值是undefined,如果是函数声明,那么将指向实际的函数对象。
|
||||
|
||||
一旦生成了作用域和AST,V8就可以依据它们来生成字节码了。AST之后会被作为输入传到字节码生成器(BytecodeGenerator),这是Ignition解释器中的一部分,用于生成以函数为单位的字节码。你可以通过–print-bytecode命令查看生成的字节码。
|
||||
|
||||
```
|
||||
[generated bytecode for function: add (0x079e0824fdc1 <SharedFunctionInfo add>)]
|
||||
Parameter count 3
|
||||
Register count 2
|
||||
Frame size 16
|
||||
0x79e0824ff7a @ 0 : a7 StackCheck
|
||||
0x79e0824ff7b @ 1 : 25 02 Ldar a1
|
||||
0x79e0824ff7d @ 3 : 34 03 00 Add a0, [0]
|
||||
0x79e0824ff80 @ 6 : 26 fb Star r0
|
||||
0x79e0824ff82 @ 8 : 0c 02 LdaSmi [2]
|
||||
0x79e0824ff84 @ 10 : 26 fa Star r1
|
||||
0x79e0824ff86 @ 12 : 25 fb Ldar r0
|
||||
0x79e0824ff88 @ 14 : ab Return
|
||||
Constant pool (size = 0)
|
||||
Handler Table (size = 0)
|
||||
Source Position Table (size = 0)
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,生成的字节码第一行提示了“Parameter count 3”,这是告诉我们这里有三个参数,包括了显式地传入了x 和 y,还有一个隐式地传入了this。下面是字节码的详细信息:
|
||||
|
||||
```
|
||||
StackCheck
|
||||
Ldar a1
|
||||
Add a0, [0]
|
||||
Star r0
|
||||
LdaSmi [2]
|
||||
Star r1
|
||||
Ldar r0
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
将JavaScript函数转换为字节码之后,我们看到只有8行,接下来我们的任务就是要分析这8行字节码是怎么工作的,理解了这8行字节码是怎么工作的,就可以学习其他字节码的工作方式了。
|
||||
|
||||
## 理解字节码:解释器的架构设计
|
||||
|
||||
通过上面的一段字节码我们可以看到,字节码似乎和汇编代码有点像,这些字节码看起来似乎难以理解,但实际上它们非常简单,每一行表示一个特定的功能,把这些功能拼凑在一起就构成完整的程序。
|
||||
|
||||
通俗地讲,你可以把这一行行字节码看成是一个个积木块,每个积木块块负责实现特定的功能,有实现运算的,有实现跳转的,有实现返回的,有实现内存读取的。一段JavaScript代码最终被V8还原成一个个积木块,将这些积木搭建在一起就实现了JavaScript的功能,现在我们大致了解了字节码就是一些基础的功能模块,接下来我们就来认识下这些构建块。
|
||||
|
||||
下图是一些常用的“积木块”,我们又称为字节码的指令集:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/80/d65e0df8275c2d351764a57f2b42a880.png" alt="" title="V8中定义的部分字节码指令集">
|
||||
|
||||
你也可以去[V8的源码中](https://github.com/v8/v8/blob/master/src/interpreter/bytecodes.h)查看这些字节码,V8字节码的指令非常多,如果要掌握所有指令的含义,需要花费一段时间的学习和实践,这节课我们不需要了解所有字节码的含义,但我们需要知道,怎样阅读字节码。
|
||||
|
||||
我们阅读汇编代码,需要先理解CPU的体系架构,然后再分析特定汇编指令的具体含义,同样,要了解怎么阅读字节码,我们就需要理解V8解释器的整体设计架构,然后再来分析特定的字节码指令的含义。接下来,我们就依次介绍这两部分内容。
|
||||
|
||||
因为解释器就是模拟物理机器来执行字节码的,比如可以实现如取指令、解析指令、执行指令、存储数据等,所以解释器的执行架构和CPU处理机器代码的架构类似(关于CPU是如何执行机器代码的,你可以参看《[10|机器代码:二进制机器码究竟是如何被CPU执行的?](https://time.geekbang.org/column/article/221211)》这节课)。
|
||||
|
||||
通常有两种类型的解释器,**基于栈(Stack-based)<strong>和**基于寄存器(Register-based)</strong>,基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等,基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果。
|
||||
|
||||
通常,基于栈的虚拟机也定义了少量的寄存器,基于寄存器的虚拟机也有堆栈,其区别体现在它们提供的指令集体系。
|
||||
|
||||
大多数解释器都是基于栈的,比如Java虚拟机,.Net虚拟机,还有早期的V8虚拟机。基于堆栈的虚拟机在处理函数调用、解决递归问题和切换上下文时简单明快。
|
||||
|
||||
而现在的V8虚拟机则采用了基于寄存器的设计,它将一些中间数据保存到寄存器中,了解这点对于我们分析字节码的执行过程非常重要。
|
||||
|
||||
接下来我们就来看看基于寄存器的解释器架构,具体你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/8f/471685cc7aa107fdd967c02467daf08f.jpg" alt="">
|
||||
|
||||
解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆。
|
||||
|
||||
这和我们介绍过的CPU执行二进制机器代码的模式是类似的:
|
||||
|
||||
- 使用内存中的一块区域来存放字节码;
|
||||
- 使用了通用寄存器 r0,r1,r2,…… 这些寄存器用来存放一些中间数据;
|
||||
- PC寄存器用来指向下一条要执行的字节码;
|
||||
- 栈顶寄存器用来指向当前的栈顶的位置。
|
||||
|
||||
但是我们需要重点注意这里的**累加器**,它是一个非常特殊的寄存器,用来保存中间的结果,这体现在很多V8字节码的语义上面,我们来看下面这个字节码的指令:
|
||||
|
||||
```
|
||||
Ldar a1
|
||||
|
||||
```
|
||||
|
||||
Ldar表示将寄存器中的值加载到累加器中,你可以把它理解为**LoaD Accumulator from Register**,就是把某个寄存器中的值,加载到累加器中。那么上面这个指令的意思就是把a1寄存器中的值,加载到累加器中,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/7f/383f390081d055a52eaaab00bc11657f.jpg" alt="">
|
||||
|
||||
我们再来看另外一个段字节码指令:
|
||||
|
||||
```
|
||||
Star r0
|
||||
|
||||
```
|
||||
|
||||
Star 表示 Store Accumulator Register, 你可以把它理解为Store Accumulator to Register,就是把累加器中的值保存到某个寄存器中,上面这段代码的意思就是将累加器中的数值保存到r0寄存器中,具体流程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/39/d2f74d6b9d7d683c5b10543cc5aa0139.jpg" alt="">
|
||||
|
||||
我们再来看一个执行加法的字节码:
|
||||
|
||||
```
|
||||
Add a0, [0]
|
||||
|
||||
```
|
||||
|
||||
Add a0, [0]是从a0寄存器加载值并将其与累加器中的值相加,然后将结果再次放入累加器,最终操作如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/35/ca75316e6fbf04267392a91f66aa9e35.jpg" alt="">
|
||||
|
||||
你可能会注意到,add a0 后面还跟了一个[0],这个符号是做什么的呢?
|
||||
|
||||
这个称之为feedback vector slot,中文我们可以称为反馈向量槽,它是一个数组,解释器将解释执行过程中的一些数据类型的分析信息都保存在这个反馈向量槽中了,目的是为了给TurboFan优化编译器提供优化信息,很多字节码都会为反馈向量槽提供运行时信息,这块内容我们会在下一节来介绍。
|
||||
|
||||
在上面的字节码中,还有一个:
|
||||
|
||||
```
|
||||
LdaSmi [2]
|
||||
|
||||
```
|
||||
|
||||
这是将小整数(Smi)2 加载到累加器寄存器中,操作流程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/45/232b4c97b686c06008ebf4b4cd0f1a45.jpg" alt="">
|
||||
|
||||
我们再来看一个字节码:
|
||||
|
||||
```
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
Return 结束当前函数的执行,并将控制权传回给调用方。返回的值是累加器中的值。
|
||||
|
||||
好了,上面我们分析了几个常见的字节码的含义,相信你已经发现了,大部分字节码都间接地使用了累加器,认识到累加器在字节码指令中的使用方式之后,再去认识V8中的字节码就会非常轻松了。
|
||||
|
||||
## 完整分析一段字节码
|
||||
|
||||
接下来,我们完整地分析一段字节码是怎么执行的:
|
||||
|
||||
```
|
||||
StackCheck
|
||||
Ldar a1
|
||||
Add a0, [0]
|
||||
Star r0
|
||||
LdaSmi [2]
|
||||
Star r1
|
||||
Ldar r0
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
执行这段代码时,整体的状态如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/56/b3a3e88341d762bb7467ca2941e4c356.jpg" alt="">
|
||||
|
||||
我们可以看到:
|
||||
|
||||
- 参数对象parameter保存在栈中,包含了a0和a1两个值,在上面的代码中,这两个值分别是1和2;
|
||||
- PC寄存器指向了第一个字节码StackCheck,我们知道,V8在执行一个函数之前,会判断栈是否会溢出,这里的StackCheck字节码指令就是检查栈是否达到了溢出的上限,如果栈增长超过某个阈值,我们将中止该函数的执行并抛出一个RangeError,表示栈已溢出。
|
||||
|
||||
然后继续执行下一条字节码,Ldar a1,这是将a1寄存器中的参数值加载到累加器中,这时候第一个参数就保存到累加器中了。
|
||||
|
||||
接下来执行加法操作,Add a0, [0],因为a0是第一个寄存器,存放了第一个参数,Add a0就是将第一个寄存器中的值和累加器中的值相加,也就是将累加器中的2和通用寄存器中a0中的1进行相加,同时将相加后的结果3保存到累加器中。
|
||||
|
||||
现在累加器中就保存了相加后的结果,然后执行第四段字节码,Star r0,这是将累加器中的值,也就是1+2的结果3保存到寄存器r0中,那么现在寄存器r0中的值就是3了。
|
||||
|
||||
然后将常数2加载到累加器中,又将累加器中的2加载到寄存器r1中,我们发现这里两段代码可能没实际的用途,不过V8生成的字节码就是这样。
|
||||
|
||||
接下来V8将寄存器r0中的值加载到累加器中,然后执行最后一句Return指令,Return指令会中断当前函数的执行,并将累加器中的值作为返回值。
|
||||
|
||||
这样V8就执行完成了add函数。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们先分析了V8是如何生成字节码的,有了字节码,V8的解释器就可以解释执行字节码了。通常有两种架构的解释器,基于栈的和基于寄存器的。基于栈的解释器会将一些中间数据存放到栈中,而基于寄存器的解释器会将一些中间数据存放到寄存器中。由于采用了不同的模式,所以字节码的指令形式是不同的。
|
||||
|
||||
而目前版本的V8是基于寄存器的,所以我们又重点分析了基于寄存器的解释器的架构,这些寄存器和CPU中的寄存器类似,不过这里有一个特别的寄存器,那就是累加器。在操作过程中,一些中间结果都默认放到累加器中,比如Ldar a1就是将第二个参数加载到累加器中,Star r0是将累加器中的值写入到r0寄存器中,Return就是返回累加器中的数值。
|
||||
|
||||
理解了累加器的重要性,我们又分析了一些常用字节码指令,这包括了Ldar、Star、Add、LdaSmi、Return,了解了这些指令是怎么工作的之后,我们就可以完整地分析一段字节码的工作流程了。
|
||||
|
||||
## 思考题
|
||||
|
||||
观察下面这段代码:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var d = 20
|
||||
return function inner(a, b) {
|
||||
const c = a + b + d
|
||||
return c
|
||||
}
|
||||
}
|
||||
const f = foo()
|
||||
f(1,2)
|
||||
|
||||
```
|
||||
|
||||
请你课后利用d8生成字节码,然后分析字节码的执行流程,欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
315
极客时间专栏/图解 Google V8/V8编译流水线/15 | 隐藏类:如何在内存中快速查找对象属性?.md
Normal file
315
极客时间专栏/图解 Google V8/V8编译流水线/15 | 隐藏类:如何在内存中快速查找对象属性?.md
Normal file
@@ -0,0 +1,315 @@
|
||||
<audio id="audio" title="15 | 隐藏类:如何在内存中快速查找对象属性?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2f/49/2f8a8e9e76f408a7a87b75bcd514b749.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
我们知道JavaScript是一门动态语言,其执行效率要低于静态语言,V8为了提升JavaScript的执行速度,借鉴了很多静态语言的特性,比如实现了JIT机制,为了提升对象的属性访问速度而引入了隐藏类,为了加速运算而引入了内联缓存。
|
||||
|
||||
今天我们来重点分析下V8中的隐藏类,看看它是怎么提升访问对象属性值速度的。
|
||||
|
||||
## 为什么静态语言的效率更高?
|
||||
|
||||
由于隐藏类借鉴了部分静态语言的特性,因此要解释清楚这个问题,我们就先来分析下为什么静态语言比动态语言的执行效率更高。
|
||||
|
||||
我们通过下面两段代码,来对比一下动态语言和静态语言在运行时的一些特征,一段是动态语言的JavaScript,另外一段静态语言的C++的源码,具体源码你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/d7/205a2fa05c6aba57ade25f3a1df2bad7.jpg" alt="">
|
||||
|
||||
那么在运行时,这两段代码的执行过程有什么区别呢?
|
||||
|
||||
我们知道,JavaScript在运行时,对象的属性是可以被修改的,所以当V8使用了一个对象时,比如使用了 start.x的时候,它并不知道该对象中是否有x,也不知道x相对于对象的偏移量是多少,也可以说V8并不知道该对象的具体的形状。
|
||||
|
||||
那么,当在JavaScript中要查询对象start中的x属性时,V8会按照具体的规则一步一步来查询,这个过程非常的慢且耗时(具体查找过程你可以参考《[03|快属性和慢属性:V8是怎样提升对象属性访问速度的?](https://time.geekbang.org/column/article/213250)》这节课程中的内容)。
|
||||
|
||||
这种动态查询对象属性的方式和C++这种静态语言不同,C++在声明一个对象之前需要定义该对象的结构,我们也可以称为形状,比如Point结构体就是一种形状,我们可以使用这个形状来定义具体的对象。
|
||||
|
||||
C++代码在执行之前需要先被编译,编译的时候,每个对象的形状都是固定的,也就是说,在代码的执行过程中,Point的形状是无法被改变的。
|
||||
|
||||
那么在C++中访问一个对象的属性时,自然就知道该属性相对于该对象地址的偏移值了,比如在C++中使用start.x的时候,编译器会直接将x相对于start的地址写进汇编指令中,那么当使用了对象start中的x属性时,CPU就可以直接去内存地址中取出该内容即可,没有任何中间的查找环节。
|
||||
|
||||
因为静态语言中,可以直接通过偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因。
|
||||
|
||||
## 什么是隐藏类(Hidden Class)?
|
||||
|
||||
既然静态语言的查询效率这么高,那么是否能将这种静态的特性引入到V8中呢?
|
||||
|
||||
答案是**可行**的。
|
||||
|
||||
目前所采用的一个思路就是将JavaScript中的对象静态化,也就是V8在运行JavaScript的过程中,会假设JavaScript中的对象是静态的,具体地讲,V8对每个对象做如下两点假设:
|
||||
|
||||
- 对象创建好了之后就不会添加新的属性;
|
||||
- 对象创建好了之后也不会删除属性。
|
||||
|
||||
符合这两个假设之后,V8就可以对JavaScript中的对象做深度优化了,那么怎么优化呢?
|
||||
|
||||
具体地讲,V8会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:
|
||||
|
||||
- 对象中所包含的所有的属性;
|
||||
- 每个属性相对于对象的偏移量。
|
||||
|
||||
有了隐藏类之后,那么当V8访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8就可以直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了V8查找对象的效率。
|
||||
|
||||
我们可以结合一段代码来分析下隐藏类是怎么工作的:
|
||||
|
||||
```
|
||||
let point = {x:100,y:200}
|
||||
|
||||
```
|
||||
|
||||
当V8执行到这段代码时,会先为point对象创建一个隐藏类,在V8中,把隐藏类又称为**map**,每个对象都有一个map属性,其值指向内存中的隐藏类。
|
||||
|
||||
隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量,比如point对象的隐藏类就包括了x和y属性,x的偏移量是4,y的偏移量是8。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/6d/4eab311ab4ab94693325a0ca24618b6d.jpg" alt="">
|
||||
|
||||
注意,这是point对象的map,它不是point对象本身。关于point对象和map之间的关系,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/f8/51f5034a7f80e4e5684d5a301178c2f8.jpg" alt="">
|
||||
|
||||
在这张图中,左边的是point对象在内存中的布局,右边是point对象的map,我们可以看到,point对象的第一个属性就指向了它的map,关于如何通过浏览器查看对象的map,我们在《[03|快属性和慢属性:V8是怎样提升对象属性访问速度的?](https://time.geekbang.org/column/article/213250)》这节课也做过简单的分析,你可以回顾下这节内容。
|
||||
|
||||
有了map之后,当你再次使用point.x访问x属性时,V8会查询point的map中x属性相对point对象的偏移量,然后将point对象的起始位置加上偏移量,就得到了x属性的值在内存中的位置,有了这个位置也就拿到了x的值,这样我们就省去了一个比较复杂的查找过程。
|
||||
|
||||
这就是将动态语言静态化的一个操作,V8通过引入隐藏类,模拟C++这种静态语言的机制,从而达到静态语言的执行效率。
|
||||
|
||||
## 实践:通过d8查看隐藏类
|
||||
|
||||
了解了隐藏类的工作机制,我们可以使用d8提供的API DebugPrint来查看point对象中的隐藏类。
|
||||
|
||||
```
|
||||
let point = {x:100,y:200};
|
||||
%DebugPrint(point);
|
||||
|
||||
```
|
||||
|
||||
这里你需要注意,在使用d8内部API时,有一点很容易出错,就是需要为JavaScript代码加上分号,不然d8会报错,所以这段代码里面我都加上了分号。
|
||||
|
||||
然后将下面这段代码保存test.js文件中,再执行:
|
||||
|
||||
```
|
||||
d8 --allow-natives-syntax test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段命令,就可以打印出point对象的基础结构了,打印出来的结果如下所示:
|
||||
|
||||
```
|
||||
DebugPrint: 0x19dc080c5af5: [JS_OBJECT_TYPE]
|
||||
- map: 0x19dc08284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- prototype: 0x19dc08241151 <Object map = 0x19dc082801c1>
|
||||
- elements: 0x19dc080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
|
||||
- properties: 0x19dc080406e9 <FixedArray[0]> {
|
||||
#x: 100 (const data field 0)
|
||||
#y: 200 (const data field 1)
|
||||
}
|
||||
0x19dc08284d11: [Map]
|
||||
- type: JS_OBJECT_TYPE
|
||||
- instance size: 20
|
||||
- inobject properties: 2
|
||||
- elements kind: HOLEY_ELEMENTS
|
||||
- unused property fields: 0
|
||||
- enum length: invalid
|
||||
- stable_map
|
||||
- back pointer: 0x19dc08284ce9 <Map(HOLEY_ELEMENTS)>
|
||||
- prototype_validity cell: 0x19dc081c0451 <Cell value= 1>
|
||||
- instance descriptors (own) #2: 0x19dc080c5b25 <DescriptorArray[2]>
|
||||
- prototype: 0x19dc08241151 <Object map = 0x19dc082801c1>
|
||||
- constructor: 0x19dc0824116d <JSFunction Object (sfi = 0x19dc081c55ad)>
|
||||
- dependent code: 0x19dc080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
|
||||
- construction counter: 0
|
||||
|
||||
```
|
||||
|
||||
从这段point的内存结构中,我们可以看到,point对象的第一个属性就是map,它指向了0x19dc08284d11这个地址,这个地址就是V8为point对象创建的隐藏类,除了map属性之外,还有我们之前介绍过的prototype属性,elements属性和properties属性(关于这些属性的函数,你可以参看《[03|快属性和慢属性:V8是怎样提升对象属性访问速度的?](https://time.geekbang.org/column/article/213250)》和《[05|原型链:V8是如何实现对象继承的?](https://time.geekbang.org/column/article/215425)》这两节的内容)。
|
||||
|
||||
## 多个对象共用一个隐藏类
|
||||
|
||||
现在我们知道了在V8中,每个对象都有一个map属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8就会为其复用同一个隐藏类,这样有两个好处:
|
||||
|
||||
1. 减少隐藏类的创建次数,也间接加速了代码的执行速度;
|
||||
1. 减少了隐藏类的存储空间。
|
||||
|
||||
那么,什么情况下两个对象的形状是相同的,要满足以下两点:
|
||||
|
||||
- 相同的属性名称;
|
||||
- 相等的属性个数。
|
||||
|
||||
接下来我们就来创建两个形状一样的对象,然后看看它们的map属性是不是指向了同一个隐藏类,你可以参看下面的代码:
|
||||
|
||||
```
|
||||
let point = {x:100,y:200};
|
||||
let point2 = {x:3,y:4};
|
||||
%DebugPrint(point);
|
||||
%DebugPrint(point2);
|
||||
|
||||
```
|
||||
|
||||
当V8执行到这段代码时,首先会为point对象创建一个隐藏类,然后继续创建point2对象。在创建point2对象的过程中,发现它的形状和point是一样的。这时候,V8就会将point的隐藏类给point2复用,具体效果你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/78/9f0de55e75463406fbbff452dcef2178.jpg" alt="">
|
||||
|
||||
你也可以使用d8来证实下,同样使用这个命令:
|
||||
|
||||
```
|
||||
d8 --allow-natives-syntax test.js
|
||||
|
||||
```
|
||||
|
||||
打印出来的point和point2对象,你会发现它们的map属性都指向了同一个地址,这也就意味着它们共用了同一个map。
|
||||
|
||||
## 重新构建隐藏类
|
||||
|
||||
关于隐藏类,还有一个问题你需要注意一下。在这节课开头我们提到了,V8为了实现隐藏类,需要两个假设条件:
|
||||
|
||||
- 对象创建好了之后就不会添加新的属性;
|
||||
- 对象创建好了之后也不会删除属性。
|
||||
|
||||
但是,JavaScript依然是动态语言,在执行过程中,对象的形状是可以被改变的,如果某个对象的形状改变了,隐藏类也会随着改变,这意味着V8要为新改变的对象重新构建新的隐藏类,这对于V8的执行效率来说,是一笔大的开销。
|
||||
|
||||
通俗地理解,给一个对象添加新的属性,删除新的属性,或者改变某个属性的数据类型都会改变这个对象的形状,那么势必也就会触发V8为改变形状后的对象重建新的隐藏类。
|
||||
|
||||
我们可以看一个简单的例子:
|
||||
|
||||
```
|
||||
let point = {};
|
||||
%DebugPrint(point);
|
||||
point.x = 100;
|
||||
%DebugPrint(point);
|
||||
point.y = 200;
|
||||
%DebugPrint(point);
|
||||
|
||||
```
|
||||
|
||||
将这段代码保存到test.js文件中,然后执行:
|
||||
|
||||
```
|
||||
d8 --allow-natives-syntax test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段命令,d8会打印出来不同阶段的point对象所指向的隐藏类,在这里我们只关心point对象map的指向,所以我将其他的一些信息都省略了,最终打印出来的结果如下所示:
|
||||
|
||||
```
|
||||
DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
|
||||
- map: 0x0986082802d9 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- ...
|
||||
|
||||
|
||||
DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
|
||||
- map: 0x098608284ce9 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- ...
|
||||
- properties: 0x0986080406e9 <FixedArray[0]> {
|
||||
#x: 100 (const data field 0)
|
||||
}
|
||||
|
||||
|
||||
DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
|
||||
- map: 0x098608284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- p
|
||||
- ...
|
||||
- properties: 0x0986080406e9 <FixedArray[0]> {
|
||||
#x: 100 (const data field 0)
|
||||
#y: 200 (const data field 1)
|
||||
|
||||
```
|
||||
|
||||
根据这个打印出来的结果,我们可以明显看到,每次给对象添加了一个新属性之后,该对象的隐藏类的地址都会改变,这也就意味着隐藏类也随着改变了,改变过程你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/11/84048c09badc17ef896023ec30f45111.jpg" alt="">
|
||||
|
||||
同样,如果你删除了对象的某个属性,那么对象的形状也就随着发生了改变,这时V8也会重建该对象的隐藏类,我们可以看下面这样的一个例子:
|
||||
|
||||
```
|
||||
let point = {x:100,y:200};
|
||||
delete point.x
|
||||
|
||||
```
|
||||
|
||||
我们再次使用d8来打印这段代码中不同阶段的point对象属性,移除多余的信息,最终打印出来的结果如下所示
|
||||
|
||||
```
|
||||
DebugPrint: 0x1c2f080c5b1d: [JS_OBJECT_TYPE]
|
||||
- map: 0x1c2f08284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
-...
|
||||
- properties: 0x1c2f080406e9 <FixedArray[0]> {
|
||||
#x: 100 (const data field 0)
|
||||
#y: 200 (const data field 1)
|
||||
}
|
||||
|
||||
|
||||
DebugPrint: 0x1c2f080c5b1d: [JS_OBJECT_TYPE]
|
||||
- map: 0x1c2f08284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
||||
- ...
|
||||
- properties: 0x1c2f08045567 <FixedArray[0]> {
|
||||
#y: 200 (const data field 1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
好了,现在我们知道了V8会为每个对象分配一个隐藏类,在执行过程中:
|
||||
|
||||
- 如果对象的形状没有发生改变,那么该对象就会一直使用该隐藏类;
|
||||
- 如果对象的形状发生了改变,那么V8会重建一个新的隐藏类给该对象。
|
||||
|
||||
我们当然希望对象中的隐藏类不要随便被改变,因为这样会触发V8重构该对象的隐藏类,直接影响到了程序的执行性能。那么在实际工作中,我们应该尽量注意以下几点:
|
||||
|
||||
**一,使用字面量初始化对象时,要保证属性的顺序是一致的。**比如先通过字面量x、y的顺序创建了一个point对象,然后通过字面量y、x的顺序创建一个对象point2,代码如下所示:
|
||||
|
||||
```
|
||||
let point = {x:100,y:200};
|
||||
let point2 = {y:100,x:200};
|
||||
|
||||
```
|
||||
|
||||
虽然创建时的对象属性一样,但是它们初始化的顺序不一样,这也会导致形状不同,所以它们会有不同的隐藏类,所以我们要尽量避免这种情况。
|
||||
|
||||
**二,尽量使用字面量一次性初始化完整对象属性。**因为每次为对象添加一个属性时,V8都会为该对象重新设置隐藏类。
|
||||
|
||||
**三,尽量避免使用delete方法。**delete方法会破坏对象的形状,同样会导致V8为该对象重新生成新的隐藏类。
|
||||
|
||||
## 总结
|
||||
|
||||
这节课我们介绍了V8中隐藏类的工作机制,我们先分析了V8引入隐藏类的动机。因为JavaScript是一门动态语言,对象属性在执行过程中是可以被修改的,这就导致了在运行时,V8无法知道对象的完整形状,那么当查找对象中的属性时,V8就需要经过一系列复杂的步骤才能获取到对象属性。
|
||||
|
||||
为了加速查找对象属性的速度,V8在背后为每个对象提供了一个隐藏类,隐藏类描述了该对象的具体形状。有了隐藏类,V8就可以根据隐藏类中描述的偏移地址获取对应的属性值,这样就省去了复杂的查找流程。
|
||||
|
||||
不过隐藏类是建立在两个假设基础之上的:
|
||||
|
||||
- 对象创建好了之后就不会添加新的属性;
|
||||
- 对象创建好了之后也不会删除属性。
|
||||
|
||||
一旦对象的形状发生了改变,这意味着V8需要为对象重建新的隐藏类,这就会带来效率问题。为了避免一些不必要的性能问题,我们在程序中尽量不要随意改变对象的形状。我在这节课中也给你列举了几个最佳实践的策略。
|
||||
|
||||
最后,关于隐藏类,我们记住以下几点。
|
||||
|
||||
- 在V8中,每个对象都有一个隐藏类,隐藏类在V8中又被称为map。
|
||||
- 在V8中,每个对象的第一个属性的指针都指向其map地址。
|
||||
- map描述了其对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少?
|
||||
- 如果添加新的属性,那么需要重新构建隐藏类。
|
||||
- 如果删除了对象中的某个属性,同样也需要构建隐藏类。
|
||||
|
||||
## 思考题
|
||||
|
||||
现在我们知道了V8为每个对象配置了一个隐藏类,隐藏类描述了该对象的形状,V8可以通过隐藏类快速获取对象的属性值。不过这里还有另外一类问题需要考虑。
|
||||
|
||||
比如我定义了一个获取对象属性值的函数loadX,loadX有一个参数,然后返回该参数的x属性值:
|
||||
|
||||
```
|
||||
function loadX(o) {
|
||||
return o.x
|
||||
}
|
||||
var o = { x: 1,y:3}
|
||||
var o1 = { x: 3 ,y:6}
|
||||
for (var i = 0; i < 90000; i++) {
|
||||
loadX(o)
|
||||
loadX(o1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当V8调用loadX的时候,会先查找参数o的隐藏类,然后利用隐藏类中的x属性的偏移量查找到x的属性值,虽然利用隐藏类能够快速提升对象属性的查找速度,但是依然有一个查找隐藏类和查找隐藏类中的偏移量两个操作,如果loadX在代码中会被重复执行,依然影响到了属性的查找效率。
|
||||
|
||||
那么留给你的问题是:如果你是V8的设计者,你会采用什么措施来提高loadX函数的执行效率?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
238
极客时间专栏/图解 Google V8/V8编译流水线/16 | 答疑: V8是怎么通过内联缓存来提升函数执行效率的?.md
Normal file
238
极客时间专栏/图解 Google V8/V8编译流水线/16 | 答疑: V8是怎么通过内联缓存来提升函数执行效率的?.md
Normal 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 < 90000; i++) {
|
||||
loadX(o)
|
||||
loadX(o1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们定义了一个loadX函数,它有一个参数o,该函数只是返回了o.x。
|
||||
|
||||
通常V8获取o.x的流程是这样的:**查找对象o的隐藏类,再通过隐藏类查找x属性偏移量,然后根据偏移量获取属性值**,在这段代码中loadX函数会被反复执行,那么获取o.x流程也需要反复被执行。我们有没有办法再度简化这个查找过程,最好能一步到位查找到x的属性值呢?答案是,有的。
|
||||
|
||||
其实这是一个关于内联缓存的思考题。我们可以看到,函数loadX在一个for循环里面被重复执行了很多次,因此V8会想尽一切办法来压缩这个查找过程,以提升对象的查找效率。这个加速函数执行的策略就是**内联缓存(Inline Cache)**,简称为**IC。**
|
||||
|
||||
这节课我们就来解答下,V8是怎么通过IC,来加速函数loadX的执行效率的。
|
||||
|
||||
## 什么是内联缓存?
|
||||
|
||||
要回答这个问题,我们需要知道IC的工作原理。其实IC的原理很简单,直观地理解,就是在V8执行函数的过程中,会观察函数中一些**调用点(CallSite)上的关键的中间数据**,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此V8利用IC,可以有效提升一些重复代码的执行效率。
|
||||
|
||||
接下来,我们就深入分析一下这个过程。
|
||||
|
||||
IC会为每个函数维护一个**反馈向量(FeedBack Vector)**,反馈向量记录了函数在执行过程中的一些关键的中间数据。关于函数和反馈向量的关系你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0f/d3/0f49d225b1ed71aaccd3cca2d1226dd3.jpg" alt="">
|
||||
|
||||
反馈向量其实就是一个表结构,它由很多项组成的,每一项称为一个**插槽(Slot)**,V8会依次将执行loadX函数的中间数据写入到反馈向量的插槽中。
|
||||
|
||||
比如下面这段函数:
|
||||
|
||||
```
|
||||
function loadX(o) {
|
||||
o.y = 4
|
||||
return o.x
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当V8执行这段函数的时候,它会判断 o.y = 4和 return o.x这两段是**调用点(CallSite)**,因为它们使用了对象和属性,那么V8会在loadX函数的反馈向量中为每个调用点分配一个插槽。
|
||||
|
||||
每个插槽中包括了插槽的索引(slot index)、插槽的类型(type)、插槽的状态(state)、隐藏类(map)的地址、还有属性的偏移量,比如上面这个函数中的两个调用点都使用了对象o,那么反馈向量两个插槽中的map属性也都是指向同一个隐藏类的,因此这两个插槽的map地址是一样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/49/609490b948c4a085e8f992de08a44549.jpg" alt="">
|
||||
|
||||
了解了反馈向量的大致结构,我们再来看下当V8执行loadX函数时,loadX函数中的关键数据是如何被写入到反馈向量中。
|
||||
|
||||
loadX的代码如下所示:
|
||||
|
||||
```
|
||||
function loadX(o) {
|
||||
return o.x
|
||||
}
|
||||
loadX({x:1})
|
||||
|
||||
```
|
||||
|
||||
我们将loadX转换为字节码:
|
||||
|
||||
```
|
||||
StackCheck
|
||||
LdaNamedProperty a0, [0], [0]
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
loadX函数的这段字节码很简单,就三句:
|
||||
|
||||
- 第一句是检查栈是否溢出;
|
||||
- 第二句是LdaNamedProperty,它的作用是取出参数a0的第一个属性值,并将属性值放到累加器中;
|
||||
- 第三句是返回累加器中的属性值。
|
||||
|
||||
这里我们重点关注LdaNamedProperty这句字节码,我们看到它有三个参数。a0就是loadX的第一个参数;第二个参数[0]表示取出对象a0的第一个属性值,这两个参数很好理解。第三个参数就和反馈向量有关了,它表示将LdaNamedProperty操作的中间数据写入到反馈向量中,方括号中间的0表示写入反馈向量的第一个插槽中。具体你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/64/a170f18653cea4b02bc9afb96b9f3764.jpg" alt="">
|
||||
|
||||
观察上图,我们可以看出,在函数loadX的反馈向量中,已经缓存了数据:
|
||||
|
||||
- 在map栏,缓存了o的隐藏类的地址;
|
||||
- 在offset一栏,缓存了属性x的偏移量;
|
||||
- 在type一栏,缓存了操作类型,这里是LOAD类型。在反馈向量中,我们把这种通过o.x来访问对象属性值的操作称为LOAD类型。
|
||||
|
||||
V8除了缓存o.x这种LOAD类型的操作以外,还会缓存**存储(STORE)类型**和**函数调用(CALL)类型**的中间数据。
|
||||
|
||||
为了分析后面两种存储形式,我们再来看下面这段代码:
|
||||
|
||||
```
|
||||
function foo(){}
|
||||
function loadX(o) {
|
||||
o.y = 4
|
||||
foo()
|
||||
return o.x
|
||||
}
|
||||
loadX({x:1,y:4})
|
||||
|
||||
```
|
||||
|
||||
相应的字节码如下所示:
|
||||
|
||||
```
|
||||
StackCheck
|
||||
LdaSmi [4]
|
||||
StaNamedProperty a0, [0], [0]
|
||||
LdaGlobal [1], [2]
|
||||
Star r0
|
||||
CallUndefinedReceiver0 r0, [4]
|
||||
LdaNamedProperty a0, [2], [6]
|
||||
Return
|
||||
|
||||
```
|
||||
|
||||
下图是我画的这段字节码的执行流程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/b4/ab7b91aea94d35ff2e6023aef05b56b4.jpg" alt="">
|
||||
|
||||
从图中可以看出,`o.y = 4` 对应的字节码是:
|
||||
|
||||
```
|
||||
LdaSmi [4]
|
||||
StaNamedProperty a0, [0], [0]
|
||||
|
||||
```
|
||||
|
||||
这段代码是先使用LdaSmi [4],将常数4加载到累加器中,然后通过StaNamedProperty的字节码指令,将累加器中的4赋给o.y,这是一个**存储(STORE)类型**的操作,V8会将操作的中间结果存放到反馈向量中的第一个插槽中。
|
||||
|
||||
调用foo函数的字节码是:
|
||||
|
||||
```
|
||||
LdaGlobal [1], [2]
|
||||
Star r0
|
||||
CallUndefinedReceiver0 r0, [4]
|
||||
|
||||
```
|
||||
|
||||
解释器首先加载foo函数对象的地址到累加器中,这是通过LdaGlobal来完成的,然后V8会将加载的中间结果存放到反馈向量的第3个插槽中,这是一个存储类型的操作。接下来执行CallUndefinedReceiver0,来实现foo函数的调用,并将执行的中间结果放到反馈向量的第5个插槽中,这是一个**调用(CALL)类型**的操作。
|
||||
|
||||
最后就是返回o.x,return o.x仅仅是加载对象中的x属性,所以这是一个**加载(LOAD)类型**的操作,我们在上面介绍过的。最终生成的反馈向量如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/cb/ba826723b58509527fd2f316214092cb.jpg" alt="">
|
||||
|
||||
现在有了反馈向量缓存的数据,那V8是如何利用这些数据的呢?
|
||||
|
||||
当V8再次调用loadX函数时,比如执行到loadX函数中的return o.x语句时,它就会在对应的插槽中查找x属性的偏移量,之后V8就能直接去内存中获取o.x的属性值了。这样就大大提升了V8的执行效率。
|
||||
|
||||
## 多态和超态
|
||||
|
||||
好了,通过缓存执行过程中的基础信息,就能够提升下次执行函数时的效率,但是这有一个前提,那就是多次执行时,对象的形状是固定的,如果对象的形状不是固定的,那V8会怎么处理呢?
|
||||
|
||||
我们调整一下上面这段loadX函数的代码,调整后的代码如下所示:
|
||||
|
||||
```
|
||||
function loadX(o) {
|
||||
return o.x
|
||||
}
|
||||
var o = { x: 1,y:3}
|
||||
var o1 = { x: 3, y:6,z:4}
|
||||
for (var i = 0; i < 90000; i++) {
|
||||
loadX(o)
|
||||
loadX(o1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,对象o和o1的形状是不同的,这意味着V8为它们创建的隐藏类也是不同的。
|
||||
|
||||
第一次执行时loadX时,V8会将o的隐藏类记录在反馈向量中,并记录属性x的偏移量。那么当再次调用loadX函数时,V8会取出反馈向量中记录的隐藏类,并和新的o1的隐藏类进行比较,发现不是一个隐藏类,那么此时V8就无法使用反馈向量中记录的偏移量信息了。
|
||||
|
||||
面对这种情况,V8会选择将新的隐藏类也记录在反馈向量中,同时记录属性值的偏移量,这时,反馈向量中的第一个槽里就包含了两个隐藏类和偏移量。具体你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/b6/63f3caf97413881481bc6a86cdf065b6.jpg" alt="">
|
||||
|
||||
当V8再次执行loadX函数中的o.x语句时,同样会查找反馈向量表,发现第一个槽中记录了两个隐藏类。这时,V8需要额外做一件事,那就是拿这个新的隐藏类和第一个插槽中的两个隐藏类来一一比较,如果新的隐藏类和第一个插槽中某个隐藏类相同,那么就使用该命中的隐藏类的偏移量。如果没有相同的呢?同样将新的信息添加到反馈向量的第一个插槽中。
|
||||
|
||||
现在我们知道了,一个反馈向量的一个插槽中可以包含多个隐藏类的信息,那么:
|
||||
|
||||
- 如果一个插槽中只包含1个隐藏类,那么我们称这种状态为**单态(monomorphic);**
|
||||
- 如果一个插槽中包含了2~4个隐藏类,那我们称这种状态为**多态(polymorphic);**
|
||||
- 如果一个插槽中超过4个隐藏类,那我们称这种状态为**超态(magamorphic)。**
|
||||
|
||||
如果函数loadX的反馈向量中存在多态或者超态的情况,其执行效率肯定要低于单态的,比如当执行到o.x的时候,V8会查询反馈向量的第一个插槽,发现里面有多个map的记录,那么V8就需要取出o的隐藏类,来和插槽中记录的隐藏类一一比较,如果记录的隐藏类越多,那么比较的次数也就越多,这就意味着执行效率越低。
|
||||
|
||||
比如插槽中包含了2~4个隐藏类,那么可以使用线性结构来存储,如果超过4个,那么V8会采取hash表的结构来存储,这无疑会拖慢执行效率。单态、多态、超态等三种情况的执行性能如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/dd/900adb91196e4be3ad5388a15069d2dd.jpg" alt="">
|
||||
|
||||
## 尽量保持单态
|
||||
|
||||
这就是IC的一些基础情况,非常简单,只是为每个函数添加了一个缓存,当第一次执行该函数时,V8会将函数中的存储、加载和调用相关的中间结果保存到反馈向量中。当再次执行时,V8就要去反馈向量中查找相关中间信息,如果命中了,那么就直接使用中间信息。
|
||||
|
||||
了解了IC的基础执行原理,我们就能理解一些最佳实践背后的道理,这样你并不需要去刻意记住这些最佳实践了,因为你已经从内部理解了它。
|
||||
|
||||
总的来说,我们只需要记住一条就足够了,那就是**单态的性能优于多态和超态,**所以我们需要稍微避免多态和超态的情况。
|
||||
|
||||
要避免多态和超态,那么就尽量默认所有的对象属性是不变的,比如你写了一个loadX(o)的函数,那么当传递参数时,尽量不要使用多个不同形状的o对象。
|
||||
|
||||
## 总结
|
||||
|
||||
这节课我们通过分析IC的工作原理,来介绍了它是如何提升代码执行速度的。
|
||||
|
||||
虽然隐藏类能够加速查找对象的速度,但是在V8查找对象属性值的过程中,依然有查找对象的隐藏类和根据隐藏类来查找对象属性值的过程。
|
||||
|
||||
如果一个函数中利用了对象的属性,并且这个函数会被多次执行,那么V8就会考虑,怎么将这个查找过程再度简化,最好能将属性的查找过程能一步到位。
|
||||
|
||||
因此,V8引入了IC,IC会监听每个函数的执行过程,并在一些关键的地方埋下监听点,这些包括了加载对象属性(Load)、给对象属性赋值(Store)、还有函数调用(Call),V8会将监听到的数据写入一个称为**反馈向量(FeedBack Vector)**的结构中,同时V8会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8就可以缩短对象属性的查找路径,从而提升执行效率。
|
||||
|
||||
但是针对函数中的同一段代码,如果对象的隐藏类是不同的,那么反馈向量也会记录这些不同的隐藏类,这就出现了多态和超态的情况。我们在实际项目中,要尽量避免出现多态或者超态的情况。
|
||||
|
||||
最后我还想强调一点,虽然我们分析的隐藏类和IC能提升代码的执行速度,但是在实际的项目中,影响执行性能的因素非常多,**找出那些影响性能瓶颈才是至关重要**的,**你不需要过度关注微优化,你也不需要过度担忧你的代码是否破坏了隐藏类或者IC的机制**,因为相对于其他的性能瓶颈,它们对效率的影响可能是微不足道的。
|
||||
|
||||
## 思考题
|
||||
|
||||
观察下面两段代码:
|
||||
|
||||
```
|
||||
let data = [1, 2, 3, 4]
|
||||
data.forEach((item) => console.log(item.toString())
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
let data = ['1', 2, '3', 4]
|
||||
data.forEach((item) => console.log(item.toString())
|
||||
|
||||
```
|
||||
|
||||
你认为这两段代码,哪段的执行效率高,为什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
Reference in New Issue
Block a user