JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)

tristan發表於2019-03-04

原文請查閱這裡

本系列持續更新中,Github 地址請查閱這裡

這是 JavaScript 工作原理的第三章。

我們將會討論日常使用中另一個被開發者越來越忽略的重要話題,這都是日益成熟和複雜的程式語言的鍋,即記憶體管理問題。我們將會提供在建立 SessionStack 的時候所遵循的處理 JavaScript 記憶體洩漏的幾條小技巧,因為我們需要保證 SessionStack 不會引起記憶體洩漏或者不會增加我們所整合的 web 應用程式的記憶體消耗。

概述

像 C 語言擁有底層的記憶體管理原語如 malloc()free()。開發者使用這些原語來顯式從作業系統分配和釋放記憶體。

與此同時,當建立事物(物件,字串等)的時候,JavaScript 分配記憶體並且當它們不再使用的時候 "自動釋放" 記憶體,這一過程稱為記憶體垃圾回收。這個乍看起來本質上是 "自動化釋放記憶體" 的釋放資源是引起混亂的原因,並且給予 JavaScript(及其它高階語言)開發者一個錯誤的印象即他們可以選擇忽略記憶體管理。這是一個巨大的錯誤

即使是當使用高階語言的時候,開發者也應該要理解記憶體管理(或者至少是一些基礎)。有時候自動化記憶體管理會存在一些問題(比如垃圾回收中的 bugs 或者實施的侷限性等等),為了能夠合理地處理記憶體洩漏問題(或者以最小代價和程式碼缺陷來尋找一個合適的方案),開發者就必須理解記憶體管理。

記憶體生命週期

不管你使用哪種程式語言,記憶體生命週期幾乎是一樣的:

JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)

以下是每一步生命週期所發生事情的一個概述:

  • 分配記憶體-記憶體是由作業系統分配,這樣程式就可以使用它。在底層語言(例如 C 語言),開發者可以顯式地操作記憶體。而在高階語言中,作業系統幫你處理。
  • 使用記憶體-這是程式實際使用之前分配的記憶體的階段。當你在程式碼中使用已分配的變數的時候,就會發生記憶體讀寫的操作。
  • 釋放記憶體-該階段你可以釋放你不再使用的整塊記憶體,該記憶體就可以被釋放且可以被再利用。和記憶體分配操作一樣,該操作也是用底層語言顯式編寫的。

為快速瀏覽呼叫堆疊和動態記憶體管理的概念,你可以閱讀第一篇文章

啥是記憶體?

在直接跳向 JavaScript 記憶體管理之前,先來簡要地介紹一下記憶體及其工作原理。

從硬體層面看,計算機記憶體是由大量的 flip flops 所組成的(這裡大概查了下,即大量的二進位制電路所組成的)。每個 flip flop 包含少量電晶體並能夠儲存一個位元位。單個的 flip flops 可以通過一個唯一識別符號定址,所以就可以讀和覆寫它們。因此,理論上,我們可以把整個計算機記憶體看成是由一個巨大的位元位陣列所組成的,這樣就可以進行讀和寫。

作為猿類,我們並不擅長用位來進行所有的邏輯思考和計算,所以我們把位組織成一個更大的組,這樣就可以用來表示數字。8 位稱為一位元組。除了位元組還有字(16 或 32 位)。

記憶體中儲存著很多東西:

  • 所有變數及所有程式使用的其它資料。
  • 程式程式碼,包括作業系統的程式碼。

編譯器和作業系統一起協作來為你進行記憶體管理,但是建議你瞭解一下底層是如何實現的。

當編譯程式碼的時候,編譯器會檢查原始資料型別並提前計算出程式執行所需要的記憶體大小。在所謂的靜態堆疊空間中,所需的記憶體大小會被分配給程式。這些變數所分配到的記憶體所在的空間之所以被稱為靜態記憶體空間是因為當呼叫函式的時候,函式所需的記憶體會被新增到現存記憶體的頂部。當函式中斷,它們被以 LIFO(後進先出) 的順序移出記憶體。比如,考慮如下程式碼:

int n; // 4 位元組
int x[4]; // 4 個元素的陣列,每個陣列元素 4 個位元組
double m; // 8 位元組
複製程式碼

