探究JS V8引擎下的“陣列”底層實現

vivo網際網路技術發表於2019-12-17

本文首發於 vivo網際網路技術 微信公眾號 
連結: https://mp.weixin.qq.com/s/np9Yoo02pEv9n_LCusZn3Q
作者:李超

JavaScript 中的陣列有很多特性:存放不同型別元素、陣列長度可變等等,這與資料結構中定義的陣列結構或者C++、Java等語言中的陣列不太一樣,那麼JS陣列的這些特性底層是如何實現的呢,我們開啟V8引擎的原始碼,從中尋找到了答案。V8中對陣列做了一層封裝,使其有兩種實現方式:快陣列和慢陣列,快陣列底層是連續記憶體,透過索引直接定位,慢陣列底層是雜湊表,透過計算雜湊值來定位。兩種實現方式各有特點,有各自的使用情況,也會相互轉換。

一、背景

使用 JS 的陣列時,發現 JS 的陣列可以存放不同型別的元素、並且陣列長度是可變的。資料結構中定義的陣列是定長的、資料型別一致的儲存結構。JS 中的陣列竟然如此特殊,這也是為什麼標題中陣列二字加上了“”的原因。帶著一臉的懵逼,開啟V8原始碼,一探究竟。

二、什麼是陣列

首先來看下什麼是陣列,下面的圖是維基百科上對於陣列的定義:


圖中有兩個關鍵的點, 相同型別、連續記憶體

這兩個關鍵點先不必深究,繼續往下看,下面來解釋。

看完資料結構中的定義,再來看下具體語言中對陣列的實現:

C、C++、Java、Scala 等語言中陣列的實現,是透過在記憶體中劃分一串連續的、固定長度的空間,來實現存放一組有限個相同資料型別的資料結構。這裡面也涉及到了幾個重要的概念: 連續、固定長度、相同資料型別,與資料結構中的定義是類似的。

下面來分別解釋下這幾個概念:

1.連續


連續空間儲存是陣列的特點,下圖是陣列在記憶體中的儲存示意圖。

可以明顯的看出各元素在記憶體中是相鄰的,是一種線性的儲存結構。

2.固定長度

因為陣列的空間是連續的,這就意味著在記憶體中會有一整塊空間來存放陣列,如果不是固定長度,那麼記憶體中位於陣列之後的區域會沒辦法分配,記憶體不知道陣列還要不要繼續存放,要使用多長的空間。長度固定,就界定了陣列使用記憶體的界限,陣列之外的空間可以分配給別人使用。

3.相同資料型別

因為陣列的長度是固定的,如果不是相同資料型別,一會存 int ,一會存String ,兩種不同長度的資料型別,不能保證各自存放幾個,這樣有悖固定長度的規定,所以也要是相同的資料型別。

看到這,想必大部分人應該感覺:嗯,這跟我認識的陣列幾乎吻合吧。

那我們再來點刺激的,進入正菜,JavaScript 中的陣列。

三、JavaScript 中的陣列

先來看段程式碼:

let arr = [100, 12.3, "red", "blue", "green"];
arr[arr.length] = "black";
console.log(arr.length);    // 6
console.log(arr[arr.length-1]);  //black


這短短几行程式碼可以看出 JS 陣列非同尋常的地方。

  • 第一行程式碼,陣列中竟然存放了三種資料型別?

  • 第二行程式碼,竟然向陣列中新增了一個值?

  • 第三行和第四行程式碼驗證了,陣列的長度改變了,新增的值也生效了。

除了這些,JS的陣列還有很多特殊的地方:

  1. JS 陣列中不止可以存放上面的三種資料型別,它可以存放陣列、物件、函式、Number、Undefined、Null、String、Boolean 等等。

  2. JS 陣列可以動態的改變容量,根據元素的數量來擴容、收縮。

  3. JS 陣列可以表現的像棧一樣,為陣列提供了push()和pop()方法。也可以表現的像佇列一樣,使用shift()和 push()方法,可以像使用佇列一樣使用陣列。

  4. JS 陣列可以使用for-each遍歷,可以排序,可以倒置。

  5. JS 提供了很多運算元組的方法,比如Array.concat()、Array.join()、Array.slice()。

看到這裡,應該可以看出一點端倪,大膽猜想,JS的陣列不是基礎的資料結構實現的,應該是在基礎上面做了一些封裝。

下面發車,一步一步地驗證我們的猜想。

四、刨根問底:從V8原始碼上看陣列的實現

Talk is cheap,show me the code.

下面一圖是 V8 中陣列的原始碼:


首先,我們看到JSArray 是繼承自JSObject,也就是說,陣列是一個特殊的物件。

那這就好解釋為什麼JS的陣列可以存放不同的資料型別,它是個物件嘛,內部也是key-value的儲存形式。

