JavaScript 效能優化

2020-08-09 03:11:49

Study Notes

JavaScript 記憶體管理

簡介

像 C 語言這樣的底層語言一般都有底層的記憶體管理介面,比如 malloc()和 free()。相反,JavaScript 是在建立變數(物件,字串等)時自動進行了分配記憶體,並且在不使用它們時「自動」釋放。 釋放的過程稱爲垃圾回收。這個「自動」是混亂的根源,並讓 JavaScript(和其他高階語言)開發者錯誤的感覺他們可以不關心記憶體管理。

記憶體生命週期

不管什麼程式語言,記憶體生命週期基本是一致的:

  • 分配你所需要的記憶體
  • 使用分配到的記憶體(讀、寫)
  • 不需要時將其釋放\歸還

所有語言第二部分都是明確的。第一和第三部分在底層語言中是明確的,但在像 JavaScript 這些高階語言中,大部分都是隱含的。

JavaScript 的記憶體分配

值的初始化

爲了不讓程式設計師費心分配記憶體,JavaScript 在定義變數時就完成了記憶體分配。

const n = 123; // 給數值變數分配記憶體
const s = 'heath'; // 給字串分配記憶體

const o = {
  a: 1,
  b: null,
}; // 給物件及其包含的值分配記憶體

// 給陣列及其包含的值分配記憶體(就像物件一樣)
const a = [1, null, 'abra'];

function f(a) {
  return a + 2;
} // 給函數(可呼叫的物件)分配記憶體

// 函數表達式也能分配一個物件
someElement.addEventListener(
  'click',
  function () {
    someElement.style.backgroundColor = 'blue';
  },
  false,
);

通過函數呼叫分配記憶體

有些函數呼叫結果是分配物件記憶體:

const d = new Date(); // 分配一個 Date 物件

const e = document.createElement('div'); // 分配一個 DOM 元素

有些方法分配新變數或者新物件:

const s = 'heath';
const s2 = s.substr(0, 3); // s2 是一個新的字串
// 因爲字串是不變數,
// JavaScript 可能決定不分配記憶體,
// 只是儲存了 [0-3] 的範圍。

const a = ['heath heath', 'nan nan'];
const a2 = ['generation', 'nan nan'];
const a3 = a.concat(a2);
// 新陣列有四個元素,是 a 連線 a2 的結果

使用值

使用值的過程實際上是對分配記憶體進行讀取與寫入的操作。讀取與寫入可能是寫入一個變數或者一個物件的屬性值,甚至傳遞函數的參數。

當記憶體不再需要使用時釋放

大多數記憶體管理的問題都在這個階段。在這裏最艱難的任務是找到「哪些被分配的記憶體確實已經不再需要了」。它往往要求開發人員來確定在程式中哪一塊記憶體不再需要並且釋放它。

高階語言直譯器嵌入了「垃圾回收器」,它的主要工作是跟蹤記憶體的分配和使用,以便當分配的記憶體不再使用時,自動釋放它。這隻能是一個近似的過程,因爲要知道是否仍然需要某塊記憶體是無法判定的(無法通過某種演算法解決)。

垃圾回收(Garbage collection)

如上文所述自動尋找是否一些記憶體「不再需要」的問題是無法判定的。因此,垃圾回收實現只能有限制的解決一般問題。本節將解釋必要的概念,瞭解主要的垃圾回收演算法和它們的侷限性。

參照

垃圾回收演算法主要依賴於參照的概念。在記憶體管理的環境中,一個物件如果有存取另一個物件的許可權(隱式或者顯式),叫做一個物件參照另一個物件。例如,一個 Javascript 物件具有對它原型的參照(隱式參照)和對它屬性的參照(顯式參照)。

在這裏,「物件」的概念不僅特指 JavaScript 物件,還包括函數作用域(或者全域性詞法作用域)。

可達物件

  • 可以存取到的物件就是可達物件(參照、作用域鏈)
  • 可達的標準就是從根出發是否能夠被找到
  • JavaScript 中的根就是可以理解爲是全域性變數物件

