Skip to content

基础数据类型 ‐ JSObject 相关

hsiaosiyuan0 edited this page Jul 29, 2023 · 1 revision

引擎中的高阶功能(比如 Generator,Promise 等)都基于 JSObject,并且当引擎内部需要存储一些较为复杂的数据时,也会使用 JSObject 作为容器。

我们在高级语言中常用的数据结构(数组,Hashmap,字符串)在 C 语言中的默认支持很有限,通常来说开发者需要在程序中自己实现这些数据结构。

对于引擎来说,其为了支持 JS 的数据类型(对象,数组等)会实现一些数据结构,当引擎内需要存储一些复杂的数据时,就可以直接使用这些数据结构,相对的内存管理也会变得简单 - 交给为 JS 实现的 GC 来负责。

JSObject

JS 中的对象都基于 JSObject,下面是其在引擎中的内存结构:

struct JSObject {
  union {
    JSGCObjectHeader header;
    struct {
      int ref_count; /* must come first, 32-bit */
      JSGCObjectTypeEnum gc_obj_type : 4;
            // ...
      };
    };
  /* byte offsets: 16/24 */
  JSShape *shape; /* prototype and property names + flag */
  JSProperty *prop; /* array of properties */
  /* byte offsets: 24/40 */
  struct JSMapRecord *first_weak_ref; /* XXX: use a bit and an external hash table? */
  /* byte offsets: 28/48 */
  union {
    // ...

    struct { /* JS_CLASS_BYTECODE_FUNCTION: 12/24 bytes */
      // ...
    } func;

    struct { /* JS_CLASS_C_FUNCTION: 12/20 bytes */
      // ...
    } cfunc;

    /* array part for fast arrays and typed arrays */
    struct { /* JS_CLASS_ARRAY, JS_CLASS_ARGUMENTS, JS_CLASS_UINT8C_ARRAY..JS_CLASS_FLOAT64_ARRAY */
      // ...
    } array;    /* 12/20 bytes */

    JSRegExp regexp;    /* JS_CLASS_REGEXP: 8/16 bytes */
    JSValue object_data;    /* for JS_SetObjectData(): 8/16/16 bytes */
  } u;
  /* byte sizes: 40/48/72 */
};

我们从上面的缩略代码可以发现一些信息:

  • 内存的起始地址存放的是 JSGCObjectHeader,看起来存储了一些 GC 相关的信息

  • shapeprop 存储了一些 JS 对象的属性相关的信息

  • u 字段的类型是 union,其中为不同的 JS 对象(funcarrayregexp 等)提供存储空间

所以 JSObject 内存储的数据分 3 块:

  1. 保存 JS 对象 GC 相关信息的 JSGCObjectHeader

  2. 保存 JS 对象属性信息的 shapeprop

  3. 以及保存 JS 对象派生类型的信息的字段 u

Data structure alignment

JSObject 定义中使用了不太常用的 Bit-field(下面代码中的 : 4 部分):

JSGCObjectTypeEnum gc_obj_type : 4

在了解 Bit-field 之前,我们需要先了解「内存对齐 Data structure alignment」。

由于底层硬件(CPU)的设计限制,待访问的地址必须是按特定字长对齐的,如果地址没有对齐,那么会进入到 CPU 的错误中断、或者访问效率低于对齐的地址。

所谓「对齐」,简单来说就是地址必须是特定字长的整数倍(通常是偶数),更多细节可以参考 Purpose of memory alignment。编译器为了迎合这样的硬件设计,默认会将类型定义中的字段的内存地址按目标平台要求的对齐字长进行对齐。

默认情况下,编译器对不同字节长度的 Primitive type 会采用不同字长来对齐,我们可以通过编译器内建的函数来打印不同字节长度的 Primitive type 的对齐方式:

#include <stdio.h>
#include <stdint.h>

int main() {
  // `_Alignof` 为 clang 的内建指令,如果是 gcc 可以使用 `__alignof__`
  printf("%ld\n", _Alignof(uint8_t));   // 1
  printf("%ld\n", _Alignof(int));       // 4
  printf("%ld\n", _Alignof(uint64_t));  // 8
  return 0;
}

不同的硬件平台以及不同字节长度的 Primitive type 可能会采用不同的对齐字长,文中没有特殊说明的话,运行环境都是 64bits Intel。

上面的代码中使用编译器内建的指令 _Alignof 打印了几个类型的默认对齐字长。比如内存上的某块内容存放的是 int 类型的数值,我们需要根据该内存的首地址去访问内存上的内容,而「对齐」就是要求这个首地址必须是特定字长的整数倍。在我们的测试环境中, int 类型的内存首地址必须为 4 的整数倍。

我们再来看一段演示代码:

#include <stdio.h>
#include <stdint.h>

struct test {
  uint8_t a;
  int b;
};

int main() {
  printf("%ld\n", sizeof(struct test));  // 8
  printf("%ld\n", _Alignof(test.b));     // 4
  return 0;
}

我们打印了 struct test 所占的字长,结果为 8。计算过程如下:

  • 结构体的首地址会在实例化时被对齐,所以我们下面讨论字段的首地址时,为了描述简单,可以将其视为相对结构体首地址的偏移量

  • a 的长度为 1 字节,相对结构体首地址的偏移量为 0

  • 字段 bint 类型,它的访问地址必须按 4 字节对齐(4 的整数倍),因此在 ab 之间需要插入 3 个字节的 padding,这样 b 的首地址偏移量就变成了 4

因此 struct test 所占的字节长度为:

$8_{struct\ test} = 1_{a} + 3_{padding} + 4_{b}$

我们再看一个结构体的整体字长不是偶数倍的例子:

#include <stdio.h>
#include <stdint.h>

struct test {
  uint8_t a;
  uint8_t b;
  uint8_t c;
};

int main() {
  printf("%ld\n", sizeof(struct test));    // 3
  printf("%ld\n", _Alignof(uint8_t));      // 1
  printf("%ld\n", _Alignof(struct test));  // 1
  return 0;
}
  • _Alignof(uint8_t) 的值为 1,说明访问单个字节长度时地址是单字节「对齐」的

  • _Alignof(struct test) 的值为 1,因为其中的字段都是单字节对齐的,所以它也按单字节对齐就可以

  • sizeof(struct test) 的值为 3,因为按单字节对齐省去了 padding,结构体的整体字长就是字段字长之和

