你為什麼要懂記憶體流失/泄露(memory leak)

前言

記憶體,這三個字其實對於一位開發者來講算是滿重要的單字,因為記憶體的使用會影響到程式的效能,因此這一篇文章我們來聊聊記憶體流失/泄露(memory leak)。

關於記憶體

其實我們的記憶體概念類似沙漏

沙漏

基本上我們可以使用的記憶體是有限的,因此當我們使用完畢之後,都會必須要歸還(釋放)記憶體,否則就會導致記憶體不足的狀況發生

記憶體空間不足

Note
Windows 作業系統在記憶體空間不足時會跳出「您電腦的記憶體即將用盡」的警告視窗。

那麼記憶體基本上區分了以下幾種:

  • 主記憶體(Main Memory)
    • 也就是所謂的 RAM(Random Access Memory),這個是我們常常聽到的記憶體空間,也是用來儲存程式執行時所需要的資料。
  • 快取記憶體(Cache Memory)
    • 這個是用來儲存 CPU 需要的資料,也就是用來暫時儲存頻繁一直被使用的資料,以加快 CPU 的執行速度。
  • 虛擬記憶體(Virtual Memory)
    • 虛擬記憶體基本上是透過硬碟來模擬記憶體,當記憶體不足時,會將一些不常用的資料暫時儲存到硬碟中,以釋放記憶體空間。
  • 唯讀記憶體(Read-Only Memory)
    • 這個是用來儲存一些不會被修改的資料,例如 BIOS 等等。

那我們常常在講的 SSD 跟 HDD 是什麼呢?其實這兩個都是儲存裝置(Storage Device),而不是記憶體(Memory),因此我們不會在這邊過度深入討論。

那麼我們主要會探討的是「主記憶體」,也就是 RAM,畢竟當你程式寫不好發生 memory leak 時,就是在指 RAM 的記憶體空間不足了。

Note
現今電腦都會建議最低 8GB 的 RAM,而標準是 16GB,如果你是開發者,建議可以考慮 32GB;如果你的電腦用途只有文書,那麼對於 RAM 的需求就不高,8GB 就可以了,反之如果你會需要執行一些較為複雜的程式,例如影像處理、遊戲等等,那麼至少 16GB 以上才夠用。

什麼是記憶體流失/泄露(memory leak)?

對於記憶體有一個基本概念後,接下來就要聊聊我們的主題「記憶體流失/泄露(memory leak)」了。

首先記憶體流失的概念很簡單,如同前面所講的,記憶體類似於「沙漏」的概念,當我們執行程式時記憶體會被使用(往下流),當程式執行完畢後就會將沙漏倒轉(歸還記憶體)

歸還記憶體

而這過程又稱之為 Garbage Collection(垃圾回收,縮寫為 GC),也就是將不再使用的記憶體空間歸還給系統。

GC 是一個自動化管理記憶體的機制,當某個記憶體不被使用時,GC 會自動收回並釋放記憶體空間,以程式碼來就如下:

1
2
3
4
5
6
7
8
function foo() {
const a = 1;
const b = 2;
const c = 3;
return a + b + c;
}

foo();

foo() 執行完畢後,GC 就會發現 abc 這三個變數不再被使用,因此就會將這三個變數所佔用的記憶體空間釋放出來。

那麼變數的賦予過程其實是採用記憶體指向的方式,也就是說當我們宣告 const a = 1 時,其實是將 a 這個變數名稱指向到記憶體中的 1,而當我們執行 a = 2 時,其實是將 a 這個變數指向到記憶體中的 2,而原本的 1 會被 GC 回收,因為沒有任何變數指向到 1 了。

Note
另一種釋放記憶體的方式就是將變數指向到 null,例如… a = null,這樣 GC 也會將 1 這個記憶體空間回收,那 null 不會站用記憶體空間嗎?其實不會,因為 null 本身就是 JavaScript 在 Runtime 時就會存在的值,因此不會被 GC 回收,所以不論是 a 指向到 null 或是 b 指向到 null,其實 null 都是同一個記憶體空間,因此不會造成記憶體浪費。

前面所提的都是有被「正常回收」的例子,接下來我們來看一些常見的「記憶體流失」的例子吧!

監聽事件

第一個例子算是比較常見的,就是監聽事件,例如我們綁定一個監聽器到某個元素上…

1
2
3
4
5
6
7
8
function addLister() {
const el = document.getElementById('el');
el.addEventListener('click', () => {
console.log('clicked');
});
}

addLister();

這個例子非常的經典,但也卻很簡單,我們可以看到當我們呼叫 addLister 之後會去綁定一個元素並建立一個監聽器,可是如果我們綁定之後,後面又將 el 這個 DOM 元素從網頁中移除…

