当前位置: 首页 > news >正文

关于JS引擎优化的理解

之前在网上断续地了解过JS引擎对JS源代码的优化过程,但都不是特别明白,最近阅读Mathias Bynens(V8作者之一)的关于JS引擎的优化原理的博文后觉得相对来说是讲得最明白易懂的,让我用最简单的方式对这个问题有了自己的理解。

这篇笔记是我对这个问题的个人理解的简单总结。原文已经写得足够明白足够好了,我是希望用自己的方式来描述一下,帮助理解和记忆。也许深入的原理我还有一些不能准确描述,建议大家若有时间阅读原文来进一步学习。

注:文中大部分图片源自作者原文

JS引擎的工作方式

首先是一些背景知识,例如JS引擎都有哪些, 以及它们如何工作。

目前的主流JS引擎:

  1. V8(Chrome和NodeJS)
  2. SpiderMonkey(FireFox)
  3. Chakra(IE和Eage)
  4. JavaScriptCore(Safari/ReactNative)

JS引擎执行代码的流程

不同JS引擎对执行和优化的一些细节上有差别,但是它们有以下通用的流程。

  1. JS源码会先被解析器parser解析生成抽象语法树(AST, Abstract Syntax Tree);
  2. 解释器可以在AST基础上产生字节码并执行;
  3. 对于部分"hot"(例如被频繁调用)的代码,解释器会连同一些分析信息(profiling data)发送到编译器中进行优化;
  4. 优化是在现有代码及分析信息的基础上作出一定的推测,然后生成优化后的机器码;优化完成后, 该部分的代码就由优化后的机器码代替, 优化后产生机器码,可以直接在系统处理器中执行;
  5. 在某个节点发现优化时的特定推测是错误的,编译器也会进行“去优化”而将代码还原给解释器。
代码生成者执行者生成效率执行效率空间效率
字节码解释器(interpreter)解释器
机器码编译器(optimizer)CPU*

*注:此处CPU是我自己的理解,原文为bytecode needs an interpreter to run, whereas the optimized code can be executed directly by the processor

简单说就是解释器可以从抽象语法树很快地拿到第一手字节码并执行,但是代码是未经过优化的,假如某个频繁调用的方法需要从一个对象中访问某个特定的属性,那么每一次调用都会执行完整的查询过程,效率就会显得比较低;

优化代码需要时间,也需要更多的空间去存储优化相关的信息和体积变大的优化代码,但却可以让诸如以上情况的代码执行效率更高。

所以这里就是启动时间-占用空间-执行效率多方面的权衡。之前的V8是采用将源码全部编译为机器码的策略,跳过字节码的步骤,牺牲了部分启动时间,可以使执行效率非常高,可是机器码占用内存也会非常大,这样给代码的缓存也带来了很大的问题。某种程度上是有一点“过度优化”了。并不是优化越多越好,而是“好钢用在刀刃上”,只对“优化代码可以显著提高运行效率”的那部分代码进行优化。也就是作者口中的“Hot Code”。

不同浏览器引擎的实现

JS引擎interpreteroptimizer
V8ignitionTurboFan
SpiderMonkeyinterpreterBaseline + IonMonkey
ChakrainterpreterSimpleJIT + FullJIT
JavaScriptCoreLLIntBaseline + DFG + FTL

虽然它们的解释器和优化编译器看起来有不同的名字,但是所有JS引擎都具有相同的架构:parser(用于生成AST)和解析器 + 优化编译器的管道结构。说是管道结构是因为解析器执行字节码和优化编译器可以并行执行,当解释器把待优化的代码发送给另一个线程的编译器执行优化时,依然可以继续执行当前未优化的字节码;而优化过程完成后优化后的代码将会合流至主线程而后执行经过优化的代码。

而采用多个优化层,也是在“未优化”和“高度优化”之间设立了更多的中间节点,相当于“分级”--根据“Hot”的程度相应增加优化的程度,从而可以更细粒度地对时间/空间/执行效率之间的权衡决策进行控制。

对象和数组

在EcmaScript中,所有object实质上可以认为是字典,也就说字符串类型的键与属性值构成的键值对集合。对象的属性也有“属性”,就是定义属性自身的特性而不直接暴露给JavaScript的描述符:[[Value]], [[Writable]], [[Enumerable]], [[Configurable]]。每个属性都有对应的描述符,对于我们给对象添加的自定义属性,[[Value]]即我们赋给该属性的值,而其他描述符都会被默认为true。

至于数组,实际上也可以看作对象,不过数组对数值索引会有特别处理,有效字符串整数i的范围缩小到+0 <= i < 232-1, 而普通对象中的整数索引只需是安全整数(+0 <= i <= 253-1)的范围。数组包含length属性,它不可枚举也不可配置,修改数组元素后会自动更新;数组以数值索引的元素与自定义对象属性的描述符默认处理是相似的。

