在我们 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
这其实是 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
同样分为两种类型:qNaN
,sNaN
,具体请看这里) 如图:
这里简单说明下:
如果 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
进行编码/解码。
上面的代码表示了 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
定义,则指针可能是下面这形式:
这样我们在使用时,就需要进行 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)。
源码位置如下:
JavaScriptCore
中所有的类型位模式设计如下:
我们可以发现这里的 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 中的标记类型:
再来看一下 V8。
V8
在 V8 中 JavaScript
的对象、数组、数字或者字符串都是用对象表示的,分配在 V8 堆区。这使得可以用一个指向对象的指针表示任何值。
而为了避免整数的堆内存占用,V8 使用了 Tagged Pointer
来表示其他数据。
在 32 位架构中,表示如下:
标记位(tag bits)有双重作用:用于指示位于 V8 堆中对象的强/弱指针或一个小整数的信号。因此,整数能够直接存储在标记值中,而不必为其分配额外的存储空间。
在 64 位架构中,表示如下:
指针压缩
从 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
the secret life of NaNIEEE Standard 754 Floating Point Numbers
What's happening in V8? - Benedikt Meurer
Advancements in the Objective-C runtime
作者简介
史广远:Hummer 核心成员,主要负责 Hummer 框架的 iOS 端研发工作,对 JavaScript 引擎有着非常深入的理解。
关于我们
Hummer 官网:https://hummer.didi.cn
Hummer GitHub:https://github.com/didi/Hummer
Hummer 邮箱:hummer@didiglobal.com*
延伸阅读
评论