我们把上面的例子进行简单的调整,使结构体的字长按偶数倍对齐:

#include <stdio.h>
#include <stdint.h>

struct test {
  uint16_t a;
  uint8_t b;
  uint8_t c;
};

int main() {
  printf("%ld\n", sizeof(struct test));    // 4
  printf("%ld\n", _Alignof(uint8_t));      // 1
  printf("%ld\n", _Alignof(uint16_t));     // 2
  printf("%ld\n", _Alignof(struct test));  // 2
  return 0;
}

可以看到这次结构体的整体字长是按 2 字节对齐的,这是为了让结构体数组元素的首字段可以按其所需的对齐字长进行对齐。假设 struct test 没有按 2 字节对齐,那么当访问 struct test t[n] 中的元素时,其首字段 a 就会未按 2 字节对齐。

我们再来看一个包含多个字段的结构体对齐的例子:

#include <stdio.h>
#include <stdint.h>

struct test {
  uint8_t a;
  // padding 1 byte
  uint16_t b;
  uint8_t c;
  // padding 3 byte
  uint32_t d;
};

int main() {
  printf("%ld\n", sizeof(struct test));    // 12
  printf("%ld\n", _Alignof(uint8_t));      // 1
  printf("%ld\n", _Alignof(uint16_t));     // 2
  printf("%ld\n", _Alignof(uint32_t));     // 4
  printf("%ld\n", _Alignof(struct test));  // 4
  return 0;
}
  • _Alignof(uint32_t) 表示 uint32_t 类型的地址需要按 4 字节对齐

在上面的例子的基础上,我们可以通过调整字段的顺序达到消除 padding 的目的:

#include <stdio.h>
#include <stdint.h>

struct test {
  uint8_t a;
  uint8_t c;
  uint16_t b;
  uint32_t d;
};

int main() {
  printf("%ld\n", sizeof(struct test));    // 8
  printf("%ld\n", _Alignof(uint8_t));      // 1
  printf("%ld\n", _Alignof(uint16_t));     // 2
  printf("%ld\n", _Alignof(uint32_t));     // 4
  printf("%ld\n", _Alignof(struct test));  // 4
  return 0;
}

Bit field

了解了内存对齐之后,我们再看下 Bit field 的作用,首先我们看看未使用 Bit field 的情况下 JSGCObjectHeader 的字长:

#include <stdio.h>
#include <stdint.h>

struct list_head {
  struct list_head *prev;
  struct list_head *next;
};

struct JSGCObjectHeader {
  int ref_count; /* must come first, 32-bit */
  int gc_obj_type;
  uint8_t mark; /* used by the GC */
  uint8_t dummy1; /* not used by the GC */
  uint16_t dummy2; /* not used by the GC */
  struct list_head link;
} JSGCObjectHeader;

int main() {
  printf("%ld\n", sizeof(struct JSGCObjectHeader));  // 32
  printf("%ld\n", _Alignof(JSGCObjectHeader.link));  // 8
  return 0;
}

计算的方式为:

$16_{struct\ list_head} = 8_{prev} + 8_{next}$

$32_{JSGCObjectHeader} = 4_{ref_count} + 4_{gc_obj_type} + 1_{mark} + 1_{dummy1} + 2_{dummy2} + 4_{padding} + 16_{link}$

其中的 $4_{padding}$ 是因为字段 $link$ 需要按 8 字节对齐

我们再看一下使用了 Bit field 后结构体字长发生的变化:

#include <stdio.h>
#include <stdint.h>

struct list_head {
  struct list_head *prev;
  struct list_head *next;
};

struct JSGCObjectHeader {
  int ref_count; /* must come first, 32-bit */
  int gc_obj_type: 4;
  uint8_t mark: 4; /* used by the GC */
  uint8_t dummy1; /* not used by the GC */
  uint16_t dummy2; /* not used by the GC */
  struct list_head link;
} JSGCObjectHeader;

int main() {
  printf("%ld\n", sizeof(struct JSGCObjectHeader));  // 24
  printf("%ld\n", _Alignof(JSGCObjectHeader.link));  // 8
  return 0;
}

可以看到变成了 24 个字节。

JSGCObjectHeader 是存在于每个 JS 对象头部的结构,缩减它的体积可以有效的减少运行时的内存占用。

下面我们来看一下 Bit field 是如何完成字长压缩的。

我将 CPU 一次可以载入的字长称为 $chunk$,结合下面的例子:

#include <stdio.h>
#include <stdint.h>

typedef struct test {
  uint8_t a;
  int b;
} test;


int main() {
  test t =  {1, 2};
  printf("%d\n", t.a); // 1
  return 0;
}

当访问 t.a 时,CPU 并不是只加载了 t.a 处的一个字节,而是一次载入了标定的字长,也就是 $chunk$,假设是 8 个字节。看似多载入的 7 个字节会停留在 CPU 内的高速缓存中,如果我们接着访问 b 字段的内容,那么会直接从高速缓存中读取。

现在我们可以简单解释一下 JSGCObjectHeader 的字长的计算方式:

struct JSGCObjectHeader {
  int ref_count; /* must come first, 32-bit */
  int gc_obj_type: 4;
  uint8_t mark: 4; /* used by the GC */
  uint8_t dummy1; /* not used by the GC */
  uint16_t dummy2; /* not used by the GC */
  struct list_head link;
} JSGCObjectHeader;
  1. 我们的 64bit 的机器通常一次会载入 8 字节,也就是一个 $chunk$ 的大小为 8 字节

  2. 那么 $chunk_{0}$ 可以包含 $ref_count$,因为后者只有 4 字节,少于 $chunk_{0}$ 的 8 字节

  3. $gc_obj_type$ 也可以放在 $chunk_{0}$ 中,因为其只有 4 bits

  4. $mark$ 也可以放在 $chunk_{0}$ 中,因为其只有 4 bits,后者的剩余空间足够存放

  5. $dummy1$ 只需按 1 字节对齐,$chunk_{0}$ 还剩余 3 个字节,因此同样可以存在 $chunk_{0}$

  6. $dummy2$ 也可以存放在 $chunk_{0}$ 中:

    • $chunk_{0}$ 还剩余 2 字节,足够存放 $dummy2$

    • $dummy2$ 之前有 6 个字节,若 $dummy2$ 存入 $chunk_{0}$,其首地址满足 2 字节对齐

  7. $link$ 字段时,由于前面的字段刚好填满了 $chunk_{0}$,所以它将被放置到 $chunk_{1}$ 中。不过由于其字长为 16 字节,所以还需要一个 $chunk_{2}$,假设其后还有字段,那些字段就需要从 $chunk_3$ 开始了

