121-图解 Google V8

开篇词 | 如何学习谷歌高性能 JavaScript 引擎V8？

你好，我是李兵，《浏览器工作原理与实践》专栏的作者。在浏览器专栏中，我们对浏览器的工作原理进行了详细的介绍，其中也提到了 V8 是如何执行 JavaScript 代码的。很多朋友对这部分的学习意犹未尽，因此我又回到了极客时间，与你深入聊聊 V8。

什么是 V8？

V8 是 JavaScript 虚拟机的一种。我们可以简单地把 JavaScript 虚拟机理解成是一个翻译程序，将人类能够理解的编程语言 JavaScript，翻译成机器能够理解的机器语言。如下图所示：

JavaScript引擎

上图中，中间的“黑盒”就是 JavaScript 引擎 V8。目前市面上有很多种 JavaScript 引擎，诸如 SpiderMonkey、V8、JavaScriptCore 等。而由谷歌开发的开源项目 V8 是当下使用最广泛的 JavaScript 虚拟机，全球有超过 25 亿台安卓设备，而这些设备中都使用了 Chrome 浏览器，所以我们写的 JavaScript 应用，大都跑在 V8 上。

V8 之所以拥有如此庞大的生态圈，也和它许多革命性的设计是分不开的。

在 V8 出现之前，所有的 JavaScript 虚拟机所采用的都是解释执行的方式，这是 JavaScript 执行速度过慢的一个主要原因。而 V8 率先引入了即时编译（JIT）的双轮驱动的设计，这是一种权衡策略，混合编译执行和解释执行这两种手段，给 JavaScript 的执行速度带来了极大的提升。

V8 出现之后，各大厂商也都在自己的 JavaScript 虚拟机中引入了 JIT 机制，所以你会看到目前市面上 JavaScript 虚拟机都有着类似的架构。另外，V8 也是早于其他虚拟机引入了惰性编译、内联缓存、隐藏类等机制，进一步优化了 JavaScript 代码的编译执行效率。

可以说，V8 的出现，将 JavaScript 虚拟机技术推向了一个全新的高度。

即便 V8 具有诸多优点，但我相信对于大部分同学来说，V8 虚拟机还只是一个黑盒，我们将一段代码丢给这个黑盒，它便会返回结果，并没有深入了解过它的工作原理。

如果只是单纯使用 JavaScript 和调用 Web API，并不了解虚拟机内部是怎样工作的，在项目中遇到的很多问题很可能找不到解决的途径。比如，有时项目的占用内存过高，或者页面响应速度过慢，又或者使用 Node.js 的时候导致任务被阻塞等问题，都与 V8 的基本运行机制有关。如果你熟悉 V8 的工作机制，就会有系统性的思路来解决这些问题。

另外，V8 的主要功能，就是结合 JavaScript 语言的特性和本质来编译执行它。通过深入地学习 V8，你对 JavaScript 语言本质和设计思想会有很直观的感受。这些设计思想像是更加高级的工具，你掌握了它，就可以提升你的语言使用和架构设计水平。

如何学习 V8？

那么，我们应该如何来学习 V8 呢？

刚刚我们也说过，V8 的主要职责是用来执行 JavaScript 代码的，那我们首先需要先了解 JavaScript 这门语言的基本特性和设计思想。

JavaScript 借鉴了很多语言的特性，比如 C 语言的基本语法、Java 的类型系统和内存管理、Scheme 的函数作为一等公民，还有 Self 基于原型（prototype）的继承机制。毫无疑问，JavaScript 是一门非常优秀的语言，特别是“原型继承机制”和“函数是一等公民”这两个设计。

JavaScript的设计思想

不过 JavaScript 也是一门处处是坑的语言，由于历史原因，很多错误的或者不合理的设计都被延续至今，比如使用 new 加构造函数来创建对象，这种方式的背后隐藏了太多的细节，非常容易增加代码出错概率，而且也大大增加了新手的学习成本；再比如初期的 JavaScript 没有块级作用域机制，使得 JavaScript 需要采取变量提升的策略，而变量提升又是非常反人性的设计。

V8 是 JavaScript 的实现，在学习 V8 工作原理时，我们就要格外关注 JavaScript 这些独特的设计思想和特性背后的实现。比如，为了实现函数是一等公民的特性，JavaScript 采取了基于对象的策略；再比如为了实现原型继承，V8 为每个对象引入了 __proto__ 属性。

深入分析过 JavaScript 语言之后，我们就可以学习 V8 执行 JavaScript 代码的完整流程了。我们把这套流程称之为 V8 的编译流水线，其完整流程如下图所示：

V8编译流水线

编译流水线本身并不复杂，但是其中涉及到了很多技术，诸如 JIT、延迟解析、隐藏类、内联缓存等等。这些技术决定着一段 JavaScript 代码能否正常执行，以及代码的执行效率。

比如 V8 中使用的隐藏类（Hide Class），这是将 JavaScript 中动态类型转换为静态类型的一种技术，可以消除动态类型的语言执行速度过慢的问题，如果你熟悉 V8 的工作机制，在你编写 JavaScript 时，就能充分利用好隐藏类这种强大的优化特性，写出更加高效的代码。

再比如，V8 实现了 JavaScript 代码的惰性解析，目的是为了加速代码的启动速度，通过对惰性解析机制的学习，你可以优化你的代码更加适应这个机制，从而提高程序性能。

要想充分了解 V8 是怎么工作的，除了要分析编译流水线，我们还需要了解另外两个非常重要的特性，那就是事件循环系统和垃圾回收机制。

事件循环系统和 JavaScript 中的难点——异步编程特性紧密相关。我们知道，JavaScript 是单线程的，JavaScript 代码都是在一个线程上执行，如果同一时间发送了多个 JavaScript 执行的请求，就需要排队，也就是进行异步编程。

V8 的事件循环系统会调度这些排队任务，保证 JavaScript 代码被 V8 有序地执行。因此也可以说，事件循环系统就是 V8 的心脏，它驱动了 V8 的持续工作。

另外，JavaScript 是一种自动垃圾回收的语言，V8 在执行垃圾回收时，会占用主线程的资源，如果我们编写的程序频繁触发垃圾回收，那么无疑会阻塞主线程，这也是我们经常会遇到的一个问题。你需要知道 V8 是如何分配内存数据的，以及这些数据是如何被回收的，打通整个链路，建立完整的系统，当下次遇到内存问题时，就知道如何去排查了。

V8知识图谱，可以点击放大查看

以上，就是系统学习 V8 的路径。在我们这一季的课程中，也会按照这样的思路来设计课程，来帮助你学习到 V8 的完整的知识体系。

首先，我们会从 JavaScript 的设计思想讲起，讨论它背后的一些主要特性，以及 V8 是怎么实现这些特性的。

然后，我们再来分析 V8 的编译流水线，在课程中间我们还会穿插介绍一些内存分配相关的内容，因为函数调用、变量声明、参数传递或者函数返回数值都涉及到了内存分配。

最后，我们会介绍事件循环系统和垃圾回收系统的工作机制。

专栏目录

虽然本课程的篇幅不多，但是也具有一定的深度和广度。不过你并不需要担心内容太难，我会尽量将每节内容做到深入浅出，有什么问题你可以在留言区提问，我看到后都会第一时间来解答。

另外，在每个模块结束后，我会做一次热点问题的答疑，尽量帮你扫清学习 V8 的障碍。你需要做的是持之以恒地学习、反思与实践。

加油，从今天起，就让我们一起开始 V8 的学习旅程吧！

01 | V8是如何执行一段JavaScript代码的？

你好，我是李兵。

今天是我们整个课程的第一讲，我会从一个高层的宏观视角来解释什么是 V8，以及 V8 又是怎么执行一段 JavaScript 代码的。在这个过程中，我会引入一些核心概念，诸如 JIT、作用域、词法环境、执行上下文等，理解了这些概念，能够帮助你更好地理解 V8 是如何工作的，同时也能帮助你写出更加高效的 JavaScript 代码。

由于本节的目的是对 V8 做一个宏观的、全面的介绍，其目的是让你对 V8 的执行流程有个整体上的认识，所以涉及到的概念会比较多，如果你对其中一些概念不太理解也没有关系，在后面的章节中我会展开了详细地介绍。

什么是 V8？

首先我们来看看什么是 V8。

V8 是一个由 Google 开发的开源 JavaScript 引擎，目前用在 Chrome 浏览器和 Node.js 中，其核心功能是执行易于人类理解的 JavaScript 代码。

V8执行JavaScript

那么 V8 又是怎么执行 JavaScript 代码的呢？

其主要核心流程分为编译和执行两步。首先需要将 JavaScript 代码转换为低级中间代码或者机器能够理解的机器代码，然后再执行转换后的代码并输出执行结果。

转换为中间代码

你可以把 V8 看成是一个虚构出来的计算机，也称为虚拟机，虚拟机通过模拟实际计算机的各种功能来实现代码的执行，如模拟实际计算机的 CPU、堆栈、寄存器等，虚拟机还具有它自己的一套指令系统。

所以对于 JavaScript 代码来说，V8 就是它的整个世界，当 V8 执行 JavaScript 代码时，你并不需要担心现实中不同操作系统的差异，也不需要担心不同体系结构计算机的差异，你只需要按照虚拟机的规范写好代码就可以了。

既然 V8 是虚构出来的计算机，用来编译和执行 JavaScript 代码，那么接下来我们就看看，为什么计算机需要对 JavaScript 这样的高级语言进行编译，以及编译完成后又是如何执行的。

高级代码为什么需要先编译再执行？

我们先从 CPU 是怎么执行机器代码讲起，你可以把 CPU 看成是一个非常小的运算机器，我们可以通过二进制的指令和 CPU 进行沟通，比如我们给 CPU 发出“1000100111011000”的二进制指令，这条指令的意思是将一个寄存器中的数据移动到另外一个寄存器中，当处理器执行到这条指令的时候，便会按照指令的意思去实现相关的操作。

为了能够完成复杂的任务，工程师们为 CPU 提供了一大堆指令，来实现各种功能，我们就把这一大堆指令称为指令集（Instructions），也就是机器语言。

注意，CPU 只能识别二进制的指令，但是对程序员来说，二进制代码难以阅读和记忆，于是我们又将二进制指令集转换为人类可以识别和记忆的符号，这就是汇编指令集，你可以参考下面的代码：

1000100111011000  机器指令
mov ax,bx         汇编指令

那么你可能会问，CPU 能直接识别汇编语言吗？

答案是“不能”，所以如果你使用汇编编写了一段程序，你还需要一个汇编编译器，其作用是将汇编代码编程成机器代码，具体流程你可以参考下图：

汇编编译器

虽然汇编语言对机器语言做了一层抽象，减少了程序员理解机器语言的复杂度，但是汇编语言依然是复杂且繁琐的，即便你写一个非常简单的功能，也需要实现大量的汇编代码，这主要表现在以下两点。

首先，不同的 CPU 有着不同的指令集，如果要使用机器语言或者汇编语言来实现一个功能，那么你需要为每种架构的 CPU 编写特定的汇编代码，这会带来巨大的、枯燥繁琐的操作，你可以参看下图：

需要为每种架构的CPU编写特定的汇编代码

其次，在编写汇编代码时，我们还需要了解和处理器架构相关的硬件知识，比如你需要使用寄存器、内存、操作 CPU 等。大部分程序员在编写应用的时候，只想专心处理业务逻辑，并不想要过多地理会这些处理器架构相关的细节。

因此我们需要一种屏蔽了计算机架构细节的语言，能适应多种不同 CPU 架构的语言，能专心处理业务逻辑的语言，诸如 C、C++、Java、C#、Python、JavaScript 等，这些“高级语言”就应运而生了。

和汇编语言一样，处理器也不能直接识别由高级语言所编写的代码，那怎么办呢？通常，要有两种方式来执行这些代码。

第一种是解释执行，需要先将输入的源代码通过解析器编译成中间代码，之后直接使用解释器解释执行中间代码，然后直接输出结果。具体流程如下图所示：

解释执行流程图

第二种是编译执行。采用这种方式时，也需要先将源代码转换为中间代码，然后我们的编译器再将中间代码编译成机器代码。通常编译成的机器代码是以二进制文件形式存储的，需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中，然后直接执行内存中的二进制代码。

编译执行流程图

以上就是计算机执行高级语言的两种基本的方式：解释执行和编译执行。但是针对不同的高级语言，这个实现方式还是有很大差异的，比如要执行 C 语言编写的代码，你需要将其编译为二进制代码的文件，然后再直接执行二进制代码。而对于像 Java 语言、JavaScript 语言等，则需要不同虚拟机，模拟计算机的这个编译执行流程。执行 Java 语言，需要经过 Java 虚拟机的转换，执行 JavaScript 需要经过 JavaScript 虚拟机的转换。

即便是 JavaScript 一门语言，也有好几种流行的虚拟机，它们之间的实现方式也存在着一部分差异，比如苹果公司在 Safari 中就是用 JavaScriptCore 虚拟机，Firefox 使用了 TraceMonkey 虚拟机，而 Chrome 则使用了 V8 虚拟机。

V8 是怎么执行 JavaScript 代码的？

那么，V8 作为 JavaScript 的虚拟机的一种，它到底是怎么执行 JavaScript 代码的呢？是解释执行，还是编译执行呢？

实际上，V8 并没有采用某种单一的技术，而是混合编译执行和解释执行这两种手段，我们把这种混合使用编译器和解释器的技术称为 JIT（Just In Time）技术。

这是一种权衡策略，因为这两种方法都各自有自的优缺点，解释执行的启动速度快，但是执行时的速度慢，而编译执行的启动速度慢，但是执行时的速度快。你可以参看下面完整的 V8 执行 JavaScript 的流程图：

V8执行一段JavaScript流程图

我们先看上图中的最左边的部分，在 V8 启动执行 JavaScript 之前，它还需要准备执行 JavaScript 时所需要的一些基础环境，这些基础环境包括了“堆空间”“栈空间”“全局执行上下文”“全局作用域”“消息循环系统”“内置函数”等，这些内容都是在执行 JavaScript 过程中需要使用到的，比如：

JavaScript 全局执行上下文就包含了执行过程中的全局信息，比如一些内置函数，全局变量等信息；

全局作用域包含了一些全局变量，在执行过程中的数据都需要存放在内存中；

而 V8 是采用了经典的堆和栈的管理内存管理模式，所以 V8 还需要初始化了内存中的堆和栈结构；

另外，要我们的 V8 系统活起来，还需要初始化消息循环系统，消息循环系统包含了消息驱动器和消息队列，它如同 V8 的心脏，不断接受消息并决策如何处理消息。

基础环境准备好之后，接下来就可以向 V8 提交要执行的 JavaScript 代码了。

首先 V8 会接收到要执行的 JavaScript 源代码，不过这对 V8 来说只是一堆字符串，V8 并不能直接理解这段字符串的含义，它需要结构化这段字符串。结构化，是指信息经过分析后可分解成多个互相关联的组成部分，各组成部分间有明确的层次结构，方便使用和维护，并有一定的操作规范。

V8 源代码的结构化之后，就生成了抽象语法树 (AST)，我们称为 AST，AST 是便于 V8 理解的结构。

这里还需要注意一点，在生成 AST 的同时，V8 还会生成相关的作用域，作用域中存放相关变量，我们会在《 06 | 作用域链：V8 是如何查找变量的？》和《12 | 延迟解析：V8 是如何实现闭包的？》这两个节课中详细分析。

有了 AST 和作用域之后，接下来就可以生成字节码了，字节码是介于 AST 和机器代码的中间代码。但是与特定类型的机器代码无关，解释器可以直接解释执行字节码，或者通过编译器将其编译为二进制的机器代码再执行。我们会在 《13｜字节码（一）：V8 为什么又重新引入字节码》这节课中详细介绍字节码的前世今生。

好了，生成了字节码之后，解释器就登场了，它会按照顺序解释执行字节码，并输出执行结果。

相信你注意到了，我们在解释器附近画了个监控机器人，这是一个监控解释器执行状态的模块，在解释执行字节码的过程中，如果发现了某一段代码会被重复多次执行，那么监控机器人就会将这段代码标记为热点代码。

当某段代码被标记为热点代码后，V8 就会将这段字节码丢给优化编译器，优化编译器会在后台将字节码编译为二进制代码，然后再对编译后的二进制代码执行优化操作，优化后的二进制机器代码的执行效率会得到大幅提升。如果下面再执行到这段代码时，那么 V8 会优先选择优化之后的二进制代码，这样代码的执行速度就会大幅提升。

不过，和静态语言不同的是，JavaScript 是一种非常灵活的动态语言，对象的结构和属性是可以在运行时任意修改的，而经过优化编译器优化过的代码只能针对某种固定的结构，一旦在执行过程中，对象的结构被动态修改了，那么优化之后的代码势必会变成无效的代码，这时候优化编译器就需要执行反优化操作，经过反优化的代码，下次执行时就会回退到解释器解释执行。

跟踪一段实际代码的执行流程

我们以一段最简单的 JavaScript 代码为例，如果将这段非常简单的代码提交给 V8 引擎，V8 在处理过程中，中间所产生的结果是怎样的呢？下面我们就一步一步详细“追踪”下。

代码如下所示：

var test = 'GeekTime'

我们知道，首先这段代码会被解析器结构化称 AST，下面我们就来看看第一阶段生成的 AST 是什么样子的？

要查看 V8 中间生成的一些结构，可以使用 V8 提供的调试工具 D8 来查看，你可以将上面那段代码保存到 test.js 的文件中，然后执行下面命令：

d8 --print-ast test.js

执行这段命令之后，D8 会打印出如下内容：

--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VARIABLE (0x7ff0e3022298) (mode = VAR, assigned = true) "test"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 11
. . . INIT at 11
. . . . VAR PROXY unallocated (0x7ff0e3022298) (mode = VAR, assigned = true) "test"
. . . . LITERAL "GeekTime"

上面这个结构就是 AST，它就是 JS 源代码的结构化表述，AST 是个树状结构，直观地理解，你可以将其转换为一个图形树，如下图所示：

将源码解析为AST

从图中可以看出，AST 和代码结构也是一一对应关系，并且后续所有的操作都会直接或者间接基于它。

上面我们还提到了，在生成 AST 的同时，还会生成作用域，同样我们使用 D8 来看看它生成的作用域是什么样子，你可以使用下面的命令来查看作用域：

d8 --print-scopes test.js

执行这段命令之后，D8 会打印出如下内容：

Global scope:
global { // (0x7fd974022048) (0, 24)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0x7fd9740223c8) local[0]
  // local vars:
  VAR test;  // (0x7fd974022298)
}

上面这行代码生成了一个全局作用域，我们可以看到 test 变量被添加进了这个全局作用域中。

生成了 AST 和作用域之后，就可以使用解释器生成字节码了，同样你可以使用 D8 来打印生成后的字节码，打印的命令如下所示：

d8 --print-bytecode test.js

执行这段语句，最终打印出来的结果如下所示：

[generated bytecode for function:  (0x2b510824fd55 <SharedFunctionInfo>)]
Parameter count 1
Register count 4
Frame size 32
         0x2b510824fdd2 @    0 : a7                StackCheck
         0x2b510824fdd3 @    1 : 12 00             LdaConstant [0]
         0x2b510824fdd5 @    3 : 26 fa             Star r1
         0x2b510824fdd7 @    5 : 0b                LdaZero
         0x2b510824fdd8 @    6 : 26 f9             Star r2
         0x2b510824fdda @    8 : 27 fe f8          Mov <closure>, r3
         0x2b510824fddd @   11 : 61 32 01 fa 03    CallRuntime [DeclareGlobals], r1-r3
         0x2b510824fde2 @   16 : 12 01             LdaConstant [1]
         0x2b510824fde4 @   18 : 15 02 02          StaGlobal [2], [2]
         0x2b510824fde7 @   21 : 0d                LdaUndefined
         0x2b510824fde8 @   22 : ab                Return
