iT邦幫忙

2024 iThome 鐵人賽

DAY 28
0
JavaScript

Don't make JavaScript Just Surpise系列 第 28

垃圾回收機制(Garbage Collection)

  • 分享至 

  • xImage
  •  

在程式中,無論是宣告變數或是物件函式,都會需要記憶體空間,然而一個執行環境裡的記憶體空間是有限的,如果持續消耗記憶體空間而沒有釋放,最後會導致執行環境的資源耗竭。
因此程式語言多會有對應的記憶體釋放機制,如垃圾回收(Garbage Collection,GC),這也是 JS 中主要的記憶體回收機制。

部分語言可能需要透過手動來執行記憶體回收,但 JS 中記憶體回收的機制是自動進行的。
JS 使用的演算法為 標記-清除算法(Mark-and-Sweep Algorithm)。
這個算法的概念是:

  1. 標記(Marking):每個執行環境都有一個所謂的根物件(root),垃圾回收器會從根物件開始,往下遍歷整棵樹,並對能訪問到的物件上標記。
  2. 清除(Sweeping):沒有被標記到的物件或變數會被認為是無法訪問的(unreachable),這些物件佔用的記憶體空間會被釋放並回收。

這個處理都是 JS 引擎在背後進行的,無需開發者手動處理。

強引用與弱引用

記得我們在變數宣告的文章裡討論過,宣告一個變數實際上是指往記憶體中的一個位址,依據該變數的內容有可能再繼續往下指向其他的位址。
這個對位址的指向我們稱為「引用」。

在現代的 JS,引用可以分為兩種「強引用」(Strong Reference)和「弱引用」(Weak Reference)

早期 JS 的資料結構中,資料結構多用強引用保存物件的引用,如 Array、Object、Map、Set,只要該引用仍指著記憶體中的位置,垃圾回收器因能訪問的到,視為可訪問的(Reachable),上了標記,就不會被清除。

let foo = {bar:'bar'};//foo 指向的儲存 {} 物件的記憶體位址,強引用
let map = new Map();
map.set('foo', foo);//map 中的 foo 鍵 指向 {} 物件的記憶體位址,強引用
console.log(map.get('foo'));

foo = null;//foo 取消對 {} 的指向,但 map 仍指向 {},此時仍不可被垃圾回收
//三種讓 map 清除對 {} 指向的方式,讓 {} 得以被垃圾回收
map.delete('foo');
//這兩種是直接清除整個 map 了
map.clear();
map = null;

上面的例子實際上是用字串當鍵,但今天如果有物件當鍵的話呢?

let foo = {bar:'bar'};//foo 指向的儲存 {} 物件的記憶體位址,強引用
let map = new Map();
map.set(foo, 'foo');//map 中的 {} 鍵 指向一個儲存 'foo' 值得記憶體位址,強引用

foo = null;
//一般來說,表示我們不需要用到這個物件才會這樣寫
//在 foo = null 時,沒有好的方法能去指向 map 的對應位置了
//得用 for of 作迭代訪問,再手動清除
//過往可能通過這樣定時遍歷來清除
for (const [key, value] of map.entries()) {
    console.log(`${key}: ${value}`);//"[object Object]: foo"
    console.log(map.get(key));//foo 
}

上面展示了以前假設 foo 物件用不到時,仍需手動對 Map 中保存的引用做移除的方式(透過迭代訪問for of)。

因為這樣的不便性,在 ES 6 的時候發展出了 WeakMap 和 WeakSet 兩種型別,讓鍵值對的儲存資料型別有了新的選擇:弱引用。
弱引用指的是發生在 WeakMap 和 WeakSet 裡的引用,並不會阻撓垃圾回收(允許上標記),所以如果一個物件在這兩種資料結構裡,僅僅被這個兩資料結構所引用,則該物件是可以被回收的。

WeakMap 使用物件為鍵,使用物件以外的內容會拋出錯誤。

