JavaScript 記憶體詳解 & 分析指南

前言

JavaScript 誕生於 1995 年,最初被設計用於網頁內的表單驗證。

這些年來 JavaScript 成長飛速,生態圈日益壯大,成為了最受程式設計師歡迎的開發語言之一。並且現在的 JavaScript 不再局限於網頁端,已經擴展到了桌面端、移動端以及服務端。

隨著大前端時代的到來,使用 JavaScript 的開發者越來越多,但是許多開發者都只停留在「會用」這個層面,而對於這門語言並沒有更多的了解。

如果想要成為一名更好的 JavaScript 開發者,理解記憶體是一個不可忽略的關鍵點。

📖 本文主要包含兩大部分:

  1. JavaScript 記憶體詳解
  2. JavaScript 記憶體分析指南

看完這篇文章後,相信你會對 JavaScript 的記憶體有比較全面的了解,並且能夠擁有獨自進行記憶體分析的能力。

🧐 話不多說,我們開始吧!

文章篇幅較長,除去程式碼也有 12000 字左右,需要一定的時間來閱讀,但是我保證你所花費的時間都是值得的。


正文

記憶體(memory)

什麼是記憶體(What is memory)

相信大家都對記憶體有一定的了解,我就不從盤古開天闢地開始講了,稍微提一下。

首先,任何應用程式想要運行都離不開記憶體。

另外,我們提到的記憶體在不同的層面上有著不同的含義。

💻 硬體層面(Hardware)

在硬體層面上,記憶體指的是隨機存取存儲器。

記憶體是電腦重要組成部分,用來儲存應用運行所需要的各種數據,CPU 能夠直接與記憶體交換數據,保證應用能夠流暢運行。

一般來說,在電腦的組成中主要有兩種隨機存取存儲器:高速快取(Cache)和主存儲器(Main memory)。

高速快取通常直接集成在 CPU 內部,離我們比較遠,所以更多時候我們提到的(硬體)記憶體都是主存儲器。

💡 隨機存取存儲器(Random Access Memory,RAM)

隨機存取存儲器分為靜態隨機存取存儲器(Static Random Access Memory,SRAM)和動態隨機存取存儲器(Dynamic Random Access Memory,DRAM)兩大類。

在速度上 SRAM 要遠快於 DRAM,而 SRAM 的速度僅次於 CPU 內部的暫存器。

在現代電腦中,高速快取使用的是 SRAM,而主存儲器使用的是 DRAM。

💡 主存儲器(Main memory,主存)

雖然高速快取的速度很快,但是其存儲容量很小,小到幾 KB 最大也才幾十 MB,根本不足以儲存應用運行的數據。

我們需要一種存儲容量與速度適中的存儲部件,讓我們在保證性能的情況下,能夠同時運行幾十甚至上百個應用,這也就是主存的作用。

電腦中的主存其實就是我們平時說的記憶體條(硬體)。

硬體記憶體不是我們今天的主題,所以就說這麼多,想要深入了解的話可以根據上面提到關鍵詞進行搜索。

🧩 軟體層面(Software)

在軟體層面上,記憶體通常指的是作業系統從主存中劃分(抽象)出來的記憶體空間。

此時記憶體又可以分為兩類:棧記憶體和堆記憶體。

接下來我將圍繞 JavaScript 這門語言來對記憶體進行講解。

在後面的文章中所提到的記憶體均指軟體層面上的記憶體。

棧與堆(Stack & Heap)

棧記憶體(Stack memory)

💡 棧(Stack)

棧是一種常見的數據結構,棧只允許在結構的一端操作數據,所有數據都遵循後進先出(Last-In First-Out,LIFO)的原則。

現實生活中最貼切的的例子就是羽毛球桶,通常我們只通過球桶的一側來進行存取,最先放進去的羽毛球只能最後被取出,而最後放進去的則會最先被取出。

棧記憶體之所以叫做棧記憶體,是因為棧記憶體使用了棧的結構。

棧記憶體是一段連續的記憶體空間,得益於棧結構的簡單直接,棧記憶體的訪問和操作速度都非常快。

棧記憶體的容量較小,主要用於存放函數調用資訊和變數等數據,大量的記憶體分配操作會導致棧溢出(Stack overflow)。

棧記憶體的數據儲存基本都是臨時性的,數據會在使用完之後立即被回收(如函數內創建的局部變數在函數返回後就會被回收)。

簡單來說:棧記憶體適合存放生命周期短、佔用空間小且固定的數據。

棧記憶體

💡 棧記憶體的大小

棧記憶體由作業系統直接管理,所以棧記憶體的大小也由作業系統決定。

通常來說,每一條執行緒(Thread)都會有獨立的棧記憶體空間,Windows 給每條執行緒分配的棧記憶體默認大小為 1MB。

堆記憶體(Heap memory)

💡 堆(Heap)

堆也是一種常見的數據結構,但是不在本文討論範圍內,就不多說了。

堆記憶體雖然名字里有個「堆」字,但是它和數據結構中的堆沒半毛錢關係,就只是撞了名罷了。

堆記憶體是一大片記憶體空間,堆記憶體的分配是動態且不連續的,程式可以按需申請堆記憶體空間,但是訪問速度要比棧記憶體慢不少。

堆記憶體里的數據可以長時間存在,無用的數據需要程式主動去回收,如果大量無用數據佔用記憶體就會造成記憶體泄露(Memory leak)。

簡單來說:堆記憶體適合存放生命周期長,佔用空間較大或佔用空間不固定的數據。

堆記憶體

💡 堆記憶體的上限

在 Node.js 中,堆記憶體默認上限在 64 位系統中約為 1.4 GB,在 32 位系統中約為 0.7 GB。

而在 Chrome 瀏覽器中,每個標籤頁的記憶體上限約為 4 GB(64 位系統)和 1 GB(32 位系統)。

💡 進程、執行緒與堆記憶體

通常來說,一個進程(Process)只會有一個堆記憶體,同一進程下的多個執行緒會共享同一個堆記憶體。

在 Chrome 瀏覽器中,一般情況下每個標籤頁都有單獨的進程,不過在某些情況下也會出現多個標籤頁共享一個進程的情況。