參照計數垃圾收集

這是最初級的垃圾收集演算法。此演算法把「物件是否不再需要」簡化定義爲「物件有沒有其他物件參照到它」。如果沒有參照指向該物件(零參照),物件將被垃圾回收機制 機製回收。

實現原理

每個物件在建立的時候,就給這個物件系結一個計數器。每當有一個參照指向該物件時,計數器加一;每當有一個指向它的參照被刪除時,計數器減一。這樣,當沒有參照指向該物件時,該物件死亡,計數器爲 0,這時就應該對這個物件進行垃圾回收操作。

let o = {
  a: {
    b: 2,
  },
};
// 兩個物件被建立,一個作爲另一個的屬性被參照,另一個被分配給變數o
// 很顯然,沒有一個可以被垃圾收集

let o2 = o; // o2變數是第二個對「這個物件」的參照

o = 1; // 現在,「這個物件」只有一個o2變數的參照了,「這個物件」的原始參照o已經沒有

let oa = o2.a; // 參照「這個物件」的a屬性
// 現在,「這個物件」有兩個參照了,一個是o2,一個是oa

o2 = 'yo'; // 雖然最初的物件現在已經是零參照了,可以被垃圾回收了
// 但是它的屬性a的物件還在被oa參照,所以還不能回收

oa = null; // a屬性的那個物件現在也是零參照了
// 它可以被垃圾回收了

限制:回圈參照

該演算法有個限制:無法處理回圈參照的事例。在下面 下麪的例子中,兩個物件被建立,並互相參照,形成了一個回圈。它們被呼叫之後會離開函數作用域,所以它們已經沒有用了,可以被回收了。然而,參照計數演算法考慮到它們互相都有至少一次參照,所以它們不會被回收。

function f() {
  let o = {};
  let o2 = {};
  o.a = o2; // o 參照 o2
  o2.a = o; // o2 參照 o

  return 'heath';
}

f();

實際例子

IE 6, 7 使用參照計數方式對 DOM 物件進行垃圾回收。該方式常常造成物件被回圈參照時記憶體發生泄漏:

let div;
window.onload = function () {
  div = document.getElementById('myDivElement');
  div.circularReference = div;
  div.lotsOfData = new Array(10000).join('*');
};

在上面的例子裡,myDivElement 這個 DOM 元素裡的 circularReference 屬性參照了 myDivElement,造成了回圈參照。如果該屬性沒有顯示移除或者設爲 null,參照計數式垃圾收集器將總是且至少有一個參照,並將一直保持在記憶體裡的 DOM 元素,即使其從 DOM 樹中刪去了。如果這個 DOM 元素擁有大量的數據 (如上的 lotsOfData 屬性),而這個數據佔用的記憶體將永遠不會被釋放。

參照計數優缺點

優點

  • 發現垃圾時,立即回收
  • 最大限度減少程式暫停

缺點

  • 無法回收回圈參照的物件
  • 時間開銷大

標記-清除演算法

這個演算法把「物件是否不再需要」簡化定義爲「物件是否可以獲得」。

這個演算法假定設定一個叫做根(root)的物件(在 Javascript 裡,根是全域性物件)。垃圾回收器將定期從根開始,找所有從根開始參照的物件,然後找這些物件參照的物件……從根開始,垃圾回收器將找到所有可以獲得的物件和收集所有不能獲得的物件。

這個演算法比前一個要好,因爲「有零參照的物件」總是不可獲得的,但是相反卻不一定,參考「回圈參照」。

從 2012 年起,所有現代瀏覽器都使用了標記-清除垃圾回收演算法。所有對 JavaScript 垃圾回收演算法的改進都是基於標記-清除演算法的改進,並沒有改進標記-清除演算法本身和它對「物件是否不再需要」的簡化定義。

實現原理

  • 核心思想:分標記和清除二個階段完成
  • 遍歷所有物件並標記活動物件
  • 遍歷所有物件清除沒有標記的物件
  • 回收相應的空間

note

回圈參照不再是問題了