Constant pool (size = 3)
0x2b510824fd9d: [FixedArray] in OldSpace
 - map: 0x2b51080404b1 <Map>
 - length: 3
           0: 0x2b510824fd7d <FixedArray[4]>
           1: 0x2b510824fd1d <String[#8]: GeekTime>
           2: 0x2b51081c8549 <String[#4]: test>
Handler Table (size = 0)
Source Position Table (size = 0)

上面就是这段代码生成的中间字节码，关于字节码，我们会在后续课程《14 | 字节码（二）：解释器是如何解释执行字节码的？》来介绍，在这里我们现有一个大致的认识就可以了。

生成字节码之后，解释器会解释执行这段字节码，如果重复执行了某段代码，监控器就会将其标记为热点代码，并提交给编译器优化执行，如果你想要查看那些代码被优化了，可以使用下面的命令：

d8 --trace-opt test.js

如果要查看那些代码被反优化了，可以使用如下命令行来查看：

pt --trace-deopt test.js

由于我们这段代码过于简单，没有触发 V8 的优化机制，在这里我们也就不展开介绍优化机制了，具体的流程我会在后续课程《15 | 隐藏类：如何在内存中快速查找对象属性？》这一节展开详细介绍。

总结

V8 是由 Google 开发的开源 JavaScript 引擎，也被称为虚拟机，模拟实际计算机各种功能来实现代码的编译和执行。那么，要想搞清楚 V8 内部的工作流程和原理，我们可以从分析计算机对语言的编译和执行过程入手。

因为计算机只能识别二进制指令，所以要让计算机执行一段高级语言通常有两种手段，第一种是将高级代码转换为二进制代码，再让计算机去执行；另外一种方式是在计算机安装一个解释器，并由解释器来解释执行。

解释执行和编译执行都有各自的优缺点，解释执行启动速度快，但是执行时速度慢，而编译执行启动速度慢，但是执行速度快。为了充分地利用解释执行和编译执行的优点，规避其缺点，V8 采用了一种权衡策略，在启动过程中采用了解释执行的策略，但是如果某段代码的执行频率超过一个值，那么 V8 就会采用优化编译器将其编译成执行效率更加高效的机器代码。

理解了这一点，我们就可以来深入分析 V8 执行一段 JavaScript 代码所经历的主要流程了，这包括了：

初始化基础环境；

解析源码生成 AST 和作用域；

依据 AST 和作用域生成字节码；

解释执行字节码；

监听热点代码；

优化热点代码为二进制的机器代码；

反优化生成的二进制机器代码。

这里你需要注意的是，V8 是一门动态语言，在运行过程中，某些被优化的结构可能会被 JavaScript 动态修改了，这会导致之前被优化的代码失效，如果某块优化之后的代码失效了，那么编译器需要执行反优化操作。

课后思考

最后，给你留一道思考题：除了 V8 采用了 JIT 技术，还有那些虚拟机采用了 JIT 技术？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

02 | 函数即对象：一篇文章彻底搞懂JavaScript的函数特点

你好，我是李兵。这是我们专栏的第二讲，我们来看下“函数是一等公民”背后的含义。

如果你熟悉了一门其他流行语言，再来使用 JavaScript，那么 JavaScript 中的函数可能会给你造成一些误解，比如在 JavaScript 中，你可以将一个函数赋值给一个变量，还可以将函数作为一个参数传递给另外一个函数，甚至可以使得一个函数返回另外一个函数，这在一些主流语言中都很难实现。

JavaScript 中的函数非常灵活，其根本原因在于 JavaScript 中的函数就是一种特殊的对象，我们把 JavaScript 中的函数称为一等公民 (First Class Function)。

基于函数是一等公民的设计，使得 JavaScript 非常容易实现一些特性，比如闭包，还有函数式编程等，而其他语言要实现这些特性就显得比较困难，比如要在 C++ 中实现闭包需要实现大量复杂的代码，而且使用起来也异常复杂。

函数式编程和闭包在实际的项目中会经常遇到，如果不了解这些特性，那么在你使用第三方代码时就会非常吃力，同时自己也很难使用这些特性写出优雅的代码，因此我们很有必要了解这些特性的底层机制。

另外，在我们后续课程介绍 V8 工作机制时，会学习 V8 是怎么实现闭包的，还会学习 V8 是如何将 JavaScript 的动态特性静态化以加快代码的执行速度，这些内容都涉及到 JavaScript 中的函数底层特性。

今天，我们就来深入分析下，JavaScript 中的“函数”到底有怎样的特点。

什么是 JavaScript 中的对象？

既然在 JavaScript 中，函数就是一种特殊的对象，那我们首先要明白，什么是 JavaScript 中的“对象”？它和面向对象语言中的“对象”有什么区别？

和其他主流语言不一样的是，JavaScript 是一门基于对象 (Object-Based) 的语言，可以说 JavaScript 中大部分的内容都是由对象构成的，诸如函数、数组，也可以说 JavaScript 是建立在对象之上的语言。

基于对象的设计

而这些对象在运行时可以动态修改其内容，这造就了 JavaScript 的超级灵活特性。不过，因为 JavaScript 太灵活了，也加大了理解和使用这门语言的难度。

虽然 JavaScript 是基于对象设计的，但是它却不是一门面向对象的语言 (Object-Oriented Programming Language)，因为面向对象语言天生支持封装、继承、多态，但是 JavaScript 并没有直接提供多态的支持，因此要在 JavaScript 中使用多态并不是一件容易的事。

面向对象的语言

除了对多态支持的不好，JavaScript 实现继承的方式和面向对象的语言实现继承的方式同样存在很大的差异。

面向对象语言是由语言本身对继承做了充分的支持，并提供了大量的关键字，如 public、protected、friend、interface 等，众多的关键字使得面向对象语言的继承变得异常繁琐和复杂，而 JavaScript 中实现继承的方式却非常简单清爽，只是在对象中添加了一个称为原型的属性，把继承的对象通过原型链接起来，就实现了继承，我们把这种继承方式称为基于原型链继承。关于 V8 是如何支持原型的，我们会在《05 | 原型链：V8 是如何实现对象继承的？》这节课做具体介绍。

既然“JavaScript 中的对象”和“面向对象语言中的对象”存在巨大差异，那么在 JavaScript 中，我们所谈论的对象到底是指什么呢？

其实 JavaScript 中的对象非常简单，每个对象就是由一组组属性和值构成的集合，比如我使用下面代码创建了一个 person 对象：

var person=new Object();
person.firstname="John";
person.lastname="Doe";
person.age=50;
person.eyecolor="blue";

这个对象里面有四个属性，为了直观理解，你可以参看下图：

对象的构成

上图展示了对象 person 的结构，我们可以看到蓝色的属性在左边，黄色的值在右边，有多组属性和值组成，这就是 JavaScript 中的对象，虽然 JavaScript 对象用途非常广泛，使用的方式也非常之多，但是万变不离其宗，其核心本质都就是由一组组属性和值组成的集合，抓住了这一点，当我们再分析对象时，就会轻松很多。

在 JavaScript 中，之所以它的对象的用途这么广，是因为对象的值可以是任意类型的数据，我们可以改造下上面的那段代码，来看看对象的值都有那些类型？改造后的代码如下所示：

var person=new Object()
person.firstname="John"
person.lastname="Doe"
person.info = new Object()
person.info.age=50
person.info.eyecolor="blue"
person.showinfo = function (){
    console.log(/*...*/)
}

我们可以先画出这段代码的内存布局，如下图所示：

属性值类型

观察上图，我们可以看出来，对象的属性值有三种类型：

第一种是原始类型 (primitive)，所谓的原始类的数据，是指值本身无法被改变，比如 JavaScript 中的字符串就是原始类型，如果你修改了 JavaScript 中字符串的值，那么 V8 会返回给你一个新的字符串，原始字符串并没有被改变，我们称这些类型的值为“原始值”。

JavaScript 中的原始值主要包括 null、undefined、boolean、number、string、bigint、symbol 这七种。

第二种就是我们现在介绍的对象类型 (Object)，对象的属性值也可以是另外一个对象，比如上图中的 info 属性值就是一个对象。

第三种是函数类型 (Function)，如果对象中的属性值是函数，那么我们把这个属性称为方法，所以我们又说对象具备属性和方法，那么上图中的 showinfo 就是 person 对象的一个方法。

对象属性值的三种类型

函数的本质

分析完对象，现在我们就能更好地理解 JavaScript 中函数的概念了。

在这节课开始我就提到，在 JavaScript 中，函数是一种特殊的对象，它和对象一样可以拥有属性和值，但是函数和普通对象不同的是，函数可以被调用。

我们先来看一段 JavaScript 代码，在这段代码中，我们定义了一个函数 foo，接下来我们给 foo 函数设置了 myName 和 uName 的属性。

function foo(){
    var test = 1
}
foo.myName = 1
foo.uName = 2
console.log(foo.myName)

既然是函数，那么它也可以被调用。比如你定义了一个函数，便可以通过函数名称加小括号来实现函数的调用，代码如下所示：

function foo(){
    var test = 1
    console.log(test)
}
foo()

除了使用函数名称来实现函数的调用，还可以直接调用一个匿名函数，代码如下所示：

(function (){
    var test = 1
    console.log(test)
})()

那么，V8 内部是怎么实现函数可调用特性的呢？

其实在 V8 内部，我们会为函数对象添加了两个隐藏属性，具体属性如下图所示：

函数对象具有隐藏属性

也就是说，函数除了可以拥有常用类型的属性值之外，还拥有两个隐藏属性，分别是 name 属性和 code 属性。

隐藏 name 属性的值就是函数名称，如果某个函数没有设置函数名，如下面这段函数：

(function (){
    var test = 1
    console.log(test)
})()

该函数对象的默认的 name 属性值就是 anonymous，表示该函数对象没有被设置名称。另外一个隐藏属性是 code 属性，其值表示函数代码，以字符串的形式存储在内存中。当执行到一个函数调用语句时，V8 便会从函数对象中取出 code 属性值，也就是函数代码，然后再解释执行这段函数代码。

函数是一等公民

因为函数是一种特殊的对象，所以在 JavaScript 中，函数可以赋值给一个变量，也可以作为函数的参数，还可以作为函数的返回值。如果某个编程语言的函数可以和它的数据类型做一样的事情，我们就把这个语言中的函数称为一等公民。支持函数是一等公民的语言可以使得代码逻辑更加清晰，代码更加简洁。

但是由于函数的“可被调用”的特性，使得实现函数的可赋值、可传参和可作为返回值等特性变得有一点麻烦。为什么？

我们知道，在执行 JavaScript 函数的过程中，为了实现变量的查找，V8 会为其维护一个作用域链，如果函数中使用了某个变量，但是在函数内部又没有定义该变量，那么函数就会沿着作用域链去外部的作用域中查找该变量，具体流程如下图所示：

查找变量

从图中可以看出，当函数内部引用了外部的变量时，使用这个函数进行赋值、传参或作为返回值，你还需要保证这些被引用的外部变量是确定存在的，这就是让函数作为一等公民麻烦的地方，因为虚拟机还需要处理函数引用的外部变量。我们来看一段简单的代码：

function foo(){
    var number = 1
    function bar(){
        number++
        console.log(number)
    }
    return bar
}
var mybar = foo()
mybar()

观察上段代码可以看到，我们在 foo 函数中定义了一个新的 bar 函数，并且 bar 函数引用了 foo 函数中的变量 number，当调用 foo 函数的时候，它会返回 bar 函数。

那么所谓的“函数是一等公民”就体现在，如果要返回函数 bar 给外部，那么即便 foo 函数执行结束了，其内部定义的 number 变量也不能被销毁，因为 bar 函数依然引用了该变量。

我们也把这种将外部变量和和函数绑定起来的技术称为闭包。V8 在实现闭包的特性时也做了大量的额外的工作，关于闭包的详细实现，我们会在《12 | 延迟解析：V8 是如何实现闭包的？》这节课再介绍。

另外基于函数是一等公民，我们可以轻松使用 JavaScript 来实现目前比较流行的函数式编程，函数式编程规则很少，非常优美，不过这并不是本专栏的重点，所以我们先略开不讲。

总结

好了，今天的内容就介绍到这里，下面我来总结下本文的主要内容。

本文我们围绕 JavaScript 中的函数来展开介绍，JavaScript 中的函数非常灵活，既可以被调用，还可以作为变量、参数和返回值，这些特性使得函数的用法非常多，这也导致了函数变得有些复杂，因此本文的目的就是要讲清楚函数到底是什么？

因为函数是一种特殊的对象，所以我们先介绍了 JavaScript 中的对象，JavaScript 中的对象就是由一组一组属性和值组成的集合，既然函数也是对象，那么函数也是由一组组值和属性组成的集合，我们还在文中使用了一段代码证明了这点。

因为函数作为一个对象，是可以被赋值、作为参数，还可以作为返回值的，那么如果一个函数返回了另外一个函数，那么就应该返回该函数所有相关的内容。

接下来，我们又介绍了一个函数到底关联了哪些内容：

函数作为一个对象，它有自己的属性和值，所以函数关联了基础的属性和值；

函数之所以成为特殊的对象，这个特殊的地方是函数可以“被调用”，所以一个函数被调用时，它还需要关联相关的执行上下文。

结合以上两点，JavaScript 中的函数就实现了“函数是一等公民”的特性。

思考题

本文我们从对象聊到了闭包，那么留给你的问题是，哪些语言天生支持“函数是一等公民”？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

03｜快属性和慢属性：V8采用了哪些策略提升了对象属性的访问速度？

你好，我是李兵。

在前面的课程中，我们介绍了 JavaScript 中的对象是由一组组属性和值的集合，从 JavaScript 语言的角度来看，JavaScript 对象像一个字典，字符串作为键名，任意对象可以作为键值，可以通过键名读写键值。

然而在 V8 实现对象存储时，并没有完全采用字典的存储方式，这主要是出于性能的考量。因为字典是非线性的数据结构，查询效率会低于线性的数据结构，V8 为了提升存储和查找效率，采用了一套复杂的存储策略。

线性结构和非线性结构

今天这节课我们就来分析下 V8 采用了哪些策略提升了对象属性的访问速度。

常规属性 (properties) 和排序属性 (element)

在开始之前，我们先来了解什么是对象中的常规属性和排序属性，你可以先参考下面这样一段代码：

function Foo() {
    this[100] = 'test-100'
    this[1] = 'test-1'
    this["B"] = 'bar-B'
    this[50] = 'test-50'
    this[9] =  'test-9'
    this[8] = 'test-8'
    this[3] = 'test-3'
    this[5] = 'test-5'
    this["A"] = 'bar-A'
    this["C"] = 'bar-C'
}
var bar = new Foo()

for(key in bar){
    console.log(`index:${key}  value:${bar[key]}`)
}

在上面这段代码中，我们利用构造函数 Foo 创建了一个 bar 对象，在构造函数中，我们给 bar 对象设置了很多属性，包括了数字属性和字符串属性，然后我们枚举出来了 bar 对象中所有的属性，并将其一一打印出来，下面就是执行这段代码所打印出来的结果：

index:1  value:test-1
index:3  value:test-3
index:5  value:test-5
index:8  value:test-8
index:9  value:test-9
index:50  value:test-50
index:100  value:test-100
index:B  value:bar-B
index:A  value:bar-A
index:C  value:bar-C

观察这段打印出来的数据，我们发现打印出来的属性顺序并不是我们设置的顺序，我们设置属性的时候是乱序设置的，比如开始先设置 100，然后有设置了 1，但是输出的内容却非常规律，总的来说体现在以下两点：

设置的数字属性被最先打印出来了，并且按照数字大小的顺序打印的；

设置的字符串属性依然是按照之前的设置顺序打印的，比如我们是按照 B、A、C 的顺序设置的，打印出来依然是这个顺序。

之所以出现这样的结果，是因为在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列，字符串属性根据创建时的顺序升序排列。

在这里我们把对象中的数字属性称为排序属性，在 V8 中被称为 elements，字符串属性就被称为常规属性，在 V8 中被称为 properties。

在 V8 内部，为了有效地提升存储和访问这两种属性的性能，分别使用了两个线性数据结构来分别保存排序属性和常规属性，具体结构如下图所示：

V8内部的对象构造

通过上图我们可以发现，bar 对象包含了两个隐藏属性：elements 属性和 properties 属性，elements 属性指向了 elements 对象，在 elements 对象中，会按照顺序存放排序属性，properties 属性则指向了 properties 对象，在 properties 对象中，会按照创建时的顺序保存了常规属性。

分解成这两种线性数据结构之后，如果执行索引操作，那么 V8 会先从 elements 属性中按照顺序读取所有的元素，然后再在 properties 属性中读取所有的元素，这样就完成一次索引操作。

快属性和慢属性

将不同的属性分别保存到 elements 属性和 properties 属性中，无疑简化了程序的复杂度，但是在查找元素时，却多了一步操作，比如执行 bar.B这个语句来查找 B 的属性值，那么在 V8 会先查找出 properties 属性所指向的对象 properties，然后再在 properties 对象中查找 B 属性，这种方式在查找过程中增加了一步操作，因此会影响到元素的查找效率。

基于这个原因，V8 采取了一个权衡的策略以加快查找属性的效率，这个策略是将部分常规属性直接存储到对象本身，我们把这称为对象内属性 (in-object properties)。对象在内存中的展现形式你可以参看下图：

对象内属性

采用对象内属性之后，常规属性就被保存到 bar 对象本身了，这样当再次使用bar.B来查找 B 的属性值时，V8 就可以直接从 bar 对象本身去获取该值就可以了，这种方式减少查找属性值的步骤，增加了查找效率。

不过对象内属性的数量是固定的，默认是 10 个，如果添加的属性超出了对象分配的空间，则它们将被保存在常规属性存储中。虽然属性存储多了一层间接层，但可以自由地扩容。

通常，我们将保存在线性数据结构中的属性称之为“快属性”，因为线性数据结构中只需要通过索引即可以访问到属性，虽然访问线性结构的速度快，但是如果从线性结构中添加或者删除大量的属性时，则执行效率会非常低，这主要因为会产生大量时间和内存开销。

因此，如果一个对象的属性过多时，V8 为就会采取另外一种存储策略，那就是“慢属性”策略，但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的，而是直接保存在属性字典中。

慢属性是如何存储的

实践：在 Chrome 中查看对象布局

现在我们知道了 V8 是怎么存储对象的了，接下来我们来结合 Chrome 中的内存快照，来看看对象在内存中是如何布局的？

你可以打开 Chrome 开发者工具，先选择控制台标签，然后在控制台中执行以下代码查看内存快照：

function Foo(property_num,element_num) {
    //添加可索引属性
    for (let i = 0; i < element_num; i++) {
        this[i] = `element${i}`
    }
    //添加常规属性
    for (let i = 0; i < property_num; i++) {
        let ppt = `property${i}`
        this[ppt] = ppt
    }
}
var bar = new Foo(10,10)

上面我们创建了一个构造函数，可以利用该构造函数创建了新的对象，我给该构造函数设置了两个参数 property_num、element_num，分别代表创建常规属性的个数和排序属性的个数，我们先将这两种类型的个数都设置为 10 个，然后利用该构造函数创建了一个新的 bar 对象。

创建了函数对象，接下来我们就来看看构造函数和对象在内存中的状态。你可以将 Chrome 开发者工具切换到 Memory 标签，然后点击左侧的小圆圈就可以捕获当前的内存快照，最终截图如下所示：

V8内存快照截图

上图就是收集了当前内存快照的界面，要想查找我们刚才创建的对象，你可以在搜索框里面输入构造函数 Foo，Chrome 会列出所有经过构造函数 Foo 创建的对象，如下图所示：

从内存快照搜索构造函数

观察上图，我们搜索出来了所有经过构造函数 Foo 创建的对象，点开 Foo 的那个下拉列表，第一个就是刚才创建的 bar 对象，我们可以看到 bar 对象有一个 elements 属性，这里面就包含我们创造的所有的排序属性，那么怎么没有常规属性对象呢？

这是因为只创建了 10 个常规属性，所以 V8 将这些常规属性直接做成了 bar 对象的对象内属性。

所以这时候的数据内存布局是这样的：

10 个常规属性作为对象内属性，存放在 bar 函数内部；

10 个排序属性存放在 elements 中。

接下来我们可以将创建的对象属性的个数调整到 20 个，你可以在控制台执行下面这段代码：

var bar2 = new Foo(20,10)

然后我们再重新生成内存快照，再来看看生成的图片：

利用构造函数生成的对象

我们可以看到，构造函数 Foo 下面已经有了两个对象了，其中一个 bar，另外一个是 bar2，我们点开第一个 bar2 对象，内容如下所示：

查看对象属性

由于创建的常用属性超过了 10 个，所以另外 10 个常用属性就被保存到 properties 中了，注意因为 properties 中只有 10 个属性，所以依然是线性的数据结构，我们可以看其都是按照创建时的顺序来排列的。

所以这时候属性的内存布局是这样的：

10 属性直接存放在 bar2 的对象内 ;

10 个常规属性以线性数据结构的方式存放在 properties 属性里面 ;

10 个数字属性存放在 elements 属性里面。

如果常用属性太多了，比如创建了 100 个，那么我们再来看看其内存分布，你可以执行下面这段代码：

var bar3 = new Foo(100,10)

然后以同样的方式打开 bar3，查看其内存布局，最终如下图所示：

利用字典存放常规元素

结合上图，我们可以看到，这时候的 properties 属性里面的数据并不是线性存储的，而是以非线性的字典形式存储的，所以这时候属性的内存布局是这样的：

10 属性直接存放在 bar3 的对象内 ;

90 个常规属性以非线性字典的这种数据结构方式存放在 properties 属性里面 ;

10 个数字属性存放在 elements 属性里面。

其他属性

好了，现在我们知道 V8 是怎么存储对象的了，不过这里还有几个重要的隐藏属性我还没有介绍，下面我们就来简单地看下。你可以先看下图：

其他隐藏属性

观察上图，除了 elements 和 properties 属性，V8 还为每个对象实现了 map 属性和 __proto__ 属性。__proto__ 属性就是原型，是用来实现 JavaScript 继承的，我们会在下一节来介绍；而 map 则是隐藏类，我们会在《15 | 隐藏类：如何在内存中快速查找对象属性？》这一节中介绍其工作机制。

总结

好了，本节的内容就介绍到这里，下面我来总结下本文的主要内容：

本文我们的主要目标是介绍 V8 内部是如何存储对象的，因为 JavaScript 中的对象是由一组组属性和值组成的，所以最简单的方式是使用一个字典来保存属性和值，但是由于字典是非线性结构，所以如果使用字典，读取效率会大大降低。

为了提升查找效率，V8 在对象中添加了两个隐藏属性，排序属性和常规属性，指向了 elements 对象，在 elements 对象中，会按照顺序存放排序属性。properties 属性则指向了 properties 对象，在 properties 对象中，会按照创建时的顺序保存常规属性。

通过引入这两个属性，加速了 V8 查找属性的速度，为了更加进一步提升查找效率，V8 还实现了内置内属性的策略，当常规属性少于一定数量时，V8 就会将这些常规属性直接写进对象中，这样又节省了一个中间步骤。

但是如果对象中的属性过多时，或者存在反复添加或者删除属性的操作，那么 V8 就会将线性的存储模式降级为非线性的字典存储模式，这样虽然降低了查找速度，但是却提升了修改对象的属性的速度。

思考题

通常，我们不建议使用 delete 来删除属性，你能结合文中介绍的快属性和慢属性，给出不建议使用 delete 的原因吗？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

04 | 函数表达式：涉及大量概念，函数表达式到底该怎么学？

你好，我是李兵。

前面几节我们聊了 V8 中的对象和函数，并介绍了函数为什么会被称为是一等公民，了解这些之后，我们就可以来学习函数表达式了。

函数表达式在 JavaScript 中非常基础也非常重要，使用函数表达式可以用来实现代码隐藏，还可以实现变量隔离，所以函数表达式被广泛地应用在各个项目中，了解函数表达式的底层工作机制，可以帮助我们更加深刻地理解项目。

但是，学好函数表达式并不容易。因为它涉及到了很多底层概念，比如表达式、语句、函数即对象（在 JavaScript 中）等，而且函数表达式和函数声明看起来类似，都是定义一个函数，然后再调用该函数，很容易把二者搞混淆了。

你知道两者的区别吗？

实际上，函数表达式和函数声明有着本质上的差异。理解了这种差异，你对函数表达式的理解也就加深了。

函数声明与函数表达式的差异

那么它们具体有什么差异呢？我们先来看一段代码：

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

在这段代码中，我声明了一个 foo 函数，然后在 foo 函数之前调用了 foo 函数，执行这段代码，我们看到 foo 函数被正确执行了。（你可能会好奇，代码不是至上而下执行吗，为什么在函数声明之前就可以调用该函数了呢？这个问题我们先留一边，后文中会进行解答。）

再来看另外一段代码：

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

在这段代码中，我定义了一个变量 foo，然后将一个函数赋值给了变量 foo，同样在源码中，我们也是在 foo 函数的前面调用 foo，执行这段代码，我们发现报错了，提示的错误信息如下所示：

VM130:1 Uncaught TypeError: foo is not a function
    at <anonymous>:1:1

这是告诉我们，变量 foo 并不是一个函数，所以无法被调用。

同样是在定义的函数之前调用函数，第一段代码就可以正确执行，而第二段代码却报错，这是为什么呢？

其主要原因是这两种定义函数的方式具有不同语义，不同的语义触发了不同的行为。

不同的语义，触发不同的行为

因为语义不同，所以我们给这两种定义函数的方式使用了不同的名称，第一种称之为函数声明，第二种称之为函数表达式。

下面我们就来分别分析下，函数声明和函数表达式的语义，以及 V8 是怎么处理函数声明和函数表达式的。

V8 是怎么处理函数声明的？

我们先来看函数声明，函数声明定义了一个具有指定参数的函数，其声明语法如下所示：

function name([param,[, param,[..., param]]]) {
   [statements]
}

接下来我们来看看 V8 是怎么处理函数声明的。

我们知道，V8 在执行 JavaScript 的过程中，会先对其进行编译，然后再执行，比如下面这段代码：

var x = 5
function foo(){
    console.log('Foo')
}

V8 执行这段代码的流程大致如下图所示：

在编译阶段，如果解析到函数声明，那么 V8 会将这个函数声明转换为内存中的函数对象，并将其放到作用域中。同样，如果解析到了某个变量声明，也会将其放到作用域中，但是会将其值设置为 undefined，表示该变量还未被使用。

然后在 V8 执行阶段，如果使用了某个变量，或者调用了某个函数，那么 V8 便会去作用域查找相关内容。

关于作用域的数据，你也可以使用 D8 来查看，具体操作方式如下：

将这段代码保存到 test.js 中；

使用“d8 --print-scopes test.js”命令即可查看作用域的状态。

执行这段指令之后，打印出如下信息：

Global scope:
global { // (0x7fb62281ca48) (0, 50)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0x7fb62281cfe8) local[0]
  // local vars:
  VAR x;  // (0x7fb62281cc98)
  VAR foo;  // (0x7fb62281cf40)

  function foo () { // (0x7fb62281cd50) (22, 50)
    // lazily parsed
    // 2 heap slots
  }
}

上面这段就是 V8 生成的作用域，我们可以看到，作用域中包含了变量 x 和 foo，变量 x 的默认值是 undefined，变量 foo 指向了 foo 函数对象，foo 函数对象被 V8 存放在内存中的堆空间了，这些变量都是在编译阶段被装进作用域中的。

因为在执行之前，这些变量都被提升到作用域中了，所以在执行阶段，V8 当然就能获取到所有的定义变量了。我们把这种在编译阶段，将所有的变量提升到作用域的过程称为变量提升。

了解了变量提升，我们就能解释，为什么可以在函数声明之前调用该函数了，这是因为声明的函数在编译阶段就被提升到作用域中，在执行阶段，只要是在作用域中存在的变量或者对象，都是可以使用的。

对于变量提升，函数和普通的对象还是存在一些差异的，通过上面的分析我们知道，如果是一个普通变量，变量提升之后的值都是 undefined，如果是声明的函数，那么变量提升之后的值则是函数对象，我们可以通过下面的代码来实践下：

console.log(x)
console.log(foo)
var x = 5
function foo(){
}

执行上面这段代码，我们可以看到，普通变量 x 的值就是 undefined，而函数对象 foo 的值则是完整的对象，那这又是为什么呢？这就是涉及到表达式和语句的区别了。

简单地理解，表达式就是表示值的式子，而语句是操作值的式子。

比如：

x = 5

就是表达式，因为执行这段代码，它会返回一个值。同样，6 === 5 也是一个表达式，因为它会返回 False。

而语句则不同了，比如你定义了一个变量：

var x

这就是一个语句，执行该语句时，V8 并不会返回任何值给你。

同样，当我声明了一个函数时，这个函数声明也是一个语句，比如下面这段函数声明：

function foo(){
  return 1
}

当执行到这段代码时，V8 并没有返回任何的值，它只是解析 foo 函数，并将函数对象存储到内存中。

表达式和语句

了解了表达式和语句的区别，接下来我们继续分析上面的问题。我们知道，在 V8 执行var x = 5这段代码时，会认为它是两段代码，一段是定义变量的语句，一段是赋值的表达式，如下所示：

var x = undefined
x = 5

首先，在变量提升阶段，V8 并不会执行赋值的表达式，该阶段只会分析基础的语句，比如变量的定义，函数的声明。

而这两行代码是在不同的阶段完成的，var x 是在编译阶段完成的，也可以说是在变量提升阶段完成的，而x = 5是表达式，所有的表达式都是在执行阶段完成的。

在变量提升阶段，V8 将这些变量存放在作用域时，还会给它们赋一个默认的 undefined 值，所以在定义一个普通的变量之前，使用该变量，那么该变量的值就是 undefined。

现在我们知道，表达式是不会在编译阶段执行的，那么函数声明是表达式还是语句呢？你可以看下面这段函数声明：

function foo(){
    console.log('Foo')
}

执行上面这段代码，它并没有输出任何内容，所以可以肯定，函数声明并不是一个表达式，而是一个语句。V8 在变量提升阶段，如果遇到函数声明，那么 V8 同样会对该函数声明执行变量提升操作。

函数也是一个对象，所以在编译阶段，V8 就会将整个函数对象提升到作用域中，并不是给该函数名称赋一个 undefined，理解这一点尤为重要。

总的来说，在 V8 解析 JavaScript 源码的过程中，如果遇到普通的变量声明，那么便会将其提升到作用域中，并给该变量赋值为 undefined，如果遇到的是函数声明，那么 V8 会在内存中为声明生成函数对象，并将该对象提升到作用域中。

V8 是怎么处理函数表达式？

了解了函数声明，在来看看函数表达式。我们在一个表达式中使用 function 来定一个函数，那么就把该函数称为函数表达式。

比如：

 foo = 1

是一个表达式，这时候我们把右边的数字 1 替换成函数定义，那么这就变成了函数表达式，如下所示：

foo = function (){
    console.log('foo')
}

函数表达式与函数声明的最主要区别有以下三点：

函数表达式是在表达式语句中使用 function 的，最典型的表达式是“a=b”这种形式，因为函数也是一个对象，我们把“a = function (){}”这种方式称为函数表达式；

在函数表达式中，可以省略函数名称，从而创建匿名函数（anonymous functions）；

一个函数表达式可以被用作一个即时调用的函数表达式——IIFE（Immediately Invoked Function Expression）。

了解了函数表达式，我们就来分析这段代码：

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

当执行这段代码的时候，V8 在编译阶段会先查找声明语句，你可以把这段代码拆分为下面两行代码：

var foo = undefined
foo = function (){
    console.log('foo')
}

第一行是声明语句，所以 V8 在解析阶段，就会在作用域中创建该对象，并将该对象设置为 undefined，第二行是函数表达式，在编译阶段，V8 并不会处理函数表达式，所以也就不会将该函数表达式提升到作用域中了。

那么在函数表达式之前调用该函数 foo，此时的 foo 只是指向了 undefined，所以就相当于调用一个 undefined，而 undefined 只是一个原生对象，并不是函数，所以当然会报错了。

立即调用的函数表达式（IIFE）

现在我们知道了，在编译阶段，V8 并不会处理函数表达式，而 JavaScript 中的立即函数调用表达式正是使用了这个特性来实现了非常广泛的应用，下面我们就来一起看看立即函数调用表达式。

JavaScript 中有一个圆括号运算符，圆括号里面可以放一个表达式，比如下面的代码：

(a=3)

括号里面是一个表达式，整个语句也是一个表达式，最终输出 3。

如果在小括号里面放上一段函数的定义，如下所示：

(function () {
    //statements
})

因为小括号之间存放的必须是表达式，所以如果在小阔号里面定义一个函数，那么 V8 就会把这个函数看成是函数表达式，执行时它会返回一个函数对象。

存放在括号里面的函数便是一个函数表达式，它会返回一个函数对象，如果我直接在表达式后面加上调用的括号，这就称立即调用函数表达式（IIFE），比如下面代码：

(function () {
    //statements
})()

因为函数立即表达式也是一个表达式，所以 V8 在编译阶段，并不会为该表达式创建函数对象。这样的一个好处就是不会污染环境，函数和函数内部的变量都不会被其他部分的代码访问到。

在 ES6 之前，JavaScript 中没有私有作用域的概念，如果在多人开发的项目中，你模块中的变量可能覆盖掉别人的变量，所以使用函数立即表达式就可以将我们内部变量封装起来，避免了相互之间的变量污染。

另外，因为函数立即表达式是立即执行的，所以将一个函数立即表达式赋给一个变量时，不是存储 IIFE 本身，而是存储 IIFE 执行后返回的结果。如下所示：

var a = (function () {
    return 1
})()

总结

今天我们是要学习 V8 是如何处理函数表达式的。函数表达式在实际的项目应用中非常广，不过由于函数声明和函数表达式之间非常类似，非常容易引起人们的误解，所以我们先从通过两段容易让人误解的代码，分析了函数声明和函数表达式之间的区别。函数声明的本质是语句，而函数表达式的本质则是表达式。

函数声明和变量声明类似，V8 在编译阶段，都会对其执行变量提升的操作，将它们提升到作用域中，在执行阶段，如果使用了某个变量，就可以直接去作用域中去查找。

不过 V8 对于提升函数和提升变量的策略是不同的，如果提升了一个变量，那么 V8 在将变量提升到作用域中时，还会为其设置默认值 undefined，如果是函数声明，那么 V8 会在内存中创建该函数对象，并提升整个函数对象。

函数表达式也是表达式的一种，在编译阶段，V8 并不会将表达式中的函数对象提升到全局作用域中，所以无法在函数表达式之前使用该函数。函数立即表达式是一种特别的表达式，主要用来封装一些变量、函数，可以起到变量隔离和代码隐藏的作用，因此在一些大的开源项目中有广泛的应用。

思考题

留给你一道经典面试题，看看下面这两段代码打印出来的结果是什么？欢迎你在留言区与我分享讨论。

var n = 1;
(function foo(){
    n = 100;
    console.log(n);
}())
console.log(n);

var n = 1;
function foo(){
    n = 100;
    console.log(n);
}
console.log(n);
foo()

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

05｜原型链: V8是如何实现对象继承的？

你好，我是李兵。

在前面两节中，我们分析了什么是 JavaScript 中的对象，以及 V8 内部是怎么存储对象的，本节我们继续深入学习对象，一起来聊聊 V8 是如何实现 JavaScript 中对象继承的。

简单地理解，继承就是一个对象可以访问另外一个对象中的属性和方法，比如我有一个 B 对象，该对象继承了 A 对象，那么 B 对象便可以直接访问 A 对象中的属性和方法，你可以参看下图：

什么是继承

观察上图，因为 B 继承了 A，那么 B 可以直接使用 A 中的 color 属性，就像这个属性是 B 自带的一样。

不同的语言实现继承的方式是不同的，其中最典型的两种方式是基于类的设计和基于原型继承的设计。

C++、Java、C# 这些语言都是基于经典的类继承的设计模式，这种模式最大的特点就是提供了非常复杂的规则，并提供了非常多的关键字，诸如 class、friend、protected、private、interface 等，通过组合使用这些关键字，就可以实现继承。

使用基于类的继承时，如果业务复杂，那么你需要创建大量的对象，然后需要维护非常复杂的继承关系，这会导致代码过度复杂和臃肿，另外引入了这么多关键字也给设计带来了更大的复杂度。

而 JavaScript 的继承方式和其他面向对象的继承方式有着很大差别，JavaScript 本身不提供一个 class 实现。虽然标准委员会在 ES2015/ES6 中引入了 class 关键字，但那只是语法糖，JavaScript 的继承依然和基于类的继承没有一点关系。所以当你看到 JavaScript 出现了 class 关键字时，不要以为 JavaScript 也是面向对象语言了。

JavaScript 仅仅在对象中引入了一个原型的属性，就实现了语言的继承机制，基于原型的继承省去了很多基于类继承时的繁文缛节，简洁而优美。

原型继承是如何实现的？

那么，基于原型继承是如何实现的呢？我们参看下图：

有一个对象 C，它包含了一个属性“type”，那么对象 C 是可以直接访问它自己的属性 type 的，这点毫无疑问。

怎样让 C 对象像访问自己的属性一样，访问 B 对象呢？

上节我们从 V8 的内存快照看到，JavaScript 的每个对象都包含了一个隐藏属性 __proto__ ，我们就把该隐藏属性 __proto__ 称之为该对象的原型 (prototype)，__proto__ 指向了内存中的另外一个对象，我们就把 __proto__ 指向的对象称为该对象的原型对象，那么该对象就可以直接访问其原型对象的方法或者属性。

比如我让 C 对象的原型指向 B 对象，那么便可以利用 C 对象来直接访问 B 对象中的属性或者方法了，最终的效果如下图所示：

观察上图，当 C 对象将它的 __proto__ 属性指向了 B 对象后，那么通过对象 C 来访问对象 B 中的 name 属性时，V8 会先从对象 C 中查找，但是并没有查找到，接下来 V8 继续在其原型对象 B 中查找，因为对象 B 中包含了 name 属性，那么 V8 就直接返回对象 B 中的 name 属性值，虽然 C 和 B 是两个不同的对象，但是使用的时候，B 的属性看上就像是 C 的属性一样。

同样的方式，B 也是一个对象，它也有自己的 __proto__ 属性，比如它的属性指向了内存中另外一块对象 A，如下图所示：

从图中可以看到，对象 A 有个属性是 color，那么通过 C.color 访问 color 属性时，V8 会先在 C 对象内部查找，但是没有查找到，接着继续在 C 对象的原型对象 B 中查找，但是依然没有查找到，那么继续去对象 B 的原型对象 A 中查找，因为 color 在对象 A 中，那么 V8 就返回该属性值。

我们看到使用 C.name 和 C.color 时，给人的感觉属性 name 和 color 都是对象 C 本身的属性，但是实际上这些属性都是位于原型对象上，我们把这个查找属性的路径称为原型链，它像一个链条一样，将几个原型链接了起来。

在这里还要注意一点，不要将原型链接和作用域链搞混淆了，作用域链是沿着函数的作用域一级一级来查找变量的，而原型链是沿着对象的原型一级一级来查找属性的，虽然它们的实现方式是类似的，但是它们的用途是不同的，关于作用域链，我会在《06 | 作用域链：V8 是如何查找变量的？》这节课来介绍。

关于继承，还有一种情况，如果我有另外一个对象 D，它可以和 C 共同拥有同一个原型对象 B，如下图所示：

因为对象 C 和对象 D 的原型都指向了对象 B，所以它们共同拥有同一个原型对象，当我通过 D 去访问 name 属性或者 color 属性时，返回的值和使用对象 C 访问 name 属性和 color 属性是一样的，因为它们是同一个数据。

我们再来回顾下继承的概念：继承就是一个对象可以访问另外一个对象中的属性和方法，在JavaScript 中，我们通过原型和原型链的方式来实现了继承特性。

通过上面的分析，你可以看到在 JavaScript 中的继承非常简洁，就是每个对象都有一个原型属性，该属性指向了原型对象，查找属性的时候，JavaScript 虚拟机会沿着原型一层一层向上查找，直至找到正确的属性。所以对于 JavaScript 中的原型继承，你不需要把它想得过度复杂。

实践：利用 __proto__ 实现继承

了解了 JavaScript 中的原型和原型链继承之后，下面我们就可以通过一个例子，看看原型是怎么应用在 JavaScript 中的，你可以先看下面这段代码：

var animal = {
    type: "Default",
    color: "Default",
    getInfo: function () {
        return `Type is: ${this.type}，color is ${this.color}.`
    }
}
var dog = {
    type: "Dog",
    color: "Black",
}

在这段代码中，我创建了两个对象 animal 和 dog，我想让 dog 对象继承于 animal 对象，那么最直接的方式就是将 dog 的原型指向对象 animal，应该怎么操作呢？

我们可以通过设置 dog 对象中的 __proto__ 属性，将其指向 animal，代码是这样的：

dog.__proto__ = animal

设置之后，我们就可以使用 dog 来调用 animal 中的 getInfo 方法了。

dog.getInfo()

你可以尝试调用下，看看输出的内容。在这里留给你一个关于“this”的小思考题：调用 dog.getInfo() 时，getInfo 函数中的 this.type 和 this.color 都是什么值？为什么？

还有一点我们要注意，通常隐藏属性是不能使用 JavaScript 来直接与之交互的。虽然现代浏览器都开了一个口子，让 JavaScript 可以访问隐藏属性 _proto_，但是在实际项目中，我们不应该直接通过 _proto_ 来访问或者修改该属性，其主要原因有两个：

首先，这是隐藏属性，并不是标准定义的 ;

其次，使用该属性会造成严重的性能问题。

我们之所以在课程中使用 _proto_ 属性，主要是为了方便教学，将其他的一些复杂的概念先抛到一边，这样有利于你循序渐进地掌握我们的课程内容，但是我并不推荐你这么做。那应该怎么去正确地设置对象的原型对象呢？

答案是使用构造函数来创建对象，下面我们就来详细解释这个过程。

构造函数是怎么创建对象的？

比如我们要创建一个 dog 对象，我可以先创建一个 DogFactory 的函数，属性通过参数就行传递，在函数体内，通过 this 设置属性值。代码如下所示：

function DogFactory(type,color){
    this.type = type
    this.color = color
}

然后再结合关键字“new”就可以创建对象了，创建对象的代码如下所示：

var dog = new DogFactory('Dog','Black')

通过这种方式，我们就把后面的函数称为构造函数，因为通过执行 new 配合一个函数，JavaScript 虚拟机便会返回一个对象。如果你没有详细研究过这个问题，很可能对这种操作感到迷惑，为什么通过 new 关键字配合一个函数，就会返回一个对象呢？

关于 JavaScript 为什么要采用这种怪异的写法，我们文章最后再来介绍，先来看看这段代码的深层含义。

其实当 V8 执行上面这段代码时，V8 会在背后悄悄地做了以下几件事情，模拟代码如下所示：

var dog = {}
dog.__proto__ = DogFactory.prototype
DogFactory.call(dog,'Dog','Black')

为了加深你的理解，我画了上面这段代码的执行流程图：

观察上图，我们可以看到执行流程分为三步：

首先，创建了一个空白对象 dog；

然后，将 DogFactory 的 prototype 属性设置为 dog 的原型对象，这就是给 dog 对象设置原型对象的关键一步，我们后面来介绍；

最后，再使用 dog 来调用 DogFactory，这时候 DogFactory 函数中的 this 就指向了对象 dog，然后在 DogFactory 函数中，利用 this 对对象 dog 执行属性填充操作，最终就创建了对象 dog。

构造函数怎么实现继承？

好了，现在我们可以通过构造函数来创建对象了，接下来我们就看看构造函数是如何实现继承的？你可以先看下面这段代码：

function DogFactory(type,color){
    this.type = type
    this.color = color
    //Mammalia
    //恒温
    this.constant_temperature = 1
}
var dog1 = new DogFactory('Dog','Black')
var dog2 = new DogFactory('Dog','Black')
var dog3 = new DogFactory('Dog','Black')

我利用上面这段代码创建了三个 dog 对象，每个对象都占用了一块空间，占用空间示意图如下所示：

从图中可以看出来，对象 dog1 到 dog3 中的 constant_temperature 属性都占用了一块空间，但是这是一个通用的属性，表示所有的 dog 对象都是恒温动物，所以没有必要在每个对象中都为该属性分配一块空间，我们可以将该属性设置公用的。

怎么设置呢？

还记得我们介绍函数时提到关于函数有两个隐藏属性吗？这两个隐藏属性就是 name 和 code，其实函数还有另外一个隐藏属性，那就是 prototype，刚才介绍构造函数时我们也提到过。一个函数有以下几个隐藏属性：

每个函数对象中都有一个公开的 prototype 属性，当你将这个函数作为构造函数来创建一个新的对象时，新创建对象的原型对象就指向了该函数的 prototype 属性。当然了，如果你只是正常调用该函数，那么 prototype 属性将不起作用。

现在我们知道了新对象的原型对象指向了构造函数的 prototype 属性，当你通过一个构造函数创建多个对象的时候，这几个对象的原型都指向了该函数的 prototype 属性，如下图所示：

这时候我们可以将 constant_temperature 属性添加到 DogFactory 的 prototype 属性上，代码如下所示：

function DogFactory(type,color){
    this.type = type
    this.color = color
    //Mammalia
}
DogFactory. prototype.constant_temperature = 1
var dog1 = new DogFactory('Dog','Black')
var dog2 = new DogFactory('Dog','Black')
var dog3 = new DogFactory('Dog','Black')

这样我们三个 dog 对象的原型对象都指向了 prototype，而 prototype 又包含了 constant_temperature 属性，这就是我们实现继承的正确方式。

一段关于 new 的历史

现在我们知道 new 关键字结合构造函数，就能生成一个对象，不过这种方式很怪异，为什么要这样呢？要了解这背后的原因，我们需要了解一段关于关于 JavaScript 的历史。

JavaScript 是 Brendan Eich 发明的，那是个“战乱”的时代，各种大公司相互争霸，有 Sun、微软、网景、甲骨文等公司，它们都有推出自己的语言，其中最如日中天的编程语言是 Sun 的 Java，而 JavaScript 就是这个时候诞生的。当时创造 JavaScript 的目的仅仅是为了让浏览器页面可以动起来，所以尽可能采用简化的方式来设计 JavaScript，所以本质上来说，Java 和 JavaScript 的关系就像雷锋和雷峰塔的关系。

那么之所以叫 JavaScript 是出于市场原因考量的，因为一门新的语言需要吸引新的开发者，而当时最大的开发者群体就是 Java，于是 JavaScript 就蹭了 Java 的热度，事后，这一招被证明的确有效果。

虽然叫 JavaScript，但是其编程方式和 Java 比起来，依然存在着非常大的差异，其中 Java 中使用最频繁的代码就是创建一个对象，如下所示：

CreateInstance instance = new CreateInstance();

当时 JavaScript 并没有使用这种方式来创建对象，因为 JavaScript 中的对象和 Java 中的对象是完全不一样的，因此，完全没有必要使用关键字 new 来创建一个新对象的，但是为了进一步吸引 Java 程序员，依然需要在语法层面去蹭 Java 热点，所以 JavaScript 中就被硬生生地强制加入了非常不协调的关键字 new，然后使用 new 来创造对象就变成这样了：

var bar = new Foo()

Java 程序员看到这段代码时，当然会感到倍感亲切，觉得 Java 和 JavaScript 非常相似，那么使用 JavaScript 也就天经地义了。不过代码形式只是表象，其背后原理是完全不同的。

了解了这段历史之后，我们知道 JavaScript 的 new 关键字设计并不合理，但是站在市场角度来说，它的出现又是非常成功的，成功地推广了 JavaScript。

总结

好了，今天的主要内容就介绍到这里，下面我们来回顾下。

今天我们的主要目的是介绍清楚 JavaScript 中的继承机制，这涉及到了原型继承机制，虽然基于原型的继承机制本身比较简单，但是在 JavaScript 中，这是通过关键字 new 加上构造函数来体现的。这种方式非常绕，且不符合人的直觉，如果直接上来就介绍 new 加构造函数是怎么工作的，可能会把你给绕晕了。

于是我先通过每个对象中都有的隐含属性 __proto__，来介绍了什么是原型和原型链。V8 为每个对象都设置了一个 __proto__ 属性，该属性直接指向了该对象的原型对象，原型对象也有自己的 __proto__ 属性，这些属性串连在一起就成了原型链。

不过在 JavaScript 中，并不建议直接使用 __proto__ 属性，主要有两个原因。

一，这是隐藏属性，并不是标准定义的；

二，使用该属性会造成严重的性能问题。

所以，而是在 JavaScript 中，是使用 new 加上构造函数的这种组合来创建对象和实现对象的继承。不过使用这种方式隐含的语义过于隐晦，所以理解起来有点难度。

为什么 JavaScript 中要使用这种怪异的方式来创建对象？为了理解这个问题，我们回顾了一段 JavaScript 的历史。由于当前的 Java 非常流行，基于市场推广的考虑，JavaScript 采取了蹭 Java 热度的策略，在语言命名上使用了 Java 字样，在语法形式上也模仿了 Java。事实上通过这些策略，确实为 JavaScript 带来了市场上的成功。不过你依然要记住，JavaScript 和 Java 是完全两种不同的语言。

思考题

我们知道函数也是一个对象，所以函数也有自己的 __proto__ 属性，那么今天留给你的思考题是：DogFactory 是一个函数，那么“DogFactory.prototype”和“DogFactory._proto_”这两个属性之间有关联吗？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

06｜作用域链：V8是如何查找变量的？

你好，我是李兵。

在前面我们介绍了 JavaScript 的继承是基于原型链的，原型链将一个个原型对象串起来，从而实现对象属性的查找，今天我们要聊一个和原型链类似的话题，那就是作用域链。

作用域链就是将一个个作用域串起来，实现变量查找的路径。讨论作用域链，实际就是在讨论按照什么路径查找变量的问题。

我们知道，作用域就是存放变量和函数的地方，全局环境有全局作用域，全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域，函数作用域中存放了函数中定义的变量。

当在函数内部使用一个变量的时候，V8 便会去作用域中去查找。我们通过一段在函数内部查找变量的代码来具体看一下：

var name = '极客时间'
var type = 'global'

function foo(){
    var name = 'foo'
    console.log(name)
    console.log(type)
}

function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}
bar()

在这段代码中，我们在全局环境中声明了变量 name 和 type，同时还定义了 bar 函数和 foo 函数，在 bar 函数中又再次定义了变量 name 和 type，在 foo 函数中再次定义了变量 name。

函数的调用关系是：在全局环境中调用 bar 函数，在 bar 函数中调用 foo 函数，在 foo 函数中打印出来变量 name 和 type 的值。

当执行到 foo 函数时，首先需要打印出变量 name 的值，而我们在三个地方都定义了变量 name，那么究竟应该使用哪个变量呢？

在 foo 函数中使用了变量 name，那么 V8 就应该先使用 foo 函数内部定义的变量 name，最终的结果确实如此，也符合我们的直觉。

接下来，foo 函数继续打印变量 type，但是在 foo 函数内部并没有定义变量 type，而是在全局环境中和调用 foo 函数的 bar 函数中分别定义了变量 type，那么这时候的问题来了，你觉得 foo 函数中打印出来的变量 type 是 bar 函数中的，还是全局环境中的呢？

什么是函数作用域和全局作用域？

要解释清楚这个问题，我们需要从作用域的工作原理讲起。

每个函数在执行时都需要查找自己的作用域，我们称为函数作用域，在执行阶段，在执行一个函数时，当该函数需要使用某个变量或者调用了某个函数时，便会优先在该函数作用域中查找相关内容。

我们再来看一段代码：

var x = 4
var test
function test_scope() {
    var name = 'foo'
    console.log(name)
    console.log(type)
    console.log(test)
    var type = 'function'
    test = 1
    console.log(x)
}
test_scope()

在上面的代码中，我们定义了一个 test_scope 函数，那么在 V8 执行 test_scope 函数的时候，在编译阶段会为 test_scope 函数创建一个作用域，在 test_scope 函数中定义的变量和声明的函数都会丢到该作用域中，因为我们在 test_scope 函数中定了三个变量，那么常见的作用域就包含有这三个变量。

你可以通过 Chrome 的控制台来直观感受下 test_scope 函数的作用域，先打开包含这段代码的页面，然后打开开发者工具，接着在 test_scope 函数中的第二段代码加上断点，然后刷新该页面。当执行到该断点时，V8 会暂停整个执行流程，这时候我们就可以通过右边的区域面板来查看当前函数的执行状态。

观察作用域

你可以参看图中右侧的 Scope 项，然后点击展开该项，这个 Local 就是当前函数 test_scope 的作用域。在 test_scope 函数中定义的变量都包含到了 Local 中，如变量 name、type、test，另外系统还为我们添加了另外一个隐藏变量 this，V8 还会默认将隐藏变量 this 存放到作用域中。

另外你还需要注意下，第一个 test1，我并没有采用 var 等关键字来声明，所以 test1 并不会出现在 test_scope 函数的作用域中，而是属于 this 所指向的对象。（this 的工作机制不是本文讨论的重点，不展开介绍。如果你感兴趣，可以在《浏览器工作原理与实践》专栏中《11 | this：从 JavaScript 执行上下文的视角讲清楚 this》这一讲查看。）

那么另一个问题来了，我在 test_scope 函数使用了变量 x，但是在 test_scope 函数的作用域中，并没有定义变量 x，那么 V8 应该如何获取变量 x？

如果在当前函数作用域中没有查找到变量，那么 V8 会去全局作用域中去查找，这个查找的线路就称为作用域链。

全局作用域和函数作用域类似，也是存放变量和函数的地方，但是它们还是有点不一样： 全局作用域是在 V8 启动过程中就创建了，且一直保存在内存中不会被销毁的，直至 V8 退出。 而函数作用域是在执行该函数时创建的，当函数执行结束之后，函数作用域就随之被销毁掉了。

全局作用域中包含了很多全局变量，比如全局的 this 值，如果是浏览器，全局作用域中还有 window、document、opener 等非常多的方法和对象，如果是 node 环境，那么会有 Global、File 等内容。

V8 启动之后就进入正常的消息循环状态，这时候就可以执行代码了，比如执行到上面那段脚本时，V8 会先解析顶层 (Top Level) 代码，我们可以看到，在顶层代码中定义了变量 x，这时候 V8 就会将变量 x 添加到全局作用域中。

作用域链是怎么工作的？

理解了作用域和作用域链，我们再回过头来看文章开头的那道思考题： “foo 函数中打印出来的变量 type 是 bar 函数中的呢，还是全局环境中的呢?”我把这段代码复制到下面：

var name = '极客时间'
var type = 'global'

function foo(){
    var name = 'foo'
    console.log(name)
    console.log(type)
}

function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}
bar()