函數調用(Function calling)

明白了棧記憶體與堆記憶體是什麼後,現在讓我們看看當一個函數被調用時,棧記憶體和堆記憶體會發生什麼變化。

當函數被調用時,會將函數推入棧記憶體中,生成一個棧幀(Stack frame),棧幀可以理解為由函數的返回地址、參數和局部變數組成的一個塊;當函數調用另一個函數時,又會將另一個函數也推入棧記憶體中,周而復始;直到最後一個函數返回,便從棧頂開始將棧記憶體中的元素逐個彈出,直到棧記憶體中不再有元素時則此次調用結束。

函數調用過程

上圖中的內容經過了簡化,剝離了棧幀和各種指針的概念,主要展示函數調用以及記憶體分配的大概過程。

在同一執行緒下(JavaScript 是單執行緒的),所有被執行的函數以及函數的參數和局部變數都會被推入到同一個棧記憶體中,這也就是大量遞歸會導致棧溢出(Stack overflow)的原因。

關於圖中涉及到的函數內部變數記憶體分配的詳情請接著往下看。

儲存變數(Store variables)

當 JavaScript 程式運行時,在非全局作用域中產生的局部變數均儲存在棧記憶體中。

但是,只有原始類型的變數是真正地把值儲存在棧記憶體中。

而引用類型的變數只在棧記憶體中儲存一個引用(reference),這個引用指向堆記憶體里的真正的值。

💡 原始類型(Primitive type)

原始類型又稱基本類型,包括 stringnumberbigintbooleanundefinednullsymbol(ES6 新增)。

原始類型的值被稱為原始值(Primitive value)。

補充:雖然 typeof null 返回的是 'object',但是 null 真的不是對象,會出現這樣的結果其實是 JavaScript 的一個 Bug~

💡 引用類型(Reference type)

除了原始類型外,其餘類型都屬於引用類型,包括 ObjectArrayFunctionDateRegExpStringNumberBoolean 等等…

實際上 Object 是最基本的引用類型,其他引用類型均繼承自 Object。也就是說,所有引用類型的值實際上都是對象。

引用類型的值被稱為引用值(Reference value)。

🎃 簡單來說

在多數情況下,原始類型的數據儲存在棧記憶體,而引用類型的數據(對象)則儲存在堆記憶體。

變數的儲存

特別注意(Attention)

全局變數以及被閉包引用的變數(即使是原始類型)均儲存在堆記憶體中。

🌐 全局變數(Global variables)

在全局作用域下創建的所有變數都會成為全局對象(如 window 對象)的屬性,也就是全局變數。

而全局對象儲存在堆記憶體中,所以全局變數必然也會儲存在堆記憶體中。

不要問我為什麼全局對象儲存在堆記憶體中,一會我翻臉了啊!

📦 閉包(Closures)

在函數(局部作用域)內創建的變數均為局部變數。

當一個局部變數被當前函數之外的其他函數所引用(也就是發生了逃逸),此時這個局部變數就不能隨著當前函數的返回而被回收,那麼這個變數就必須儲存在堆記憶體中。

而這裡的「其他函數」就是我們說的閉包,就如下面這個例子:

function getCounter() {
  let count = 0;
  function counter() {
    return ++count;
  }
  return counter;
}
// closure 是一個閉包函數
// 變數 count 發生了逃逸
let closure = getCounter();
closure(); // 1
closure(); // 2
closure(); // 3

閉包是一個非常重要且常用的概念,許多程式語言里都有閉包這個概念。這裡就不詳細介紹了,貼一篇阮一峰大佬的文章。

學習 JavaScript 閉包://www.ruanyifeng.com/blog/2009/08/learning_javascript_closures.html

💡 逃逸分析(Escape Analysis)

實際上,JavaScript 引擎會通過逃逸分析來決定變數是要儲存在棧記憶體還是堆記憶體中。

簡單來說,逃逸分析是一種用來分析變數的作用域的機制。

不可變與可變(Immutable and Mutable)

棧記憶體中會儲存兩種變數數據:原始值和對象引用。

不僅類型不同,它們在棧記憶體中的具體表現也不太一樣。

原始值(Primitive values)

🚫 Primitive values are immutable!

前面有說到:原始類型的數據(原始值)直接儲存在棧記憶體中。

當我們定義一個原始類型變數的時候,JavaScript 會在棧記憶體中激活一塊記憶體來儲存變數的值(原始值)

當我們更改原始類型變數的值時,實際上會再激活一塊新的記憶體來儲存新的值,並將變數指向新的記憶體空間,而不是改變原來那塊記憶體里的值。

當我們將一個原始類型變數賦值給另一個新的變數(也就是複製變數)時,也是會再激活一塊新的記憶體,並將源變數記憶體里的值複製一份到新的記憶體里

更改原始類型變數

🤠 總之就是:棧記憶體中的原始值一旦確定就不能被更改(不可變的)。

原始值的比較(Comparison)

當我們比較原始類型的變數時,會直接比較棧記憶體中的值,只要值相等那麼它們就相等。

let a = '123';
let b = '123';
let c = '110';
let d = 123;
console.log(a === b); // true
console.log(a === c); // false
console.log(a === d); // false
對象引用(Object references)

🧩 Object references are mutable!

前面也有說到:引用類型的變數在棧記憶體中儲存的只是一個指向堆記憶體的引用。

當我們定義一個引用類型的變數時,JavaScript 會先在堆記憶體中找到一塊合適的地方來儲存對象,並激活一塊棧記憶體來儲存對象的引用(堆記憶體地址),最後將變數指向這塊棧記憶體

💡 所以當我們通過變數訪問對象時,實際的訪問過程應該是:

變數 -> 棧記憶體中的引用 -> 堆記憶體中的值

當我們把引用類型變數賦值給另一個變數時,會將源變數指向的棧記憶體中的對象引用複製到新變數的棧記憶體中,所以實際上只是複製了個對象引用,並沒有在堆記憶體中生成一份新的對象。

而當我們給引用類型變數分配為一個新的對象時,則會直接修改變數指向的棧記憶體中的引用,新的引用指向堆記憶體中新的對象。

更改引用類型變數

