如果你是寫 C/C++ 的開發者,應該對記憶體管理並不陌生,如果你是後端開發者,應該會常常注意伺服器有沒有發生 Memory Leak 與 Memory 使用量的狀況。然而在前端開發中,因為瀏覽器可以迅速啟動與關閉的特性,再加上 JavaScript 的 Garbage Collection 垃圾回收機制,常常讓前端開發者忽略了 JavaScript 的記憶體管理機制與 Memory Leak 帶來的危險性,有時應用的效能瓶頸可能就因此產生了。
今天想與各位讀者分享 JavaScript 的記憶體管理機制,知道不同的資料結構在 JS 中是如何儲存的。接著會看看 JavaScript 的 Garbage Collection 機制與它的限制,希望在經過今天的內容後,我們除了能夠知道 JavaScript 的記憶體管理機制以外,也能盡量避免寫出會造成記憶體用量大增甚至造成 Memory Leak 的程式碼,進而避免網站的效能產生瓶頸。
(今天的內容會以 Chrome 與 Node.js 使用的 JavaScript 引擎 V8 為例,不同的 JavaScript Engine 可能機制上會有些許不同。)
首先要先來談談記憶體的生命週期,這個觀念無論使用的是哪種程式語言概念都是差不多的。
1. 分配程式需要用到的記憶體空間
2. 使用分配到的記憶體空間(讀寫操作)
3. 當不會再使用時要釋放被配置的記憶體空間
JS 引擎又會將記憶體分為兩個區塊
我們知道 JavaScript 主要有 7 種資料型態:
這些數據資料會儲存在 Stack & Heap 之中,而程式碼空間則會儲存一些非數據的資料,舉例來說
const it_home = 'ironman';
“ironman” 這個 string 會被儲存到 Heap & Stack 的記憶體裡,而變數 it_home 則會被存放到程式碼空間的記憶體裡。
而今天主要要介紹的是「Heap & Stack」的部分。
數據空間又可以分為 stack 記憶體與 heap 記憶體,要注意這裡的 stack 與 heap 並不是指資料結構的 stack & heap,而是指記憶體的空間。熟悉 JS 的讀者應該知道數據又分成兩種類型,Primitive Type 與 Reference Type,比較簡單類型的 Primitive Type 會被放在 stack 裡,比較複雜類型的 Reference Type 則會把資料存在 heap 中,再把資料在 heap 的記憶體位址記錄到 stack 裡,為了快速理解,我們直接看一段 code。
function ironman(){
let one = "鐵人賽";
let two = one;
let three = { author: "Kyle Mo"};
let four = three;
}ironman();
當執行一段 JavaScript 的程式碼時需要先經過編譯,並創建所謂的執行環境(Execution Context), 接著再按照順序執行程式碼。
當 ironman function 執行完最後一行即將離開 function 時,記憶體的狀況會是這樣
可以發現 Object 類型的數據實際上是存在 Heap 裡,Stack 中存的只是物件在 Heap 中的記憶體位置而已,而變數 four = three
這段 code 實際上是把 Three 指向的物件在 Heap 中的記憶體位置指派給 Four 變數,所以它們實際上指向的是同一個物件,這也是身為 JS 開發者應該十分熟悉的一個特性。
原因是 JS Engine 是透過 stack 來維護 Execution Context 的切換狀態,如果 Stack 太過肥大,會影響 Context Switch 的執行效率,連帶影響到整個程式執行的效率。以上面的例子來說,當 ironman 這個 function 執行完畢後,JS Engine 會執行環境切換,將指針移到下一層的 Execution Context,也就是 Global Execution Context,然後回收 ironman function 的 執行環境與 stack memory。
社群上有大大指出字串型別與一些數字在 compile 的時候沒有辦法知道確切大小為何,所以應該不會是存在 stack 中。
關於這點我查詢了一些文章,發現這似乎不是一個很單純是或否的二選一問題,實際上可能得考慮 compiler 的實作方式,例如這篇文章所提及的。
又例如這篇文章與它的續集透過觀察 Bytecode 而得出一些字串與數字會有「 constant pool」的概念,可以共用同一個記憶體位置。
所以目前結論是 JS 在 V8 引擎中:
詳細內容可以參考這篇文章。
雖然我在某篇 Stack Overflow 的討論串中看到一句話「 For the JS programmer, worrying about stacks and heaps is somewhere between meaningless and distracting. It’s more important to understand the behavior of various types of values.」
但為了避免傳遞錯誤觀念給讀者,未來如果有新的結論,會再更新在文章中🙏 最後感謝社群大大的指正!
當數據不會再被程式使用時,就會變成所謂的垃圾數據(好像在罵人😂),而記憶體的空間是有限的,所以理想上必須針對這些垃圾數據進行回收,挪出記憶體空間以供未來儲存數據使用。
如果曾經寫過 C/C++ 的讀者應該寫過一些需要自己管理記憶體的分配與回收的程式碼,例如
char* ironman = (char*)malloc(1024);free(ironman);
ironman = NULL;
不過 JavaScript 這門程式語言有一個叫做 「Garbage Collector」的系統,Garbage Collector (簡稱 GC)的工作是「追蹤記憶體分配的使用情況,以便自動釋放一些不再使用的記憶體空間」。這個 GC 的機制方便歸方便,卻讓許多 JavaScript 開發者產生「寫 JS 時可以不須理會記憶體管理」的錯誤認知。
根據 MDN 文件,有 GC 機制的存在仍然不能不管記憶體管理的原因在於 GC 只是「儘量」做到自動釋放記憶體空間,因為判斷記憶體空間是否要繼續使用,這件事是「不可判定(undecidable)」的,也就是不能單純透過演算法來解決。
所以我認為了解 GC 基本的運作方式是很重要的,有了基本的觀念才能避免 memory leak 的發生,讓應用的效能不會因為記憶體空間不足而出現瓶頸甚至崩潰。
首先需要先釐清一下,剛剛有提到在執行執行環境被回收時,該執行環境的 Stack 空間也會被回收(Stack 空間由 OS 管理,背後的實作機制我們先不討論),那各位讀者可能會發現一個問題,如果是物件的話,Stack 中存的是 Heap 空間的 address,所以就算 Stack 被回收,存在 Heap 空間的數據依然存在,這時就需要靠 GC 來判斷 Heap 空間中哪些數據是用不到且需要被回收的,接下來就一起來看看 Chrome 的 V8 引擎的垃圾回收機制是如何運作的。
其實 Garbage Collection 的演算法有非常多種,但目前還沒有出現所謂完美的 GC 演算法,依據不同的執行環境、語言,只能盡量找出「最適合」的 GC 演算法,以盡量達到最好的回收效果。
在 V8 引擎中,heap 又被分為兩個區域 — New Space
與 Old Space
。
New Space 中存放的是存活時間較短的物件,這裡垃圾回收的速度會比較快,不過空間卻比較小,大概只有 1–8 MB 左右,存在 New Space 中的物件也被稱作 Young Generation
。Old Space 中存放的是存活時間較久的物件,這些物件是在 New Space 中經過幾次 GC Cycle 並成功存活後才被移到 Old Space,在 Old Space 做垃圾回收的效率比較差,因此它執行 GC 的頻率相較於 New Space 會比較低,而在 Old Space 中的物件也被稱作 Old Generation
。
在 V8 中,分別對 Young Generation 與 Old Generation 實作了不同的 GC 演算法
Scavenge 演算法將 Young Generation 再分為「物件區域」與「空閒區域」。
新存入記憶體的物件會被放到物件區域,當物件區域快要 overflow 時,就得執行一次 GC。要做 GC 時,得先標記出哪些物件是應該要被回收的垃圾,標記出垃圾後才會正式進入記憶體清理階段,Garbage Collector 會把「仍然存活的物件」Copy 到空閒區域中並且排序。如果有使用過電腦的「磁碟重組」功能,應該知道它的原理是把一些不再使用的空間清除,並將碎片化的空間連接在一起。上面 GC 這段 Copy & Sort 的操作其實就跟磁碟重組類似是一種整理記憶體空間的行為。
Copy & Sort 後垃圾回收器會再將物件區域與空閒區域的角色翻轉,這樣就順利完成了 Young Generation 垃圾回收的操作,並且這樣清除與翻轉角色的機制是可以一直重複執行下去的。
從 Scavenge 演算法我們可以得知兩件事:
在 Old Generation,主要會有兩種物件
所…所以呢?
因為 Old Generation 物件佔用記憶體的空間通常較大,執行 Scavenge 演算法的 Copy & Paste 是很沒有效率的,同時還得切分出一半的空間用來轉換作為,對於本身記憶體空間比較大的 Old Generation 來說浪費了更多的空間,種種原因影響之下,在 V8 引擎的 Old Generation 中通常會採用另外一種演算法 — 「Mark-Sweep」來進行垃圾回收。
Mark-Sweep GC 演算法分為「標記」與「清除」兩個步驟,標記就是紙從根元素開始遞迴的尋訪這組根元素,在這個過程中,能夠被造訪的元素就是仍然需要存活的物件,而沒有被造訪的元素則被判定為垃圾數據,應該要被 GC 給清除。
在標記(Mark)完成後下一個階段就是把標記為垃圾的物件給清除(Sweep)。
上面的 Mark-Sweep 演算法有一個缺點,就是容易讓記憶體產生不連續且碎片化的空間,碎片過多會導致需要較大空間的物件沒辦法被分配到足夠的連續記憶體。為了解決這個問題,另外一種被稱作 Mark-Compact 的演算法誕生了。這個演算法在 Mark 階段與 Mark-Sweep 基本上一致,然而在清理過程會將存活的物件往記憶體的其中一端移動,整理出足夠的連續記憶體空間。
在前端的世界裡,Garbage Collection 也是由瀏覽器的 Main Thread 來負責的,不過 JavaScript 會受到 Single Thread 的限制,這意味著在做垃圾回收時 Main Thread 是不能夠做其他事的,必須等到回收任務完畢才能繼續執行 Script,這個特性也被稱作 「Stop The World」。
這看起來不是那麼理想,因為在 Old Generation 的 GC 是比較緩慢的,萬一 GC 需要耗時幾百毫秒,也會對頁面效能造成重大的影響。
V8 實作了一種叫做 Incremental Marking 的演算法,透過交替執行 GC 與 Script 的方式來解決使用者感覺到頁面卡頓的問題。
Memory Leak 可以說是工程師的公敵,不管前端後端甚至系統工程師在開發時都會盡量避免 Memory Leak 的發生。
先來看看 Memory Leak 的定義
記憶體流失是在電腦科學中,由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體,從而造成了記憶體的浪費。嚴重的話會可能會導致程式效能變慢甚至 crash。
像是 C/C++ 這類的語言需要開發者自己手動管理記憶體的釋放,而 Java 或 JavaScript 這類有垃圾回收機制的語言則不用手動釋放記憶體,但是不要以為這樣就安全了,在開發時有些寫法會造成垃圾回收機制沒辦法正確判斷記憶體已經不再被使用了,而無法被自動會收,造成所謂的 Memory Leak。
在 JavaScript 中,遵守某些 Best Practices 或是避免一些寫法可以盡量避免 Memory Leak 的發生。
在前端開發中,Event Listener 是很常見的功能,前端開發者要特別注意事件監聽器是不是會重複產生新的監聽器還有當監聽器用不到的時候是不是有正確移除。
熟悉 React 的讀者一定看過這個寫法
就是為了確保事件監聽器在不需要時可以被正確移除。
例如說以下這段簡單的 Express Web Server 的程式
const express = require('express');
const app = express();
// 全域變數
const requestStatusCollection = [];
app.get('/ironman', (req, res) => {
requestStatusCollection.push(req.status);
return res.send({});
})
app.listen(3000,() => {
console.log('server listening on port 3000...');
})
requestStatusCollection 是一個全域的陣列變數,雖然每個 endpoint 被呼叫後就會離開 handler,但全域變數卻不會被移除,當成長到一定的量時伺服器很可能會因為記憶體使用量過多卻沒有正確釋放而影響效能。
當然也不是說都不能用全域變數,例如有些 Cache 就會使用 memory 的 data structure 實作,不過通常會搭配例如 LRU Cache 的資料結構來控制記憶體的用量。
另外因為 hoisting 的特性,JavaScript 有些寫法也會產生不預期的全域變數
// 假設以下 function 都是 global 的
// author 會被 hoist 成一個全域變數
function ironman() {
author = "Kyle Mo";
}
// 這種寫法在 non strict mode 下也會變成全域變數
function hello_it_home() {
this.author = "Kyle Mo";
}
// 這種情況下就算是 strict mode author 也會變成全域變數
const hello_ironman = () => {
this.author = "Kyle Mo";
}
在不使用前端框架的狀況下,有時候可能會把 DOM Node 存在像物件這樣的資料結構中
const elementsMap = {
button: document.getElementById('btn'),
image: document.getElementById('img'),
};
有時候會有 remove DOM element 的需求
function removeButton() {
document.body.removeChild(document.getElementById('btn'));
}
你可能會覺得在 removeChild 後這個 DOM Element 所佔用的記憶體空間已經被清除了,然而實際上因為 elementsMap 這個物件還存在對 btn 這個 element 的 reference,所以 GC 並不會清除它的記憶體空間。
一些比較舊的瀏覽器例如 IE 的 GC 演算法比較不精確,沒辦法解決像是 circular reference 等問題,因此比較容易造成 Memory Leak。此外一些有缺陷的瀏覽器擴充套件也是造成 Memory Leak 的可能原因之一。
身為前端開發者,應該要學會好好利用瀏覽器 Devtool 提供的種種功能,以 Chrome 來說就有提供 memory tab 讓開發者可以觀測應用的記憶體使用量,礙於篇幅就不多做介紹,建議各位讀者可以去玩玩,也推薦閱讀這篇文章,看看實際在專案開發上是如何找出潛在 Memory Leak 的問題並嘗試解決。
這裡有一個用 React 撰寫的簡易 Demo
首先在 global 建立一個擁有一千萬個 items 的陣列,並分別實作兩個按鈕:cheap Loop 與 expensive loop。cheap loop 是利用迴圈更改 array item 的屬性值,expensive loop 則是每一次迴圈都重新指派一個新的物件給 array 中的 item,實際上最終這兩種方式跑出來的 array 應該要是長一樣的,但這兩種方式的效能卻有極大的不同。
可以看到在點擊 cheap loop 的時候頁面基本上是平順的,但點擊 expensive loop 後頁面很明顯直接卡頓住了。
當然這跟 Single Thread 的特性有關,expensiveLoop 相較於 cheapLoop 也是一個較耗費 CPU 的操作,這點可以從 performance tab 觀察出來
可以看到 CPU 在跑 expensiveLoop 後是爆量升高的,再來看看 heap 記憶體空間的 snapshot 對比
snapshot1
是點擊任何按鈕前的 heap snapshot 狀況snapshot2
則是點了 cheapLoop 按鈕後的 snapshot 狀況snapshot3
則是點了 expensiveLoop 按鈕後的 snapshot 狀況
可以發現點了 expensiveLoop 後的 heap 記憶體用量變成了原本狀況的將近 5 倍左右!雖然後續有機會被 GC 回收,但因為 GC 是自己運作的,開發者沒有控制它的權力,因此我們也不能保證未來記憶體會被順利回收。
可見在開發時除了注意能不能完成需求以外,也要留意是不是一個好的寫法或是有沒有更好的解決方案,不管是 CPU 的消耗還是記憶體的使用量,如果能盡量避免就該避免!
(後續更新:其實 immutable 的寫法在 JS 中很常見,Immutable 的寫法的確是在記憶體新增一個物件,理論上會比較沒那麼有效率,但一般使用情景應該都沒什麼問題,變垃圾的物件自然會被 GC 清掉,上述範例是因為一次爆量(一千萬次迴圈)新增物件導致記憶體用量暴增,理論上未來 GC 也會做清理,但在JS 中開發者對 GC 沒有控制權,那個 snapshot 是馬上做完操作時紀錄的,所以會顯示記憶體爆量成長,平常開發正常使用 immutable 的寫法倒是不用太擔心喔!)
了解記憶體管理的機制嚴格來說不是一種效能優化的技巧,而是一種「避免效能出現瓶頸」的一個重要觀念,今天的內容不深,卻是我認為前端開發者或是 JS 開發者一定要了解的記憶體管理機制,希望各位有所收穫!
(本篇文章由個人 Medium 部落格搬遷而來)