关于JS引擎优化的理解
栏目: JavaScript · 发布时间: 5年前
内容简介:之前在网上断续地了解过JS引擎对JS源代码的优化过程,但都不是特别明白,最近阅读Mathias Bynens(V8作者之一)的关于JS引擎的优化原理的博文后觉得相对来说是讲得最明白易懂的,让我用最简单的方式对这个问题有了自己的理解。这篇笔记是我对这个问题的个人理解的简单总结。原文已经写得足够明白足够好了,我是希望用自己的方式来描述一下,帮助理解和记忆。也许深入的原理我还有一些不能准确描述,建议大家若有时间阅读原文来进一步学习。
之前在网上断续地了解过JS引擎对JS源代码的优化过程,但都不是特别明白,最近阅读Mathias Bynens(V8作者之一)的关于JS引擎的优化原理的博文后觉得相对来说是讲得最明白易懂的,让我用最简单的方式对这个问题有了自己的理解。
这篇笔记是我对这个问题的个人理解的简单总结。原文已经写得足够明白足够好了,我是希望用自己的方式来描述一下,帮助理解和记忆。也许深入的原理我还有一些不能准确描述,建议大家若有时间阅读原文来进一步学习。
注:文中大部分图片源自作者原文 。
JS引擎的工作方式
首先是一些背景知识,例如JS引擎都有哪些, 以及它们如何工作。
目前的主流JS引擎:
- V8(Chrome和NodeJS)
- SpiderMonkey(FireFox)
- Chakra(IE和Eage)
- JavaScriptCore(Safari/ReactNative)
JS引擎执行代码的流程
不同JS引擎对执行和优化的一些细节上有差别,但是它们有以下通用的流程。
- JS源码会先被解析器parser解析生成抽象语法树(AST, Abstract Syntax Tree);
- 解释器可以在AST基础上产生字节码并执行;
- 对于部分"hot"(例如被频繁调用)的代码,解释器会连同一些分析信息(profiling data)发送到编译器中进行优化;
- 优化是在现有代码及分析信息的基础上作出一定的推测,然后生成优化后的机器码;优化完成后, 该部分的代码就由优化后的机器码代替, 优化后产生机器码,可以直接在系统处理器中执行;
- 在某个节点发现优化时的特定推测是错误的,编译器也会进行“去优化”而将代码还原给解释器。
代码 | 生成者 | 执行者 | 生成效率 | 执行效率 | 空间效率 |
---|---|---|---|---|---|
字节码 | 解释器(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引擎 | interpreter | optimizer |
---|---|---|
V8 | ignition | TurboFan |
SpiderMonkey | interpreter | Baseline + IonMonkey |
Chakra | interpreter | SimpleJIT + FullJIT |
JavaScriptCore | LLInt | Baseline + DFG + FTL |
虽然它们的解释器和优化编译器看起来有不同的名字,但是所有JS引擎都具有相同的架构:parser(用于生成AST)和解析器 + 优化编译器的 管道结构 。说是管道结构是因为解析器执行字节码和优化编译器可以并行执行,当解释器把待优化的代码发送给另一个线程的编译器执行优化时,依然可以继续执行当前未优化的字节码;而优化过程完成后优化后的代码将会合流至主线程而后执行经过优化的代码。
而采用多个优化层,也是在“未优化”和“高度优化”之间设立了更多的中间节点,相当于“分级”--根据“Hot”的程度相应增加优化的程度,从而可以更细粒度地对时间/空间/执行效率之间的权衡决策进行控制。
对象和数组
在EcmaScript中,所有object实质上可以认为是字典,也就说字符串类型的键与属性值构成的键值对集合。对象的属性也有“属性”,就是定义属性自身的特性而不直接暴露给JavaScript的描述符: [[Value]]
, [[Writable]]
, [[Enumerable]]
, [[Configurable]]
。每个属性都有对应的描述符,对于我们给对象添加的自定义属性, [[Value]]
即我们赋给该属性的值,而其他描述符都会被默认为true。
至于数组,实际上也可以看作对象,不过数组对数值索引会有特别处理,有效字符串整数i的范围缩小到+0 <= i < 2 32 -1, 而普通对象中的整数索引只需是安全整数(+0 <= i <= 2 53 -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开发者以下几方面的建议:
-
始终以相同的方式初始化对象。
一方面提高Shape的重用性,另一方面尽量降低过渡链或过渡树的长度/深度,缩短沿Shape链检索属性的时间;
-
不要对数组的元素(数值索引属性)修改属性描述.
这样可以保留引擎对数组的优化处理,使数组的存储和访问更高效;
-
不要修改原型,尤其是层级较深的原型如Object.prototype等,即使确实有必要修改,也应该在所有代码执行之前修改而不要在代码执行过程中修改。
否则引擎为了保证取到正确的值而不得不放弃之前的内联缓存,重新以最笨的方法重新去查找和获取属性。
原文链接:
- JavaScript engine fundamentals: Shapes and Inline Caches
- JavaScript engine fundamentals: optimizing prototypes
中文译版:
参考:
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 前端模板引擎Handlebars理解与举例
- 深入理解Java虚拟机(类文件结构+类加载机制+字节码执行引擎)
- javascript系列--javascript引擎执行的过程的理解--语法分析和预编译阶段
- 《深入理解Java虚拟机》-----第8章 虚拟机字节码执行引擎——Java高级开发必须懂的
- 物理引擎 – 物理引擎如何实际模拟物理学?
- 快讯|专注于游戏引擎服务,Cocos引擎获得A轮融资
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
JavaScript: The Definitive Guide, 5th Edition
David Flanagan / O'Reilly Media / 2006-08-01 / USD 49.99
This Fifth Edition is completely revised and expanded to cover JavaScript as it is used in today's Web 2.0 applications. This book is both an example-driven programmer's guide and a keep-on-your-desk ......一起来看看 《JavaScript: The Definitive Guide, 5th Edition》 这本书的介绍吧!