我们已经知道V8在查找对象(比如o.x)的属性时的过程是这样的:找到隐藏的对象o的类,然后通过隐藏类找到x的属性偏移量,然后根据偏移量得到属性值。分析以下代码:functionloadX(o){returno.x}varo={x:1,y:3}varo1={x:3,y:6}for(vari=0;i<90000;i++){loadX(o)loadX(o1)}这段代码中会重复执行loadX函数,所以获取o.x的过程也需要重复执行。有什么办法可以再次简化这个查找过程,最好是一步找到x的属性值?答案是肯定的。可以看出,函数loadX在一个for循环中被多次重复执行,所以V8会千方百计压缩搜索过程,提高对象搜索效率。这种加速函数执行的策略就是InlineCache,简称IC。InlineCache原理在V8执行一个函数的过程中,会在函数中观察调用点(CallSite)上的一些关键中间数据,然后缓存这些数据。下次再次执行该函数时,V8可以直接使用这些中间数据,省去了再次获取这些数据的过程,所以V8可以通过使用IC有效提高一些重复代码的执行效率。IC会为每个函数维护一个反馈向量(FeedBackVector),反馈向量记录了函数执行过程中的一些关键中间数据。函数与反馈向量的关系图:反馈向量实际上是一个表结构,由很多项组成,每一项称为一个槽(Slot)。V8会依次将loadX函数的中间数据写入feedbackVector的slot。比如下面这个函数:functionloadX(o){o.y=4returno.x}V8在执行这个函数的时候,会判断o.y=4和returno.x这两段是调用点(CallSite),因为他们使用了object和属性,然后V8为loadX函数的反馈向量中的每个调用点分配一个槽。每个槽包括槽的索引(slotindex)、槽的类型(type)、槽的状态(state)、隐藏类的地址(map)、属性的偏移量,如上面这个函数中的两个调用点都使用了对象o,所以feedbackvector的两个slot中的map属性也指向同一个hiddenclass,所以两个slot的map地址是一样的。V8在执行loadX函数时,loadX函数中的关键数据是如何写入反馈向量的呢?loadX的代码如下所示:functionloadX(o){returno.x}loadX({x:1})我们将loadX转换为字节码:StackCheckLdaNamedPropertya0,[0],[0]返回loadX函数的这个字节代码很简单,就三句:第一句检查栈是否溢出;第二句是LdaNamedProperty,它的作用是取出参数a0的第一个属性值,并将属性值放入累加器;第三句是返回累加器中的属性值。这里重点介绍LdaNamedProperty的字节码,它有3个参数。a0是loadX的第一个参数。第二个参数[0]表示取对象a0的第一个属性值。第三个参数与反馈向量相关,表示将LdaNamedProperty操作的中间数据写入反馈向量,方括号中间的0表示写入反馈向量的第一个槽位。具体可以参考下图:观察上图,我们可以看到在函数loadX的反馈向量中,已经缓存了数据:在map列中,缓存了o的隐藏类的地址;在offset列中,属性缓存了x的偏移量;在type栏中,缓存了操作类型,这里是LOAD类型。(在反馈向量中,我们把这种通过o.x访问对象属性值的操作称为LOAD类型)V8除了缓存o.x的LOAD类型操作,还缓存了存储(STORE)类型和函数调用(CALL)类型的中间数据。为了分析后两种存储形式,我们再看下面的代码:functionfoo(){}functionloadX(o){o.y=4foo()returno.x}loadX({x:1,y:4})对应的字节码如下:StackCheckLdaSmi[4]StaNamedPropertya0,[0],[0]LdaGlobal[1],[2]Starr0CallUndefinedReceiver0r0,[4]LdaNamedPropertya0,[2],[6]返回如图下面是这段字节码的执行流程:从图中可以看出,o.y=4对应的字节码是:LdaSmi[4]StaNamedPropertya0,[0],[0]这段代码首先使用了LdaSmi[4]],将常量4加载到累加器中,然后通过StaNamedProperty的字节码指令将累加器中的4赋值给o.y。这是一个存储(STORE)类型的操作,V8会将操作的中间结果存储在反馈向量的第一个槽中。调用foo函数的字节码为:LdaGlobal[1],[2]Starr0CallUndefinedReceiver0r0,[4]解释器首先将foo函数对象的地址加载到累加器中,这是通过LdaGlobal完成的,然后V8将加载的中间结果存放在反馈向量的第三个槽中,属于存储型操作。接下来执行CallUndefinedReceiver0,实现foo函数的调用,执行的中间结果放在反馈向量的第5个槽中,属于调用(CALL)类型的操作。最后是returno.x,returno.x只是加载对象中的x属性,所以这是一个加载(LOAD)类型的操作,我们在上面介绍过。最终生成的反馈向量如下图所示:既然反馈向量缓存中有了数据,那么V8是如何使用这些数据的呢?当V8再次调用loadX函数时,比如在loadX函数中执行returno.x语句,会在对应槽中寻找x属性的偏移量,然后V8可以直接去内存中获取属性值o.x向上。这大大提高了V8的执行效率。多态和超态可以通过缓存执行过程中的基本信息来提高下一个函数执行的效率,但是有一个前提,就是多次执行时对象的形状是固定的。如果物体的形状不是固定的,那么V8将如何处理呢?我们调整一下上面loadX函数的代码。调整后的代码如下:functionloadX(o){returno.x}varo={x:1,y:3}varo1={x:3,y:6,z:4}for(vari=0;i<90000;i++){loadX(o)loadX(o1)}我们可以看到对象o和o1的形状不一样,也就是说V8创建的隐藏类也不一样。第一次执行loadX时,V8会在反馈向量中记录o的隐藏类,记录属性x的偏移量。再次调用loadX函数时,V8会取出反馈向量中记录的隐藏类,与o1新的隐藏类进行比较,发现不是隐藏类,则V8不能使用记录在中的偏移量此时的反馈向量信息。面对这种情况,V8会选择在反馈向量中记录新的隐藏类,同时记录属性值的偏移量。此时,反馈向量中的第一个槽包含两个隐藏类和Offset。如下图所示:当V8再次执行loadX函数中的o.x语句时,也会查找反馈向量表,发现第一个槽中记录了两个隐藏类。这时候V8还需要做一件事,就是将这个新的隐藏类和第一个slot中的两个隐藏类一一进行比较。隐藏类也是一样,那就用命中的隐藏类的偏移量。如果它们不相同,则将新信息添加到反馈向量的第一个槽位。反馈向量的一个槽可以包含多个隐藏类的信息,因此定义如下:如果一个槽只包含一个隐藏类,则我们称这种状态为单态的;如果一个槽如果一个槽包含2到4个隐藏类,我们称这种状态为多态的;如果一个槽中有超过4个隐藏类,我们称这种状态为magamorphic。如果函数loadX的反馈向量存在多态或超态,其执行效率必然低于单态。例如,当执行到o.x时,V8会查询反馈向量的第一个槽位,发现有多个map记录,那么V8需要取出o的隐藏类与记录在的隐藏类进行比较插槽一个接一个。如果记录的隐藏类越多,比较的次数就会越多,也就意味着执行效率越低。例如,如果一个槽包含2到4个隐藏类,则可以将其存储在线性结构中。如果超过4个,V8会将它们存储在哈希表结构中,这无疑会降低执行效率。单态、多态、超态三种情况下的执行性能如下图所示:尽量保持单态IC,即V8为每个函数增加了缓存。当函数第一次执行时,V8会将Store、load、call相关的中间结果保存到反馈向量中。再次执行时,V8会在反馈向量中寻找相关的中间信息,如果命中则直接使用中间信息。了解了IC的基本执行原理,我们可以得出一些最佳实践:单态的性能优于多态和超态,所以我们需要尽可能避免多态和超态。为避免多态和超态,请尝试将所有对象属性默认为常量。比如你写了一个loadX(o)函数,那么在传递参数的时候,尽量不要使用多个不同形状的o对象。总结虽然隐藏类可以加快查找对象的速度,但是在V8中查找对象属性值的过程中,仍然存在查找对象隐藏类和根据隐藏类查找对象属性值的过程。V8引入了IC,IC会监控每个函数的执行过程,并在一些关键的地方埋下监控点,包括加载对象属性(Load)、为对象属性赋值(Store)、函数调用(Call),V8会把监测到的数据写入一个叫做FeedBackVector的结构体,V8会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8可以缩短对象属性的搜索路径,从而提高执行效率。反馈向量是一个表结构,由很多项组成,每一项称为一个槽(Slot);V8为每个调用点(CallSite)分配一个槽(Slot)。对于函数中的同一段代码,如果对象的隐藏类不同,反馈向量也会记录这些不同的隐藏类,从而导致多态和超态。在实际项目中,尽量避免多态或超态。隐藏类和IC虽然可以提高代码的执行速度,但是在实际项目中,影响执行性能的因素有很多。找出那些影响性能的瓶颈是至关重要的。你不需要过多关注微优化,你也可以不必过分担心你的代码是否破坏了隐藏类或IC机制,因为它们对效率的影响与其他性能瓶颈相比可能可以忽略不计。