在上面的範例中,函數呼叫返回之後,兩個物件從全域性物件出發無法獲取。因此,他們將會被垃圾回收器回收。第二個範例同樣,一旦 div 和其事件處理無法從根獲取到,他們將會被垃圾回收器回收。

限制: 那些無法從根物件查詢到的物件都將被清除

儘管這是一個限制,但實踐中我們很少會碰到類似的情況,所以開發者不太會去關心垃圾回收機制 機製。

標記-清除演算法缺點

容易產生記憶體碎片化空間

  • 標記演算法並未在清除未標記物件的時候,進行整理,所以在清除標記後,會產生大量的不連續的記憶體碎片。

  • 當分配的記憶體大於現有連續的記憶體碎片,則會提前觸發新一輪的垃圾回收動作;

  • 當分配的記憶體小於現有連續的記憶體碎片,則可能會造成浪費。

note

標記整理演算法

實現原理

  • 標記整理可以看做是標記清除的增強
  • 標記階段的操作和標記清除一致
  • 清除階段會先執行整理,移動物件位置

note

V8(JavaScript 執行引擎)

  • V8 引擎是一個 JavaScript 引擎實現,最初由一些語言方面專家設計,後被谷歌收購,隨後谷歌對其進行了開源。
  • V8 使用 C++開發,在執行 JavaScript 之前,相比其它的 JavaScript 的引擎轉換成位元組碼或解釋執行,V8 將其編譯成原生機器碼(IA-32, x86-64, ARM, or MIPS CPUs),並且使用瞭如內聯快取(inline caching)等方法來提高效能。
  • 有了這些功能,JavaScript 程式在 V8 引擎下的執行速度媲美二進制程式。
  • V8 支援衆多操作系統,如 windows、linux、android 等,也支援其他硬體架構,如 IA32,X64,ARM 等,具有很好的可移植和跨平臺特性。

記憶體管理

V8 記憶體限制

限制大小

64 位爲 1.4GB,32 位爲 0.7GB

限制原因

V8 之所以限制了記憶體的大小,表面上的原因是 V8 最初是作爲瀏覽器的 JavaScript 引擎而設計,不太可能遇到大量記憶體的場景,而深層次的原因則是由於 V8 的垃圾回收機制 機製的限制。由於 V8 需要保證 JavaScript 應用邏輯與垃圾回收器所看到的不一樣,V8 在執行垃圾回收時會阻塞 JavaScript 應用邏輯,直到垃圾回收結束再重新執行 JavaScript 應用邏輯,這種行爲被稱爲「全停頓」(stop-the-world)。若 V8 的堆記憶體爲 1.5GB,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收甚至要 1 秒以上。這樣瀏覽器將在 1s 內失去對使用者的響應,造成假死現象。如果有動畫效果的話,動畫的展現也將顯著受到影響。

V8 垃圾回收策略

  • 採用分代回收的思想
  • 記憶體分爲新生代、老生代
  • 針對新、老生代採用不同演算法來提升垃圾回收的效率

新生代的物件爲存活時間較短的物件,老生代中的物件爲存活時間較長或常駐記憶體的物件。

V8 新生代、老生代記憶體大小

V8 引擎的新生代記憶體大小 32MB(64 位)、16MB(32 位),老生代記憶體大小爲 1400MB(64 位)、700MB( 32 位)。

新生代物件回收實現

  • 回收過程採用複製演算法+標記整理
  • 新生代記憶體區被等分爲兩個空間
  • 使用空間爲 From,空閒空間爲 To
  • 標記整理後將活動物件拷貝至 To
  • From 和 To 交換空間完成釋放

note

晉升

將新生代物件移到老生代

晉升條件

  • 一輪 GC 還存活的新生代需要晉升
  • 物件從 From 空間複製到 To 空間時,如果 To 空間已經被使用了超過 25%,那麼這個物件直接被複制到老生代

老生代物件回收實現

  • 主要採取標記清除、標記整理、增量標記演算法
  • 首先使用標記清除完成垃圾空間的回收
  • 採用標記整理進行空間優化
  • 採用增量標記進行效率優化

細節對比

