本系列为极客时间上的《浏览器工作原理与实践》专栏的学习笔记
数据存储
JavaScript 的数据类型分为两大类:原始类型和引用类型,他们在内存中的存储位置是不一样的。内存空间分为三类:代码空间、栈空间和堆空间。代码空间,顾名思义就是存储执行代码的地方。剩下的栈空间和堆空间就是用来存储我们程序中的数据的,比如我们定义的各种类型的变量以及生产出来的数据。
栈空间也就是调用栈,是用来存储执行上下文的。当 JavaScript 程序执行的时候,会先创建一个全局执行上下文,接着执行函数的时候,也会创建该函数的执行上下文,里面包含变量环境和词法环境。变量环境里存储的是变量名和变量值。如果某个变量是原始类型,那么变量值就会存储在栈中。但是如果某个变量是引用类型的话,那么栈中存储的变量值只一个引用地址,真正的值是存放在堆空间。所以对于一些引用类型的变量的赋值,其实赋值的是引用地址。
这样做的原因是因为,JavaScript 引擎需要用栈来维护程序执行期间的上下文状态,如果所有的数据都存放在栈中的话,会影响到上下文切换的效率。
在一个函数执行完之后,对应的执行上下文会被销毁(通过移动指针)。但是如果创建了一个闭包的话,里面引用的变量会被保留下来,这是为什么呢?
因为在编译过程中,对于一些内部函数,JavaScript 引擎会做一个快速的扫描,如果发现内部函数有引用到外部变量的话,就会在堆空间中创建一个 closure(foo) 对象,用来保存引用到的外部变量。这样在函数执行完之后,返回的内部函数也引用到了 closure(foo) 对象,这样在下次执行这些内部函数时,创建的执行上下文中就包含了 closure(foo) 对象。
垃圾回收
调用栈中的数据回收
当执行到函数 A 的时候,该函数的执行上下文会被压入到调用栈中,同时,还有一个记录当前执行状态的指针(称为 ESP),指向调用栈中函数 A 的执行上下文。
当函数 A 执行完毕之后,ESP 会进行下移,这个下移操作就是销毁函数 A 的执行上下文的过程。因为等到执行其他函数 B 的时候,会入栈直接覆盖掉之前函数 A 的内存。
堆中的数据回收
代际假说:
大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
不死的对象,会活得更久;
V8 会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,同时分配的空间也比较小。老生代中存放的是生存时间长的对象,同时分配的空间也比较大。
V8 采用两个垃圾回收器用来分别回收这两个区域的垃圾数据:
副垃圾回收器,主要负责新生代的垃圾回收。
主垃圾回收器,主要负责老生代的垃圾回收。
垃圾回收器的主要工作流程如下:
- 标记空间中活动对象和非活动对象
- 回收非活动对象的内存空间
- 内存整理。经过多次回收之后会产生大量不连续的内存碎片,如果不及时整理,就无法为接下来需要占用大内存空间的对象分配内存。
副垃圾回收器处理流程
新生代对半划分为两个区域:对象区和空闲区。
新加入的对象会被放到对象区域,对象区域快要被写满的时候,就会执行一次垃圾清理。
首先对对象区域进行标记,然后将存活的对象复制到空闲区域,复制的过程中会有序排列起来,这样最后复制完成之后,就不会有内存碎片的存在。
复制完成之后,将对象区域和空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
为了执行效率,一般新生区的空间会被设置得比较小。经过两次垃圾回收依然还存活的对象,会通过对象晋升策略,被移动到老生代中。
主垃圾回收器处理流程
主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的。递归遍历根元素,把能到达的元素归为活动元素,未到达的元素归为垃圾数据。标记完成之后,就会进行垃圾清除。
多次清除之后,会产生大量不连续的碎片,就需要进行整理,于是又产生了另外一种算法——标记 - 整理(Mark-Compact)。这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

全停顿
由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。
为了减小全停顿带来的影响,V8 将标记过程分为一个个的子标记过程,同时将标记过程与 JavaScript 应用逻辑交替进行,直到标记阶段结束。我们把这个算法称为增量标记(Incremental Marking)算法。

V8 如何执行一段 JavaScript 代码
相关名词:编译器(Compiler)、解释器(Interpreter)、抽象语法树(AST)、字节码(Bytecode)、即时编译器(JIT)。
编译器和解释器存在的目的就是将我们编写的代码,”翻译“成机器能够理解的语言。大致过程如下:

编译型语言在执行之前,就会被编译成机器能够读懂的二进制文件。每次运行的时候,可以直接运行编译过后的文件。
解释型语言在每次执行的时候,都会由解释器对程序进行动态的解释和执行。
抽象语法树(AST),为了能够让机器更好的处理由高级程序语言编写的代码,首先需要将代码转化为 AST,它是代码的一种结构化表示,是后续处理流程的前提。
AST 的生成过程由两阶段组成:分词(词法分析)和解析(语法分析)。分词是将源码拆解成一个个 token (指的是语法上不可再分的最小的单个字符或字符串)。语法分析是将 token 按照语法规则转为 AST,只要源码符合语法规则,这一步就会顺利完成。

上图是 V8 引擎执行一段代码的流程图,有了 AST 之后,解释器就会将 AST 转换为字节码,并且逐行解释执行。
字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。
为什么要在机器码之前引入字节码?因为机器码需要占据大量的存储空间,在一些低内存的移动设备上,内存占用问题就会显露出来。使用字节码,可以减少系统的内存使用。
第一次执行的字节码,解释器会逐条解释执行。在执行的过程中,如果有发现热点代码(HotSpot),比如一段代码被重复执行多次,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。
这种字节码配合解释器和编译器的技术,就是即时编译(JIT)。具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。