🤠 總之就是:棧記憶體中的對象引用是可以被更改的(可變的)。

對象的比較(Comparison)

所有引用類型的值實際上都是對象。

當我們比較引用類型的變數時,實際上是在比較棧記憶體中的引用,只有引用相同時變數才相等。

即使是看起來完全一樣的兩個引用類型變數,只要他們的引用的不是同一個值,那麼他們就是不一樣。

// 兩個變數指向的是兩個不同的引用
// 雖然這兩個對象看起來完全一樣
// 但它們確確實實是不同的對象實例
let a = { name: 'pp' }
let b = { name: 'pp' }
console.log(a === b); // false
// 直接賦值的方式複製的是對象的引用
let c = a;
console.log(a === c); // true
對象的深拷貝(Deep copy)

當我們搞明白引用類型變數在記憶體中的表現時,就能清楚地理解為什麼淺拷貝對象是不可靠的

在淺拷貝中,簡單的賦值只會複製對象的引用,實際上新變數和源變數引用的都是同一個對象,修改時也是修改的同一個對象,這顯然不是我們想要的。

想要真正的複製一個對象,就必須新建一個對象,將源對象的屬性複製過去;如果遇到引用類型的屬性,那就再新建一個對象,繼續複製…

此時我們就需要藉助遞歸來實現多層次對象的複製,這也就是我們說的深拷貝。

對於任何引用類型的變數,都應該使用深拷貝來複制,除非你很確定你的目的就是複製一個引用。

記憶體生命周期(Memory life cycle)

通常來說,所有應用程式的記憶體生命周期都是基本一致的:

分配 -> 使用 -> 釋放

當我們使用高級語言編寫程式時,往往不會涉及到記憶體的分配與釋放操作,因為分配與釋放均已經在底層語言中實現了。

對於 JavaScript 程式來說,記憶體的分配與釋放是由 JavaScript 引擎自動完成的(目前的 JavaScript 引擎基本都是使用 C++ 或 C 編寫的)。

但是這不意味著我們就不需要在乎記憶體管理,了解記憶體的更多細節可以幫助我們寫出性能更好,穩定性更高的程式碼。

垃圾回收(Garbage collection)

垃圾回收即我們常說的 GC(Garbage collection),也就是清除記憶體中不再需要的數據,釋放記憶體空間。

由於棧記憶體由作業系統直接管理,所以當我們提到 GC 時指的都是堆記憶體的垃圾回收。

基本上現在的瀏覽器的 JavaScript 引擎(如 V8 和 SpiderMonkey)都實現了垃圾回收機制,引擎中的垃圾回收器(Garbage collector)會定期進行垃圾回收。

📢 緊急補課

在我們繼續之前,必須先了解「可達性」和「記憶體泄露」這兩個概念:

💡 可達性(Reachability)

在 JavaScript 中,可達性指的是一個變數是否能夠直接或間接通過全局對象訪問到,如果可以那麼該變數就是可達的(Reachable),否則就是不可達的(Unreachable)。

可達與不可達

上圖中的節點 9 和節點 10 均無法通過節點 1(根節點)直接或間接訪問,所以它們都是不可達的,可以被安全地回收。

💡 記憶體泄漏(Memory leak)

記憶體泄露指的是程式運行時由於某種原因未能釋放那些不再使用的記憶體,造成記憶體空間的浪費。

輕微的記憶體泄漏或許不太會對程式造成什麼影響,但是一旦泄露變嚴重,就會開始影響程式的性能,甚至導致程式的崩潰。

垃圾回收演算法(Algorithms)

垃圾回收的基本思路很簡單:確定哪個變數不會再使用,然後釋放它佔用的記憶體。

實際上,在回收過程中想要確定一個變數是否還有用並不簡單。

直到現在也還沒有一個真正完美的垃圾回收演算法,接下來介紹 3 種最廣為人知的垃圾回收演算法。

標記-清除(Mark-and-Sweep)

標記清除演算法是目前最常用的垃圾收集演算法之一。

從該演算法的名字上就可以看出,演算法的關鍵就是標記清除

標記指的是標記變數的狀態的過程,標記變數的具體方法有很多種,但是基本理念是相似的。

對於標記演算法我們不需要知道所有細節,只需明白標記的基本原理即可。

需要注意的是,這個演算法的效率不算高,同時會引起記憶體碎片化的問題。

🌰 舉個栗子

當一個變數進入執行上下文時,它就會被標記為「處於上下文中」;而當變數離開執行上下文時,則會被標記為「已離開上下文」。

💡 執行上下文(Execution context)

執行上下文是 JavaScript 中非常重要的概念,簡單來說的是程式碼執行的環境。

如果你現在對於執行上下文還不是很了解,我強烈建議你抽空專門去學習下!!!

垃圾回收器將定期掃描記憶體中的所有變數,將處於上下文中以及被處於上下文中的變數引用的變數的標記去除,將其餘變數標記為「待刪除」。

隨後,垃圾回收器會清除所有帶有「待刪除」標記的變數,並釋放它們所佔用的記憶體。

標記-整理(Mark-Compact)

準確來說,Compact 應譯為緊湊、壓縮,但是在這裡我覺得用「整理」更為貼切。

標記整理演算法也是常用的垃圾收集演算法之一。

使用標記整理演算法可以解決記憶體碎片化的問題(通過整理),提高記憶體空間的可用性。

但是,該演算法的標記階段比較耗時,可能會堵塞主執行緒,導致程式長時間處於無響應狀態。

雖然演算法的名字上只有標記和整理,但這個演算法通常有 3 個階段,即標記整理清除

🌰 以 V8 的標記整理演算法為例

首先,在標記階段,垃圾回收器會從全局對象(根)開始,一層一層往下查詢,直到標記完所有活躍的對象,那麼剩下的未被標記的對象就是不可達的了。

V8 的標記階段

然後是整理階段(碎片整理),垃圾回收器會將活躍的(被標記了的)對象往記憶體空間的一端移動,這個過程可能會改變記憶體中的對象的記憶體地址。

最後來到清除階段,垃圾回收器會將邊界後面(也就是最後一個活躍的對象後面)的對象清除,並釋放它們佔用的記憶體空間。

