探究JS V8引擎下的「數組」底層實現

本文首發於 vivo互聯網技術 微信公衆號 
連接:https://mp.weixin.qq.com/s/np9Yoo02pEv9n_LCusZn3Q
做者:李超算法

JavaScript 中的數組有不少特性:存放不一樣類型元素、數組長度可變等等,這與數據結構中定義的數組結構或者C++、Java等語言中的數組不太同樣,那麼JS數組的這些特性底層是如何實現的呢,咱們打開V8引擎的源碼,從中尋找到了答案。V8中對數組作了一層封裝,使其有兩種實現方式:快數組和慢數組,快數組底層是連續內存,經過索引直接定位,慢數組底層是哈希表,經過計算哈希值來定位。兩種實現方式各有特色,有各自的使用狀況,也會相互轉換。數組

1、背景

使用 JS 的數組時,發現 JS 的數組能夠存放不一樣類型的元素、而且數組長度是可變的。數據結構中定義的數組是定長的、數據類型一致的存儲結構。JS 中的數組居然如此特殊,這也是爲何標題中數組二字加上了「」的緣由。帶着一臉的懵逼,打開V8源碼,一探究竟。微信

2、什麼是數組

首先來看下什麼是數組,下面的圖是維基百科上對於數組的定義:數據結構

探究JS V8引擎下的「數組」底層實現

圖中有兩個關鍵的點,相同類型、連續內存ide

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

看完數據結構中的定義,再來看下具體語言中對數組的實現:性能

C、C++、Java、Scala 等語言中數組的實現,是經過在內存中劃分一串連續的、固定長度的空間,來實現存放一組有限個相同數據類型的數據結構。這裏面也涉及到了幾個重要的概念:連續、固定長度、相同數據類型,與數據結構中的定義是相似的。優化

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

1.連續

探究JS V8引擎下的「數組」底層實現

連續空間存儲是數組的特色,下圖是數組在內存中的存儲示意圖。debug

能夠明顯的看出各元素在內存中是相鄰的,是一種線性的存儲結構。

2.固定長度

由於數組的空間是連續的,這就意味着在內存中會有一整塊空間來存放數組,若是不是固定長度,那麼內存中位於數組以後的區域會沒辦法分配,內存不知道數組還要不要繼續存放,要使用多長的空間。長度固定,就界定了數組使用內存的界限,數組以外的空間能夠分配給別人使用。

3.相同數據類型

由於數組的長度是固定的,若是不是相同數據類型,一會存 int ,一會存String ,兩種不一樣長度的數據類型,不能保證各自存放幾個,這樣有悖固定長度的規定,因此也要是相同的數據類型。

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

那咱們再來點刺激的,進入正菜,JavaScript 中的數組。

3、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的數組不是基礎的數據結構實現的,應該是在基礎上面作了一些封裝。

下面發車,一步一步地驗證咱們的猜測。

4、刨根問底:從V8源碼上看數組的實現

Talk is cheap,show me the code.

下面一圖是 V8 中數組的源碼:

探究JS V8引擎下的「數組」底層實現

首先,咱們看到JSArray 是繼承自JSObject,也就是說,數組是一個特殊的對象。

那這就好解釋爲何JS的數組能夠存放不一樣的數據類型,它是個對象嘛,內部也是key-value的存儲形式。

咱們使用這段代碼來驗證一下:

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

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

探究JS V8引擎下的「數組」底層實現

能夠看到,底層就是個 Map ,key 爲0,1,2,3這種索引,value 就是數組的元素。

其中,數組的index實際上是字符串。

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

fast :

快速的後備存儲結構是 FixedArray ,而且數組長度 <= elements.length();

slow :

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

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

散列表(Hash table,也叫哈希表),是根據鍵(Key)而直接訪問在內存存儲位置的數據結構。也就是說,它經過計算一個關於鍵值的函數,將所需查詢的數據映射到表中一個位置來訪問記錄,這加快了查找速度。這個映射函數稱作散列函數,存放記錄的數組稱作散列表。

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

一、快數組(FAST ELEMENTS)

快數組是一種線性的存儲方式。新建立的空數組,默認的存儲方式是快數組,快數組長度是可變的,能夠根據元素的增長和刪除來動態調整存儲空間大小,內部是經過擴容和收縮機制實現,那來看下源碼中是怎麼擴容和收縮的。

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

探究JS V8引擎下的「數組」底層實現

新容量的的計算方式:

探究JS V8引擎下的「數組」底層實現

即new_capacity = old_capacity /2 + old_capacity + 16

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

擴容後會將數組拷貝到新的內存空間中,源碼:

探究JS V8引擎下的「數組」底層實現

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

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

探究JS V8引擎下的「數組」底層實現

能夠看出收縮數組的判斷是:
若是容量 >= length的2倍 + 16,則進行收縮容量調整,不然用holes對象(什麼是holes對象?下面來解釋)填充未被初始化的位置。