我們使用這段程式碼來驗證一下:

let a = [1, "hello", true, function () {
  return 1;
}];

透過 jsvu 來看一下底層是如何實現的:


可以看到,底層就是個 Map ,key 為0,1,2,3這種索引,value 就是陣列的元素。

其中,陣列的index其實是字串。

驗證完這個問題,我們再繼續看上面的V8原始碼,摩拳擦掌,準備見大招了!
從註釋上可以看出,JS 陣列有兩種表現形式,fast 和 slow ,啥?英文看不懂?那我讓谷歌幫我們翻譯好了!

fast :

快速的後備儲存結構是 FixedArray ,並且陣列長度 <= elements.length();

slow :

緩慢的後備儲存結構是一個以數字為鍵的 HashTable 。

HashTable,維基百科中解釋的很好:

雜湊表(Hash table,也叫雜湊表),是根據鍵(Key)而直接訪問在記憶體儲存位置的資料結構。也就是說,它透過計算一個關於鍵值的函式,將所需查詢的資料對映到表中一個位置來訪問記錄,這加快了查詢速度。這個對映函式稱做雜湊函式,存放記錄的陣列稱做雜湊表。

原始碼註釋中的fast和slow,只是簡單的解釋了一下,其對應的是快陣列和慢陣列,下面來具體的看一下兩種形式是如何實現的。

1、快陣列(FAST ELEMENTS)

快陣列是一種線性的儲存方式。新建立的空陣列,預設的儲存方式是快陣列,快陣列長度是可變的,可以根據元素的增加和刪除來動態調整儲存空間大小,內部是透過擴容和收縮機制實現,那來看下原始碼中是怎麼擴容和收縮的。

原始碼中 擴容的實現方法(C++):


新容量的的計算方式:


即new_capacity = old_capacity /2 + old_capacity + 16

也就是,擴容後的新容量 = 舊容量的1.5倍 + 16

擴容後會將陣列複製到新的記憶體空間中,原始碼:


看完了擴容,再來看看當空間多餘時如何收縮陣列空間。

原始碼中 收縮的實現方法(C++):


可以看出收縮陣列的判斷是:
如果容量 >= length的2倍 + 16,則進行收縮容量調整,否則用holes物件(什麼是holes物件?下面來解釋)填充未被初始化的位置。

如果收縮,那收縮到多大呢?

看上面圖中的這段程式碼:


這個elements_to_trim就是需要收縮的大小,需要根據 length + 1 和 old_length 進行判斷,是將空出的空間全部收縮掉還是隻收縮二分之一。

解釋完了擴容和減容,來看下剛剛提到的holes物件。

holes (空洞)物件指的是陣列中分配了空間,但是沒有存放元素的位置。對於holes,快陣列中有個專門的模式,在 Fast Elements 模式中有一個擴充套件,是 Fast Holey Elements模式。

Fast Holey Elements 模式適合於陣列中的 holes (空洞)情況,即只有某些索引存有資料,而其他的索引都沒有賦值的情況。

那什麼時候會是Fast Holey Elements 模式呢?

當陣列中有空洞,沒有賦值的陣列索引將會儲存一個特殊的值,這樣在訪問這些位置時就可以得到 undefined。這種情況下就會是 Fast Holey Elements 模式。

Fast Holey Elements 模式與Fast Elements 模式一樣,會動態分配連續的儲存空間,分配空間的大小由最大的索引值決定。

新建陣列時,如果沒有設定容量,V8會預設使用 Fast Elements 模式實現。

如果要對陣列設定容量,但並沒有進行內部元素的初始化,例如let a = new Array(10);,這樣的話陣列內部就存在了空洞,就會以Fast Holey Elements 模式實現。

使用jsvu呼叫v8-debug版本的底層實現來驗證一下:


一目瞭然, HOLEY_SMI_ELEMENTS 就是Fast Holey Elements 模式 。

如果對陣列進行了初始化,比如let a = new Array(1,2,3);,這種就不存在空洞,就是以Fast Elements 模式實現。

驗證:


這個 PACKED_SMI_ELEMENTS就是Fast Elements 模式。

快陣列先到這,再來看下慢陣列:

2、慢陣列(DICTIONARY ELEMENTS)

慢陣列是一種字典的記憶體形式。不用開闢大塊連續的儲存空間,節省了記憶體,但是由於需要維護這樣一個 HashTable,其效率會比快陣列低。

原始碼中 Dictionary 的結構


可以看到,內部是一個HashTable,然後定義了一些操作方法,和 Java 的 HashMap類似,沒有什麼特別之處。

瞭解了陣列的兩種實現方式,我們來總結下兩者的區別。