于是使用了 Bit field 的 JSGCObjectHeader 的字长计算方式为:

$24_{JSGCObjectHeader} = 8_{fields_before_link} + 16_{link}$

现在我们可以将 Bit field 的工作方式简单概括为:

  • 通过一个个 $chunk$ 来模拟 CPU 按字长载入内容的情况

  • 将字段合并到其之前或之后的 $chunk$

虽然 Bit field 可以达到压缩字长的目的,但也不是完全没有成本:

  • 当访问 gc_obj_type 时,因为它只有 4bits,编译器会为我们生成相应的 bit mask 操作以取得对应的 bit 位上的值,相比直接访问一个 int 型的字段,多出的 bit mask 指令会多消耗一些 CPU 时间

  • 使用 bit field 修饰过的字段,它在结构体中的排布、归属于哪个 $chunk$,是受到多个因素影响的:

    • 字段的 bits 数,以及其前后的字段字长、对齐字长

    • 不同硬件平台以及不同的编译器实现

    因此会丢失一部分代码的可移植性,更多的细节可以参考 C Bit FieldsAlignment of bit fields

header

JSObject 内的 headerJSGCObjectHeader 类型,它与另一个 anonymous struct 一起包裹在了同一个 anonymous union 中:

struct JSGCObjectHeader {
  int ref_count; /* must come first, 32-bit */
  JSGCObjectTypeEnum gc_obj_type : 4;
  uint8_t mark : 4; /* used by the GC */
  uint8_t dummy1; /* not used by the GC */
  uint16_t dummy2; /* not used by the GC */
  struct list_head link;
};

struct JSObject {
  // ...
  union {  // Anonymous union
    JSGCObjectHeader header;
    struct {  // Anonymous struct
      int __gc_ref_count; /* corresponds to header.ref_count */
      uint8_t __gc_mark; /* corresponds to header.mark/gc_obj_type */

      uint8_t extensible : 1;
      // ...
    };
  };
}

通过使用 anonymous union 让其中的 anonymous struct 不开辟额外的空间,而是共用 header 的内存空间。

通过嵌套的 anonymous struct 使得可以通过 JSObject 上访问到其中的字段,也就是说通过 extensible 可以访问到位于 dummy1 之上的内容。

对于 GC 来说是无需关心诸如 extensible 这样的应用层字段的,所以 GC 工作时将通过 header 来访问 JSObject 头部保存的 GC 相关的信息(ref_count/gc_obj_type)。而在 GC 之外,我们的应用层使用 JSObject 时,则可以通过其头 anonymous union/struct 中定义的字段访问相关内容。

Shape

上一节我们了解了 JSObject 的结构,知道了对象的属性信息存放在 shapeprop 字段上,这一节我们继续了解其中的细节。

属性元信息

下面一段代码定义了两个对象 o1o2

let o1 = { a: 1, b: 2 };
let o2 = { a: 1, b: "str" };

引擎层面需要为属性保存下面这些元信息:

如果需要我们简单地设计一个结构来保存这些信息,Hashmap 是一个比较简单的方案,不过它有 2 个缺点:

  • 结构相同的不同的对象,因为是不同的 Hashmap 实例,相同的键也会存在于不同的 Hashmap 中,造成了内存浪费

  • 一般的 Hashmap 其值在内存上的分布是各自离散的,因此无法很好的利用 CPU 高速缓存

为了解决上面的问题,quickjs 中使用了 shapeprop

  • shape 保存了键值的映射关系

  • 相同结构的不同对象共用同一个 shape

  • 值通过 props 保存在连续的内存地址上

Shape 的存储

相同结构的不同对象共用同一个 shape,这就引申出 2 个问题:

  • 如何快速确定对象的结构是相同的,这样才能决定是否有可复用的 shape

  • 多个 shape 是如何进行内存管理的

JSRuntime 上保存了引擎运行阶段的一些信息,上面的 2 个问题就是通过其上的一个 Hashmap 来解决的。

不过这个 Hashmap 是以 shape_hash 为主的几个字段手动构造的:

struct JSRuntime {
  // ...
  int shape_hash_bits;
  int shape_hash_size;
  int shape_hash_count; /* number of hashed shapes */
  JSShape **shape_hash;
  // ...
}

Hashmap 的值是 shape,那键是什么呢?键是对象结构的结构特征,为了方便描述,我们称之为「结构特征量」。在创建对象或者添加属性时,先算出对象结构的特征量,然后以其为键,在 Hashmap 中查看是否已经存在 shape

计算结构特征量

结构特征量的计算时的输入需要包含下面的内容:

  • 对象的原型

  • 所有属性的名称

  • 所有属性的先后顺序

  • 所有属性的描述符

这些信息其实就构成了对象的元信息,我们来看看如何基于这些信息计算出特征量。

首先是创建对象时,先以对象的原型为基础计算出一个 hash 值:

/* create a new empty shape with prototype 'proto' */
static no_inline JSShape *js_new_shape2(JSContext *ctx, JSObject *proto,
                                        int hash_size, int prop_size)
{
// ...
  /* insert in the hash table */
  sh->hash = shape_initial_hash(proto);
// ...
}

后续添加属性时,再把属性和其描述符追加到之前的 hash 上算出新的 hash 值:

static int add_shape_property(JSContext *ctx, JSShape **psh,
                              JSObject *p, JSAtom atom, int prop_flags)
{
  // ...

  /* update the shape hash */
  if (sh->is_hashed) {
    js_shape_hash_unlink(rt, sh);
    new_shape_hash = shape_hash(shape_hash(sh->hash, atom), prop_flags);
  }

  // ...
}

上面的 atomprop_flags 就分别表示对象的属性名和属性描述符,而属性的先后顺序信息是隐式的:

  • 属性定义的顺序对应了 add_shape_property 的调用顺序

  • add_shape_property 总是会在代表上一个元信息特征的 hash 的基础上计算出新的 hash

