关于JS引擎

V8是如今最流行的JS引擎,包括NodeJS也内置了V8,而其他引擎如spidermonkey逐渐式微。抛开性能之类的指标不谈,所有的JS引擎都做了相同的事情 - 编译并执行JS代码。 JS最初被设计为浏览器中运行的脚本语言,并没有文件处理,内存管理之类的特性,加之实现self-hosed是相当困难,所以JS引擎都使用C,C++来实现,甚至有JAVA,当然这个情况也可以拓展到其他语言,比如Python,PHP。 简而言之,JS引擎的角色是一个JS编译器和JS虚拟机的混合体,提供JS编译,存储管理,运行时管理,垃圾回收,以及语言标准的实现等功能。

几个概念

Isolate

相当于一个完整的V8实例,拥有独立的内存,垃圾回收等,相互间是隔离开的,并且同一时间只允许一个线程访问,因为可以被多个线程访问,所以需要进程访问时需要加锁,它是V8中的最高层次抽象。
具体定义可以在include/v8-isolate.h中找到。

Context

其目的为了隔离js应用,因为每个js环境中的内置的全局方法或者变量会被用户代码修改,为了防止不同js应用之间相互影响,所以有必要将它们隔离开来。 另外一个考虑是网页中的iframe以及每个window都需要一个独立的context。 为每个Context初始化js环境看起来是一件开销很大的事情,不过v8对此做了优化,在首次初始化之后,会将这些环境数据缓存,后面初始化其他环环境的时候,可以复用缓存,避免了反复创建带来的性能影响。

// 实例化一个context
static Local<Context> New(
      Isolate* isolate, ExtensionConfiguration* extensions = nullptr,
      MaybeLocal<ObjectTemplate> global_template = MaybeLocal<ObjectTemplate>(),
      MaybeLocal<Value> global_object = MaybeLocal<Value>(),
      DeserializeInternalFieldsCallback internal_fields_deserializer =
          DeserializeInternalFieldsCallback(),
      MicrotaskQueue* microtask_queue = nullptr);

实力化时context必须要关联一个isolate。 其中包含一个变量Local<Object> Global();代表了执行环境。 具体的定义在api/api.cc

Scope

由于JS使用的是词法作用域,某活动记录内的局部变量,它的生命周期很容易确定,当此活动记录销毁,那么它内部的局部变量就应该全部释放掉,对于这种情况,V8引入了handleScope的概念,它相当于一个容器,且只能位于栈区,用于管理活动记录内局部变量的回收。当它的析构函数被调用时,其包含的变量所指向的堆上空间也会被回收。 另外一种情况是,活动记录被调用时,返回它内部的一个局部变量,此时它的生命周期不在与此活动记录关联,因此有了另外一个概念EscapableHandleScope

Handle

它是某对象内存地址的指针的指针,而不是直接指向地址,包含一个 Address*字段, 解引用Address*后得到的指针与对应的Object的地址指针相同。表现形式如同handle -> object -> memory,如此设计的目的是跟踪对象以便垃圾回收。

v8中有两类handle,local handlePersistent handle。前者用于局部变量的处理,必须关联到一个handleScope,意味着其必须分配到栈区,当scope销毁后,其下所有的local handle也可以进行垃圾回收相关操作。

Persistent handle指向的是堆上的JS对象,与local不同的事,它的生命周期不被某个作用域或者是活动记录管理,而是需要手动调用api进行回收。另外还有一种是Eternal,它的生命周期与isolate相同。 具体定义在include/v8-local-handles.h

Tagged-impl

即tagged pointer,目的是为了提高64位机器上的内存使用效率提高性能。 由于逻辑地址也是数字,且某些小整数即smi不需要很大的空间,可以不必使用借助指针分配到堆上,而是直接存储为一个伪指针在栈上,将低位置为特殊值。从而区分smi与指针。 它是所有Javascript Object的基类。

具体而言,在32位机器上,表现如下

Smi: 	                [xx...31...xxx]0
Strong HeapObject:	    [xx...30...xx]01
Weak HeapObject:	    [xx...30...xx]11
Cleared weak reference:	[00...30...00]11

在64位机器上,smi的高32位会被置0,如此情况就变得与32位一致了。
另外需要考虑的是32位指针意味着地址空间被限制到4G,虽然这在浏览器上够用,但在NodeJS的场景中,4G远远不够,所以V8依然保留了64位指针的支持。