3、快陣列、慢陣列的區別

  1. 儲存方式方面:快陣列記憶體中是連續的,慢陣列在記憶體中是零散分配的。

  2. 記憶體使用方面:由於快陣列記憶體是連續的,可能需要開闢一大塊供其使用,其中還可能有很多空洞,是比較費記憶體的。慢陣列不會有空洞的情況,且都是零散的記憶體,比較節省記憶體空間。

  3. 遍歷效率方面:快陣列由於是空間連續的,遍歷速度很快,而慢陣列每次都要尋找 key 的位置,遍歷效率會差一些。

既然有快陣列和慢陣列,兩者的也有各自的特點,每個陣列的儲存結構不會是一成不變的,會有具體情況下的快慢陣列轉換,下面來看一下什麼情況下會發生轉換。

五、快陣列慢陣列之間的轉換

1、快 -> 慢

首先來看 V8 中判斷快陣列是否應該轉為慢陣列的原始碼:


關鍵程式碼:

  1. 新容量 >= 3 * 擴容後的容量 * 2 ,會轉變為慢陣列。

  2. 當加入的 index- 當前capacity >= kMaxGap(1024) 時(也就是至少有了 1024 個空洞),會轉變為慢陣列。

我們主要來看下第二種關鍵程式碼的情況。

kMaxGap 是原始碼中的一個常量,值為1024。


也就是說,當對陣列賦值時使用遠超當前陣列的容量+ 1024時(這樣出現了大於等於 1024 個空洞,這時候要對陣列分配大量空間則將可能造成儲存空間的浪費,為了空間的最佳化,會轉化為慢陣列。

程式碼實錘:

let a = [1, 2]
a[1030] = 1;


陣列中只有三個元素,但是卻在 1030 的位置存放了一個值,那麼中間會有多於1024個空洞,這時就會變為慢陣列。

來驗證一下:


可以看到,此時的陣列確實是字典型別了,成功!

好了,看完了快陣列轉慢陣列,再反過來看下慢陣列轉換為快陣列。

2、慢 -> 快

處於雜湊表實現的陣列,在每次空間增長時, V8 的啟發式演算法會檢查其空間佔用量, 若其空洞元素減少到一定程度,則會將其轉化為快陣列模式。

V8中是否應該轉為快陣列的判斷原始碼:

關鍵程式碼:

當慢陣列的元素可存放在快陣列中且長度在 smi 之間且僅節省了50%的空間,則會轉變為快陣列


來寫程式碼驗證一下:

let a = [1,2];
a[1030] = 1;
for (let i = 200; i < 1030; i++) {
    a[i] = i;
}


上面我們說過的,在 1030 的位置上面新增一個值,會造成多於 1024 個空洞,陣列會使用為 Dictionary 模式來實現。

那麼我們現在往這個陣列中再新增幾個值來填補空洞,往 200-1029 這些位置上賦值,使慢陣列不再比快陣列節省 50% 的空間,會發生什麼神奇的事情呢?


可以看到,陣列變成了快陣列的 Fast Holey Elements 模式,驗證成功。

那是不是快陣列儲存空間連續,效率高,就一定更好呢?其實不然。

3、各有優勢

快陣列就是以空間換時間的方式,申請了大塊連續記憶體,提高效率。
慢陣列以時間換空間,不必申請連續的空間,節省了記憶體,但需要付出效率變差的代價。

六、擴充套件:ArrayBuffer

JS在ES6也推出了可以按照需要分配連續記憶體的陣列,這就是ArrayBuffer。

ArrayBuffer會從記憶體中申請設定的二進位制大小的空間,但是並不能直接操作它,需要透過ArrayBuffer構建一個檢視,透過檢視來操作這個記憶體。

let buffer = new ArrayBuffer(1024);


這行程式碼就申請了 1kb 的記憶體區域。但是並不能對 arrayBuffer 直接操作,需要將它賦給一個檢視來操作記憶體。

let intArray = new Int32Array(bf);


這行程式碼建立了有符號的32位的整數陣列,每個數佔 4 位元組,長度也就是 1024 / 4 = 256 個。

程式碼驗證:


七、總結

看到這,腦瓜子是不是嗡嗡的?喘口氣,我們來回顧一下,這篇文章我們主要討論了這幾件事:

  1. 傳統意義上的陣列是怎麼樣的

  2. JavaScript 中的陣列有哪些特別之處

  3. 從V8原始碼下研究 JS 陣列的底層實現

  4. JS 陣列的兩種模式是如何轉換的

  5. ArrayBuffer

總的來說,JS 的陣列看似與傳統陣列不一樣,其實只是 V8 在底層實現上做了一層封裝,使用兩種資料結構實現陣列,透過時間和空間緯度的取捨,最佳化陣列的效能。

瞭解陣列的底層實現,可以幫助我們寫出執行效率更高的程式碼。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912579/viewspace-2668264/,如需轉載,請註明出處,否則將追究法律責任。

相關文章