浅析浏览器是如何工作的(三):机器码和字节码、隐藏类和内联缓存、异步编程与消息队列、垃圾回收机制原理、利用V8原理优化
最近看到一篇文章,详细讲述了浏览器是如何工作的,感觉非常好,所以决定一点点摘录及研究下。
一、机器码、字节码
1、V8 为什么要引入字节码
早期的 V8 为了提升代码的执行速度,直接将 JavaScript 源代码编译成了没有优化的二进制机器代码,如果某一段二进制代码执行频率过高,那么 V8 会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高。
随着移动设备的普及,V8 团队逐渐发现将 JavaScript 源码直接编译成二进制代码存在两个致命的问题:
(1)时间问题:编译时间过久,影响代码启动速度;
(2)空间问题:缓存编译后的二进制代码占用更多的内存。
这两个问题无疑会阻碍 V8 在移动设备上的普及,于是 V8 团队大规模重构代码,引入了中间的字节码。
字节码的优势有如下三点:
(1)解决启动问题:生成字节码的时间很短;
(2)解决空间问题:字节码虽然占用的空间比原始的 JavaScript 多,但是相较于机器代码,字节码还是小了太多,缓存字节码会大大降低内存的使用。
(3)代码架构清晰:采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易。
Bytecode 某种程度上就是汇编语言,只是它没有对应特定的 CPU,或者说它对应的是虚拟的 CPU。这样的话,生成 Bytecode 时简单很多,无需为不同的 CPU 生产不同的代码。要知道,V8 支持 9 种不同的 CPU,引入一个中间层 Bytecode,可以简化 V8 的编译流程,提高可扩展性。
如果我们在不同硬件上去生成 Bytecode,会发现生成代码的指令是一样的。
2、如何查看字节码
// test.js
function add(x, y) {
var z = x + y;
return z;
}
console.log(add(1, 2));
运行./d8 ./test.js --print-bytecode
:
[generated bytecode for function: add (0x01000824fe59 <SharedFunctionInfo add>)]
Parameter count 3 #三个参数,包括了显式地传入的 x 和 y,还有一个隐式地传入的 this
Register count 1
Frame size 8
0x10008250026 @ 0 : 25 02 Ldar a1 #将a1寄存器中的值加载到累加器中,LoaD Accumulator from Register
0x10008250028 @ 2 : 34 03 00 Add a0, [0]
0x1000825002b @ 5 : 26 fb Star r0 #Store Accumulator to Register,把累加器中的值保存到r0寄存器中
0x1000825002d @ 7 : aa Return #结束当前函数的执行,并将控制权传回给调用方
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 0)
3
3、常用字节码指令:
- Ldar:表示将寄存器中的值加载到累加器中,你可以把它理解为 LoaD Accumulator from Register,就是把某个寄存器中的值,加载到累加器中。
- Star:表示 Store Accumulator Register, 你可以把它理解为 Store Accumulator to Register,就是把累加器中的值保存到某个寄存器中
-
Add:
Add a0, [0]
是从 a0 寄存器加载值并将其与累加器中的值相加,然后将结果再次放入累加器。add a0 后面的[0]称之为 feedback vector slot,又叫反馈向量槽,它是一个数组,解释器将解释执行过程中的一些数据类型的分析信息都保存在这个反馈向量槽中了,目的是为了给 TurboFan 优化编译器提供优化信息,很多字节码都会为反馈向量槽提供运行时信息。
- LdaSmi:将小整数(Smi)加载到累加器寄存器中
- Return:结束当前函数的执行,并将控制权传回给调用方。返回的值是累加器中的值。
二、隐藏类和内联缓存
JavaScript 是一门动态语言,其执行效率要低于静态语言,V8 为了提升 JavaScript 的执行速度,借鉴了很多静态语言的特性,比如实现了 JIT 机制,为了提升对象的属性访问速度而引入了隐藏类,为了加速运算而引入了内联缓存。
1、为什么静态语言的效率更高?
静态语言中,如 C++ 在声明一个对象之前需要定义该对象的结构,代码在执行之前需要先被编译,编译的时候,每个对象的形状都是固定的,也就是说,在代码的执行过程中是无法被改变的。可以直接通过偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因。
JavaScript 在运行时,对象的属性是可以被修改的,所以当 V8 使用了一个对象时,比如使用了 obj.x 的时候,它并不知道该对象中是否有 x,也不知道 x 相对于对象的偏移量是多少,也就是说 V8 并不知道该对象的具体的形状。那么,当在 JavaScript 中要查询对象 obj 中的 x 属性时,V8 会按照具体的规则一步一步来查询,这个过程非常的慢且耗时。
2、将静态的特性引入到 V8
(1)V8 采用的一个思路就是将 JavaScript 中的对象静态化,也就是 V8 在运行 JavaScript 的过程中,会假设 JavaScript 中的对象是静态的。
具体地讲,V8 对每个对象做如下两点假设:
(1)对象创建好了之后就不会添加新的属性;
(2)对象创建好了之后也不会删除属性。
符合这两个假设之后,V8 就可以对 JavaScript 中的对象做深度优化了。V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:
(1)对象中所包含的所有的属性;
(2)每个属性相对于对象的偏移量。
有了隐藏类之后,那么当 V8 访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8 就可以直接去内存中取出对应的属性值,而不需要经历一系列的查找过程,那么这就大大提升了 V8 查找对象的效率。
(2)在 V8 中,把隐藏类又称为 map,每个对象都有一个 map 属性,其值指向内存中的隐藏类;
map 描述了对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少。
3、通过 d8 查看隐藏类
// test.js
let point1 = { x: 100, y: 200 };
let point2 = { x: 200, y: 300 };
let point3 = { x: 100 };
%DebugPrint(point1);
%DebugPrint(point2);
%DebugPrint(point3);
./d8 --allow-natives-syntax ./test.js
# ===============
DebugPrint: 0x1ea3080c5bc5: [JS_OBJECT_TYPE]
# V8 为 point1 对象创建的隐藏类
- map: 0x1ea308284ce9 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1ea308241395 <Object map = 0x1ea3082801c1>
- elements: 0x1ea3080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x1ea3080406e9 <FixedArray[0]> {
#x: 100 (const data field 0)
#y: 200 (const data field 1)
}
0x1ea308284ce9: [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: 0x1ea308284cc1 <Map(HOLEY_ELEMENTS)>
- prototype_validity cell: 0x1ea3081c0451 <Cell value= 1>
- instance descriptors (own) #2: 0x1ea3080c5bf5 <DescriptorArray[2]>
- prototype: 0x1ea308241395 <Object map = 0x1ea3082801c1>
- constructor: 0x1ea3082413b1 <JSFunction Object (sfi = 0x1ea3081c557d)>
- dependent code: 0x1ea3080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
# ===============
DebugPrint: 0x1ea3080c5c1d: [JS_OBJECT_TYPE]
# V8 为 point2 对象创建的隐藏类
- map: 0x1ea308284ce9 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1ea308241395 <Object map = 0x1ea3082801c1>
- elements: 0x1ea3080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x1ea3080406e9 <FixedArray[0]> {
#x: 200 (const data field 0)
#y: 300 (const data field 1)
}
0x1ea308284ce9: [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: 0x1ea308284cc1 <Map(HOLEY_ELEMENTS)>
- prototype_validity cell: 0x1ea3081c0451 <Cell value= 1>
- instance descriptors (own) #2: 0x1ea3080c5bf5 <DescriptorArray[2]>
- prototype: 0x1ea308241395 <Object map = 0x1ea3082801c1>
- constructor: 0x1ea3082413b1 <JSFunction Object (sfi = 0x1ea3081c557d)>
- dependent code: 0x1ea3080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
# ===============
DebugPrint: 0x1ea3080c5c31: [JS_OBJECT_TYPE]
# V8 为 point3 对象创建的隐藏类
- map: 0x1ea308284d39 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1ea308241395 <Object map = 0x1ea3082801c1>
- elements: 0x1ea3080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x1ea3080406e9 <FixedArray[0]> {
#x: 100 (const data field 0)
}
0x1ea308284d39: [Map]
- type: JS_OBJECT_TYPE
- instance size: 16
- inobject properties: 1
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x1ea308284d11 <Map(HOLEY_ELEMENTS)>
- prototype_validity cell: 0x1ea3081c0451 <Cell value= 1>
- instance descriptors (own) #1: 0x1ea3080c5c41 <DescriptorArray[1]>
- prototype: 0x1ea308241395 <Object map = 0x1ea3082801c1>
- constructor: 0x1ea3082413b1 <JSFunction Object (sfi = 0x1ea3081c557d)>
- dependent code: 0x1ea3080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
4、多个对象共用一个隐藏类
(1)在 V8 中,每个对象都有一个 map 属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类,这样有两个好处:
第一:减少隐藏类的创建次数,也间接加速了代码的执行速度;
第二:减少了隐藏类的存储空间。
(2)那么,什么情况下两个对象的形状是相同的,要满足以下两点:
第一:需要有相同的属性名称;
第二:需要有相等的属性个数。
5、重新构建隐藏类
给一个对象添加新的属性,删除新的属性,或者改变某个属性的数据类型都会改变这个对象的形状,那么势必也就会触发 V8 为改变形状后的对象重建新的隐藏类。
// test.js
let point = {};
%DebugPrint(point);
point.x = 100;
%DebugPrint(point);
point.y = 200;
%DebugPrint(point);
# ./d8 --allow-natives-syntax ./test.js
DebugPrint: 0x32c7080c5b2d: [JS_OBJECT_TYPE]
- map: 0x32c7082802d9 <Map(HOLEY_ELEMENTS)> [FastProperties]
...
DebugPrint: 0x32c7080c5b2d: [JS_OBJECT_TYPE]
- map: 0x32c708284cc1 <Map(HOLEY_ELEMENTS)> [FastProperties]
...
DebugPrint: 0x32c7080c5b2d: [JS_OBJECT_TYPE]
- map: 0x32c708284ce9 <Map(HOLEY_ELEMENTS)> [FastProperties]
...
每次给对象添加了一个新属性之后,该对象的隐藏类的地址都会改变,这也就意味着隐藏类也随着改变了;如果删除对象的某个属性,那么对象的形状也就随着发生了改变,这时 V8 也会重建该对象的隐藏类;
最佳实践:
(1)使用字面量初始化对象时,要保证属性的顺序是一致的;
(2)尽量使用字面量一次性初始化完整对象属性;
(3)尽量避免使用 delete 方法。
6、通过内联缓存来提升函数执行效率
虽然隐藏类能够加速查找对象的速度,但是在 V8 查找对象属性值的过程中,依然有查找对象的隐藏类和根据隐藏类来查找对象属性值的过程。如果一个函数中利用了对象的属性,并且这个函数会被多次执行:
function loadX(obj) {
return obj.x;
}
var obj = { x: 1, y: 3 };
var obj1 = { x: 3, y: 6 };
var obj2 = { x: 3, y: 6, z: 8 };
for (var i = 0; i < 100; i++) {
// 对比时间差异
console.time(`---${i}----`)
loadX(obj);
console.timeEnd(`---${i}----`)
loadX(obj1);
// 产生多态
loadX(obj2);
}
(1)通常 V8 获取 obj.x 的流程:
- 找对象 obj 的隐藏类;
- 再通过隐藏类查找 x 属性偏移量;
- 然后根据偏移量获取属性值,在这段代码中 loadX 函数会被反复执行,那么获取 obj.x 的流程也需要反复被执行;
(2)内联缓存及其原理:
- 函数 loadX 在一个 for 循环里面被重复执行了很多次,因此 V8 会想尽一切办法来压缩这个查找过程,以提升对象的查找效率。这个加速函数执行的策略就是内联缓存 (Inline Cache),简称为 IC;
- IC 的原理:在 V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率。
- IC 会为每个函数维护一个反馈向量 (FeedBack Vector),反馈向量记录了函数在执行过程中的一些关键的中间数据。
- 反馈向量其实就是一个表结构,它由很多项组成的,每一项称为一个插槽 (Slot),V8 会依次将执行 loadX 函数的中间数据写入到反馈向量的插槽中。
- 当 V8 再次调用 loadX 函数时,比如执行到 loadX 函数中的 return obj.x 语句时,它就会在对应的插槽中查找 x 属性的偏移量,之后 V8 就能直接去内存中获取 obj.x 的属性值了。这样就大大提升了 V8 的执行效率。
(3)单态、多态和超态:
- 如果一个插槽中只包含 1 个隐藏类,那么我们称这种状态为单态 (monomorphic);
- 如果一个插槽中包含了 2 ~ 4 个隐藏类,那我们称这种状态为多态 (polymorphic);
- 如果一个插槽中超过 4 个隐藏类,那我们称这种状态为超态 (magamorphic)。
- 单态的性能优于多态和超态,所以我们需要稍微避免多态和超态的情况。要避免多态和超态,那么就尽量默认所有的对象属性是不变的,比如你写了一个 loadX(obj) 的函数,那么当传递参数时,尽量不要使用多个不同形状的 obj 对象。
(4)总结:
V8 引入了内联缓存(IC),IC 会监听每个函数的执行过程,并在一些关键的地方埋下监听点,这些包括了加载对象属性 (Load)、给对象属性赋值 (Store)、还有函数调用 (Call),V8 会将监听到的数据写入一个称为反馈向量 (FeedBack Vector) 的结构中,同时 V8 会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8 就可以缩短对象属性的查找路径,从而提升执行效率。
但是针对函数中的同一段代码,如果对象的隐藏类是不同的,那么反馈向量也会记录这些不同的隐藏类,这就出现了多态和超态的情况。我们在实际项目中,要尽量避免出现多态或者超态的情况。
三、异步编程与消息队列
1、V8 是如何执行回调函数的
回调函数有两种类型:同步回调和异步回调,同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。
通用 UI 线程宏观架构:
UI 线程提供一个消息队列,并将待执行的事件添加到消息队列中,然后 UI 线程会不断循环地从消息队列中取出事件、执行事件。关于异步回调,这里也有两种不同的类型,其典型代表是 setTimeout 和 XMLHttpRequest:
- setTimeout 的执行流程其实是比较简单的,在 setTimeout 函数内部封装回调消息,并将回调消息添加进消息队列,然后主线程从消息队列中取出回调事件,并执行回调函数。
- XMLHttpRequest 稍微复杂一点,因为下载过程需要放到单独的一个线程中去执行,所以执行 XMLHttpRequest.send 的时候,宿主会将实际请求转发给网络线程,然后 send 函数退出,主线程继续执行下面的任务。网络线程在执行下载的过程中,会将一些中间信息和回调函数封装成新的消息,并将其添加进消息队列中,然后主线程从消息队列中取出回调事件,并执行回调函数。
2、宏任务和微任务
- 调用栈:调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。主线程在执行任务的过程中,如果函数的调用层次过深,可能造成栈溢出的错误,我们可以使用 setTimeout 来解决栈溢出的问题。setTimeout 的本质是将同步函数调用改成异步函数调用,这里的异步调用是将回调函数封装成宏任务,并将其添加进消息队列中,然后主线程再按照一定规则循环地从消息队列中读取下一个宏任务。
- 宏任务:就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。
- 微任务:你可以把微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
- JavaScript 中之所以要引入微任务,主要是由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,微任务可以在实时性和效率之间做一个有效的权衡。另外使用微任务,可以改变我们现在的异步编程模型,使得我们可以使用同步形式的代码来编写异步调用。
-
微任务是基于消息队列、事件循环、UI 主线程还有堆栈而来的,然后基于微任务,又可以延伸出协程、Promise、Generator、await/async 等现代前端经常使用的一些技术。
// 不会使浏览器卡死
function foo() {
setTimeout(foo, 0);
}
foo();
如果采用微任务形式:
// 浏览器console控制台可使浏览器卡死(无法响应鼠标事件等)
function foo() {
return Promise.resolve().then(foo);
}
foo();
- 如果当前的任务中产生了一个微任务,通过 Promise.resolve() 或者 Promise.reject() 都会触发微任务,触发的微任务不会在当前的函数中被执行,所以执行微任务时,不会导致栈的无限扩张;
- 和异步调用不同,微任务依然会在当前任务执行结束之前被执行,这也就意味着在当前微任务执行结束之前,消息队列中的其他任务是不可能被执行的。因此在函数内部触发的微任务,一定比在函数内部触发的宏任务要优先执行。
- 微任务依然是在当前的任务中执行的,所以如果在微任务中循环触发新的微任务,那么将导致消息队列中的其他任务没有机会被执行
3、前端异步编程方案史
- Callback 模式的异步编程模型需要实现大量的回调函数,大量的回调函数会打乱代码的正常逻辑,使得代码变得不线性、不易阅读,这就是我们所说的回调地狱问题。
- Promise 能很好地解决回调地狱的问题,我们可以按照线性的思路来编写代码,这个过程是线性的,非常符合人的直觉。
- 但是这种方式充满了 Promise 的 then() 方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的 then,语义化不明显,代码不能很好地表示执行流程。我们想要通过线性的方式来编写异步代码,要实现这个理想,最关键的是要能实现函数暂停和恢复执行的功能。而生成器就可以实现函数暂停和恢复,我们可以在生成器中使用同步代码的逻辑来异步代码 (实现该逻辑的核心是协程)。
- 但是在生成器之外,我们还需要一个触发器来驱动生成器的执行。前端的最终方案就是 async/await,async 是一个可以暂停和恢复执行的函数,在 async 函数内部使用 await 来暂停 async 函数的执行,await 等待的是一个 Promise 对象,如果 Promise 的状态变成 resolve 或者 reject,那么 async 函数会恢复执行。因此,使用 async/await 可以实现以同步的方式编写异步代码这一目标。和生成器函数一样,使用了 async 声明的函数在执行时,也是一个单独的协程,我们可以使用 await 来暂停该协程,由于 await 等待的是一个 Promise 对象,我们可以 resolve 来恢复该协程。
4、协程
协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。
比如,当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。
正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。每一时刻,该线程只能执行其中某一个协程。
最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
四、垃圾回收
1、垃圾数据
从“GC Roots”对象出发,遍历 GC Root 中的所有对象,如果通过 GC Roots 没有遍历到的对象,则这些对象便是垃圾数据。V8 会有专门的垃圾回收器来回收这些垃圾数据。
2、垃圾回收算法
垃圾回收大致可以分为以下几个步骤:
(1)第一步,通过 GC Root 标记空间中活动对象和非活动对象。
目前 V8 采用的可访问性(reachability)算法来判断堆中的对象是否是活动对象。具体地讲,这个算法是将一些 GC Root 作为初始存活的对象的集合,从 GC Roots 对象出发,遍历 GC Root 中的所有对象:
通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable),那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象;
通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable),那么这些不可访问的对象就可能被回收,我们称不可访问的对象为非活动对象。
在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种):
1)全局的 window 对象(位于每个 iframe 中);
2)文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;
3)存放栈上变量。
(2)第二步,回收非活动对象所占据的内存。
其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
(3)第三步,做内存整理。
一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。
当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况,所以最后一步需要整理这些内存碎片。
但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片(比如副垃圾回收器)。
3、垃圾回收
V8 依据代际假说,将堆内存划分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。
代际假说有两个特点:
(1)第一个是大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;
(2)第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。
为了提升垃圾回收的效率,V8 设置了两个垃圾回收器,主垃圾回收器和副垃圾回收器。
(1)主垃圾回收器负责收集老生代中的垃圾数据,副垃圾回收器负责收集新生代中的垃圾数据。
也就是说主垃圾回收器负责回收生存时间久的对象,副垃圾回收器负责回收生存时间短的对象。
(2)副垃圾回收器采用了 Scavenge 算法,是把新生代空间对半划分为两个区域(有些地方也称作From和To空间),一半是对象区域,一半是空闲区域。
新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换。这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
(3)副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。
(4)副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。
(5)主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作,会经历标记、清除和整理过程。
(6)主垃圾回收器主要负责老生代中的垃圾回收。除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。
(7)老生代中的对象有两个特点:一个是对象占用空间大;另一个是对象存活时间长。
4、全停顿(Stop-The-World)
由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。
(1)V8 最开始的垃圾回收器有两个特点:
第一个是垃圾回收在主线程上执行,
第二个特点是一次执行一个完整的垃圾回收流程。
(2)由于这两个原因,很容易造成主线程卡顿,所以 V8 采用了很多优化执行效率的方案。
第一个方案是并行回收,在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。
第二个方案是增量式垃圾回收,垃圾回收器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。
第三个方案是并发回收,回收线程在执行 JavaScript 的过程,辅助线程能够在后台完成的执行垃圾回收的操作。
资料参考:深入解读 V8 引擎的「并发标记」技术
(3)主垃圾回收器就综合采用了所有的方案(并发标记,增量标记,辅助清理),副垃圾回收器也采用了部分方案。
五、利用V8原理优化
1、Breaking the JavaScript Speed Limit with V8
Daniel Clifford 在 Google I/O 2012 上做了一个精彩的演讲“Breaking the JavaScript Speed Limit with V8”。在演讲中,他深入解释了 13 个简单的代码优化方法,可以让你的JavaScript代码在 Chrome V8 引擎编译/运行时更加快速。在演讲中,他介绍了怎么优化,并解释了原因。下面简明的列出了13 个 JavaScript 性能提升技巧:
- 在构造函数里初始化所有对象的成员(所以这些实例之后不会改变其隐藏类);
- 总是以相同的次序初始化对象成员;
- 尽量使用可以用 31 位有符号整数表示的数;
- 为数组使用从 0 开始的连续的主键;
- 别预分配大数组(比如大于 64K 个元素)到其最大尺寸,令其尺寸顺其自然发展就好;
- 别删除数组里的元素,尤其是数字数组;
- 别加载未初始化或已删除的元素;
- 对于固定大小的数组,使用”array literals“初始化(初始化小额定长数组时,用字面量进行初始化);
- 小数组(小于 64k)在使用之前先预分配正确的尺寸;
- 请勿在数字数组中存放非数字的值(对象);
- 尽量使用单一类型(monomorphic)而不是多类型(polymorphic)(如果通过非字面量进行初始化小数组时,切勿触发类型的重新转换);
- 不要使用
try{} catch{}
(如果存在try/catch
代码快,则将性能敏感的代码放到一个嵌套的函数中); - 在优化后避免在方法中修改隐藏类。
演讲资料参考: Performance Tips for JavaScript in V8 | JavaScript V8性能小贴士【译】 | 内网视频 | YouTube
2、在 V8 引擎里 5 个优化代码的技巧
- 对象属性的顺序: 在实例化你的对象属性的时候一定要使用相同的顺序,这样隐藏类和随后的优化代码才能共享;
- 动态属性: 在对象实例化之后再添加属性会强制使得隐藏类变化,并且会减慢为旧隐藏类所优化的代码的执行。所以,要在对象的构造函数中完成所有属性的分配;
- 方法: 重复执行相同的方法会运行的比不同的方法只执行一次要快 (因为内联缓存);
- 数组: 避免使用 keys 不是递增的数字的稀疏数组,这种 key 值不是递增数字的稀疏数组其实是一个 hash 表。在这种数组中每一个元素的获取都是昂贵的代价。同时,要避免提前申请大数组。最好的做法是随着你的需要慢慢的增大数组。最后,不要删除数组中的元素,因为这会使得 keys 变得稀疏;
- 标记值 (Tagged values): V8 用 32 位来表示对象和数字。它使用一位来区分它是对象 (flag = 1) 还是一个整型 (flag = 0),也被叫做小整型(SMI),因为它只有 31 位。然后,如果一个数值大于 31 位,V8 将会对其进行 box 操作,然后将其转换成 double 型,并且创建一个新的对象来装这个数。所以,为了避免代价很高的 box 操作,尽量使用 31 位的有符号数。
资料参考:How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code | 译文
box 操作参考:JavaScript类型:关于类型,有哪些你不知道的细节? | JavaScript 的装箱和拆箱 | 谈谈JavaScript中装箱和拆箱
3、JavaScript 启动性能瓶颈分析与解决方案
资料参考: JavaScript Start-up Performance | JavaScript 启动性能瓶颈分析与解决方案
作者:独钓寒江雪
原文链接:https://segmentfault.com/a/1190000037435824