使用 hash 的另一个目的是希望特征量的分布相对均匀,因为需要用其作为 Hashmap 的键

使用结构特征量取 Shape

在了解结构特征量是如何映射到 shape 之前,我们先看一下 shape_hash 是如何手动构造的,可以通过 init_shape_hash 函数来了解它的初始化过程:

static int init_shape_hash(JSRuntime *rt)
{
  rt->shape_hash_bits = 4;   /* 16 shapes */
  rt->shape_hash_size = 1 << rt->shape_hash_bits;
  rt->shape_hash_count = 0;
  rt->shape_hash = js_mallocz_rt(rt, sizeof(rt->shape_hash[0]) * 
                                   rt->shape_hash_size);              
  if (!rt->shape_hash)
    return -1;
  return 0;
}

上面代码的包含了这些信息:

  • rt->shape_hash 指向的一块连续的内存,也就是数组,数组元素的类型是 JSShape*

  • rt->shape_hash_size 表示 rt->shape_hash 数组的长度

  • rt->shape_hash_count 表示 rt->shape_hash 数组中的已经使用的位置数

  • rt->shape_hash_bits 作用是 bit mask,值为 rt->shape_hash 数组中最大索引的有效 bit 位,它的作用是将结构特征量的值空间缩小到 rt->shape_hash 数组长度中:

    uint32_t get_shape_hash(uint32_t h, int hash_bits) {
      return h >> (32 - hash_bits);
    }

    上面方法的入参分别是结构特征量 hhash_bits,其中 h 的取值范围是 uint32_t,该方法会取 h 的高 hash_bits 个数的 bits

    比如,初始情况下 rt->shape_hash 有 16 个元素,最大索引为 15(0b1111) 的有效 bit 位个数为 4,那么该方法将保留 h 的高位的 4 个 bits

接着我们以对象创建为例,看看引擎是如何判断是否有可复用的 shape

图中包含的步骤为:

  • 首先根据对象的原型计算出一个初始的 $hash$

  • 通过 mask 方法计算 $hash$ 得到 $hash'$,后者的取值会落在 rt->shape_hash 数组长度内

  • 经过上一步的 $hash'$ 可以作为数组索引,访问到 rt->shape_hash 上的元素,视为 Bucket 首地址

  • 然后线性查找 Bucket 中的元素,看是否有相同的 $hash$,若存在的话则为可以复用的 shape

元信息的存储

shape 中为为了存储键到属性元信息的映射关系,也使用了手动构造的 Hashmap:

struct JSShape {
  // ...
  uint32_t prop_hash_mask;
  int prop_size;  /* allocated properties */
  int prop_count; /* include deleted properties */
  // ...
  JSShapeProperty prop[0]; /* prop_size elements */
};

上面的字段作用为:

  • prop,存储属性元信息的数组。因为对象属性的访问是高频的操作,所以使用连续内存来利用 CPU 缓存加速访问

  • prop_size 表示 prop 数组的长度。为了减少内存分配次数,prop 每次会多申请一些内存

  • prop_count 表示 prop 数组已存放的属性元信息数目

  • prop_hash_mask 用于将键先定位到 Buckets 数组中的某个 Bucket 上

    Buckets 数组的长度是 2 的幂,而 prop_hash_mask 的大小为 Buckets 数组的长度减 1

JSShapeProperty 结构用于存放属性的元信息:

typedef struct JSShapeProperty {
  uint32_t hash_next : 26; /* 0 if last in list */
  uint32_t flags : 6;      /* JS_PROP_XXX */
  JSAtom atom;             /* JS_ATOM_NULL = free property entry */
} JSShapeProperty;

其中的字段分别表示:

  • hash_next 与当前元信息存在于同一个 Bucket 中的下一个元信息

  • flags 属性的元信息,诸如描述符之类

  • atom 属性名对应的 JSAtom,为了减少运行阶段的内存占用,以及加速属性名的对比操作,引擎内部在使用属性名的时候,会将其映射到一个整数:

    typedef uint32_t JSAtom;

JSShape 的内存分配比较有意思,我们到现在还没有看到 Buckets 数组,为了一探究竟,我们可以从 JSShape 在引擎内的初始化方式为入口:

static no_inline JSShape *js_new_shape2(JSContext *ctx, JSObject *proto,
                                        int hash_size, int prop_size)
{
  // ...
  JSShape *sh;
  // ...
  sh_alloc = js_malloc(ctx, get_shape_size(hash_size, prop_size));
  if (!sh_alloc)
    return NULL;
  sh = get_shape_from_alloc(sh_alloc, hash_size);
  // ...
}

上面初始化 shape 的方法 js_new_shape2 中的特别操作在于:

sh_alloc = js_malloc(ctx, get_shape_size(hash_size, prop_size))

可以看到分配给 shape 的大小并不是 sizeof(JSShape),我们继续看一下 get_shape_size 的操作:

static inline size_t get_shape_size(size_t hash_size, size_t prop_size)
{
  return hash_size * sizeof(uint32_t) + sizeof(JSShape) +
    prop_size * sizeof(JSShapeProperty);
}

其中数值的计算含义可以分 3 部分来看:

  • sizeof(JSShape)JSShape 结构体所占的字节长度

  • prop_size * sizeof(JSShapeProperty) 比较好理解,对象有多少属性时不确定的,自然需要动态得分配内存

  • hash_size * sizeof(uint32_t) 其实就是 Buckets 数组了

访问属性元信息

有趣的是我们在 JSShape 中并没有看到相应的字段表示 Buckets 数组,不过我通过 js_new_shape2 中的 get_shape_from_alloc 调用可以发现一些端倪:

static inline JSShape *get_shape_from_alloc(void *sh_alloc, size_t hash_size)
{
  return (JSShape *)(void *)((uint32_t *)sh_alloc + hash_size);
}

可以看到并没有使用分配的内存首地址,而是偏移了 hash_size,也就是说 Buckets 数组是紧挨着 JSShape 之前一块内存:

上图的一些补充说明是:

  • prop_hash 即 Buckets 数组

  • 为了方便解释映射方式,图中引用了函数 find_own_property1 的实现

  • prop_hash_end(sh)[-h - 1] 的解释为:

    • prop_hash_end(sh) 其实就是 JSShape 的起始地址

    • [-h - 1] 中的 h 表示元素索引,-h 表示从后往前使用 Buckets 数组,加上 -1 是因为硬件访问内存时是读取的内存地址的高位内容(例子中为从左往右),因此需多偏移一个位置