V8 的標記整理演算法

引用計數(Reference counting)

引用計數演算法是基於「引用計數」實現的垃圾回收演算法,這是最初級但已經被棄用的垃圾回收演算法。

引用計數演算法需要 JavaScript 引擎在程式運行時記錄每個變數被引用的次數,隨後根據引用的次數來判斷變數是否能夠被回收。

雖然垃圾回收已不再使用引用計數演算法,但是引用計數技術仍非常有用!

🌰 舉個栗子

注意:垃圾回收不是即使生效的!但是在下面的例子中我們將假設回收是立即生效的,這樣會更好理解~

// 下面我將 name 屬性為 ππ 的對象簡稱為 ππ
// 而 name 屬性為 pp 的對象則簡稱為 pp
// ππ 的引用:1,pp 的引用:1
let a = {
  name: 'ππ',
  z: {
    name: 'pp'
  }
}

// b 和 a 都指向 ππ
// ππ 的引用:2,pp 的引用:1
let b = a;

// x 和 a.z 都指向 pp
// ππ 的引用:2,pp 的引用:2
let x = a.z;

// 現在只有 b 還指向 ππ
// ππ 的引用:1,pp 的引用:2
a = null;

// 現在 ππ 沒有任何引用了,可以被回收了
// 在 ππ 被回收後,pp 的引用也會相應減少
// ππ 的引用:0,pp 的引用:1
b = null;

// 現在 pp 也可以被回收了
// ππ 的引用:0,pp 的引用:0
x = null;

// 哦豁,這下全完了!

🔄 循環引用(Circular references)

引用計數演算法看似很美好,但是它有一個致命的缺點,就是無法處理循環引用的情況。

在下方的例子中,當 foo() 函數執行完畢之後,對象 ab 都已經離開了作用域,理論上它們都應該能夠被回收才對。

但是由於它們互相引用了對方,所以垃圾回收器就認為他們都還在被引用著,導致它們哥倆永遠都不會被回收,這就造成了記憶體泄露

function foo() {
  let a = { o: null };
  let b = { o: null };
  a.o = b;
  b.o = a;
}
foo();
// 即使 foo 函數已經執行完畢
// 對象 a 和 b 均已離開函數作用域
// 但是 a 和 b 還在互相引用
// 那麼它們這輩子都不會被回收了
// Oops!記憶體泄露了!

V8 中的垃圾回收(GC in V8)

8️⃣ V8

V8 是一個由 Google 開源的用 C++ 編寫的高性能 JavaScript 引擎。

V8 是目前最流行的 JavaScript 引擎之一,我們熟知的 Chrome 瀏覽器和 Node.js 等軟體都在使用 V8。

在 V8 的記憶體管理機制中,把堆記憶體(Heap memory)劃分成了多個區域。

V8 常駐集

這裡我們只關注這兩個區域:

  • New Space(新空間):又稱 Young generation(新世代),用於儲存新生成的對象,由 Minor GC 進行管理。
  • Old Space(舊空間):又稱 Old generation(舊世代),用於儲存那些在兩次 GC 後仍然存活的對象,由 Major GC 進行管理。

也就是說,只要 New Space 里的對象熬過了兩次 GC,就會被轉移到 Old Space,變成老油條。

🧹 雙管齊下

V8 內部實現了兩個垃圾回收器:

  • Minor GC(副 GC):它還有個名字叫做 Scavenger(清道夫),具體使用的是 Cheney’s Algorithm(Cheney 演算法)。
  • Major GC(主 GC):使用的是文章前面提到的 Mark-Compact Algorithm(標記-整理演算法)。

儲存在 New Space 里的新生對象大多都只是臨時使用的,而且 New Space 的容量比較小,為了保持記憶體的可用率,Minor GC 會頻繁地運行。

而 Old Space 里的對象存活時間都比較長,所以 Major GC 沒那麼勤快,這一定程度地降低了頻繁 GC 帶來的性能損耗。

💥 加點魔法

我們在上方的「標記整理演算法」中有提到這個演算法的標記過程非常耗時,所以很容易導致應用長時間無響應。

為了提升用戶體驗,V8 還實現了一個名為增量標記(Incremental marking)的特性。

增量標記的要點就是把標記工作分成多個小段,夾雜在主執行緒(Main thread)的 JavaScript 邏輯中,這樣就不會長時間阻塞主執行緒了。

增量標記

當然增量標記也有代價的,在增量標記過程中所有對象的變化都需要通知垃圾回收器,好讓垃圾回收器能夠正確地標記那些對象,這裡的「通知」也是需要成本的。

另外 V8 中還有使用工作執行緒(Worker thread)實現的平行標記(Parallel marking)和並行標記(Concurrent marking),這裡我就不再細說了~

🤓 總結一下

為了提升性能和用戶體驗,V8 內部做了非常非常多的「騷操作」,本文提到的都只是冰山一角,但足以讓我五體投地佩服連連!

總之就是非常 Amazing 啊~

記憶體管理(Memory management)

或者說是:記憶體優化(Memory optimization)?

雖然我們寫程式碼的時候一般不會直接接觸記憶體管理,但是有一些注意事項可以讓我們避免引起記憶體問題,甚至提升程式碼的性能。

全局變數(Global variable)

全局變數的訪問速度遠不及局部變數,應盡量避免定義非必要的全局變數。

在我們實際的項目開發中,難免會需要去定義一些全局變數,但是我們必須謹慎使用全局變數。

因為全局變數永遠都是可達的,所以全局變數永遠不會被回收。

🌐 還記得「可達性」這個概念嗎?

因為全局變數直接掛載在全局對象上,也就是說全局變數永遠都可以通過全局對象直接訪問。

所以全局變數永遠都是可達的,而可達的變數永遠都不會被回收。

🤨 應該怎麼做?

當一個全局變數不再需要用到時,記得解除其引用(置空),好讓垃圾回收器可以釋放這部分記憶體。

// 全局變數不會被回收
window.me = {
  name: '吳彥祖',
  speak: function() {
    console.log(`我是${this.name}`);
  }
};
window.me.speak();
// 解除引用後才可以被回收
window.me = null;

隱藏類(HiddenClass)

