Objects in v8

圖片來源:siliconangle.comhtml

本文做者:hsy前端

前言

文本將和你們一塊兒簡單瞭解一下 v8 內部是如何處理對象的,以及 v8 爲了高速化對象屬性的訪問所作的一些優化的細節。除告終合現有的資料外,本文還連接了一些實現所對應的源碼位置,以節約你們後續須要結合源碼進行深刻時所花的時間node

本文的目的是瞭解 v8 的內部實現細節,你們能夠根據本身的狀況來決定是否須要先閱讀下面的資料:git

TaggedImpl

在 v8 內部實現中,全部對象都是從 TaggedImpl 派生的github

下圖是 v8 中涉及 Object 實現的部分類的繼承關係圖示:web

TaggedImpl 所抽象的邏輯是「打標籤」,因此咱們須要進一步瞭解「標籤」的含義算法

v8 的 GC 是「準確式 GC,Precise GC」,與之相對的是「保守式 GC,Conservative GC」chrome

GC 的任務就是幫助咱們自動管理堆上的內存。當一個對象被 GC 識別爲垃圾對象以後,GC 就須要對其佔用的內存進行回收,隨之而來的問題是 GC 如何判斷指針和非指針,由於咱們知道對象的屬性多是值屬性、或者引用堆上的其餘內容(指針):typescript

type Object = Record<string, number>;
const obj = { field1: 1 };

上面的代碼咱們經過 Record 來模擬對象的數據結構,其實就是簡單的鍵值對。不過咱們把值都定義成了 number 類型,這是由於對於值類型,咱們直接存放它們的值就能夠了,而對於引用類型,咱們則存放它們的內存地址,而內存地址也是值,因此就都用 number 表示了bootstrap

保守式 GC 的優點是與應用之間的耦合性很低,爲了達到這樣的設計目的,就要讓 GC 儘量少的依賴應用提供的信息,結果就是 GC 沒法準確判斷某個值表示的是指針仍是非指針。好比上面的例子,保守式 GC 沒法準確知道 field1 的值 1 是表示數值,仍是指針

固然保守式 GC 並非徹底不能識別指針,它能夠根據應用具體的使用內存時的行爲特色(因此也並非徹底解耦),對指針和非指針進行猜想。簡單來講就是硬編碼一些猜想的邏輯,好比咱們知道應用中的一些肯定行爲,那麼咱們就不用和應用交互,直接把這部分邏輯硬編碼到 GC 實現中就能夠了。好比咱們知道身份證的編碼格式,若是要驗證一串數字是否是身份證,咱們能夠根據編碼格式來驗證,也能夠調用公安的 API(若是有的話),前者就是保守式 GC 的工做方式,能夠驗證出一部分,可是對於那些符合格式、但卻不存在的號碼,則也會被識別爲身份證

咱們知道若是一個內存地址被意外釋放,那麼必定會致使應用後續進入錯誤的狀態、甚至崩潰。保守式 GC 爲了應對這個問題,當它在標記活動對象時,會把看起來像是指針的地址都標記爲活動的,這樣就不會發生內存被意外釋放的問題了,「保守式」之名也所以而得。不過隨之而來的是,某些可能已是垃圾的對象存活了下來,所以保守式 GC 存在壓迫堆的風險

v8 的 GC 是準確式 GC,準確式 GC 就須要和應用進行緊密配合了,TaggedImpl 就是爲了配合 GC 識別指針和非指針而定義的。TaggedImpl 使用的是稱爲 pointer tagging 的技術(該技術在 Pointer Compression in V8 有說起)

pointer tagging 技術簡單來講,就是利用地址都是按字長對齊(字長的整數倍)的特性。這個特性是這樣來的:

  1. 首先 CPU 的字長因爲硬件設計上的考量,都是偶數
  2. 而後早期 CPU 因爲內部設計的緣由,對偶數地址的尋址的效率要高於對基數地址尋址的效率(不過因爲硬件設計上的升級,目前來看也並不是絕對了)
  3. 因此你們(編譯器,運行時的內存分配)都會確保地址是按字長對齊的