现在，我们结合 V8 执行这段代码的流程来具体分析下。首先当 V8 启动时，会创建全局作用域，全局作用域中包括了 this、window 等变量，还有一些全局的 Web API 接口，创建的作用域如下图所示：

全局作用域

V8 启动之后，消息循环系统便开始工作了，这时候，我输入了这段代码，让其执行。

V8 会先编译顶层代码，在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中，最终的全局作用域如下图所示：

全局作用域

全局作用域创建完成之后，V8 便进入了执行状态。前面我们介绍了变量提升，因为变量提升的原因，你可以把上面这段代码分解为如下两个部分：

//======解析阶段--实现变量提升=======
var name = undefined
var type = undefined
function foo(){
    var name = 'foo'
    console.log(name)
    console.log(type)
}
function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}

//====执行阶段========
name = '极客时间'
type = 'global'
bar()

第一部分是在编译过程中完成的，此时全局作用中两个变量的值依然是 undefined，然后进入执行阶段；第二部代码就是执行时的顺序，首先全局作用域中的两个变量赋值“极客时间”和“global”，然后就开始执行函数 bar 的调用了。

当 V8 执行 bar 函数的时候，同样需要经历两个阶段：编译和执行。在编译阶段，V8 会为 bar 函数创建函数作用域，最终效果如下所示：

bar函数作用域

然后进入了 bar 函数的执行阶段。在 bar 函数中，只是简单地调用 foo 函数，因此 V8 又开始执行 foo 函数了。

同样，在编译 foo 函数的过程中，会创建 foo 函数的作用域，最终创建效果如下图所示：

foo函数作用域

好了，这时候我们就有了三个作用域了，分别是全局作用域、bar 的函数作用域、foo 的函数作用域。

现在我们就可以将刚才提到的问题转换为作用域链的问题了：foo 函数查找变量的路径到底是什么？

沿着 foo 函数作用域–>bar 函数作用域–> 全局作用域 ;

还是，沿着 foo 函数作用域—> 全局作用域？

因为 JavaScript 是基于词法作用域的，词法作用域就是指，查找作用域的顺序是按照函数定义时的位置来决定的。bar 和 foo 函数的外部代码都是全局代码，所以无论你是在 bar 函数中查找变量，还是在 foo 函数中查找变量，其查找顺序都是按照当前函数作用域–> 全局作用域这个路径来的。

由于我们代码中的 foo 函数和 bar 函数都是在全局下面定义的，所以在 foo 函数中使用了 type，最终打印出来的值就是全局作用域中的 type。

你可以参看下面这张图：

另外，我再展开说一些。因为词法作用域是根据函数在代码中的位置来确定的，作用域是在声明函数时就确定好的了，所以我们也将词法作用域称为静态作用域。

和静态作用域相对的是动态作用域，动态作用域并不关心函数和作用域是如何声明以及在何处声明的，只关心它们从何处调用。换句话说，作用域链是基于调用栈的，而不是基于函数定义的位置的。（动态作用域不是本文讨论的重点，如果你感兴趣，可以参考《浏览器工作原理与实践》专栏中的《10 | 作用域链和闭包 ：代码中出现相同的变量，JavaScript 引擎是如何选择的？》这一节。）

总结

今天，我们主要解释了一个问题，那就是在一个函数中，如果使用了一个变量，或者调用了另外一个函数，V8 将会怎么去查找该变量或者函数。

为了解释清楚这个问题，我们引入了作用域的概念。作用域就是用来存放变量和函数的地方，全局作用域中存放了全局环境中声明的变量和函数，函数作用域中存放了函数中声明的变量和函数。当在某个函数中使用某个变量时，V8 就会去这些作用域中查找相关变量。沿着这些作用域查找的路径，我们就称为作用域链。

要了解查找路径，我们需要明白词法作用域，词法作用域是按照代码定义时的位置决定的，而 JavaScript 所采用的作用域机制就是词法作用域，所以作用域链的路径就是按照词法作用域来实现的。

思考题

我将文章开头那段代码稍微调整了下，foo 函数并不是在全局环境中声明的，而是在 bar 函数中声明的，改造后的代码如下所示：

var name = '极客时间'
var type = 'global'
function bar() {
    var type = 'function'
    function foo() {
        console.log(type)
    }
    foo()
}
bar()

那么执行这段代码之后，打印出来的内容是什么？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

07｜类型转换：V8是怎么实现1+“2”的？

你好，我是李兵。

前面我们花了很多篇幅聊了 JavaScript 中最基础却很容易被忽略的“对象”，以及 V8 是怎么处理“对象”的，今天我们继续来聊另一个非常基础，同时也是很容易被大家忽略的问题，那就是 JavaScript 中的“类型系统”。

在理解这个概念之前，你可以先思考一个简单的表达式，那就是在 JavaScript 中，“1+‘2’等于多少？”

其实这相当于是在问，在 JavaScript 中，让数字和字符串相加是会报错，还是可以正确执行。如果能正确执行，那么结果是等于数字 3，还是字符串“3”，还是字符串“12”呢？

如果你尝试在 Python 中使用数字和字符串进行相加操作，那么 Python 虚拟机会直接返回一个执行错误，错误提示是这样的：

>>> 1+'2'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

这段错误代码提示了这是个类型错误，表明 Python 并不支持数字类型和字符串类型相加的操作。

不过在 JavaScript 中执行这段表达式，是可以返回一个结果的，最终返回的结果是字符串“12”。

最终结果如下所示：

>>> 1+'2'
>>> "12"

为什么同样一条的表达式，在 Python 和 JavaScript 中执行会有不同的结果？为什么在 JavaScript 中执行，输出的是字符串“12”，不是数字 3 或者字符串“3”呢？

什么是类型系统 (Type System)？

在这个简单的表达式中，涉及到了两种不同类型的数据的相加。要想理清以上两个问题，我们就需要知道类型的概念，以及 JavaScript 操作类型的策略。

对机器语言来说，所有的数据都是一堆二进制代码，CPU 处理这些数据的时候，并没有类型的概念，CPU 所做的仅仅是移动数据，比如对其进行移位，相加或相乘。

而在高级语言中，我们都会为操作的数据赋予指定的类型，类型可以确认一个值或者一组值具有特定的意义和目的。所以，类型是高级语言中的概念。

比如在 C/C++ 中，你需要为要处理的每条数据指定类型，这样定义变量：

int counter = 100 # 赋值整型变量
float miles = 1000.0 # 浮点型
char* name = "John" # 字符串

C/C++ 编译器负责将这些数据片段转换为供 CPU 处理的正确命令，通常是二进制的机器代码。

在某些更高级的语言中，还可以根据数据推断出类型，比如在 Python 或 JavaScript 中，你就不必为数据指定专门的数据类型，在 Python 中，你可以这样定义变量：

counter = 100 # 赋值整型变量
miles = 1000.0 # 浮点型
name = "John" # 字符串

在 JavaScript 中，你可以这样定义变量：

var counter = 100 # 赋值整型变量
let miles = 1000.0 # 浮点型
const name = "John" # 字符串

虽然 Python 和 JavaScript 定义变量的方式不同，但是它们都不需要直接指定变量的类型，因为虚拟机会根据数据自动推导出类型。

通用的类型有数字类型、字符串、Boolean 类型等等，引入了这些类型之后，编译器或者解释器就可以根据类型来限制一些有害的或者没有意义的操作。

比如在 Python 语言中，如果使用字符串和数字相加就会报错，因为 Python 觉得这是没有意义的。而在 JavaScript 中，字符串和数字相加是有意义的，可以使用字符串和数字进行相加的。再比如，你让一个字符串和一个字符串相乘，这个操作是没有意义的，所有语言几乎都会禁止该操作。

每种语言都定义了自己的类型，还定义了如何操作这些类型，另外还定义了这些类型应该如何相互作用，我们就把这称为类型系统。

关于类型系统，wiki 百科上是这样解释的：

在计算机科学中，类型系统（type system）用于定义如何将编程语言中的数值和表达式归类为许多不同的类型，如何操作这些类型，这些类型如何互相作用。

直观地理解，一门语言的类型系统定义了各种类型之间应该如何相互操作，比如，两种不同类型相加应该如何处理，两种相同的类型相加又应该如何处理等。还规定了各种不同类型应该如何相互转换，比如字符串类型如何转换为数字类型。

一个语言的类型系统越强大，那编译器能帮程序员检查的东西就越多，程序员定义“检查规则”的方式就越灵活。

V8 是怎么执行加法操作的？