編譯器會立即計算出程式碼所需的記憶體:4 + 4 x 4 + 8 = 28 位元組。

編譯器是這樣處理當前整數和浮點數的大小的。大約 20 年前,整數一般是 2 位元組而 浮點數是 4 位元組。程式碼不用依賴於當前基礎資料型別的位元組大小。

編譯器會插入標記,標記會和作業系統協商從堆疊中獲取所需要的記憶體大小,以便在堆疊中儲存變數。

在以上示例中,編譯知道每個變數的準確記憶體地址。事實上,當你編寫變數 n 的時候,會在內部把它轉換為類似 "記憶體地址 412763" 的樣子。

注意到這裡當我們試圖訪問 x[4] 時候,將會訪問到 m 相關的資料。這是因為我們訪問了陣列中不存在的陣列元素-它超過了最後一個實際分配到記憶體的陣列元素 x[3] 4 位元組,並且有可能會讀取(或者覆寫) m 的位。這幾乎可以確定會產生其它程式所預料不到的後果。

JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)

當函式呼叫其它函式的時候,各個函式都會在被呼叫的時候取得其在堆疊中的各自分片記憶體地址。函式會把儲存它所有的本地變數,但也會有一個程式計數器用來記住函式在其執行環境中的地址。當函式執行結束時,其記憶體塊可以再次被用作其它用途。

動態記憶體分配

不幸的是,想要知道編譯時一個變數需要多少記憶體並沒有想象中那般容易。設想一下若要做類似如下事情:

int n = readInput(); // 從使用者讀取資訊
...
// 建立一個含有 n 個元素的陣列
複製程式碼

這裡,編譯器並不知道編譯時陣列需要多少記憶體,因為這是由使用者輸入的陣列元素的值所決定的。

因此,就不能夠在堆疊中為變數分配記憶體空間。相反,程式需要在執行時顯式地從作業系統分配到正確的記憶體空間。這裡的記憶體是由動態記憶體空間所分配的。靜態和動態記憶體分配的差異總結如下圖表:

JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)

*靜態和動態分配記憶體的區別*

為了完全理解動態記憶體分配的工作原理,我們需要花點時間瞭解指標,這個就可能有點跑題了 ^.^。如果你對指標感興趣,請留言,然後我們將會在以後的章節中討論更多關於指標的內容。

JavaScript 中的記憶體分配

現在,我們將會介紹在 JavaScript 中是如何分配記憶體的((第一步)。

JavaScript 通過宣告變數值,自己處理記憶體分配工作而不需要開發者干涉。

var n = 374; // 為數字分配記憶體
var s = 'sessionstack'; // 為字串分配記憶體

var o = {
  a: 1,
  b: null
}; // 為物件及其值分配記憶體

var a = [1, null, 'str']; // (類似物件)為陣列及其陣列元素值分配記憶體

function f(a) {
  return a + 3;
} // 分配一個函式(一個可呼叫物件)

// 函式表示式也分配一個物件
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);
複製程式碼

一些函式呼叫也會分配一個物件:

var d = new Date(); // 分配一個日期物件

var e = document.createElement('div'); // 分配一個 DOM 元素
複製程式碼

可以分配值或物件的方法:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 為一個新字串
// 因為字串是不可變的,所以 JavaScript 可能會選擇不分配記憶體而只是儲存陣列 [0, 3] 的記憶體地址範圍。
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
// 包含 4 個元素的新陣列由 a1 和 a2 陣列元素所組成
複製程式碼

JavaScript 中的記憶體使用

JavaScript 中使用分配的記憶體主要指的是記憶體讀寫。

可以通過為變數或者物件屬性賦值,亦或是為函式傳參來使用記憶體。

釋放不再使用的記憶體

大多數的記憶體管理問題是出現在這一階段。

痛點在於檢測出何時分配的記憶體是閒置的。它經常會要求開發者來決定程式中的這段記憶體是否已經不再使用,然後釋放它。

高階程式語言整合了一塊稱為垃圾回收器的軟體,該軟體的工作就是追蹤記憶體分配和使用情況以便找出並自動釋放閒置的分配記憶體片段。

不幸的是,這是個近似的過程,因為判定一些記憶體片段是否閒置的普遍問題在於其不可判定性(不能為演算法所解決)。

