你的object可能沒別人的快/小

阿里妹導讀
本文深入探討了JavaScript物件在V8引擎中的記憶體管理和最佳化策略,特別是在處理大規模資料時可能出現的效能和記憶體問題。
背景
開發某JS應用時使用了一個較大的資料列表,在探究效能和記憶體過程中,觀察到了反常的資料記憶體變化,從而引發了本文相關內容的研究。本文絕大部分物件設計和實現細節的內容和結論來自於V8的原始碼閱讀、以及Chrome上的JS實驗,如有錯誤歡迎指出糾正。
引子
假設有100,000*100的資料儲存在一個JSON檔案中,表達形式是一個含10萬個物件的陣列,其中每個物件有相同的100個屬性,屬性名和屬性值非常簡單,比如{"a0":0, "a1":0,"a2":0…}。
JSON.parse載入此資料後,JS記憶體佔用是42.6MB(所有Chrome記憶體彙報都已經過垃圾回收)。
此時,如果我們刪除每一個元素中間的某個屬性,如:
arr.forEach((item) => { delete item[`a0`]; }))
刪除一個屬性,大家以為記憶體有會變化嗎?剛開始我以為資料量沒變記憶體變化不會太大,然而JS堆記憶體飆升到324MB,記憶體卻增加近8倍,為什麼?
你可能會發覺在這些特定的場景下,JS物件的儲存結構發生了變化,事實確實如此。Chrome的核心是V8引擎,V8是如何設計JS物件,物件什麼情況下會發生儲存結構變化,如何避免和削弱負面影響,這是本文探討的幾個問題。
相關測試程式碼如下:
// 建立一個空陣列constdata = [];// 生成100個物件for (let i = 0; i < 100000; i++) {// 建立一個空物件const obj = {};// 生成100個屬性for (let j = 0; j < 100; j++) {// 屬性名和屬性值都是數字const propName = `a${j}`;const propValue = 0; obj[propName] = propValue; }// 將物件新增到陣列中data.push(obj);}// 將資料轉換為JSON字串const jsonString = JSON.stringify(data);// 將JSON字串寫入檔案const fs = require('fs');fs.writeFileSync('data.json', jsonString);<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head> <body> <button id="click">點選</button> <div>點選按鈕執行 arr.forEach((item) => { delete item[`a0`]; })</div> <script>var xhr = new XMLHttpRequest();// 方便在Heap Snapshot觀測 function createObject(data) {this["json"] = data; }var obj = new createObject(); xhr.onreadystatechange = function () {if (this.readyState == 4 && this.status == 200) {vardata = JSON.parse(this.responseText); obj["json"] = data; } }; xhr.open("GET", "data.json", true); xhr.send();const btn = document.getElementById("click"); btn.addEventListener("click", () => { obj.json.forEach((item) => { delete item[`a0`]; }); }); </script> </body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="UTF-8" /><metaname="viewport"content="width=device-width, initial-scale=1.0" /><title>Document</title></head><body><buttonid="click">點選</button><div>點選按鈕執行 arr.forEach((item) => { delete item[`a0`]; })</div><script>var xhr = new XMLHttpRequest();// 方便在Heap Snapshot觀測functioncreateObject(data) {this["json"] = data; }var obj = new createObject(); xhr.onreadystatechange = function () {if (this.readyState == 4 && this.status == 200) {var data = JSON.parse(this.responseText); obj["json"] = data; } }; xhr.open("GET", "data.json", true); xhr.send();const btn = document.getElementById("click"); btn.addEventListener("click", () => { obj.json.forEach((item) => {delete item[`a0`]; }); });</script></body></html>
JSObject基本結構
JSObject最少會有三個指標,分別指向HiddenClass,Properties store和Elements store,V8中的Map在一些文章中也被叫做hidden class,本文出現的所有Map均指hidden class,簡單來說Map用於描述物件的結構資料的。這裡引用V8官方文件中的一張圖。
V8 支援所謂的物件內屬性(in-object properties),這些屬性直接儲存在物件本身上。這些是 V8 中可用的最快屬性,因為它們無需任何間接即可訪問。
物件內屬性的數量由物件的初始大小預先確定,如果新增的屬性數多於物件中的空間,則它們將儲存在Properties store中,Properties store增加了一個間接級別,但可以獨立增長。
在物件中的數字屬性({1 : "a", 2: "b"})稱為排序屬性,數字屬性將儲存在Elements store中,元素和屬性儲存在兩個單獨的資料結構中,這使得新增和訪問屬性或元素對於不同的使用模式更加高效。如圖一個簡單🌰:
V8傾向於具有相同結構的物件共享相同Map,有很多物件具備相似屬性結構,只是屬性的數值不同,共享這些結構資料是一個節約儲存的好方法,V8透過構建transition tree來實現這一點。
每次新增新屬性時,物件的 map 都會更改。V8 建立了一個將 map 連結在一起的transition tree。例如上圖中將屬性 “a” 新增到空物件時,V8 知道要採用哪個 map。此過渡樹可確保以相同的順序新增相同的屬性時,最終會得到相同的最終 map。下面的示例顯示,即使我們在兩者之間新增簡單的索引屬性,我們也會遵循相同的轉換樹。
但是,如果我們建立一個添加了不同屬性的新物件,在本例中為屬性 “d”,V8 會為新的 map 建立一個單獨的分支。
簡單概述了 V8中物件的基本結構之後,下面讓我們瞭解一下如何避免或消弱物件結構變化帶來的負面影響👇👇👇。
如何避免或削弱物件結構變換帶來的負面影響
先劃重點:要擁有最高的效能,儘量讓物件處於快速模式
看一段在jsPerf平臺簡單實驗對比:
這段測試程式碼很簡單,宣告兩個物件fasetObject和slowObject,然後迴圈100次訪問各自a、c兩個屬性。Ops/sec 表示測試結果以每秒鐘執行測試程式碼的次數顯示,這個數值是越大越好,可以看到圖中fastObject比slowObject物件測試出來的Ops/sec大了很多。
要解釋這個現象,我們就要先了解 V8 對於 JavaScript 物件的兩種訪問模式:
Fast Mode:將儲存線上性屬性儲存中的屬性定義為 “fast”。快速屬性只需透過屬性儲存中的索引即可訪問。要從屬性名稱到屬性儲存中的實際位置,必須查閱 map 上的描述符陣列。
Dictionary Mode:字典模式也稱為雜湊表模式,V8 使用雜湊表來儲存物件的屬性。
在這裡給出上文中引子實驗案例記憶體飆升的答案,記憶體飆升的原因就是Array中的10萬個物件全部發生了fast到slow的模式轉換,物件儲存結構發生了變化。透過上面一段benchmark可以看出,fast與slow模式訪問屬性速度也有一定差距。
從fast模式到slow模式的轉換,一般兩種情況一是屬性總數太多,二是刪除屬性(主要由刪除非最後新增屬性造成),有時候難以避免遇到慢物件,下面舉幾個慢物件轉換為快物件的方法。
  1. 當物件被設定成為一個函式(或物件)的原型時會從Dictionary Mode最佳化成為Fast Mode