了解了 JavaScript 中的类型系统，接下来我们就可以来看看 V8 是怎么处理 1+“2”的了。

当有两个值相加的时候，比如:

a+b

V8 会严格根据 ECMAScript 规范来执行操作。ECMAScript 是一个语言标准，JavaScript 就是 ECMAScript 的一个实现，比如在 ECMAScript 就定义了怎么执行加法操作，如下所示：

ECMAScript定义加法语义

具体细节你也可以参考规范，我将标准定义的内容翻译如下：

AdditiveExpression : AdditiveExpression + MultiplicativeExpression

把第一个表达式 (AdditiveExpression) 的值赋值给左引用 (lref)。

使用 GetValue(lref) 获取左引用 (lref) 的计算结果，并赋值给左值。

使用ReturnIfAbrupt(lval) 如果报错就返回错误。

把第二个表达式 (MultiplicativeExpression) 的值赋值给右引用 (rref)。

使用 GetValue(rref) 获取右引用 (rref) 的计算结果，并赋值给 rval。

使用ReturnIfAbrupt(rval) 如果报错就返回错误。

使用 ToPrimitive(lval) 获取左值 (lval) 的计算结果，并将其赋值给左原生值 (lprim)。

使用 ToPrimitive(rval) 获取右值 (rval) 的计算结果，并将其赋值给右原生值 (rprim)。

如果 Type(lprim) 和 Type(rprim) 中有一个是 String，则：

a. 把 ToString(lprim) 的结果赋给左字符串 (lstr)；

b. 把 ToString(rprim) 的结果赋给右字符串 (rstr)；

c. 返回左字符串 (lstr) 和右字符串 (rstr) 拼接的字符串。

把 ToNumber(lprim) 的结果赋给左数字 (lnum)。

把 ToNumber(rprim) 的结果赋给右数字 (rnum)。

返回左数字 (lnum) 和右数字 (rnum) 相加的数值。

通俗地理解，V8 会提供了一个 ToPrimitve 方法，其作用是将 a 和 b 转换为原生数据类型，其转换流程如下：

先检测该对象中是否存在 valueOf 方法，如果有并返回了原始类型，那么就使用该值进行强制类型转换；

如果 valueOf 没有返回原始类型，那么就使用 toString 方法的返回值；

如果 vauleOf 和 toString 两个方法都不返回基本类型值，便会触发一个 TypeError 的错误。

将对象转换为原生类型的流程图如下所示：

当 V8 执行 1+“2”时，因为这是两个原始值相加，原始值相加的时候，如果其中一项是字符串，那么 V8 会默认将另外一个值也转换为字符串，相当于执行了下面的操作：

Number(1).toString() + "2"

这里，把数字 1 偷偷转换为字符串“1”的过程也称为强制类型转换，因为这种转换是隐式的，所以如果我们不熟悉语义，那么就很容易判断错误。

我们还可以再看一个例子来验证上面流程，你可以看下面的代码：

var Obj = {
    toString() {
      return '200'
    },
    valueOf() {
      return 100
    }
  }
  Obj+3

执行这段代码，你觉得应该返回什么内容呢？

上面我们介绍过了，由于需要先使用 ToPrimitve 方法将 Obj 转换为原生类型，而 ToPrimitve 会优调用对象中的 valueOf 方法，由于 valueOf 返回了 100，那么 Obj 就会被转换为数字 100，那么数字 100 加数字 3，那么结果当然是 103 了。

如果我改造下代码，让 valueOf 方法和 toString 方法都返回对象，其改造后的代码如下：

var Obj = {
    toString() {
      return new Object()
    },
    valueOf() {
      return new Object()
    }
  }
  Obj+3

再执行这段代码，你觉得应该返回什么内容呢？

因为 ToPrimitve 会先调用 valueOf 方法，发现返回的是一个对象，并不是原生类型，当 ToPrimitve 继续调用 toString 方法时，发现 toString 返回的也是一个对象，都是对象，就无法执行相加运算了，这时候虚拟机就会抛出一个异常，异常如下所示：

VM263:9 Uncaught TypeError: Cannot convert object to primitive value
    at <anonymous>:9:6

提示的是类型错误，错误原因是无法将对象类型转换为原生类型。

所以说，在执行加法操作的时候，V8 会通过 ToPrimitve 方法将对象类型转换为原生类型，最后就是两个原生类型相加，如果其中一个值的类型是字符串时，则另一个值也需要强制转换为字符串，然后做字符串的连接运算。在其他情况时，所有的值都会转换为数字类型值，然后做数字的相加。

总结

今天我们主要学习了 JavaScript 中的类型系统是怎么工作的。类型系统定义了语言应当如何操作类型，以及这些类型如何互相作用。因为 Python 和 JavaScript 的类型系统差异，所以当处理同样的表达式时，返回的结果是不同的。

在 Python 中，数字和字符串相加会抛出异常，这是因为 Python 认为字符串和数字相加是无意义的，所以限制其操作。

在 JavaScript 中，数字和字符串相加会返回一个新的字符串，这是因为 JavaScript 认为字符串和数字相加是有意义的，V8 会将其中的数字转换为字符，然后执行两个字符串的相加操作，最终得到的是一个新的字符串。

在 JavaScript 中，类型系统是依据 ECMAScript 标准来实现的，所以 V8 会严格根据 ECMAScript 标准来执行。在执行加法过程中，V8 会先通过 ToPrimitive 函数，将对象转换为原生的字符串或者是数字类型，在转换过程中，ToPrimitive 会先调用对象的 valueOf 方法，如果没有 valueOf 方法，则调用 toString 方法，如果 vauleOf 和 toString 两个方法都不返回基本类型值，便会触发一个 TypeError 的错误。

思考题

我们一起来分析一段代码：

var Obj = {
    toString() {
      return "200"
    },
    valueOf() {
      return 100
    }
  }
  Obj+"3"

你觉得执行这段代码会打印出什么内容呢？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

08｜答疑：如何构建和使用V8的调试工具d8？

你好，我是李兵。

今天是我们第一单元的答疑环节，课后有很多同学留言问我关于 d8 的问题，所以今天我们就来专门讲讲，如何构建和使用 V8 的调试工具 d8。

d8 是一个非常有用的调试工具，你可以把它看成是 debug for V8 的缩写。我们可以使用 d8 来查看 V8 在执行 JavaScript 过程中的各种中间数据，比如作用域、AST、字节码、优化的二进制代码、垃圾回收的状态，还可以使用 d8 提供的私有 API 查看一些内部信息。

如何通过 V8 的源码构建 D8？

通常，我们没有直接获取 d8 的途径，而是需要通过编译 V8 的源码来生成 d8，接下来，我们就先来看看如何构建 d8。

其实并不难，总的来说，大体分为三部分。首先我们需要先下载 V8 的源码，然后再生成工程文件，最后编译 V8 的工程并生成 d8。

接下来我们就来具体操作一下。考虑到使用 Windows 系统的同学比较多，所以下面的操作，我们的默认环境是 Windows 系统，mac OS 和 Liunx 的配置会简单一些。

安装 VPN

V8 并不是一个单一的版本库，它还引用了很多第三方的版本库，大多是版本库我们都无法直接访问，所以，在下载代码过程中，你得先准备一个 VPN。

下载编译工具链：depot_tools

有了 VPN，接下来我们需要下载编译工具链：depot_tools，后续 V8 源码的下载、配置和编译都是由 depot_tools 来完成的，你可以直接点击下载：depot_tools bundle。

depot_tools 压缩包下载到本地之后，解压压缩包，比如你解压到以下这个路径中：

C:\src\depot_tools

然后需要将这个路径添加到环境变量中，这样我们就可以在控制台中使用 gclient 了。

设置环境变量

接下来，还需要往系统环境变量中添加变量 DEPOT_TOOLS_WIN_TOOLCHAIN ，值设为 0。

DEPOT_TOOLS_WIN_TOOLCHAIN = 0

这个环境变量的作用是告诉 deppt_tools，使用本地已安装的默认的 Visual Studio 版本去编译，否则 depot_tools 会使用 Google 内部默认的版本。

然后你可以在命令行中测试下是否可以使用：

gclient sync

安装 VS2019

在 Windows 系统下面，depot_tools 使用了 VS2019，因为 VS2019 自带了编译 V8 的编译器，所以需要安装 VS2019 时，安装时，你需要选择以下两项内容：

Desktop development with C++；

MFC/ATL support。

因为编译 V8 时，使用了这两项所提供的基础开发环境。

下载 V8 源码

安装了 VS2019，接下来就可以使用 depot_tools 来下载 V8 源码了，具体下载命令如下所示：

d:
mkdir v8
cd v8
fetch v8
cd v8

执行这个命令就会开始下载 V8 源码，这个过程可能比较漫长，下载时间主要取决于你的网速和 VPN 的速度。

配置工程

代码下载完成之后，就需要配置工程了，我们使用 gn 来配置。

cd v8

gn gen out.gn/x64.release --args='is_debug=false target_cpu="x64" v8_target_cpu="arm64" use_goma=true'

如果是 mac 系统，你可以使用：

gn gen out/gn --ide=xcode

来生成工程。

gn 是一个跨平台的构建系统，用来构建 Ninja 工程，Ninja 是一个跨平台的编译系统，比如可以通过 gn 构建 Chromium 还有 V8 的工程文件，然后使用 Ninja 来执行编译，可以使用 gn 和 Ninja 来配合使用构建跨平台的工程，这些工程可以在 macOS、Linux、Windows 等平台上进行编译。在 gn 之前，Google 使用了 gyp 来构建，由于 gn 的效率更高，所以现在都在使用 gn。

下面我们来看下生成 V8 工程的一些基础配置项：

is_debug = false 编译成 release 版本 ;

is_component_build = true 编译成动态链接库而不是很大的可执行文件 ;

symbol_level = 0 将所有的 debug 符号放在一起，可以加速二次编译，并加速链接过程 ;

ide = vs2019 ide=xcode。

工程生成好之后，你就可以去 v8\out.gn\x64.release 这个目录下查看生成的工程文件。如下图所示：

编译 d8

生成了 d8 的工程配置文件，接下来就可以编译 d8 了，你可以使用下面的命令：

ninja -C out.gn/x64.release

如果想编译特定目标，比如 d8，可以使用下面的命令：

ninja -C out.gn/x64.release d8

这个命令只会编译和 d8 所依赖的工程，然后就开始执行编译流程了。如下图所示：

编译时间取决于你硬盘读写速度和 CPU 的个数，比如我的电脑是 10 核 CPU，ssd 硬盘，整个编译过程大概花费了 15 分钟。

最终编译结束之后，你就可以去 v8\out.gn\x64.release 查看生成的文件，如下图所示：

我们可以看到 d8 也在其中。

我将编译出来的 d8 也放到了网上，如果你不想编译，也可以点击这里直接下载使用。

如何使用 d8？

好了，现在我们编译出来了 d8 ，接下来我们将 d8 所在的目录，v8\out.gn\x64.release 添加到环境变量“PATH”的路径中，这样我们就可以在控制台中使用 d8 了。

我们先来测试下能不能使用 d8，你可以使用下面这个命令，在控制台中执行 d8：

d8 --help

最终显示出来了一大堆命令，如下所示：

我们可以看到上图中打印出来了很多行，每一行其实都对应着一个命令，比如 print-bytecode 就是查看生成的字节码，print-opt-code 是要查看优化后的代码，turbofan-stats 是打印出来优化编译器的一些统计数据的命令，每个命令后面都有一个括号，括号里面是介绍这个命令的具体用途。

使用 d8 进行调试方式如下：

d8 test.js --print-bytecode

d8 后面跟上文件名和要执行的命令，如果执行上面这行命令，就会打印出 test.js 文件所生成的字节码。

不过通过 d8 --help 打印出来的列表非常长，如果过滤特定的命令，你可以使用下面的命令来查看：

d8 --help |grep print

这样我们就能查看看 d8 有多少关于 print 的命令，如果你使用了 Windows 系统，可能缺少 grep 程序，你可以去这里下载。

安装完成之后，记得手动将 grep 程序所在的目录添加到环境变量 PATH 中，这样才能在控制台使用 grep 命令。

最终打印出来带有 print 字样的命令，包含以下内容：

d8 的命令很多，如果有时间，你可以逐一试下。接下来下面我们挑其中一些重点的命令来介绍下，比如 trace-gc，trace-opt-verbose 。这些命令涉及到了编译流水线的中间数据，垃圾回收器执行状态等，熟悉使用这些命令可以帮助我们更加深刻理解编译流水线和垃圾回收器的执行状态。

在使用 d8 执行一段代码之前，你需要将你的 JavaScript 源码保存到一个 js 文件中，我们把所需要需要观察的代码都存放到 test.js 这个文件中。

打印优化数据

你可以使用–print-ast 来查看中间生成的 AST，使用—print-scope 来查看中间生成的作用域，–print-bytecode 来查看中间生成的字节码。除了这些数据之外，我们还可以使用 d8 来打印一些优化的数据，比如下面这样一段代码：

let a = {x:1}
function bar(obj) {
  return obj.x
}

function foo () {
  let ret = 0
  for(let i = 1; i < 7049; i++) {
    ret += bar(a)
  }
  return ret
}

foo(

当 V8 先执行到这段代码的时候，监控到 while 循环会一直被执行，于是判断这是一块热点代码，于是，V8 就会将热点代码编译为优化后的二进制代码，你可以通过下面的命令来查看：

d8 --trace-opt-verbose test.js

执行这段命令之后，提示如下所示：

观察上图，我们可以看到终端中出现了一段优化的提示：

<JSFunction foo (sfi = 0x2c730824fe21)> for optimized recompilation, reason: small function]

这就是告诉我们，已经使用 TurboFan 优化编译器将函数 foo 优化成了二进制代码，执行 foo 时，实际上是执行优化过的二进制代码。

现在我们把 foo 函数中的循环加到 10 万，再来查看优化信息，最终效果如下图所示：

我们看到又出现了一条新的优化信息，新的提示信息如下：

<JSFunction foo (sfi = 0xc9c0824fe21)> using TurboFan OSR]

这段提示是说，由于循环次数过多，V8 采取了 TurboFan 的 OSR 优化，OSR 全称是 On-Stack Replacement，它是一种在运行时替换正在运行的函数的栈帧的技术，如果在 foo 函数中，每次调用 bar 函数时，都要创建 bar 函数的栈帧，等 bar 函数执行结束之后，又要销毁 bar 函数的栈帧。

通常情况下，这没有问题，但是在 foo 函数中，采用了大量的循环来重复调用 bar 函数，这就意味这 V8 需要不断为 bar 函数创建栈帧，销毁栈帧，那么这样势必会影响到 foo 函数的执行效率。

于是，V8 采用了 OSR 技术，将 bar 函数和 foo 函数合并成一个新的函数，具体你可以参考下图：

如果我在 foo 函数里面执行了 10 万次循环，在循环体内调用了 10 万次 bar 函数，那么 V8 会实现两次优化，第一次是将 foo 函数编译成优化的二进制代码，第二次是将 foo 函数和 bar 函数合成为一个新的函数。

网上有一篇介绍 OSR 的文章也不错，叫on-stack replacement in v8，如果你感兴趣可以查看下。

查看垃圾回收

我们还可以通过 d8 来查看垃圾回收的状态，你可以参看下面这段代码：

function strToArray(str) {
  let i = 0
  const len = str.length
  let arr = new Uint16Array(str.length)
  for (; i < len; ++i) {
    arr[i] = str.charCodeAt(i)
  }
  return arr;
}

function foo() {
  let i = 0
  let str = 'test V8 GC'
  while (i++ < 1e5) {
    strToArray(str);
  }
}

foo(

上面这段代码，我们重复将一段字符串转换为数组，并重复在堆中申请内存，将转换后的数组存放在内存中。我们可以通过 trace-gc 来查看这段代码的内存回收状态，执行下面这段命令：

d8 --trace-gc test.js

最终打印出来的结果如下图所示：

你会看到一堆提示，如下：

Scavenge 1.2 (2.4) -> 0.3 (3.4) MB, 0.9 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure

这句话的意思是提示“Scavenge … 分配失败”，是因为垃圾回收器 Scavenge 所负责的空间已经满了，Scavenge 主要回收 V8 中“新生代”中的内存，大多数对象都是分配在新生代内存中，内存分配到新生代中是非常快速的，但是新生代的空间却非常小，通常在 1～8 MB 之间，一旦空间被填满，Scavenge 就会进行“清理”操作。

上面这段代码之所以能频繁触发新生代的垃圾回收，是因为它频繁地去申请内存，而申请内存之后，这块内存就立马变得无效了，为了减少垃圾回收的频率，我们尽量避免申请不必要的内存，比如我们可以换种方式来实现上述代码，如下所示：

function strToArray(str, bufferView) {
  let i = 0
  const len = str.length
  for (; i < len; ++i) {
    bufferView[i] = str.charCodeAt(i);
  }
  return bufferView;
}
function foo() {
  let i = 0
  let str = 'test V8 GC'
  let buffer = new ArrayBuffer(str.length * 2)
  let bufferView = new Uint16Array(buffer);
  while (i++ < 1e5) {
    strToArray(str,bufferView);
  }
}
foo()

我们将 strToArray 中分配的内存块，提前到了 foo 函数中分配，这样我们就不需要每次在 strToArray 函数分配内存了，再次执行 trace-gc 的命令：

d8 --trace-gc test.js

我们就会看到，这时候没有任何垃圾回收的提示了，这也意味着这时没有任何垃圾分配的操作了。

内部方法

另外，你还可以使用 V8 所提供的一些内部方法，只需要在启动 V8 时传入 allow-natives-syntax 命令，具体使用方式如下所示：

d8 --allow-natives-syntax test.js

还记得我们在《03｜快属性和慢属性：V8 采用了哪些策略提升了对象属性的访问速度？》讲到的快属性和慢属性吗？

我们可以通过内部方法 HasFastProperties 来检查一个对象是否拥有快属性，比如下面这段代码：

function Foo(property_num,element_num) {
  //添加可索引属性
  for (let i = 0; i < element_num; i++) {
      this[i] = `element${i}`
  }
  //添加常规属性
  for (let i = 0; i < property_num; i++) {
      let ppt = `property${i}`
      this[ppt] = ppt
  }
}
var bar = new Foo(10,10)
console.log(%HasFastProperties(bar));
delete bar.property2
console.log(%HasFastProperties(bar));

我们执行：

d8  test.js --allow-natives-syntax

通过传入 allow-natives-syntax 命令，就能使用 HasFastProperties 这一类内部接口，默认情况下，我们知道 V8 中的对象都提供了快属性，不过使用了 delete bar.property2 之后，就没有快属性了，我们可以通过 HasFastProperties 来判断。

所以可以得出，使用 delete 时候，我们查找属性的速度就会变慢，这也是我们尽量不要使用 delete 的原因。

除了 HasFastProperties 方法之外，V8 提供的内部方法还有很多，比如你可以使用 GetHeapUsage 来查看堆的使用状态，可以使用 CollectGarbage 来主动触发垃圾回收，诸如 HaveSameMap、HasDoubleElements 等，具体命令细节你可以参考这里。

总结

好了，今天的内容就介绍到这里，我们来简单总结一下。

d8 是个非常有用的调试工具，能够帮助我们发现我们的代码是否可以被 V8 高效地执行，比如通过 d8 查看代码有没有被 JIT 编译器优化，还可以通过 d8 内置的一些接口查看更多的代码内部信息，而且通过使用 d8，我们会接触各种实际的优化策略，学习这些策略并结合 V8 的工作原理，可以让我们更加接地气地了解 V8 的工作机制。

通过源码来构建 d8 的流程比较简单，首先下载 V8 的编译工具链：depot_tools，然后再利用 depot_tools 下载源码、生成工程、编译工程，这就实现了通过源码编译 d8。这个过程不难，但涉及到了许多工具，在配置过程中可能会遇到一些坑，不过按照流程操作应该能顺利编译出来 d8。

接下来我们重点讨论了如何使用 d8，我们可以通过传入不同的命令，让 d8 来分析 V8 在执行 JavaScript 过程中的一些中间数据。你应该熟练掌握 d8 的使用方式，在后续课程中我们应该还会反复引用本节的一些内容。

思考题

c/c++ 中有内联 (Inline) 函数，和我们文中分析的 OSR 类似，内联函数和 V8 中所采用的 OSR 优化手段类似，都是在执行过程中将两个函数合并成一个，这样在执行代码的过程中，就减少了栈帧切换操作，增加了执行效率，那么今天留给你的思考题时，你认为在什么情况下，V8 会将多个函数合成一个函数？

你可以列举一个实际的列子，并使用 d8 来分析 V8 是否对你的列子进行了优化操作。欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

09 | 运行时环境：运行JavaScript代码的基石

你好，我是李兵。

通过前面几节课的学习，我们理解了 JavaScript 是一门基于对象的语言，它能实现非常多的特性，诸如函数是一等公民、闭包、函数式编程、原型继承等，搞懂了这些特性，我们就可以来打开 V8 这个黑盒，深入了解它的编译流水线了。

我们知道，当你想执行一段 JavaScript 代码时，只需要将代码丢给 V8 虚拟机，V8 便会执行并返回给你结果。

其实在执行 JavaScript 代码之前，V8 就已经准备好了代码的运行时环境，这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象，还有消息循环系统。准备好运行时环境之后，V8 才可以执行 JavaScript 代码，这包括解析源码、生成字节码、解释执行或者编译执行这一系列操作。

对运行时环境有足够的了解，能够帮助我们更好地理 V8 的执行流程。比如事件循环系统可以让你清楚各种回调函数是怎么被执行的，栈空间可以让你了解函数是怎么被调用的，堆空间和栈空间让你了解为什么要有传值和传引用，等等。

运行时环境涉及到的知识都是非常基础，但又非常容易被忽视的。今天这节课，我们就来分析下这些基础的运行时环境。

什么是宿主环境？

要聊运行 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 也会提供一套消息循环系统，也会提供一个运行时的主线程。

宿主环境和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 等变量的内容。

执行上下文所包含的具体内容，你可以参考下图：

什么是执行上下文

全局执行上下文在 V8 的生存周期内是不会被销毁的，它会一直保存在堆中，这样当下次在需要使用函数或者全局变量时，就不需要重新创建了。另外，当你执行了一段全局代码时，如果全局代码中有声明的函数或者定义的变量，那么函数对象和声明的变量都会被添加到全局执行上下文中。比如下面这段代码：

var x = 1
function show_x(){
    console.log(x)
}

V8 在执行这段代码的过程中，会在全局执行上下文中添加变量 x 和函数 show_x。

在这里还有一点需要注意下，全局作用域和全局执行上下文的关系，其实你可以把作用域看成是一个抽象的概念，比如在 ES6 中，同一个全局执行上下文中，都能存在多个作用域，你可以看下面这段代码：

var x = 5
{
    let y = 2
    const z = 3
}

这段代码在执行时，就会有两个对应的作用域，一个是全局作用域，另外一个是括号内部的作用域，但是这些内容都会保存到全局执行上下文中。具体你可以参看下图：

全局作用域和子作用域关系

当 V8 调用了一个函数时，就会进入函数的执行上下文，这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构。比如执行下面这段代码：

var x = 1
function show_x(){
    console.log(x)
}
function bar(){
  show_x（）
}
bar()

当执行到 show_x 的时候，其栈状态如下图所示：

函数调用栈

构造事件循环系统

有了堆空间和栈空间，生成了全局执行上下文和全局作用域，接下来就可以执行 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 的生命周期是一致的，所以在实际项目中，如果不经常使用的变量或者数据，最好不要放到全局执行上下文中。

另外，宿主环境还需要构造事件循环系统，事件循环系统主要用来处理任务的排队和任务的调度。

思考题

你认为作用域和执行上下文是什么关系？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

10 | 机器代码：二进制机器码究竟是如何被CPU执行的？

你好，我是李兵。

在上一节我们分析了 V8 的运行时环境，准备好了运行时环境，V8 就可以执行 JavaScript 代码了。在执行代码时，V8 需要先将 JavaScript 编译成字节码，然后再解释执行字节码，或者将需要优化的字节码编译成二进制，并直接执行二进制代码。

也就是说，V8 首先需要将 JavaScript编译成字节码或者二进制代码，然后再执行。

在后续课程中，我们会分析 V8 如何解释执行字节码，以及执行编译好的二进制代码，不过在分析这些过程之前，我们需要了解最基础的知识，那就是 CPU 如何执行二进制代码。

因为字节码的执行模式和 CPU 直接执行二进制代码的模式是类似的，了解 CPU 执行二进制代码的过程，后续我们分析字节码的执行流程就会显得比较轻松，而且也能加深我们对计算机底层工作原理的理解。

今天我们就要来分析下二进制代码是怎么被 CPU 执行的，在编译流水线中的位置你可以参看下图：

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

最后编译出来的机器码如下图所示：

观察上图，左边就是编译生成的机器码，在这里它是使用十六进制来展示的，这主要是因为十六进制比较容易阅读，所以我们通常使用十六进制来展示二进制代码。你可以观察到上图是由很多行组成的，每一行其实都是一个指令，该指令可以让 CPU 执行指定的任务。

中间的部分是汇编代码，汇编代码采用助记符（memonic）来编写程序，例如原本是二进制表示的指令，在汇编代码中可以使用单词来表示，比如 mov、add 就分别表示数据的存储和相加。汇编语言和机器语言是一一对应的，这一点和高级语言有很大的不同。

通常我们将汇编语言编写的程序转换为机器语言的过程称为“汇编”；反之，机器语言转化为汇编语言的过程称为“反汇编”，比如上图就是对 code_prog 进程进行了反汇编操作。

另外，右边是我添加的注释，表示每条指令的具体含义，你可以对照着阅读。

这一大堆指令按照顺序集合在一起就组成了程序，所以程序的执行，本质上就是 CPU 按照顺序执行这一大堆指令的过程。

CPU 是怎么执行程序的？

现在我们知道了编译后的程序是由一堆二进制代码组成的，也知道二进制代码是由一条条指令构成的，那么接下来我们就可以来分析 CPU 是如何执行这些指令的了。

不过为了分析程序的执行过程，我们还需要理解典型的计算机系统的硬件组织结构，具体你可以参看下图：

计算机系统的硬件组织结构

这张图是比较通用的系统硬件组织模型图，从图中我们可以看出，它主要是由 CPU、主存储器、各种 IO 总线，还有一些外部设备，诸如硬盘、显示器、USB 等设备组成的。

有了这张图，接下来我们就可以分析程序到底是怎么被执行的了。

首先，在程序执行之前，我们的程序需要被装进内存，比如在 Windows 下面，你可以通过鼠标点击一个可执行文件，当你点击该文件的时候，系统中的程序加载器会将该文件加载到内存中。

那么到底什么是内存呢？

你可以把内存看成是一个快递柜，比如当你需要寄件的时候，你可以打开快递柜中的第 100 号单元格，并存放你的物品，有时候你会收到快递，提示你在快递柜的 105 号单元格中，你就可以打开 105 号单元格取出的你的快递。

这里有三个重要的内容，分别是快递柜、快递柜中的每个单元格的编号、操作快递柜的人，你可以把它们对比成点算机中的内存、内存地址和 CPU。

也就是说，CPU 可以通过指定内存地址，从内存中读取数据，或者往内存中写入数据，有了内存地址，CPU 和内存就可以有序地交互。同时，从内存的角度理解地址也是非常重要的，这能帮助我们理解后续很多有深度的内容。

另外，内存还是一个临时存储数据的设备，之所以是临时的存储器，是因为断电之后，内存中的数据都会消失。

内存中的每个存储空间都有其对应的独一无二的地址，你也可以通过下图来直观地理解下内存中两个重要的概念，内存和地址：

内存中的存储空间都有唯一地址

在内存中，每个存放字节的空间都有其唯一的地址，而且地址是按照顺序排放的，理解了内存和内存地址，接下来我们就可以继续往下分析了。

我们还是分析这节课开头的那段 C 代码，这段代码会被编译成可执行文件，可执行文件中包含了二进制的机器码，当二进制代码被加载进了内存后，那么内存中的每条二进制代码便都有了自己对应的地址，如下图所示：

加载到内存中的程序

有时候一条指令只需要一个字节就可以了，但是有时候一条指令却需要多个字节。在上图中，对于同一条指令，我使用了相同的颜色来标记，我们可以把上面这个一堆二进制数据反汇编成一条条指令的形式，这样可以方便我们的阅读，效果如下图所示：

好了，一旦二进制代码被装载进内存，CPU 便可以从内存中取出一条指令，然后分析该指令，最后执行该指令。

我们把取出指令、分析指令、执行指令这三个过程称为一个 CPU 时钟周期。CPU 是永不停歇的，当它执行完成一条指令之后，会立即从内存中取出下一条指令，接着分析该指令，执行该指令，CPU 一直重复执行该过程，直至所有的指令执行完成。

也许你有这样的疑问，CPU 是怎么知道要取出内存中的哪条指令呢？要解答这个问题，我们先看下图：

将混乱的二进制代码转换为有序的指令形式

观察上图，我们可以看到 CPU 中有一个 PC 寄存器，它保存了将要执行的指令地址，当二进制代码被装载进了内存之后，系统会将二进制代码中的第一条指令的地址写入到 PC 寄存器中，到了下一个时钟周期时，CPU 便会根据 PC 寄存器中的地址，从内存中取出指令。

PC 寄存器中的指令取出来之后，系统要做两件事：

第一件是将下一条指令的地址更新到 PC 寄存器中，比如上图中，CPU 将第一个指令 55 取出来之后，系统会立即将下一个指令的地址填写到 PC 寄存器中，上个寄存器的地址是 100000f90，那么下一条指令的地址就是 100000f91 了，如下图所示：

将第一条指令写入PC中

更新了 PC 寄存器之后，CPU 就会立即做第二件事，那就是分析该指令，并识别出不同的类型的指令，以及各种获取操作数的方法。在指令分析完成之后，就要执行指令了。不过要了解 CPU 是如何执行指令的，我们还需要了解 CPU 中的一个重要部件：通用寄存器。

通用寄存器是 CPU 中用来存放数据的设备，不同处理器中寄存器的个数也是不一样的，之所要通用寄存器，是因为 CPU 访问内存的速度很慢，所以 CPU 就在内部添加了一些存储设备，这些设备就是通用寄存器。

你可以把通用寄存器比喻成是你身上的口袋，内存就是你的背包，而硬盘则是你的行李箱，要从背包里面拿物品会比较不方便，所以你会将常用的物品放进口袋。你身上口袋的个数通常不会太多，容量也不会太大，而背包就不同了，它的容量会非常大。

我们可以这样总结通用寄存器和内存的关系：通用寄存器容量小，读写速度快，内存容量大，读写速度慢。

通用寄存器通常用来存放数据或者内存中某块数据的地址，我们把这个地址又称为指针，通常情况下寄存器对存放的数据是没有特别的限制的，比如某个通用寄存器既可以存储数据，也可以存储指针。

不过由于历史原因，我们还会将某些专用的数据或者指针存储在专用的通用寄存器中 ，比如 rbp 寄存器通常用来存放栈帧指针的，rsp 寄存器用来存放栈顶指针的，PC 寄存器用来存放下一条要执行的指令等。

现在我们理解了什么是通用寄存器了，接下来我们就可以分析 CPU 是如何执行指令的了，我们先来了解下几种常用的指令类型：

第一种是加载的指令，其作用是从内存中复制指定长度的内容到通用寄存器中，并覆盖寄存器中原来的内容。你可以参看下图：

更新PC寄存器

比如上图使用了 movl 指令，指令后面跟着的第一个参数是要拷贝数据的内存的位置，第二个参数是要拷贝到 ecx 这个寄存器。

第二种存储的指令，和加载类型的指令相反，其作用是将寄存器中的内容复制内存某个位置，并覆盖掉内存中的这个位置上原来的内容。你可以参看下图：

上图也是使用 movl 指令，movl 指令后面的 %ecx 就是寄存器地址，-8(%rbp) 是内存中的地址，这条指令的作用是将寄存器中的值拷贝到内存中。

第三种是更新指令，其作用是复制两个寄存器中的内容到 ALU 中，也可以是一块寄存器和一块内存中的内容到 ALU 中，ALU 将两个字相加，并将结果存放在其中的一个寄存器中，并覆盖该寄存器中的内容。具体流程如下图所示：

参看上图，我们可以发现 addl 指令，将寄存器 eax 和 ecx 中的值传给 ALU，ALU 对它们进行相加操纵，并将计算的结果写回 ecx。

还有一个非常重要的指令，是跳转指令，从指令本身抽取出一个字，这个字是下一条要执行的指令的地址，并将该字复制到 PC 寄存器中，并覆盖掉 PC 寄存器中原来的值。那么当执行下一条指令时，便会跳转到对应的指令了。

观察上图，上图是通过 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 是怎么执行一段二进制机器代码的吗？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

11 | 堆和栈：函数调用是如何影响到内存布局的？

你好，我是李兵。

相信你在使用 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 执行时的内存布局。

这节课，我们从函数特性入手，来一步步延伸出通用的函数调用模型，进而来分析不同的函数调用方式是如何影响到运行时内存布局的。

下图是本文的主要内容在编译流水线中的位置，因为解释执行和直接执行二进制代码都使用了堆和栈，虽然它们在执行细节上存在着一定的差异，但是整体的执行架构是类似的。

为什么使用栈结构来管理函数调用？

我们知道，大部分高级语言都不约而同地采用栈这种结构来管理函数调用，为什么呢？这与函数的特性有关。通常函数有两个主要的特性：

第一个特点是函数可以被调用，你可以在一个函数中调用另外一个函数，当函数调用发生时，执行代码的控制权将从父函数转移到子函数，子函数执行结束之后，又会将代码执行控制权返还给父函数；

第二个特点是函数具有作用域机制，所谓作用域机制，是指函数在执行的时候可以将定义在函数内部的变量和外部环境隔离，在函数内部定义的变量我们也称为临时变量，临时变量只能在该函数中被访问，外部函数通常无权访问，当函数执行结束之后，存放在内存中的临时变量也随之被销毁。

我们可以看下面这段 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);
}