1
2
3
4
5
6
7
8
9
10
11
function addLister() {
const el = document.getElementById('el');
el.addEventListener('click', () => {
console.log('clicked');
});
}

addLister();

const el = document.getElementById('el');
el.remove();

這種時候就會發生「記憶體流失」的狀況,雖然我們將 el 這個 DOM 從網頁上移除,但實際上我們的監聽事件其實還存在著,因此 GC 就不會將 el 這個記憶體空間回收,當這種忘記移除監聽器的情況越來越多時,就會導致記憶體空間不足的狀況發生。

Note
移除監聽的方式很簡單,只需要使用 removeEventListener 就可以了,這也是為什麼許多開發者都會提醒你綁定監聽器之後要記得移除的原因。

計時器

你如果有實作過番茄鐘或者一些需要使用到計時器的功能,那麼你應該會知道計時器的實作方式,例如…

1
2
3
setInterval(() => {
// 做一些事情
}, 1000);

其實計時器也是會造成記憶體流失的原因之一,畢竟 setInterval 如果你不移除的話,就會一直存在,而且每次都會執行,因此如果你的計時器越多,就會造成記憶體流失的狀況更嚴重。

其中我曾經看過一個案例有一個系統需要透過 setInterval 去定時觸發某個事件,結果開發者忘記阻擋/清除原有的計時器,導致每次觸發時就會建立一個新的計時器,最後就爆掉的狀況。

閉包

基本上閉包算是滿多 JavaScript 開發者都會使用到的技巧,而 React 的 useState 就有使用到閉包的概念,因此閉包的使用是很常見的,但也是造成記憶體流失的原因之一,為什麼呢?讓我們繼續聊下去。

首先我們都知道閉包可以做到將變數私有化、狀態保存等,因此我們可以透過閉包的特性寫出一些很有趣的程式碼,例如像是儲值卡的範例…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function myMoney(storage) {
var money = storage;
console.log(money);
return function(price) {
return { // 使用物件函數的方式來製作功能查詢及扣除餘額
nowMoney: function () {
return console.log(money);
},
count: function (price) {
if(money < price) return console.log('餘額不足,目前餘額: ' + money + ' $'); // 當 price 大於目前 餘額 money 就回傳錯誤。
if (!money <= 0) { // 當 money 等於 0 或是小於 money 就不進入計算。
return money = money - price;
}
return console.log('餘額扣除失敗,目前餘額: ' + money + ' $');
}
}
}
}
// 小明比較窮只儲值 500$
var ming = myMoney(500);
// 小美暴發戶儲值了 5000$
var mei = myMoney(5000);
// 小王不知道哪裡來的錢,儲值了 30000$
var wang = myMoney(30000);

// 小明連三天都花了 500$
ming().count(100);
ming().count(100);
ming().count(300);
//查詢小明目前餘額
ming().nowMoney();
// 小美花了 2300
mei().count(1600);
mei().count(100);
mei().count(600);
//查詢小美目前餘額
mei().nowMoney();
// 小王只花300
wang().count(300);
// 查詢小王目前餘額
wang().nowMoney();

我們可以發現閉包這技巧其實真的滿有趣的,但如果你在使用閉包時,不小心寫了以下程式碼…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function fn() {
const data = []
return (item) => {
data.push(item)
};
}

const add = fn();

const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;

for (let i = 0; i < 1000000; i++) {
add(characters.charAt(Math.floor(Math.random() * charactersLength)));
}

那麼就會導致記憶體被莫名其妙的佔用了,因為閉包的特性就是會將變數保存在記憶體中,因此當你不小心將一些不該保存的變數保存在閉包中時,就會導致記憶體流失的狀況發生。

總結

基本上你會發現我們許多常見的開發技巧都會造成記憶體流失的問題,因此我在前面拿沙漏來當作舉例就滿剛好的,因為正常狀況下我們執行程式時,沙漏會往下一點一點的流,當程式執行完畢後,沙漏就會自動倒轉過來並等待再次執行。

但如果我們的沙漏一旦破了,那麼我們的沙漏就只會越來越少,最後就會沒有沙子可用了,而這過程就是記憶體流失的過程。

因此我們在開發上要多少注意一下記憶體的使用唷~

Liker 讚賞

這篇文章如果對你有幫助,你可以花 30 秒登入 LikeCoin 並點擊下方拍手按鈕(最多五下)免費支持與牡蠣鼓勵我。
或者你可以也可以請我「喝一杯咖啡(Donate)」。

Buy Me A Coffee Buy Me A Coffee

Google AD

撰寫一篇文章其實真的很花時間,如果你願意「關閉 Adblock (廣告阻擋器)」來支持我的話,我會非常感謝你 ヽ(・∀・)ノ