/ node --allow-natives-syntax xxx.jsfunctiontoFastProperties(o) {functionA() {this.x = 'x' } A.prototype = o;const a = new A();functionic() {returntypeof a.b; } ic(); ic();return o;}const o = {a:1,b:2};console.log(%HasFastProperties(o)); // truedelete o.a;console.log(%HasFastProperties(o)); // false toFastProperties(o);console.log(%HasFastProperties(o)); // true
在設定物件為函式原型後,又進行了例項化和兩次屬性查詢,感興趣的可以看下V8系列中的 lnline Caches 或其它有關的博文。
  1. 使用JSON.stringify和JSON.parse解析物件
const o = {};for (let i = 0; i < 127; ++i) { o[`${i}i`] = i;}const json = JSON.stringify(o);const o1 = JSON.parse(json);// in-object屬性數量越多我們能新增的快速屬性就越多console.log(%DebugPrint(o1));console.log(%HasFastProperties(o1));// 執行 node --allow-natives-syntax xx.js
透過測試發現此方法最多可以解析127個屬性的快物件並能共享map。
  1. 非必要不使用Object.create(null)建立物件
const x = Object.create(null);console.log(%HasFastProperties(x)); // false
Object.create(null)創建出來的物件是Dictionary Mode,儘量不使用這種方式建立物件。
JS物件記憶體最佳化的解決方案
在實踐中場景是一個目錄,使用的資料結構是一個長陣列(3000左右元素),每個元素物件有50個左右屬性,其中有字串、布林值、巢狀陣列等,下面介紹兩種最佳化策略,並附上部分實驗資料。貼一張最佳化前陣列的記憶體圖,每個元素佔用1580位元組。
方法一:遷移慢物件到fast模式下
當一個物件被設為原型時,V8會對其進行最佳化,透過將物件設定為某種原型,能遷移慢物件到快速模式,在此模式下我們可以持續新增快速屬性直到達到快屬性數量的上限。透過此方法最佳化物件儲存大小為220位元組,相比最佳化前物件儲存大小降低⬇️ 7倍。
不過使用這種方法轉換的物件屬性不能保證共享map,同時由於被指定為原型,相比一般快速物件會略大一點點(部分相關引用物件新建),為了進一步降低資料記憶體,儘可能的讓物件屬性共享map。
方法二:使用JSON.stringify和JSON.parse解析物件
JSON.stringify/JSON.parse最多可以解析127個屬性的快物件並能共享map, in-object屬性數量越多我們能新增的快速屬性就越多,實踐中陣列的每個元素50個屬性,此方法可以適用,相關程式碼可見上文👆👆👆。
使用這種方法最佳化後物件儲存大小為208位元組,相比最佳化前物件儲存大小降低⬇️ 7.5倍。
小結
簡單分析了V8中object的實現細節,對於 JavaScript 開發人員來說,V8其中許多內部決策並不直接可見,但它們解釋了為什麼某些程式碼模式比其他程式碼模式更快。更改屬性或元素型別通常會導致 V8 建立不同的Map,這可能會導致型別汙染,從而阻止 V8 生成最佳程式碼。
探討了部分有效同構物件記憶體最佳化思路,雖然文章結合實際案例給出了兩個記憶體最佳化手段,可能大部分js開發者實踐中未必會遭遇此類問題,如有遇到在實踐中仍需要針對具體情景進行設計。有多少個屬性,資料在使用時有新增和刪除的場景嗎,新增時可能會新增多少,刪除的規則是什麼,理解物件結構和轉換邏輯才是學習最佳化的根本。

參考資料

  • Fast properties in V8 · V8:https://v8.dev/blog/fast-properties
  • Elements kinds in V8 · V8:https://v8.dev/blog/elements-kinds
  • Explaining JavaScript VMs in JavaScript – Inline Caches:https://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html
  • Understanding the size of an object in Chrome/V8:https://www.mattzeunert.com/2017/03/29/v8-object-size.html
  • A tour of V8: object representation:https://jayconrod.com/posts/52/a-tour-of-v8-object-representation

相關文章