JavaScript 引擎深入剖析:JSValue 的內部實現

**桔妹導讀:**在我們 Hummer 跨端技術框架 的研發過程中,不可避免會對 JavaScript 引擎有所探索和研究。只有深入瞭解了 JavaScript 的工作原理,才能在跨端研發的諸多細節上避免踩坑,並且做出更好地調優工作。對於很多前端同學來說,JavaScript 引擎就像一個難以觸及的黑盒,既熟悉又陌生,因爲它被內置在了瀏覽器內核中。即使在平時開發過程中天天和 JavaScript 引擎打交道,但大多也只是知道 JavaScript 引擎可以解釋執行 JavaScript 代碼,對於其內部實現原理並不是特別瞭解。所以我們接下來會專門花幾個專題,來深入剖析一下 JavaScript 引擎的世界,逐步揭開它的神祕面紗。這一期我們主要講一下 JavaScript 引擎中的 “JSValue 的內部實現”。

前言

許多現代編程語言都具有稱之爲動態類型的功能。動態類型語言和靜態類型語言之間的主要區別在於,大多數類型檢查是在運行時執行的,而不是在編譯時執行的。類型不再與變量關聯,而是與內部存儲的基礎值關聯,本文將以 JavaScript 爲例進行分析。

實現方式

實現 JavaScript 引擎的第一步是實現值的表示形式,這其實有一定的難度,因爲 JS 值 可以是幾種不同的類型中的任何一種:

要實現 動態類型 就需要一種能夠表示上面所有類型的數據結構。實現這樣的值類型主要有以下幾種方式:

下面分別來詳細介紹下這些實現方式,以及這些方式對應的落地 JavaScript 引擎:

1. tagged un****ions

先來看下 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 的實現。

  1. nan-boxing

在開始之前,我們需要一些準備知識。IEEE 754 標準。在下文所提標準中,如無特殊說明,均爲 IEEE 754,且以 64 架構爲例。

double

關於 double 的定義可以根據 維基百科的相關鏈接 查看。這裏我們主要摘錄其格式:

以 0.3 爲例:

二進制格式:

0b0011111111010011001100110011001100110011001100110011001100110011

NaN

同樣,根據標準,NaN(Not a Number)的定義和種類 (NaN 同樣分爲兩種類型:qNaN,sNaN,具體請看 (https://en.wikipedia.org/wiki/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:

根據上圖,我們可以得知 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)。

源碼位置如下:

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 中所有的類型位模式設計如下:

xzWb9R

我們可以發現這裏的 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)。

  1. 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

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 官網:https://hummer.didi.cn

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

OrangeLab 郵箱:orange-lab@didiglobal.com

本文作者:

內容編輯 | Hokka

聯繫我們 | DiDiTech@didiglobal.com

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/OlnIqu5JvO2WjIA5re9KIg