JS引擎的优化方式

Shapes和Inline Caches

想象一个书架(对象)有很多格子(连续的存储位置),每个格子可以放一本书(属性),我们每次买来新书都直接放在下一个空格子中。当我们想要去查看一本书的信息,需要从头开始一本一本检查书名,找一次就算了,如果每次都这样找,效率会很低。所以我们可以想办法把之前找到的位置序号记住,避免下次重复劳动。

但是这样有个问题,如果书架上的书有增减,位置发生变动了怎么办?那原来保存的信息就不可靠了。可又怎么知道有没有发生过变动呢?

我们建立一个图书名单,上面写了书名和它对应的位置,如果有变动就更新并且做一定标记,那就可以通过对比这个名单确认是否有过变更。采用这种方式对于需要经常来找某一本书的人来说就非常方便,他只需要记住是哪一个书名单和自己要找的书的位置,下次来只要书名单没有发生过变动,连查找书名那一步都省了,直接可以从对应位置取到他要的书。

如果比喻对象的属性值都是书而属性名是书名,Shape就是类似于上面所说“图书名单”的东西。Shape是一个统称,在不同的JS引擎中叫法不一,但含义相似。Shape只和属性信息(包括属性所在的内存位置和描述信息)有关,和实际对象的值之间是解耦的,所以只要两个对象的属性名称/描述信息和属性顺序都一样,那就可以共用一个Shape。

Inline Caches(ICs)是加速执行JS的关键所在,可以理解为为了减少对Hot代码执行重复检索而缓存下来的重要信息。之所以叫这个名字(内联缓存),大概是因为这种缓存信息是嵌入Hot Code所在命令的结构中保存的,在每次执行这段代码时进行即时校验和取用。

对象的存储和访问

实际上在JS引擎中对象的属性名和属性值是分别存储的,属性值本身被按顺序保存在对象中,而属性名则建立一个列表(Shape),存储每个属性名的“偏移量(offset)”和其他描述符属性

如果一个对象在运行时增加了新的属性,那么这个属性名单会过渡到一个新的Shape(只包含了新添加的属性)并链接回原Shape(原文中称为“过渡链”,transition chains),这样访问属性时如果最新的属性列表中没有找到,可以回溯到上一个列表去检索。

因为存在不同的对象有相同的属性名称列表而重用Shape,当它们发生不同改变会分别过渡到各自的新Shape,形成分叉结构(原文中称为“过渡树”,transition tree)。

但是如果频繁扩展对象使得Shape链非常长怎么办呢?引擎内部会针对这样的情况再整理一张表(ShapeTable),把所有属性名都列出来然后分别链接至它们所属的Shape...这看起来还是比较繁琐,但都是为了不要浪费“已经做过的工作”,使保留有用的检索信息——Inline Caches更加方便。

引用文中的例子:

function getX(o) {
    return o.x;
}
// 第一次执行,检索并缓存Shape链接和offset
getX({x: "a"});
// 之后执行,检查Shape是否相同,决定是否使用缓存
getX({x: "b"});
复制代码

第一次执行时检索Shape,得到offset后取出对象中的值;同时,Shape的链接和这次检索的结果也被内联缓存在代码结构中。

之后再访问时,如果对比Shape还是和之前一样(对象重用Shape的好处),就直接用缓存的offset。

数组的存储和访问

数组本身就是一种特殊的对象。数组的length属性与对象的属性存储方式相同。而对于数组的元素,本质上也是以字符串(数值)作为key的属性值,且默认情况下与对象自定义属性的描述信息相同(除[[value]]外,都可写,可枚举,可配置)。

JS引擎会把所有数值索引的元素单独存储在该数组的elements backing store中,可以理解为它的物品摆放整齐的后备仓库。如果没有人为修改任何索引的属性描述信息,不需要再存储“offset",因为通过数值索引访问时索引本身就是“offset”,而属性描述符只需存储一份给每一个索引属性共用。

但是以上是一般的情况,如果不幸遇到了数组索引的描述符被重新定义的情况,即使只是改变了一个,JS引擎也不得不放弃上面的优化策略,它的仓库也不得不变成“字典”一样的结构,为每个元素开辟更大的地方,为其索引属性保存完整的描述信息。这样数组操作相对来说会变得低效。

这里很容易让人想起特别常见的一个关于“手动缓存属性”的例子:

const arr = new Array(100000);
// arr.length内联在每次循环的检查条件中
for (let i = 0; i < arr.length; i++) {
    // ...
}
复制代码