图中没有解释到的内容可以结合 find_own_property1 的实现一起看:

static force_inline JSShapeProperty *find_own_property1(JSObject *p,
                                                        JSAtom atom)
{
  JSShape *sh;
  JSShapeProperty *pr, *prop;
  intptr_t h;
  sh = p->shape;
  h = (uintptr_t)atom & sh->prop_hash_mask; // [0, hash_size)
  h = prop_hash_end(sh)[-h - 1];
  prop = get_shape_prop(sh);
  while (h) {
    pr = &prop[h - 1];
    if (likely(pr->atom == atom)) {
      return pr;
    }
    h = pr->hash_next;
  }
  return NULL;
}

上面的代码其实就是 Hashmap 的元素查找过程:

  1. 首先通过 (uintptr_t)atom & sh->prop_hash_mask 计算出 Bucket,这一步基于下面的条件:

    • prop_hash 的长度为 2 的幂

    • prop_hash_mask 的取值为 Buckets 数组长度减 1

  2. 定位到 Bucket 之后,将通过 whilepr->hash_next 对 Bucket 内的元素进行线性查找

  3. 当 Bucket 内元素的 pr->atom 等于传入的属性名的 atom 时,则为该属性名对应的元信息,反之则属性不存在

find_own_property1 返回的是属性的元信息,而返回属性的方法是 find_own_property1

static force_inline JSShapeProperty *find_own_property(JSProperty **ppr,
                                                       JSObject *p,
                                                       JSAtom atom)
{
  // ...
  while (h) {
    pr = &prop[h - 1];
    if (likely(pr->atom == atom)) {
      *ppr = &p->prop[h - 1];        // 1
      /* the compiler should be able to assume that pr != NULL here */
      return pr;
    }
    h = pr->hash_next;
  }
  // ...
}

两个方法的实现基本相同,差别在于 find_own_property11 处,使用 ppr 返回了找到的属性,因为 C 语言不支持多返回值。

另一个值得注意的信息是,存在于 JSShape::prop 中的属性元信息和存在于 JSObject::prop 中的的属性、两者的元素索引是对应的,比如上面位置 1 处的代码,直接使用了相同的索引取回属性。

属性操作

我们已经知道了 JSShape::prop 中的属性元信息和 JSObject::prop 中的的属性两者的索引是对应的,我们看看引擎内部是如何确保这种对应关系的。

首先两个的数组长度是一致的,通过 JS_NewObjectFromShape 可以看出:

static JSValue JS_NewObjectFromShape(JSContext *ctx, JSShape *sh, JSClassID class_id)
{
  JSObject *p;
  // ...
  p->prop = js_malloc(ctx, sizeof(JSProperty) * sh->prop_size);
  // ...
}

接着我们分别看看新增、更新以及删除属性时的操作。

新增属性

新增属性通过方法 add_property 来完成,主要分为 2 步:

  1. 添加属性元信息

  2. 添加属性值

添加属性的元信息通过函数 add_shape_property 来完成:

int add_shape_property(JSContext *ctx, JSShape **psh, JSObject *p, JSAtom atom,
                       int prop_flags) {
  JSRuntime *rt = ctx->rt;
  JSShape *sh = *psh;
  JSShapeProperty *pr, *prop;
  uint32_t hash_mask, new_shape_hash = 0;
  intptr_t h;

  // ...
  if (unlikely(sh->prop_count >= sh->prop_size)) {
    if (resize_properties(ctx, psh, p, sh->prop_count + 1)) {
      // ...
    }
    sh = *psh;
  }

  /* Initialize the new shape property.
     The object property at p->prop[sh->prop_count] is uninitialized */
  prop = get_shape_prop(sh);
  pr = &prop[sh->prop_count++];      // 1                
  pr->atom = JS_DupAtom(ctx, atom);
  pr->flags = prop_flags;
  sh->has_small_array_index |= __JS_AtomIsTaggedInt(atom);
  /* add in hash table */           
  hash_mask = sh->prop_hash_mask;    // 2
  h = atom & hash_mask;
  pr->hash_next = prop_hash_end(sh)[-h - 1];  // 3
  prop_hash_end(sh)[-h - 1] = sh->prop_count; // 4
  return 0;
}

上面代码中新增属性元信息的操作可以分为 2 步:

  • 首先 1 的位置,表示将新的元信息追加到 JSShape::prop 数组中

    不过为了性能考虑,实际的操作是使用预分配的内存位置:

    JSShape::prop_size 是整个元信息 Buffer 数组的大小,JSShape::prop_count 是 Buffer 中实际已经使用的元素位置个数,因此 sh->prop_count++ 是使用了预分配的内存位置

  • 其次 2 的位置,表示将新追加的属性元信息连接到其 atom 对应的 Bucket 中:

    • 前面我们提到,为了建立属性名到属性元信息的关系,需要一个 Hashmap

    • prop_hash_end(sh)[-h - 1] 表示的是 Bucket 中的首个元素,Bucket 中的元素通过 JSShapeProperty::hash_next 连接

    • 34 的位置将新追加的元素作为 Bucket 的首个元素

      需要注意的是 4 的位置,使用的是 sh->prop_count 而不是 sh->prop_count - 1,前者为属性元信息 Buffer 中下一个可用的位置,后者才是新添加的属性元信息的索引

      正因为这个原因,在使用 JSShapeProperty::hash_next 的时候都需要减去 1

      static force_inline JSShapeProperty *find_own_property1(JSObject *p,
                                                              JSAtom atom)
      {
        // ...
        while (h) {
          // h 存放的是下一个索引位置,因此减去 1 才是元素的索引位置
          pr = &prop[h - 1]; 
          if (likely(pr->atom == atom)) {
            return pr;
          }
          h = pr->hash_next;
        }
        return NULL;
      }

      Bucket 中首个元素因为存放的是「下一个索引位置」的关系,所以其值的范围是以 1 起始的,这样的好处是可以快速的分辨 Bucket 中是否已经有元素了,因为默认初始化 Bucket 时首个元素赋值了 0