const weakMap = new WeakMap();
weakMap.set('key', 'value'); // Uncaught TypeError: Invalid value used as weak map key"

另外為了達成弱引用,WeakMap 本身是不可迭代的,迭代依賴強引用來實現。

let foo = {bar:'bar'};
let weakMap = new WeakMap();
weakMap.set(foo, 'foo');
for (const [key, value] of weakMap.entries()) {
    //Uncaught TypeError: weakMap.entries is not a function or its return value is not iterable"
    console.log(`${key}: ${value}`);
    console.log(weakMap.get(key));
}

舉個真實世界中的例子如何從這樣子的語法中得到好處,像是運用 WeakMap 來儲存具時效性且會被移除的物件,假設一個儲存使用者登入資訊的 WeakMap。

let user = {name:'Ryu', age:27};
let weakMap = new WeakMap();
function generateLoginInfo(user) {
    return { token: 'abc123', lastLogin: Date.now() };
}
weakMap.set(user, generateLoginInfo(user));//塞入一些臨時的使用者登入數據,如憑證或登入時間
//一段時間後,使用者登出
user = null;
//weakMap 不會阻止 GC 對 user 的標記與回收,因為 weakMap 對其是弱引用

垃圾回收機制演進

垃圾回收作為記憶體的管理機制其實也一直在演進,我們稍微看一下 JS 中有實踐的方法,大概瞭解一下。

引用計數(Reference Counting)

在標記與清除更早之前,還有一個清除方法稱作引用計數(Reference Counting)。
這個方法是會對每個物件的「被引用次數」做追蹤,當一個物件的被引用次數為零的時候,這個物件就可以被回收。
優點是當物件引用次數歸零時就能夠回收,無需等候定時清理的時間。
主要缺點是無法處理循環引用(因為循環引用對彼此的引用次數永不為零),會造成記憶體洩漏(沒有被妥善處理的程式持續佔據記憶體空間,長久下來會導致資源不足且無重啟外的方法)。

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

上面陳述過了算法,優點是能處理循環引用(當循環引用的物件已可從根物件到達時),缺點是因為要定期去做這個標記與清除的動作,這個動作本身也會有一定的效能影響。
近代的瀏覽器的 GC 方法推進幾乎都基於這個機制繼續改進或結合使用。

分代(Generational)

這個算法會把物件依據物件的存活時間來近來分代,依據具體實踐可能分為更多代,但概略來分就是「年輕世代」(Young)和「老世代」(Old)。
年輕世代指存活時間較短的,會較頻繁的被垃圾回收機制確認,通常也是使用標記與清除,快速回收短命的物件。
老世代相對就只存活時間較長的,對這些存活較久的物件,就可以降低檢查的頻率,讓每次檢查的對象專注於年輕世代上,進一步提升垃圾回收的效率。

延遲(Lazy)

這個機制主要是針對執行垃圾回收的時間做調整,讓垃圾回收盡量在系統資源空閒時再執行,而非週期一到就直接執行。這個實踐避免了在系統資源高峰期的時候進行垃圾回收影響重要計算,讓效能更有效被利用。

上面的幾個機制並非互斥,即使引用計數,處理小型物件或短期使用的資料結構也可能被混用,用於快速釋放不用的資源。現代瀏覽器主要以標記與清除為基底,可能再加上分代、延遲機制,讓整體垃圾回收的效率進一步提升,管理好記憶體的同時,也不影響主要執行的效能。


這篇主要是想介紹強引用弱引用跟垃圾回收的概念,順帶提到了 JS 中垃圾回收較主流、現代有實作的做法,讓讀者能對垃圾回收有大略的概念。


上一篇
模組(Module)的前世今生 - ES 6 Module
下一篇
JavaScript 裡的二進位與關於檔案的那些事(ArrayBuffer, Blob and File)
系列文
Don't make JavaScript Just Surpise31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言