这里的for循环中,每次循环的检查条件是i < arr.length,这样相当于每次都要对arr进行检索取出length属性值,循环的次数越多这种操作就越浪费。所以一般的建议是将arr.length提前用变量缓存,然后循环过程中直接使用变量,这样对数组length属性读取只需执行一次。

之前在某些文章见到过说这种最佳实践在最新JS引擎的优化功能下已经不那么重要,如果我没有理解错应该就是指即使没有手动缓存,JS引擎中也可以发现这段Hot代码并使用Inline Cache进行结果的缓存。但是这里并非直接缓存length的结果,而只是缓存可直接用于读取length的内存位置,所以还是没有把基本值缓存在变量里快。

粗略在console里通过循环测试了下,1000000次循环,结果是缓存变量执行<20ms可以完成的情况下,每次读取length属性需要~150ms。以下是测试代码:

// 每次循环读取arr.length
const arr = new Array(1000000);
let count = 0;
console.time("inline")
// arr.length内联在每次循环的检查条件中
for (let i = 0; i < arr.length; i++) {
    count++;
}
console.timeEnd("inline")
console.log(count);
// inline: 148.780029296875ms
// 1000000

// 将 length 缓存变量
const arr1 = new Array(1000000);
const len = arr1.length;
let count1 = 0;
console.time("len")

for (let i = 0; i < len; i++) {
    count1++;
}
console.timeEnd("len")
console.log(count1);
// len: 13.648193359375ms
// 1000000
复制代码

原型链优化

原型本身也是对象,当通过一个对象访问属性,如果在当前对象没有找到,会沿着原型链向上一级一级查找直到找到或原型为null时停止而返回undefined。

如果把原型和对象一样处理,当访问一个对象的属性,需要先在它本身的Shape中查找是否存在,如果没有,再访问该对象的原型,然后检查原型的Shape,以此类推——每次访问一个原型,相当于要完成在当前Shape中查找属性通过对象访问原型两次检索。而实际上,在JS引擎中,原型的引用被保存在了对象的Shape上而非对象本身,这样可以在检查当前Shape中没有目标属性的时候直接链接至下一个原型对象,使每跳转一次原型只需完成一次检索。

但是这样做还是需要沿着原型链检索属性,对于重复访问特定属性的操作优化十分有限。沿着原型链查找属性是比较昂贵的操作,尤其是有很多情况下对象的原型链可能会很长而常用的重要操作都在原型上,比如作者举的HTML中a元素的例子,我们可以用下面代码在console中打印出它的原型链:

function protoChain(node) {
    const p = Object.getPrototypeOf(node); // 或node.__proto__
    console.dir(p);
    return p == null || protoChain(p);
};
const a = document.createElement("a");
protoChain(a);
复制代码

打印出的结果是:

如果目标属性在比较深的原型上,每次检索都是一串昂贵操作。按照对象中缓存属性offset的思路,我们可以把原型上的属性位置也缓存一下,显然同时还必须把这个原型对象也保存一份引用,这样如果下次访问时原型链和原型对象本身没有发生过变化,就可以直接用上次缓存的结果,跳过查找操作。需要注意的是,任何对象的原型可以动态修改,如何确定原型链是否变化了呢?

JS引擎的做法是,每一个原型对象都有一个唯一的Shape(不和任何其他对象重用),Shape上会链接一个校验位(ValidityCell),标记“这个原型及其上游的原型链是否发生过变化”。当一个原型对象的属性发生变动,那这个原型和原型链中在它下游的所有原型的ValidityCell都会被置为false。所以为了保证缓存有效,只要确认实例对象的直接原型的这个校验位是否依然为true。

所以,除了缓存实例对象本身的Shape链接、offset和目标属性所在的原型对象,还需要保存该实例对象的直接原型的ValidityCell的链接。

比如以下这段代码:

class Bar {
    constructor(x) { this.x = x; }
    getX() { return this.x; }
}

const foo = new Bar(true);
const $getX = foo.getX;
复制代码

当执行$getX = foo.getX,实际上是先加载出foo.getX对应的值,然后将其赋值给$getX,第一步就是访问对象属性的过程,很明显它需要从原型中获取到,那么这段代码的Inline Cache在一次检索后会保存以下信息:

  • offset结果---目标属性的内存位置
  • 实例对象本身的Shape链接---对象的属性列表和直接原型是否发生过改变
  • 目标属性所在的原型对象链接---获取属性值
  • 实例对象的直接原型的ValidityCell的链接---确认原型链是否发生过改变

下次调用这段代码时,除了需要对比实例对象的Shape,还要对比原型链上是否有变化,如果都没有改变,那么不再需要检索,直接用缓存的offset取出对应原型对象的属性值即可。这将大大节省查找原型属性所耗费的时间。

而假如此期间修改了原型链的任何一环,原先保存的ValidityCell链接指向的valid值会被置为false,这时缓存就失效了,下次就需要把标准的检索重来一遍。