這樣延續到如今,基本就當成一個默認規則了。基於這個規則,由於偶數的最低二進制位是 0,因此 v8 中:

  • 對於數值統一左移一位,這樣數值的最低二進制位爲 0
  • 對於指針則將最低二進制位置爲 1

好比,對於 GC 而言,0b110 表示的是數值 0b11(使用時需右移一位),對於 0b111 表示的是指針 0b110(尋址時需減 1)。

經過打標籤的操做,GC 就能夠認爲,若是某個地址最低二進制位是 0 則該位置就是 Smi - small integer,不然就是 HeapObject

能夠參考 垃圾回收的算法與實現 一書來更加系統的瞭解 GC 實現的細節

Object

Object 在 v8 內部用於表示全部受 GC 管理的對象

上圖演示了 v8 運行時的內存佈局,其中:

  • stack 表示 native 代碼(cpp 或 asm)使用的 stack
  • heap 表示受 GC 管理的堆
  • native 代碼經過 ptr_ 來引用堆上的對象,若是是 smi 則無需訪問 GC 的堆
  • 若是要操做堆上對象的字段,則需進一步經過在對象所屬的類的定義中、硬編碼的偏移量來完成

各個類中的字段的偏移量都定義在 field-offsets-tq.h 中。之因此要手動硬編碼,是由於這些類的實例內存須要經過 GC 來分配,而是否是直接使用 native 的堆,因此就不能利用 cpp 編譯器自動生成的偏移量了

咱們經過一個圖例來解釋一下編碼方式(64bit 系統):

  • 圖中經過不一樣的顏色表示對象自身定義的區域和繼承的區域
  • Object 中沒有字段,因此 Object::kHeaderSize0
  • HeapObject 是 Object 類的子類,所以它的字段偏移起始值是 Object::kHeaderSize參考代碼),HeapObject 只有一個字段偏移 kMapOffset 值等於 Object::kHeaderSize0,由於該字段大小是 kTaggedSize(在 64bit 系統上該值爲 8),因此 HeapObject:kHeaderSize 是 8bytes
  • JSReceiver 是 HeapObject 類的子類,所以它的字段偏移起始值是 HeapObject:kHeaderSize參考代碼),JSReceiver 也只有一個字段偏移 kPropertiesOrHashOffset,其值爲 HeapObject:kHeaderSize 即 8bytes,由於該字段大小是 kTaggedSize,因此 JSReceiver::kHeaderSize 爲 16bytes(加上了繼承的 8bytes)
  • JSObject 是 JSReceiver 的子類,所以它的字段偏移起始值是 JSReceiver::kHeaderSize參考代碼), JSObject 也只有一個字段偏移 kElementsOffset,值爲 JSReceiver::kHeaderSize 即 16bytes,最後 JSObject::kHeaderSize 就是 24bytes

根據上面的分析結果,最終經過手動編碼實現的繼承後,JSObject 中一共有三個偏移量:

  • kMapOffset
  • kPropertiesOrHashOffset
  • kElementsOffset

這三個偏移量也就表示 JSObject 有三個內置的屬性:

  • map
  • propertiesOrHash
  • elements

map

map 通常也稱爲 HiddenClass,它描述了對象的元信息,好比對象的大小(instance_size)等等。map 也是繼承自 HeapObject,所以它自己也是受 GC 管理的對象,JSObject 中的 map 字段是指向堆上的 map 對象的指針

咱們能夠結合 map 源碼中註釋的 Map layout 和下圖來理解 map 的內存的拓撲形式:

propertiesOrHash,elements

在 JS 中,數組和字典在使用上沒有顯著的差異,可是從引擎實現的角度,在其內部爲數組和字典選擇不一樣的數據結構能夠優化它們的訪問速度,因此分別使用 propertiesOrHashelements 兩個屬性就是這個目的

對於命名屬性(named properties)會關聯到 propertiesOrHash,對於索引屬性(indexed properties)則關聯到 elements。之因此使用「關聯」一詞,是由於 propertiesOrHashelements 只是指針,引擎會根據運行時的優化策略,將它們鏈接到堆上的不一樣的數據結構