Maybe

指它包含的对象可能有值,类似于rust中的Option,Haskell中的maybe。强化了对空值的处理。由于JS是动态类型,空值情况经常出现,因此在V8中引入maybe可以提高内存安全性。

基本结构

继承结构

源码实现

- TaggedImpl (_ptr)
  - V8::internal::Object 
    - V8::internal::HeapObject
      - V8::internal::JSReceiver
        -  V8::internal::JSObject

在C++的实现里,JSObject的继承结构如上所示,不同层级上提供了不同功能。

TaggedImpl如前文所述,包含一个_ptr属性,其值可能是smi或者是指针,该类上定义了判断_ptr类型的方法。

V8::internal::Object(定义在src/objects/objects.h),是所有堆上对象(可以是JS对象也可能是其他用于JS实现的内部对象,例如map)的基类,仅有一个堆上对象的指针的数据属性。

V8::internal::HeapObject(定义在src/objects/heap-object.h),主要定义了堆上对象的map相关属性,map属性的偏移量被设置为0,由于该类继承自object,故map存储在object的开头位置:

  // Layout description.
#define HEAP_OBJECT_FIELDS(V) \
  V(kMapOffset, kTaggedSize)  \
  /* Header size. */          \
  V(kHeaderSize, 0)

  DEFINE_FIELD_OFFSET_CONSTANTS(Object::kHeaderSize, HEAP_OBJECT_FIELDS)
#undef HEAP_OBJECT_FIELDS

  static_assert(kMapOffset == Internals::kHeapObjectMapOffset);

  using MapField = TaggedField<MapWord, HeapObject::kMapOffset>;

V8::internal::JSReceiver,由TorqueGeneratedJSReceiver模板生成(需要编译V8后才可看到其源代码),它定义了对象的property相关的操作, 该模板中定义了property的偏移量,是父类的kHeaderSize。

  static constexpr int kPropertiesOrHashOffset = P::kHeaderSize;
  static constexpr int kPropertiesOrHashOffsetEnd = kPropertiesOrHashOffset + kTaggedSize - 1;

而后,设置property从该偏移量开始。

ACCESSORS(JSReceiver, raw_properties_or_hash, Object, kPropertiesOrHashOffset)
RELAXED_ACCESSORS(JSReceiver, raw_properties_or_hash, Object,
                  kPropertiesOrHashOffset)

故在object中map属性之后是property属性。

V8::internal::JSObject。由TorqueGeneratedJSObject模板生成,其定义了堆上JS对象的属性。 其中定义了elements属性的偏移量是父类的header末尾

  static constexpr int kStartOfStrongFieldsOffset = P::kHeaderSize;
  // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-objects.tq?l=33&c=3
  static constexpr int kElementsOffset = P::kHeaderSize;

然后设置elements从该偏移量开始

void JSObject::set_elements(FixedArrayBase value, WriteBarrierMode mode) {
  // Note the relaxed atomic store.
  TaggedField<FixedArrayBase, kElementsOffset>::Relaxed_Store(*this, value);
  CONDITIONAL_WRITE_BARRIER(*this, kElementsOffset, value, mode);
}

最终一个object中会形成如下结构:

- Object
  - map
  - properties
  - elements

如下所示创建一个对象:

let foobar = {
  name: '小红',
  age: 12,
  gender: 'female'
};
%DebugPrint(foobar);

然后打印出它的结构

添加几个数字的key及一个方法

let foobar = {
  name: '小红',
  age: 12,
  gender: 'female',
  1: 'aaa',
  2: 'bbb',
  4: 'ccc',
  say: function () {
    console.log('foobar')
  }
};
%DebugPrint(foobar);

对比两幅图可以看出,后者多了一个elements字段,存储对象中了类型为数字的属性。而其他属性保留在own properties中,如果动态的给对象新增属性,会发现新增的属性保存在properties中。

Object的优化策略

