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

JavaScript引擎深入剖析(一):JSValue 的内部实现_第1张图片

桔妹导读:在我们 Hummer 跨端技术框架 的研发过程中,不可避免会对 JavaScript 引擎有所探索和研究。只有深入了解了 JavaScript 的工作原理,才能在跨端研发的诸多细节上避免踩坑,并且做出更好地调优工作。对于很多前端同学来说,JavaScript 引擎就像一个难以触及的黑盒,既熟悉又陌生,因为它被内置在了浏览器内核中。即使在平时开发过程中天天和 JavaScript 引擎打交道,但大多也只是知道 JavaScript 引擎可以解释执行 JavaScript 代码,对于其内部实现原理并不是特别了解。所以我们接下来会专门花几个专题,来深入剖析一下 JavaScript 引擎的世界,逐步揭开它的神秘面纱。这一期我们主要讲一下 JavaScript 引擎中的 “JSValue 的内部实现”。

1. 

前言

JavaScript引擎深入剖析(一):JSValue 的内部实现_第2张图片

许多现代编程语言都具有称之为动态类型的功能。动态类型语言和静态类型语言之间的主要区别在于,大多数类型检查是在运行时执行的,而不是在编译时执行的。类型不再与变量关联,而是与内部存储的基础值关联,本文将以 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)

 

下面分别来详细介绍下这些实现方式,以及这些方式对应的落地 JavaScript 引擎:

1. tagged unions

先来看下 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

这其实是 tag + struct 的改进版。使用 union 可减少一定的内存使用。

 

但缺点是不论 JSValue 表示 int32 还是 指针 类型。都需要 16 个字节(以在双精度浮点数或 64 位指针或 int64 上保持 8 字节对齐)。

 

那么是否有更好的 JSValue 表示方法呢?能否压缩到只用8字节呢?接下来我们先来看 JavaScriptCore 的实现。

2. nan-boxing

在开始之前,我们需要一些准备知识。IEEE 754 标准。在下文所提标准中,如无特殊说明,均为 IEEE 754,且以 64 架构为例。

double

关于 double 的定义可以根据 维基百科的相关链接 查看。这里我们主要摘录其格式:

JavaScript引擎深入剖析(一):JSValue 的内部实现_第3张图片

  • sign: 表示正负,0为正,1为负

  • exponent: 指数位

  • fraction: 尾数

 

以 0.3 为例:

二进制格式:

0b0011111111010011001100110011001100110011001100110011001100110011

NaN

同样,根据标准,NaN(Not a Number)的定义和种类 (NaN 同样分为两种类型:qNaN,sNaN,具体请看(https://en.wikipedia.org/wiki/NaN)) 如图:

JavaScript引擎深入剖析(一):JSValue 的内部实现_第4张图片  

这里简单说明下:

  • 如果 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

JavaScriptCore 使用了 qNaN 标准来表示,因此有 51bit 来对剩余的 payload 进行编码/解码。

Pointer { 0000:PPPP:PPPP:PPPP
/ 0002:****:****:****
Double { ...
\ FFFC:****:****:****
Integer { FFFE:0000:IIII:IIII
上面的代码表示了 JavaScriptCore 中不同值类型的范围。但是我们可以发现,
这和 IEEE-754 定义的标准存在偏差。

 

回过头来再来看 IEEE-754 中定义的 qNaN:

JavaScript引擎深入剖析(一):JSValue 的内部实现_第5张图片

根据上图,我们可以得知 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 定义,则指针可能是下面这形式:

JavaScript引擎深入剖析(一):JSValue 的内部实现_第6张图片

这样我们在使用时,就需要进行 mask 操作,来读取真正的指针。JavaScriptCore 的这种做法,使得指针的操作变得简单高效。那么 double 的问题如何处理呢?

The scheme we have implemented encodes double precision values by performing a 64-bit integer addition of the value 2^49 to the number. After this manipulation no encoded double-precision value will begin with the pattern 0x0000 or 0xFFFE. Values must be decoded by reversing this operation before subsequent floating point operations may be peformed.

 

由于 double 的范围从 0x0002x 起,因此需要进行修正 (减去 2^49)。

JavaScript引擎深入剖析(一):JSValue 的内部实现_第7张图片

源码位置如下:

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:

JavaScript引擎深入剖析(一):JSValue 的内部实现_第8张图片

因此我们可以使用其余的部分进行标记存储,根据标记读取 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

JavaScript引擎深入剖析(一):JSValue 的内部实现_第9张图片

压缩后的内存布局如下:

JavaScript引擎深入剖析(一):JSValue 的内部实现_第10张图片

该项技术使用也较为广泛,如最近的 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

Dynamic Typing and NaN Boxing

https://leonardschuetz.ch/blog/nan-boxing/

the secret life of NaN

https://anniecherkaev.com/the-secret-life-of-nan

IEEE Standard 754 Floating Point Numbers

https://steve.hollasch.net/cgindex/coding/ieeefloat.html

SpiderMonkey

https://firefox-source-docs.mozilla.org/js/index.html

What's happening in V8? - Benedikt Meurer

https://www.youtube.com/watch?v=XsgUEUXP9no&feature=youtu.be&t=589

Pointer Compression in V8

https://v8.dev/blog/pointer-compression

Advancements in the Objective-C runtime

https://developer.apple.com/videos/play/wwdc2020/10163/

 

关于Hummer

JavaScript引擎深入剖析(一):JSValue 的内部实现_第11张图片

Hummer 官网:https://hummer.didi.cn

GitHub:https://github.com/didi/Hummer

OrangeLab 邮箱:[email protected]

JavaScript引擎深入剖析(一):JSValue 的内部实现_第12张图片

延伸阅读

《滴滴开源轻量级跨端开发框架:Hummer》

《揭秘 Hummer —— 为何选择 Hummer ?》

本文作者

JavaScript引擎深入剖析(一):JSValue 的内部实现_第13张图片

延伸阅读

JavaScript引擎深入剖析(一):JSValue 的内部实现_第14张图片

JavaScript引擎深入剖析(一):JSValue 的内部实现_第15张图片

JavaScript引擎深入剖析(一):JSValue 的内部实现_第16张图片

内容编辑 | Hokka

联系我们 | [email protected]


你可能感兴趣的:(java,编程语言,数据库,javascript,go)