桔妹导读:在我们 Hummer 跨端技术框架 的研发过程中,不可避免会对 JavaScript 引擎有所探索和研究。只有深入了解了 JavaScript 的工作原理,才能在跨端研发的诸多细节上避免踩坑,并且做出更好地调优工作。对于很多前端同学来说,JavaScript 引擎就像一个难以触及的黑盒,既熟悉又陌生,因为它被内置在了浏览器内核中。即使在平时开发过程中天天和 JavaScript 引擎打交道,但大多也只是知道 JavaScript 引擎可以解释执行 JavaScript 代码,对于其内部实现原理并不是特别了解。所以我们接下来会专门花几个专题,来深入剖析一下 JavaScript 引擎的世界,逐步揭开它的神秘面纱。这一期我们主要讲一下 JavaScript 引擎中的 “JSValue 的内部实现”。
实现 JavaScript 引擎的第一步是实现值的表示形式,这其实有一定的难度,因为 JS值 可以是几种不同的类型中的任何一种:
undefined
null
boolean
number (double)
reference (string, Symbol, Object, etc)
要实现 动态类型 就需要一种能够表示上面所有类型的数据结构。实现这样的值类型主要有以下几种方式:
tagged 方式
tagged unions(QuickJS)
boxing 方式
nan-boxing(JavaScriptCore)
nun-boxing & pun-boxing(SpiderMonkey)
先来看下 QuickJS 中比较直接的一种实现方式:
▎QuickJS
#else /* !JS_NAN_BOXING */
typedef union JSValueUnion {
int32_t int32;
double float64;
void *ptr;
} JSValueUnion;
typedef struct JSValue {
JSValueUnion u;
int64_t tag;
} JSValue;
#define JSValueConst JSValue
▍2. nan-boxing
sign: 表示正负,0为正,1为负
exponent: 指数位
fraction: 尾数
▎NaN
同样,根据标准,NaN(Not a Number)的定义和种类 (NaN 同样分为两种类型:qNaN,sNaN,具体请看(https://en.wikipedia.org/wiki/NaN)) 如图:
这里简单说明下:
如果 exponent 全部设置为 1,则表示为 NaN。
剩余的 fraction(Mantissa) 的最左边 1 位,代表 NaN 的类型。
因此,一个 NaN 值,是有 51(64 - 11 + 1 + 1) 位未使用的。而 指针 真正也只是使用(限制)了 64 位中的 48 位。
当我们对超过 0x0000 7fff ffff ffff 的地址进行寻址时,会收到一个 EXC_I386_GPFLT 错误。
因此我们可以在剩余的 51 位中,按照一定的 规则 写入(encode)一些自定义的数据(payload),再按照同样的规则读取(decode)。
下面我们先来看下 JavaScriptCore 的实现。
▎JavaScriptCore
Pointer { 0000:PPPP:PPPP:PPPP
/ 0002:****:****:****
Double { ...
\ FFFC:****:****:****
Integer { FFFE:0000:IIII:IIII
上面的代码表示了 JavaScriptCore 中不同值类型的范围。但是我们可以发现,
这和 IEEE-754 定义的标准存在偏差。
回过头来再来看 IEEE-754 中定义的 qNaN:
根据上图,我们可以得知 NaN 的范围(16进制表示)如下:
0xfff8 xxxx xxxx xxxx ~ 0xffff xxxx xxxx xxxx
也就是说 double 的范围实际为:
0x0000 xxxx xxxx xxxx ~ 0xfff7 xxxx xxxx xxxx
与 JavaScriptCore 中的 double 范围 (0x0002x ~ 0xFFFCx) 明显存在偏差。
这么做的原因是 JavaScriptCore 更偏向对指针的操作。如果完全采用 IEEE-754 的 qNaN 定义,则指针可能是下面这形式:
源码位置如下:
ALWAYS_INLINE JSValue::JSValue(EncodeAsDoubleTag, double d)
{
ASSERT(!isImpureNaN(d));
u.asInt64 = reinterpretDoubleToInt64(d) + JSValue::DoubleEncodeOffset;
}
inline double JSValue::asDouble() const
{
ASSERT(isDouble());
return reinterpretInt64ToDouble(u.asInt64 - JSValue::DoubleEncodeOffset);
}
JavaScriptCore 中所有的类型位模式设计如下:
类型 | encode pattern |
ValEmpty | 0x0000 0000 0000 0000 |
Null | 0x0000 0000 0000 0002 |
Wasm | 0x0000 0000 0000 0003 |
ValueDeleted | 0x0000 0000 0000 0004 |
false | 0x0000 0000 0000 0006 |
true | 0x0000 0000 0000 0007 |
Undefined | 0x0000 0000 0000 000a |
pointer | 0x0000 PPPP PPPP PPPP |
double | 0x0002 xxxx xxxx xxxx |
double | 0xFFFC xxxx xxxx xxxx |
Integer | 0xFFFE 0000 IIII IIII |
我们可以发现这里的 not a number 更想表达的是 not a double!
▍3. nun-boxing & pun-boxing
既然 JavaScriptCore 可以选择保留对指针的直接操作,而对 double 特殊处理,那么相反,我们也可以保留 double 的原来标准,对指针进行编码。Mozilla’s SpiderMonkey 采用了这种方式,可以参考 SpiderMonkey 中对 JSValue 的定义。
▎SpiderMonkey
在32位设备平台中,SpiderMonkey 使用 nun-boxing 。其中 u 代表 unboxed 。因为非 double 类型的值,直接使用 32(tag) + 32(payload) 的方式,即:payload 的部分是 unboxed 。
在 x64 和类似的 64 位平台上,指针的长度超过 32 位,因此不能使用 nun-boxing 格式。取而代之的是使用 pun-boxing,17(tag) + 47(payload)。
▍4. tagged pointer
作为一名 iOS 开发,提起 Tagged Pointer,应该是比较熟悉的。下面先以 iOS 中的 Tagged Pointer 为例简单介绍下。
在 64 位架构中,一个指针为 8 字节(64 位),但是通常不会真正使用到所有这些位,且由于内存对齐要求的存在,低位始终为0。高位也始终为0 (内存访问限制)。实际上我们只是用中间这一部分的位。下面图片均来源于 WWDC:
因此我们可以使用其余的部分进行标记存储,根据标记读取 payload 中数据的具体类型:
下面是 Objective-C 中的标记类型:
OBJC_TAG_NSAtom = 0,
OBJC_TAG_1 = 1,
OBJC_TAG_NSString = 2,
OBJC_TAG_NSNumber = 3,
OBJC_TAG_NSIndexPath = 4,
OBJC_TAG_NSManagedObjectID = 5,
OBJC_TAG_NSDate = 6,
OBJC_TAG_7 = 7
再来看一下 V8。
▎V8
在 V8 中 JavaScript 的对象、数组、数字或者字符串都是用对象表示的,分配在 V8 堆区。这使得可以用一个指向对象的指针表示任何值。
而为了避免整数的堆内存占用,V8 使用了 Tagged Pointer 来表示其他数据。
在 32 位架构中,表示如下:
|----- 32 bits -----|
Pointer: |_____address_____w1|
Smi: |___int31_value____0|
标记位(tag bits)有双重作用:用于指示位于 V8 堆中对象的强/弱指针或一个小整数的信号。因此,整数能够直接存储在标记值中,而不必为其分配额外的存储空间。
在 64 位架构中,表示如下:
|----- 32 bits -----|
Pointer: |_____address_____w1|
Smi: |___int31_value____0|
▎指针压缩
从 32 位切换到 64 位。这个变化带给了 Chrome 更好的安全性、稳定性和性能,但同时也带来了更多内存消耗,因为之前每个指针占用 4 个字节而现在占用是 8 个字节。
V8 的堆区包含如下:浮点值(floating point values)、字符串字符(string characters)、解析器字节码(interpreter bytecode)和标记值(tagged values)。而在检查堆区时发现,标记值占了 V8 堆区的70%!
为了减少内存占用,V8 使用基于基地址的 32 位偏移量,代替直接存储 64 位指针。具体见 Pointer Compression in V8 (https://v8.dev/blog/pointer-compression)。
压缩前的内存布局如下:
图片来源 www.youtube.com/watchv=XsgUEUXP9no&feature=youtu.be&t=589
压缩后的内存布局如下:
该项技术使用也较为广泛,如最近的 2020 WWDC 上 Advancements in the Objective-C runtime,也使用了该技术。
我们可以发现类 nan-boxing 的方案具有明显的优势,即不会在堆上分配 double,大大减少了缓存压力和 GC 压力等。这就是 Moz 和 JSC 选择它的原因。同时如果在 32 位架构上,Moz 和 JSC 也会分配 64 位内存来实现装箱。
而 V8 虽然会在堆上分配 double,但也针对一些常见的场景进行了优化,如 Smi(small integer),且无论在 32 位还是 64 位架构上,V8 都只需要 32 位来表示指针。
value representation in javascript implementations
http://wingolog.org/archives/2011/05/18/value-representation-in-javascript-implementations
本文作者
▬
延伸阅读
▬
内容编辑 | Hokka
联系我们 | DiDiTech@didiglobal.com