實際上的隱藏類遠比本文所提到的複雜,但是今天的主角不是它,所以我們點到為止。

在 V8 內部有一個叫做「隱藏類」的機制,主要用於提升對象(Object)的性能。

V8 里的每一個 JS 對象(JS Objects)都會關聯一個隱藏類,隱藏類裡面儲存了對象的形狀(特徵)和屬性名稱到屬性的映射等資訊。

隱藏類內記錄了每個屬性的記憶體偏移(Memory offset),後續訪問屬性的時候就可以快速定位到對應屬性的記憶體位置,從而提升對象屬性的訪問速度。

在我們創建對象時,擁有完全相同的特徵(相同屬性且相同順序)的對象可以共享同一個隱藏類。

🤯 再想像一下

我們可以把隱藏類想像成工業生產中使用的模具,有了模具之後,產品的生產效率得到了很大的提升。

但是如果我們更改了產品的形狀,那麼原來的模具就不能用了,又需要製作新的模具才行。

🌰 舉個栗子

在 Chrome 瀏覽器 Devtools 的 Console 面板中執行以下程式碼:

// 對象 A
let objectA = {
  id: 'A',
  name: '吳彥祖'
};
// 對象 B
let objectB = {
  id: 'B',
  name: '彭于晏'
};
// 對象 C
let objectC = {
  id: 'C',
  name: '劉德華',
  gender: '男'
};
// 對象 A 和 B 擁有完全相同的特徵
// 所以它們可以使用同一個隱藏類
// good!

隨後在 Memory 面板打一個堆快照,通過堆快照中的 Comparison 視圖可以快速找到上面創建的 3 個對象:

註:關於如何查看記憶體中的對象將會在文章的第二大部分中進行講解,現在讓我們專註於隱藏類。

隱藏類示例

在上圖中可以很清楚地看到對象 A 和 B 確實使用了同一個隱藏類。

而對象 C 因為多了一個 gender 屬性,所以不能和前面兩個對象共享隱藏類。

🧀 動態增刪對象屬性

一般情況下,當我們動態修改對象的特徵(增刪屬性)時,V8 會為該對象分配一個能用的隱藏類或者創建一個新的隱藏類(新的分支)。

例如動態地給對象增加一個新的屬性:

註:這種操作被稱為「先創建再補充(ready-fire-aim)」。

// 增加 gender 屬性
objectB.gender = '男';
// 對象 B 的特徵發生了變化
// 多了一個原本沒有的 gender 屬性
// 導致對象 B 不能再與 A 共享隱藏類
// bad!

動態刪除(delete)對象的屬性也會導致同樣的結果:

// 刪除 name 屬性
delete objectB.name;
// A:我們不一樣!
// bad!

不過,添加數組索引屬性(Array-indexed properties)並不會有影響:

其實就是用整數作為屬性名,此時 V8 會另外處理。

// 增加 1 屬性
objectB[1] = '數字組引屬性';
// 不影響共享隱藏類
// so far so good!

🙄 那問題來了

說了這麼多,隱藏類看起來確實可以提升性能,那它和記憶體又有什麼關係呢?

實際上,隱藏類也需要佔用記憶體空間,這其實就是一種用空間換時間的機制。

如果由於動態增刪對象屬性而創建了大量隱藏類和分支,結果就是會浪費不少記憶體空間。

🌰 舉個栗子

創建 1000 個擁有相同屬性的對象,記憶體中只會多出 1 個隱藏類。

而創建 1000 個屬性資訊完全不同的對象,記憶體中就會多出 1000 個隱藏類。

🤔 應該怎麼做?

所以,我們要盡量避免動態增刪對象屬性操作,應該在構造函數內就一次性聲明所有需要用到的屬性。

如果確實不再需要某個屬性,我們可以將屬性的值設為 null,如下:

// 將 age 屬性置空
objectB.age = null;
// still good!

另外,相同名稱的屬性盡量按照相同的順序來聲明,可以儘可能地讓更多對象共享相同的隱藏類。

即使遇到不能共享隱藏類的情況,也至少可以減少隱藏類分支的產生。

其實動態增刪對象屬性所引起的性能問題更為關鍵,但因本文篇幅有限,就不再展開了。

閉包(Closure)

前面有提到:被閉包引用的變數儲存在堆記憶體中。

這裡我們再重點關注一下閉包中的記憶體問題,還是前面的例子:

function getCounter() {
  let count = 0;
  function counter() {
    return ++count;
  }
  return counter;
}
// closure 是一個閉包函數
let closure = getCounter();
closure(); // 1
closure(); // 2
closure(); // 3

現在只要我們一直持有變數(函數) closure,那麼變數 count 就不會被釋放。

或許你還沒有發現風險所在,不如讓我們試想變數 count 不是一個數字,而是一個巨大的數組,一但這樣的閉包多了,那對於記憶體來說就是災難。

// 我將這個作品稱為:閉包炸彈
function closureBomb() {
  const handsomeBoys = [];
  setInterval(() => {
    for (let i = 0; i < 100; i++) {
      handsomeBoys.push(
        { name: '陳皮皮', rank: 0 },
        { name: ' 你 ', rank: 1 },
        { name: '吳彥祖', rank: 2 },
        { name: '彭于晏', rank: 3 },
        { name: '劉德華', rank: 4 },
        { name: '郭富城', rank: 5 }
      );
    }
  }, 100);
}
closureBomb();
// 即將毀滅世界
// 💣 🌍 💥 💨

🤔 應該怎麼做?

所以,我們必須避免濫用閉包,並且謹慎使用閉包!

當不再需要時記得解除閉包函數的引用,讓閉包函數以及引用的變數能夠被回收。

closure = null;
// 變數 count 終於得救了

如何分析記憶體(Analyze)

說了這麼多,那我們應該如何查看並分析程式運行時的記憶體情況呢?

「工欲善其事,必先利其器。」

對於 Web 前端項目來說,分析記憶體的最佳工具非 Memory 莫屬!

這裡的 Memory 指的是 DevTools 中的一個工具,為了避免混淆,下面我會用「Memory 面板」或」記憶體面板「代稱。

🔧 DevTools(開發者工具)