大多數的垃圾回收器會收集那些不再被訪問的記憶體,比如引用該記憶體的所有變數超出了記憶體定址範圍。然而還是會有低於近似值的記憶體空間被收集,因為在任何情況下仍然可能會有變數在記憶體定址範圍內引用該記憶體地址,即使該記憶體是閒置的。

記憶體垃圾回收

由於找出 "不再使用" 的記憶體的不可判定性,針對這一普遍問題,垃圾回收實現了一個有限的解決方案。本小節將會闡述必要的觀點來理解主要的記憶體垃圾回收演算法及其侷限性。

記憶體引用

引用是記憶體垃圾回收演算法所依賴的主要概念之一。

在記憶體管理上下文中,如果物件 A 訪問了另一個物件 B 表示 A 引用了物件 B(可以隱式或顯式)。舉個栗子,一個 JavaScript 物件有引用了它的原型(隱式引用)和它的屬性值(顯式引用)。

在這個上下文中,"物件" 的概念被擴充超過了一般的 JavaScript 物件並且包含函式作用域(或者全域性詞法作用域)。

詞法作用域定義瞭如何在巢狀函式中解析變數名。即使父函式已經返回,內部的函式仍然會包含父函式的作用域。

垃圾回收引用計數

這是最簡單的記憶體垃圾回收演算法。當一個物件被 0 引用,會被標記為 "可回收記憶體垃圾"。

看下如下程式碼:

var o1 = {
  o2: {
    x: 1
  }
};

// 建立兩個物件。
// 'o1' 引用物件 'o2' 作為其屬性。全部都是不可回收的。

// 'o3' 是第二個引用 'o1' 物件的變數
var o3 = o1;

o1 = 1; // 現在,原先在 'o1' 中的物件只有一個單一的引用,以變數 'o3' 來表示

// 引用物件的 'o2' 屬性。
// 該物件有兩個引用:一個是作為屬性,另一個是 'o4' 變數
var o4 = o3.o2;

// 'o1' 物件現在只有 0 引用,它可以被作為記憶體垃圾回收。
// 然而,其 'o2' 屬性仍然被變數 'o4' 所引用,所以它的記憶體不能夠被釋放。
o3 = '374';

o4 = null;
// 'o1' 中的 'o2' 屬性現在只有 0 引用了。所以 'o1' 物件可以被回收。
複製程式碼

迴圈引用是個麻煩事

迴圈引用會造成限制。在以下的示例中,建立了兩個互相引用的物件,這樣就會造成迴圈引用。函式呼叫之後他們將會超出範圍,所以,實際上它們是無用且可以釋放對他們的引用。然而,引用計數演算法會認為由於兩個物件都至少互相引用一次,所以他們都不可回收的。

function f() {
  var o1 = {};
  var o2 = {};
  o1.P = O2; // O1 引用 o2
  o2.p = o1; // o2 引用 o1. 這就造成迴圈引用
}

f();
複製程式碼

JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)

標記-清除演算法

為了判斷是否需要釋放對物件的引用,演算法會確定該物件是否可獲得。

標記-清除演算法包含三個步驟:

  • 根:一般來說,根指的是程式碼中引用的全域性變數。就拿 JavaScript 來說,window 物件即是根的全域性變數。Node.js 中相對應的變數為 "global"。垃圾回收器會構建出一份所有根變數的完整列表。
  • 隨後,演算法會檢測所有的根變數及他們的後代變數並標記它們為啟用狀態(表示它們不可回收)。任何根變數所到達不了的變數(或者物件等等)都會被標記為記憶體垃圾。
  • 最後,垃圾回收器會釋放所有非啟用狀態的記憶體片段然後返還給作業系統。

JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)

標記-清除演算法的動態圖示

該演算法比之前的演算法要好,因為物件零引用可以讓物件不可獲得。反之則不然,正如之前所看到的迴圈引用。

從 2012 年起,所有的現代瀏覽器都內建了一個標記-清除垃圾回收器。前些年所有對於 JavaScript 記憶體垃圾收集(分代/增量/併發/並行 垃圾收集)的優化都是針對標記-清除演算法的實現的優化,但既沒有提升垃圾收集演算法本身,也沒有提升判定物件是否可獲得的能力。