我们小结一下:

  • 属性元信息在 JSShape::prop 数组中的顺序,是与添加属性时的顺序一致的

  • 同时为了提高 atom 到属性元信息的匹配效率,构建了一个 Hashmap

我们知道 JSShape::prop 采用了预分配的内存,当新增属性时,如果预分配的内存不够用了,就需要重新分配,该操作是通过 resize_properties 函数完成的。

JSShape 中的 Hashmap 的 Buckets 数组长度必须是 2 的幂,当增加元信息时,元信息数量 $m$ 发生了变化 $m'$,需要找到数值 $n'$ 作为 Buckets 数组的新长度,$n'$ 需要满足:为 2 的幂、且大于 $m'$

// ...
new_size = max_int(count, sh->prop_size * 3 / 2);
// ...
new_hash_size = sh->prop_hash_mask + 1;
while (new_hash_size < new_size)
  new_hash_size = 2 * new_hash_size;

可以发现 Buckets 数组的长度是大于实际的元信息数量的,目的是为了减少 Hashmap 发生冲突的而进入 Bucket 线性查找的概率。

如果 $n'$ 大于之前 Buckets 数组长度,那么会同时重新分配 Buckets 数组的长度,并重新构造 Hashmap(下面的分支 1),否则仅重新分配 JSShape::prop(下面的分支 2):

no_inline int resize_properties(JSContext *ctx, JSShape **psh, JSObject *p,
                                uint32_t count) {
  // ...
  if (new_hash_size != (sh->prop_hash_mask + 1)) {
    // ...
    // 1
  } else {
    /* only resize the properties */
    // ...
    // 2
  }
  *psh = sh;
  sh->prop_size = new_size;
  return 0;
}

更新属性

属性的更新通过函数 JS_SetPropertyInternal 完成,该方法内部主要是 2 步操作:

  1. 通过函数 find_own_property 在对象和对象的原型上查找属性名 atom 对应的属性元信息 JSShapeProperty 和属性值 JSProperty

  2. 通过 set_value 方法将新的值设置到 JSProperty::u::value 上,并将之前的值释放,该方法内部并没有增加新值的引用计数

删除属性

删除属性通过函数 delete_property 来完成

删除属性首先也是通过 atom 先找到属性元信息 Bucket,然后在 Bucket 中线性查找与之相配的元素(对比 JSShapeProperty::atom):

while (h != 0) {
    pr = &prop[h - 1];
    if (likely(pr->atom == atom)) {

    }
}

