首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

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

在我们 Hummer 跨端技术框架 的研发过程中,不可避免会对 JavaScript 引擎有所探索和研究。只有深入了解了 JavaScript 的工作原理,才能在跨端研发的诸多细节上避免踩坑,并且做出更好地调优工作。

对于很多前端同学来说,JavaScript 引擎就像一个难以触及的黑盒,既熟悉又陌生,因为它被内置在了浏览器内核中。即使在平时开发过程中天天和 JavaScript 引擎打交道,但大多也只是知道 JavaScript 引擎可以解释执行 JavaScript 代码,对于其内部实现原理并不是特别了解。

所以我们接下来会专门花几个专题,来深入剖析一下 JavaScript 引擎的世界,逐步揭开它的神秘面纱。

这一期我们主要讲一下 JavaScript 引擎中的 “JSValue 的内部实现”。

前言

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

实现方式

实现 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

代码语言:javascript
复制
#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 的定义可以根据 维基百科的相关链接 查看。这里我们主要摘录其格式:

  • sign: 表示正负,0 为正,1 为负
  • exponent: 指数位
  • fraction: 尾数

0.3 为例:二进制格式: 0b0011111111010011001100110011001100110011001100110011001100110011

NaN

同样,根据标准,NaN(Not a Number)的定义和种类 (NaN 同样分为两种类型:qNaNsNaN,具体请看这里) 如图:

这里简单说明下:

  • 如果 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 进行编码/解码。

代码语言:javascript
复制
   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-754qNaN 定义,则指针可能是下面这形式:

这样我们在使用时,就需要进行 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
复制
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 中的标记类型:

代码语言:javascript
复制
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 位架构中,表示如下:

代码语言:javascript
复制
                         |----- 32 bits -----|
Pointer:                |_____address_____w1|
Smi:                    |___int31_value____0|

标记位(tag bits)有双重作用:用于指示位于 V8 堆中对象的强/弱指针或一个小整数的信号。因此,整数能够直接存储在标记值中,而不必为其分配额外的存储空间。

在 64 位架构中,表示如下:

代码语言:javascript
复制
            |----- 32 bits -----|----- 32 bits -----|
Pointer:    |________________address______________w1|
Smi:        |____int32_value____|0000000000000000000|

指针压缩

从 32 位切换到 64 位。这个变化带给了 Chrome 更好的安全性、稳定性和性能,但同时也带来了更多内存消耗,因为之前每个指针占用 4 个字节而现在占用是 8 个字节。

V8 的堆区包含如下:浮点值(floating point values)、字符串字符(string characters)、解析器字节码(interpreter bytecode)和标记值(tagged values)。而在检查堆区时发现,标记值占了 V8 堆区的 70%!

为了减少内存占用,V8 使用基于基地址的 32 位偏移量,代替直接存储 64 位指针。具体见 Pointer Compression in V8

压缩前的内存布局如下:(图片来源 What's happening in V8? - Benedikt Meurer

压缩后的内存布局如下:

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

Dynamic Typing and NaN Boxing

the secret life of NaNIEEE Standard 754 Floating Point Numbers

SpiderMonkey

What's happening in V8? - Benedikt Meurer

Pointer Compression in V8

Advancements in the Objective-C runtime

作者简介

史广远:Hummer 核心成员,主要负责 Hummer 框架的 iOS 端研发工作,对 JavaScript 引擎有着非常深入的理解。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/e8CdMSWKcDJSk3JhrGus
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券