观察上面这段代码，我们发现其中包含了多层函数嵌套调用，其实这个过程很简单，执行流程是这样的：

当 main 函数调用 add 函数时，需要将代码执行控制权交给 add 函数；

然后 add 函数又调用了 getZ 函数，于是又将代码控制权转交给 getZ 函数；

接下来 getZ 函数执行完成，需要将控制权返回给 add 函数；

同样当 add 函数执行结束之后，需要将控制权返还给 main 函数；

然后 main 函数继续向下执行。

具体的函数调用示意图如下：

函数调用示意图

通过上述分析，我们可以得出，函数调用者的生命周期总是长于被调用者（后进），并且被调用者的生命周期总是先于调用者的生命周期结束 (先出)。

在执行上述流程时，各个函数的生命周期如下图所示：

嵌套调用时函数的生命周期

因为函数是有作用域机制的，作用域机制通常表现在函数执行时，会在内存中分配函数内部的变量、上下文等数据，在函数执行完成之后，这些内部数据会被销毁掉。所以站在函数资源分配和回收角度来看，被调用函数的资源分配总是晚于调用函数 (后进)，而函数资源的释放则总是先于调用函数 (先出)。如下图所示：

函数资源分配流程

通过观察函数的生命周期和函数的资源分配情况，我们发现，它们都符合后进先出 (LIFO) 的策略，而栈结构正好满足这种后进先出 (LIFO) 的需求，所以我们选择栈来管理函数调用关系是一种很自然的选择。

关于栈，你可以结合这么一个贴切的例子来理解，一条单车道的单行线，一端被堵住了，而另一端入口处没有任何提示信息，堵住之后就只能后进去的车子先出来（后进先出），这时这个堵住的单行线就可以被看作是一个栈容器，车子开进单行线的操作叫做入栈，车子倒出去的操作叫做出栈。

在车流量较大的场景中，就会发生反复地入栈、栈满、出栈、空栈和再次入栈，一直循环。你可以参看下图：

数据结构层面的栈

栈如何管理函数调用？

了解了栈的特性之后，我们就来看看栈是如何管理函数调用的。

首先我们来分析最简单的场景：当执行一个函数的时候，栈怎么变化？

当一个函数被执行时，函数的参数、函数内部定义变量都会依次压入到栈中，我们结合实际的代码来分析下这个过程，你可以参考下图：

函数内部变量压栈状态

上图展示的是一段简单的 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) 时，当前栈的状态如下所示：

接下来，就要调用 add 函数了，理想状态下，执行 add 函数的过程是下面这样的：

当执行到 add 函数时，会先把参数 num1 和 num2 压栈，接着我们再把变量 x、y、ret 的值依次压栈，不过执行这里，会遇到一个问题，那就是当 add 函数执行完成之后，需要将执行代码的控制权转交给 main 函数，这意味着需要将栈的状态恢复到 main 函数上次执行时的状态，我们把这个过程叫恢复现场。那么应该怎么恢复 main 函数的执行现场呢？

其实方法很简单，只要在寄存器中保存一个永远指向当前栈顶的指针，栈顶指针的作用就是告诉你应该往哪个位置添加新元素，这个指针通常存放在 esp 寄存器中。如果你想往栈中添加一个元素，那么你需要先根据 esp 寄存器找到当前栈顶的位置，然后在栈顶上方添加新元素，新元素添加之后，还需要将新元素的地址更新到 esp 寄存器中。

有了栈顶指针，就很容易恢复 main 函数的执行现场了，当 add 函数执行结束时，只需要将栈顶指针向下移动就可以了，具体你可以参看下图：

add函数即将执行结束的状态

恢复mian函数执行现场

观察上图，将 esp 的指针向下移动到之前 main 函数执行时的地方就可以，不过新的问题又来了，CPU 是怎么知道要移动到这个地址呢？

CPU 的解决方法是增加了另外一个 ebp 寄存器，用来保存当前函数的起始位置，我们把一个函数的起始位置也称为栈帧指针，ebp 寄存器中保存的就是当前函数的栈帧指针，如下图所示：

ebp寄存器保存了栈帧指针

在 main 函数调用 add 函数的时候，main 函数的栈顶指针就变成了 add 函数的栈帧指针，所以需要将 main 函数的栈顶指针保存到 ebp 中，当 add 函数执行结束之后，我需要销毁 add 函数的栈帧，并恢复 main 函数的栈帧，那么只需要取出 main 函数的栈顶指针写到 esp 中即可 (main 函数的栈顶指针是保存在 ebp 中的)，这就相当于将栈顶指针移动到 main 函数的区域。

那么现在，我们可以执行 main 函数了吗？

答案依然是“不能”，这主要是因为 main 函数也有它自己的栈帧指针，在执行 main 函数之前，我们还需恢复它的栈帧指针。如何恢复 main 函数的栈帧指针呢？

通常的方法是在 main 函数中调用 add 函数时，CPU 会将当前 main 函数的栈帧指针保存在栈中，如下图所示：

当函数调用结束之后，就需要恢复 main 函数的执行现场了，首先取出 ebp 中的指针，写入 esp 中，然后从栈中取出之前保留的 main 的栈帧地址，将其写入 ebp 中，到了这里 ebp 和 esp 就都恢复了，可以继续执行 main 函数了。

另外在这里，我们还需要补充下栈帧的概念，因为在很多文章中我们会看到这个概念，每个栈帧对应着一个未运行完的函数，栈帧中保存了该函数的返回地址和局部变量。

以上我们详细分析了 C 函数的执行过程，在 JavaScript 中，函数的执行过程也是类似的，如果调用一个新函数，那么 V8 会为该函数创建栈帧，等函数执行结束之后，销毁该栈帧，而栈结构的容量是固定的，所有如果重复嵌套执行一个函数，那么就会导致栈会栈溢出。

了解了这些，现在我们再回过头来看下这节课开头提到的三段代码。

第一段代码由于循环嵌套调用了 foo，所以当函数运行时，就会导致 foo 函数会不断地调用 foo 函数自身，这样就会导致栈无限增，进而导致栈溢出的错误。

第二段代码是在函数内部使用了 setTimeout 来启动 foo 函数，这段代码之所以不会导致栈溢出，是因为 setTimeout 会使得 foo 函数在消息队列后面的任务中执行，所以不会影响到当前的栈结构。 也就不会导致栈溢出。关于消息队列和事件循环系统，我们会在最后一单元来介绍。

最后一段代码是 Promise，Promise 的情况比较特别，既不会造成栈溢出，但是这种方式会导致主线的卡死，这就涉及到了微任务，关于微任务在这里我们先不展开介绍，我会在微任务这一节来详细介绍。

既然有了栈，为什么还要堆？

好了，我们现在理解了栈是怎么管理函数调用的了，使用栈有非常多的优势：

栈的结构和非常适合函数调用过程。

在栈上分配资源和销毁资源的速度非常快，这主要归结于栈空间是连续的，分配空间和销毁空间只需要移动下指针就可以了。

虽然操作速度非常快，但是栈也是有缺点的，其中最大的缺点也是它的优点所造成的，那就是栈是连续的，所以要想在内存中分配一块连续的大空间是非常难的，因此栈空间是有限的。

因为栈空间是有限的，这就导致我们在编写程序的时候，经常一不小心就会导致栈溢出，比如函数循环嵌套层次太多，或者在栈上分配的数据过大，都会导致栈溢出，基于栈不方便存放大的数据，因此我们使用了另外一种数据结构用来保存一些大数据，这就是堆。

和栈空间不同，存放在堆空间中的数据是不要求连续存放的，从堆上分配内存块没有固定模式的，你可以在任何时候分配和释放它，为了更好地理解堆，我们看下面这段代码是怎么执行的：

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 函数快执行结束时，堆和栈的状态，具体内容你可以参看下图：

观察上图，我们可以发现，当使用 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()

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

12 | 延迟解析：V8是如何实现闭包的？

你好，我是李兵。

在第一节我们介绍过 V8 执行 JavaScript 代码，需要经过编译和执行两个阶段，其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段，而执行阶段则是指解释器解释执行字节码，或者是 CPU 直接执行二进制机器代码的阶段。总的流程你可以参看下图：

代码执行

在编译 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 在这个阶段只需要将该函数转换为函数对象，如下图所示：

注意，这里只是将该函数声明转换为函数对象，但是并有没有解析和编译函数内部的代码，所以也不会为 foo 函数的内部代码生成抽象语法树。

然后继续往下解析，由于后续的代码都是顶层代码，所以 V8 会为它们生成抽象语法树，最终生成的结果如下所示：

生成顶层代码的抽象语法树

代码解析完成之后，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 是如何查找变量的？》这节课，我介绍了词法作用域和词法作用域链，每个函数有自己的词法作用域，该函数中定义的变量都存在于该作用域中，然后 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 函数的时候，堆栈的变化如下图所示：

从上图可以看出来，在执行全局代码时，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 会分别分配到栈上？还是堆上？为什么？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这篇文章对你有所启发，也欢迎把它分享给你的朋友。

13 | 字节码（一）：V8为什么又重新引入字节码？

你好，我是李兵。

在第一节课我们就介绍了 V8 的编译流水线，我们知道 V8 在执行一段 JavaScript 代码之前，需要将其编译为字节码，然后再解释执行字节码或者将字节码编译为二进制代码然后再执行。

所谓字节码，是指编译过程中的中间代码，你可以把字节码看成是机器代码的抽象，在 V8 中，字节码有两个作用：

第一个是解释器可以直接解释执行字节码 ;

第二个是优化编译器可以将字节码编译为二进制代码，然后再执行二进制机器代码。

虽然目前的架构使用了字节码，不过早期的 V8 并不是这样设计的，那时候 V8 团队认为这种“先生成字节码再执行字节码”的方式，多了个中间环节，多出来的中间环节会牺牲代码的执行速度。

于是在早期，V8 团队采取了非常激进的策略，直接将 JavaScript 代码编译成机器代码。其执行流程如下图所示：

早期V8执行流水线

观察上面的执行流程图，我们可以发现，早期的 V8 也使用了两个编译器：

第一个是基线编译器，它负责将 JavaScript 代码编译为没有优化过的机器代码。

第二个是优化编译器，它负责将一些热点代码（执行频繁的代码）优化为执行效率更高的机器代码。

了解这两个编译器之后，接下来我们再来看看早期的 V8 是怎么执行一段 JavaScript 代码的。

首先，V8 会将一段 JavaScript 代码转换为抽象语法树 (AST)。

接下来基线编译器会将抽象语法树编译为未优化过的机器代码，然后 V8 直接执行这些未优化过的机器代码。

在执行未优化的二进制代码过程中，如果 V8 检测到某段代码重复执行的概率过高，那么 V8 会将该段代码标记为 HOT，标记为 HOT 的代码会被优化编译器优化成执行效率高的二进制代码，然后就执行该段优化过的二进制代码。

不过如果优化过的二进制代码并不能满足当前代码的执行，这也就意味着优化失败，V8 则会执行反优化操作。

以上就是早期的 V8 执行一段 JavaScript 代码的流程，不过最近发布的 V8 已经抛弃了直接将 JavaScript 代码编译为二进制代码的方式，也抛弃了这两个编译器，进而使用了字节码 + 解释器 + 编译器方式，也就是我们在第一节课介绍的形式。

早期的 V8 之所以抛弃中间形式的代码，直接将 JavaScript 代码编译成机器代码，是因为机器代码的执行性能非常高效，但是最新版本却朝着执行性能相反的方向进化，那么这是出于什么原因呢？

机器代码缓存

当 JavaScript 代码在浏览器中被执行的时候，需要先被 V8 编译，早期的 V8 会将 JavaScript 编译成未经优化的二进制机器代码，然后再执行这些未优化的二进制代码，通常情况下，编译占用了很大一部分时间，下面是一段代码的编译和执行时间图：

从图中可以看出，编译所消耗的时间和执行所消耗的时间是差不多的，试想一下，如果在浏览器中再次打开相同的页面，当页面中的 JavaScript 文件没有被修改，那么再次编译之后的二进制代码也会保持不变， 这意味着编译这一步白白浪费了 CPU 资源，因为之前已经编译过一次了。

这就是 Chrome 浏览器引入二进制代码缓存的原因，通过把二进制代码保存在内存中来消除冗余的编译，重用它们完成后续的调用，这样就省去了再次编译的时间。

V8 使用两种代码缓存策略来缓存生成的代码。

首先，是 V8 第一次执行一段代码时，会编译源 JavaScript 代码，并将编译后的二进制代码缓存在内存中，我们把这种方式称为内存缓存（in-memory cache)。然后通过 JavaScript 源文件的字符串在内存中查找对应的编译后的二进制代码。这样当再次执行到这段代码时，V8 就可以直接去内存中查找是否编译过这段代码。如果内存缓存中存在这段代码所对应的二进制代码，那么就直接执行编译好的二进制代码。

其次，V8 除了采用将代码缓存在内存中策略之外，还会将代码缓存到硬盘上，这样即便关闭了浏览器，下次重新打开浏览器再次执行相同代码时，也可以直接重复使用编译好的二进制代码。

二进制代码缓存

实践表明，在浏览器中采用了二进制代码缓存的方式，初始加载时分析和编译的时间缩短了 20%～40%。

字节码降低了内存占用

所以在早期，Chrome 做了两件事来提升 JavaScript 代码的执行速度：

第一，将运行时将二进制机器代码缓存在内存中；

第二，当浏览器退出时，缓存编译之后二进制代码到磁盘上。

很明显，采用缓存是一种典型的以空间换时间的策略，以牺牲存储空间来换取执行速度，我们知道 Chrome 的多进程架构已经非常吃内存了，而 Chrome 中每个页面进程都运行了一份 V8 实例，V8 在执行 JavaScript 代码的过程中，会将 JavaScript 代码转换为未经优化的二进制代码，你可以对照下图中的 JavaScript 代码和二进制代码的：

从上图我们可以看出，二进制代码所占用的内存空间是 JavaScript 代码的十几倍，通常一个页面的 JavaScript 几 M 大小，转换为二进制代码就变成几十 M 了，如果是 PC 应用，多占用一些内存，也不会太影响性能，但是在移动设备流行起来之后，V8 过度占用内存的问题就充分暴露出来了。因为通常一部手机的内存不会太大，如果过度占用内存，那么会导致 Web 应用的速度大大降低。

在上一节我们介绍过，V8 团队为了提升 V8 的启动速度，采用了惰性编译，其实惰性编译除了能提升 JavaScript 启动速度，还可以解决部分内存占用的问题。你可以先参看下面的代码：

根据惰性编译的原则，当 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 在执行时的内存占用呢？要解释这个问题，我们不妨看下面这张图：

从图中可以看出，字节码虽然占用的空间比原始的 JavaScript 多，但是相较于机器代码，字节码还是小了太多。

有了字节码，无论是解释器的解释执行，还是优化编译器的编译执行，都可以直接针对字节来进行操作。由于字节码占用的空间远小于二进制代码，所以浏览器就可以实现缓存所有的字节码，而不是仅仅缓存顶层的字节码。

虽然采用字节码在执行速度上稍慢于机器代码，但是整体上权衡利弊，采用字节码也许是最优解。之所以说是最优解，是因为采用字节码除了降低内存之外，还提升了代码的启动速度，并降低了代码的复杂度，而牺牲的仅仅是一点执行效率。接下来我们继续来分析下，采用字节码是怎么提升代码启动速度和降低复杂度的。

字节码如何提升代码启动速度？

我们先看引入字节码是怎么提升代码启动速度的。下面是启动 JavaScript 代码的流程图：

从图中可以看出，生成机器代码比生成字节码需要花费更久的时间，但是直接执行机器代码却比解释执行字节码要更高效，所以在快速启动 JavaScript 代码与花费更多时间获得最优运行性能的代码之间，我们需要找到一个平衡点。

解释器可以快速生成字节码，但字节码通常效率不高。 相比之下，优化编译器虽然需要更长的时间进行处理，但最终会产生更高效的机器码，这正是 V8 在使用的模型。它的解释器叫 Ignition，（就原始字节码执行速度而言）是所有引擎中最快的解释器。V8 的优化编译器名为 TurboFan，最终由它生成高度优化的机器码。

字节码如何降低代码的复杂度？

早期的 V8 代码，无论是基线编译器还是优化编译器，它们都是基于 AST 抽象语法树来将代码转换为机器码的，我们知道，不同架构的机器码是不一样的，而市面上存在不同架构的处理器又是非常之多，你可以参看下图：

这意味着基线编译器和优化编译器要针对不同的体系的 CPU 编写不同的代码，这会大大增加代码量。

引入了字节码，就可以统一将字节码转换为不同平台的二进制代码，你可以对比下执行流程：

因为字节码的执行过程和 CPU 执行二进制代码的过程类似，相似的执行流程，那么将字节码转换为不同架构的二进制代码的工作量也会大大降低，这就降低了转换底层代码的工作量。

总结

这节课我们介绍了 V8 为什么要引入字节码。早期的 V8 为了提升代码的执行速度，直接将 JavaScript 源代码编译成了没有优化的二进制的机器代码，如果某一段二进制代码执行频率过高，那么 V8 会将其标记为热点代码，热点代码会被优化编译器优化，优化后的机器代码执行效率更高。

不过随着移动设备的普及，V8 团队逐渐发现将 JavaScript 源码直接编译成二进制代码存在两个致命的问题：

时间问题：编译时间过久，影响代码启动速度；

空间问题：缓存编译后的二进制代码占用更多的内存。

这两个问题无疑会阻碍 V8 在移动设备上的普及，于是 V8 团队大规模重构代码，引入了中间的字节码。字节码的优势有如下三点：

解决启动问题：生成字节码的时间很短；

解决空间问题：字节码占用内存不多，缓存字节码会大大降低内存的使用；

代码架构清晰：采用字节码，可以简化程序的复杂度，使得 V8 移植到不同的 CPU 架构平台更加容易。

思考题

今天留给你一个开放的思考题：你认为 V8 虚拟机中的机器代码和字节码有哪些异同？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

14｜字节码（二）：解释器是如何解释执行字节码的？

你好，我是李兵。

在上节我们介绍了 V8 为什么要引入字节码，这节课我们来聊聊解释器是如何解释执行字节码的。学习字节码如何被执行，可以让我们理解解释器的工作机制，同时还能帮助我们搞懂 JavaScript 运行时的内存结构，特别是闭包的结构和非闭包数据的区别。

字节码的解释执行在编译流水线中的位置你可以参看下图：

如何生成字节码？

我们知道当 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"

同样，我们将其图形化：

从图中可以看出，函数的字面量被解析为 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，在执行阶段，作用域中的变量会指向堆和栈中相应的数据，作用域和实际数据的关系如下图所示：

在解析期间，所有函数体中声明的变量和函数参数，都被放进作用域中，如果是普通变量，那么默认值是 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 的功能，现在我们大致了解了字节码就是一些基础的功能模块，接下来我们就来认识下这些构建块。

下图是一些常用的“积木块”，我们又称为字节码的指令集：

V8中定义的部分字节码指令集

你也可以去V8 的源码中查看这些字节码，V8 字节码的指令非常多，如果要掌握所有指令的含义，需要花费一段时间的学习和实践，这节课我们不需要了解所有字节码的含义，但我们需要知道，怎样阅读字节码。

我们阅读汇编代码，需要先理解 CPU 的体系架构，然后再分析特定汇编指令的具体含义，同样，要了解怎么阅读字节码，我们就需要理解 V8 解释器的整体设计架构，然后再来分析特定的字节码指令的含义。接下来，我们就依次介绍这两部分内容。

因为解释器就是模拟物理机器来执行字节码的，比如可以实现如取指令、解析指令、执行指令、存储数据等，所以解释器的执行架构和 CPU 处理机器代码的架构类似（关于 CPU 是如何执行机器代码的，你可以参看《10｜机器代码：二进制机器码究竟是如何被 CPU 执行的？》这节课）。

通常有两种类型的解释器，基于栈 (Stack-based)和基于寄存器 (Register-based)，基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等，基于寄存器的虚拟机则支持寄存器的指令操作，使用寄存器来保存参数、中间计算结果。

通常，基于栈的虚拟机也定义了少量的寄存器，基于寄存器的虚拟机也有堆栈，其区别体现在它们提供的指令集体系。

大多数解释器都是基于栈的，比如 Java 虚拟机，.Net 虚拟机，还有早期的 V8 虚拟机。基于堆栈的虚拟机在处理函数调用、解决递归问题和切换上下文时简单明快。

而现在的 V8 虚拟机则采用了基于寄存器的设计，它将一些中间数据保存到寄存器中，了解这点对于我们分析字节码的执行过程非常重要。

接下来我们就来看看基于寄存器的解释器架构，具体你可以参考下图：

解释器执行时主要有四个模块，内存中的字节码、寄存器、栈、堆。

这和我们介绍过的 CPU 执行二进制机器代码的模式是类似的：

使用内存中的一块区域来存放字节码；

使用了通用寄存器 r0，r1，r2，…… 这些寄存器用来存放一些中间数据；

PC 寄存器用来指向下一条要执行的字节码；

栈顶寄存器用来指向当前的栈顶的位置。

但是我们需要重点注意这里的累加器，它是一个非常特殊的寄存器，用来保存中间的结果，这体现在很多 V8 字节码的语义上面，我们来看下面这个字节码的指令：

Ldar a1

Ldar 表示将寄存器中的值加载到累加器中，你可以把它理解为 LoaD Accumulator from Register，就是把某个寄存器中的值，加载到累加器中。那么上面这个指令的意思就是把 a1 寄存器中的值，加载到累加器中，你可以参看下图：