DevTools 是瀏覽器里內置的一套用於 Web 開發和調試的工具。

使用 Chromuim 內核的瀏覽器都帶有 DevTools,個人推薦使用 Chrome 或者 Edge(新)。

Memory in Devtools(記憶體面板)

在我們切換到 Memory 面板後,會看到以下介面(注意標註):

Memory 面板

在這個面板中,我們可以通過 3 種方式來記錄記憶體情況:

  • Heap snapshot:堆快照
  • Allocation instrumentation on timeline:記憶體分配時間軸
  • Allocation sampling:記憶體分配取樣

小貼士:點擊面板左上角的 Collect garbage 按鈕(垃圾桶圖標)可以主動觸發垃圾回收。

🤓 在正式開始分析記憶體之前,讓我們先學習幾個重要的概念:

💡 Shallow Size(淺層大小)

淺層大小指的是當前對象自身佔用的記憶體大小。

淺層大小不包含自身引用的對象。

💡 Retained Size(保留大小)

保留大小指的是當前對象被 GC 回收後總共能夠釋放的記憶體大小。

換句話說,也就是當前對象自身大小加上對象直接或間接引用的其他對象的大小總和。

需要注意的是,保留大小不包含那些除了被當前對象引用之外還被全局對象直接或間接引用的對象。

Heap snapshot(堆快照)

分析類型-堆快照

堆快照可以記錄頁面當前時刻的 JS 對象以及 DOM 節點的記憶體分配情況。

🚀 如何開始

點擊頁面底部的 Take snapshot 按鈕或者左上角的 ⚫ 按鈕即可打一個堆快照,片刻之後就會自動展示結果。

選擇一個視圖

在堆快照結果頁面中,我們可以使用 4 種不同的視圖來觀察記憶體情況:

  • Summary:摘要視圖
  • Comparison:比較視圖
  • Containment:包含視圖
  • Statistics:統計視圖

默認顯示 Summary 視圖。

Summary(摘要視圖)

摘要視圖根據 Constructor(構造函數)來將對象進行分組,我們可以在 Class filter(類過濾器)中輸入構造函數名稱來快速篩選對象。

堆快照-摘要視圖

頁面中的幾個關鍵詞:

  • Constructor:構造函數。
  • Distance:(根)距離,對象與 GC 根之間的最短距離。
  • Shallow Size:淺層大小,單位:Bytes(位元組)。
  • Retained Size:保留大小,單位:Bytes(位元組)。
  • Retainers:持有者,也就是直接引用目標對象的變數。

📌 Retainers(持有者)

Retainers 欄在舊版的 Devtools 里叫做 Object’s retaining tree(對象保留樹)。

Retainers 下的對象也展開為樹形結構,方便我們進行引用溯源。

在視圖中的構造函數列表中,有一些用「()」包裹的條目:

  • (compiled code):已編譯的程式碼。
  • (closure):閉包函數。
  • (array, string, number, symbol, regexp):對應類型(ArrayStringNumberSymbolRegExp)的數據。
  • (concatenated string):使用 concat() 函數拼接而成的字元串。
  • (sliced string):使用 slice()substring() 等函數進行邊緣切割的字元串。
  • (system):系統(引擎)產生的對象,如 V8 創建的 HiddenClasses(隱藏類)和 DescriptorArrays(描述符數組)等數據。

💡 DescriptorArrays(描述符數組)

描述符數組主要包含對象的屬性名資訊,是隱藏類的重要組成部分。

不過描述符數組內不會包含整數索引屬性。

而其餘沒有用「()」包裹的則為全局屬性和 GC 根。

另外,每個對象後面都會有一串「@」開頭的數字,這是對象在記憶體中的唯一 ID。

小貼士:按下快捷鍵 Ctrl/Command + F 展示搜索欄,輸入名稱或 ID 即可快速查找目標對象。

💪 實踐一下:實例化一個對象

切換到 Console 面板,執行以下程式碼來實例化一個對象:

function TestClass() {
  this.number = 123;
  this.string = 'abc';
  this.boolean = true;
  this.symbol = Symbol('test');
  this.undefined = undefined;
  this.null = null;
  this.object = { name: 'pp' };
  this.array = [1, 2, 3];
  this.getSet = {
    _value: 0,
    get value() {
      return this._value;
    },
    set value(v) {
      this._value = v;
    }
  };
}
let testObject = new TestClass();

實例化一個對象

回到 Memory 面板,打一個堆快照,在 Class filter 中輸入「TestClass」:

可以看到記憶體中有一個 TestClass 的實例,該實例的淺層大小為 80 位元組,保留大小為 876 位元組。

記憶體中的對象實例

🤔 注意到了嗎?

堆快照中的 TestClass 實例的屬性中少了一個名為 number 屬性,這是因為堆快照不會捕捉數字屬性。

💪 實踐一下:創建一個字元串

切換到 Console 面板,執行以下程式碼來創建一個字元串:

// 這是一個全局變數
let testString = '我是吳彥祖';

回到 Memory 面板,打一個堆快照,打開搜索欄(Ctrl/Command + F)並輸入「我是吳彥祖」:

記憶體中的吳彥祖

Comparison(比較視圖)

只有同時存在 2 個或以上的堆快照時才會出現 Comparison 選項。

比較視圖用於展示兩個堆快照之間的差異。

使用比較視圖可以讓我們快速得知在執行某個操作後的記憶體變化情況(如新增或減少對象)。

通過多個快照的對比還可以讓我們快速判斷並定位記憶體泄漏。

文章前面提到隱藏類的時候,就是使用了比較視圖來快速查找新創建的對象。

💪 實踐一下

新建一個無痕(匿名)標籤頁並切換到 Memory 面板,打一個堆快照 Snapshot 1。

💡 為什麼是無痕標籤頁?

普通標籤頁會受到瀏覽器擴展或者其他腳本影響,記憶體佔用不穩定。

使用無痕窗口的標籤頁可以保證頁面的記憶體相對純凈且穩定,有利於我們進行對比。

另外,建議打開窗口一段之間之後再開始測試,這樣記憶體會比較穩定(控制變數)。

切換到 Console 面板,執行以下程式碼來實例化一個 Foo 對象:

