JavaScript引擎深入剖析(一):JSValue 的内部实现
1.
前言 

许多现代编程语言都具有称之为动态类型的功能。动态类型语言和静态类型语言之间的主要区别在于,大多数类型检查是在运行时执行的,而不是在编译时执行的。类型不再与变量关联,而是与内部存储的基础值关联,本文将以 JavaScript 为例进行分析。
2.
实现方式
实现 JavaScript 引擎的第一步是实现值的表示形式,这其实有一定的难度,因为 JS值 可以是几种不同的类型中的任何一种:
undefined
null
boolean
number (double)
reference (string, Symbol, Object, etc)
要实现 动态类型 就需要一种能够表示上面所有类型的数据结构。实现这样的值类型主要有以下几种方式:
tagged 方式
tagged unions(QuickJS)
tagged pointer(V8) 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,也使用了该技术。
3.
总结
我们可以发现类 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