删除属性元信息分 2 步:

  1. 将元信息的 JSShapeProperty::atom 清除(设置为 JS_ATOM_NULL

  2. 将待删除元信息的前后元素进行连接:

    • 通过 lpr 记录待删除元素的前一个元素

    • 通过设置 lpr->hash_next = pr->hash_next; 完成删除

找到属性元信息后,由于其索引同时也是属性值在 JSObject::prop 中的索引,所以可以很快访问到表示属性值的元素:

  1. 通过 free_property 对索引位置的属性值上的 JSProperty::u::value 进行释放

  2. 将属性值的 JSProperty::u::value 设置为 JS_UNDEFINED

可以看到上面并没有将元素从 JSShape::propJSObject::prop 中移除,因为二者都是数组,移除元素后需要拷贝整个数组,会有性能问题,但一直不移除元素的话,又可能会压迫内存,于是引擎内部设置了阈值:

if (sh->deleted_prop_count >= 8 &&
  sh->deleted_prop_count >= ((unsigned)sh->prop_count / 2)) {
  compact_properties(ctx, p);
}

当已删除的元素数目达到 8 时,通过函数 compact_properties 进行一次属性相关的数组的压实操作:

  • 元信息数组压实,只需要将 JSShapeProperty::atom 不为 JS_ATOM_NULL 的元素拷贝到新的元信息数组中即可

    for (i = 0; i < sh->prop_count; i++) {
      if (old_pr->atom != JS_ATOM_NULL) {
        // ...
      }
    }

    之所以需要拷贝是因为 shape 被多个对象共用,而属性删除操作只作用到某个对象

  • 因为元信息数组的长度发生变化,在其之上构建的 atom 到元信息的 Hashmap 也需要做调整:

    • 将 Bucket 数组的长度缩小:

      new_hash_size = sh->prop_hash_mask + 1;
      while ((new_hash_size / 2) >= new_size)
        new_hash_size = new_hash_size / 2;
      new_hash_mask = new_hash_size - 1;
    • 根据 JSShapeProperty::atom 和新的 Buckets 数组长度构建 Hashmap:

      for (i = 0; i < sh->prop_count; i++) {
        // ...
        h = ((uintptr_t)old_pr->atom & new_hash_mask);
        pr->hash_next = prop_hash_end(sh)[-h - 1];
        prop_hash_end(sh)[-h - 1] = j + 1;
        // ...
      }
  • 属性值数组因为不被多个对象共享,所以没有像 JSShape::prop 那样分配新的数组,只需让被删除属性之后的元素都往前移动一个索引位置即可

对象创建

如果从高级语言的角度看对象创建的话,引擎工作主要分 2 步:

  1. 为对象的属性分配内存

  2. 设置对象的原型 Prototype

使用 Shape 创建对象

在前面的章节中,我们知道对象属性的元信息,包括对象的原型、属性描述符等都存在了 shape 中,接下来我们看看引擎是如何使用这些信息创建对象的。

引擎的 C-API 中提供了函数 JS_NewObject 来创建对象,该函数内部会通过下面的调用链:

JS_NewObject
  JS_NewObjectProtoClass
    JS_NewObjectFromShape

主要的操作都在函数 JS_NewObjectFromShape 中完成:

static JSValue JS_NewObjectFromShape(JSContext *ctx, JSShape *sh,
                                     JSClassID class_id) {
  JSObject *p;

  js_trigger_gc(ctx->rt, sizeof(JSObject)); // 1
  p = js_malloc(ctx, sizeof(JSObject));     // 2
  if (unlikely(!p))
    goto fail;
  p->class_id = class_id;                   // 3
  // ...
  p->shape = sh;                            // 4
  p->prop = js_malloc(ctx, sizeof(JSProperty) * sh->prop_size); // 5
  // ...

  switch (class_id) { // 6
  case JS_CLASS_OBJECT:
    break;
  case JS_CLASS_ARRAY: {
    // ...
  case JS_CLASS_C_FUNCTION:
    p->prop[0].u.value = JS_UNDEFINED;
    break;
  // ...
  }
  }
  p->header.ref_count = 1; // 7
  add_gc_object(ctx->rt, &p->header, JS_GC_OBJ_TYPE_JS_OBJECT); // 8
  return JS_MKPTR(JS_TAG_OBJECT, p); // 9
}

上面的函数体经过了一些省略,主要的操作为:

  1. 先通过 js_trigger_gc 触发一次 GC,不过该函数内部有阈值判断,所以并不是每次调用都会实际的 GC 动作

  2. 接着通过 js_malloc 分配 JSObject 的内存,并对其中的字段进行初始化

  3. class_id 目前还不知道起作用

  4. 将 shape 记录到 JSObject 结构上

  5. 创建存放属性值的内存空间,前面的章节我们已经知道存放属性元信息的数组 JSShape::prop 与存放属性值的数组 JSObject::prop 的长度是一致的

  6. 通过 switch 语句中的内容,可以大致知道 class_id 用于施加不同类型的对象初始化操作

  7. 初始引用计数设置为 1

  8. 将创建的对象加入到 GC 管理中

  9. 通过值类型的方式返回 JSValue,其中包含了新创建对象的指针

使用原型创建对象

JS_NewObject 的调用链中,函数 JS_NewObjectProtoClass 的作用就是使用原型创建对象:

JSValue JS_NewObjectProtoClass(JSContext *ctx, JSValueConst proto_val,
                               JSClassID class_id) {
  JSShape *sh;
  JSObject *proto;

  proto = get_proto_obj(proto_val);
  sh = find_hashed_shape_proto(ctx->rt, proto);     // 1
  // ...
  return JS_NewObjectFromShape(ctx, sh, class_id);
}

在前面的章节中我们构造结构特征量时,使用了 hash 链的方式,hash 链的第一环就是对象的原型,所在 1 的位置,通过 find_hashed_shape_proto 会使用查找与当前原型对应的 shape

现在我们可以再回到 JS_NewObject1 函数中,看看其调用 JS_NewObjectProtoClass 是如何传递原型对象的:

JSValue JS_NewObject(JSContext *ctx) {
  /* inline JS_NewObjectClass(ctx, JS_CLASS_OBJECT); */
  return JS_NewObjectProtoClass(ctx, ctx->class_proto[JS_CLASS_OBJECT],
                                JS_CLASS_OBJECT);
}

可以看创建对象所需的原型对象是通过 JS_CLASS_OBJECTJSContext::class_proto 上取得的。

JSContext::class_proto 是一个数组,上面包含了创建内置对象所需的原型对象,比如当需要创建 Object 时,通过 JS_CLASS_OBJECT 就可以访问到 Object.prototype

JSContext::class_proto 中的内容是其实例化函数 JS_NewContext 中调用函数 JS_AddIntrinsicBasicObjects 设置的:

JSContext *JS_NewContext(JSRuntime *rt)
{
  JSContext *ctx;

  ctx = JS_NewContextRaw(rt);
  if (!ctx)
    return NULL;

  JS_AddIntrinsicBaseObjects(ctx);
  // ...
}

访问对象的原型

JS 对象上有一个已经废弃的属性 proto 可以访问到对象的原型:

({}).__proto__ === Object.prototype; // true

我们来看看引擎内部是如何实现 __proto__ 的。

首先对象自身是没有属性 __proto__ 的,比如我们上面创建的是一个没有任何属性的对象。由于 JS 原型链的关系,很容易联想到是不是在对象的原型中存在属性 __proto__ 的定义。于是我们通过 JS_CLASS_OBJECT 可以找到原型对象的定义:

void JS_AddIntrinsicBaseObjects(JSContext *ctx)
{
  // ...
  /* Object */

  // 设置原型对象上的方法
  JS_SetPropertyFunctionList(ctx, ctx->class_proto[JS_CLASS_OBJECT],
                              js_object_proto_funcs, countof(js_object_proto_funcs));
  // ...
}

其中 js_object_proto_funcs 上定义了 __proto__

static const JSCFunctionListEntry js_object_proto_funcs[] = {
  // ...
  JS_CGETSET_DEF("__proto__", js_object_get___proto__, js_object_set___proto__ ),
  // ...
};

大致可以看出来 __proto__ 是一个 getter-setter 属性,当访问属性时,调用的函数是 js_object_get___proto__

static JSValue js_object_get___proto__(JSContext *ctx, JSValueConst this_val) {
  JSValue val, ret;

  // ..
  ret = JS_GetPrototype(ctx, val);
  JS_FreeValue(ctx, val);
  return ret;
}

继续看一下函数 JS_GetPrototype

JSValue JS_GetPrototype(JSContext *ctx, JSValueConst obj) {
  JSValue val;
  if (JS_VALUE_GET_TAG(obj) == JS_TAG_OBJECT) { // 1
    JSObject *p;
    p = JS_VALUE_GET_OBJ(obj);
    if (unlikely(p->class_id == JS_CLASS_PROXY)) {  // 2
      val = js_proxy_getPrototypeOf(ctx, obj);
    } else {
      p = p->shape->proto;  // 3
      if (!p)
        val = JS_NULL;
      else
        val = JS_DupValue(ctx, JS_MKPTR(JS_TAG_OBJECT, p));
    }
  } else {
    val = JS_DupValue(ctx, JS_GetPrototypePrimitive(ctx, obj)); // 4
  }
  return val;
}

上面代码的操作为:

  1. 如果传入的 obj 是对象,那么就进行第 2 步,否则进入第 4 步

  2. 如果对象是 Proxy 那么就获取 Proxy 对象的原型,否则进行第 3 步

  3. 返回对象的 shape 上的 proto

  4. 返回 Primitive 类型的原型对象

原型链

下面图是 JS 对象的原型链关系概览:

图中显示 Object.prototype.__proto__null,我们看看引擎中是怎么设置 Object.prototype 的:

static void JS_AddIntrinsicBasicObjects(JSContext *ctx)
{
  // ...
  ctx->class_proto[JS_CLASS_OBJECT] = JS_NewObjectProto(ctx, JS_NULL);
  // ...
}

可以看到在 JS_NewObjectProto 的调用中,将其返回的对象的 shape 上的 proto 字段设置为了 JS_NULL,结合上面 __proto__ 的 getter 方法,就可以完成标准中规定的 Object.prototype.__proto__null

我们再看图中的另一个表示:

Object.__proto__ === Function.prototype // true

首先我们要明确的是,图中的关系是语言标准规定的,引擎只是按照标准来实现。回到引擎实现中,Object 是在 JS_AddIntrinsicBaseObjects 函数中设置的:

void JS_AddIntrinsicBaseObjects(JSContext *ctx)
{
  // ...
  obj = JS_NewGlobalCConstructor(ctx, "Object", js_object_constructor, 1,
                                   ctx->class_proto[JS_CLASS_OBJECT]);
  // ...
}

也就是说在引擎初始化阶段,需要初始化一个全局的构造函数 Object,并且这个函数的 prototypeJSContext::class_proto 数组的 JS_CLASS_OBJECT 位置上的对象。

我们继续看看 JS_NewGlobalCConstructor 函数的实现:

static JSValueConst JS_NewGlobalCConstructor(JSContext *ctx, const char *name,
                                             JSCFunction *func, int length,
                                             JSValueConst proto)
{
  JSValue func_obj;
  func_obj = JS_NewCFunction2(ctx, func, name, length, JS_CFUNC_constructor_or_func, 0);
  JS_NewGlobalCConstructor2(ctx, func_obj, name, proto);
  return func_obj;
}

可以看到该函数返回的是一个使用 JS_NewCFunction2 函数创建的对象,而该函数的实现为:

JSValue JS_NewCFunction2(JSContext *ctx, JSCFunction *func,
                         const char *name,
                         int length, JSCFunctionEnum cproto, int magic)
{
  return JS_NewCFunction3(ctx, func, name, length, cproto, magic,
                          ctx->function_proto);
}

static JSValue JS_NewCFunction3(JSContext *ctx, JSCFunction *func,
                                const char *name,
                                int length, JSCFunctionEnum cproto, int magic,
                                JSValueConst proto_val)
{
  JSValue func_obj;
  JSObject *p;
  JSAtom name_atom;

  func_obj = JS_NewObjectProtoClass(ctx, proto_val, JS_CLASS_C_FUNCTION);
  // ...
  return func_obj;
}

根据传参,我们可以将调用简化为:

func_obj = JS_NewObjectProtoClass(ctx, ctx->function_proto, JS_CLASS_C_FUNCTION)

其中 ctx->function_protoFunction.prototype 了。

instanceof

引擎中实现 instanceof 操作是通过调用函数 JS_IsInstanceOf 完成的:

int JS_IsInstanceOf(JSContext *ctx, JSValueConst val, JSValueConst obj) {
  JSValue method;

  if (!JS_IsObject(obj))
    goto fail;
  method = JS_GetProperty(ctx, obj, JS_ATOM_Symbol_hasInstance); // 1
  if (JS_IsException(method))
    return -1;
  if (!JS_IsNull(method) && !JS_IsUndefined(method)) {
    JSValue ret;
    ret = JS_CallFree(ctx, method, obj, 1, &val);
    return JS_ToBoolFree(ctx, ret);
  }

  /* legacy case */
  if (!JS_IsFunction(ctx, obj)) { // 2
  fail:
    JS_ThrowTypeError(ctx, "invalid 'instanceof' right operand");
    return -1;
  }
  return JS_OrdinaryIsInstanceOf(ctx, val, obj);
}

函数内容主要分两部分,如果 obj 实现了 Symbol.hasInstance 的话,那么就调用 obj 对应的方法

否则就使用 JS_OrdinaryIsInstanceOf 函数判断 objprototype 是否在 val 的原型链上:

int JS_OrdinaryIsInstanceOf(JSContext *ctx, JSValueConst val,
                            JSValueConst obj) {
  // ...
  obj_proto = JS_GetProperty(ctx, obj, JS_ATOM_prototype); // 1
  // ...
  proto = JS_VALUE_GET_OBJ(obj_proto); // 2
  p = JS_VALUE_GET_OBJ(val);
  for (;;) {                           // 3
    proto1 = p->shape->proto;          // 4
    if (!proto1) {
      /* slow case if proxy in the prototype chain */
      if (unlikely(p->class_id == JS_CLASS_PROXY)) { // 5
        // ...
      } else {
        ret = FALSE;
      }
      break;
    }
    p = proto1;
    if (proto == p) { // 6
      ret = TRUE;
      break;
    }
  }
done:
  JS_FreeValue(ctx, obj_proto);
  return ret;
}
  1. 首先 12 的配合,取得 objprototype 属性,保存在 obj_proto

  2. 然后进入循环 3,在循环内通过 346 的操作,遍历原型链,看链上是否存在对象 obj_proto

  3. 循环间如果对象是 Proxy,那么需要通过 Proxy 的方式取得对象的原型

常见函数

下面是几个创建对象时引擎内部常用的函数,了解它们的功能梗概可以方便我们阅读源码:

// - 以 `sh` 为 shape 创建一个对象 O,新创建的对象将包含 `sh` 上定义的属性
//   并且新创建对象 O 的 `__proto__` 属性为 `sh` 上的 `proto` 字段
//
// - `class_id` 用于对创建的对象 O 做一些差异化的调整
static JSValue JS_NewObjectFromShape(JSContext *ctx, JSShape *sh, JSClassID class_id);

// - 使用 `proto_val` 找到(或者创建)一个 shape 记为 S(`proto_val` 为 S 的 `Shape::proto` 字段),S 上的属性数为 0
//   然后用 S 为 shape 创建对象 O 也就是说 `proto_val` 为新创建对象的 `__proto__` 属性
//
// - `class_id` 用于对创建的对象 O 做一些差异化的调整
JSValue JS_NewObjectProtoClass(JSContext *ctx, JSValueConst proto_val,
                               JSClassID class_id);
                               
// 使用内置的原型对象创建一个对象 O,对象 O 的属性 `__proto__` 为根据 class_id 在 `JSContext::class_proto` 上索引到原型对象
JSValue JS_NewObjectClass(JSContext *ctx, int class_id);

// 即 `JS_NewObjectProtoClass(ctx, proto, JS_CLASS_OBJECT)`
JSValue JS_NewObjectProto(JSContext *ctx, JSValueConst proto);