你可以檢視這篇文章 來了解追蹤記憶體垃圾回收的詳情及包含優化了的標記-清除演算法。

迴圈引用不再讓人蛋疼

在之前的第一個示例中,當函式返回,全域性物件不再引用這兩個物件。結果,記憶體垃圾回收器發現它們是不可獲得的。

JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)

即使兩個物件互相引用,也不能夠從根變數獲得他們。

記憶體垃圾回收器的反直觀行為

雖然記憶體垃圾回收器很方便,但是它們也有其一系列的代價。其中之一便是不確定性。意思即記憶體垃圾回收具有不可預見性。你不能確定記憶體垃圾收集的確切時機。這意味著在某些情況下,程式會使用比實際需要更多的記憶體。在其它情況下,在特定的互動敏感的程式中,你也許需要注意那些記憶體垃圾收集短暫停時間。雖然不確定性意味著不能夠確定什麼時候可以進行記憶體垃圾收集,但是大多數 GC 的實現都是在記憶體分配期間進行記憶體垃圾回收的一般模式。如果沒有進行記憶體分配,大多數的記憶體垃圾回收就會保持閒置狀態。考慮以下情況:

  • 分配一段固定大小的記憶體。
  • 大多數的元素(或所有)被標記為不可獲得(假設我們賦值我們不再需要的快取為 null )
  • 不再分配其它記憶體。

在該情況下,大多數的記憶體垃圾回收器不會再執行任何的記憶體垃圾回收。換句話說,即使可以對該不可獲得的引用進行垃圾回收,但是記憶體收集器不會進行標記。雖然這不是嚴格意義上的記憶體洩漏,但是這會導致高於平常的記憶體使用率。

記憶體洩漏是啥?

正如記憶體管理所說的那樣,記憶體洩漏即一些程式在過去時使用但處於閒置狀態,卻沒有返回給作業系統或者可用的記憶體池。

JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)

程式語言喜歡多種記憶體管理方法。然而,某個記憶體片段是否被使用是一個不確定的問題。換句話說,只有開發人員清楚某個記憶體片段是否可以返回給作業系統。

某些程式語言會為開發者提供功能函式來解決這個問題。其它的程式語言完全依賴於開發者全權掌控哪個記憶體片段是可回收的。維其百科上有關於手動自動記憶體管理的好文章。

四種常見的 JavaScript 記憶體洩漏

1: 全域性變數

JavaScript 以一種有趣的方式來處理未宣告變數:當引用一個未宣告的變數,會在全域性物件上建立一個新的變數。在瀏覽器中,全域性物件是 window,這意味著如下程式碼:

function foo(arg) {
  bar = "some text";
}
複製程式碼

等同於:

function foo(arg) {
  window.bar = "some text";
}
複製程式碼

變數 bar 本意是隻能在 foo 函式中被引用。但是如果你沒有用 var 來宣告變數,那麼將會建立一個多餘的全域性變數。在上面的例子中,並不會造成大的事故。但你可以很自然地想象一個更具破壞性的場景。

你也可以使用 this 關鍵字不經意地建立一個全域性變數。

function foo() {
  this.var1 = "potential accidental global";
}

// 呼叫 foo 函式自身,this 會指向全域性物件(window)而不是未定義
複製程式碼

你可以通過在 JavaScript 檔案的頂部新增 'use strict' 來避免以上的所有問題,'use strict' 會切換到更加嚴格的 JavaScript 解析模式,這樣就可以防止建立意外的全域性變數。

意外的全域性變數的確是個問題,而程式碼經常會被顯式定義的全域性變數所汙染,根據定義這些全域性變數是不會被記憶體垃圾回收器所收集的。你需要特別注意的是使用全域性變數來臨時儲存和處理大型的位資訊。只有在必要的時候使用全域性變數來儲存資料,記得一旦你不再使用的時候,把它賦值為 null 或者對其再分配。

2:定時器及被遺忘的回撥函式

因為經常在 JavaScript 中使用 setInterval,所以讓我們以它為例。

框架中提供了觀察者和接受回撥的其它指令通常會確保當他們的例項不可獲得的時候,所有對回撥的引用都會變成不可獲得。很容易找到如下程式碼:

var serverData = loadData();
setInterval(function() {
  var renderer = document.getElementById('renderer');
  if (renderer) {
    renderer.innerHTML = JSON.stringify(serverData);
  }
}, 5000); // 這將會每隔大約 5 秒鐘執行一次
複製程式碼

以上程式碼片段展示了使用定時器來引用不再需要的節點或資料的後果。

renderer 物件會在某些時候被替換或移除,這樣就會導致由定時處理程式封裝的程式碼變得冗餘。當這種情況發生的時候,不管是定時處理程式還是它的依賴都不會被垃圾回收,這是由於需要先停止定時器(記住,定時器仍然處於啟用狀態)。這可以歸結為儲存和處理資料載入的 serverData 變數也不會被垃圾回收。

當使用觀察者的時候,你需要確保一旦你不再需要它們的時候顯式地移除它們(不再需要觀察者或者物件變得不可獲得)。

幸運的是,大多數現代瀏覽器都會替你進行處理:當被觀察者物件變得不可獲得時,即使你忘記移除事件監聽函式,瀏覽器也會自動回收觀察者處理程式。以前,一些老掉牙的瀏覽器處理不了這些情況(如老舊的 IE6)。

那麼,最佳實踐是當物件被廢棄的時候,移除觀察者處理程式。檢視如下例子:

var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
  counter++;
  element.innerHTML = 'text' + counter;
}

element.addEventListener('click', onClick);

// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 現在當元素超出範圍
// 即使在不能很好處理迴圈引用的瀏覽器中也會回收元素和 onClick 事件
複製程式碼

在讓一個 DOM 節點不可獲得之前,你不再需要呼叫 removeEventListener,因為現代瀏覽器支援用記憶體垃圾回收器來檢測並適當地處理 DOM 節點的生命週期。

如果你使用 jQuery API(其它的庫和框架也支援的 API),你可以在廢棄節點之前移除事件監聽函式。jQuery 也會確保即使在老舊的瀏覽器之中,也不會產生記憶體洩漏。

閉包

閉包是 JavaScript 的一個重要功能:巢狀函式可以訪問外部(封閉)函式的變數。鑑於 JavaScript 執行時的實現細節,以下方法可能會造成記憶體洩漏:

var theThing = null

var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // 引用 originalThing
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);
複製程式碼

當呼叫 replaceThing 的時候,theThing 物件由一個大陣列和新的閉包(someMethod)所組成。而 originalThingunused 變數建立的閉包所引用(即引用 replaceThing 函式之前的 theThing 變數)。需要記住的是當一旦為同一個父作用域中的閉包建立閉包作用域的時候,該閉包作用域是共享的。

在這樣的情況下,閉包 someMethodunused 共享相同的作用域。unused 引用了 origintalThing。即使 unused 永不使用,也可以在 replaceThing 的作用域外使用 someMethod 函式。然後由於 someMethodunused 共享相同的閉包作用域,unused 變數引用 originalThing 會強迫 unused 保持啟用狀態(兩個閉包共享作用域)。這會阻止記憶體垃圾回收。

在以上例子中,閉包 someMethodunused 共享作用域,而 unused 引用 origintalThing。可以在 replaceThing 作用域外通過 theThing 使用 someMethod,即使 unused 從未被使用。事實上,由於 someMethodunused 共享閉包作用域,unused 引用 origintalThing 要求 unused 保持啟用狀態。

所有的這些行為會導致記憶體洩漏。當你不斷地執行如上程式碼片段,你將會發現飆升的記憶體使用率。當記憶體垃圾回收器執行的時候,這些記憶體使用率不會下降。這裡會建立出一份閉包連結串列(當前情況下,其根變數是 theThing),每個閉包作用域都間接引用了大陣列。

該問題是由 Metor 小組發現的並且他們寫了一篇很好的文章來詳細描述該問題。

4: 源自 DOM 引用

有時候,開發者會在資料結構中儲存 DOM 節點。

假設你想要快速更新幾行表格內容。如果你在一個字典或者陣列中儲存對每個表格行的引用,這將會造成重複引用相同的 DOM 元素:一個在 DOM 樹中而另一個在字典中。如果你想要釋放對這些表格行的引用,你需要記得讓這些引用變成不可獲得。