若是收縮,那收縮到多大呢?

看上面圖中的這段代碼:

探究JS V8引擎下的「數組」底層實現

這個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版本的底層實現來驗證一下:

探究JS V8引擎下的「數組」底層實現

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

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

驗證:

探究JS V8引擎下的「數組」底層實現

這個PACKED_SMI_ELEMENTS就是Fast Elements 模式。

快數組先到這,再來看下慢數組:

二、慢數組(DICTIONARY ELEMENTS)

慢數組是一種字典的內存形式。不用開闢大塊連續的存儲空間,節省了內存,可是因爲須要維護這樣一個 HashTable,其效率會比快數組低。

源碼中 Dictionary 的結構

探究JS V8引擎下的「數組」底層實現

能夠看到,內部是一個HashTable,而後定義了一些操做方法,和 Java 的 HashMap相似,沒有什麼特別之處。

瞭解了數組的兩種實現方式,咱們來總結下二者的區別。

三、快數組、慢數組的區別

  1. 存儲方式方面:快數組內存中是連續的,慢數組在內存中是零散分配的。

  2. 內存使用方面:因爲快數組內存是連續的,可能須要開闢一大塊供其使用,其中還可能有不少空洞,是比較費內存的。慢數組不會有空洞的狀況,且都是零散的內存,比較節省內存空間。

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

既然有快數組和慢數組,二者的也有各自的特色,每一個數組的存儲結構不會是一成不變的,會有具體狀況下的快慢數組轉換,下面來看一下什麼狀況下會發生轉換。

5、快數組慢數組之間的轉換

一、快 -> 慢

首先來看 V8 中判斷快數組是否應該轉爲慢數組的源碼:

探究JS V8引擎下的「數組」底層實現

關鍵代碼:

  1. 新容量 >= 3 擴容後的容量 2 ,會轉變爲慢數組。

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

咱們主要來看下第二種關鍵代碼的狀況。

kMaxGap 是源碼中的一個常量,值爲1024。

探究JS V8引擎下的「數組」底層實現

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

代碼實錘:

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

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

來驗證一下:

探究JS V8引擎下的「數組」底層實現

能夠看到,此時的數組確實是字典類型了,成功!

好了,看完了快數組轉慢數組,再反過來看下慢數組轉換爲快數組。

二、慢 -> 快

處於哈希表實現的數組,在每次空間增加時, V8 的啓發式算法會檢查其空間佔用量, 若其空洞元素減小到必定程度,則會將其轉化爲快數組模式。

V8中是否應該轉爲快數組的判斷源碼:

關鍵代碼:

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

探究JS V8引擎下的「數組」底層實現

來寫代碼驗證一下:

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

上面咱們說過的,在 1030 的位置上面添加一個值,會形成多於 1024 個空洞,數組會使用爲 Dictionary 模式來實現。

那麼咱們如今往這個數組中再添加幾個值來填補空洞,往 200-1029 這些位置上賦值,使慢數組再也不比快數組節省 50% 的空間,會發生什麼神奇的事情呢?

探究JS V8引擎下的「數組」底層實現

能夠看到,數組變成了快數組的 Fast Holey Elements 模式,驗證成功。

那是否是快數組存儲空間連續,效率高,就必定更好呢?其實否則。

三、各有優點

快數組就是以空間換時間的方式,申請了大塊連續內存,提升效率。
慢數組以時間換空間,沒必要申請連續的空間,節省了內存,但須要付出效率變差的代價。

6、擴展:ArrayBuffer

JS在ES6也推出了能夠按照須要分配連續內存的數組,這就是ArrayBuffer。

ArrayBuffer會從內存中申請設定的二進制大小的空間,可是並不能直接操做它,須要經過ArrayBuffer構建一個視圖,經過視圖來操做這個內存。

let buffer = new ArrayBuffer(1024);

這行代碼就申請了 1kb 的內存區域。可是並不能對 arrayBuffer 直接操做,須要將它賦給一個視圖來操做內存。

let intArray = new Int32Array(bf);

這行代碼建立了有符號的32位的整數數組,每一個數佔 4 字節,長度也就是 1024 / 4 = 256 個。

代碼驗證:

探究JS V8引擎下的「數組」底層實現

7、總結

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

  1. 傳統意義上的數組是怎麼樣的

  2. JavaScript 中的數組有哪些特別之處

  3. 從V8源碼下研究 JS 數組的底層實現

  4. JS 數組的兩種模式是如何轉換的

  5. ArrayBuffer

總的來講,JS 的數組看似與傳統數組不同,其實只是 V8 在底層實現上作了一層封裝,使用兩種數據結構實現數組,經過時間和空間緯度的取捨,優化數組的性能。

瞭解數組的底層實現,能夠幫助咱們寫出執行效率更高的代碼。

相關文章
相關標籤/搜索