咱們能夠經過下面的圖來演示 JSObject 在堆上的可能的拓撲形式:

須要說明的是,v8 的分代式 GC 會對堆按對象的活躍度和用途進行劃分,因此 map 對象實際會放到專門的堆空間中(因此實際會比上圖顯得更有組織),不過並不影響上圖的示意

inobject、fast

上面咱們介紹到 named properties 會關聯到對象的 propertiesOrHash 指針指向的數據結構,而用於存儲屬性的數據結構,v8 並非直接選擇了常見的 hash map,而是內置了 3 種關聯屬性的形式:

  • inobject
  • fast
  • slow

咱們先來了解 inobject 和 fast 的形式,下面是它們的總體圖示:

inobject 就和它的名字同樣,表示屬性值對應的指針直接保存在對象開頭的連續地址內,它是 3 種形式中訪問速度最快的(按照 fast-properties 中的描述)

注意觀察上圖中的 inobject_ptr_x,它們只是指向屬性值的指針,所以爲了按照名稱找到對應的屬性,須要藉助一個名爲 DescriptorArray 的結構,這個結構中記錄了:

  • key,字段名稱
  • PropertyDetails,表示字段的元信息,好比 IsReadOnlyIsEnumerable
  • value,只有常量時纔會存入其中,若是是 1 表示該位置未被使用(能夠結合上文的標籤進行理解)