我们再来看另外一个段字节码指令：

Star r0

Star 表示 Store Accumulator Register， 你可以把它理解为 Store Accumulator to Register，就是把累加器中的值保存到某个寄存器中，上面这段代码的意思就是将累加器中的数值保存到 r0 寄存器中，具体流程你可以参看下图：

我们再来看一个执行加法的字节码：

Add a0, [0]

Add a0, [0]是从 a0 寄存器加载值并将其与累加器中的值相加，然后将结果再次放入累加器，最终操作如下图所示：

你可能会注意到，add a0 后面还跟了一个[0]，这个符号是做什么的呢？

这个称之为 feedback vector slot，中文我们可以称为反馈向量槽，它是一个数组，解释器将解释执行过程中的一些数据类型的分析信息都保存在这个反馈向量槽中了，目的是为了给 TurboFan 优化编译器提供优化信息，很多字节码都会为反馈向量槽提供运行时信息，这块内容我们会在下一节来介绍。

在上面的字节码中，还有一个：

LdaSmi [2]

这是将小整数（Smi）2 加载到累加器寄存器中，操作流程你可以参看下图：

我们再来看一个字节码：

Return

Return 结束当前函数的执行，并将控制权传回给调用方。返回的值是累加器中的值。

好了，上面我们分析了几个常见的字节码的含义，相信你已经发现了，大部分字节码都间接地使用了累加器，认识到累加器在字节码指令中的使用方式之后，再去认识 V8 中的字节码就会非常轻松了。

完整分析一段字节码

接下来，我们完整地分析一段字节码是怎么执行的：

StackCheck
Ldar a1
Add a0, [0]
Star r0
LdaSmi [2]
Star r1
Ldar r0
Return

执行这段代码时，整体的状态如下图所示：

我们可以看到：

参数对象 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 生成字节码，然后分析字节码的执行流程，欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

15 | 隐藏类：如何在内存中快速查找对象属性？

你好，我是李兵。

我们知道 JavaScript 是一门动态语言，其执行效率要低于静态语言，V8 为了提升 JavaScript 的执行速度，借鉴了很多静态语言的特性，比如实现了 JIT 机制，为了提升对象的属性访问速度而引入了隐藏类，为了加速运算而引入了内联缓存。

今天我们来重点分析下 V8 中的隐藏类，看看它是怎么提升访问对象属性值速度的。

为什么静态语言的效率更高？

由于隐藏类借鉴了部分静态语言的特性，因此要解释清楚这个问题，我们就先来分析下为什么静态语言比动态语言的执行效率更高。

我们通过下面两段代码，来对比一下动态语言和静态语言在运行时的一些特征，一段是动态语言的 JavaScript，另外一段静态语言的 C++ 的源码，具体源码你可以参看下图：

那么在运行时，这两段代码的执行过程有什么区别呢？

我们知道，JavaScript 在运行时，对象的属性是可以被修改的，所以当 V8 使用了一个对象时，比如使用了 start.x 的时候，它并不知道该对象中是否有 x，也不知道 x 相对于对象的偏移量是多少，也可以说 V8 并不知道该对象的具体的形状。

那么，当在 JavaScript 中要查询对象 start 中的 x 属性时，V8 会按照具体的规则一步一步来查询，这个过程非常的慢且耗时（具体查找过程你可以参考《03｜快属性和慢属性：V8 采用了哪些策略提升了对象属性的访问速度？》这节课程中的内容）。

这种动态查询对象属性的方式和 C++ 这种静态语言不同，C++ 在声明一个对象之前需要定义该对象的结构，我们也可以称为形状，比如 Point 结构体就是一种形状，我们可以使用这个形状来定义具体的对象。

C++ 代码在执行之前需要先被编译，编译的时候，每个对象的形状都是固定的，也就是说，在代码的执行过程中，Point 的形状是无法被改变的。

那么在 C++ 中访问一个对象的属性时，自然就知道该属性相对于该对象地址的偏移值了，比如在 C++ 中使用 start.x 的时候，编译器会直接将 x 相对于 start 的地址写进汇编指令中，那么当使用了对象 start 中的 x 属性时，CPU 就可以直接去内存地址中取出该内容即可，没有任何中间的查找环节。

因为静态语言中，可以直接通过偏移量查询来查询对象的属性值，这也就是静态语言的执行效率高的一个原因。

什么是隐藏类 (Hiden 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。

注意，这是 point 对象的 map，它不是 point 对象本身。关于 point 对象和 map 之间的关系，你可以参看下图：

在这张图中，左边的是 point 对象在内存中的布局，右边是 point 对象的 map，我们可以看到，point 对象的第一个属性就指向了它的 map，关于如何通过浏览器查看对象的 map，我们在《03｜快属性和慢属性：V8 采用了哪些策略提升了对象属性的访问速度？》这节课也做过简单的分析，你可以回顾下这节内容。

有了 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 采用了哪些策略提升了对象属性的访问速度？》和《05｜原型链：V8 是如何实现对象继承的？》这两节的内容）。

多个对象共用一个隐藏类

现在我们知道了在 V8 中，每个对象都有一个 map 属性，该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的，V8 就会为其复用同一个隐藏类，这样有两个好处：

减少隐藏类的创建次数，也间接加速了代码的执行速度；

减少了隐藏类的存储空间。

那么，什么情况下两个对象的形状是相同的，要满足以下两点：

相同的属性名称；

相等的属性个数。

接下来我们就来创建两个形状一样的对象，然后看看它们的 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 复用，具体效果你可以参看下图：

你也可以使用 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)

根据这个打印出来的结果，我们可以明显看到，每次给对象添加了一个新属性之后，该对象的隐藏类的地址都会改变，这也就意味着隐藏类也随着改变了，改变过程你可以参看下图：

同样，如果你删除了对象的某个属性，那么对象的形状也就随着发生了改变，这时 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 函数的执行效率？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

16 | 答疑： V8是怎么通过内联缓存来提升函数执行效率的？

你好，我是李兵。

上节我们留了个思考题，提到了一段代码是这样的：

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)，反馈向量记录了函数在执行过程中的一些关键的中间数据。关于函数和反馈向量的关系你可以参看下图：

反馈向量其实就是一个表结构，它由很多项组成的，每一项称为一个插槽 (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 地址是一样的。

了解了反馈向量的大致结构，我们再来看下当 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 表示写入反馈向量的第一个插槽中。具体你可以参看下图：

观察上图，我们可以看出，在函数 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

下图是我画的这段字节码的执行流程：

从图中可以看出，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) 类型的操作，我们在上面介绍过的。最终生成的反馈向量如下图所示：

现在有了反馈向量缓存的数据，那 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 会选择将新的隐藏类也记录在反馈向量中，同时记录属性值的偏移量，这时，反馈向量中的第一个槽里就包含了两个隐藏类和偏移量。具体你可以参看下图：

当 V8 再次执行 loadX 函数中的 o.x 语句时，同样会查找反馈向量表，发现第一个槽中记录了两个隐藏类。这时，V8 需要额外做一件事，那就是拿这个新的隐藏类和第一个插槽中的两个隐藏类来一一比较，如果新的隐藏类和第一个插槽中某个隐藏类相同，那么就使用该命中的隐藏类的偏移量。如果没有相同的呢？同样将新的信息添加到反馈向量的第一个插槽中。

现在我们知道了，一个反馈向量的一个插槽中可以包含多个隐藏类的信息，那么：

如果一个插槽中只包含 1 个隐藏类，那么我们称这种状态为单态 (monomorphic)；

如果一个插槽中包含了 2～4 个隐藏类，那我们称这种状态为多态 (polymorphic)；

如果一个插槽中超过 4 个隐藏类，那我们称这种状态为超态 (magamorphic)。

如果函数 loadX 的反馈向量中存在多态或者超态的情况，其执行效率肯定要低于单态的，比如当执行到 o.x 的时候，V8 会查询反馈向量的第一个插槽，发现里面有多个 map 的记录，那么 V8 就需要取出 o 的隐藏类，来和插槽中记录的隐藏类一一比较，如果记录的隐藏类越多，那么比较的次数也就越多，这就意味着执行效率越低。

比如插槽中包含了 2～4 个隐藏类，那么可以使用线性结构来存储，如果超过 4 个，那么 V8 会采取 hash 表的结构来存储，这无疑会拖慢执行效率。单态、多态、超态等三种情况的执行性能如下图所示：

尽量保持单态

这就是 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())

你认为这两段代码，哪段的执行效率高，为什么？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

17 | 消息队列：V8是怎么实现回调函数的？

你好，我是李兵。

我们在使用 JavaScript 时，经常要用到大量的回调函数，比如在浏览器中可以使用 setTimeout 来设置定时器，使用 XMLHTTPRequest 来异步下载资源文件，在 Node 中可以使用 readFile 来读取文件，这些操作都有一个共同的特点，那就是需要给调用 API 传入回调函数，然后浏览器或者 Node 会将执行处理的结果通过回调函数来触发。

从内部了解回调函数，可以帮助我们梳理清楚很多问题：

有助于我们理解浏览器中的 Web API 到底是怎么工作的；

有助于我们理解宏任务和微任务到底有哪些区别；

理解回调函数，是理解异步编程模型 async/await 的基础。

这些内容在我们实际的项目中都会频繁使用到，所以理解 V8 是怎么实现回调函数的就显得至关重要了。

什么是回调函数？

那究竟什么是回调函数呢？其实回调函数也是个函数，就像白马也是马一样。它具有函数的所有特征，它可以有参数和返回值。如果单独给出一个函数，你是看不出来它是不是回调函数的。回调函数区别于普通函数，在于它的调用方式。只有当某个函数被作为参数，传递给另外一个函数，或者传递给宿主环境，然后该函数在函数内部或者在宿主环境中被调用，我们才称为回调函数。

具体地讲，回调函数有两种不同的形式，同步回调和异步回调。通常，我们需要将回调函数传入给另外一个执行函数，那么同步回调和异步回调的最大区别在于同步回调函数是在执行函数内部被执行的，而异步回调函数是在执行函数外部被执行的。

我们先看一个同步回调的例子，你可以先看下面这段代码：

var myArray = ["water", "goods", "123", "like"];
function handlerArray(indexName,index){
    console.log(index + 1 + ". " + indexName);
}
myArray.forEach(handlerArray)

在这段代码中，我们通过 JavaScript 自带的 forEach 方法来枚举数字中的每个项，这里的逻辑很简单：

调用 forEach 时，需要使用回调函数 handlerArray 作为其参数；

在 forEach 方法内部，会遍历 myArray 数组，每遍历一次都会调用一次回调函数 handlerArray。

因为 handlerArray 是 forEach 的参数，而且 handlerArray 是在 forEach 函数内部执行，所以这是一个同步回调。

和同步回调函数不同的是，异步回调函数并不是在它的执行函数内部被执行的，而是在其他的位置和其他的时间点被执行的，比如下面这段 setTimeout 代码：

function foo() {
    alert("Hello");
}
setTimeout(foo, 3000)

在这段代码中，我们使用了 setTimeout 函数，setTimeout 的第一个参数 foo 就是一个回调函数，V8 执行 setTimeout 时，会立即返回，等待 3000 毫秒之后，foo 函数才会被 V8 调用，foo 函数并不是在 setTimeout 函数内部被执行的，所以这是一个异步回调。

对于同步回调函数的执行时机，我们理解起来比较简单，就是回调函数在执行函数内部被执行，那么异步回调函数在什么时机和什么位置被调用的呢？

要解释清楚这个问题，我们就需要了解 V8 在运行时的线程模型，因为这涉及到了消息队列，事件循环等概念，这些概念都和线程模型是直接相关的，所以接下来我们就先来分析下 V8 的线程架构模型。

UI 线程的宏观架构

早期浏览器的页面是运行在一个单独的 UI 线程中的，所以要在页面中引入 JavaScript，那么 JavaScript 也必须要运行在和页面相同的线程上，这样才能方便使用 JavaScript 来操纵 DOM，所以从一开始，JavaScript 就被设计成了运行在 UI 线程中。

所谓 UI 线程，是指运行窗口的线程，当你运行一个窗口时，无论该页面是 Windows 上的窗口系统，还是 Android 或者 iOS 上的窗口系统，它们都需要处理各种事件，诸如有触发绘制页面的事件，有鼠标点击、拖拽、放大缩小的事件，有资源下载、文件读写的事件，等等。

在页面线程中，当一个事件被触发时，比如用户使用鼠标点击了页面，系统需要将该事件提交给 UI 线程来处理。

在大部分情况下，UI 线程并不能立即响应和处理这些事件，比如在你在移动鼠标的过程中，每移动一个像素都会产生一个事件，所以鼠标移动的事件会频繁地被触发。在这种情况下，页面线程可能正在处理前一个事件，那么最新的事件就无法被立即执行。

针对这种情况，我们为 UI 线程提供一个消息队列，并将这些待执行的事件添加到消息队列中，然后 UI 线程会不断循环地从消息队列中取出事件、执行事件。我们把 UI 线程每次从消息队列中取出事件，执行事件的过程称为一个任务。整个流程大致如下所示：

通用UI线程架构

我们可以用一段 JavaScript 代码来模拟下这个过程：

function UIMainThread() {
    while (queue.waitForMessage()) {
        Task task = queue.getNext()
        processNextMessage(task)
    }
}

在这段代码中，queue 是消息队列，queue.waitForMessage() 会同步地等待消息队列中的消息到达，如果当前没有任何消息等待被处理，则这个函数会将 UI 线程挂起。如果消息队列中有消息，则使用 queue.getNext() 取出下一个要执行的消息，并交由 processNextMessage 函数来处理消息。

这就是通用的 UI 线程的结构，有消息队列，通过鼠标、键盘、触控板等产生的消息都会被添加进消息队列，主线程会循环地从消息队列中取出消息并执行。

异步回调函数的调用时机

理解了 UI 线程的基础架构模型，下面我们就可以来解释下异步函数的执行时机了，比如在页面主线程中正在执行 A 任务，在执行 A 任务的过程中调用 setTimeout(foo, 3000)，在执行 setTimeout 函数的过程中，宿主就会将 foo 函数封装成一个事件，并添加到消息队列中，然后 setTimeout 函数执行结束。

主线程会不间断地从消息队列中取出新的任务，执行新的任务，等到时机合适，便取出 setTimeout 设置的 foo 函数的回调的任务，然后就可以直接执行 foo 函数的调用了。

通过分析，相信你已经发现了，通过 setTimeout 的执行流程其实是比较简单的，在 setTimeout 函数内部封装回调消息，并将回调消息添加进消息队列，然后主线程从消息队列中取出回调事件，并执行。

还有一类比较复杂一点的流程，最典型的是通过 XMLHttpRequest 所触发的回调，它和 setTimeout 有一些区别。

因为 XMLHttpRequest 是用来下载网络资源的，但是实际的下载过程却并不适合在主线程上执行，因为下载任务会消耗比较久的时间，如果在 UI 线程上执行，那么会阻塞 UI 线程，这就会拖慢 UI 界面的交互和绘制的效果。所以当主线程从消息队列中取出来了这类下载任务之后，会将其分配给网络线程，让其在网络线程上执行下载过程，这样就不会影响到主线程的执行了。

那么下面我们就来分析下 XMLHttpRequest 是怎么触发回调函数的？具体流程你可以参看下图：

处理下载事件

结合上图，我们就可以来分析下通用的 UI 线程是如何处理下载事件的，大致可以分为以下几步：

UI 线程会从消息队列中取出一个任务，并分析该任务。

分析过程中发现该任务是一个下载请求，那么主线程就会将该任务交给网络线程去执行。

网络线程接到请求之后，便会和服务器端建立连接，并发出下载请求；

网络线程不断地收到服务器端传过来的数据；

网络线程每次接收到数据时，都会将设置的回调函数和返回的数据信息，如大小、返回了多少字节、返回的数据在内存中存放的位置等信息封装成一个新的事件，并将该事件放到消息队列中 ;

UI 线程继续循环地读取消息队列中的事件，如果是下载状态的事件，那么 UI 线程会执行回调函数，程序员便可以在回调函数内部编写更新下载进度的状态的代码；

直到最后接收到下载结束事件，UI 线程会显示该页面下载完成。

这就是 XMLHttpRequest 所触发的回调流程，除了下载以外，JavaScript 中获取系统设备信息、文件读取等都是采用了类似的方式来实现的，因此，理解了 XMLHttpRequest 的执行流程，你也就理解了这一类异步 API 的执行流程了。

总结

今天我们介绍了 V8 是如何执行回调函数的。回调函数有两种类型：同步回调和异步回调，同步回调函数是在执行函数内部被执行的，而异步回调函数是在执行函数外部被执行的。

那么，搞清楚异步回调函数在什么时机被执行就非常关键了。为了理清楚这个问题，我们分析了通用 UI 线程宏观架构。UI 线程提供一个消息队列，并将待执行的事件添加到消息队列中，然后 UI 线程会不断循环地从消息队列中取出事件、执行事件。

关于异步回调，这里也有两种不同的类型，其典型代表是 setTimeout 和 XMLHttpRequest。

setTimeout 的执行流程其实是比较简单的，在 setTimeout 函数内部封装回调消息，并将回调消息添加进消息队列，然后主线程从消息队列中取出回调事件，并执行回调函数。

XMLHttpRequest 稍微复杂一点，因为下载过程需要放到单独的一个线程中去执行，所以执行 XMLHttpRequest.send 的时候，宿主会将实际请求转发给网络线程，然后 send 函数退出，主线程继续执行下面的任务。网络线程在执行下载的过程中，会将一些中间信息和回调函数封装成新的消息，并将其添加进消息队列中，然后主线程从消息队列中取出回调事件，并执行回调函数。

思考题

分析 Node 中的 readFileSync 和 readFile 函数，其中一个是同步读文件操作，另外一个是异步读文件操作，这两段代码如下所示：

var fs = require('fs')
var data = fs.readFileSync('test.js')

fs.readFile('test.txt', function(err, data){
    data.toString()
})

那么请你分别分析下它们的执行流程。欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

18 | 异步编程（一）：V8是如何实现微任务的？

你好，我是李兵。

上节我们介绍了通用的 UI 线程架构，每个 UI 线程都拥有一个消息队列，所有的待执行的事件都会被添加进消息队列中，UI 线程会按照一定规则，循环地取出消息队列中的事件，并执行事件。而 JavaScript 最初也是运行在 UI 线程中的。换句话说，JavaScript 语言就是基于这套通用的 UI 线程架构而设计的。

基于这套基础 UI 框架，JavaScript 又延伸出很多新的技术，其中应用最广泛的当属宏任务和微任务。

宏任务很简单，就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时，V8 都会重新创建栈，然后随着宏任务中函数调用，栈也随之变化，最终，当该宏任务执行结束时，整个栈又会被清空，接着主线程继续执行下一个宏任务。

微任务稍微复杂一点，其实你可以把微任务看成是一个需要异步执行的函数，执行时机是在主函数执行结束之后、当前宏任务结束之前。

JavaScript 中之所以要引入微任务，主要是由于主线程执行消息队列中宏任务的时间颗粒度太粗了，无法胜任一些对精度和实时性要求较高的场景，那么微任务可以在实时性和效率之间做一个有效的权衡。另外使用微任务，可以改变我们现在的异步编程模型，使得我们可以使用同步形式的代码来编写异步调用。

虽然微任务如此重要，但是理解起来并不是太容易。我们先看下和微任务相关的知识栈，具体内容如下图所示：

从图中可以看出，微任务是基于消息队列、事件循环、UI 主线程还有堆栈而来的，然后基于微任务，又可以延伸出协程、Promise、Generator、await/async 等现代前端经常使用的一些技术。也就是说，如果对消息队列、主线程还有调用栈理解的不够深入，你在研究微任务时，就容易一头雾水。

今天，我们就先来打通微任务的底层技术，搞懂消息队列、主线程、调用栈的关联，然后抽丝剥茧地剖析微任务的实现机制。

主线程、调用栈、消息队列

我们先从主线程和调用栈开始分析。我们知道，调用栈是一种数据结构，用来管理在主线程上执行的函数的调用关系。接下来我们通过执行下面这段代码，来分析下调用栈是如何管理主线程上函数调用的。

function bar() {
}
foo(fun){
  fun()
}
foo(bar)

当 V8 准备执行这段代码时，会先将全局执行上下文压入到调用栈中，如下图所示：

然后 V8 便开始在主线程上执行 foo 函数，首先它会创建 foo 函数的执行上下文，并将其压入栈中，那么此时调用栈、主线程的关系如下图所示：

准备执行

然后，foo 函数又调用了 bar 函数，那么当 V8 执行 bar 函数时，同样要创建 bar 函数的执行上下文，并将其压入栈中，最终效果如下图所示：

调用bar函数

等 bar 函数执行结束，V8 就会从栈中弹出 bar 函数的执行上下文，此时的效果如下所示：

bar函数执行结束

最后，foo 函数执行结束，V8 会将 foo 函数的执行上下文从栈中弹出，效果如下所示：

foo函数执行结束

以上就是调用栈管理主线程上函数调用的方式，不过，这种方式会带来一种问题，那就是栈溢出。比如下面这段代码：

function foo(){
  foo()
}
foo()

由于 foo 函数内部嵌套调用它自己，所以在调用 foo 函数的时候，它的栈会一直向上增长，但是由于栈空间在内存中是连续的，所以通常我们都会限制调用栈的大小，如果当函数嵌套层数过深时，过多的执行上下文堆积在栈中便会导致栈溢出，最终如下图所示：

栈溢出

我们可以使用 setTimeout 来解决栈溢出的问题，setTimeout 的本质是将同步函数调用改成异步函数调用，这里的异步调用是将 foo 封装成事件，并将其添加进消息队列中，然后主线程再按照一定规则循环地从消息队列中读取下一个任务。使用 setTimeout 改造后代码代码如下所示：

function foo() {
  setTimeout(foo, 0)
}
foo()

那么现在我们就可以从调用栈、主线程、消息队列这三者的角度来分析这段代码的执行流程了。

首先，主线程会从消息队列中取出需要执行的宏任务，假设当前取出的任务就是要执行的这段代码，这时候主线程便会进入代码的执行状态。这时关于主线程、消息队列、调用栈的关系如下图所示：

接下来 V8 就要执行 foo 函数了，同样执行 foo 函数时，会创建 foo 函数的执行上下文，并将其压入栈中，最终效果如下图所示：

当 V8 执行执行 foo 函数中的 setTimeout 时，setTimeout 会将 foo 函数封装成一个新的宏任务，并将其添加到消息队列中，在 V8 执行 setTimeout 函数时的状态图如下所示：

等 foo 函数执行结束，V8 就会结束当前的宏任务，调用栈也会被清空，调用栈被清空后状态如下图所示：

当一个宏任务执行结束之后，忙碌的主线程依然不会闲下来，它会一直重复这个取宏任务、执行宏任务的过程。刚才通过 setTimeout 封装的回调宏任务，也会在某一时刻被主线取出并执行，这个执行过程，就是 foo 函数的调用过程。具体示意图如下所示：

因为 foo 函数并不是在当前的父函数内部被执行的，而是封装成了宏任务，并丢进了消息队列中，然后等待主线程从消息队列中取出该任务，再执行该回调函数 foo，这样就解决了栈溢出的问题。

微任务解决了宏任务执行时机不可控的问题

不过，对于栈溢出问题，虽然我们可以通过将某些函数封装成宏任务的方式来解决，但是宏任务需要先被放到消息队列中，如果某些宏任务的执行时间过久，那么就会影响到消息队列后面的宏任务的执行，而且这个影响是不可控的，因为你无法知道前面的宏任务需要多久才能执行完成。

于是 JavaScript 中又引入了微任务，微任务会在当前的任务快要执行结束时执行，利用微任务，你就能比较精准地控制你的回调函数的执行时机。

通俗地理解，V8 会为每个宏任务维护一个微任务队列。当 V8 执行一段 JavaScript 时，会为这段代码创建一个环境对象，微任务队列就是存放在该环境对象中的。当你通过 Promise.resolve 生成一个微任务，该微任务会被 V8 自动添加进微任务队列，等整段代码快要执行结束时，该环境对象也随之被销毁，但是在销毁之前，V8 会先处理微任务队列中的微任务。

理解微任务的执行时机，你只需要记住以下两点：

首先，如果当前的任务中产生了一个微任务，通过 Promise.resolve() 或者 Promise.reject() 都会触发微任务，触发的微任务不会在当前的函数中被执行，所以执行微任务时，不会导致栈的无限扩张；

其次，和异步调用不同，微任务依然会在当前任务执行结束之前被执行，这也就意味着在当前微任务执行结束之前，消息队列中的其他任务是不可能被执行的。

因此在函数内部触发的微任务，一定比在函数内部触发的宏任务要优先执行。为了验证这个观点，我们来分析一段代码：

function bar(){
  console.log('bar')
  Promise.resolve().then(
    (str) =>console.log('micro-bar')
  )
  setTimeout((str) =>console.log('macro-bar'),0)
}

function foo() {
  console.log('foo')
  Promise.resolve().then(
    (str) =>console.log('micro-foo')
  )
  setTimeout((str) =>console.log('macro-foo'),0)

  bar()
}
foo()
console.log('global')
Promise.resolve().then(
  (str) =>console.log('micro-global')
)
setTimeout((str) =>console.log('macro-global'),0)

在这段代码中，包含了通过 setTimeout 宏任务和通过 Promise.resolve 创建的微任务，你认为最终打印出来的顺序是什么？

执行这段代码，我们发现最终打印出来的顺序是：

foo
bar
global
micro-foo
micro-bar
micro-global
macro-foo
macro-bar
macro-global

我们可以清晰地看出，微任务是处于宏任务之前执行的。接下来，我们就来详细分析下 V8 是怎么执行这段 JavaScript 代码的。

首先，当 V8 执行这段代码时，会将全局执行上下文压入调用栈中，并在执行上下文中创建一个空的微任务队列。那么此时：

调用栈中包含了全局执行上下文；

微任务队列为空。

此时的消息队列、主线程、调用栈的状态图如下所示：

然后，执行 foo 函数的调用，V8 会先创建 foo 函数的执行上下文，并将其压入到栈中。接着执行 Promise.resolve，这会触发一个 micro-foo1 微任务，V8 会将该微任务添加进微任务队列。然后执行 setTimeout 方法。该方法会触发了一个 macro-foo1 宏任务，V8 会将该宏任务添加进消息队列。那么此时：

调用栈中包含了全局执行上下文、foo 函数的执行上下文；

微任务队列有了一个微任务，micro-foo；

消息队列中存放了一个通过 setTimeout 设置的宏任务，macro-foo。

此时的消息队列、主线程和调用栈的状态图如下所示：

接下来，foo 函数调用了 bar 函数，那么 V8 需要再创建 bar 函数的执行上下文，并将其压入栈中，接着执行 Promise.resolve，这会触发一个 micro-bar 微任务，该微任务会被添加进微任务队列。然后执行 setTimeout 方法，这也会触发一个 macro-bar 宏任务，宏任务同样也会被添加进消息队列。那么此时：

调用栈中包含了全局执行上下文、foo 函数的执行上下文、bar 的执行上下文；

微任务队列中的微任务是 micro-foo、micro-bar；

消息队列中，宏任务的状态是 macro-foo、macro-bar。

此时的消息队列、主线程和调用栈的状态图如下所示：

接下来，bar 函数执行结束并退出，bar 函数的执行上下文也会从栈中弹出，紧接着 foo 函数执行结束并退出，foo 函数的执行上下文也随之从栈中被弹出。那么此时：

调用栈中包含了全局执行上下文，因为 bar 函数和 foo 函数都执行结束了，所以它们的执行上下文都被弹出调用栈了；