新生代區域,採用複製演算法, 因此其每時每刻內部都有空閒空間的存在(爲了完成 From 到 To 的物件複製),但是新生代區域空間較小(32M)且被一分爲二,所以這種空間上的浪費也是比較微不足道的。

老生代因其空間較大(1.4G),如果同樣採用一分爲二的做法則對空間大小是比較浪費,且老生代空間較大,存放對物件也較多,如果進行復制演算法,則其消耗對時間也會更大。也就是是否使用複製演算法來進行垃圾回收,是一個時間 T 關於記憶體大小的關係,當記憶體大小較小時,使用複製演算法消耗的時間是比較短的,而當記憶體較大時,採用複製演算法對時間對消耗也就更大。

V8 的優化

增量標記

由於全停頓會造成了瀏覽器一段時間無響應,所以 V8 使用了一種增量標記的方式,將完整的標記拆分成很多部分,每做完一部分就停下來,讓 JS 的應用邏輯執行一會,這樣垃圾回收與應用邏輯交替完成。經過增量標記的改進後,垃圾回收的最大停頓時間可以減少到原來的 1/6 左右

note

惰性清理

由於標記完成後,所有的物件都已經被標記,不是死物件就是活物件,堆上多少空間格局已經確定。我們可以不必着急釋放那些死物件所佔用的空間,而延遲清理過程的執行。垃圾回收器可以根據需要逐一清理死物件所佔用的記憶體空間

其他

V8 後續還引入了增量式整理(incremental compaction),以及並行標記和並行清理,通過並行利用多核 CPU 來提升垃圾回收的效能

監控記憶體

記憶體問題的外在表現

  • 頁面出現延遲載入或經常性暫停: 可能存在頻繁當 GC 操作,存在一些程式碼瞬間吃滿了記憶體。
  • 頁面出現持續性的糟糕效能: 程式爲了達到最優的執行速度,向記憶體申請了一片較大的記憶體空間,但空間大小超過了裝置所能提供的大小。
  • 頁面使用隨着時間延長越來越卡: 可能存在記憶體漏失。

##界定記憶體問題的標準

  • 記憶體漏失:記憶體使用持續升高
  • 記憶體膨脹:在多數裝置上都存在效能問題
  • 頻繁垃圾回收:通過記憶體變化時序圖進行分析

監控記憶體方式

工作管理員

這裏以 Google 瀏覽器爲例,使用 Shift + Esc 喚起 Google 瀏覽器自帶的工作管理員

  • Memory(記憶體) 列表示原生記憶體。DOM 節點儲存在原生記憶體中。 如果此值正在增大,則說明正在建立 DOM 節點。
  • JavaScript Memory(JavaScript 記憶體) 列表示 JS 堆。此列包含兩個值。 您感興趣的值是實時數位(括號中的數位)。 實時數位表示您的頁面上的可到達物件正在使用的記憶體量。 如果此數位在增大,要麼是正在建立新物件,要麼是現有物件正在增長。

模擬記憶體漏失

在工作管理員裡可以看到 JavaScript 記憶體持續上升

document.body.innerHTML = `<button id="add">add</button>`;
document.getElementById('add').addEventListener('click', function (e) {
  simulateMemoryLeak();
});
let result = [];
function simulateMemoryLeak() {
  setInterval(function () {
    result.push(new Array(1000000).join('x'));
    document.body.innerHTML = result;
  }, 100);
}

Timeline 記錄記憶體

這裏以 Google 瀏覽器爲例,使用 F12 開啓調式,選擇 Performance,點選 record(錄製),進行頁面操作,點選 stop 結束錄製之後,開啓記憶體勾選,拖動截圖到指定時間段檢視發生記憶體問題時候到頁面展示,並定位問題。同時可以檢視對應出現紅點到執行指令碼,定位問題程式碼。

note

利用瀏覽器記憶體模組,查詢分離 dom

這裏以 Google 瀏覽器爲例,在頁面上進行相關操作後,使用 F12 開啓調式,選擇 Memory,點選 Take snapshot(拍照),在快照中查詢 Detached HTMLElement,回到程式碼中查詢對應的分離 dom 存在的程式碼,在相關操作程式碼之後,對分離 dom 進行釋放,防止記憶體漏失。

