基础概念
Javascript中的垃圾
- js中的内存管理是自动的
- 内存不再被引用时是垃圾
- 对象不能从根上访问时是垃圾
- 可以访问到的对象就是可达对象(引用,作用域链)
- 可达的标准就是从根出发是否可以被找到
- Javascipt中的根就可以理解为全队变量对象
- GC是一种机制,垃圾回收器完成的具体工作
- 工作内容就是查找垃圾释放空间、回收空间
- 算法就是工作时查找和回收所遵循的规则
核心思想就是内部通过一个引用计数器,维护当前对象的引用数,判断当前引用数是否为0来判断是否为垃圾对象,当引用数为0,GC开始工作,将其所在内存空间进行垃圾回收。当某一个引用关系发生改变时,引用计数器主动改变引用计数值,当为0时,GC开始工作,回收空间。
优缺点:
- 发现垃圾时立即回收
- 减少程序卡顿时间
- 无法回收循环引用的对象(a指向b,b指向a)
- 时间开销大(需要维护数值变化,当对象多时,维护会变慢,相对于其他算法,时间开销更大)
核心思想,分标记和清楚两个阶段。第一阶段遍历所有对象标记活动对象(可达对象),第二阶段遍历所有对象清除没有标记的对象,并把有标记对象身上的标记进行抹除,回收相应空间。最终,把回收空间放在空闲列表中,方便程序后面继续申请空间
优缺点
- 相对于引用计数法,可以对循环引用对象进行回收
- 空间碎片化,回收空间地址可能是不连续的
- 不会立即回收垃圾对象
可以理解为标记清楚算法的增强,标记阶段和标记清除法一样,清楚阶段会先执行整理,然后移动对象位置,使空闲地址连续.
优缺点:
- 减少碎片化空间
- 不会立即回收垃圾对象
- 移动对象位置,回收效率慢
V8内存有上限,64位操作系统不超过1.5G,32位系统不超过800M。
采用分代回收思想,内存分为新生代、老生代,针对不同对象采用不同算法(新生代采用具体GC算法,老生代采用具体的算法)。
GC算法常用的有分代回收、空间复制、标记清除、标记整理、标记增量
新生代存储区对象回收:
小空间存储新生代对象(64位系统32M,32位系统16M),新生代指的是存活时间较短的对象。回收过采用复制算法和标记整理,小空间分为From空间(使用空间),To(空闲空间),两个空间等大,当使用空间时,都存储在From空间,当内存使用达到一定程度,触发GC操作,标记整理算法标记From空间,整理后将活动对象拷贝至To空间,然后将From与To交换空间然后使用。
回收细节
- 拷贝过程中可能出现晋升(新生代中某个使用的对象老生代也有使用),如果一轮GC操作之后新生代对象还活着,就将新生代内对象移动至老生代区
- To空间使用率超过25%,变成使用状态时,新生代区域会不够用,这时会移动至老生代区
老生代对象存放在右侧老生代区域,64位系统1.4G,32位系统700M。老生代对象就是指存活时间较长的对象(全局对象,闭包中放置的变量数据)
主要采用标记清除,标记整理,标记增量算法。首先使用标记清除完成垃圾空间的回收,这时存在垃圾碎片化的问题。当新生代对象实现晋升时,老生代区域空间不够用,此时触发标记整理进行空间优化,把碎片空间进行整理(老生代区域主要使用算法),最后还会采用增量标记进行效率优化(程序在运行时会有暂停,多次暂停会存在时间空档,在多个时间空闲间隙,进行遍历对象进行标记、完成清楚)。
新老生代回收对比
- 新生代区域垃圾回收使用空间换时间(空间小)
- 老生代区域垃圾回收不合适复制算法
2008 年 V8 发布了第一个版本。当时 V8 架构比较激进,直接将 JavaScript 代码编译为机器码并执行,所以执行速度很快,但在该架构中,V8 只有 Codegen 一个编译器,对代码的优化很有限。
文章图片
2010 年,V8 发布了 Crankshaft 编译器,JavaScript 函数通常会先被 Full-Codegen 编译,如果后续该函数被多次执行,那么就用 Crankshaft 再重新编译,生成更优化的代码,之后使用优化后的代码执行,进一步提升性能。
文章图片
但是 Crankshaft 对代码的优化有限,所以 2015 年 V8 中加入了 TurboFan。
文章图片
V8 依旧是直接将源码编译为机器码的架构,这种架构存在的核心问题:内存消耗特别大。尤其是在移动设备上,通过 Full-Codegen 编译出的机器码几乎占整个 Chrome 浏览器的三分之一。
2016 年 V8 加入了 Ignition 解释器,重新引入字节码,旨在减少内存使用。
文章图片
2017 年,V8 正式发布全新编译 pipeline,即用 Ignition 和 TurboFan 的组合,来编译执行代码,从 V8 5.9 版开始,早期的 Full-Codegen 和 Crankshaft 编译器不再用来执行 JavaScript。
文章图片
其中,最核心的是三个模块:
- 解析器(Parser)
- 解释器(Ignition)
- 优化编译器(TurboFan)
在此过程中,解释器会记特定代码片段的运行次数,如果代码运行次数超过某个阈值,那么该段代码 就被标记为热代码(hot code),并将运行信息反馈给优化编译器(TurboFan)。
优化编译器根据反馈信息,优化并编译字节码,最终生成优化后的机器码,这样当该段代码再次执行时,解释器就直接使用优化机器码执行,不用再次解释,大大提高了代码运行效率。
这种在运行时编译代码的技术也被称为 JIT(即时编译),通过JIT可以极大提升 JavaScript 代码的执行性能。
解析器(Parser)如何把源码转换成 AST 要让 V8 执行我们编写的源码,就要将源码转换成 V8 能理解的格式。V8 会先把源码解析为一个抽象语法树(AST),这是用来表示源码的树形结构的对象,这个过程称为解析(Parsing),主要由 V8 的 Parser 模块实现。然后, V8 的解释器会把 AST 编译为字节码,一边解释一边执行。
解析和编译过程的性能非常重要,因为 V8 只有等编译完成后才能运行代码(现在我们先关注 V8 中解析过程的实现)。
文章图片
整个解析过程可分为两部分。
- 词法分析:将字符流转换为 tokens,字符流就是我们编写的一行行代码,token 是指语法上不能再分割的最小单位,可能是单个字符,也可能是字符串,图中的 Scanner 就是 V8 的词法分析器。
- 语法分析:根据语法规则,将 tokens 组成一个有嵌套层级的抽象语法结构树,这个树就是 AST,在此过程中,如果源码不符合语法规范,解析过程就会终止,并抛出语法错误。图中的 Parser 和 Pre-Parser 都是 V8 的语法分析器。
[
{
"type": "Keyword",
"value": "var"
},
{
"type": "Identifier",
"value": "a"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "Numeric",
"value": "1"
},
{
"type": "Punctuator",
"value": ";
"
}
]
可以看到, var a = 1; 这样一行代码包括 5 个tokens:
语法分析 接下来, V8 的解析器会通过语法分析,根据 tokens 生成 AST, var a = 1; 这行代码生成的 AST 的 JSON 结构如下所示:
AST在线转换网站
{
"type": "Program",
"start": 0,
"end": 10,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 10,
"declarations": [
{
"type": "VariableDeclarator",
"start": 4,
"end": 9,
"id": {
"type": "Identifier",
"start": 4,
"end": 5,
"name": "a"
},
"init": {
"type": "Literal",
"start": 8,
"end": 9,
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
],
"sourceType": "module"
}
但是,对于一份 JavaScript 源码,如果所有源码在执行前都要完全经过解析才能执行,那必然会面临以下问题。
代码执行时间变长:一次性解析所有代码,必然会增加代码的运行时间。
消耗更多内存:解析完的 AST,以及根据 AST 编译后的字节码都会存放在内存中,必然会占用更多内存空间。
占用磁盘空间:编译后的代码会缓存在磁盘上,占用磁盘空间。
所以,现在主流 JavaScript 引擎都实现了延迟解析(Lazy Parsing)。
延迟解析 延迟解析的思想很简单:在解析过程中,对于不是立即执行的函数,只进行预解析(Pre Parser),只有当函数调用时,才对函数进行全量解析。
进行预解析时,只验证函数语法是否有效、解析函数声明、确定函数作用域,不生成 AST,而实现预解析的,就是 Pre-Parser 解析器。
以一段代码为例:
function foo(a, b) {
var res = a + b;
return res;
}
var a = 1;
var c = 2;
foo(1, 2);
由于 Scanner 是按字节流从上往下一行行读取代码的,所以 V8 解析器也是从上往下解析代码。当 V8 解析器遇到函数声明 foo 时,发现它不是立即执行,所以会用 Pre-Parser 解析器对其预解析,过程中只会解析函数声明,不会解析函数内部代码,不会为函数内部代码生成 AST。
然后 Ignition 解释器会把 AST 编译为字节码并执行,解释器会按照自上而下的顺序执行代码,先执行 var a = 1; 和 var a = 2; 两个赋值表达式,然后执行函数调用 foo(1, 2) ,这时 Parser 解析器才会继续解析函数内的代码、生成 AST,再交给 Ignition 解释器编译执行。
解释器(Ignition)如何将 AST翻译为字节码并执行 在 V8 架构的演进中,我提到 V8 为了解决内存占用问题引入了字节码。如图所示,通常一个几 KB 的文件,转换为机器码可能就是几十兆,这会消耗巨大的内存空间。
文章图片
V8 的字节码是对机器码的抽象,语法与汇编有些类似,你可以把 V8 字节码看作一个个指令,这些指令组合到一起实现我们编写的功能,V8 定义了几百个字节码,你可以在 V8 解释器的头文件中查看所有字节码,bytecode.h
Ignition 解释器在执行字节码时,主要使用通用寄存器和累加寄存器(accumulator register),函数参数和局部变量都保存在通用寄存器中,累加寄存器用于保存中间结果。
如果安装了 Node.js ,可以通过 node --print-bytecode index.js 命令查看 JavaScript 文件生成的字节码,该命令会输出很多信息,专注于文件末尾的字节码。
node --print-bytecode index.js
优化编译器(TurboFan)的工作原理 V8 为了提升 JavaScript 的执行性能,在优化编译方面做了很多工作,其中最主要有内联和逃逸分析两种算法。
function add(x, y) {
return x + y;
}
function three() {
return add(1, 2);
}
如果不经优化,直接编译该段代码,则会分别生成两个函数的机器码。但为了进一步提升性能,TurboFan 优化编译器首先会对以上两个函数进行内联,然后再编译。
由于函数 three 内部的行为就是求 1 和 2 的和,所以上面的代码就等价于下面的:
function three_add_inlined() {
var x = 1;
var y = 2;
var add_return_value = https://www.it610.com/article/x + y;
return add_return_value;
}
更进一步,由于函数 three_add_inlined 中 x 和 y 的值都是确定的,所以 three_add_inlined 还可以进一步优化,直接返回结果 3 :
function three_add_const_folded() {
return 3;
}
这样最终编译生成的机器码相比优化前,就少非常多了,执行效率自然也高很多。
通过内联,可以降低复杂度、消除冗余代码、合并常量,并且内联技术通常也是逃逸分析的基础。
逃逸分析(Escape Analysis) 分析对象的生命周期是否仅限于当前函数。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(that) {
return Math.abs(this.x - that.x)
+ Math.abs(this.y - that.y);
}
}
function manhattan(x1, y1, x2, y2) {
const a = new Point(x1, y1);
const b = new Point(x2, y2);
return a.distance(b);
}
我们定义了一个 Point 类,用于表示某个点的坐标,类中有个 distance 方法,用来计算两个点之前的曼哈顿距离。
然后我们在 manhattan 函数中 new 了两个点, a 和 b ,并计算 a b 的曼哈顿距离。TurboFan 首先会通过内联,将 manhattan 函数转换为下面这样的函数:
function manhattan_inlined(x1, y1, x2, y2) {
const a = {x:x1, y:y1};
const b = {x:x2, y:y2};
return Math.abs(a.x - b.x)
+ Math.abs(a.y - b.y);
}
再接下来就会对 manhattan_inlined 中的对象进行逃逸分析。两类对象会被认定为“未逃逸”
- 对象在函数内部定义;
- 对象只作用域函数内部,如:没有被返回、没有传递应用给其他函数等。
function manhattan_scalar_eplacement(x1, y1, x2, y2) {
var a_x = x1;
var a_y = y1;
var b_x = x2;
var b_y = y2;
return Math.abs(a_x - b_x)
+ Math.abs(a_y - b_y);
}
这样函数内就不再有对象定义,取而代之的是 a_x a_y b_x b_y ,且直接来源于函数参数。
这样做的好处是,我们可以直接将变量加载到寄存器上,不再需要从内存中访问对象属性了,提升了执行效率的同时还减少了内存使用。
小结
【GC垃圾回收与V8引擎如何执行Javascipt代码过程】V8 执行 JavaScript 的原理,大致分为三个步骤:
- 解析器将 JavaScript 源码解析为 AST,解析过程分为词法分析和语法分析,V8 通过预解析提升解析效率;
- 解释器 Ignition 根据 AST 生成字节码并执行。这个过程中会收集执行反馈信息,交给 TurboFan 进行优化编译;
- TurboFan 根据 Ignition 收集的反馈信息,将字节码编译为优化后的机器码,后续 Ignition 用优化机器码代替字节码执行,进而提升性能。
推荐阅读
- 首屏时间,你说你优化了,那你倒是计算出给给我看啊!
- 选择篇(021)-下面代码的输出是什么?
- 精读《zustand 源码》
- 浅谈js的垃圾回收机制
- 选择篇(017)-哪个选项是不正确的?
- JavaScript笔记之如何写好JavaScript
- 拉新×23,盈利可能性高出19倍,嵌入式分析到底有多香
- 厉害了,JavaScript 新提案(array.groupBy())
- 【JavaScript Weekly #554】ES2022 特性(类静态初始化块)
- 单元测试(即刻搞定!)