特别需要注意的一点是,当原型链上的原型对象发生改变时,其下游的任何原型对象原先的Shape对应的ValidityCell都会被标记为“无效”。可以想象,在代码执行过程中当Object.prototype这样的顶级原型被修改时,多少基于原型属性的Inline Cache会失效。

如上面提到过的HTML中a元素的例子,作者有非常形象的示意图:

当执行Object.prototype.x = 42,使顶级原型发生改变:

优化代码的建议

综合以上信息,作者站在引擎的角度给JS开发者以下几方面的建议:

  1. 始终以相同的方式初始化对象。

一方面提高Shape的重用性,另一方面尽量降低过渡链或过渡树的长度/深度,缩短沿Shape链检索属性的时间;

  1. 不要对数组的元素(数值索引属性)修改属性描述.

这样可以保留引擎对数组的优化处理,使数组的存储和访问更高效;

  1. 不要修改原型,尤其是层级较深的原型如Object.prototype等,即使确实有必要修改,也应该在所有代码执行之前修改而不要在代码执行过程中修改。

否则引擎为了保证取到正确的值而不得不放弃之前的内联缓存,重新以最笨的方法重新去查找和获取属性。

原文链接:

  • JavaScript engine fundamentals: Shapes and Inline Caches
  • JavaScript engine fundamentals: optimizing prototypes

中文译版:

  • JavaScript 引擎基础:Shapes 和 Inline Caches
  • JavaScript 引擎基础:原型优化

参考:

  • V8 Ignition:JS 引擎与字节码的不解之缘

相关文章:

  • 如何优雅的备份账号相关信息
  • mybatis学习总结
  • 全球首个大规模光电芯片到来
  • 1.4T的mysql表删除
  • MYSQL-SELECT查
  • css 浮动的时候如何,div进行居中
  • 大文件如何传输,大文件的传输方式有哪些?
  • 5 分钟掌握 JavaScript 实用窍门
  • Kubernetes弹性伸缩全场景解读(五) - 定时伸缩组件发布与开源
  • Linux下Python3.6的安装及避坑指南
  • 四川建筑企业资质办理材料详细说明
  • 组件化页面:封装el-form
  • Redis过期策略
  • cors方法跨域
  • (三) prometheus + grafana + alertmanager 配置Redis监控
  • 深入了解以太坊
  • 【159天】尚学堂高琪Java300集视频精华笔记(128)
  • 【css3】浏览器内核及其兼容性
  • 2019年如何成为全栈工程师?
  • DOM的那些事
  • eclipse的离线汉化
  • JavaSE小实践1:Java爬取斗图网站的所有表情包
  • OpenStack安装流程(juno版)- 添加网络服务(neutron)- controller节点
  • Sass 快速入门教程
  • windows-nginx-https-本地配置
  • 分享一份非常强势的Android面试题
  • 分享一个自己写的基于canvas的原生js图片爆炸插件
  • 高度不固定时垂直居中
  • 给新手的新浪微博 SDK 集成教程【一】
  • 基于组件的设计工作流与界面抽象
  • 警报:线上事故之CountDownLatch的威力
  • 前端学习笔记之原型——一张图说明`prototype`和`__proto__`的区别
  • 吴恩达Deep Learning课程练习题参考答案——R语言版
  • 怎么把视频里的音乐提取出来
  • 正则表达式小结
  • - 转 Ext2.0 form使用实例
  • 专访Pony.ai 楼天城:自动驾驶已经走过了“从0到1”,“规模”是行业的分水岭| 自动驾驶这十年 ...
  • ​configparser --- 配置文件解析器​
  • $con= MySQL有关填空题_2015年计算机二级考试《MySQL》提高练习题(10)
  • (1)bark-ml
  • (1)Map集合 (2)异常机制 (3)File类 (4)I/O流
  • (1综述)从零开始的嵌入式图像图像处理(PI+QT+OpenCV)实战演练
  • (2022 CVPR) Unbiased Teacher v2
  • (BFS)hdoj2377-Bus Pass
  • (delphi11最新学习资料) Object Pascal 学习笔记---第8章第5节(封闭类和Final方法)
  • (附源码)springboot高校宿舍交电费系统 毕业设计031552
  • (力扣记录)1448. 统计二叉树中好节点的数目
  • (十五)Flask覆写wsgi_app函数实现自定义中间件
  • (一)pytest自动化测试框架之生成测试报告(mac系统)
  • .a文件和.so文件
  • .CSS-hover 的解释
  • .gitignore文件—git忽略文件
  • .Net通用分页类(存储过程分页版,可以选择页码的显示样式,且有中英选择)
  • .NET委托:一个关于C#的睡前故事
  • [1127]图形打印 sdutOJ