微任务队列中的微任务同样还是 micro-foo、micro-bar；

消息队列中宏任务的状态同样还是 macro-foo、macro-bar。

此时的消息队列、主线程和调用栈的状态图如下所示：

主线程执行完了 foo 函数，紧接着就要执行全局环境中的代码 Promise.resolve 了，这会触发一个 micro-global 微任务，V8 会将该微任务添加进微任务队列。接着又执行 setTimeout 方法，该方法会触发了一个 macro-global 宏任务，V8 会将该宏任务添加进消息队列。那么此时：

调用栈中包含的是全局执行上下文；

微任务队列中的微任务同样还是 micro-foo、micro-bar、micro-global；

消息队列中宏任务的状态同样还是 macro-foo、macro-bar、macro-global。

此时的消息队列、主线程和调用栈的状态图如下所示：

等到这段代码即将执行完成时，V8 便要销毁这段代码的环境对象，此时环境对象的析构函数被调用（注意，这里的析构函数是 C++ 中的概念），这里就是 V8 执行微任务的一个检查点，这时候 V8 会检查微任务队列，如果微任务队列中存在微任务，那么 V8 会依次取出微任务，并按照顺行执行。因为微任务队列中的任务分别是：micro-foo、micro-bar、micro-global，所以执行的顺序也是如此。

此时的消息队列、主线程和调用栈的状态图如下所示：

等微任务队列中的所有微任务都执行完成之后，当前的宏任务也就执行结束了，接下来主线程会继续重复执行取出任务、执行任务的过程。由于正常情况下，取出宏任务的循序是按照先进先出的顺序，所有最后打印出来的顺序是：macro-foo、macro-bar、macro-global。

等所有的任务执行完成之后，消息队列、主线程和调用栈的状态图如下所示：

以上就是完整的执行流程的分析，到这里，相信你已经了解微任务和宏任务的执行时机是不同的了，微任务是在当前的任务快要执行结束之前执行的，宏任务是消息队列中的任务，主线程执行完一个宏任务之后，便会接着从消息队列中取出下一个宏任务并执行。

能否在微任务中循环地触发新的微任务？

既然宏任务和微任务都是异步调用，只是执行的时机不同，那能不能在 setTimeout 解决栈溢出的问题时，把触发宏任务改成是触发微任务呢？

比如，我们将代码改为：

function foo() {
  return Promise.resolve().then(foo)
}
foo()

当执行 foo 函数时，由于 foo 函数中调用了 Promise.resolve()，这会触发一个微任务，那么此时，V8 会将该微任务添加进微任务队列中，退出当前 foo 函数的执行。

然后，V8 在准备退出当前的宏任务之前，会检查微任务队列，发现微任务队列中有一个微任务，于是先执行微任务。由于这个微任务就是调用 foo 函数本身，所以在执行微任务的过程中，需要继续调用 foo 函数，在执行 foo 函数的过程中，又会触发了同样的微任务。

那么这个循环就会一直持续下去，当前的宏任务无法退出，也就意味着消息队列中其他的宏任务是无法被执行的，比如通过鼠标、键盘所产生的事件。这些事件会一直保存在消息队列中，页面无法响应这些事件，具体的体现就是页面的卡死。

不过，由于 V8 每次执行微任务时，都会退出当前 foo 函数的调用栈，所以这段代码是不会造成栈溢出的。

总结

这节课我们主要从调用栈、主线程、消息队列这三者关联的角度来分析了微任务。

调用栈是一种数据结构，用来管理在主线程上执行的函数的调用关系。主线在执行任务的过程中，如果函数的调用层次过深，可能造成栈溢出的错误，我们可以使用 setTimeout 来解决栈溢出的问题。

setTimeout 的本质是将同步函数调用改成异步函数调用，这里的异步调用是将回调函数封装成宏任务，并将其添加进消息队列中，然后主线程再按照一定规则循环地从消息队列中读取下一个宏任务。

消息队列中事件又被称为宏任务，不过，宏任务的时间颗粒度太粗了，无法胜任一些对精度和实时性要求较高的场景，而微任务可以在实时性和效率之间做有效的权衡。

微任务之所以能实现这样的效果，主要取决于微任务的执行时机，微任务其实是一个需要异步执行的函数，执行时机是在主函数执行结束之后、当前宏任务结束之前。

因为微任务依然是在当前的任务中执行的，所以如果在微任务中循环触发新的微任务，那么将导致消息队列中的其他任务没有机会被执行。

思考题

浏览器中的 MutationObserver 接口提供了监视对 DOM 树所做更改的能力，它在内部也使用了微任务的技术，那么今天留给你的作业是，查找 MutationObserver 相关资料，分析它是如何工作的，其中微任务的作用是什么？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

19｜异步编程（二）：V8是如何实现async/await的？

你好，我是李兵。

上一节我们介绍了 JavaScript 是基于单线程设计的，最终造成了 JavaScript 中出现大量回调的场景。当 JavaScript 中有大量的异步操作时，会降低代码的可读性, 其中最容易造成的就是回调地狱的问题。

JavaScript 社区探索并推出了一系列的方案，从“Promise 加 then”到“generator 加 co”方案，再到最近推出“终极”的 async/await 方案，完美地解决了回调地狱所造成的问题。

今天我们就来分析下回调地狱问题是如何被一步步解决的，在这个过程中，你也就理解了 V8 实现 async/await 的机制。

什么是回调地狱？

我们先来看什么是回调地狱。

假设你们老板给了你一个小需求，要求你从网络获取某个用户的用户名，获取用户名称的步骤是先通过一个 id_url 来获取用户 ID，然后再使用获取到的用户 ID 作为另外一个 name_url 的参数，以获取用户名。

我做了两个 DEMO URL，如下所示：

const id_url = 'https://raw.githubusercontent.com/binaryacademy/geektime-v8/master/id'

const name_url = 'https://raw.githubusercontent.com/binaryacademy/geektime-v8/master/name'

那么你会怎么实现这个小小的需求呢？

其中最容易想到的方案是使用 XMLHttpRequest，并按照前后顺序异步请求这两个 URL。具体地讲，你可以先定义一个 GetUrlContent 函数，这个函数负责封装 XMLHttpRequest 来下载 URL 文件内容，由于下载过程是异步执行的，所以需要通过回调函数来触发返回结果。那么我们需要给 GetUrlContent 传递一个回调函数 result_callback，来触发异步下载的结果。

最终实现的业务代码如下所示：

//result_callback：下载结果的回调函数
//url：需要获取URL的内容
function GetUrlContent(result_callback,url) {
    let request = new XMLHttpRequest()

request.open('GET', url)

request.responseType = 'text'

request.onload = function () {

result_callback(request.response)

}

request.send()

}

function IDCallback(id) {

console.log(id)

let new_name_url = name_url + "?id="+id

GetUrlContent(NameCallback,new_name_url)

}

function NameCallback(name) {

console.log(name)

}

GetUrlContent(IDCallback,id_url)

在这段代码中：

我们先使用 GetUrlContent 函数来异步下载用户 ID，之后再通过 IDCallback 回调函数来获取到请求的 ID；

有了 ID 之后，我们再在 IDCallback 函数内部，使用获取到的 ID 和 name_url 合并成新的获取用户名称的 URL 地址；

然后，再次使用 GetUrlContent 来获取用户名称，返回的用户名称会触发 NameCallback 回调函数，我们可以在 NameCallback 函数内部处理最终的返回结果。

可以看到，我们每次请求网络内容，都需要设置一个回调函数，用来返回异步请求的结果，这些穿插在代码之间的回调函数打乱了代码原有的顺序，比如正常的代码顺序是先获取 ID，再获取用户名。但是由于使用了异步回调函数，获取用户名代码的位置，反而在获取用户 ID 的代码之上了，这就直接导致了我们代码逻辑的不连贯、不线性，非常不符合人的直觉。

因此，异步回调模式影响到我们的编码方式，如果在代码中过多地使用异步回调函数，会将你的整个代码逻辑打乱，从而让代码变得难以理解，这也就是我们经常所说的回调地狱问题。

使用 Promise 解决回调地狱问题

为了解决回调地狱的问题，JavaScript 做了大量探索，最开始引入了 Promise 来解决部分回调地狱的问题，比如最新的 fetch 就使用 Promise 的技术，我们可以使用 fetch 来改造上面这段代码，改造后的代码如下所示：

fetch(id_url)

.then((response) => {

return response.text()

})

.then((response) => {

let new_name_url = name_url + "?id=" + response

return fetch(new_name_url)

}).then((response) => {

return response.text()

}).then((response) => {

console.log(response)//输出最终的结果

})

我们可以看到，改造后的代码是先获取用户 ID，等到返回了结果之后，再利用用户 ID 生成新的获取用户名称的 URL，然后再获取用户名，最终返回用户名。使用 Promise，我们就可以按照线性的思路来编写代码，非常符合人的直觉。所以说，使用 Promise 可以解决回调地狱中编码不线性的问题。

使用 Generator 函数实现更加线性化逻辑

虽然使用 Promise 可以解决回调地狱中编码不线性的问题，但这种方式充满了 Promise 的 then() 方法，如果处理流程比较复杂的话，那么整段代码将充斥着大量的 then，异步逻辑之间依然被 then 方法打断了，因此这种方式的语义化不明显，代码不能很好地表示执行流程。

那么我们就需要思考，能不能更进一步，像编写同步代码的方式来编写异步代码，比如：

 function getResult(){
   let id = getUserID()
   let name = getUserName(id)
   return name
 }

由于 getUserID() 和 getUserName() 都是异步请求，如果要实现这种线性的编码方式，那么一个可行的方案就是执行到异步请求的时候，暂停当前函数，等异步请求返回了结果，再恢复该函数。

具体地讲，执行到 getUserID() 时暂停 GetResult 函数，然后浏览器在后台处理实际的请求过程，待 ID 数据返回时，再来恢复 GetResult 函数。接下来再执行 getUserName 来获取到用户名，由于 getUserName() 也是一个异步请求，所以在使用 getUserName() 的同时，依然需要暂停 GetResult 函数的执行，等到 getUserName() 返回了用户名数据，再恢复 GetResult 函数的执行，最终 getUserName() 函数返回了 name 信息。

这个思维模型大致如下所示：

我们可以看出，这个模型的关键就是实现函数暂停执行和函数恢复执行，而生成器就是为了实现暂停函数和恢复函数而设计的。

生成器函数是一个带星号函数，配合 yield 就可以实现函数的暂停和恢复，我们看看生成器的具体使用方式：

function* getResult() {

yield 'getUserID'

yield 'getUserName'

return 'name'

}

let result = getResult()

console.log(result.next().value)

console.log(result.next().value)
console.log(result.next().value)

执行上面这段代码，观察输出结果，你会发现函数 getResult 并不是一次执行完的，而是全局代码和 getResult 函数交替执行。

其实这就是生成器函数的特性，在生成器内部，如果遇到 yield 关键字，那么 V8 将返回关键字后面的内容给外部，并暂停该生成器函数的执行。生成器暂停执行后，外部的代码便开始执行，外部代码如果想要恢复生成器的执行，可以使用 result.next 方法。

那么，V8 是怎么实现生成器函数的暂停执行和恢复执行的呢？

这背后的魔法就是协程，协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务，一个线程上可以存在多个协程，但是在线程上同时只能执行一个协程。比如，当前执行的是 A 协程，要启动 B 协程，那么 A 协程就需要将主线程的控制权交给 B 协程，这就体现在 A 协程暂停执行，B 协程恢复执行；同样，也可以从 B 协程中启动 A 协程。通常，如果从 A 协程启动 B 协程，我们就把 A 协程称为 B 协程的父协程。

正如一个进程可以拥有多个线程一样，一个线程也可以拥有多个协程。每一时刻，该线程只能执行其中某一个协程。最重要的是，协程不是被操作系统内核所管理，而完全是由程序所控制（也就是在用户态执行）。这样带来的好处就是性能得到了很大的提升，不会像线程切换那样消耗资源。

为了让你更好地理解协程是怎么执行的，我结合上面那段代码的执行过程，画出了下面的“协程执行流程图”，你可以对照着代码来分析：

到这里，相信你已经弄清楚协程是怎么工作的了，其实在 JavaScript 中，生成器就是协程的一种实现方式，这样，你也就理解什么是生成器了。

因为生成器可以暂停函数的执行，所以，我们将所有异步调用的方式，写成同步调用的方式，比如我们使用生成器来实现上面的需求，代码如下所示：

function* getResult() {
    let id_res = yield fetch(id_url);
    console.log(id_res)
    let id_text = yield id_res.text();
    console.log(id_text)

    let new_name_url = name_url + "?id=" + id_text
    console.log(new_name_url)

    let name_res = yield fetch(new_name_url)
    console.log(name_res)
    let name_text = yield name_res.text()
    console.log(name_text)
}

let result = getResult()
result.next().value.then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value
}).then((response) => {
    return result.next(response).value

这样，我们可以将同步、异步逻辑全部写进生成器函数 getResult 的内部，然后，我们在外面依次使用一段代码来控制生成器的暂停和恢复执行。以上，就是协程和 Promise 相互配合执行的大致流程。

通常，我们把执行生成器的代码封装成一个函数，这个函数驱动了 getResult 函数继续往下执行，我们把这个执行生成器代码的函数称为执行器（可参考著名的 co 框架），如下面这种方式：

function* getResult() {
    let id_res = yield fetch(id_url);
    console.log(id_res)
    let id_text = yield id_res.text();
    console.log(id_text)

    let new_name_url = name_url + "?id=" + id_text
    console.log(new_name_url)

    let name_res = yield fetch(new_name_url)
    console.log(name_res)
    let name_text = yield name_res.text()
    console.log(name_text)
}
co(getResult())

async/await：异步编程的“终极”方案

由于生成器函数可以暂停，因此我们可以在生成器内部编写完整的异步逻辑代码，不过生成器依然需要使用额外的 co 函数来驱动生成器函数的执行，这一点非常不友好。

基于这个原因，ES7 引入了 async/await，这是 JavaScript 异步编程的一个重大改进，它改进了生成器的缺点，提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。你可以参考下面这段使用 async/await 改造后的代码：

async function getResult() {
    try {
        let id_res = await fetch(id_url)
        let id_text = await id_res.text()
        console.log(id_text)

        let new_name_url = name_url+"?id="+id_text
        console.log(new_name_url)

        let name_res = await fetch(new_name_url)
        let name_text = await name_res.text()
        console.log(name_text)
    } catch (err) {
        console.error(err)
    }
}
getResult()

观察上面这段代码，你会发现整个异步处理的逻辑都是使用同步代码的方式来实现的，而且还支持 try catch 来捕获异常，这就是完全在写同步代码，所以非常符合人的线性思维。

虽然这种方式看起来像是同步代码，但是实际上它又是异步执行的，也就是说，在执行到 await fetch 的时候，整个函数会暂停等待 fetch 的执行结果，等到函数返回时，再恢复该函数，然后继续往下执行。

其实 async/await 技术背后的秘密就是 Promise 和生成器应用，往底层说，就是微任务和协程应用。要搞清楚 async 和 await 的工作原理，我们就得对 async 和 await 分开分析。

我们先来看看 async 到底是什么。根据 MDN 定义，async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。

这里需要重点关注异步执行这个词，简单地理解，如果在 async 函数里面使用了 await，那么此时 async 函数就会暂停执行，并等待合适的时机来恢复执行，所以说 async 是一个异步执行的函数。

那么暂停之后，什么时机恢复 async 函数的执行呢？

要解释这个问题，我们先来看看，V8 是如何处理 await 后面的内容的。

通常，await 可以等待两种类型的表达式：

可以是任何普通表达式 ;

也可以是一个 Promise 对象的表达式。

如果 await 等待的是一个 Promise 对象，它就会暂停执行生成器函数，直到 Promise 对象的状态变成 resolve，才会恢复执行，然后得到 resolve 的值，作为 await 表达式的运算结果。

我们看下面这样一段代码：

function NeverResolvePromise(){
    return new Promise((resolve, reject) => {})
}
async function getResult() {
    let a = await NeverResolvePromise()
    console.log(a)
}
getResult()
console.log(0)

这一段代码，我们使用 await 等待一个没有 resolve 的 Promise，那么这也就意味着，getResult 函数会一直等待下去。

和生成器函数一样，使用了 async 声明的函数在执行时，也是一个单独的协程，我们可以使用 await 来暂停该协程，由于 await 等待的是一个 Promise 对象，我们可以 resolve 来恢复该协程。

下面是我从协程的视角，画的这段代码的执行流程图，你可以对照参考下：

如果 await 等待的对象已经变成了 resolve 状态，那么 V8 就会恢复该协程的执行，我们可以修改下上面的代码，来证明下这个过程：

function HaveResolvePromise(){
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(100)
          }, 0);
      })
}
async function getResult() {
    console.log(1)
    let a = await NoResolvePromise()
    console.log(a)
    console.log(2)
}
console.log(0)
getResult()
console.log(3)

现在，这段代码的执行流程就非常清晰了，具体执行流程你可以参看下图：

如果 await 等待的是一个非 Promise 对象，比如 await 100，那么通用 V8 会隐式地将 await 后面的 100 包装成一个已经 resolve 的对象，其效果等价于下面这段代码：

function ResolvePromise(){
    return new Promise((resolve, reject) => {
            resolve(100)
      })
}
async function getResult() {
    let a = await ResolvePromise()
    console.log(a)
}
getResult()
console.log(3)

总结

Callback 模式的异步编程模型需要实现大量的回调函数，大量的回调函数会打乱代码的正常逻辑，使得代码变得不线性、不易阅读，这就是我们所说的回调地狱问题。

使用 Promise 能很好地解决回调地狱的问题，我们可以按照线性的思路来编写代码，这个过程是线性的，非常符合人的直觉。

但是这种方式充满了 Promise 的 then() 方法，如果处理流程比较复杂的话，那么整段代码将充斥着大量的 then，语义化不明显，代码不能很好地表示执行流程。

我们想要通过线性的方式来编写异步代码，要实现这个理想，最关键的是要能实现函数暂停和恢复执行的功能。而生成器就可以实现函数暂停和恢复，我们可以在生成器中使用同步代码的逻辑来异步代码 (实现该逻辑的核心是协程)，但是在生成器之外，我们还需要一个触发器来驱动生成器的执行，因此这依然不是我们最终想要的方案。

我们的最终方案就是 async/await，async 是一个可以暂停和恢复执行的函数，我们会在 async 函数内部使用 await 来暂停 async 函数的执行，await 等待的是一个 Promise 对象，如果 Promise 的状态变成 resolve 或者 reject，那么 async 函数会恢复执行。因此，使用 async/await 可以实现以同步的方式编写异步代码这一目标。

你会发现，这节课我们讲的也是前端异步编程的方案史，我把这一过程也画了一张图供你参考：

思考题

了解 async/await 的演化过程，对于理解 async/await 至关重要，在进化过程中，co+generator 是比较优秀的一个设计。今天留给你的思考题是，co 的运行原理是什么？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

20 | 垃圾回收（一）：V8的两个垃圾回收器是如何工作的？

你好，我是李兵。

我们都知道，JavaScript 是一门自动垃圾回收的语言，也就是说，我们不需要去手动回收垃圾数据，这一切都交给 V8 的垃圾回收器来完成。V8 为了更高效地回收垃圾，引入了两个垃圾回收器，它们分别针对着不同的场景。

那这两个回收器究竟是如何工作的呢，这节课我们就来分析这个问题。

垃圾数据是怎么产生的？

首先，我们看看垃圾数据是怎么产生的。

无论是使用什么语言，我们都会频繁地使用数据，这些数据会被存放到栈和堆中，通常的方式是在内存中创建一块空间，使用这块空间，在不需要的时候回收这块空间。

比如下面这样一句代码：

window.test = new Object()
window.test.a = new Uint16Array(100)

当 JavaScript 执行这段代码的时候，会先为 window 对象添加一个 test 属性，并在堆中创建了一个空对象，并将该对象的地址指向了 window.test 属性。随后又创建一个大小为 100 的数组，并将属性地址指向了 test.a 的属性值。此时的内存布局图如下所示：

我们可以看到，栈中保存了指向 window 对象的指针，通过栈中 window 的地址，我们可以到达 window 对象，通过 window 对象可以到达 test 对象，通过 test 对象还可以到达 a 对象。

如果此时，我将另外一个对象赋给了 a 属性，代码如下所示：

window.test.a = new Object()

那么此时的内存布局如下所示：

我们可以看到，a 属性之前是指向堆中数组对象的，现在已经指向了另外一个空对象，那么此时堆中的数组对象就成为了垃圾数据，因为我们无法从一个根对象遍历到这个 Array 对象。

不过，你不用担心这个数组对象会一直占用内存空间，因为 V8 虚拟机中的垃圾回收器会帮你自动清理。

垃圾回收算法

那么垃圾回收是怎么实现的呢？大致可以分为以下几个步骤：

第一步，通过 GC Root 标记空间中活动对象和非活动对象。

目前 V8 采用的可访问性（reachability）算法来判断堆中的对象是否是活动对象。具体地讲，这个算法是将一些 GC Root 作为初始存活的对象的集合，从 GC Roots 对象出发，遍历 GC Root 中的所有对象：

通过 GC Root 遍历到的对象，我们就认为该对象是可访问的（reachable），那么必须保证这些对象应该在内存中保留，我们也称可访问的对象为活动对象；

通过 GC Roots 没有遍历到的对象，则是不可访问的（unreachable），那么这些不可访问的对象就可能被回收，我们称不可访问的对象为非活动对象。

在浏览器环境中，GC Root 有很多，通常包括了以下几种 (但是不止于这几种)：

全局的 window 对象（位于每个 iframe 中）；

文档 DOM 树，由可以通过遍历文档到达的所有原生 DOM 节点组成；

存放栈上变量。

第二步，回收非活动对象所占据的内存。其实就是在所有的标记完成之后，统一清理内存中所有被标记为可回收的对象。

第三步，做内存整理。一般来说，频繁回收对象后，内存中就会存在大量不连续空间，我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后，如果需要分配较大的连续内存时，就有可能出现内存不足的情况，所以最后一步需要整理这些内存碎片。但这步其实是可选的，因为有的垃圾回收器不会产生内存碎片，比如接下来我们要介绍的副垃圾回收器。

以上就是大致的垃圾回收的流程。目前 V8 采用了两个垃圾回收器，主垃圾回收器 -Major GC 和副垃圾回收器 -Minor GC (Scavenger)。V8 之所以使用了两个垃圾回收器，主要是受到了代际假说（The Generational Hypothesis）的影响。

代际假说是垃圾回收领域中一个重要的术语，它有以下两个特点：

第一个是大部分对象都是“朝生夕死”的，也就是说大部分对象在内存中存活的时间很短，比如函数内部声明的变量，或者块级作用域中的变量，当函数或者代码块执行结束时，作用域中定义的变量就会被销毁。因此这一类对象一经分配内存，很快就变得不可访问；

第二个是不死的对象，会活得更久，比如全局的 window、DOM、Web API 等对象。

其实这两个特点不仅仅适用于 JavaScript，同样适用于大多数的动态语言，如 Java、Python 等。

V8 的垃圾回收策略，就是建立在该假说的基础之上的。接下来，我们来分析下 V8 是如何实现垃圾回收的。

如果我们只使用一个垃圾回收器，在优化大多数新对象的同时，就很难优化到那些老对象，因此你需要权衡各种场景，根据对象生存周期的不同，而使用不同的算法，以便达到最好的效果。

所以，在 V8 中，会把堆分为新生代和老生代两个区域，新生代中存放的是生存时间短的对象，老生代中存放生存时间久的对象。

新生代通常只支持 1～8M 的容量，而老生代支持的容量就大很多了。对于这两块区域，V8 分别使用两个不同的垃圾回收器，以便更高效地实施垃圾回收。

副垃圾回收器 -Minor GC (Scavenger)，主要负责新生代的垃圾回收。

主垃圾回收器 -Major GC，主要负责老生代的垃圾回收。

副垃圾回收器

副垃圾回收器主要负责新生代的垃圾回收。通常情况下，大多数小的对象都会被分配到新生代，所以说这个区域虽然不大，但是垃圾回收还是比较频繁的。

新生代中的垃圾数据用 Scavenge 算法来处理。所谓 Scavenge 算法，是把新生代空间对半划分为两个区域，一半是对象区域 (from-space)，一半是空闲区域 (to-space)，如下图所示：

新生区要划分为对象区域和空闲区域

新加入的对象都会存放到对象区域，当对象区域快被写满时，就需要执行一次垃圾清理操作。

在垃圾回收过程中，首先要对对象区域中的垃圾做标记；标记完成之后，就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中，同时它还会把这些对象有序地排列起来，所以这个复制过程，也就相当于完成了内存整理操作，复制后空闲区域就没有内存碎片了。

完成复制后，对象区域与空闲区域进行角色翻转，也就是原来的对象区域变成空闲区域，原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作，同时，这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

不过，副垃圾回收器每次执行清理操作时，都需要将存活的对象从对象区域复制到空闲区域，复制操作需要时间成本，如果新生区空间设置得太大了，那么每次清理的时间就会过久，所以为了执行效率，一般新生区的空间会被设置得比较小。

也正是因为新生区的空间不大，所以很容易被存活的对象装满整个区域，副垃圾回收器一旦监控对象装满了，便执行垃圾回收。同时，副垃圾回收器还会采用对象晋升策略，也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。

主垃圾回收器

主垃圾回收器主要负责老生代中的垃圾回收。除了新生代中晋升的对象，一些大的对象会直接被分配到老生代里。因此，老生代中的对象有两个特点：

一个是对象占用空间大；

另一个是对象存活时间长。

由于老生代的对象比较大，若要在老生代中使用 Scavenge 算法进行垃圾回收，复制这些大的对象将会花费比较多的时间，从而导致回收执行效率不高，同时还会浪费一半的空间。所以，主垃圾回收器是采用标记 - 清除（Mark-Sweep）的算法进行垃圾回收的。

那么，标记 - 清除算法是如何工作的呢？

首先是标记过程阶段。标记阶段就是从一组根元素开始，递归遍历这组根元素，在这个遍历过程中，能到达的元素称为活动对象，没有到达的元素就可以判断为垃圾数据。

接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同，主垃圾回收器会直接将标记为垃圾的数据清理掉。

你可以理解这个过程是清除掉下图中红色标记数据的过程，你可参考下图大致理解下其清除过程：

标记清除过程

对垃圾数据进行标记，然后清除，这就是标记 - 清除算法，不过对一块内存多次执行标记 - 清除算法后，会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存，于是又引入了另外一种算法——标记 - 整理（Mark-Compact）。

这个算法的标记过程仍然与标记 - 清除算法里的是一样的，先标记可回收对象，但后续步骤不是直接对可回收对象进行清理，而是让所有存活的对象都向一端移动，然后直接清理掉这一端之外的内存。你可以参考下图：

标记整理过程

总结

今天，我们先分析了什么是垃圾数据，从“GC Roots”对象出发，遍历 GC Root 中的所有对象，如果通过 GC Roots 没有遍历到的对象，则这些对象便是垃圾数据。V8 会有专门的垃圾回收器来回收这些垃圾数据。

V8 依据代际假说，将堆内存划分为新生代和老生代两个区域，新生代中存放的是生存时间短的对象，老生代中存放生存时间久的对象。为了提升垃圾回收的效率，V8 设置了两个垃圾回收器，主垃圾回收器和副垃圾回收器。主垃圾回收器负责收集老生代中的垃圾数据，副垃圾回收器负责收集新生代中的垃圾数据。

副垃圾回收器采用了 Scavenge 算法，是把新生代空间对半划分为两个区域，一半是对象区域，一半是空闲区域。新的数据都分配在对象区域，等待对象区域快分配满的时候，垃圾回收器便执行垃圾回收操作，之后将存活的对象从对象区域拷贝到空闲区域，并将两个区域互换。主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作，会经历标记、清除和整理过程。

