前言: 在網上閱讀 V8 原理相關文章過程中,我發現大部分側重講述優化思路,但是缺少具體的實現講解,因此讀來總覺得隔靴搔癢,深入淺出 V8 優化系列連載由此誕生。本連載講述的是 V8 2.0.2.1 版本內部實現的相關優化,將會介紹核心的 7 個部分:
Smi 和 HeapNumber
字符串的優化
數組結構的優化
Map 結構詳解
HiddenClass 的實現
InlineCache 的實現
JIT 生成的細節
由於此系列文章主要用於自我學習和探究 V8 的運行原理,同時 2.0.2.1 版本的發布距離今已有 11 年,因此部分內容可能較為老舊,若有差異請讀者以最新版本為主。同時在此非常感謝 @龍泉寺掃地僧、@灰豆、@於航等多位老師對本篇文章的審校和建議。由於作者能力有限,文章中難免會出現錯誤,歡迎大家探討並斧正。
在標準中,ECMAScript 的 Number 類型都是 Boxed Object(儘管有字面量和 Number 構造函數之分),如果完全按照規範無腦實現,那麼類似於下面的偽代碼:
class Number: public Object { double value;}但這樣的實現會存在一些問題:
所有 Number 對象均需要分配在堆上,持有一個 Number 對象需要 4 字節 + 8 字節的大小(32 位);
分配更多的對象,浪費了更多的 CPU 緩存,對應的存取性能相比純數值的存取更慢。
但現實的開發過程中,我們的程序會被 V8 基於兩個假設進行優化:
更常使用較小數值的整數,而不是更大的整數值或浮點數;
我們訪問過的數值會在後續頻繁地存取,程序的局部性特點很強。
因此 V8 針對 Number 類型進行了拆分:Smi 和 HeapNumber。
// 在 32 位系統下,Smi 的取值範圍為 -2^30 ~ 2**30 - 1 -Infinity // HeapNumber-(2**30)-1 // HeapNumber -(2**30) // Smi -42 // Smi -0 // HeapNumber 0 // Smi 4.2 // HeapNumber 42 // Smi 2**30-1 // Smi 2**30 // HeapNumber Infinity // HeapNumber NaN // HeapNumberSmi 的基本想法很簡單,即:指針是合法的整形。因此我們可以通過指針轉整型來存儲一定範圍的整數值。
如果我們通過 C++ 簡要來實現相關邏輯則代碼如下:
#include <iostream>class Object { // ......}class Smi: public Object {public: inline int32_t value(){ return static_cast<int32_t>(reinterpret_cast<intptr_t>(this)) >> 1; } static Smi* fromInt(int32_t value){ intptr_t tagged_value = (static_cast<intptr_t>(value) << 1) | 0; return reinterpret_cast<Smi*>(tagged_value); } static bool isSmi(Smi* ptr){ return (reinterpret_cast<intptr_t>(ptr) & 1) == 0; }};int main() { int32_t value = 23333; Smi* smi = Smi::fromInt(value); std::cout << "is smi: " << Smi::isSmi(smi) << std::endl; std::cout << "is equal: " << (value == smi->value()) << std::endl; return 0;}在 V8 中,HeapNumber 的繼承關係如下所示:
class Object {public: static const int32_t kHeaderSize = 0;}class HeapObject: public Object {public: static const int32_t kHeaderSize = Object::kHeaderSize + 4; // 必須的指針長度}class HeapNumber: public HeapObject {public: static const int32_t kSize = HeapObject::kHeaderSize + 8; // 指針 (4) + double(8)}其中一個 HeapNumber 對象自身占用 12 字節內存(在 32 位系統中),由於 GC 的原因(Alloc 的返回對象均為 Object 指針),HeapNumber 的內存布局直接採用偏移量處理的方式(這裡又涉及一個 Trick):
因此 HeapNumber 的存取需要自行手工地對內存偏移進行處理,簡要實現的代碼如下:
#include <iostream>const int32_t kHeapObjectTag = 1;class Object {public: static const int32_t kHeaderSize = 0; bool isHeapObject() { return (reinterpret_cast<uintptr_t>(this) & 3) == kHeapObjectTag; }};class HeapObject:public Object {public: static const int32_t kHeaderSize = Object::kHeaderSize + 4; void set_map(void* map) { uint8_t* ptr = reinterpret_cast<uint8_t*>(this) - kHeapObjectTag; *reinterpret_cast<uint32_t*>(ptr) = reinterpret_cast<intptr_t>(map); } static HeapObject* cast(Object* ptr){ return reinterpret_cast<HeapObject*>(ptr); }};class HeapNumber:public HeapObject {public: static const int32_t kSize = HeapObject::kHeaderSize + 8; static const int32_t kValueOffset = HeapObject::kHeaderSize; void set_value(double value) { uint8_t *ptr = reinterpret_cast<uint8_t *>(this) + kValueOffset - kHeapObjectTag; *reinterpret_cast<double *>(ptr) = value; } double value() { uint8_t *ptr = reinterpret_cast<uint8_t *>(this) + kValueOffset - kHeapObjectTag; return *reinterpret_cast<double *>(ptr); } static HeapNumber* cast(Object* ptr) { return reinterpret_cast<HeapNumber *>(ptr); }};Object* AllocateRaw(size_t size) { return reinterpret_cast<Object *>(malloc(size)) + kHeapObjectTag;}int main() { Object* result = AllocateRaw(HeapNumber::kSize); // 實際上這裡源碼是塞入了 heap_number_map() // map 結構比較複雜,我們暫時略去以後講,這裡並不重要 HeapObject::cast(result)->set_map(malloc(4)); HeapNumber::cast(result)->set_value(2.3333); std::cout << "is HeapObject: " << result->isHeapObject() << std::endl; std::cout << "value: " << HeapNumber::cast(result)->value() << std::endl; return 0;}在這裡我們注意到不管是在 AllocateRaw,還是 HeapNumber 的 set_value 及 value 方法調用過程中,除了自身偏移的 4 字節外還額外的偏移了 1 個字節。並且我們也不難發現,對應的 1 字節在 isHeapObject 中起到了對應的作用,這是為什麼呢?原因是:V8 通過內存地址 4 字節對齊的特點(32 位系統),減少了 1 字節的成員屬性存儲的開銷。
那什麼叫內存地址 4 字節對齊呢?簡單來說就是每個內存地址是占用 4 個字節的,如果我們從 0 開始,那麼下一個內存的地址就是 4、8、12、16... 從這裡我們看到,對應內存地址至少低 2 位是無法被利用的,比如我們將 4 字節寫成二進制數的話就是:
...00000000 00000000 00000000 00000100從上面你可以看到第三位為 1,但是低兩位一定會是 0,因此如果我們對低兩位進行操作的話,並不會造成非法操作,在這裡 V8 就是利用了這個特性用低位 01 來判斷是否是一個 HeapObject,因此我們可以像 Smi 一樣得到更完整的 HeapObject 內存布局:
那為什麼要大費周章地這麼做呢?畢竟 2022 年手機內存都標配 8G 了,我們也不差 1 字節內存呀!其實是因為由於 JavaScript 是弱類型語言,其很多內部函數都需要判斷對象的類型然後再進行對應操作,因此判斷類型這個操作真的太太太頻繁了。不管是從內存還是 CPU 緩存中去讀取都比位操作而言耗費更多時間,秉持能省則省的原則,我們就勢必需要這麼極致的優化了。
如果有細心的同學話,一定會注意到在 Smi 的分類中,NaN 和 Infinity 被歸屬到了 HeapNumber,那為什麼不是用一個枚舉類型或者標誌位來表示呢?為什麼要讓 NaN 和 Infinity 搞成一個 HeapNumber,帶一個這麼大的 double 類型來浪費呢?如果想知道這個問題,我們需要深入到 IEEE754 標準裡面去。
我們知道對於 JavaScript 的 Number 類型而言,其實質就是一個 64 位的浮點數類型。對於 64 位浮點數的標準我們通常遵循 IEEE754,其對應表示如下:
其計算公式如下:
但實際上 IEEE 754 有一套自己的規則,其總結起來就是:
圖片來自維基百科
大家可以看到,實際上對於 double 而言,我們完全利用 IEE754 自身的規則來進行 NaN 和 Infinity 的表達,因此自然而言 NaN 和 Infinity 就歸屬到了 HeapNumber 中了。
我們知道,對於布爾值而言其 True 及 False 完全可以由數值 1 和 0 來代表,那麼在 V8 里關於 Boolean 的實現邏輯是使用 Smi 還是 HeapNumber 來表示呢?實際上在 V8 的實現中,Boolean 既不是 Smi 也不是 HeapNumber,而是一個 Heap::true_value 和 Heap::false_value。與此同時對應的 undefined 及 null 也採用了相同的實現,指向了 Heap::undefined_value 和 Heap::null_value。這裡的 Heap::*_value 實際上等價於某個 Map 對象(HeapNumber 也含有一個 Map 指針),因此你程序里的 Boolean 值都通通指向了一個相同的 Map 對象,如圖所示:
Map 是非常重要的結構,他決定了對象的類型、大小、原型甚至面試八股文裡面常聊的 HiddenClass,我們會在後續多篇中來進行剖析。
推薦閱讀:
https://v8.dev/blog/react-cliff
趙洋,曾任百度、騰訊、全民直播前端工程師、Coupang Senior Enginer,Modern Web/GMTC/FDCon 等多個會議講師,參與及編寫了 WXInlinePlayer、sablejs 等多個開源項目,目前對 WebAssembly、Compiler、Graphics 有濃厚興趣及一定實踐。GMTC 全球大前端技術大會(深圳站)2021 明星講師,演講整理見:《不用 WebAssembly 也能實現 Web 虛擬機保護》
GMTC 全球大前端技術大會將於今年 8 月落地北京,大會策劃的大前端 DevOps、前端框架新體驗、大前端監控、移動端性能與效率優化、B 端研發效能、IoT 動態應用開發、低代碼等 13 個熱點專題均已上線部分精彩演講內容,點擊底部【閱讀原文】查看更多。大會門票 9 折限時優惠,立減 480 元,組團學習還能解鎖更多折扣,感興趣的同學可以聯繫票務經理:+86 13269078023(同微信)