爲了訪問 inobject 或者 fast 屬性(相關實如今 LookupIterator::LookupInRegularHolder):

  1. v8 須要先根據屬性名,在 DescriptorArray 中搜索到屬性值在 inobject array(inobject 由於是連續的內存地址,因此能夠當作是數組)或者 property array (圖中最左邊)中的索引
  2. 而後結合數組首地址與指針偏移、拿到屬性值的指針,再經過屬性值的指針,訪問具體的屬性值(相關實如今 JSObject::FastPropertyAtPut

inobject 相比 fast 要更快,這是由於 fast 屬性多了一次間接尋址:

  1. inobject 屬性知道了其屬性值的索引以後,直接根據對象的首地址進行偏移便可(inobject array 以前的 map_ptrpropertiesOrHash_ptrelements_ptr 是固定的大小)
  2. 而若是是 fast,則須要先在對象的首地址偏移 kPropertiesOrHashOffset 拿到 PropertyArray 的首地址,而後在基於該首地址再進行索引的偏移

由於 inobject 是訪問速度最快的形式,因此在 v8 中將其設定爲了默認形式,不過須要注意的是 fast 和 inobject 是互補的,只是默認狀況下,添加的屬性優先按 inobject 形式進行處理,而當遇到下面的情形時,屬性會被添加到 fast 的 PropertyArray 中:

  • 當總體 inobject 屬性的數量超過必定上限時
  • 當動態添加的屬性超過 inobject 的預留數量時
  • 當 slack tracking 完成後

v8 在建立對象的時候,會動態地選擇一個 inobject 數量,記爲 expected_nof_properties(後面會介紹),而後以該數量結合對象的內部字段(好比 map_ptr 等)數來建立對象

初始的 inobject 數量老是會比當前實際所需的尺寸大一些,目的是做爲後續可能動態添加的屬性的緩衝區,若是後續沒有動態添加屬性的動做,那麼勢必會形成空間的浪費,這個問題就能夠經過後面介紹的 slack tracking 來解決

好比:

class A {
  b = 1;
}

const a = new A();
a.c = 2;

在爲 a 分配空間時,雖然 A 只有 1 個屬性 b,可是 v8 選擇的 expected_nof_properties 值會比實際所需的 1 大。由於 JS 語言的動態性,多分配的空間可讓後續動態添加的屬性也能享受 inobject 的效率,好比例子中的 a.c = 2c 也是 inobject property,儘管它是後續動態添加的

slow

slow 相比 fast 和 inobject 更慢,是由於 slow 型的屬性訪問沒法使用 inline cache 技術進行優化,跟多關於 inline cache 的細節能夠參考:

slow 是和 inobject、fast 互斥的,當進入 slow 模式後,對象內的屬性結構以下:

slow 模式再也不須要上文提到的 DescriptorArray 了,字段的信息統一都存放在一個字典中

inobject 上限

上文提到 inobject properties 的數量是有上限的,其計算過程大體是:

// 爲了方便計算,這裏把涉及到的常量定義從源碼各個文件中摘出後放到了一塊兒
#if V8_HOST_ARCH_64_BIT
constexpr int kSystemPointerSizeLog2 = 3;
#endif
constexpr int kTaggedSizeLog2 = kSystemPointerSizeLog2;
constexpr int kSystemPointerSize = sizeof(void*);

static const int kJSObjectHeaderSize = 3 * kApiTaggedSize;
STATIC_ASSERT(kHeaderSize == Internals::kJSObjectHeaderSize);

constexpr int kTaggedSize = kSystemPointerSize;
static const int kMaxInstanceSize = 255 * kTaggedSize;
static const int kMaxInObjectProperties = (kMaxInstanceSize - kHeaderSize) >> kTaggedSizeLog2;

根據上面的定義,在 64bit 系統上、未開啓指針壓縮的狀況下,最大數量是 252 = (255 * 8 - 3 * 8) / 8

allow-natives-syntax

爲了後面能夠經過代碼演示,這裏須要穿插介紹一下 --allow-natives-syntax 選項,該選項是 v8 的一個選項,開啓該選項後,咱們可使用一些私有的 API,這些 API 能夠方便了解引擎運行時的內部細節,最初是用於 v8 源碼中編寫測試案例的

// test.js
const a = 1;
%DebugPrint(a);

經過命令 node --allow-natives-syntax test.js 便可運行上面的代碼,其中 %DebugPrint 就是 natives-syntax,而 DebugPrint 則是私有 API 中的一個

更多的 API 能夠在 runtime.h 中找到,它們具體的用法則能夠經過搜索 v8 源碼中的測試案例來了解。另外,DebugPrint 對應的實如今 objects-printer.cc

上面的代碼運行後顯示的內容相似:

DebugPrint: Smi: 0x1 (1) # Smi 咱們已經在上文介紹過了

構造函數建立

上文提到 v8 建立對象的時候,會動態選擇一個預期值,該值做爲 inobject 屬性的初始數量,記爲 expected_nof_properties,接下來咱們看下該值是如何選擇的

在 JS 中有兩種主要的建立對象的方式:

  • 從構造函數建立
  • 對象字面量

咱們先看從構造函數建立的狀況

將字段做爲 inobject properties 的技術並非 v8 獨創的,在靜態語言的編譯中,是常見的屬性處理方案。v8 只是將其引入到 JS 引擎的設計中,並針對 JS 引擎作了一些調整

從構造函數建立的對象,由於在編譯階段就能大體得到屬性的數量,因此在分配對象的時候,inobject 屬性數就能夠藉助編譯階段收集的信息:

function Ctor1() {
  this.p1 = 1;
  this.p2 = 2;
}

function Ctor2(condition) {
  this.p1 = 1;
  this.p2 = 2;
  if (condition) {
    this.p3 = 3;
    this.p4 = 4;
  }
}

const o1 = new Ctor1();
const o2 = new Ctor2();

%DebugPrint(o1);
%DebugPrint(o2);

「大體」的含義就是,對於上面的 Ctor2 會認爲它有 4 個屬性,而不會考慮 condition 的狀況

咱們能夠經過運行上面的代碼來測試:

DebugPrint: 0x954bdc78c61: [JS_OBJECT_TYPE]
 - map: 0x0954a8d7a921 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0954bdc78b91 <Object map = 0x954a8d7a891>
 - elements: 0x095411500b29 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x095411500b29 <FixedArray[0]> {
    #p1: 1 (const data field 0)
    #p2: 2 (const data field 1)
 }
0x954a8d7a921: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 104
 - inobject properties: 10
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 8
 - enum length: invalid
 - stable_map
 - back pointer: 0x0954a8d7a8d9 <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x0954ff2b9459 <Cell value= 0>
 - instance descriptors (own) #2: 0x0954bdc78d41 <DescriptorArray[2]>
 - prototype: 0x0954bdc78b91 <Object map = 0x954a8d7a891>
 - constructor: 0x0954bdc78481 <JSFunction Ctor1 (sfi = 0x954ff2b6c49)>
 - dependent code: 0x095411500289 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>

上面代碼會輸出兩段 DebugPrint,上面爲其中的第一段:

  • 緊接着 DebugPrint: 打印的是咱們傳入的對象 o1
  • 隨後的 0x954a8d7a921: [Map] 是該對象的 map 信息
  • 咱們已經介紹過 map 是對象的元信息,所以諸如 inobject properties 都記錄在其中
  • 上面的 inobject properties10 = 2 + 8,其中 2 是編譯階段收集到的屬性數,8 是額外預分配的屬性數
  • 由於對象 header 中老是有指向 mappropertiesOrHashelements 的三個指針,因此整個對象的大小(instance size)就是 headerSize + inobject_properties_size104 = (3 + (2 + 8)) * 8

你們能夠根據上面的過程驗證下 %DebugPrint(o2) 的輸出

空構造函數

爲了不你們在試驗的過程當中產生疑惑,下面再解釋一下空構造函數時分配的對象大小:

function Ctor() {}
const o = new Ctor();
%DebugPrint(o);

上面的打印結果顯示 inobject properties 數量也是 10,按照前文的計算過程,由於編譯階段發現該構造函數並無屬性,數量應該是 8 = 0 + 8 纔對

之因此顯示 10 是由於,若是編譯階段發現沒有屬性,那麼默認也會給定一個數值 2 做爲屬性的數量,這麼作是基於「大部分構造函數都會有屬性,當前沒有多是後續動態添加」的假定

關於上面的計算過程,能夠經過 shared-function-info.cc 進一步探究

Class

上文咱們都是直接將函數對象當作構造函數來使用的,而 ES6 中早已支持了 Class,接下來咱們來看下使用 Class 來實例化對象的狀況

其實 Class 只是一個語法糖,JS 語言標準對 Class 的運行時語義定義在 ClassDefinitionEvaluation 一節中。簡單來講就是一樣會建立一個函數對象(並設置該函數的名稱爲 Class 名),這樣隨後咱們的 new Class 其實和咱們 new FunctionObject 的語義一致

function Ctor() {}
class Class1 {}

%DebugPrint(Ctor);
%DebugPrint(Class1);

咱們能夠運行上面的代碼,會發現 CtorClass1 都是 JS_FUNCTION_TYPE

咱們以前已經介紹過,初始的 inobject properties 數量會藉助編譯時收集的信息,因此下面的幾個形式是等價的,且 inobject properties 數量都是 11(3 + 8):

function Ctor() {
  this.p1 = 1;
  this.p2 = 2;
  this.p3 = 3;
}
class Class1 {
  p1 = 1;
  p2 = 2;
  p3 = 3;
}
class Class2 {
  constructor() {
    this.p1 = 1;
    this.p2 = 2;
    this.p3 = 3;
  }
}
const o1 = new Ctor();
const o2 = new Class1();
const o3 = new Class2();
%DebugPrint(o1);
%DebugPrint(o2);
%DebugPrint(o3);

在編譯階段的收集的屬性數稱爲「預估屬性數」,由於其只需提供預估的精度,因此邏輯很簡單,在解解析函數或者 Class 定義的時候,發了一個設置屬性的語句就讓「預估屬性數」累加 1。下面的形式是等價的,都會將「預估屬性數」識別爲 0 而形成 inobject properties 初始值被設定爲 10(上文有講道過,當 estimated 爲 0 時,老是會分配固定的個數 2,再加上預分配 8,會讓初始 inobject 數定成 10):

function Ctor() {}

// babel runtime patch
function _defineProperty(obj, key, value) {
  if (key in obj) {
    Object.defineProperty(obj, key, {
      value: value,
      enumerable: true,
      configurable: true,
      writable: true,
    });
  } else {
    obj[key] = value;
  }
  return obj;
}

class Class1 {
  constructor() {
    _defineProperty(this, "p1", 1);
    _defineProperty(this, "p2", 2);
    _defineProperty(this, "p3", 3);
  }
}

const o1 = new Ctor();
const o2 = new Class1();
%DebugPrint(o1);
%DebugPrint(o2);

Class1 構造函數中的 _defineProperty 對於目前的預估邏輯來講太複雜了,預估邏輯設計的簡單並非由於從技術上不能分析上面的例子,而是由於 JS 語言的動態性,與爲了保持啓動速度(也是動態語言的優點)讓這裏不太適合使用太重的靜態分析技術

_defineProperty 的形式實際上是 babel 目前編譯的結果,結合後面會介紹的 slack tracking 來講,即便這裏預估數不符合咱們的預期,但也不會有太大的影響,由於咱們的單個類的屬性個數超過 10 的狀況在整個應用中來看也不會是大多數,不過若是咱們考慮繼承的狀況:

class Class1 {
  p11 = 1;
  p12 = 1;
  p13 = 1;
  p14 = 1;
  p15 = 1;
}

class Class2 extends Class1 {
  p21 = 1;
  p22 = 1;
  p23 = 1;
  p24 = 1;
  p25 = 1;
}

class Class3 extends Class2 {
  p31 = 1;
  p32 = 1;
  p33 = 1;
  p34 = 1;
  p35 = 1;
}

const o1 = new Class3();
%DebugPrint(o1);

由於繼承形式的存在,極可能通過屢次繼承,咱們的屬性數會超過 10。咱們打印上面的代碼,會發現 inobject properties 是 23(15 + 8),若是通過 babel 編譯,則代碼會變成:

"use strict";

function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }

class Class1 {
  constructor() {
    _defineProperty(this, "p11", 1);
    _defineProperty(this, "p12", 1);
    _defineProperty(this, "p13", 1);
    _defineProperty(this, "p14", 1);
    _defineProperty(this, "p15", 1);
  }
}

class Class2 extends Class1 {
  constructor(...args) {
    super(...args);

    _defineProperty(this, "p21", 1);
    _defineProperty(this, "p22", 1);
    _defineProperty(this, "p23", 1);
    _defineProperty(this, "p24", 1);
    _defineProperty(this, "p25", 1);
  }
}

class Class3 extends Class2 {
  constructor(...args) {
    super(...args);

    _defineProperty(this, "p31", 1);
    _defineProperty(this, "p32", 1);
    _defineProperty(this, "p33", 1);
    _defineProperty(this, "p34", 1);
    _defineProperty(this, "p35", 1);
  }
}

const o1 = new Class3();
%DebugPrint(o1);

上面的 inobject properties 數量只有 14 個,緣由是 Class3 的 inobject 屬性數預估值、還須要加上其祖先類的 inobject 屬性數的預估值,其兩個祖先類的預估值都是 2(由於編譯期沒有收集到數量而默認分配的固定數量 2),所以 Class3 的 inobject 屬性預估值就是 6 = 2 + 2 + 2,加上額外分配的 8 個,最後是 14 個