思考题

观察下面这段代码：

function strToArray(str) {
  let i = 0
  const len = str.length
  let arr = new Uint16Array(str.length)
  for (; i < len; ++i) {
    arr[i] = str.charCodeAt(i)
  }
  return arr;
}

function foo() {
  let i = 0
  let str = 'test V8 GC'
  while (i++ < 1e5) {
    strToArray(str);
  }
}

foo()

课后请你想一想，V8 执行这段代码的过程中，产生了哪些垃圾数据，以及 V8 又是如何回收这些垃圾的数据的， 最后站在内存空间和主线程资源的角度来分析，如何优化这段代码。欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

21 | 垃圾回收（二）：V8是如何优化垃圾回收器执行效率的？

你好，我是李兵。

上节我们介绍了 V8 使用副垃圾回收器和主垃圾回收器来处理垃圾回收，这节课我们看看 V8 是如何优化垃圾回收器的执行效率的。

由于 JavaScript 是运行在主线程之上的，因此，一旦执行垃圾回收算法，都需要将正在执行的 JavaScript 脚本暂停下来，待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿（Stop-The-World）。

一次完整的垃圾回收分为标记和清理两个阶段，垃圾数据标记之后，V8 会继续执行清理和整理操作，虽然主垃圾回收器和副垃圾回收器的处理方式稍微有些不同，但它们都是主线程上执行的，执行垃圾回收过程中，会暂停主线程上的其他任务，具体全停顿的执行效果如下图所示：

可以看到，执行垃圾回收时会占用主线程的时间，如果在执行垃圾回收的过程中，垃圾回收器占用主线程时间过久，就像上面图片展示的那样，花费了 200 毫秒，在这 200 毫秒内，主线程是不能做其他事情的。比如，页面正在执行一个 JavaScript 动画，因为垃圾回收器在工作，就会导致这个动画在这 200 毫秒内无法执行，造成页面的卡顿 (Jank)，用户体验不佳。

为了解决全停顿而造成的用户体验的问题，V8 团队经过了很多年的努力，向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术，并且也已经取得了一些成效。这些技术主要是从两方面来解决垃圾回收效率问题的：

第一，将一个完整的垃圾回收的任务拆分成多个小的任务，这样就消灭了单个长的垃圾回收任务；

第二，将标记对象、移动对象等任务转移到后台线程进行，这会大大减少主线程暂停的时间，改善页面卡顿的问题，让动画、滚动和用户交互更加流畅。

接下来，我们就来深入分析下，V8 是怎么向现有的垃圾回收器添加并行、并发和增量等技术，来提升垃圾回收执行效率的。

并行回收

既然执行一次完整的垃圾回收过程比较耗时，那么解决效率问题，第一个思路就是主线程在执行垃圾回收的任务时，引入多个辅助线程来并行处理，这样就会加速垃圾回收的执行速度，因此 V8 团队引入了并行回收机制。

所谓并行回收，是指垃圾回收器在主线程上执行的过程中，还会开启多个协助线程，同时执行同样的回收工作，其工作模式如下图所示：

采用并行回收时，垃圾回收所消耗的时间，等于总体辅助线程所消耗的时间（辅助线程数量乘以单个线程所消耗的时间），再加上一些同步开销的时间。这种方式比较简单，因为在执行垃圾标记的过程中，主线程并不会同时执行 JavaScript 代码，因此 JavaScript 代码也不会改变回收的过程。所以我们可以假定内存状态是静态的，因此只要确保同时只有一个协助线程在访问对象就好了。

V8 的副垃圾回收器所采用的就是并行策略，它在执行垃圾回收的过程中，启动了多个线程来负责新生代中的垃圾清理操作，这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变，所以还需要同步更新引用这些对象的指针。

增量回收

虽然并行策略能增加垃圾回收的效率，能够很好地优化副垃圾回收器，但是这仍然是一种全停顿的垃圾回收方式，在主线程执行回收工作的时候才会开启辅助线程，这依然还会存在效率问题。比如老生代存放的都是一些大的对象，如 window、DOM 这种，完整执行老生代的垃圾回收，时间依然会很久。这些大的对象都是主垃圾回收器的，所以在 2011 年，V8 从又引入了增量标记的方式，我们把这种垃圾回收的方式称为谓增量式垃圾回收。

所谓增量式垃圾回收，是指垃圾收集器将标记工作分解为更小的块，并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时，垃圾回收器没有必要一次执行完整的垃圾回收过程，每次执行的只是整个垃圾回收过程中的一小部分工作，具体流程你可以参看下图：

增量标记的算法，比全停顿的算法要稍微复杂，这主要是因为增量回收是并发的（concurrent），要实现增量执行，需要满足两点要求：

垃圾回收可以被随时暂停和重启，暂停时需要保存当时的扫描结果，等下一波垃圾回收来了之后，才能继续启动。

在暂停期间，被标记好的垃圾数据如果被 JavaScript 代码修改了，那么垃圾回收器需要能够正确地处理。

我们先来看看第一点，V8 是如何实现垃圾回收器的暂停和恢复执行的。

这里我们需要知道，在没有采用增量算法之前，V8 使用黑色和白色来标记数据。在执行一次完整的垃圾回收之前，垃圾回收器会将所有的数据设置为白色，用来表示这些数据还没有被标记，然后垃圾回收器在会从 GC Roots 出发，将所有能访问到的数据标记为黑色。遍历结束之后，被标记为黑色的数据就是活动数据，那些白色数据就是垃圾数据。如下图所示：

如果内存中的数据只有两种状态，非黑即白，那么当你暂停了当前的垃圾回收器之后，再次恢复垃圾回收器，那么垃圾回收器就不知道从哪个位置继续开始执行了。

比如垃圾回收器执行了一小段增量回收后，被 V8 暂停了，然后主线程执行了一段 JavaScript 代码，然后垃圾回收器又被恢复了，那么恢复时内存状态就如下图所示：

那么，当垃圾回收器再次被启动的时候，它到底是从 A 节点开始标记，还是从 B 节点开始执行标注过程呢？因为没有其他额外的信息，所以垃圾回收器也不知道该如何处理了。

为了解决这个问题，V8 采用了三色标记法，除了黑色和白色，还额外引入了灰色：

黑色表示这个节点被 GC Root 引用到了，而且该节点的子节点都已经标记完成了 ;

灰色表示这个节点被 GC Root 引用到，但子节点还没被垃圾回收器标记处理，也表明目前正在处理这个节点；

白色表示这个节点没有被访问到，如果在本轮遍历结束时还是白色，那么这块数据就会被收回。

引入灰色标记之后，垃圾回收器就可以依据当前内存中有没有灰色节点，来判断整个标记是否完成，如果没有灰色节点了，就可以进行清理工作了。如果还有灰色标记，当下次恢复垃圾回收器时，便从灰色的节点开始继续执行。

因此采用三色标记，可以很好地支持增量式垃圾回收。

接下来，我们再来分析下，标记好的垃圾数据被 JavaScript 修改了，V8 是如何处理的。我们看下面这样的一个例子：

window.a = Object()
window.a.b = Object()
window.a.b.c=Object()

执行到这段代码时，垃圾回收器标记的结果如下图所示：

然后又执行了另外一个代码，这段代码如下所示：

window.a.b = Object() //d

执行完之后，垃圾回收器又恢复执行了增量标记过程，由于 b 重新指向了 d 对象，所以 b 和 c 对象的连接就断开了。这时候代码的应用如下图所示：

这就说明一个问题，当垃圾回收器将某个节点标记成了黑色，然后这个黑色的节点被续上了一个白色节点，那么垃圾回收器不会再次将这个白色节点标记为黑色节点了，因为它已经走过这个路径了。

但是这个新的白色节点的确被引用了，所以我们还是需要想办法将其标记为黑色。

为了解决这个问题，增量垃圾回收器添加了一个约束条件：不能让黑色节点指向白色节点。

通常我们使用写屏障 (Write-barrier) 机制实现这个约束条件，也就是说，当发生了黑色的节点引用了白色的节点，写屏障机制会强制将被引用的白色节点变成灰色的，这样就保证了黑色节点不能指向白色节点的约束条件。这个方法也被称为强三色不变性，它保证了垃圾回收器能够正确地回收数据，因为在标记结束时的所有白色对象，对于垃圾回收器来说，都是不可到达的，可以安全释放。

所以在 V8 中，每次执行如 window.a.b = value的写操作之后，V8 会插入写屏障代码，强制将 value 这块内存标记为灰色。

并发 (concurrent) 回收

虽然通过三色标记法和写屏障机制可以很好地实现增量垃圾回收，但是由于这些操作都是在主线程上执行的，如果主线程繁忙的时候，增量垃圾回收操作依然会增加降低主线程处理任务的吞吐量 (throughput)。

结合并行回收可以将一些任务分配给辅助线程，但是并行回收依然会阻塞主线程，那么，有没有办法在不阻塞主线程的情况下，执行垃圾回收操作呢？

还真有，这就是我们要来重点研究的并发回收机制了。

所谓并发回收，是指主线程在执行 JavaScript 的过程中，辅助线程能够在后台完成执行垃圾回收的操作。并发标记的流程大致如下图所示：

并发回收的优势非常明显，主线程不会被挂起，JavaScript 可以自由地执行 ，在执行的同时，辅助线程可以执行垃圾回收操作。

但是并发回收却是这三种技术中最难的一种，这主要由以下两个原因导致的：

第一，当主线程执行 JavaScript 时，堆中的内容随时都有可能发生变化，从而使得辅助线程之前做的工作完全无效；

第二，主线程和辅助线程极有可能在同一时间去更改同一个对象，这就需要额外实现读写锁的一些功能了。

尽管并行回收要额外解决以上两个问题，但是权衡利弊，并行回收这种方式的效率还是远高于其他方式的。

不过，这三种技术在实际使用中，并不是单独的存在，通常会将其融合在一起使用，V8 的主垃圾回收器就融合了这三种机制，来实现垃圾回收，那它具体是怎么工作的呢？你可以先看下图：

可以看出来，主垃圾回收器同时采用了这三种策略：

首先主垃圾回收器主要使用并发标记，我们可以看到，在主线程执行 JavaScript，辅助线程就开始执行标记操作了，所以说标记是在辅助线程中完成的。

标记完成之后，再执行并行清理操作。主线程在执行清理操作时，多个辅助线程也在执行清理操作。

另外，主垃圾回收器还采用了增量标记的方式，清理的任务会穿插在各种 JavaScript 任务之间执行。

总结

V8 最开始的垃圾回收器有两个特点，第一个是垃圾回收在主线程上执行，第二个特点是一次执行一个完整的垃圾回收流程。

由于这两个原因，很容易造成主线程卡顿，所以 V8 采用了很多优化执行效率的方案。

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

第二个方案是增量式垃圾回收，垃圾回收器将标记工作分解为更小的块，并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时，垃圾回收器没有必要一次执行完整的垃圾回收过程，每次执行的只是整个垃圾回收过程中的一小部分工作。

第三个方案是并发回收，回收线程在执行 JavaScript 的过程，辅助线程能够在后台完成的执行垃圾回收的操作。

主垃圾回收器就综合采用了所有的方案，副垃圾回收器也采用了部分方案。

思考题

虽然 V8 为执行垃圾回收的效率做了大量的优化，但是在实际项目中我们依然要关心内存问题，那么今天我留给你的思考题是：在使用 JavaScript 时，如何避免内存泄漏？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

22｜答疑：几种常见内存问题的解决策略

你好，我是李兵。

这是我们“事件循环和垃圾回收”这个模块的最后一讲。在这个模块中，我们讲了消息循环系统和垃圾回收机制，这两块内容涉及到了比较底层的知识，但是这些知识对实际的项目有着非常重要的指导作用，很多同学也比较关注这两部分内容。

今天这节答疑课，我们来结合 Node 中的读文件操作，分析下消息循环系统是怎么影响到异步编程的，然后我们再来结合 JavaScript 中的几种常见的内存问题，来分析下内存问题出现的原因和解决方法。

Node 中的 readFile API 工作机制

Node 中很多 API 都提供了同步和异步两种形式，下面我们来看下《17 | 消息队列：V8 是怎么实现回调函数的？》这节课留的思考题。思考题中有两段代码，我们通过这两段代码来分析下同步和异步读文件 API 的区别。

var fs = require('fs')

var data = fs.readFileSync('test.js')

function fileHanlder(err, data){
  data.toString()
}

fs.readFile('test.txt', fileHanlder)

在解答这个问题之前，我们来看看 Node 的体系架构。你可以先参考下图：

Node 是 V8 的宿主，它会给 V8 提供事件循环和消息队列。在 Node 中，事件循环是由 libuv 提供的，libuv 工作在主线程中，它会从消息队列中取出事件，并在主线程上执行事件。

同样，对于一些主线程上不适合处理的事件，比如消耗时间过久的网络资源下载、文件读写、设备访问等，Node 会提供很多线程来处理这些事件，我们把这些线程称为线程池。

通常，在 Node 中，我们认为读写文件是一个非常耗时的工作，因此主线程会将回调函数和读文件的操作一道发送给文件读写线程，并让实际的读写操作运行在读写线程中。

比如当在 Node 的主线程上执行 readFile 的时候，主线程会将 readFile 的文件名称和回调函数，提交给文件读写线程来处理，具体过程如下所示：

文件读写线程完成了文件读取之后，会将结果和回调函数封装成新的事件，并将其添加进消息队列中。比如文件线程将读取的文件内容存放在内存中，并将 data 指针指向了该内存，然后文件读写线程会将 data 和回调函数封装成新的事件，并将其丢进消息队列中，具体过程如下所示：

等到 libuv 从消息队列中读取该事件后，主线程就可以着手来处理该事件了。在主线程处理该事件的过程中，主线程调用事件中的回调函数，并将 data 结果数据作为参数，如下图所示：

然后在回调函数中，我们就可以拿到读取的结果来实现一些业务逻辑了。

不过，总有些人觉得异步读写文件操作过于复杂了，如果读取的文件体积不大或者项目瓶颈不在文件读写，那么依然使用异步调用和回调函数的模式就显得有点过度复杂了。

因此 Node 还提供了一套同步读写的 API。第一段代码中的 readFileSync 就是同步实现的，同步代码非常简单，当 libuv 读取到 readFileSync 的任务后，就直接在主线程上执行读写操作，等待读写结束，直接返回读写的结果，这也是同步回调的一种应用。当然在读写过程中，消息队列中的其他任务是无法被执行的。

所以在选择使用同步 API 还是异步 API 时，我们要看实际的场景，并不是非 A 即 B。

几种内存问题

分析了异步 API，接下来我们再来看看 JavaScript 中的内存问题，内存问题至关重要，因为通过内存而造成的问题很容易被用户察觉。总的来说，内存问题可以定义为下面这三类：

内存泄漏 (Memory leak)，它会导致页面的性能越来越差；

内存膨胀 (Memory bloat)，它会导致页面的性能会一直很差；

频繁垃圾回收，它会导致页面出现延迟或者经常暂停。

内存泄漏

我们先看内存泄漏。本质上，内存泄漏可以定义为：当进程不再需要某些内存的时候，这些不再被需要的内存依然没有被进程回收。

在 JavaScript 中，造成内存泄漏 (Memory leak) 的主要原因是不再需要 (没有作用) 的内存数据依然被其他对象引用着。

下面我们就来看几种实际的例子：

我们知道，JavaScript 是一门非常宽松的语言，你甚至可以使用一个未定义的变量，比如下面这样一段代码：

function foo() {
    //创建一个临时的temp_array
    temp_array = new Array(200000)
   /**
    * 使用temp_array
    */
}

当执行这段代码时，由于函数体内的对象没有被 var、let、const 这些关键字声明，那么 V8 就会使用 this.temp_array 替换 temp_array。

function foo() {
    //创建一个临时的temp_array
    this.temp_array = new Array(200000)
   /**
    * this.temp_array
    */
}

在浏览器，默认情况下，this 是指向 window 对象的，而 window 对象是常驻内存的，所以即便 foo 函数退出了，但是 temp_array 依然被 window 对象引用了， 所以 temp_array 依然也会和 window 对象一样，会常驻内存。因为 temp_array 已经是不再被使用的对象了，但是依然被 window 对象引用了，这就造成了 temp_array 的泄漏。

为了解决这个问题，我们可以在 JavaScript 文件头部加上use strict，使用严格模式避免意外的全局变量，此时上例中的 this 指向 undefined。

另外，我们还要时刻警惕闭包这种情况，因为闭包会引用父级函数中定义的变量，如果引用了不被需要的变量，那么也会造成内存泄漏。比如你可以看下面这样一段代码：

function foo(){
    var temp_object = new Object()
    temp_object.x = 1
    temp_object.y = 2
    temp_object.array = new Array(200000)
    /**
    *   使用temp_object
    */
    return function(){
        console.log(temp_object.x);
    }
}

可以看到，foo 函数使用了一个局部临时变量 temp_object，temp_object 对象有三个属性，x、y，还有一个非常占用内存的 array 属性。最后 foo 函数返回了一个匿名函数，该匿名函数引用了 temp_object.x。那么当调用完 foo 函数之后，由于返回的匿名函数引用了 foo 函数中的 temp_object.x，这会造成 temp_object 无法被销毁，即便只是引用了 temp_object.x，也会造成整个 temp_object 对象依然保留在内存中。我们可以通过 Chrome 调试工具查看下：

从上图可以看出，我们仅仅是需要 temp_object.x 的值，V8 却保留了整个 temp_object 对象。

要解决这个问题，我就需要根据实际情况，来判断闭包中返回的函数到底需要引用什么数据，不需要引用的数据就绝不引用，因为上面例子中，返回函数中只需要 temp_object.x 的值，因此我们可以这样改造下这段代码：

function foo(){
    var temp_object = new Object()
    temp_object.x = 1
    temp_object.y = 2
    temp_object.array = new Array(200000)
    /**
    *   使用temp_object
    */
   let closure = temp_object.x
    return function(){
        console.log(closure);
    }
}

当再次执行这段代码时，我们就可以看到闭包引用的仅仅是一个 closure 的变量，最终如下图所示：

我们再来看看由于 JavaScript 引用了 DOM 节点而造成的内存泄漏的问题，只有同时满足 DOM 树和 JavaScript 代码都不引用某个 DOM 节点，该节点才会被作为垃圾进行回收。 如果某个节点已从 DOM 树移除，但 JavaScript 仍然引用它，我们称此节点为“detached ”。“detached ”节点是 DOM 内存泄漏的常见原因。比如下面这段代码：

let detachedTree;
function create() {

var ul = document.createElement('ul');

for (var i = 0; i < 100; i++) {

var li = document.createElement('li');

ul.appendChild(li);

}

detachedTree = ul;

}

create()

我们通过 JavaScript 创建了一些 DOM 元素，有了这些内存中的 DOM 元素，当有需要的时候，我们就快速地将这些 DOM 元素关联到 DOM 树上，一旦这些 DOM 元素从 DOM 上被移除后，它们并不会立即销毁，这主要是由于 JavaScript 代码中保留了这些元素的引用，导致这些 DOM 元素依然会呆在内存中。所以在保存 DOM 元素引用的时候，我们需要非常小心谨慎。

内存膨胀

了解几种可能造成内存泄漏的问题之后，接下来，我们再来看看另外一个和内存泄漏类似的问题：内存膨胀（Memory bloat）。

内存膨胀和内存泄漏有一些差异，内存膨胀主要表现在程序员对内存管理的不科学，比如只需要 50M 内存就可以搞定的，有些程序员却花费了 500M 内存。

额外使用过多的内存有可能是没有充分地利用好缓存，也有可能加载了一些不必要的资源。通常表现为内存在某一段时间内快速增长，然后达到一个平稳的峰值继续运行。

比如一次性加载了大量的资源，内存会快速达到一个峰值。内存膨胀和内存泄漏的关系你可以参看下图：

我们可以看到，内存膨胀是快速增长，然后达到一个平衡的位置，而内存泄漏是内存一直在缓慢增长。要避免内存膨胀，我们需要合理规划项目，充分利用缓存等技术来减轻项目中不必要的内存占用。

频繁的垃圾回收

除了内存泄漏和内存膨胀，还有另外一类内存问题，那就是频繁使用大的临时变量，导致了新生代空间很快被装满，从而频繁触发垃圾回收。频繁的垃圾回收操作会让你感觉到页面卡顿。比如下面这段代码：

function strToArray(str) {
  let i = 0
  const len = str.length
  let arr = new Uint16Array(str.length)
  for (; i < len; ++i) {
    arr[i] = str.charCodeAt(i)
  }
  return arr;
}

function foo() {
  let i = 0
  let str = 'test V8 GC'
  while (i++ < 1e5) {
    strToArray(str);
  }
}

foo()

这段代码就会频繁创建临时变量，这种方式很快就会造成新生代内存内装满，从而频繁触发垃圾回收。为了解决频繁的垃圾回收的问题，你可以考虑将这些临时变量设置为全局变量。

总结

这篇答疑主要分析了两个问题，第一个是异步 API 和同步 API 的底层差异，第二个是 JavaScript 的主要内存问题的产生原因和解决方法。

Node 为读写文件提供了两套 API，一套是默认的异步 API，另外一套是同步 API。

readFile 就是异步 API，主线程在执行 readFile 的时候，会将实际读写操作丢给文件读写线程，文件读写线程处理完成之后，会将回调函数读取的结果封装成新的消息，添加到消息队列中，然后等主线执行该消息的时候，就会执行 readFile 设置的回调函数，这就是 Node 中的异步处理过程。readFileSync 是同步 API，同步 API 很简单，直接在主线程上执行，执行完成直接返回结果给它的调用函数。使用同步 API 会比较方便简单，但是你需要考虑项目能否接受读取文件而造成的暂停。

内存问题对于前端开发者来说也是至关重要的，通常有三种内存问题：内存泄漏 (Memory leak)、内存膨胀 (Memory bloat)、频繁垃圾回收。

在 JavaScript 中，造成内存泄漏 (Memory leak) 的主要原因，是不再需要 (没有作用) 的内存数据依然被其他对象引用着。所以要避免内存泄漏，我们需要避免引用那些已经没有用途的数据。

内存膨胀和内存泄漏有一些差异，内存膨胀主要是由于程序员对内存管理不科学导致的，比如只需要 50M 内存就可以搞定的，有些程序员却花费了 500M 内存。要解决内存膨胀问题，我们需要对项目有着透彻的理解，也要熟悉各种能减少内存占用的技术方案。

如果频繁使用大的临时变量，那么就会导致频繁垃圾回收，频繁的垃圾回收操作会让你感觉到页面卡顿，要解决这个问题，我们可以考虑将这些临时变量设置为全局变量。

思考题

今天留给你的题目是，在实际的项目中，你还遇到过哪些具体的内存问题呢？这些问题都是怎么解决的？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。

结束语 | 我的前端学习踩坑史

你好，我是李兵。

这是我们这个系列课程的最后一讲，今天我们不谈技术，我想跟你聊聊这么多年我学习前端踩过的一些坑和学习心得。

其实我早些年主要是做 C/C++ 开发的，到了 2009 年，接触到了浏览器研发，这个时候我对前端并不是太熟悉，但是在开发浏览器的过程中，需要了解大量的前端知识。我当时的学习策略是，只有当遇到了与前端有关的问题时，才会去学习相应的知识来解决。这样做本意是想要节省时间，结果却反而让我花费了更多不必要的时间。

比如在开发双核浏览器的 Cookie 共享的过程中，我发现有一个例外，那就是设置了 HttpOnly 属性的 Cookie 是无法被共享的。

为了解决这个问题，我就去查 HttpOnly 相关概念，发现这和 Web 安全体系有关系，然后我就去补安全相关的知识。之后我又发现，浏览器多进程架构和安全系统息息相关，也就是说，这些知识是环环相扣的。

因为我当时对前端并没有做整体的了解，所以对浏览器中涉及到的安全相关的设计也没有过多关注。每次遇到一些和前端相关的细节问题时，我都需要花费很多时间去补充相关知识，然后再去解决问题，这种方式白白地浪费了我非常多的时间。更加遗憾的是，我之前积累的很多前端知识，都是这么缝缝补补学习过来的。

所以，当你要开发一个新项目或者学习一门手艺之前，应该将其所涉及到的知识做一个全方位的了解。我就觉得发明“技术栈”这个词的人是一个天才，“技术栈”非常形象地表达了学习一门手艺所需要的是哪些知识，以及应该按照什么顺序来学。

比如学习前端这门手艺，栈底到栈顶依次是浏览器架构、Web 网络、事件循环机制、JavaScript 核心、V8 的内存管理、浏览器的渲染流程、Web 安全、CSS、React、Vue、Node、构建工具链等，我们可以从栈底往栈顶一步步循序渐进地学习。

学习技术栈的每一层都不难，难的是当你抛开底层栈的内容去理解上层栈的内容，那么就有可能陷入知识点的盲区。

其实，我们在学习一门手艺的时候，即便没有完善的技术栈资料，只要肯花更多的时间，也可以克服困难。甚至我们的学习过程可以像一个无头苍蝇，横冲直撞，某一天也会突然顿悟整个知识体系 ，俗话说，在一个领域耕耘十年，必定能成为该领域的专家。

但是，三年可以搞定的事，为什么非要花上十年呢？

系统性学习一门技术，花费的时间也是最短的，也可以说是性价比最高的，因为系统性地、循序渐进地学习，那么学习到每个知识点时，其实并没有其他的知识盲区，这样学习起来是最轻松、简单的。

虽然技术栈的概念已经提出来很久了，但是我发现大家对技术栈的理解却不同。比如我们在招聘前端工程师时，经常会看到有人在简历上提到自己的技术栈包含了 Vue、React、Node 等这些偏应用的知识，在我看来，这并不是技术栈，这只不过是栈顶函数的上下文而已，而对于那些在技术栈底层上下文相关的知识，在简历中却很少有提及。所谓技术栈，应该是在某一领域，从底层的基础知识到上层的应用技术有一个完整体系。

那么当我们进入一个领域时，应该如何建立适合自己的技术栈呢？

如果你进入的是一个成熟的领域，那么一般都有比较完整的技术栈的资料，我们需要花些时间分析资料，系统性地了解这一领域知识的宏观架构、它的过往历史、它的优缺点，然后结合现有资料和我们自身的特点来建立我们自己的技术栈。如果你所在的领域还在高速发展中，并没有人总结出完整的技术栈，那么为了更好地理解技术的发展脉络，我们需要花更多一些的时间去整理出该领域的技术栈。

所以后面我就吸取了经验，比如我学习神经网络的过程中，就采取了先构建技术栈的方式，先系统性复习了微积分、概率论、线性代数，然后结合实际项目完成了数据清洗、构建模型、模型训练，最后到实际应用。整个过程我只花了半年时间，最后我对神经网络也有了非常深的认知，现在我们正在打算将神经网络运用到我们的新项目中。

采用了同样的的方法，我在学习区块链和数字货币时，整个过程也是非常轻松的，先分析区块链要解决什么问题，然后整理技术栈，再通过技术栈来一步步学习，不到两周，我基本就把整个数字货币的来龙去脉分析的非常清楚了。

好了，到了这里我们的课程也要告一段落了。最后我想跟你说的是，学习一门手艺其实并不难，难的是如何充分利用时间来高效学习，因为这世界最值钱的就是我们的专注力和时间，花三年干成一件事和花十年干成一件事的代价是不一样的，希望你都能够找到适合自己的方法来高效学习，也都能学有所成。

另外，如果你有时间，也请帮我填个课程问卷，我和编辑大茹想收集更多的反馈来优化课程。今天虽然是结课，但我们的工作并没有结束，未来我还会回来加餐、回复留言。

再见。