var elements = {
  button: document.getElementById('button'),
  image: document.getElementById('image')
};

function doStuff() {
  elements.image.src = 'http://example.com/image_name.png';
}

function removeImage() {
    // image 元素是 body 元素的直系後代元素
    document.body.removeChild(document.getElementById('image'));
    // 這時,我們仍然在 elements 全域性物件中引用了 #button 元素
    // 換句話說,按鈕元素仍然在記憶體中且不能夠被垃圾回收器收集
}
複製程式碼

你還需要額外考慮的情況是引用 DOM 樹中的內節點或者葉節點。如果你在程式碼中儲存著對一個單元格的引用,這時候當你決定從 DOM 中移除表格,卻仍然會保持對該單元格的引用,這就會導致大量的記憶體洩漏。你可以認為記憶體垃圾回收器將會釋放除了該單元格以外的記憶體。而這還沒完。因為單元格是表格的一個後代元素而後代元素儲存著對其父節點的引用,對一個單元格的引用會導致無法釋放整個表格所佔用的記憶體

記憶體管理心得

以下內容為個人原創分享。By 三月

指導思想

儘可能減少記憶體佔用,儘可能減少 GC。

  • 減少 GC 次數

    瀏覽器會不定時回收垃圾記憶體,稱為 GC,不定時觸發,一般在向瀏覽器申請新記憶體時,瀏覽器會檢測是否到達一個臨界值再進行觸發。一般來說,GC 會較為耗時,GC 觸發時可能會導致頁面卡頓及丟幀。故我們要儘可能避免GC的觸發。GC 無法通過程式碼觸發,但部分瀏覽器如 Chrome,可在 DevTools -> TimeLine 頁面手動點選 CollectGarbage 按鈕觸發 GC。

  • 減少記憶體佔用

    降低記憶體佔用,可避免記憶體佔用過多導致的應用/系統卡頓,App 閃退等,在移動端尤為明顯。當記憶體消耗較多時,瀏覽器可能會頻繁觸發 GC。而如前所述,GC 發生在申請新記憶體時,若能避免申請新記憶體,則可避免GC 觸發。

優化方案

使用物件池

物件池**(英語:object pool pattern)是一種設計模式。**一個物件池包含一組已經初始化過且可以使用的物件,而可以在有需求時建立和銷燬物件。池的使用者可以從池子中取得物件,對其進行操作處理,並在不需要時歸還給池子而非直接銷燬它。這是一種特殊的工廠物件。

若初始化、例項化的代價高,且有需求需要經常例項化,但每次例項化的數量較少的情況下,使用物件池可以獲得顯著的效能提升。從池子中取得物件的時間是可預測的,但新建一個例項所需的時間是不確定。

以上摘自維基百科。

使用物件池技術能顯著優化需頻繁建立物件時的記憶體消耗,但建議按不同使用場景做以下細微優化。

  1. 按需建立

    預設建立空物件池,按需建立物件,用完歸還池子。

  2. 預建立物件

    如在高頻操作下,如滾動事件、TouchMove事件、resize事件、for 迴圈內部等頻繁建立物件,則可能會觸發GC的發生。故在特殊情況下,可優化為提前建立物件放入池子。

    高頻情況下,建議使用截流/防抖及任務佇列相關技術。

  3. 定時釋放

    物件池內的物件不會被垃圾回收,若極端情況下建立了大量物件回收進池子卻不釋放只會適得其反。

    故池子需設計定時/定量釋放物件機制,如以已用容量/最大容量/池子使用時間等引數來定時釋放物件。

