Skip to content

Google V8

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

另外,V8 也是早于其他虚拟机引入了惰性编译、内联缓存、隐藏类等机制,进一步优化了 JavaScript 代码的编译执行效率

V8 主要是用来执行 JavaScript 代码的,我们首先需要先了解 JavaScript 这门语言的基本特性和设计思想

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

img

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

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

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

img

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

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

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

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

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

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

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

img

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

什么是 V8

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

img

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

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

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

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

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

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

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

但是 CPU 不能直接识别汇编语言,所以如果你使用汇编编写了一段程序,你还需要一个汇编编译器,其作用是将汇编代码编程成机器代码,具体流程你可以参考下图:

img

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

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

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

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

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

第一种是解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果

img

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

img

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

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

V8 是怎么执行 JavaScript 代码的?

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

这是一种权衡策略,因为这两种方法都各自有各自的优缺点,解释执行的启动速度快,但是执行时的速度慢,而编译执行的启动速度慢,但是执行时的速度快

img

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

  • JavaScript 全局执行上下文就包含了执行过程中的全局信息,比如一些内置函数,全局变量等信息
  • 全局作用域包含了一些全局变量,在执行过程中的数据都需要存放在内存中
  • 而 V8 是采用了经典的堆和栈的内存管理模式,所以 V8 还需要初始化内存中的堆和栈结构
  • 另外,想要我们的 V8 系统活起来,还需要初始化消息循环系统,消息循环系统包含了消息驱动器和消息队列,它如同 V8 的心脏,不断接受消息并决策如何处理消息

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

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

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

在生成 AST 的同时,V8 还会生成相关的作用域,作用域中存放相关变量

有了 AST 和作用域之后,接下来就可以生成字节码了,字节码是介于 AST 和机器代码的中间代码。但是与特定类型的机器代码无关,解释器可以直接解释执行字节码,或者通过编译器将其编译为二进制的机器代码再执行

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

在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码

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

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

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

我们以一段最简单的 JavaScript 代码为例,如果将这段非常简单的代码提交给 V8 引擎,V8 在处理过程中,中间所产生的结果是怎样的呢?

JS
var test = 'GeekTime';

首先这段代码会被解析器结构化成 AST

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

d8 --print-ast test.js

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

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

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

img

AST 和代码结构也是一一对应关系,并且后续所有的操作都会直接或者间接基于它

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

d8 --print-scopes test.js

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

js
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)

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

d8 --trace-opt test.js

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

pt --trace-deopt test.js

这段代码过于简单,所以没有触发 V8 的优化机制

总结

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

V8 执行一段 JavaScript 代码所经历的主要流程包括了:

  • 初始化基础环境
  • 解析源码生成 AST 和作用域
  • 依据 AST 和作用域生成字节码
  • 解释执行字节码
  • 监听热点代码
  • 优化热点代码为二进制的机器代码
  • 反优化生成的二进制机器代码

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

1、网上很多的关于 v8 编译的文章包括《WebKit 技术内幕》这本书写道 v8 和 javascriptcore 的最重要的区别就是,v8 不再将 AST 转成字节码或者是中间代码,而是直接转为本地代码,但在您的课程里面,好像很重要的一点就是 AST 转成了字节码文件,请老师能够答疑以下,谢谢

那是初期的 v8,的确这么做的,后面发现这种方式不太适应移动网络,于是有彻底重构了代码

2、著名的还有 JVM 以及 luajit,包括 oracle 最新的 graalVM 都已经采用了 JIT 技术

nice

3、解释器执行字节码,不也是要将字节码转换成二进制代码让 CPU 执行吗?这和编译器有啥区别

解释器不需要转换的

4、前文说解释器生成字节码? 解释器不是执行字节码么

生成自字节码和解释执行都是它干的

函数即对象

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

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

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

什么是 JavaScript 中的对象?

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

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

img

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

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

img

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

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

在 JavaScript 中,我们所谈论的对象到底是指什么呢?

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

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

这个对象里面有四个属性

img

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

之所以 JavaScript 中对象的用途这么广,是因为对象的值可以是任意类型的数据,我们可以改造下上面的那段代码,来看看对象的值都有哪些类型?

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

我们可以先画出这段代码的内存布局

img

我们可以看出来,对象的属性值有三种类型:

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

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

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

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

img

函数的本质

在 JavaScript 中,函数是一种特殊的对象,它和对象一样可以拥有属性和值,但是函数和普通对象不同的是,函数可以被调用

我们先来看一段 JavaScript 代码

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

既然是函数,那么它也可以被调用

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

除了使用函数名称来实现函数的调用,还可以直接调用一个匿名函数

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

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

其实在 V8 内部,会为函数对象添加了两个隐藏属性:

img

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

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

函数是一等公民

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

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

在执行 JavaScript 函数的过程中,为了实现变量的查找,V8 会为其维护一个作用域链,如果函数中使用了某个变量,但是在函数内部又没有定义该变量,那么函数就会沿着作用域链去外部的作用域中查找该变量

img

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

js
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 函数依然引用了该变量

我们也把这种将外部变量和和函数绑定起来的技术称为闭包

总结

一个函数联了的内容:

  • 函数作为一个对象,它有自己的属性和值,所以函数关联了基础的属性和值
  • 函数之所以成为特殊的对象,这个特殊的地方是函数可以“被调用”,所以一个函数被调用时,它还需要关联相关的执行上下文

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

1、如果在函数对象上再用代码 foo.name="aaaa",这会覆盖函数的名称吗?

不会的,编译规程中已经生成函数对象了,对应的作用域中也保存了指向对象的引用了,所以对象本身的 name 属性改变是不会影响到函数正常的执行的

js
function a(){console.log(1)}
a.name="b"
console.log(Object.getOwnPropertyDescriptor(a,"name"));

Object.defineProperty(a, "name", {
    enumerable: false,
    configurable: false,
    writable: true,
    value: "b"
});

console.log(Object.getOwnPropertyDescriptor(a,"name"));
a()

通过描述符 覆盖name属性,但是影响不了函数在环境中的名字

b()
// Uncaught ReferenceError: b is not defined

2、在函数内使用了某个变量,却没有在这个函数中声明,这种变量叫 free variable,比如上面的 number 变量

3、生成 ast 语法树是一次性将所有代码都结构化成 ast 吗 还是部分结构化呀 ?

部分

4、老师说 js 不是面向对象”因为后者天然支持封装继承和多态“,这个是原因么 js 对象不是也原型继承,也可以封装属性么,还是您想说 js 的这些实现其实和 java 这种差异巨大和这种面向对象的语言有别,甚至也不能叫”继承“ 只是当年蹭热点

oop 和 fp(Functional programming)是两种不通的编程风格,oop 有封装、继承、多态,fp 封装、继承,函数是一等公民等特性,所有这两种语言都有封装和继承,只不过实现方式不一样!

但是 js 就不会天生支持多态了,所以使用 javascript 时,我们应当能站在它的语言特性角度来思考,这样使用起来会如鱼得水

当然你也可以将 javascript 强行包装成 oop 的语言,来动态实现多态特性,但是这种方式并不可取

一句话总结:一门语言支持封装和继承并不就意味着支持 oop,oop 还需要多态

快属性和慢属性

JavaScript 语言的角度来看,JavaScript 对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值

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

img

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

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

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

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

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

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

  • 设置的数字属性被最先打印出来了,并且是按照数字大小的顺序打印的
  • 设置的字符串属性依然是按照之前的设置顺序打印的,比如我们是按照 B、A、C 的顺序设置的,打印出来依然是这个顺序

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

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

在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性

img

bar 对象包含了两个隐藏属性:elements 属性和 properties 属性,elements 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性,properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存了常规属性

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

快属性和慢属性

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

基于这个原因,V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties)

img

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

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

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

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

img

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

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

js
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);

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

要想查找我们刚才创建的对象,你可以在搜索框里面输入构造函数 Foo,Chrome 会列出所有经过构造函数 Foo 创建的对象:

img

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

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

接下来我们可以将创建的对象属性的个数调整到 20 个

js
var bar2 = new Foo(20, 10);

img

我们点开第一个 bar2 对象,内容如下所示:

img

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

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

  • 10 属性直接存放在 bar2 的对象内
  • 10 个常规属性以线性数据结构的方式存放在 properties 属性里面
  • 10 个数字属性存放在 elements 属性里面

如果常用属性太多了,比如创建了 100 个,那么我们再来看看其内存分布:

js
var bar3 = new Foo(100, 10);

然后以同样的方式打开 bar3,查看其内存布局:

img

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

  • 10 属性直接存放在 bar3 的对象内
  • 90 个常规属性以非线性字典的这种数据结构方式存放在 properties 属性里面
  • 10 个数字属性存放在 elements 属性里面

其他属性

不过这里还有几个重要的隐藏属性

img

观察上图,除了 elements 和 properties 属性,V8 还为每个对象实现了 map 属性和 proto 属性。proto 属性就是原型,是用来实现 JavaScript 继承的,而 map 则是隐藏类,这两个属性会在后续章节中介绍

总结

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

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

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

思考

通常,我们不建议使用 delete 来删除属性,你能结合文中介绍的快属性和慢属性,给出不建议使用 delete 的原因吗?

1、优秀文章

2、V8 引擎博客

3、词典和字典是怎样的数据结构,类似于树?

就是 hash 表

4、老师,hash 表和 js 中对象是什么关系?我感觉对象就是哈希表,但是我看哈希表的定义里面 key 会经过哈希函数进行编码,这之间有什么区别呢?

你可以把对象看成是一个 hash 表,但是 V8 为了性能,做了很多改进

函数表达式

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

img

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

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

js
foo();
function foo() {
  console.log("foo");
}

在这段代码中,我声明了一个 foo 函数,然后在 foo 函数之前调用了 foo 函数,执行这段代码,我们看到 foo 函数被正确执行了

js
foo();
var foo = function () {
  console.log("foo");
};

执行这段代码,我们发现报错了

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

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

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

img

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

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

函数声明定义了一个具有指定参数的函数

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

V8 在执行 JavaScript 的过程中,会先对其进行编译,然后再执行

img

在编译阶段,如果解析到函数声明,那么 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,如果是声明的函数,那么变量提升之后的值则是函数对象

表达式就是表示值的式子,而语句是操作值的式子

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

而语句则不同,比如定义一个变量var x,这就是一个语句,执行该语句时,V8 并不会返回任何值给你

同样,当我声明了一个函数时,这个函数声明也是一个语句,function foo() {}

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

img

我们知道,在 V8 执行var x = 5这段代码时,会认为它是两段代码,一段是定义变量的语句,一段是赋值的表达式,如下所示:

js
var x;
x = 5;

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

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

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

表达式是不会在编译阶段执行的,那么函数声明是表达式还是语句呢?

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

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

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

img

V8 是怎么处理函数表达式的?

我们在一个表达式中使用 function 来定义一个函数,那么就把该函数称为函数表达式

var foo = function() {}

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

  • 函数表达式是在表达式语句中使用 function 的,最典型的表达式是“a=b”这种形式,因为函数也是一个对象,我们把“a = function (){}”这种方式称为函数表达式
  • 在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions)
  • 一个函数表达式可以被用作一个即时调用的函数表达式——IIFE(Immediately Invoked Function Expression)
js
foo();
var foo = function () {
  console.log("foo");
};

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

js
var foo = undefined;
foo = function () {
  console.log("foo");
};

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

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

立即调用的函数表达式(IIFE)

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

JavaScript 中有一个圆括号运算符,圆括号里面可以放一个表达式:(a=3)

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

如果在小括号里面放上一段函数的定义:(function() {})

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

存放在括号里面的函数便是一个函数表达式,它会返回一个函数对象,如果我直接在表达式后面加上调用的括号,这就称为立即调用函数表达式(IIFE)

js
(function () {
  //statements
})();

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

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

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

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

思考

1、老师,我有一个巨大的疑问,麻烦您有时间的话,一定帮我解答,回答是或不是就行! 在函数即对象一文,您提到“函数有个隐藏属性是 code 属性,其值表示函数代码,以字符串的形式存储在内存中。”,所以我理解成了,函数本质是储存在栈中的! 可是在这篇文里,你明确说到函数是被 v8 储存在堆中里的。 能解释下,具体是什么回事么? 因为感觉函数和普通对象还是有些区别的,之前看那篇文章里提到的 code 属性,以为区别就是函数有一部分是储存在栈中,现在似乎是我理解错了?

这个理解是错误的,如果你定义了一个对象,那么在运行时,改对象一定是存放在堆中的,包括对象里面的原生类型的属性值也是存放在堆中的! 所以当一个 V8 在编译阶段解析到一个函数声明时,它首先在堆中创建改函数对象,然后为改对象设置各个属性值!

2、在文中一会说 解析阶段,一会说编译阶段,这里这两个概念是一样的吧,解析阶段就是编译阶段,生成作用域和字节码

大的范围上来讲,我把从源码解析到输出字节码和字节码编译为二进制代码,都称为编译阶段,执行字节码和执行二进制代码称为执行阶段!

小范围来讲,编译阶段又分为解析,预解析、生成字节码、编译成二进制代码!

这里一个是大范围的编译,一个是小范围的!

3、按照当前文章的理解,所有的程序在执行前引擎都会有一个编译过程,生产作用域和字节码。这样在编译过程中,函数的执行应该会把所有的函数加入一个执行队列中,然后按照先进先出方式对函数进行执行。这样的理解对于在主线程的任务应该是一个执行队列,而不是一个执行栈

消息队列里面的存放的是一个个嗷嗷待执行的任务,然后主线程会从消息队列中按照特定的策略: while(1){ 取出任务() 执行任务() }

在执行每个任务的过程中,都会有一个栈结构来管理这个任务的函数调用关系; 所以说,执行栈又称调用栈,它是针对消息队列中的单个任务的!

4、老师,函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用。难道函数声明就不能封装变量和函数从而起到变量隔离和代码隐藏的作用了吗???再者,代码隐藏作何解???还望老师百忙中回复这两个问题

因为当初 JavaScript 只有函数级作用域,没有块级作用域和空间作用域,所以要实现模块化开发,隐藏内部函数和变量只能使用函数,通常情况下,我们使用函数立即表达式,也就是将一个函数内部需要使用的方法和变量直接暴露给一个全局变量!

函数和函数立即表达式都能隐藏变量,但是通常模块化开发都是使用函数立即表达式来封装内部方法和变量,并直接返回需要暴露的内容!