function Foo() {
  this.name = 'pp';
  this.age = 18;
}
let foo = new Foo();

回到 Memory 面板,再打一個堆快照 Snapshot 2,切換到 Comparison 視圖,選擇 Snapshot 1 作為 Base snapshot(基本快照),在 Class filter 中輸入「Foo」:

可以看到記憶體中新增了一個 Foo 對象實例,分配了 52 位元組記憶體空間,該實例的引用持有者為變數 foo

比較視圖-新增實例

再次切換到 Console 面板,執行以下程式碼來解除變數 foo 的引用:

// 解除對象的引用
foo = null;

再回到 Memory 面板,打一個堆快照 Snapshot 3,選擇 Snapshot 2 作為 Base snapshot,在 Class filter 中輸入「Foo」:

記憶體中的 Foo 對象實例已經被刪除,釋放了 52 位元組的記憶體空間。

比較視圖-實例對象

Containment(包含視圖)

包含視圖就是程式對象結構的「鳥瞰圖(Bird’s eye view)」,允許我們通過全局對象出發,一層一層往下探索,從而了解記憶體的詳細情況。

堆快照-統計視圖

包含視圖中有以下幾種全局對象:

GC roots(GC 根)

GC roots 就是 JavaScript 虛擬機的垃圾回收中實際使用的根節點。

GC 根可以由 Built-in object maps(內置對象映射)、Symbol tables(符號表)、VM thread stacks(VM 執行緒堆棧)、Compilation caches(編譯快取)、Handle scopes(句柄作用域)和 Global handles(全局句柄)等組成。

DOMWindow objects(DOMWindow 對象)

DOMWindow objects 指的是由宿主環境(瀏覽器)提供的頂級對象,也就是 JavaScript 程式碼中的全局對象 window,每個標籤頁都有自己的 window 對象(即使是同一窗口)。

Native objects(原生對象)

Native objects 指的是那些基於 ECMAScript 標準實現的內置對象,包括 ObjectFunctionArrayStringBooleanNumberDateRegExpMath 等對象。

💪 實踐一下

切換到 Console 面板,執行以下程式碼來創建一個構造函數 $ABC

構造函數命名前面加個 $ 是因為這樣排序的時候可以排在前面,方便找。

function $ABC() {
  this.name = 'pp';
}

切換到 Memory 面板,打一個堆快照,切換為 Containment 視圖:

在當前標籤頁的全局對象下就可以找到我們剛剛創建的構造函數 $ABC

包含視圖-示例

Statistics(統計視圖)

統計視圖可以很直觀地展示記憶體整體分配情況。

堆快照-統計視圖

在該視圖裡的空心餅圖中共有 6 種顏色,各含義分別為:

  • 紅色:Code(程式碼)
  • 綠色:Strings(字元串)
  • 藍色:JS arrays(數組)
  • 橙色:Typed arrays(類型化數組)
  • 紫色:System objects(系統對象)
  • 白色:空閑記憶體

Allocation instrumentation on timeline(分配時間軸)

分析類型-分配時間軸

在一段時間內持續地記錄記憶體分配(約每 50 毫秒打一張堆快照),記錄完成後可以選擇查看任意時間段的記憶體分配詳情。

另外還可以勾選同時記錄分配堆棧(Allocation stacks),也就是記錄調用堆棧,不過這會產生額外的性能消耗。

🚀 如何開始

點擊頁面底部的 Start 按鈕或者左上角的 ⚫ 按鈕即可開始記錄,記錄過程中點擊左上角的 🔴 按鈕來結束記錄,片刻之後就會自動展示結果。

💪 操作一下

打開 Memory 面板,開始記錄分配時間軸。

切換到 Console 面板,執行以下程式碼:

程式碼效果:每隔 1 秒鐘創建 100 個對象,共創建 1000 個對象。

console.log('測試開始');
let objects = [];
let handler = setInterval(() => {
  // 每秒創建 100 個對象
  for (let i = 0; i < 100; i++) {
    const name = `n${objects.length}`;
    const value = `v${objects.length}`;
    objects.push({ [name]: value});
  }
  console.log(`對象數量:${objects.length}`);
  // 達到 1000 個後停止
  if (objects.length >= 1000) {
    clearInterval(handler);
    console.log('測試結束');
  }
}, 1000);

😈 又是一個細節

不知道你有沒有發現,在上面的程式碼中,我幹了一件壞事。

在 for 循環創建對象時,會根據對象數組當前長度生成一個唯一的屬性名和屬性值。

這樣一來 V8 就無法對這些對象進行優化,方便我們進行測試。

另外,如果直接使用對象數組的長度作為屬性名會有驚喜~

靜靜等待 10 秒鐘,控制台會列印出「測試結束」。

切換回 Memory 面板,停止記錄,片刻之後會自動進入結果頁面。

分配時間軸-視圖模式

分配時間軸結果頁有 4 種視圖:

  • Summary:摘要視圖
  • Containment:包含視圖
  • Allocation:分配視圖
  • Statistics:統計視圖

默認顯示 Summary 視圖。

Summary(摘要視圖)

看起來和堆快照的摘要視圖很相似,主要是頁面上方多了一條橫向的時間軸(Timeline)。

分配時間軸-摘要視圖

🧭 時間軸

時間軸中主要的 3 種線:

  • 細橫線:記憶體分配大小刻度線
  • 藍色豎線:表示記憶體在對應時刻被分配,最後仍然活躍
  • 灰色豎線:表示記憶體在對應時刻被分配,但最後被回收

時間軸的幾個操作:

  • 滑鼠移動到時間軸內任意位置,點擊左鍵或長按左鍵並拖動即可選擇一段時間
  • 滑鼠拖動時間段框上方的方塊可以對已選擇的時間段進行調整
  • 滑鼠移到已選擇的時間段框內部,滑動滾輪可以調整時間範圍
  • 滑鼠移到已選擇的時間段框兩旁,滑動滾輪即可調整時間段
  • 雙擊滑鼠左鍵即可取消選擇

分配時間軸-操作時間軸

在時間軸中選擇要查看的時間段,即可得到該段時間的記憶體分配詳情。

分配時間軸-摘要視圖