其他優化tips

  1. 儘可能避免建立物件,非必要情況下避免呼叫會建立物件的方法,如 Array.sliceArray.mapArray.filter、字串相加、$('div')ArrayBuffer.slice 等。

  2. 不再使用的物件,手動賦為 null,可避免迴圈引用等問題。

  3. 使用 Weakmap

  4. 生產環境勿用 console.log 大物件,包括 DOM、大陣列、ImageData、ArrayBuffer 等。因為 console.log 的物件不會被垃圾回收。詳見Will console.log prevent garbage collection?

  5. 合理設計頁面,按需建立物件/渲染頁面/載入圖片等。

    • 避免如下問題:

      • 為了省事兒,一次性請求全部資料。
      • 為了省事兒,一次性渲染全部資料,再做隱藏。
      • 為了省事兒,一次性載入/渲染全部圖片。
    • 使用重複 DOM 等,如重複使用同一個彈窗而非建立多個。

      如 Vue-Element 框架中,PopOver/Tooltip 等元件用於表格內時會建立 m * n 個例項,可優化為只建立一個例項,動態設定位置及資料。

  6. ImageData 物件是 JS 記憶體殺手,避免重複建立 ImageData 物件。

  7. 重複使用 ArrayBuffer。

  8. 壓縮圖片、按需載入圖片、按需渲染圖片,使用恰當的圖片尺寸、圖片格式,如 WebP 格式。

圖片處理優化

假設渲染一張 100KB 大小,300x500 的透明圖片,粗略的可分為三個過程:

  1. 載入圖片

    載入圖片二進位制格式到記憶體中並快取,此時消耗了100KB 記憶體 & 100KB 快取。

  2. 解碼圖片

    將二進位制格式解碼為畫素格式,此時佔用寬 * 高 * 24(透明為32位)位元大小的記憶體,即 300 * 500 * 32,約等於 585 KB,這裡約定名為畫素格式記憶體。個人猜測此時瀏覽器會回收載入圖片時建立的 100KB 記憶體。

  3. 渲染圖片

    通過 CPU 或 GPU 渲染圖片,若為 GPU 渲染,則還需上傳到 GPU 視訊記憶體,該過程較為耗時,由圖片尺寸 / 視訊記憶體位寬決定,圖片尺寸越大,上傳時間越慢,佔用視訊記憶體越多。

其中,較舊的瀏覽器如Firefox回收畫素記憶體時機較晚,若渲染了大量圖片時會記憶體佔用過高。

PS:瀏覽器會複用同一份圖片二進位制記憶體及畫素格式記憶體,瀏覽器渲染圖片會按以下順序去獲取資料:

視訊記憶體 >> 畫素格式記憶體 >> 二進位制記憶體 >> 快取 >> 從伺服器獲取。我們需控制和優化的是二進位制記憶體及畫素記憶體的大小及回收。

總結一下,瀏覽器渲染圖片時所消耗記憶體由圖片檔案大小記憶體、寬高、透明度等所決定,故建議:

  1. 使用 CSS3、SVG、IconFont、Canvas 替代圖片。展示大量圖片的頁面,建議使用 Canvas 渲染而非直接使用img標籤。具體詳見 Javascript的Image物件、影象渲染與瀏覽器記憶體兩三事

  2. 適當壓縮圖片,可減小頻寬消耗及圖片記憶體佔用。

  3. 使用恰當的圖片尺寸,即響應式圖片,為不同終端輸出不同尺寸圖片,勿使用原圖縮小代替 ICON 等,比如一些圖片服務如 OSS。

  4. 使用恰當的圖片格式,如使用WebP格式等。詳細圖片格式對比,使用場景等建議檢視web前端圖片極限優化策略

  5. 按需載入及按需渲染圖片。

  6. 預載入圖片時,切記要將 img 物件賦為 null,否則會導致圖片記憶體無法釋放。

    當實際渲染圖片時,瀏覽器會從快取中再次讀取。

  7. 將離屏 img 物件賦為 null,src 賦為 null,督促瀏覽器及時回收記憶體及畫素格式記憶體。

  8. 將非可視區域圖片移除,需要時再次渲染。和按需渲染結合時實現很簡單,切換 src 與 v-src 即可。

參考連結:

garbage-collector-friendly-code/

移動 WEB 通用優化策略介紹(二)

H5前端效能優化高階進階

Javascript的Image物件、影象渲染與瀏覽器記憶體兩三事

web前端圖片極限優化策略

MDN Weakmap

函式節流、函式防抖實現原理分析

a-tour-of-v8-garbage-collection

打個廣告 ^.^

今日頭條招人啦!傳送簡歷到 likun.liyuk@bytedance.com ,即可走快速內推通道,長期有效!國際化PGC部門的JD如下:c.xiumi.us/board/v5/2H…,也可內推其他部門!

本系列持續更新中,Github 地址請查閱這裡

相關文章