5、var foo = 1; (function foo(){ foo = 100; console.log(foo); }()) console.log(foo); 这个题怎么理解?

因为是在 foo 函数内部调用 foo,所以会有限使用函数属性

6、(function foo() {})(); console.log(foo); VM2044:2 Uncaught ReferenceError: foo is not defined at <anonymous>:2:13

当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问。这是规定

原型链

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

img

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

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

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

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

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

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

原型继承是如何实现的?

img

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

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

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

比如我让 C 对象的原型指向 B 对象,那么便可以利用 C 对象来直接访问 B 对象中的属性或者方法了

img

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

img

同理,B 对象也有原型,也可以指向其他对象,这样形成的一个链式结构,就被称为原型链

注意不要将原型链接和作用域链搞混淆了,作用域链是沿着函数的作用域一级一级来查找变量的,而原型链是沿着对象的原型一级一级来查找属性的,虽然它们的实现方式是类似的,但是它们的用途是不同的

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

img

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

实践:利用 __proto__ 实现继承

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

我们可以通过设置 dog 对象中的 __proto__ 属性,将其指向 animal:

js
dog.__proto__ = animal;

调用 dog.getInfo() 时,getInfo 函数中的 this.type 和 this.color 都是什么值?为什么?

this.type => Dog; this.color => Black;

this 指向调用它的 dog,而函数在原型链上找

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

  • 首先,[ 这是隐藏属性 ],并不是标准定义的(但在解释里老师却说__proto__不是隐藏属性)
  • 其次,使用该属性会造成严重的性能问题(修改 __proto__ 会破坏 v8 通过隐藏类优化好的结构对象,进而引发隐藏类对该数据对象重新优化)

那应该怎么去正确地设置对象的原型对象呢?

答案是使用构造函数来创建对象

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

比如我们要创建一个 dog 对象,我可以先创建一个 DogFactory 的函数,属性通过参数进行传递,在函数体内,通过 this 设置属性值

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

然后再结合关键字“new”就可以创建对象了

js
var dog = new DogFactory("Dog", "Black");

通过这种方式,我们就把后面的函数称为构造函数,因为通过执行 new 配合一个函数,JavaScript 虚拟机便会返回一个对象

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

js
var dog = {};
dog.__proto__ = DogFactory.prototype;
DogFactory.call(dog, "Dog", "Black");

img

我们可以看到执行流程分为三步:

  • 首先,创建了一个空白对象 dog
  • 然后,将 DogFactory 的 prototype 属性设置为 dog 的原型对象,这就是给 dog 对象设置原型对象的关键一步
  • 最后,再使用 dog 来调用 DogFactory,这时候 DogFactory 函数中的 this 就指向了对象 dog,然后在 DogFactory 函数中,利用 this 对对象 dog 执行属性填充操作,最终就创建了对象 dog

构造函数怎么实现继承?

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

上面这段代码创建了三个 dog 对象,每个对象都占用了一块空间

img

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

我们介绍函数时提到关于函数有两个隐藏属性吗?这两个隐藏属性就是 name 和 code,其实函数还有另外一个隐藏属性,那就是 prototype,一个函数有以下几个隐藏属性:

img

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

新对象的原型对象指向了构造函数的 prototype 属性,当你通过一个构造函数创建多个对象的时候,这几个对象的原型都指向了该函数的 prototype 属性

img

这时候我们可以将 constant_temperature 属性添加到 DogFactory 的 prototype 属性上

js
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()

不过代码形式只是表象,其背后原理是完全不同的

思考

1、DogFactory.prototype”和“DogFactory.__proto__”这两个属性之间有关联吗?

DogFactory 是 Function 构造函数的一个实例,所以 DogFactory.__proto__ === Function.prototype

DogFactory.prototype 是调用 Object 构造函数的一个实例,所以 DogFactory.prototype.__proto__ === Object.prototype

因此 DogFactory.proto 和 DogFactory.prototype 没有直接关系

2、老师,这几节课看了有关对象,函数这些东西在 v8 的实现,感觉还不过瘾,想问下老师能否把文中提到的一些 v8 的实现思路,在文末增加一个链接直接跳转到 v8 的 c++源代码里 具体到文件和行号?

这个专栏定位还是给前端工程师的,所以根本没打算讲源码,源码比想象的复杂太多,光一个原型的实现就做了很多复杂的优化!比如通过隐藏类优化了很多原有的对象结构,所以通过直接修改—proto—会直接破坏现有已经优化的结构,造成严重的性能问题!

另外比如讲作用域的 C++实现我觉得也没太大意义,有能力看代码的人结合文档和流程就可以直接去看代码了!

比如编译流程,代码的文档结构 在 v8.dev 中都有介绍

3、构造函数、继承概念图

构造函数-原型对象-实例关系图

ES6继承(extends)关系图By@若川

4、箭头函数能否成为一个构造函数?

不能,箭头函数在 js 里也是一个比较特殊的存在,根本没是 prototype 的属性,自然也没有 constructor

5、Null 设计的初衷是什么 它具体担任了什么样的角色

最初 NULL 就代表是空,比如 Number(null),就会返回一个 0,可以把 null 看成是 c 中或者 java 中的 null

可以根据一个值是否是 null,来判断做什么事情

但是 javascript 同时支持原生类型和对象类型,null 是一个对象,那么发明者认为,对象和原生类型进行默认转换,会造成很多误解,并且不容易发现错误,那么又设计了一个 undefined,用来表示未使用的原始值,转换为数值时为 NaN!

总的来说,这个设计糟糕的一塌糊涂,但是我们依然得使用它们

还需要一个类型来表示原生类型的

作用域链

原型链将一个个原型对象串起来,从而实现对象属性的查找

作用域链就是将一个个作用域串起来,实现变量查找的路径

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

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

js
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();

输出应该是foo global

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

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

js
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 函数中定了三个变量,那么常见的作用域就包含有这三个变量

浏览器开发工具上的状态:

img

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

另外你还需要注意下,函数中的 test,我并没有采用 var 等关键字来声明,所以 test 并不会出现在 test_scope 函数的作用域中,而是属于 this 所指向的对象(所有的这种情况都会一样,包括嵌套函数中的也会属于 window 对象,这里我做了测试,发现并不是老师所说的 this 对象,如下)

js
var test = 1;
var aa = {
  test: 0,
};
function a() {
  function b() {
    console.log(this);
    test = 3;
  }
  console.log(aa);
  b.call(aa);
}
a();

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

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

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

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

作用域链是怎么工作的?

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

img

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

V8 会先编译顶层代码,在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中

img

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

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

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

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

当 V8 执行 bar 函数的时候,同样需要经历两个阶段:编译和执行。在编译阶段,V8 会为 bar 函数创建函数作用域

img

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

同样,在编译 foo 函数的过程中,会创建 foo 函数的作用域

img

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

因为 JavaScript 是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的

bar 和 foo 函数的外部代码都是全局代码,所以无论你是在 bar 函数中查找变量,还是在 foo 函数中查找变量,其查找顺序都是按照当前函数作用域–> 全局作用域这个路径来的

img

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

和静态作用域相对的是动态作用域,动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是基于函数定义的位置的

思考

1、如何思考这个问题?

js
var a = [];
for (let i = 0; i < 10; i++) {
  a[i] = function () {
    console.log(i);
  };
}
a[2]();

let 定义的 i 会运行 for 的块级作用域中,每次执行一次循环,都会创建一个块级作用域

在这个块级作用域中,你又定义了一个函数,而这个函数又引用了函数外部的 i 变量,那么这就产生了闭包,也就是说,所有块级作用域中的 i 都不会被销毁,你在这里执行了 10 次循环,那么也就创建了 10 个块级作用域,这十个块级作用域中的变量 i 都会被保存在内存中

那么当你再次调用该 a[n]()时,v8 就会拿出闭包中的变量 i,并将其打印出来,因为每个闭包中的 i 值都不同,所以 a[n]()时,打印出来的值就是 n,这个就非常符合直觉了

但是如果你将 for 循环中的 i 变量声明改成 var,那么并不会产生块级作用域,那么函数引用的 i 就是全局作用域中的了,由于全局作用域中只有一个,那么在执行 for 循环的时候,i 的值会一直被改变,最后是 10,所以最终你执行 a[n]()时,无论 n 是多少,打印出来的都是 10. 那么这就是 bug 之源了

2、和 this 对比就很好记了,可以简单的理解为 this 是看函数的调用位置,作用域是看函数的声明位置。除了箭头函数等那些特殊的情况

可以认为 this 是用来弥补 JavaScript 没有动态作用域特性的

3、function f(){setTimeOut(f,0)}面试官问我这种调用会不会导致内存溢出?

不会溢出啊,因为这是异步调用,下次执行 f 函数时,已经在新的栈中执行了,所以当前栈不会发生溢出!

**4、在大量数据时(百万级别) ,foreach 循环比 for 循环的执行效率低,是因为什么 **

因为 foreach 有函数回调过程啊,每次回调都要额外创建新的额外的栈贞,新的上下文,那么效率也就随之下来了

5、根据 ECMAScript 最新规范,函数对象有一个[[Environment]]内部属性,保存的是函数创建时当前正在执行的上下文环境,当函数被调用并创建执行上下文时会以[[Environment]]的值初始化作用域链,所以从规范也可以得知函数的作用域只跟函数创建时的当前上下文环境有关

规范中关于[[Environment]]的描述:https://tc39.es/ecma262/#sec-ecmascript-function-objects

6、如果我运行一个 js 文件,在解释阶段生成 AST 树之后,紧接着,这个 js 文件的所有的作用域(函数作用域,块级作用域,全局作用域)就都已经确定了,就算有某些函数没有被执行,它的作用域内含有哪些变量也已经确定了,但是这些变量还都不会真实存在栈或堆中。也就是说,某个未执行函数的执行上下文中的变量环境和词法环境现在也已经确定了,这样理解对吗?

正常情况下是这样的,单是执行 eval 的情况,这个 eval 方法很有破坏性,因为在执行 eval 之前,引擎并不知道 eval 要执行的内容,也就没有办法提前做预解析

(按照 d8 的结果,这个问题中的说法是错误的,如果我不执行任何函数,我只会将全局作用域生成出来,并且将该函数中的作用域生成,并不会向下继续生成)

测试代码:

js
var test = 1;
var aa = {
  test: 0,
};
function a() {
  function b() {
    test = 3;
  }
  b();
}
a();

执行 a 和 b:

Inner function scope:
function a () { // (000001AE0D1D91B8) (56, 118)
  // NormalFunction
  // 2 heap slots
  // local vars:
  VAR b;  // (000001AE0D1D6E38) never assigned

  function () { // (000001AE0D1D6BD0) (76, 105)
    // NormalFunction
    // 2 heap slots
  }
}
Global scope:
global { // (000001AE0D1D8BE0) (0, 126)
  // will be compiled
  // NormalFunction
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (000001AE0D1D9568) local[0]
  // local vars:
  VAR aa;  // (000001AE0D1D8EC0)
  VAR test;  // (000001AE0D1D8E00)
  VAR a;  // (000001AE0D1D9480)

  function a () { // (000001AE0D1D91B8) (56, 118)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}
Global scope:
function a () { // (000001AE0D1DF400) (56, 118)
  // will be compiled
  // NormalFunction
  // 1 stack slots
  // local vars:
  VAR b;  // (000001AE0D1DF790) local[0], never assigned

  function b () { // (000001AE0D1DF620) (76, 105)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}
Global scope:
function b () { // (000001AE0D1D6BC0) (76, 105)
  // will be compiled
  // NormalFunction
}

不执行 b:

Inner function scope:
function a () { // (00000267C674AC08) (56, 121)
  // NormalFunction
  // 2 heap slots
  // local vars:
  VAR b;  // (00000267C6752B38) never assigned

  function () { // (00000267C67528D0) (76, 105)
    // NormalFunction
    // 2 heap slots
  }
}
Global scope:
global { // (00000267C674A630) (0, 129)
  // will be compiled
  // NormalFunction
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (00000267C674AFB8) local[0]
  // local vars:
  VAR aa;  // (00000267C674A910)
  VAR a;  // (00000267C674AED0)
  VAR test;  // (00000267C674A850)

  function a () { // (00000267C674AC08) (56, 121)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}
Global scope:
function a () { // (00000267C6715410) (56, 121)
  // will be compiled
  // NormalFunction

  function b () { // (00000267C6715630) (76, 105)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}

不执行 a:

Inner function scope:
function a () { // (0000020CD8011CA8) (56, 121)
  // NormalFunction
  // 2 heap slots
  // local vars:
  VAR b;  // (0000020CD8013D48) never assigned

  function () { // (0000020CD8013AE0) (76, 105)
    // NormalFunction
    // 2 heap slots
  }
}
Global scope:
global { // (0000020CD80116D0) (0, 132)
  // will be compiled
  // NormalFunction
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0000020CD8012008) local[0]
  // local vars:
  VAR a;  // (0000020CD8011F70)
  VAR aa;  // (0000020CD80119B0)
  VAR test;  // (0000020CD80118F0)

  function a () { // (0000020CD8011CA8) (56, 121)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}

所以应该是下面 7 的论述才是对的

7、之前提到在编译阶段就会生成作用域和 AST,在本节中又提到函数在执行时才会创建作用域。那么编译时创建的作用域具体是哪些作用域,因为通过 d8 print-scopes 发现都所有作用域都存在

是打印所有的作用域,编译的时候就编译什么代码就创建什么代码的作用域。 比如执行全局代码的时候,只会生成全局作用域,函数的作用域就不会被生成,当执行某个函数时,就会生成函数的作用域了

类型转换

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

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

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

img

比如在 C/C++ 中,你需要为要处理的每条数据指定类型

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

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

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

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

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

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

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

wiki 百科上是这样解释的:

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

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

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

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

当有两个值相加的时候,比如:a + b

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

img

通俗地理解,V8 会提供了一个 ToPrimitive 方法,其作用是将 a 和 b 转换为原生数据类型

  • 先检测该对象中是否存在 valueOf 方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换
  • 如果 valueOf 没有返回原始类型,那么就使用 toString 方法的返回值
  • 如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误

img

当 V8 执行 1+“2”时,因为这是两个原始值相加,原始值相加的时候,如果其中一项是字符串,那么 V8 会默认将另外一个值也转换为字符串:Number(1).toString() + "2"

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

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

由于需要先使用 ToPrimitive 方法将 Obj 转换为原生类型,而 ToPrimitive 会优先调用对象中的 valueOf 方法,由于 valueOf 返回了 100,那么 Obj 就会被转换为数字 100,那么数字 100 加数字 3,那么结果当然是 103 了

如果我改造下代码,让 valueOf 方法和 toString 方法都返回对象:

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

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

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

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

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

思考

1、ToPrimitive 的第二个参数期望值 如果没填默认值是 number 但是 date 类型的默认值是 string;Number 就是 valueof 先调用 string 就是 tostring 先调用;toNumberic toNumber toBigint >和< 都是传递 Number [toprimitive(arguments,Number)] tostring 是传递 string [toprimitive(arguments,string)]; ==和+都是没传递 preferredtype 的,会使用当前类型的默认值(日期是 string,其他都是 Number)

2、一直有个疑问,js 垃圾回收会回收原型吗,比如 fuction,我声明了之后,没有用,会被垃圾回收给给回收掉吗,还是想其他静态语言一样一直程序生命结束一直存在

函数是不会被回收的

如何使用 d8?

d8 --help,可以显示所有命令

d8 --help | grep print, 显示所有关于 print 的命令,Windows 如果没有 grep 命令可以下载:https://sourceforge.net/projects/gnuwin32/files/grep/2.5.4/grep-2.5.4-setup.exe/download?use_mirror=managedway,然后添加环境变量

在使用 d8 执行一段代码之前,你需要将你的 JavaScript 源码保存到一个 js 文件中

打印优化数据

--print-ast,查看生成的 AST

--print-scopes,查看中间生成的作用域

--print-bytecode,查看生成的字节码

--trace-opt-verbose,查看优化代码,例如:

img

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

如果我们循环更大的话:

img

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

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

于是,V8 采用了 OSR 技术,将 bar 函数和 foo 函数合并成一个新的函数

img

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

OSR 相关文章

查看垃圾回收

trace-gc

img

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

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

js
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();

再次执行命令,这时候没有任何垃圾回收的提示了

内部方法

另外,你还可以使用 V8 所提供的一些内部方法,只需要在启动 V8 时传入 allow-natives-syntax 命令

d8 --allow-natives-syntax test.js

我们可以通过内部方法 HasFastProperties 来检查一个对象是否拥有快属性

js
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));

使用 delete 时候,我们查找属性的速度就会变慢,这也是我们尽量不要使用 delete 的原因

除了 HasFastProperties 方法之外,V8 提供的内部方法还有很多,比如你可以使用 GetHeapUsage 来查看堆的使用状态,可以使用 CollectGarbage 来主动触发垃圾回收,诸如 HaveSameMap、HasDoubleElements 等,更多内部方法

运行时环境

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

img

img

什么是宿主环境?

在生物学上,宿主是指为病毒等寄生物提供生存环境的生物,宿主有自己的完整的代谢系统,而病毒则没有自己的代谢系统,也没有自己的酶系统,它只是由核酸长链和蛋白质外壳构成

因此,病毒想要完成自我复制,则会和宿主共同使用一套代谢系统,当病毒离开了宿主细胞,就成了没有任何生命活动,也不能独立自我繁殖的化学物质。同时,如果病毒利用了太多的宿主细胞资源,也会影响到细胞的正常活动

同样,你可以把 V8 和浏览器的渲染进程的关系看成病毒和细胞的关系,浏览器为 V8 提供基础的消息循环系统、全局变量、Web API,而 V8 的核心是实现了 ECMAScript 标准,这相当于病毒自己的 DNA 或者 RNA,V8 只提供了 ECMAScript 定义的一些对象和一些核心的函数,这包括了 Object、Function、String。除此之外,V8 还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要宿主环境的配合才能完整执行

如果 V8 使用不当,比如不规范的代码触发了频繁的垃圾回收,或者某个函数执行时间过久,这些都会占用宿主环境的主线程,从而影响到程序的执行效率,甚至导致宿主环境的卡死

其实,除了浏览器可以作为 V8 的宿主环境,Node.js 也是 V8 的另外一种宿主环境,它提供了不同的宿主对象和宿主的 API,但是整个流程依然是相同的,比如 Node.js 也会提供一套消息循环系统,也会提供一个运行时的主线程

img

构造数据存储空间:堆空间和栈空间

由于 V8 是寄生在浏览器或者 Node.js 这些宿主中的,因此,V8 也是被这些宿主启动的。比如,在 Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间

栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉

栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层过深,那么 V8 就有可能抛出栈溢出的错误:VM68:1 Uncaught RangeError: Maximum call stack size exceeded

如果有一些占用内存比较大的数据,或者不需要存储在连续空间中的数据,使用栈空间就显得不是太合适了,所以 V8 又使用了堆空间

堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,在前面我们也讲过,JavaScript 中除了原生类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有 window 对象、document 对象等,这些都是存在堆空间的

宿主在启动 V8 的过程中,会同时创建堆空间和栈空间,再继续往下执行,产生的新数据都会存放在这两个空间中

全局执行上下文和全局作用域

V8 初始化了基础的存储空间之后,接下来就需要初始化全局执行上下文和全局作用域了,这两个内容是 V8 执行后续流程的基础

当 V8 开始执行一段可执行代码时,会生成一个执行上下文。V8 用执行上下文来维护执行当前代码所需要的变量声明、this 指向等

执行上下文中主要包含三部分,变量环境、词法环境和 this 关键字。比如在浏览器的环境中,全局执行上下文中就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数,诸如 setTimeout、XMLHttpRequest 等内容

而词法环境中,则包含了使用 let、const 等变量的内容

img

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

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

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

这段代码在执行时,就会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中

img

当 V8 调用了一个函数时,就会进入函数的执行上下文,这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构

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

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

img

构造事件循环系统

有了堆空间和栈空间,生成了全局执行上下文和全局作用域,接下来就可以执行 JavaScript 代码了吗?

答案是不行,因为 V8 还需要有一个主线程,用来执行 JavaScript 和执行垃圾回收等工作。V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的

只有一个主线程依然不行,因为如果你开启一个线程,在该线程执行一段代码,那么当该线程执行完这段代码之后,就会自动退出了,执行过程中的一些栈上的数据也随之被销毁,下次再执行另外一个段代码时,你还需要重新启动一个线程,重新初始化栈数据,这会严重影响到程序执行时的性能

为了在执行完代码之后,让线程继续运行,通常的做法是在代码中添加一个循环语句,在循环语句中监听下个事件,比如你要执行另外一个语句,那么激活该循环就可以执行了

如果主线程正在执行一个任务,这时候又来了一个新任务,比如 V8 正在操作 DOM,这时候浏览器的网络线程完成了一个页面下载的任务,而且 V8 注册监听下载完成的事件,那么这种情况下就需要引入一个消息队列,让下载完成的事件暂存到消息队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务。当执行完一个任务之后,我们的事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务

有一点你需要注意一下,因为所有的任务都是运行在主线程的,在浏览器的页面中,V8 会和页面共用主线程,共用消息队列,所以如果 V8 执行一个函数过久,会影响到浏览器页面的交互性能

思考

1、这个事件循环一直有个点不太明白,事件循环是跑在主线程的,需要不断轮询,它在没有任务的时候是如何保证不卡死的,就像我们随便写一段死循环,cpu 都 100%了,它是如何做的?

实际上是事件驱动的!

也就是说,如果没有任务,那么该线程将被挂起,一旦有新的任务到达了消息队列,那么系统会将这个挂起的线程激活,激活之后线程继续向下执行!

我在例子中使用的是 while(1)这是为了讲解方便,实际项目中不可能这样用的!

比如 node 使用的是 libuv 就封装好了这种机制,Chrome 浏览器中相对复杂点,但是本质一样!

2、老师,有个疑惑希望您能解答,我看您的图:宿主环境和 V8 的关系 里面堆栈空间是属于 宿主环境的,但是我看很多文章写的都是 堆栈是属于 v8 引擎提供的,不知道哪个是正确的。 我其实比较偏向与是 v8 提供的,因为他要进行垃圾回收,如果 v8 来提供可能比较好管理一点

因为是一个进程内部的,所以宿主和 v8 共同一套内存空间,通常在启动 V8 的过程中,宿主会创建好堆和栈的空间,在 V8 中叫 isolate,然后 V8 利用创建好的堆和栈!

所以不用纠结谁创建的,因为他们使用的是一套内存

机器代码

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

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

字节码的执行模式和 CPU 直接执行二进制代码的模式是类似的

img

将源码编译成机器码

js
int main()
{
    int x = 1;
    int y = 2;
    int z = x + y;
    return z;
}

通过 GCC 编译器将这段 C 代码编译成二进制文件gcc -O0 -o code_prog code.c

接下来我们再将编译出来的 code_prog 程序进行反汇编,这样我们就可以看到二进制代码和对应的汇编代码。你可以使用 objdump 的完成该任务objdump -d code_prog

(下图中第 4 行解释稍微有错误,在思考中有对话)

img

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

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

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

所以程序的执行,本质上就是 CPU 按照顺序执行这一大堆指令的过程

CPU 是怎么执行程序的?

img

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

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

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

内存中的每个存储空间都有其对应的独一无二的地址

img

在内存中,每个存放字节的空间都有其唯一的地址,而且地址是按照顺序排放的

开头的那段 C 代码会被编译成可执行文件,可执行文件中包含了二进制的机器码,当二进制代码被加载进了内存后,那么内存中的每条二进制代码便都有了自己对应的地址

img

有时候一条指令只需要一个字节就可以了,但是有时候一条指令却需要多个字节。在上图中,对于同一条指令,我使用了相同的颜色来标记

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

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

CPU 是怎么知道要取出内存中的哪条指令呢?

img

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

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

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

img

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

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

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

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

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

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

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

第一种是加载的指令,其作用是从内存中复制指定长度的内容到通用寄存器中,并覆盖寄存器中原来的内容

img

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

第二种存储的指令,和加载类型的指令相反,其作用是将寄存器中的内容复制内存某个位置,并覆盖掉内存中的这个位置上原来的内容

img

第三种是更新指令,其作用是复制两个寄存器中的内容到 ALU 中,也可以是一块寄存器和一块内存中的内容到 ALU 中,ALU 将两个字相加,并将结果存放在其中的一个寄存器中,并覆盖该寄存器中的内容

img

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

img

除了以上指令之外,还有 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 %rbpretq

思考

1、不理解为什么要 movl $0, -4(%rbp) 把 0 写进栈帧

这个我也没搞懂,我继续找专家请教哈,有结果我会第一时间回复

2、“movl $0, -4(%rbp)”这条指令并不是“在栈中把返回值默认设置为 0”,一般情况下函数不是通过 eax 来返回值?那为什么还要多此一举呢?感觉这一条指令的存在像是编译器的默认做法,可能是出于安全考虑

这个的确不是返回值,我特地问了几个精通汇编的朋友,为什么要在栈中放个 0 他们也没有给出准确答案

堆和栈

js
function foo() {
  foo();
}
foo();

function foo() {
  setTimeout(foo, 0);
}
foo();

function foo() {
  return Promise.resolve().then(foo);
}
foo();

第一个方法会导致栈溢出,第二个方法不会有问题,第三个方法会导致页面卡住

主要原因是这三段代码的底层执行逻辑是完全不同的:

  • 第一段代码是在同一个任务中重复调用嵌套的 foo 函数
  • 第二段代码是使用 setTimeout 让 foo 函数在不同的任务中执行
  • 第三段代码是在同一个任务中执行 foo 函数,但是却不是嵌套执行

V8 执行这三种不同代码时,它们的内存布局是不同的,而不同的内存布局又会影响到代码的执行逻辑

解释执行和直接执行二进制代码都使用了堆和栈,虽然它们在执行细节上存在着一定的差异,但是整体的执行架构是类似的

img

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

因为通常函数有两个主要的特性:

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

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

看一段 C 代码:

c
int getZ()
{
    return 4;
}
int add(int x, int y)
{
    int z = getZ();
    return x + y + z;
}
int main()
{
    int x = 5;
    int y = 6;
    int ret = add(x, y);
}

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

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

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

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

5、然后 main 函数继续向下执行

img

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

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

img

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

img

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

栈如何管理函数调用?

当一个函数被执行时,函数的参数、函数内部定义变量都会依次压入到栈中

img

函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中

当一个函数调用另外一个函数时,栈的变化情况是怎样的?

c
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;
}

当执行到 int z = add(x,y) 时

img

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

img

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

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

img

有了栈顶指针,就很容易恢复 main 函数的执行现场了,当 add 函数执行结束时,只需要将栈顶指针向下移动就可以了

img

CPU 是怎么知道要移动到这个地址呢?

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

img

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

那么现在,我们可以执行 main 函数了吗?

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

通常的方法是在 main 函数中调用 add 函数时,CPU 会将当前 main 函数的栈帧指针保存在栈中

img

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

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

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

我们再回过头来看下这节课开头提到的三段代码

  • 第一段代码由于循环嵌套调用了 foo,所以当函数运行时,就会导致 foo 函数会不断地调用 foo 函数自身,这样就会导致栈无限增,进而导致栈溢出的错误
  • 第二段代码是在函数内部使用了 setTimeout 来启动 foo 函数,这段代码之所以不会导致栈溢出,是因为 setTimeout 会使得 foo 函数在消息队列后面的任务中执行,所以不会影响到当前的栈结构。 也就不会导致栈溢出
  • 最后一段代码是 Promise,Promise 的情况比较特别,既不会造成栈溢出,但是这种方式会导致主线的卡死,这就涉及到了微任务

既然有了栈,为什么还要堆?

使用栈有非常多的优势:

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

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

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

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

和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,你可以在任何时候分配和释放它

c
struct Point
{
    int x;
    int y;
};


int main()
{
    int x = 5;
    int y = 6;
    int *z = new int;
    *z = 20;


    Point p;
    p.x = 100;
    p.y = 200;


    Point *pp = new Point();
    pp->y = 400;
    pp->x = 500;
    delete z;
    delete pp;
    return 0;
}

观察上面这段代码,你可以看到代码中有 new int、new Point 这种语句,当执行这些语句时,表示要在堆中分配一块数据,然后返回指针,通常返回的指针会被保存到栈中,下面我们来看看当 main 函数快执行结束时,堆和栈的状态

img

当使用 new 时,我们会在堆中分配一块空间,在堆中分配空间之后,会返回分配后的地址,我们会把该地址保存在栈中,如上图中 p 和 pp 都是地址,它们保存在栈中,指向了在堆中分配的空间

通常,当堆中的数据不再需要的时候,需要对其进行销毁,在 C 语言中可以使用 free,在 C++ 语言中可以使用 delete 来进行操作

像 C/C++ 这种手动管理内存的语言,如果没有手动销毁堆中的数据,那么就会造成内存泄漏。不过 JavaScript,Java 使用了自动垃圾回收策略,可以实现垃圾自动回收,但是事情总有两面性,垃圾自动回收也会给我们带来一些性能问题

总结

因为现代语言都是基于函数的,每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为调用栈

思考

1、function foo() { return Promise.resolve().then(foo) } foo() 上述代码执行后其实还是会报错的。 在执行 5-10 分钟后,Chrome 会报错: paused before potential out-of-memory crash 然后当前宏任务继续处于被挂起状态

2、请问下栈帧是一个逻辑内存还是物理内存?

程序使用了自己的进程空间,64 位系统,每个程序的虚拟进程空间是 2^64,实际上使用时,才会在物理内存中开辟空间! 所以可以说,栈空间都是程序的虚拟空间,使用的地址和实际内存中的地址是不一样的,中间还做了一层映射!

3、老师,我有一个疑惑哈,就是我看你图中这些变量是按照栈来存的,那么当访问先入栈的变量的时候岂不是要把后入栈的弹出去才能访问?但是我觉得肯定不会这么做,老师能解释下怎么访问栈底部的变量么?

不需要弹出来啊,比如栈帧指针地址是 1000,那么变量 x 对应栈顶的偏移是 16,那么变量 x 的内存位置就是 1000-16(通常情况下,栈的方向是向下增长的)

延迟解析

V8 执行 JavaScript 代码,需要经过编译执行两个阶段,其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字节码,或者是 CPU 直接执行二进制机器代码的阶段

img

在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点:

  • 首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。因为有时候一个页面的 JavaScript 代码都有 10 多兆,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间
  • 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源

基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码

惰性解析的过程

js
function foo(a, b) {
  var d = 100;
  var f = 10;
  return d + f + a + b;
}
var a = 1;
var c = 4;
foo(1, 5);

当把这段代码交给 V8 处理时,V8 会至上而下解析这段代码,在解析过程中首先会遇到 foo 函数,由于这只是一个函数声明语句,V8 在这个阶段只需要将该函数转换为函数对象

img

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

然后继续往下解析,由于后续的代码都是顶层代码,所以 V8 会为它们生成抽象语法树

img

代码解析完成之后,V8 便会按照顺序自上而下执行代码,首先会先执行“a=1”和“c=4”这两个赋值表达式,接下来执行 foo 函数的调用,过程是从 foo 函数对象中取出函数代码,然后和编译顶层代码一样,V8 会先编译 foo 函数的代码,编译时同样需要先将其编译为抽象语法树和字节码,然后再解释执行

不过在 V8 实现惰性解析的过程中,需要支持 JavaScript 中的闭包特性,这会使得 V8 的解析过程变得异常复杂

拆解闭包——JavaScript 的三个特性

JavaScript 中的闭包有三个基础特性:

第一,JavaScript 语言允许在函数内部定义新的函数

主要是因为 JavaScript 中的函数即对象,你可以在函数中声明一个变量,当然你也可以在函数中声明一个函数

第二,可以在内部函数中访问父函数中定义的变量

由于可以在函数中定义新的函数,所以很自然的,内部的函数可以使用外部函数中定义的变量,比如 inner 函数和 foo 函数,inner 是在 foo 函数内部定义的,我们就称 inner 函数是 foo 函数的子函数,foo 函数是 inner 函数的父函数。这里的父子关系是针对词法作用域而言的,因为词法作用域在函数声明时就决定了,比如 inner 函数是在 foo 函数内部声明的,所以 inner 函数可以访问 foo 函数内部的变量,比如 inner 就可以访问 foo 函数中的变量 d

但是如果在 foo 函数外部,也定义了一个变量 d,那么当 inner 函数访问该变量时,会沿着词法作用域链的途径来查找

inner 函数在自己的作用域中没有查找到变量 d,就接着在 foo 函数的作用域中查找,再查找不到才会查找顶层作用域中的变量

第三,因为函数是一等公民,所以函数可以作为返回值

闭包给惰性解析带来的问题

js
function foo() {
  var d = 20;
  return function inner(a, b) {
    const c = a + b + d;
    return c;
  };
}
const f = foo();
  • 当调用 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

当执行 foo 函数的时候,堆栈的变化如下图所示:

img

从上图可以看出来,在执行全局代码时,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 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个:

第一,是判断当前函数是不是存在一些语法上的错误

第二,除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题

思考

1、“惰性编译”或者“惰性解析”,也就是说 v8 默认不会对函数内部的代码进行编译,只有当函数被执行前,才会进行编译。在编译阶段,v8 不会完全不解析函数,而是预解析函数,简单理解来说,就是判断一下父函数中是否有被子函数饮用的变量,如果有的话,就需要把这个变量 copy 一份到 堆内存中,同时子函数本身也是一个对象,它会被存在堆内存中,这样即使父函数执行完成,内存被释放以后,子函数在执行的时候,依然可以从堆内存中访问 copy 过来的变量

这个 copy 的描述有点问题,可以查看问题 3

2、老师,如果是这种 eval 动态执行的怎么预解析,又是怎么处理的作用域的问题的?

eval 会造成将栈中的数据复制到堆中的情况,这种情况效率低下

3、如果有闭包,函数是执行完毕再进行堆复制的吧? 堆复制后。 变量地址是怎么跟真正有引用关系的未编译的函数保持关系的。 这个引用是否直接存放在未编译的函数对象上?

我们可以看下面一段简单的闭包代码:

js
function main() {
  let a = 1;
  let b = 2;
  let c = 3;
  return function foo() {
    return c;
  };
}

let inner = main();

使用 d8 来打印这段代码的作用域:

Global scope:
function main () { // (0x7fca29051668) (13, 112)
  // will be compiled
  // 2 stack slots
  // 3 heap slots
  // local vars:
  LET b;  // (0x7fca290519d0) local[1], never assigned, hole initialization elided
  LET c;  // (0x7fca29051ab8) context[2], forced context allocation, never assigned
  LET a;  // (0x7fca290518e8) local[0], never assigned, hole initialization elided

  function foo () { // (0x7fca29051b70) (83, 110)
    // lazily parsed
    // 2 heap slots
  }
}

可以看出,let c 后面是这样描述的

LET c; // (0x7fca29051ab8) context[2], forced context allocation, never assigned

说明 c 在一开始就是在堆中分配的

堆复制的这样情况也是存在的,那就是使用 eval,这种方式没办法提前解析,所以 eval 是非常影响效率的一种方式

字节码(上)

V8 在执行一段 JavaScript 代码之前,需要将其编译为字节码,然后再解释执行字节码或者将字节码编译为二进制代码然后再执行

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

  • 第一个是解释器可以直接解释执行字节码
  • 第二个是优化编译器可以将字节码编译为二进制代码,然后再执行二进制机器代码

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

于是在早期,V8 团队采取了非常激进的策略,直接将 JavaScript 代码编译成机器代码

img

早期的 V8 也使用了两个编译器:

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

2、第二个是优化编译器,它负责将一些热点代码(执行频繁的代码)优化为执行效率更高的机器代码

早期的 V8 是怎么执行一段 JavaScript 代码的:

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

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

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

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

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

机器代码缓存

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

img

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

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

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

  • 首先,是 V8 第一次执行一段代码时,会编译源 JavaScript 代码,并将编译后的二进制代码缓存在内存中,我们把这种方式称为内存缓存(in-memory cache)。然后通过 JavaScript 源文件的字符串在内存中查找对应的编译后的二进制代码。这样当再次执行到这段代码时,V8 就可以直接去内存中查找是否编译过这段代码。如果内存缓存中存在这段代码所对应的二进制代码,那么就直接执行编译好的二进制代码
  • 其次,V8 除了采用将代码缓存在内存中策略之外,还会将代码缓存到硬盘上,这样即便关闭了浏览器,下次重新打开浏览器再次执行相同代码时,也可以直接重复使用编译好的二进制代码

img

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

字节码降低了内存占用

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

img

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

在上一章我们介绍过,V8 团队为了提升 V8 的启动速度,采用了惰性编译,其实惰性编译除了能提升 JavaScript 启动速度,还可以解决部分内存占用的问题

img

根据惰性编译的原则,当 V8 首次执行上面这段代码的过程中,开始只是编译最外层的代码,那些函数内部的代码,如下图中的黄色的部分,会推迟到第一次调用时再编译

为了解决缓存的二进制机器代码占用过多内存的问题,早期的 Chrome 并没有缓存函数内部的二进制代码,只是缓存了顶层次的二进制代码,比如上图中红色的区域

但是这种方式却存在很大的不确定性,比如我们多人开发的项目,通常喜欢将自己的代码封装成模块,在 JavaScript 中,由于没有块级作用域(ES6 之前),所以我们习惯使用立即调用函数表达式 (IIFEs),而这种表达式又一般会使用到闭包,它将和模块相关的所有信息都封装在一个匿名立即执行函数表达式中,并将需要暴漏的接口数据返回给变量 test_module。如果浏览器只缓存顶层代码,那么闭包模块中的代码将无法被缓存,而对于高度工程化的模块来说,这种模块式的处理方式到处都是,这就导致了一些关键代码没有办法被缓存

因此,V8 团队对早期的 V8 架构进行了非常大的重构,具体地讲,抛弃之前的基线编译器和优化编译器,引入了字节码、解释器和新的优化编译器

img

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

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

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

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

img

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

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

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

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

img

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

引入了字节码,就可以统一将字节码转换为不同平台的二进制代码

img

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

总结

早期的 V8 为了提升代码的执行速度,直接将 JavaScript 源代码编译成了没有优化的二进制的机器代码,如果某一段二进制代码执行频率过高,那么 V8 会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高

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

  • 时间问题:编译时间过久,影响代码启动速度
  • 空间问题:缓存编译后的二进制代码占用更多的内存

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

  • 解决启动问题:生成字节码的时间很短
  • 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用
  • 代码架构清晰:采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易

字节码(下)

img

如何生成字节码?

我们知道当 V8 执行一段 JavaScript 代码时,会先对 JavaScript 代码进行解析 (Parser),并生成为 AST 和作用域信息,之后 AST 和作用域信息被输入到一个称为 Ignition 的解释器中,并将其转化为字节码,之后字节码再由 Ignition 解释器来解释执行

js
function add(x, y) {
  var z = x + y;
  return z;
}
console.log(add(1, 2));

V8 首先会将函数的源码解析为 AST,这一步由解析器 (Parser) 完成,你可以在 d8 中通过–print-ast 命令来查看 V8 内部生成的 AST

[generating bytecode for function: add]
--- AST ---
FUNC at 12
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. DECLS
. . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 31
. . . INIT at 31
. . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. . . . ADD at 32
. . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. RETURN at 37
. . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"

图形化后:

img

这个函数主要拆分成四部分

  • 第一部分为参数的声明 (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 y 的语法,它们指向了实际 x 和 y 的值
  • 第四部分是 RETURN 节点,它指向了 z 的值,在这里是 local[0]

V8 在生成 AST 的同时,还生成了 add 函数的作用域,你可以使用–print-scopes 命令来查看:

Global scope:
function add (x, y) { // (0x7f9ed7849468) (12, 47)
  // will be compiled
  // 1 stack slots
  // local vars:
  VAR y;  // (0x7f9ed7849790) parameter[1], never assigned
  VAR z;  // (0x7f9ed7849838) local[0], never assigned
  VAR x;  // (0x7f9ed78496e8) parameter[0], never assigned
}

作用域中的变量都是未使用的,默认值都是 undefined,在执行阶段,作用域中的变量会指向堆和栈中相应的数据

img

在解析期间,所有函数体中声明的变量和函数参数,都被放进作用域中,如果是普通变量,那么默认值是 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

理解字节码:解释器的架构设计

字节码似乎和汇编代码有点像,这些字节码看起来似乎难以理解,但实际上它们非常简单,每一行表示一个特定的功能,把这些功能拼凑在一起就构成完整的程序

我们阅读汇编代码,需要先理解 CPU 的体系架构,然后再分析特定汇编指令的具体含义,同样,要了解怎么阅读字节码,我们就需要理解 V8 解释器的整体设计架构,然后再来分析特定的字节码指令的含义

因为解释器就是模拟物理机器来执行字节码的,比如可以实现如取指令、解析指令、执行指令、存储数据等,所以解释器的执行架构和 CPU 处理机器代码的架构类似

通常有两种类型的解释器,基于栈 (Stack-based)和基于寄存器 (Register-based),基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等,基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果

通常,基于栈的虚拟机也定义了少量的寄存器,基于寄存器的虚拟机也有堆栈,其区别体现在它们提供的指令集体系

大多数解释器都是基于栈的,比如 Java 虚拟机,.Net 虚拟机,还有早期的 V8 虚拟机。基于堆栈的虚拟机在处理函数调用、解决递归问题和切换上下文时简单明快

而现在的 V8 虚拟机则采用了基于寄存器的设计,它将一些中间数据保存到寄存器中,了解这点对于我们分析字节码的执行过程非常重要

接下来我们就来看看基于寄存器的解释器架构:

img

解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆

这和我们介绍过的 CPU 执行二进制机器代码的模式是类似的:

  • 使用内存中的一块区域来存放字节码
  • 使用了通用寄存器 r0,r1,r2,…… 这些寄存器用来存放一些中间数据
  • PC 寄存器用来指向下一条要执行的字节码
  • 栈顶寄存器用来指向当前的栈顶的位置

但是我们需要重点注意这里的累加器,它是一个非常特殊的寄存器,用来保存中间的结果,这体现在很多 V8 字节码的语义上面,我们来看下面这个字节码的指令:Ldar a1

Ldar 表示将寄存器中的值加载到累加器中,你可以把它理解为 LoaD Accumulator from Register,就是把某个寄存器中的值,加载到累加器中。那么上面这个指令的意思就是把 a1 寄存器中的值,加载到累加器中

img

再来看另外一个段字节码指令:Star r0

Star 表示 Store Accumulator Register, 你可以把它理解为 Store Accumulator to Register,就是把累加器中的值保存到某个寄存器中,上面这段代码的意思就是将累加器中的数值保存到 r0 寄存器中

img

再来看一个执行加法的字节码:Add a0, [0]

Add a0, [0]是从 a0 寄存器加载值并将其与累加器中的值相加,然后将结果再次放入累加器

img

你可能会注意到,add a0 后面还跟了一个[0],这个符号是做什么的呢?

这个称之为 feedback vector slot,中文我们可以称为反馈向量槽,它是一个数组,解释器将解释执行过程中的一些数据类型的分析信息都保存在这个反馈向量槽中了,目的是为了给 TurboFan 优化编译器提供优化信息,很多字节码都会为反馈向量槽提供运行时信息

在上面的字节码中,还有一个:LdaSmi [2]

这是将小整数(Smi)2 加载到累加器寄存器中

img

最后一个字节码:Return

Return 结束当前函数的执行,并将控制权传回给调用方。返回的值是累加器中的值

完整分析一段字节码

StackCheck
Ldar a1
Add a0, [0]
Star r0
LdaSmi [2]
Star r1
Ldar r0
Return

执行这段代码时,整体的状态如下图所示:

img

  • 参数对象 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 函数

思考

1、'Ldar a1 表示将寄存器中的值加载到累加器中' a1 的值不是在栈里面吗?不是直接从栈中加载到累加器吗?

这个是参数,存放在栈中,a0 代表第一个参数,a1 参数代表第二参数,参数 an 代表第 n 个参数,你可以把存放参数的地方也看成是存放在栈中的一块寄存器,参数寄存器

2、分析下列代码的字节码执行流程

js
function foo() {
  var d = 20;
  return function inner(a, b) {
    const c = a + b + d;
    return c;
  };
}
const f = foo();
f(1, 2);
/**
[generated bytecode for function: foo (0x32e4082d26c5 <SharedFunctionInfo foo>)]

// Creates a new context with number of |slots| for the function closure
CreateFunctionContext [0], [1] // 创建函数上下文环境

// Saves the current context in <context>, and pushes the accumulator as the new current context.
// 保存旧的上下文到r0中,然后把累加器中的内容作为新的上下文
PushContext r0

LdaSmi [20]                   // 加载20到累加器中
StaCurrentContextSlot [2]     // 把累加器中的值存储到上下文中的slot 2中
CreateClosure [1], [0], #2    // 创建闭包,并存储在累加器中
Return                        // 返回累加器中的值

[generated bytecode for function: inner (0x32e4082d28e9 <SharedFunctionInfo inner>)]
Ldar a1                       // 把a1加载到累加器中
Add a0, [0]                   // 把累加器中的值和a0相加,即a0+a1
Star1                       // 把累加器中的值存储到r1

// Load the object in |slot_index| of the current context into the accumulator.
// 把当前上下文中的slot 2加载到累加器中,即把20(变量d)加载到累加器中
LdaImmutableCurrentContextSlot [2]
Add r1, [1]     // 把r1跟累加器中的值相加
Star0         // 把累加器中的值存储到r0
Return          // 返回累加器中的值

*/

2、老师,我在这次的课件中看到了,小整型 smi。我之前在看书的时候看到了 v8 的数据表示,书上说 smi 直接使用前 32 位进行数值表示,后 32 位为句柄且最后一位标记位是 1,除了 smi 其他的类型都是存放的指针句柄最后一位是 0,但是我不清楚这个其他类型存档指针是什么格式,然后又怎么样和咱们课程里面的内容联系在一起呢,麻烦您能简单介绍一下吗

smi 主要是为了优化内存存储,其实很简单,就是使用更少的内存空间来存储数据,比如现在系统都是 64 位系统了,那么默认整数数据和指针都是 64 位的,V8 就会考虑将这部分内容压缩位 32 位,但是压缩到 32 位后就不知道这快内存是数据还是整数了,于是拿出了一个位来表示整数还是指针,这种技术也称指针压缩,课程中对这块内容没有做介绍

隐藏类

JavaScript 是一门动态语言,其执行效率要低于静态语言,V8 为了提升 JavaScript 的执行速度,借鉴了很多静态语言的特性,比如实现了 JIT 机制,为了提升对象的属性访问速度而引入了隐藏类,为了加速运算而引入了内联缓存

为什么静态语言的效率更高?

img

JavaScript 在运行时,对象的属性是可以被修改的,所以当 V8 使用了一个对象时,比如使用了 start.x 的时候,它并不知道该对象中是否有 x,也不知道 x 相对于对象的偏移量是多少,也可以说 V8 并不知道该对象的具体的形状

那么,当在 JavaScript 中要查询对象 start 中的 x 属性时,V8 会按照具体的规则一步一步来查询,这个过程非常的慢且耗时

这种动态查询对象属性的方式和 C++ 这种静态语言不同,C++ 在声明一个对象之前需要定义该对象的结构,我们也可以称为形状,比如 Point 结构体就是一种形状,我们可以使用这个形状来定义具体的对象

C++ 代码在执行之前需要先被编译,编译的时候,每个对象的形状都是固定的,也就是说,在代码的执行过程中,Point 的形状是无法被改变的

那么在 C++ 中访问一个对象的属性时,自然就知道该属性相对于该对象地址的偏移值了,比如在 C++ 中使用 start.x 的时候,编译器会直接将 x 相对于 start 的地址写进汇编指令中,那么当使用了对象 start 中的 x 属性时,CPU 就可以直接去内存地址中取出该内容即可,没有任何中间的查找环节

因为静态语言中,可以直接通过偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因

什么是隐藏类 (Hidden Class)?

既然静态语言的查询效率这么高,那么是否能将这种静态的特性引入到 V8 中呢?

答案是可行

目前所采用的一个思路就是将 JavaScript 中的对象静态化,也就是 V8 在运行 JavaScript 的过程中,会假设 JavaScript 中的对象是静态的,具体地讲,V8 对每个对象做如下两点假设:

  • 对象创建好了之后就不会添加新的属性
  • 对象创建好了之后也不会删除属性

符合这两个假设之后,V8 就可以对 JavaScript 中的对象做深度优化了

具体地讲,V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息:

  • 对象中所包含的所有的属性
  • 每个属性相对于对象的偏移量

有了隐藏类之后,那么当 V8 访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8 就可以直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了 V8 查找对象的效率

js
let point = { x: 100, y: 200 };

当 V8 执行到这段代码时,会先为 point 对象创建一个隐藏类,在 V8 中,把隐藏类又称为 map,每个对象都有一个 map 属性,其值指向内存中的隐藏类

隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量,比如 point 对象的隐藏类就包括了 x 和 y 属性,x 的偏移量是 4,y 的偏移量是 8

img

注意,这是 point 对象的 map,它不是 point 对象本身

img

在这张图中,左边的是 point 对象在内存中的布局,右边是 point 对象的 map,我们可以看到,point 对象的第一个属性就指向了它的 map

有了 map 之后,当你再次使用 point.x 访问 x 属性时,V8 会查询 point 的 map 中 x 属性相对 point 对象的偏移量,然后将 point 对象的起始位置加上偏移量,就得到了 x 属性的值在内存中的位置,有了这个位置也就拿到了 x 的值,这样我们就省去了一个比较复杂的查找过程

这就是将动态语言静态化的一个操作,V8 通过引入隐藏类,模拟 C++ 这种静态语言的机制,从而达到静态语言的执行效率

实践:通过 d8 查看隐藏类

我们可以使用 d8 提供的 API DebugPrint 来查看 point 对象中的隐藏类

js
let point = { x: 100, y: 200 };
%DebugPrint(point);

这里你需要注意,在使用 d8 内部 API 时,有一点很容易出错,就是需要为 JavaScript 代码加上分号,不然 d8 会报错

然后使用 d8 执行d8 --allow-natives-syntax test.js

打印出来的结果如下所示:

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 属性

多个对象共用一个隐藏类

现在我们知道了在 V8 中,每个对象都有一个 map 属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类,这样有两个好处:

1、减少隐藏类的创建次数,也间接加速了代码的执行速度

2、减少了隐藏类的存储空间

什么情况下两个对象的形状是相同的,要满足以下两点:

  • 相同的属性名称
  • 相等的属性个数
js
let point = { x: 100, y: 200 };
let point2 = { x: 3, y: 4 };
%DebugPrint(point);
%DebugPrint(point2);

当 V8 执行到这段代码时,首先会为 point 对象创建一个隐藏类,然后继续创建 point2 对象。在创建 point2 对象的过程中,发现它的形状和 point 是一样的。这时候,V8 就会将 point 的隐藏类给 point2 复用

img

也可以使用 d8 来证实下d8 --allow-natives-syntax test.js

打印出来的 point 和 point2 对象,你会发现它们的 map 属性都指向了同一个地址,这也就意味着它们共用了同一个 map

重新构建隐藏类

但是,JavaScript 依然是动态语言,在执行过程中,对象的形状是可以被改变的,如果某个对象的形状改变了,隐藏类也会随着改变,这意味着 V8 要为新改变的对象重新构建新的隐藏类,这对于 V8 的执行效率来说,是一笔大的开销

通俗地理解,给一个对象添加新的属性,删除新的属性,或者改变某个属性的数据类型都会改变这个对象的形状,那么势必也就会触发 V8 为改变形状后的对象重建新的隐藏类

js
let point = {};
%DebugPrint(point);
point.x = 100;
%DebugPrint(point);
point.y = 200;
%DebugPrint(point);

在 d8 中执行d8 --allow-natives-syntax test.js

将其他的一些信息都省略了,最终打印出来的结果如下所示:

DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
 - map: 0x0986082802d9 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - ...


DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
 - map: 0x098608284ce9 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - ...
 - properties: 0x0986080406e9 <FixedArray[0]> {
    #x: 100 (const data field 0)
 }


DebugPrint: 0x986080c5b35: [JS_OBJECT_TYPE]
 - map: 0x098608284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - p
 - ...
 - properties: 0x0986080406e9 <FixedArray[0]> {
    #x: 100 (const data field 0)
    #y: 200 (const data field 1)

根据这个打印出来的结果,我们可以明显看到,每次给对象添加了一个新属性之后,该对象的隐藏类的地址都会改变,这也就意味着隐藏类也随着改变了

img

同样,如果你删除了对象的某个属性,那么对象的形状也就随着发生了改变,这时 V8 也会重建该对象的隐藏类

最佳实践

V8 会为每个对象分配一个隐藏类,在执行过程中:

  • 如果对象的形状没有发生改变,那么该对象就会一直使用该隐藏类
  • 如果对象的形状发生了改变,那么 V8 会重建一个新的隐藏类给该对象

我们当然希望对象中的隐藏类不要随便被改变,因为这样会触发 V8 重构该对象的隐藏类,直接影响到了程序的执行性能。那么在实际工作中,我们应该尽量注意以下几点:

一,使用字面量初始化对象时,要保证属性的顺序是一致的。比如先通过字面量 x、y 的顺序创建了一个 point 对象,然后通过字面量 y、x 的顺序创建一个对象 point2

虽然创建时的对象属性一样,但是它们初始化的顺序不一样,这也会导致形状不同,所以它们会有不同的隐藏类,所以我们要尽量避免这种情况

二,尽量使用字面量一次性初始化完整对象属性。因为每次为对象添加一个属性时,V8 都会为该对象重新设置隐藏类

三,尽量避免使用 delete 方法。delete 方法会破坏对象的形状,同样会导致 V8 为该对象重新生成新的隐藏类

总结

最后,关于隐藏类,我们记住以下几点:

  • 在 V8 中,每个对象都有一个隐藏类,隐藏类在 V8 中又被称为 map
  • 在 V8 中,每个对象的第一个属性的指针都指向其 map 地址
  • map 描述了其对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少
  • 如果添加新的属性,那么需要重新构建隐藏类
  • 如果删除了对象中的某个属性,同样也需要构建隐藏类

思考

如果我定义了一个获取对象属性值的函数 loadX,loadX 有一个参数,然后返回该参数的 x 属性值:

js
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 函数的执行效率?

ans:内联缓存

1、什么情况下两个对象的形状是相同的,要满足以下两点: 相同的属性名称 相等的属性个数 这里还要要求:相同的属性顺序吧

yeap

内联缓存

通常 V8 获取 o.x 的流程是这样的:查找对象 o 的隐藏类,再通过隐藏类查找 x 属性偏移量,然后根据偏移量获取属性值,在这段代码中 loadX 函数会被反复执行,那么获取 o.x 流程也需要反复被执行。我们有没有办法再度简化这个查找过程,最好能一步到位查找到 x 的属性值呢?

其实这是一个关于内联缓存的思考题。我们可以看到,函数 loadX 在一个 for 循环里面被重复执行了很多次,因此 V8 会想尽一切办法来压缩这个查找过程,以提升对象的查找效率。这个加速函数执行的策略就是内联缓存 (Inline Cache),简称为 IC

什么是内联缓存?

其实 IC 的原理很简单,直观地理解,就是在 V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键的中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率

IC 会为每个函数维护一个反馈向量 (FeedBack Vector),反馈向量记录了函数在执行过程中的一些关键的中间数据

img

反馈向量其实就是一个表结构,它由很多项组成的,每一项称为一个插槽 (Slot),V8 会依次将执行 loadX 函数的中间数据写入到反馈向量的插槽中

js
function loadX(o) {
  o.y = 4;
  return o.x;
}

当 V8 执行这段函数的时候,它会判断 o.y = 4 和 return o.x 这两段是调用点 (CallSite),因为它们使用了对象和属性,那么 V8 会在 loadX 函数的反馈向量中为每个调用点分配一个插槽

每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量,比如上面这个函数中的两个调用点都使用了对象 o,那么反馈向量两个插槽中的 map 属性也都是指向同一个隐藏类的,因此这两个插槽的 map 地址是一样的

img

当 V8 执行 loadX 函数时,loadX 函数中的关键数据是如何被写入到反馈向量中

js
function loadX(o) {
  return o.x;
}
loadX({ x: 1 });

将 loadX 转换为字节码:

StackCheck
LdaNamedProperty a0, [0], [0]
Return

loadX 函数的这段字节码很简单:

  • 第一句是检查栈是否溢出
  • 第二句是 LdaNamedProperty,它的作用是取出参数 a0 的第一个属性值,并将属性值放到累加器中
  • 第三句是返回累加器中的属性值

这里我们重点关注 LdaNamedProperty 这句字节码,我们看到它有三个参数。a0 就是 loadX 的第一个参数;第二个参数[0]表示取出对象 a0 的第一个属性值,这两个参数很好理解。第三个参数就和反馈向量有关了,它表示将 LdaNamedProperty 操作的中间数据写入到反馈向量中,方括号中间的 0 表示写入反馈向量的第一个插槽中

img

观察上图,我们可以看出,在函数 loadX 的反馈向量中,已经缓存了数据:

  • 在 map 栏,缓存了 o 的隐藏类的地址
  • 在 offset 一栏,缓存了属性 x 的偏移量
  • 在 type 一栏,缓存了操作类型,这里是 LOAD 类型。在反馈向量中,我们把这种通过 o.x 来访问对象属性值的操作称为 LOAD 类型

V8 除了缓存 o.x 这种 LOAD 类型的操作以外,还会缓存存储 (STORE) 类型函数调用 (CALL) 类型的中间数据

js
function foo() {}
function loadX(o) {
  o.y = 4;
  foo();
  return o.x;
}
loadX({ x: 1, y: 4 });

转换成字节码:

StackCheck
LdaSmi [4]
StaNamedProperty a0, [0], [0]
LdaGlobal [1], [2]
Star r0
CallUndefinedReceiver0 r0, [4]
LdaNamedProperty a0, [2], [6]
Return

img

o.y = 4 对应的字节码是:

LdaSmi [4]
StaNamedProperty a0, [0], [0]

这段代码是先使用 LdaSmi [4],将常数 4 加载到累加器中,然后通过 StaNamedProperty 的字节码指令,将累加器中的 4 赋给 o.y,这是一个存储 (STORE) 类型的操作,V8 会将操作的中间结果存放到反馈向量中的第一个插槽中

调用 foo 函数的字节码是:

LdaGlobal [1], [2]
Star r0
CallUndefinedReceiver0 r0, [4]

解释器首先加载 foo 函数对象的地址到累加器中,这是通过 LdaGlobal 来完成的,然后 V8 会将加载的中间结果存放到反馈向量的第 3 个插槽中,这是一个存储类型的操作。接下来执行 CallUndefinedReceiver0,来实现 foo 函数的调用,并将执行的中间结果放到反馈向量的第 5 个插槽中,这是一个调用 (CALL) 类型的操作

最后就是返回 o.x,return o.x 仅仅是加载对象中的 x 属性,所以这是一个加载 (LOAD) 类型的操作,最终生成的反馈向量如下图所示:

img

当 V8 再次调用 loadX 函数时,比如执行到 loadX 函数中的 return o.x 语句时,它就会在对应的插槽中查找 x 属性的偏移量,之后 V8 就能直接去内存中获取 o.x 的属性值了。这样就大大提升了 V8 的执行效率

多态和超态

如果对象的形状不是固定的,那 V8 会怎么处理呢?

js
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 表的结构来存储,这无疑会拖慢执行效率

img

尽量保持单态

这就是 IC 的一些基础情况,非常简单,只是为每个函数添加了一个缓存,当第一次执行该函数时,V8 会将函数中的存储、加载和调用相关的中间结果保存到反馈向量中。当再次执行时,V8 就要去反馈向量中查找相关中间信息,如果命中了,那么就直接使用中间信息

总的来说,我们只需要记住一条就足够了,那就是单态的性能优于多态和超态,所以我们需要稍微避免多态和超态的情况

要避免多态和超态,那么就尽量默认所有的对象属性是不变的,比如你写了一个 loadX(o) 的函数,那么当传递参数时,尽量不要使用多个不同形状的 o 对象

总结

虽然我们分析的隐藏类和 IC 能提升代码的执行速度,但是在实际的项目中,影响执行性能的因素非常多,找出那些影响性能瓶颈才是至关重要的,你不需要过度关注微优化,你也不需要过度担忧你的代码是否破坏了隐藏类或者 IC 的机制,因为相对于其他的性能瓶颈,它们对效率的影响可能是微不足道的

思考

观察下面两段代码:

js
let data = [1, 2, 3, 4]
data.forEach((item) => console.log(item.toString())
js
let data = ['1', 2, '3', 4]
data.forEach((item) => console.log(item.toString())

你认为这两段代码,哪段的执行效率高,为什么?

认为第一种方式效率更高

第一种方式中,每一个 item 类型一样,后续几次调用 toString 可以直接命中,是单态

第二种方式中,由于 item 类型间错不同,经常变换,就要同时缓存多个类型,是多态

item.toString() 函数由于 item 要转换为包装类,而 stirng 类型和 number 类型包装类对应的隐藏类是不一样的,因此第一种是单态,第二种是有两个隐藏类的多态,第一种效率高

1、内联合缓存 IC 的缓存向量只针对函数调用吗?

其实你在全局环境下执行一段代码,V8 也会将其封装成一个匿名(anonymous)函数,所以 IC 对所有代码都是有效的

2、感觉查 feedback vector 的方式还是不够高效,请问代码被 jit 后 o.x 中 x 的偏移量是否就可以直接硬编码到机器码中去了。关于 CALL 类型的 IC,feedback vector 里面存的是什么信息?

是的,机器代码都是直接将偏移地址写进去,这样就更加高效了。

call、一样的,函数也是一个对象,存放函数对象隐藏类的一些基础信息

消息队列

我们在使用 JavaScript 时,经常要用到大量的回调函数,比如在浏览器中可以使用 setTimeout 来设置定时器,使用 XMLHTTPRequest 来异步下载资源文件,在 Node 中可以使用 readFile 来读取文件,这些操作都有一个共同的特点,那就是需要给调用 API 传入回调函数,然后浏览器或者 Node 会将执行处理的结果通过回调函数来触发

从内部了解回调函数,可以帮助我们梳理清楚很多问题:

  • 有助于我们理解浏览器中的 Web API 到底是怎么工作的
  • 有助于我们理解宏任务和微任务到底有哪些区别
  • 理解回调函数,是理解异步编程模型 async/await 的基础

什么是回调函数?

那究竟什么是回调函数呢?其实回调函数也是个函数,回调函数区别于普通函数,在于它的调用方式。只有当某个函数被作为参数,传递给另外一个函数,或者传递给宿主环境,然后该函数在函数内部或者在宿主环境中被调用,我们才称为回调函数

具体地讲,回调函数有两种不同的形式,同步回调和异步回调。通常,我们需要将回调函数传入给另外一个执行函数,那么同步回调和异步回调的最大区别在于同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的

同步回调:

js
var myArray = ["water", "goods", "123", "like"];
function handlerArray(indexName, index) {
  console.log(index + 1 + ". " + indexName);
}
myArray.forEach(handlerArray);

和同步回调函数不同的是,异步回调函数并不是在它的执行函数内部被执行的,而是在其他的位置和其他的时间点被执行的

js
function foo() {
  alert("Hello");
}
setTimeout(foo, 3000);

在这段代码中,我们使用了 setTimeout 函数,setTimeout 的第一个参数 foo 就是一个回调函数,V8 执行 setTimeout 时,会立即返回,等待 3000 毫秒之后,foo 函数才会被 V8 调用,foo 函数并不是在 setTimeout 函数内部被执行的,所以这是一个异步回调

UI 线程的宏观架构

期浏览器的页面是运行在一个单独的 UI 线程中的,所以要在页面中引入 JavaScript,那么 JavaScript 也必须要运行在和页面相同的线程上,这样才能方便使用 JavaScript 来操纵 DOM,所以从一开始,JavaScript 就被设计成了运行在 UI 线程中

所谓 UI 线程,是指运行窗口的线程,当你运行一个窗口时,无论该页面是 Windows 上的窗口系统,还是 Android 或者 iOS 上的窗口系统,它们都需要处理各种事件,诸如有触发绘制页面的事件,有鼠标点击、拖拽、放大缩小的事件,有资源下载、文件读写的事件,等等

在页面线程中,当一个事件被触发时,比如用户使用鼠标点击了页面,系统需要将该事件提交给 UI 线程来处理

在大部分情况下,UI 线程并不能立即响应和处理这些事件,比如在你在移动鼠标的过程中,每移动一个像素都会产生一个事件,所以鼠标移动的事件会频繁地被触发。在这种情况下,页面线程可能正在处理前一个事件,那么最新的事件就无法被立即执行

针对这种情况,我们为 UI 线程提供一个消息队列,并将这些待执行的事件添加到消息队列中,然后 UI 线程会不断循环地从消息队列中取出事件、执行事件。我们把 UI 线程每次从消息队列中取出事件,执行事件的过程称为一个任务

img

js
function UIMainThread() {
    while (queue.waitForMessage()) {
        Task task = queue.getNext()
        processNextMessage(task)
    }
}

在这段代码中,queue 是消息队列,queue.waitForMessage() 会同步地等待消息队列中的消息到达,如果当前没有任何消息等待被处理,则这个函数会将 UI 线程挂起。如果消息队列中有消息,则使用 queue.getNext() 取出下一个要执行的消息,并交由 processNextMessage 函数来处理消息

这就是通用的 UI 线程的结构,有消息队列,通过鼠标、键盘、触控板等产生的消息都会被添加进消息队列,主线程会循环地从消息队列中取出消息并执行

异步回调函数的调用时机

img

比如在页面主线程中正在执行 A 任务,在执行 A 任务的过程中调用 setTimeout(foo, 3000),在执行 setTimeout 函数的过程中,宿主就会将 foo 函数封装成一个事件,并添加到消息队列中,然后 setTimeout 函数执行结束

通过 setTimeout 的执行流程其实是比较简单的,在 setTimeout 函数内部封装回调消息,并将回调消息添加进消息队列,然后主线程从消息队列中取出回调事件,并执行

还有一类比较复杂一点的流程,最典型的是通过 XMLHttpRequest 所触发的回调,它和 setTimeout 有一些区别

因为 XMLHttpRequest 是用来下载网络资源的,但是实际的下载过程却并不适合在主线程上执行,因为下载任务会消耗比较久的时间,如果在 UI 线程上执行,那么会阻塞 UI 线程,这就会拖慢 UI 界面的交互和绘制的效果。所以当主线程从消息队列中取出来了这类下载任务之后,会将其分配给网络线程,让其在网络线程上执行下载过程,这样就不会影响到主线程的执行了

img

  1. UI 线程会从消息队列中取出一个任务,并分析该任务
  2. 分析过程中发现该任务是一个下载请求,那么主线程就会将该任务交给网络线程去执行
  3. 网络线程接到请求之后,便会和服务器端建立连接,并发出下载请求
  4. 网络线程不断地收到服务器端传过来的数据
  5. 网络线程每次接收到数据时,都会将设置的回调函数和返回的数据信息,如大小、返回了多少字节、返回的数据在内存中存放的位置等信息封装成一个新的事件,并将该事件放到消息队列中
  6. UI 线程继续循环地读取消息队列中的事件,如果是下载状态的事件,那么 UI 线程会执行回调函数,程序员便可以在回调函数内部编写更新下载进度的状态的代码
  7. 直到最后接收到下载结束事件,UI 线程会显示该页面下载完成

这就是 XMLHttpRequest 所触发的回调流程,除了下载以外,JavaScript 中获取系统设备信息、文件读取等都是采用了类似的方式来实现的

思考

分析 Node 中的 readFileSync 和 readFile 函数,其中一个是同步读文件操作,另外一个是异步读文件操作

js
var fs = require("fs");
var data = fs.readFileSync("test.js");
js
fs.readFile("test.txt", function (err, data) {
  data.toString();
});

1、readFileSync 函数执行时会等待文件读取完毕,再执行下一条语句,在该语句后可正常访问其执行结果(获取 data); readFile 函数执行时不会等待文件读取完毕就会执行下一条语句,如果直接在其后而不是回调函数中操作其执行结果 data 时,程序会出现报错;

2、老师,setTimeout 的事件会立即被放进事件队列吗?我的理解是应该先放进一个类似于堆的数据结构然后等到指定的时间到后才放到事件队列的?

不是这样的,在浏览器中这块比较复杂,实际上有另外一个队列用来存放定时器中的回掉事件的,然后还有一个任务调度器,它会从一系列的事件队列中,按照一定规则取出下一个要执行的事件,这个调度策略比较复杂

2、看文章感觉“UI 线程“就是“主线程“

在浏览器中页面的 UI 线程就是主线程,在 Node 中主线程就是主线程

3、老师,请问这里的网络线程和网络进程有什么联系呢,文中所说 1 的网络线程指的是渲染进程里面的异步 http 请求线程吗

Chrome 浏览器中比较复杂,下载的是采用了进程。

我在这里只是介绍一个通用的模型,一般都是采用线程的。

其实不管是线程还是进程,大的原理是一样的,都是丢给一个和主线程平行的线程或者进程来处理,处理过程中会动态返回结果给主线程!

4、settimeout 是立即放入消息队列还是等时间到了再放入的?

定时器有单独的队列,每次执行新的宏任务时,主线程会先在这两个队列中查找即将要执行的事件,然后执行

异步编程(上)

基于这套基础 UI 框架,JavaScript 又延伸出很多新的技术,其中应用最广泛的当属宏任务微任务

宏任务很简单,就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务

微任务稍微复杂一点,其实你可以把微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

JavaScript 中之所以要引入微任务,主要是由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,那么微任务可以在实时性和效率之间做一个有效的权衡。另外使用微任务,可以改变我们现在的异步编程模型,使得我们可以使用同步形式的代码来编写异步调用

我们先看下和微任务相关的知识栈:

img

微任务是基于消息队列、事件循环、UI 主线程还有堆栈而来的,然后基于微任务,又可以延伸出协程、Promise、Generator、await/async 等现代前端经常使用的一些技术。也就是说,如果对消息队列主线程还有调用栈理解的不够深入,你在研究微任务时,就容易一头雾水

主线程、调用栈、消息队列

我们知道,调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系

js
function bar() {
}
foo(fun){
  fun()
}
foo(bar)

当 V8 准备执行这段代码时,会先将全局执行上下文压入到调用栈中

img

然后 V8 便开始在主线程上执行 foo 函数,首先它会创建 foo 函数的执行上下文,并将其压入栈中

img

然后,foo 函数又调用了 bar 函数,那么当 V8 执行 bar 函数时,同样要创建 bar 函数的执行上下文,并将其压入栈中

img

等 bar 函数执行结束,V8 就会从栈中弹出 bar 函数的执行上下文

img

最后,foo 函数执行结束,V8 会将 foo 函数的执行上下文从栈中弹出

img

以上就是调用栈管理主线程上函数调用的方式,不过,这种方式会带来一种问题,那就是栈溢出

js
function foo() {
  foo();
}
foo();

由于 foo 函数内部嵌套调用它自己,所以在调用 foo 函数的时候,它的栈会一直向上增长,但是由于栈空间在内存中是连续的,所以通常我们都会限制调用栈的大小,如果当函数嵌套层数过深时,过多的执行上下文堆积在栈中便会导致栈溢出

img

我们可以使用 setTimeout 来解决栈溢出的问题,setTimeout 的本质是将同步函数调用改成异步函数调用,这里的异步调用是将 foo 封装成事件,并将其添加进消息队列中,然后主线程再按照一定规则循环地从消息队列中读取下一个任务

js
function foo() {
  setTimeout(foo, 0);
}
foo();

首先,主线程会从消息队列中取出需要执行的宏任务,假设当前取出的任务就是要执行的这段代码,这时候主线程便会进入代码的执行状态

img

接下来 V8 就要执行 foo 函数了,同样执行 foo 函数时,会创建 foo 函数的执行上下文,并将其压入栈中

img

当 V8 执行执行 foo 函数中的 setTimeout 时,setTimeout 会将 foo 函数封装成一个新的宏任务,并将其添加到消息队列中

img

等 foo 函数执行结束,V8 就会结束当前的宏任务,调用栈也会被清空

img

当一个宏任务执行结束之后,忙碌的主线程依然不会闲下来,它会一直重复这个取宏任务、执行宏任务的过程。刚才通过 setTimeout 封装的回调宏任务,也会在某一时刻被主线取出并执行,这个执行过程,就是 foo 函数的调用过程

因为 foo 函数并不是在当前的父函数内部被执行的,而是封装成了宏任务,并丢进了消息队列中,然后等待主线程从消息队列中取出该任务,再执行该回调函数 foo,这样就解决了栈溢出的问题

微任务解决了宏任务执行时机不可控的问题

不过,对于栈溢出问题,虽然我们可以通过将某些函数封装成宏任务的方式来解决,但是宏任务需要先被放到消息队列中,如果某些宏任务的执行时间过久,那么就会影响到消息队列后面的宏任务的执行,而且这个影响是不可控的,因为你无法知道前面的宏任务需要多久才能执行完成

于是 JavaScript 中又引入了微任务,微任务会在当前的任务快要执行结束时执行,利用微任务,你就能比较精准地控制你的回调函数的执行时机

通俗地理解,V8 会为每个宏任务维护一个微任务队列。当 V8 执行一段 JavaScript 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。当你通过 Promise.resolve 生成一个微任务,该微任务会被 V8 自动添加进微任务队列,等整段代码快要执行结束时,该环境对象也随之被销毁,但是在销毁之前,V8 会先处理微任务队列中的微任务

理解微任务的执行时机,你只需要记住以下两点:

  • 首先,如果当前的任务中产生了一个微任务,通过 Promise.resolve() 或者 Promise.reject() 都会触发微任务,触发的微任务不会在当前的函数中被执行,所以执行微任务时,不会导致栈的无限扩张
  • 其次,和异步调用不同,微任务依然会在当前任务执行结束之前被执行,这也就意味着在当前微任务执行结束之前,消息队列中的其他任务是不可能被执行的

因此在函数内部触发的微任务,一定比在函数内部触发的宏任务要优先执行

js
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);

// 打印结果
foo;
bar;
global;
micro - foo;
micro - bar;
micro - global;
macro - foo;
macro - bar;
macro - global;

消息队列、主线程、调用栈的状态图如下所示:

img

img

img

img

img

等到这段代码即将执行完成时,V8 便要销毁这段代码的环境对象,此时环境对象的析构函数被调用(注意,这里的析构函数是 C++ 中的概念),这里就是 V8 执行微任务的一个检查点,这时候 V8 会检查微任务队列,如果微任务队列中存在微任务,那么 V8 会依次取出微任务,并按照顺行执行。因为微任务队列中的任务分别是:micro-foo、micro-bar、micro-global,所以执行的顺序也是如此

img

等微任务队列中的所有微任务都执行完成之后,当前的宏任务也就执行结束了,接下来主线程会继续重复执行取出任务、执行任务的过程。由于正常情况下,取出宏任务的顺序是按照先进先出的顺序,所有最后打印出来的顺序是:macro-foo、macro-bar、macro-global

等所有的任务执行完成之后,消息队列、主线程和调用栈的状态图如下所示:

img

能否在微任务中循环地触发新的微任务?

js
function foo() {
  return Promise.resolve().then(foo);
}
foo();

当执行 foo 函数时,由于 foo 函数中调用了 Promise.resolve(),这会触发一个微任务,那么此时,V8 会将该微任务添加进微任务队列中,退出当前 foo 函数的执行

然后,V8 在准备退出当前的宏任务之前,会检查微任务队列,发现微任务队列中有一个微任务,于是先执行微任务。由于这个微任务就是调用 foo 函数本身,所以在执行微任务的过程中,需要继续调用 foo 函数,在执行 foo 函数的过程中,又会触发了同样的微任务

那么这个循环就会一直持续下去,当前的宏任务无法退出,也就意味着消息队列中其他的宏任务是无法被执行的,比如通过鼠标、键盘所产生的事件。这些事件会一直保存在消息队列中,页面无法响应这些事件,具体的体现就是页面的卡死

不过,由于 V8 每次执行微任务时,都会退出当前 foo 函数的调用栈,所以这段代码是不会造成栈溢出的

思考

浏览器中的 MutationObserver 接口提供了监视对 DOM 树所做更改的能力,它在内部也使用了微任务的技术,那么今天留给你的作业是,查找 MutationObserver 相关资料,分析它是如何工作的,其中微任务的作用是什么?

1、MutationObserver 和 IntersectionObserver 两个性质应该差不多。我这里简称 ob。ob 是一个微任务,通过浏览器的 requestIdleCallback,在浏览器每一帧的空闲时间执行 ob 监听的回调,该监听是不影响主线程的,但是回调会阻塞主线程。当然有一个限制,如果 100ms 内主线程一直处于未空闲状态,那会强制触发 ob

2、微任务执行时,还是会在调用栈中创建对应函数的执行上下文吗?

会的,和正常执行函数一样

3、像 setTimeout 、XMLHttpRequest 这种 web APIs,是浏览器的哪个部分提供的呢?它们并不是 V8 提供的,是浏览器内核么?提供这些 web APIs 的部分与 V8 又有什么关系?

对,浏览器内核提供的,相当于宿主对 V8 的扩展

4、微任务小知识,非常重要 !!important

5、微任务如果用到函数的变量,会产生闭包吗?

会的

6、每次宏任务结束都会清空调用栈,那如何在宏任务之间共享信息呢?

数据可以保存在堆中啊

异步编程(下)

当 JavaScript 中有大量的异步操作时,会降低代码的可读性, 其中最容易造成的就是回调地狱的问题

JavaScript 社区探索并推出了一系列的方案,从“Promise 加 then”到“generator 加 co”方案,再到最近推出“终极”的 async/await 方案,完美地解决了回调地狱所造成的问题

什么是回调地狱?

因为异步回调嵌套导致代码逻辑的不连贯、不线性,非常不符合人的直觉

因此,异步回调模式影响到我们的编码方式,如果在代码中过多地使用异步回调函数,会将你的整个代码逻辑打乱,从而让代码变得难以理解,这也就是我们经常所说的回调地狱问题

使用 Promise 解决回调地狱问题

为了解决回调地狱的问题,JavaScript 做了大量探索,最开始引入了 Promise 来解决部分回调地狱的问题

使用 Promise,我们就可以按照线性的思路来编写代码,非常符合人的直觉。所以说,使用 Promise 可以解决回调地狱中编码不线性的问题

使用 Generator 函数实现更加线性化逻辑

虽然使用 Promise 可以解决回调地狱中编码不线性的问题,但这种方式充满了 Promise 的 then() 方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的 then,异步逻辑之间依然被 then 方法打断了,因此这种方式的语义化不明显,代码不能很好地表示执行流程

那么我们就需要思考,能不能更进一步,像编写同步代码的方式来编写异步代码,比如:

js
function getResult() {
  let id = getUserID();
  let name = getUserName(id);
  return name;
}

由于 getUserID() 和 getUserName() 都是异步请求,如果要实现这种线性的编码方式,那么一个可行的方案就是执行到异步请求的时候,暂停当前函数,等异步请求返回了结果,再恢复该函数

img

这个模型的关键就是实现函数暂停执行函数恢复执行,而生成器就是为了实现暂停函数和恢复函数而设计的

生成器函数是一个带星号函数,配合 yield 就可以实现函数的暂停和恢复

js
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 中,生成器就是协程的一种实现方式

我们可以将同步、异步逻辑全部写进生成器函数 getResult 的内部,然后,我们在外面依次使用一段代码来控制生成器的暂停和恢复执行

js
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 函数继续往下执行,我们把这个执行生成器代码的函数称为执行器(可参考著名的 co 框架)

js
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 异步编程的一个重大改进,它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力

js
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 到底是什么。根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数

这里需要重点关注异步执行这个词,简单地理解,如果在 async 函数里面使用了 await,那么此时 async 函数就会暂停执行,并等待合适的时机来恢复执行,所以说 async 是一个异步执行的函数

那么暂停之后,什么时机恢复 async 函数的执行呢?

我们先来看看,V8 是如何处理 await 后面的内容的

通常,await 可以等待两种类型的表达式:

  • 可以是任何普通表达式
  • 也可以是一个 Promise 对象的表达式

如果 await 等待的是一个 Promise 对象,它就会暂停执行生成器函数,直到 Promise 对象的状态变成 resolve,才会恢复执行,然后得到 resolve 的值,作为 await 表达式的运算结果

js
function NeverResolvePromise() {
  return new Promise((resolve, reject) => {});
}
async function getResult() {
  let a = await NeverResolvePromise();
  console.log(a);
}
getResult();
console.log(0);

这一段代码,我们使用 await 等待一个没有 resolve 的 Promise,那么这也就意味着,getResult 函数会一直等待下去

和生成器函数一样,使用了 async 声明的函数在执行时,也是一个单独的协程,我们可以使用 await 来暂停该协程,由于 await 等待的是一个 Promise 对象,我们可以 resolve 来恢复该协程

img

如果 await 等待的对象已经变成了 resolve 状态,那么 V8 就会恢复该协程的执行,我们可以修改下上面的代码

js
function HaveResolvePromise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(100);
    }, 0);
  });
}
async function getResult() {
  console.log(1);
  let a = await HaveResolvePromise();
  console.log(a);
  console.log(2);
}
console.log(0);
getResult();
console.log(3);

img

如果 await 等待的是一个非 Promise 对象,比如 await 100,那么 V8 会隐式地将 await 后面的 100 包装成一个已经 resolve 的对象

总结

img

思考

co 的运行原理是什么?

1、co 源码实现原理:其实就是通过不断的调用 generator 函数的 next()函数,来达到自动执行 generator 函数的效果(类似 async、await 函数的自动自行)《学习 koa 源码的整体架构,浅析 koa 洋葱模型原理和 co 原理》

2、老师 async、await 是 generator promise 的语法糖吗,v8 里面前者是借助后者实现的吗?async await 为什么能用 try catch 捕获错误?

async/await 可以不是语法糖,而是从设计到开发都是一套完整的体系,只不过使用了协程和 promise!

支持 try catch 也是引擎的底层来实现的

3、老师,很长很长一段代码中,业务逻辑很复杂,既有产生微任务,又有 setTimeout 产生宏任务,更有很多 await 的语句,那么这些结合上一章节讲的内容,它的执行顺序是怎样的呢?可以帮我分析下吗?

微任务先执行,settimeout 后执行,await 可以跨越多个宏任务

垃圾回收(上)

垃圾数据是怎么产生的?

无论是使用什么语言,我们都会频繁地使用数据,这些数据会被存放到栈和堆中,通常的方式是在内存中创建一块空间,使用这块空间,在不需要的时候回收这块空间

js
window.test = new Object();
window.test.a = new Uint16Array(100);

当 JavaScript 执行这段代码的时候,会先为 window 对象添加一个 test 属性,并在堆中创建了一个空对象,并将该对象的地址指向了 window.test 属性。随后又创建一个大小为 100 的数组,并将属性地址指向了 test.a 的属性值

img

栈中保存了指向 window 对象的指针,通过栈中 window 的地址,我们可以到达 window 对象,通过 window 对象可以到达 test 对象,通过 test 对象还可以到达 a 对象

如果此时,将另外一个对象赋给了 a 属性window.test.a = new Object()

img

我们可以看到,a 属性之前是指向堆中数组对象的,现在已经指向了另外一个空对象,那么此时堆中的数组对象就成为了垃圾数据,因为我们无法从一个根对象遍历到这个 Array 对象

垃圾回收算法

垃圾回收大致可以分为以下几个步骤:

第一步,通过 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 中,会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象

新生代通常只支持 1 ~ 8M 的容量,而老生代支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收

  • 副垃圾回收器 -Minor GC (Scavenger),主要负责新生代的垃圾回收
  • 主垃圾回收器 -Major GC,主要负责老生代的垃圾回收

副垃圾回收器

副垃圾回收器主要负责新生代的垃圾回收。通常情况下,大多数小的对象都会被分配到新生代,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的

新生代中的垃圾数据用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域 (from-space),一半是空闲区域 (to-space)

img

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作

在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了

img

完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时,这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去

不过,副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中

主垃圾回收器

主垃圾回收器主要负责老生代中的垃圾回收。除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。因此,老生代中的对象有两个特点:

  • 一个是对象占用空间大
  • 另一个是对象存活时间长

由于老生代的对象比较大,若要在老生代中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。所以,主垃圾回收器是采用**标记 - 清除(Mark-Sweep)**的算法进行垃圾回收的

首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据

接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉

img

对垃圾数据进行标记,然后清除,这就是标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法——标记 - 整理(Mark-Compact)

这个算法的标记过程仍然与标记 - 清除算法里的是一样的,先标记可回收对象,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存

思考

1、有个疑惑,没有被 GC Root 遍历触达的对象就是垃圾数据。那在垃圾清理的过程中,没有被遍历到的对象是如何被标记为垃圾数据的呢?

比如在标记之前,将所有对象设置为白色,然后遍历到的设置为黑色,最终白色的都视为垃圾数据

2、主垃圾回收器的两种算法是同时使用还是有个策略?还是说早期用的是标记 - 清除算法,现在都是使用标记 - 整理算法?

同时使用的,并不是所有时候都需要做内存整理

3、引用计数的垃圾回收机制,目前只看到 iOS 中使用,想知道苹果为啥选择引用计数,而其他很多语言都采用 GC 呢?因为 GC 有 stop-the-world 的问题,而引用计数方法貌似主要是会有循环引用问题,但是循环引用问题写代码时注意就不会有问题了,而 stop-the-world 是无法避免的。所以我个人更倾向于引用计数的方案,不知道老师怎么认为的呢?

引用计数存在无法回收循环引用的问题,以前 ie 的 js 引擎就采用了循环引用的方式,现在都切换回来了

垃圾回收(下)

由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)

一次完整的垃圾回收分为标记和清理两个阶段,垃圾数据标记之后,V8 会继续执行清理和整理操作,虽然主垃圾回收器和副垃圾回收器的处理方式稍微有些不同,但它们都是主线程上执行的,执行垃圾回收过程中,会暂停主线程上的其他任务

img

执行垃圾回收时会占用主线程的时间,如果在执行垃圾回收的过程中,垃圾回收器占用主线程时间过久,就像上面图片展示的那样,花费了 200 毫秒,在这 200 毫秒内,主线程是不能做其他事情的。比如,页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行,造成页面的卡顿 (Jank),用户体验不佳

为了解决全停顿而造成的用户体验的问题,V8 团队经过了很多年的努力,向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,并且也已经取得了一些成效

  • 第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务
  • 第二,将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅

并行回收

既然执行一次完整的垃圾回收过程比较耗时,那么解决效率问题,第一个思路就是主线程在执行垃圾回收的任务时,引入多个辅助线程来并行处理,这样就会加速垃圾回收的执行速度,因此 V8 团队引入了并行回收机制

所谓并行回收,是指垃圾回收器在主线程上执行的过程中,还会开启多个协助线程,同时执行同样的回收工作

img

采用并行回收时,垃圾回收所消耗的时间,等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间),再加上一些同步开销的时间。这种方式比较简单,因为在执行垃圾标记的过程中,主线程并不会同时执行 JavaScript 代码,因此 JavaScript 代码也不会改变回收的过程。所以我们可以假定内存状态是静态的,因此只要确保同时只有一个协助线程在访问对象就好了

V8 的副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针

增量回收

虽然并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是一种全停顿的垃圾回收方式,在主线程执行回收工作的时候才会开启辅助线程,这依然还会存在效率问题。比如老生代存放的都是一些大的对象,如 window、DOM 这种,完整执行老生代的垃圾回收,时间依然会很久。这些大的对象都是主垃圾回收器的,所以在 2011 年,V8 又引入了增量标记的方式,我们把这种垃圾回收的方式称之为增量式垃圾回收

所谓增量式垃圾回收,是指垃圾收集器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作

img

增量标记的算法,比全停顿的算法要稍微复杂,这主要是因为增量回收是并发的(concurrent),要实现增量执行,需要满足两点要求:

1、垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收来了之后,才能继续启动

2、在暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理

这里我们需要知道,在没有采用增量算法之前,V8 使用黑色和白色来标记数据。在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots 出发,将所有能访问到的数据标记为黑色。遍历结束之后,被标记为黑色的数据就是活动数据,那些白色数据就是垃圾数据

如果内存中的数据只有两种状态,非黑即白,那么当你暂停了当前的垃圾回收器之后,再次恢复垃圾回收器,那么垃圾回收器就不知道从哪个位置继续开始执行了

比如垃圾回收器执行了一小段增量回收后,被 V8 暂停了,然后主线程执行了一段 JavaScript 代码,然后垃圾回收器又被恢复了

img

那么,当垃圾回收器再次被启动的时候,它到底是从 A 节点开始标记,还是从 B 节点开始执行标注过程呢?因为没有其他额外的信息,所以垃圾回收器也不知道该如何处理了

为了解决这个问题,V8 采用了三色标记法,除了黑色和白色,还额外引入了灰色:

1、黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了

2、灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点

3、白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回

引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行

接下来,我们再来分析下,标记好的垃圾数据被 JavaScript 修改了,V8 是如何处理的

js
window.a = Object();
window.a.b = Object();
window.a.b.c = Object();

img

然后又执行了另外一个代码window.a.b = Object() //d

执行完之后,垃圾回收器又恢复执行了增量标记过程,由于 b 重新指向了 d 对象,所以 b 和 c 对象的连接就断开了

img

这就说明一个问题,当垃圾回收器将某个节点标记成了黑色,然后这个黑色的节点被续上了一个白色节点,那么垃圾回收器不会再次将这个白色节点标记为黑色节点了,因为它已经走过这个路径了

但是这个新的白色节点的确被引用了,所以我们还是需要想办法将其标记为黑色

为了解决这个问题,增量垃圾回收器添加了一个约束条件:不能让黑色节点指向白色节点

通常我们使用写屏障 (Write-barrier) 机制实现这个约束条件,也就是说,当发生了黑色的节点引用了白色的节点,写屏障机制会强制将被引用的白色节点变成灰色的,这样就保证了黑色节点不能指向白色节点的约束条件。这个方法也被称为强三色不变性,它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是不可到达的,可以安全释放

所以在 V8 中,每次执行如window.a.b = value的写操作之后,V8 会插入写屏障代码,强制将 value 这块内存标记为灰色

并发 (concurrent) 回收

虽然通过三色标记法和写屏障机制可以很好地实现增量垃圾回收,但是由于这些操作都是在主线程上执行的,如果主线程繁忙的时候,增量垃圾回收操作依然会增加主线程处理任务的吞吐量 (throughput)

结合并行回收可以将一些任务分配给辅助线程,但是并行回收依然会阻塞主线程,那么,有没有办法在不阻塞主线程的情况下,执行垃圾回收操作呢?

这就是我们要来重点研究的并发回收机制

所谓并发回收,是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作

img

并发回收的优势非常明显,主线程不会被挂起,JavaScript 可以自由地执行 ,在执行的同时,辅助线程可以执行垃圾回收操作

但是并发回收却是这三种技术中最难的一种,这主要由以下两个原因导致的:

  • 第一,当主线程执行 JavaScript 时,堆中的内容随时都有可能发生变化,从而使得辅助线程之前做的工作完全无效
  • 第二,主线程和辅助线程极有可能在同一时间去更改同一个对象,这就需要额外实现读写锁的一些功能了

尽管并行回收要额外解决以上两个问题,但是权衡利弊,并行回收这种方式的效率还是远高于其他方式的

不过,这三种技术在实际使用中,并不是单独的存在,通常会将其融合在一起使用,V8 的主垃圾回收器就融合了这三种机制,来实现垃圾回收,那它具体是怎么工作的呢?

img

可以看出来,主垃圾回收器同时采用了这三种策略:

  • 首先主垃圾回收器主要使用并发标记,我们可以看到,在主线程执行 JavaScript,辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的
  • 标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作
  • 另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种 JavaScript 任务之间执行

思考

在使用 JavaScript 时,如何避免内存泄漏?

1、别把对象关联到全局变量上,避免循环引用

答疑

Node 中的 readFile API 工作机制

Node 中很多 API 都提供了同步和异步两种形式。消息队列一章中思考题中有两段代码,我们通过这两段代码来分析下同步和异步读文件 API 的区别

js
var fs = require("fs");

var data = fs.readFileSync("test.js");
js
function fileHanlder(err, data) {
  data.toString();
}

fs.readFile("test.txt", fileHanlder);

我们先来看看 Node 的体系架构

img

Node 是 V8 的宿主,它会给 V8 提供事件循环和消息队列。在 Node 中,事件循环是由 libuv 提供的,libuv 工作在主线程中,它会从消息队列中取出事件,并在主线程上执行事件

同样,对于一些主线程上不适合处理的事件,比如消耗时间过久的网络资源下载、文件读写、设备访问等,Node 会提供很多线程来处理这些事件,我们把这些线程称为线程池

通常,在 Node 中,我们认为读写文件是一个非常耗时的工作,因此主线程会将回调函数和读文件的操作一道发送给文件读写线程,并让实际的读写操作运行在读写线程中

比如当在 Node 的主线程上执行 readFile 的时候,主线程会将 readFile 的文件名称和回调函数,提交给文件读写线程来处理

img

文件读写线程完成了文件读取之后,会将结果和回调函数封装成新的事件,并将其添加进消息队列中。比如文件线程将读取的文件内容存放在内存中,并将 data 指针指向了该内存,然后文件读写线程会将 data 和回调函数封装成新的事件,并将其丢进消息队列中

img

等到 libuv 从消息队列中读取该事件后,主线程就可以着手来处理该事件了。在主线程处理该事件的过程中,主线程调用事件中的回调函数,并将 data 结果数据作为参数

img

然后在回调函数中,我们就可以拿到读取的结果来实现一些业务逻辑了

不过,总有些人觉得异步读写文件操作过于复杂了,如果读取的文件体积不大或者项目瓶颈不在文件读写,那么依然使用异步调用和回调函数的模式就显得有点过度复杂了

因此 Node 还提供了一套同步读写的 API。第一段代码中的 readFileSync 就是同步实现的,同步代码非常简单,当 libuv 读取到 readFileSync 的任务后,就直接在主线程上执行读写操作,等待读写结束,直接返回读写的结果,这也是同步回调的一种应用。当然在读写过程中,消息队列中的其他任务是无法被执行的

几种内存问题

内存问题至关重要,因为通过内存而造成的问题很容易被用户察觉。总的来说,内存问题可以定义为下面这三类:

  • 内存泄漏 (Memory leak),它会导致页面的性能越来越差
  • 内存膨胀 (Memory bloat),它会导致页面的性能会一直很差
  • 频繁垃圾回收,它会导致页面出现延迟或者经常暂停

内存泄漏

本质上,内存泄漏可以定义为:当进程不再需要某些内存的时候,这些不再被需要的内存依然没有被进程回收

在 JavaScript 中,造成内存泄漏 (Memory leak) 的主要原因是不再需要 (没有作用) 的内存数据依然被其他对象引用着

来看几种实际的例子:

js
function foo() {
  //创建一个临时的temp_array
  temp_array = new Array(200000);
  /**
   * 使用temp_array
   */
}

当执行这段代码时,由于函数体内的对象没有被 var、let、const 这些关键字声明,那么 V8 就会使用 this.temp_array 替换 temp_array

js
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

另外,我们还要时刻警惕闭包这种情况,因为闭包会引用父级函数中定义的变量,如果引用了不被需要的变量,那么也会造成内存泄漏

js
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 对象依然保留在内存中

img

我们可以优化一下代码

js
function foo() {
  var temp_object = new Object();
  temp_object.x = 1;
  temp_object.y = 2;
  temp_object.array = new Array(200000);
  /**
   *   使用temp_object
   */
  let closure = temp_object.x;
  return function () {
    console.log(closure);
  };
}

当再次执行这段代码时,我们就可以看到闭包引用的仅仅是一个 closure 的变量

img

我们再来看看由于 JavaScript 引用了 DOM 节点而造成的内存泄漏的问题,只有同时满足 DOM 树和 JavaScript 代码都不引用某个 DOM 节点,该节点才会被作为垃圾进行回收。 如果某个节点已从 DOM 树移除,但 JavaScript 仍然引用它,我们称此节点为“detached ”。“detached ”节点是 DOM 内存泄漏的常见原因

我们通过 JavaScript 创建了一些 DOM 元素,有了这些内存中的 DOM 元素,当有需要的时候,我们就快速地将这些 DOM 元素关联到 DOM 树上,一旦这些 DOM 元素从 DOM 上被移除后,它们并不会立即销毁,这主要是由于 JavaScript 代码中保留了这些元素的引用,导致这些 DOM 元素依然会呆在内存中。所以在保存 DOM 元素引用的时候,我们需要非常小心谨慎

内存膨胀

了解几种可能造成内存泄漏的问题之后,接下来,我们再来看看另外一个和内存泄漏类似的问题:内存膨胀(Memory bloat)

内存膨胀和内存泄漏有一些差异,内存膨胀主要表现在程序员对内存管理的不科学,比如只需要 50M 内存就可以搞定的,有些程序员却花费了 500M 内存

额外使用过多的内存有可能是没有充分地利用好缓存,也有可能加载了一些不必要的资源。通常表现为内存在某一段时间内快速增长,然后达到一个平稳的峰值继续运行

比如一次性加载了大量的资源,内存会快速达到一个峰值

img

我们可以看到,内存膨胀是快速增长,然后达到一个平衡的位置,而内存泄漏是内存一直在缓慢增长。要避免内存膨胀,我们需要合理规划项目,充分利用缓存等技术来减轻项目中不必要的内存占用

频繁的垃圾回收

除了内存泄漏和内存膨胀,还有另外一类内存问题,那就是频繁使用大的临时变量,导致了新生代空间很快被装满,从而频繁触发垃圾回收。频繁的垃圾回收操作会让你感觉到页面卡顿

js
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();

这段代码就会频繁创建临时变量,这种方式很快就会造成新生代内存内装满,从而频繁触发垃圾回收。为了解决频繁的垃圾回收的问题,你可以考虑将这些临时变量设置为全局变量

思考

你还遇到过哪些具体的内存问题呢?这些问题都是怎么解决的?

1、介绍一个场景:Node.js v4.x ,BFF 层服务端在 js 代码中写了一个 lib 模块 做 lfu、lru 的缓存,用于针对后端返回的数据进行缓存。把内存当缓存用的时候,由于线上 qps 较大的时候,缓存模块被频繁调用,造成了明显的 gc stw 现象,外部表现就是 node 对上游 http 返回逐渐变慢。由于当时上游是 nginx,且 nginx 设置了 timeout retry,因此这个内存 gc 问题当 node 返回时间超出 nginx timeout 阈值时 进而引起了 nginx 大量 retry,迅速形成雪崩效应。后来不再使用这样的当时,改为使用 node 服务器端本地文件+redis/memcache 的缓存方案,node 做 bff 层时 确实不适合做内存当缓存这种事

2、运行场景:K 线行情列表 技术方案,websocket 推送二进制数据(2 次/秒) -> 转换为 utf-8 格式 -> 检查数据是否相同 -> 渲染到 dom 中 出现问题:页面长时间运行后出现卡顿的现象 问题分析:将二进制数据转换为 utf-8 时,频繁触发了垃圾回收机制 解决方案:后端推送采取增量推送形式

3、请教老师个问题,chromium 中的 jscore 和 v8 是一个东西吗 ?

不是,jscore 是苹果内置在 webkit 中的 js 引擎,也是很早期的了,开始 chrome 用了 webkit,但是没用 jscore,而是采用了他们自己开发的 v8