Containment(包含視圖)

分配時間軸的包含視圖與堆快照的包含視圖是一樣的,這裡就不再重複介紹了。

分配時間軸-包含視圖

Allocation(分配視圖)

對不起各位,這玩意兒我也不知道有啥用…

打開就直接報錯,我:喵喵喵?

分配時間軸-分配視圖

是不是因為沒人用這玩意兒,所以沒人發現有問題…

Statistics(統計視圖)

分配時間軸的統計視圖與堆快照的統計視圖也是一樣的,不再贅述。

分配時間軸-統計視圖

Allocation sampling(分配取樣)

分析類型-分配取樣

Memory 面板上的簡介:使用取樣方法記錄記憶體分配。這種分析方式的性能開銷最小,可以用於長時間的記錄。

好傢夥,這個簡介有夠模糊,說了跟沒說似的,很有精神!

我在官方文檔里沒有找到任何關於分配取樣的介紹,Google 上也幾乎沒有與之有關的資訊。所以以下內容僅為個人實踐得出的結果,如有不對的地方歡迎各位指出!

簡單來說,通過分配取樣我們可以很直觀地看到程式碼中的每個函數(API)所分配的記憶體大小。

由於是取樣的方式,所以結果並非百分百準確,即使每次執行相同的操作也可能會有不同的結果,但是足以讓我們了解記憶體分配的大體情況。

如何開始

點擊頁面底部的 Start 按鈕或者左上角的 ⚫ 按鈕即可開始記錄,記錄過程中點擊左上角的 🔴 按鈕來結束記錄,片刻之後就會自動展示結果。

💪 操作一下

打開 Memory 面板,開始記錄分配取樣。

切換到 Console 面板,執行以下程式碼:

程式碼看起來有點長,其實就是 4 個函數分別以不同的方式往數組裡面添加對象。

// 普通單層調用
let array_a = [];
function aoo1() {
  for (let i = 0; i < 10000; i++) {
    array_a.push({ a: 'pp' });
  }
}
aoo1();
// 兩層嵌套調用
let array_b = [];
function boo1() {
  function boo2() {
    for (let i = 0; i < 20000; i++) {
      array_b.push({ b: 'pp' });
    }
  }
  boo2();
}
boo1();
// 三層嵌套調用
let array_c = [];
function coo1() {
  function coo2() {
    function coo3() {
      for (let i = 0; i < 30000; i++) {
        array_c.push({ c: 'pp' });
      }
    }
    coo3();
  }
  coo2();
}
coo1();
// 兩層嵌套多個調用
let array_d = [];
function doo1() {
  function doo2_1() {
    for (let i = 0; i < 20000; i++) {
      array_d.push({ d: 'pp' });
    }
  }
  doo2_1();
  function doo2_2() {
    for (let i = 0; i < 20000; i++) {
      array_d.push({ d: 'pp' });
    }
  }
  doo2_2();
}
doo1();

切換回 Memory 面板,停止記錄,片刻之後會自動進入結果頁面。

分配取樣-視圖模式

分配取樣結果頁有 3 種視圖可選:

  • Chart:圖表視圖
  • Heavy (Bottom Up):扁平視圖(調用層級自下而上)
  • Tree (Top Down):樹狀視圖(調用層級自上而下)

這個 Heavy 我真的不知道該怎麼翻譯,所以我就按照具體表現來命名了。

默認會顯示 Chart 視圖。

Chart(圖表視圖)

Chart 視圖以圖形化的表格形式展現各個函數的記憶體分配詳情,可以選擇精確到記憶體分配的不同階段(以記憶體分配的大小為軸)。

分配取樣-圖表視圖

滑鼠左鍵點擊、拖動和雙擊以操作記憶體分配階段軸(和時間軸一樣),選擇要查看的階段範圍。

分配取樣-操作階段軸

將滑鼠移動到函數方塊上會顯示函數的記憶體分配詳情。

顯示記憶體分配詳情

滑鼠左鍵點擊函數方塊可以跳轉到相應程式碼。

跳轉到相應程式碼

Heavy(扁平視圖)

Heavy 視圖將函數調用層級壓平,函數將以獨立的個體形式展現。另外也可以展開調用層級,不過是自下而上的結構,也就是一個反向的函數調用過程。

分配取樣-扁平視圖

視圖中的兩種 Size(大小):

  • Self Size:自身大小,指的是在函數內部直接分配的記憶體空間大小。
  • Total Size:總大小,指的是函數總共分配的記憶體空間大小,也就是包括函數內部嵌套調用的其他函數所分配的大小。
Tree(樹狀視圖)

Tree 視圖以樹形結構展現函數調用層級。我們可以從程式碼執行的源頭開始自上而下逐層展開,呈現一個完整的正向的函數調用過程。

分配取樣-樹狀視圖


參考資料

《JavaScript 高級程式設計(第4版)》

Memory Management://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management

Visualizing memory management in V8 Engine://deepu.tech/memory-management-in-v8/

Trash talk: the Orinoco garbage collector://v8.dev/blog/trash-talk

Fast properties in V8://v8.dev/blog/fast-properties

Concurrent marking in V8://v8.dev/blog/concurrent-marking

Chrome DevTools://developers.google.com/web/tools/chrome-devtools


傳送門

微信推文版本

個人部落格:菜鳥小棧

開源主頁:陳皮皮

Eazax-CCC 遊戲開發腳手架


更多分享

《為什麼選擇使用 TypeScript ?》

《高斯模糊 Shader》

《一文看懂 YAML》

《Cocos Creator 性能優化:DrawCall》

《互聯網運營術語掃盲》

《在 Cocos Creator 里畫個炫酷的雷達圖》

《用 Shader 寫個完美的波浪》

《在 Cocos Creator 中優雅且高效地管理彈窗》

《Cocos Creator 源碼解讀:引擎啟動與主循環》


公眾號

菜鳥小棧

😺我是陳皮皮,一個還在不斷學習的遊戲開發者,一個熱愛分享的 Cocos Star Writer。

🎨這是我的個人公眾號,專註但不僅限於遊戲開發和前端技術分享。

💖每一篇原創都非常用心,你的關注就是我原創的動力!

Input and output.