C++之类的静态类型语言,其对象在编译期即可确定其大小及内存布局,且运行过程中由于不可变,可以把某些被频繁访问的属性的实际位置缓存下来,即inline cache,带来更好的性能。 与之相反,javascript是动态类型,对象可以在运行时随意修改。通常情况下,访问某属性需要动态查找,但为了更好的性能,我们希望将能静态化的场景全部静态,就像C++一样,通过分析class即可确定对象内存结构,所以v8中的对象引入了一个很重要的属性 - Map,也叫hidden class。具体定义在src/objects/map.h和src/objects/map.cc,我们可以认为它就像C++的class一样保存了对象的元信息。

Map(hidden class)

v8的代码注释中,对其结构作了明确的解释: 打印对象的map

其中有两个重要的属性instance_descriptorsconstructor_or_back_pointer_or_native_context,前者指向对象所有属性的数组,后者指向对象的构造函数或者transition map父节点或者context。

instance_descriptors的结构如下所示

注意attrs后的 [WEC],分别代表着writable,enumerable,configurableattrs前的h代表堆上数据 heapobject,s代表短整数smi

属性访问优化

对象的属性的类型有两种情况,字符串和数字。在v8中,前者称为Named properties,后者称为indexed properties,由于js中数组也是对象,所以后者其实包含了两种情况数组以及对象中类型为数字的属性。

对象在本质上是一系列有关联的数据,而在物理结构上,只有顺序存储这一种存储方式,所以应该尽可能使用index的方式来查找数据以达到性能最优,即类似数组的方式。 对于indexed properties,我们自然而然可以使用数组,而Named properties无法直接使用数组的形式。

Named properties

出于内存效率和访问速度的考虑,有三种模式,前两者称之为fast模式。

  • In-object properties
  • Fast properties
  • Slow properties

In-object properties

直接将某属性的值(smi或者指针)存储于对象中 如之前例子中: 0x129500253ad5: [String] in OldSpace: #age: 12 (const data field 1), location: in-object

可以看到最后的location: in-object,代表了相应的字段直接储存于object中。

Fast properties

存储在properties字段中,如上例中所示,字面量创建的对象如果未经修改,它的properties属性为空,如果给对象增加几个属性,情况就会发生变化:

foobar.address = 'beijing tiananmen';

可以看到新增的address字段存储在PropertyArray类型的properties中。

访问in-objectfast属性都依赖map中的instance descriptors属性保存的DescriptorArray。

v8在每次属性查找时,先在缓存中查找,未命中则在DescriptorArray中进行线性或者二分查找,然后判断属性在object或者是properties数组中的偏移量,最终拿到具体的值,并为该属性及其map建立一条缓存。

// src/objects/js-objects-inl.h
Object JSObject::RawFastPropertyAt(PtrComprCageBase cage_base,
                                   FieldIndex index) const {
  if (index.is_inobject()) {
    return TaggedField<Object>::Relaxed_Load(cage_base, *this, index.offset());
  } else {
    return property_array(cage_base).get(cage_base,
                                         index.outobject_array_index());
  }
}

Slow properties

属性以dict的结构存储。之前的例子中,属性的访问依赖map,但如果对象的属性被删除或者大量修改,v8将会放弃对map的依赖,转而用dict的直接存储属性信息,dict本质上是hashmap,其定义在src/objects/swiss-name-dictionary.h

for (var i = 15; i >= 0; i--) {
  foobar['add' + i] = 'test' + i;
}

fast VS. slow

真实情况并不是像它们字面意思那样简单,fast并不一定比slow模式快,它们分别适用于不同的场景。

这里引用V8开发者jmrk的一段非常好的解释

场景fast模式(依赖map)dict模式
新增属性+
删除属性+
首次 读/写属性-+
缓存命中且单态化 读/写属性++++
缓存命中但有少量不同map 读/写属性+++
缓存命中但有大量不同map 读/写属性+

注:缓存指inline cache

可以看到读取和更新对象的场景下,fast模式由于inline cache加持而拥有巨大的优势,并且实际应用中,这两种场景最常见,所以一般而言它更快,因此v8会默认使用fast模式。

然而一旦delete了某个属性,v8认为该对象未来会经常发生delete,所以切换成dict模式以拥有更稳定的性能。

v8也内置了从dict切换回fast模式的api,不过会有很大的开销。

indexed properties

对象中属性为数字的字段将存储在elements属性的数组中,由于js中array也是数组,所以数组同样也使用这个字段。 由于数组中某些index可能没有定义或者对象的数字属性并不是按照自然数顺序,会导致某些index缺失,例如:

let list = [1,,,32];

加之在js中访问对象本身不存在的属性会发生原型链回溯,尝试在list的原型链中查找, 但如果对象本身就未定义该属性,这种查找就显得多此一举。

由于elements上的属性不会出现在map中,所以需要其他方式来标记数组的状态,因此v8引入了HOLEYPACKED类型,如果属性有缺失将会填充HOLEY,如果属性是按找自然数顺序完整定义的,将标记该数组是PACKED类型,从而避免多余的原型链回溯

另外,与上面的slow properties类似,elements也会转换成dict模式NumberDictionary

如下所示,批量新增属性,并且将数字转成字符串的形式

for (var i = 15; i >= 0; i--) {
  foobar['99' + i] = 'test' + i;
};

如果不转为字符串,elements依然保持数组类型。

提高内存使用效率

静态类型的语言,class的大小可以在编译时确定,因此实例化时可以直接申请相应大小的内存,而js对象可变,因此无法准确判断该为某个对象申请多大内存,过大过小都会使得内存效率下降,也为GC带来阻碍。 V8采用的策略是,引入一个魔数使对象初始化时有一个固定的大小,当与其构造函数实例化的对象越来越多的时候,V8遍历这些对象并计算大小,选出最大值作为后续实例化时所申请的内存大小,该值应该尽可能大尽以满足了绝大部分对象的需求。V8称此策略为slack tracking

那么如何追踪同一构造器所实例的对象呢?V8的方案是transition tree

transition tree是一颗树,其节点是hidden class,构造函数是这颗树的根节点,当对象属性发生改变,V8为该对象重新生成一个hidden class,并保存一个指针指向旧的hidden class,如此同一构造器所实例的对象,将可以通过这颗树追踪到。

假设有如下构造函数和对象

function Foobar (name, age) {
    this.name = name || '';
    this.age = age || '';
}
let obj1 = new Foobar('lily', 12);
let obj2 = new Foobar('keke', 13);
let obj3 = new Foobar('bob', 14);

obj1结构如下

它的hidden class上有一个back pointer指针,其内容如下

可以看到其包含了一个transition,它指向obj1当前的hidden class,也就是说它是transition tree上的父节点,但是它只包含age属性。 继续回溯当前hidden class的back pointer,结构如下 它的transition只包含name属性,继续回溯back pointer,结构如下 此时它的类型是ODDBALL_TYPE <undefined>,ODDBALL_TYPE在V8中用来处理null,undefined,true,false,此时我们来到了transition tree的最顶点,

回到上一个hidden class,它是构造函数Foobar的初始hidden class,此时 instance size是52,inobject properties是10,unused property fields: 10。 经过两次transition:name -> age后obj1的hidden class中,上述三个值只有unused property fields变成了8,因为name和age占用了两个。

此时给obj1增加属性obj1.mobile = 123,可以发现该属性位置依然是in-object,因为有预留8个位置。至于为什么是8?这是V8选的一个魔数。这样避免了在properties上分配新的内存来存储新增的属性。值得注意的是这个特性在对象字面量中是无效的,前面的例子字面量上新增属性是存放在properties中,in-object上没有预留位置。 另一个重要的属性是construction counter,在初始hidden class中它是6,如果再用Foobar创建一个对象,我们可以发现它的值会减1,而且新增的对象与obj1拥有相同的hidden class。 继续用Foobar创建5个对象,此时construction counter值为0,另外几个属性发生了一些有趣的变化

此时obj1的hidden class也有类似的变化 instance size变成20,inobject properties变成2,之前预留的位置被回收, 这是因为在一个构造函数实例化7个对象后(7是V8选择的一个魔数),V8会通过transition map遍历所有对象,计算大小,进而算出一个最大值,用于后续初始化时分配初始内存大小。 如此,V8完成了对某个构造函数所产生的对象大小的观测,提高了内存使用率。

javascript对象会被动态增加属性,如果在内存上强行将增加的属性与对象紧挨在一起而此时该对象在实例化时分配的空间又不足以存放新属性,那么势必要移动整个对象进行内存重排,这显然是一种低效行为,所以V8预留了一个指针用来处理这种情况,这即前文所说的properties字段的意义所在。