只有頁面的 DOM 樹或 JavaScript 程式碼不再參照 DOM 節點時,DOM 節點纔會被作爲垃圾進行回收。 如果某個節點已從 DOM 樹移除,但某些 JavaScript 仍然參照它,我們稱此節點爲「已分離」。已分離的 DOM 節點是記憶體漏失的常見原因。

模擬已分離 DOM 節點

document.body.innerHTML = `<button id="add">add</button>`;
document.getElementById('add').addEventListener('click', function (e) {
  create();
});
let detachedTree;
function create() {
  let ul = document.createElement('ul');
  for (let i = 0; i < 10; i++) {
    let li = document.createElement('li');
    ul.appendChild(li);
  }
  detachedTree = ul;
}

note

如何確定頻繁對垃圾回收

  • GC 工作時,程式是暫停的,頻繁/過長的 GC 會導致程式假死,使用者會感知到卡頓。
  • 檢視 Timeline 中是否存在記憶體走向在短時間內頻繁上升下降的區域。瀏覽器工作管理員是否頻繁的增加減少。

程式碼優化

jsPerf(JavaScript 效能測試)

基於 Benchmark.js

慎用全域性變數

  • 全域性變數定義在全域性執行的上下文,是所有作用域鏈的頂端
  • 全域性執行上下文一直存在於上下文執行棧,直到程式退出
  • 如果某個區域性作用域出現了同名變數則會遮蔽或者污染全域性作用域
  • 全域性變數的執行速度,存取速度要低於區域性變數,因此對於一些需要經常存取的全域性變數可以在區域性作用域中進行快取

note

上圖可以看出,test2 的效能要比 test1 的效能要好,從而得知,全域性變數的執行速度,存取速度要低於區域性變數

避免全域性查詢

note

上圖可以看出,test2 的效能要比 test1 的效能要好,從而得知,快取全域性變數後使用可以提升效能

通過原型物件新增附加方法提高效能

note

上圖可以看出,test2 的效能要比 test1 的效能要好,從而得知,通過原型物件新增方法與直接在物件上新增成員方法相比,原型物件上的屬性存取速度較快。

避開閉包陷阱

閉包特點

  • 外部具有指向內部的參照
  • 在「外」部作用域存取「內」部作用域的數據
function foo() {
  let name = 'heath';
  function fn() {
    console.log(name);
  }
  return fn;
}
let a = foo();
a();

閉包使用不當很容易出現記憶體漏失

function f5() {
  // el 參照了全域性變數document,假設btn節點被刪除後,因爲這裏被參照着,所以這裏不會被垃圾回收,導致記憶體漏失
  let el = document.getElementById('btn');
  el.onclick = function (e) {
    console.log(e.id);
  };
}
f5();

function f6() {
  // el 參照了全域性變數document,假設btn節點被刪除後,因爲這裏被參照着,所以這裏不會被垃圾回收,導致記憶體漏失
  let el = document.getElementById('btn');
  el.onclick = function (e) {
    console.log(e.id);
  };
  el = null; // 我們這裏手動將el記憶體釋放,從而當btn節點被刪除後,可以被垃圾回收
}
f6();

避免屬性存取方法使用

JavaScript 中的物件導向

  • JS 不需屬性的存取方法,所有屬性都是外部可見的
  • 使用屬性存取方法只會增加一層重定義,沒有存取的控制力

note

上圖可以看出,test2 的效能要比 test1 的效能要好不少,從而得知,直接存取屬性,會比通過方法存取屬性速度來的快。

遍歷速度

note

上圖可以看出,loop 遍歷速度 forEach > 優化 for > for of > for > for in

dom 節點操作

note

上圖可以看出,節點克隆(cloneNode)生成節點速度要快於建立節點。

採用字面量替換 New 操作

note

上圖可以看出,字面量宣告的數據生成速度要快於單獨屬性賦值行爲生成的數據。