-
Notifications
You must be signed in to change notification settings - Fork 10
基础数据类型 ‐ JSObject 相关
引擎中的高阶功能(比如 Generator,Promise 等)都基于 JSObject,并且当引擎内部需要存储一些较为复杂的数据时,也会使用 JSObject 作为容器。
我们在高级语言中常用的数据结构(数组,Hashmap,字符串)在 C 语言中的默认支持很有限,通常来说开发者需要在程序中自己实现这些数据结构。
对于引擎来说,其为了支持 JS 的数据类型(对象,数组等)会实现一些数据结构,当引擎内需要存储一些复杂的数据时,就可以直接使用这些数据结构,相对的内存管理也会变得简单 - 交给为 JS 实现的 GC 来负责。
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 相关的信息 -
shape
和prop
存储了一些 JS 对象的属性相关的信息 -
u
字段的类型是union
,其中为不同的 JS 对象(func
,array
,regexp
等)提供存储空间
所以 JSObject 内存储的数据分 3 块:
-
保存 JS 对象 GC 相关信息的
JSGCObjectHeader
-
保存 JS 对象属性信息的
shape
和prop
-
以及保存 JS 对象派生类型的信息的字段
u
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 -
字段
b
是int
类型,它的访问地址必须按 4 字节对齐(4 的整数倍),因此在a
和b
之间需要插入 3 个字节的 padding,这样b
的首地址偏移量就变成了 4
因此 struct test
所占的字节长度为:
我们再看一个结构体的整体字长不是偶数倍的例子:
#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 的情况下 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;
}
计算的方式为:
其中的
我们再看一下使用了 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 一次可以载入的字长称为
#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
处的一个字节,而是一次载入了标定的字长,也就是 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;
-
我们的 64bit 的机器通常一次会载入 8 字节,也就是一个
$chunk$ 的大小为 8 字节 -
那么
$chunk_{0}$ 可以包含$ref_count$ ,因为后者只有 4 字节,少于$chunk_{0}$ 的 8 字节 -
$gc_obj_type$ 也可以放在$chunk_{0}$ 中,因为其只有 4 bits -
$mark$ 也可以放在$chunk_{0}$ 中,因为其只有 4 bits,后者的剩余空间足够存放 -
$dummy1$ 只需按 1 字节对齐,$chunk_{0}$ 还剩余 3 个字节,因此同样可以存在$chunk_{0}$ 中 -
$dummy2$ 也可以存放在$chunk_{0}$ 中:-
$chunk_{0}$ 还剩余 2 字节,足够存放$dummy2$ -
$dummy2$ 之前有 6 个字节,若$dummy2$ 存入$chunk_{0}$ ,其首地址满足 2 字节对齐
-
-
到
$link$ 字段时,由于前面的字段刚好填满了$chunk_{0}$ ,所以它将被放置到$chunk_{1}$ 中。不过由于其字长为 16 字节,所以还需要一个$chunk_{2}$ ,假设其后还有字段,那些字段就需要从$chunk_3$ 开始了
于是使用了 Bit field 的 JSGCObjectHeader
的字长计算方式为:
现在我们可以将 Bit field 的工作方式简单概括为:
-
通过一个个
$chunk$ 来模拟 CPU 按字长载入内容的情况 -
将字段合并到其之前或之后的
$chunk$ 中
虽然 Bit field 可以达到压缩字长的目的,但也不是完全没有成本:
-
当访问
gc_obj_type
时,因为它只有 4bits,编译器会为我们生成相应的 bit mask 操作以取得对应的 bit 位上的值,相比直接访问一个int
型的字段,多出的 bit mask 指令会多消耗一些 CPU 时间 -
使用 bit field 修饰过的字段,它在结构体中的排布、归属于哪个
$chunk$ ,是受到多个因素影响的:-
字段的 bits 数,以及其前后的字段字长、对齐字长
-
不同硬件平台以及不同的编译器实现
因此会丢失一部分代码的可移植性,更多的细节可以参考 C Bit Fields 和 Alignment of bit fields
-
JSObject
内的 header
为 JSGCObjectHeader
类型,它与另一个 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 中定义的字段访问相关内容。
上一节我们了解了 JSObject 的结构,知道了对象的属性信息存放在 shape
和 prop
字段上,这一节我们继续了解其中的细节。
下面一段代码定义了两个对象 o1
和 o2
:
let o1 = { a: 1, b: 2 };
let o2 = { a: 1, b: "str" };
引擎层面需要为属性保存下面这些元信息:
-
键到值的映射关系
-
属性描述符,比如 accessor descriptor
如果需要我们简单地设计一个结构来保存这些信息,Hashmap 是一个比较简单的方案,不过它有 2 个缺点:
-
结构相同的不同的对象,因为是不同的 Hashmap 实例,相同的键也会存在于不同的 Hashmap 中,造成了内存浪费
-
一般的 Hashmap 其值在内存上的分布是各自离散的,因此无法很好的利用 CPU 高速缓存
为了解决上面的问题,quickjs 中使用了 shape
和 prop
:
-
shape
保存了键值的映射关系 -
相同结构的不同对象共用同一个
shape
-
值通过
props
保存在连续的内存地址上
相同结构的不同对象共用同一个 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);
}
// ...
}
上面的 atom
和 prop_flags
就分别表示对象的属性名和属性描述符,而属性的先后顺序信息是隐式的:
-
属性定义的顺序对应了
add_shape_property
的调用顺序 -
而
add_shape_property
总是会在代表上一个元信息特征的 hash 的基础上计算出新的 hash
使用 hash 的另一个目的是希望特征量的分布相对均匀,因为需要用其作为 Hashmap 的键
在了解结构特征量是如何映射到 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); }
上面方法的入参分别是结构特征量
h
和hash_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 的元素查找过程:
-
首先通过
(uintptr_t)atom & sh->prop_hash_mask
计算出 Bucket,这一步基于下面的条件:-
prop_hash
的长度为 2 的幂 -
prop_hash_mask
的取值为 Buckets 数组长度减 1
-
-
定位到 Bucket 之后,将通过
while
和pr->hash_next
对 Bucket 内的元素进行线性查找 -
当 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_property1
的 1
处,使用 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 步:
-
添加属性元信息
-
添加属性值
添加属性的元信息通过函数 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
连接 -
3
和4
的位置将新追加的元素作为 Bucket 的首个元素需要注意的是
4
的位置,使用的是sh->prop_count
而不是sh->prop_count - 1
,前者为属性元信息 Buffer 中下一个可用的位置,后者才是新添加的属性元信息的索引正因为这个原因,在使用
JSShapeProperty::hash_next
的时候都需要减去 1static 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 的幂,当增加元信息时,元信息数量
// ...
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 线性查找的概率。
如果 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 步操作:
-
通过函数
find_own_property
在对象和对象的原型上查找属性名 atom 对应的属性元信息JSShapeProperty
和属性值JSProperty
-
通过 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 步:
-
将元信息的
JSShapeProperty::atom
清除(设置为JS_ATOM_NULL
) -
将待删除元信息的前后元素进行连接:
-
通过
lpr
记录待删除元素的前一个元素 -
通过设置
lpr->hash_next = pr->hash_next;
完成删除
-
找到属性元信息后,由于其索引同时也是属性值在 JSObject::prop
中的索引,所以可以很快访问到表示属性值的元素:
-
通过 free_property 对索引位置的属性值上的
JSProperty::u::value
进行释放 -
将属性值的
JSProperty::u::value
设置为JS_UNDEFINED
可以看到上面并没有将元素从 JSShape::prop
和 JSObject::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 步:
-
为对象的属性分配内存
-
设置对象的原型 Prototype
在前面的章节中,我们知道对象属性的元信息,包括对象的原型、属性描述符等都存在了 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
}
上面的函数体经过了一些省略,主要的操作为:
-
先通过
js_trigger_gc
触发一次 GC,不过该函数内部有阈值判断,所以并不是每次调用都会实际的 GC 动作 -
接着通过
js_malloc
分配JSObject
的内存,并对其中的字段进行初始化 -
class_id
目前还不知道起作用 -
将 shape 记录到
JSObject
结构上 -
创建存放属性值的内存空间,前面的章节我们已经知道存放属性元信息的数组
JSShape::prop
与存放属性值的数组JSObject::prop
的长度是一致的 -
通过
switch
语句中的内容,可以大致知道class_id
用于施加不同类型的对象初始化操作 -
初始引用计数设置为 1
-
将创建的对象加入到 GC 管理中
-
通过值类型的方式返回
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_OBJECT
从 JSContext::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;
}
上面代码的操作为:
-
如果传入的
obj
是对象,那么就进行第 2 步,否则进入第 4 步 -
如果对象是 Proxy 那么就获取 Proxy 对象的原型,否则进行第 3 步
-
返回对象的 shape 上的
proto
-
返回 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
,并且这个函数的 prototype
为 JSContext::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_proto
即 Function.prototype
了。
引擎中实现 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
函数判断 obj
的 prototype
是否在 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
和2
的配合,取得obj
的prototype
属性,保存在obj_proto
中 -
然后进入循环
3
,在循环内通过3
,4
,6
的操作,遍历原型链,看链上是否存在对象obj_proto
-
循环间如果对象是 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);