而咱們實際的屬性數量是 15 個,這就致使第 15 個屬性 p35 被分配成了 fast 型,回顧沒有通過 babel 編譯的代碼,全部屬性都會是 inobject 型的

最初發現 babel 和 tsc 的編譯結果不一樣,後者未使用 _defineProperty 的形式,覺得是 babel 編譯實現有瑕疵。後面發現 babel 的結果實際上是標準中規定的行爲,見 Public instance fields - 實例字段是使用 Object.defineProperty 添加的。對於 tsc 來講,開啓 useDefineForClassFields 後能夠達到相同的編譯結果(在目前的 deno-v1.9 中這個選項被默認開啓了)

原本是想說你們能夠選擇 tsc 的,但如今看來在一些對性能有極致要求的場景下,避免引入編譯環節或許是最好的方法

從對象字面量建立

const a = { p1: 1 };
%DebugPrint(a);

運行上面的代碼,會發現 inobject properties 數量是 1,這裏沒有 8 個的預留空間,是由於從對象字面量建立通過的是 CreateObjectLiteral 方法,其內部沒有預留空間的策略,而是 直接使用 編譯收集的信息,這與從構造函數建立通過的 JSObject::New 方法內部的策略不一樣

從對象字面量建立會使用字面量中的屬性數做爲 inobject properties 的數量,所以後續添加的屬性會是 fast 型

