1

隐藏类(Hidden Classes)

众所周知,Javascript是一种动态编程语言,这意味着对象在初始化后仍然可以对其属性进行增删操作。比如,下面这段代码,“car”对象经初始化后带有“make”和“model”两个属性,但是之后,该对象又被动态地添加了“year”这个属性。

function Car(make,model) {
    this.make = make;
    this.model = model;
}

const car = new Car(honda,accord);

car.year = 2005;

大多数的Javascript解释器使用类字典结构来储存类的属性在内存中的位置。但是这样的结构使得在进行属性值的查找时,Javascript要比Java这种静态语言更消耗性能。在Java中,所有对象的属性在编译前将会被一个固定的对象结构确定下来,并且在运行时不能动态的进行增删。这样带来的好处就是,属性的值(或是属性的指针)可以彼此间间隔固定的偏移量储存在一段连续的内存空间中。通过属性的类型可以轻松确定它的偏移量,但是由于Javascript中在运行时可以动态地改变属性类型,所以在Javascript中是这种方法是不可能实现的。

在像Java这样的非动态语言中,单个指令就可以确定属性在内存中的位置,但是在Javascript中需要多个指令在哈希表中查找属性的内存位置。这导致Javascript中的属性查找相较于其他语言要慢得多。

鉴于字典表这种查找属性内存位置的方式如此低效,V8使用了一种截然不同的方法进行改进,隐藏类。其实,抛开隐藏类作用在运行时的区别不谈,它和Java中的固定对象结构十分相似。在阅读下面的内容之前,请明确两个重点,第一,V8会为每个对象关联一个隐藏类,第二,隐藏类的目的是优化属性的访问速度。下面让我们进入正题。

function Point(x,y) {
    this.x = x;
    this.y = y;
}

const obj = new Point(1,2);

一旦声明了一个新的方法,Javascript就会创建一个隐藏类C0。

image

在此时还没有声明任何的属性,所以C0现在为空。

一旦第一个语句“this.x=x”被执行,V8将会基于C0创建第二个隐藏类C1。C1记录了可以找到属性x在内存中的位置。在这个例子中,x保存在偏移量为0的位置,这表示可以将一个内存中的对象目标看作是一段连续的空间。而这段空间中的第一段偏移代表着属性x。与此同时,V8将会用“类偏移”操作更新C0,这代表着属性x已经添加到了目标对象。之后,目标对象所对应的隐藏类指针将指向C1。

image

每当目标对象添加一个新的属性,对象的旧的隐藏类就会变换路径到一个新的隐藏类。隐藏类的重要之处在于可以使经过相同创建过程创建的对象共享隐藏类。假如两个对象共享一个隐藏类,并向两个对象中同时添加相同的属性,那么这种变换将会保证变换后得到相同的隐藏类,这样代码就得到了优化。

当“this.y=y”执行时将重复上面的操作。一个新的叫C2的隐藏类将被创建,然后对C1进行类变换表明属性y已经添加到了目标对象,最后将隐藏类指向C2。这样目标对象的隐藏类就更新到了C2。

image

注意:隐藏类的变换取决于对目标对象的属性添加顺序。请注意下面的代码:

1  function Point(x,y) {
2    this.x = x;
3    this.y = y;
4  }
5 
7  var obj1 = new Point(1,2);
8  var obj2 = new Point(3,4);
9
10 obj1.a = 5;
11 obj1.b = 10;
12
13 obj2.b = 10;
14 obj2.a = 5;

直到第九行为止,obj1和obj2都共享同一个隐藏类。但是,当属性a和b以相反的顺序添加到了两个对象中,这导致最后两个对象以不同的变换路径产生了两个不同的的隐藏类。

image

看到这里,有些读者会认为两个对象具有两个不同的隐藏类并不是什么严重的问题。只要隐藏类中储存着正确的偏移量,访问属性的速度应该和共享相同隐藏类一样快。想理解为什么这种想法是错误的,需要先介绍另一种V8的优化技术,行内缓存。

行内缓存(Inline Caching)

V8利用的另一种技术来优化动态类型语言的性能,叫做“行内缓存”。如果想详细深入地了解行内缓存,可以参考这里,但是简单来讲,行内缓存依赖于一种观察到的现象,那就是,重复调用方法大概率会使用相同类型的参数。

那它到底是如何工作的呢?V8将会维护一个记录最近有一段时间内调用方法时传入参数类型的缓存,然后使用所获得的信息预测在未来调用时所传入的参数类型。一旦V8引擎对参数的类型进行了正确的预测,将使得引擎越过解析如何访问类属性的过程,直接使用之前缓存的信息直接获得隐藏类并对对象属性进行访问。

那么为什么隐藏类的概念和行内缓存这两个概念如此紧密相关呢?每当使用一个特定的对象调用方法时,V8引擎就会去查找对象的隐藏类,以便获取后续访问特定属性的偏移量。经过两次成功地以具有相同隐藏类参数调用相同的方法后,V8引擎将省略隐藏类的查找过程,并直接的添加属性的偏移量。对于后续的调用,引擎都将假设隐藏类不会变化,并直接使用上一次查找时缓存的偏移访问内存,这样将极大地提升访问速度。

行内缓存是对象共享隐藏类地一个重要原因。如果你创建了两个相同类型但是具有不同隐藏类的对象,引擎将无法对其进行行内缓存的优化,因为不同隐藏类代表着具有不同的属性偏移量。

image

优化相关建议

  1. 保证以相同的顺序实例化对象属性,这样可以保证它们共享相同的隐藏类。
  2. 在对象实例化后向对象添加属性将会迫使隐藏类改变,这将会使也已经进行行内缓存的方法的访问速度变慢。所以,请尽量保证,在构造器内进行所有的属性声明。
  3. 不停执行相同方法的代码会比总在执行不同方法的代码速度快。

ScottTian
87 声望0 粉丝

Learn By Doing!