空對象字面量

和空構造函數的狀況相似,空對象字面量的大小也須要另外討論:

const a = {};
%DebugPrint(a);

運行上面的代碼,會發現 inobject properties 數量是 4,這是由於:

因此 4 是一個硬編碼的值,當建立空對象的時候,就使用該值做爲初始的 inobject properties 的數量

另外 CreateObjectLiteral 源碼中也 說起,若是使用 Object.create(null) 建立的對象,則直接是 slow 模式

inobject、fast、slow 之切換

inobject、fast、slow 三種模式的存在,是基於分而治之的理念。對有靜態性的場景(好比構造函數建立),則適用 inobject、fast,對動態性的部分,則適用 slow。下面咱們來簡單看一下三者之間的切換條件

  1. 在 inobject 配額足夠的狀況下,屬性優先被當成 inobject 型的
  2. 當 inobject 配個不足的狀況下,屬性被當成是 fast 型的
  3. 當 fast 型的配額也不足的狀況下,對象整個切換成 slow 模式
  4. 中間某一步驟中,執行了 delete 操做刪除屬性(除了刪除最後一個順位的屬性之外,刪除其他順位的屬性都會)讓對象整個切換成 slow 模式
  5. 若是某個對象被設置爲另外一個函數對象的 property 屬性,則該對象也會切換成 slow 模式,見 JSObject::OptimizeAsPrototype
  6. 一旦對象切換成 slow 模式,從開發者的角度,就基本能夠認爲該對象不會再切換成 fast 模式了(雖然引擎內部的一些特殊狀況下會使用 JSObject::MigrateSlowToFast 切換回 fast)

上面的切換規則看起來好像很繁瑣(而且也可能並非所有狀況),但其實背後的思路很簡單,inobject 和 fast 都是「偏靜態」的優化手段,而 slow 則是徹底動態的形式,當對象頻繁地動態添加屬性、或者執行了 delete 操做,則預測它極可能將來還會頻繁的變更,那麼使用純動態的形式可能會更好,因此切換成 slow 模式

關於 fast 型的配額咱們能夠稍微瞭解一下,fast 型是存放在 PropertyArray 中的,這個數組以每次 kFieldsAdded(當前版本是 3)的步長擴充其長度,目前有一個 kFastPropertiesSoftLimit(當前是 12)做爲其 limit,而 Map::TooManyFastProperties 中使用的是 >,因此 fast 型目前的配額最大是 15

你們可使用下面的代碼測試:

const obj = {};
const cnt = 19;
for (let i = 0; i < cnt; i++) {
  obj["p" + i] = 1;
}
%DebugPrint(obj);

分別設置 cnt41920,會獲得相似下面的輸出:

# 4
DebugPrint: 0x3de5e3537989: [JS_OBJECT_TYPE]
 #...
 - properties: 0x3de5de480b29 <FixedArray[0]> {

#19
DebugPrint: 0x3f0726bbde89: [JS_OBJECT_TYPE]
 #...
 - properties: 0x3f0726bbeb31 <PropertyArray[15]> {

# 20
DebugPrint: 0x1a98617377e1: [JS_OBJECT_TYPE]
 #...
 - properties: 0x1a9861738781 <NameDictionary[101]>
  • 上面的輸出中,當使用了 4 個屬性時,它們都是 inobject 型的 FixedArray[0]
  • 當使用了 19 個屬性時,已經有 15 個屬性是 fast 型 PropertyArray[15]
  • 當使用了 20 個屬性時,由於超過了上限,對象總體切換成了 slow 型 NameDictionary[101]

至於爲何 inobject 顯示的是 FixedArray,只是由於當沒有使用到 fast 型的時候 propertiesOrHash_ptr 默認指向了一個 empty_fixed_array,有興趣的同窗能夠經過閱讀 property_array 來確認

slack tracking

前文咱們提到,v8 中的初始 inobject 屬性的數量,老是會多分配一些,目的是讓後續可能經過動態添加的屬性也能夠成爲 inobject 屬性,以享受到其帶來的快速訪問效率。可是多分配的空間若是沒有被使用必定會形成浪費,在 v8 中是經過稱爲 slack tracking 的技術來提升空間利用率的

這個技術簡單來講是這樣實現的:

  • 構造函數對象的 map 中有一個 initial_map() 屬性,該屬性就是那些由該構造函數對象建立的模板,即它們的 map
  • slack tracking 會修改 initial_map() 屬性中的 instance_size 屬性值,該值是 GC 分配內存空間時使用的
  • 當第一次使用某個構造函數 C 建立對象時,它的 initial_map() 是未設置的,所以初次會設置該值,簡單來講就是建立一個新的 map 對象,並設置該對象的 construction_counter 屬性,見 Map::StartInobjectSlackTracking
  • construction_counter 實際上是一個遞減的計數器,初始值是 kSlackTrackingCounterStart 即 7
  • 隨後每次(包括當次)使用該構造函數建立對象,都會對 construction_counter 遞減,當計數爲 0 時,就會彙總當前的屬性數(包括動態添加的),而後獲得最終的 instance_size
  • slack tracking 完成後,後續動態添加的屬性都是 fast 型的

construction_counter 計數的形式相似下圖:

slack tracking 是根據構造函數調用的次數來的,因此使用對象字面量建立的對象沒法利用其提升空間利用率,這也側面說明了上文提到的空字面量的建立,默認預分配的是 4 個而不像構造函數建立那樣預留 8 個(由於沒法利用 slack tracking 後續提升空間利用率,因此只能在開始的時候就節流)

能夠經過 Slack tracking in V8 進一步瞭解其實現的細節

小結

咱們能夠將上文的重點部分小結以下:

  • 對象的屬性有三種模式:inobject,fast,slow
  • 三種模式的屬性訪問效率由左往右遞減
  • 屬性默認使用 inobject 型,超過預留配額後,繼續添加的屬性屬於 fast 型
  • 當繼續超過 fast 型的配額後,對象整個切換到 slow 型
  • 初始 inobject 的配額會由於使用的是「構造函數建立」仍是「對象字面量」建立而不一樣,前者根據編譯器收集的信息(大體屬性數 + 8,且上限爲 252),後者是固定的 4
  • 使用 Object.create(null) 建立的對象直接是 slow 型
  • 對於任意對象 A,在其聲明週期內,使用 delete 刪除了除最後順位之外的其他順位的屬性,或者將 A 設置爲另外一個構造函數的 prototype 屬性,都會將對象 A 整個切換爲 slow 型
  • 目前來看,切換到 slow 型後將不能再回到 fast 型

在實際使用時,咱們沒必要考慮上面的細節,只要確保在有條件的狀況下:

  • 儘量使用構造函數的方式建立對象,換句話說是儘量的減小屬性的動態建立。實際上,像這樣儘量讓 JS 代碼體現出更多的靜態性,是迎合引擎內部優化方式以得到更優性能的核心原則,一樣的操做包括儘量的保持變量的類型始終惟1、以免 JIT 失效等
  • 若是須要大量的動態添加屬性,或者須要刪除屬性,直接使用 Map 對象會更好(雖然引擎內部也會自動切換,可是直接用 Map 更符合這樣的場景,也省去了內部切換的消耗)

本文簡單結合源碼介紹了一下 v8 中是如何處理對象的,但願能夠有幸做爲你們深刻了解 v8 內存管理的初始讀物

參考資料

本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe (at) corp.netease.com!
相關文章
相關標籤/搜索