注:本文主要針對初學GC的讀者,筆者對於GC的瞭解比較疏漏,有學習的慾望但終究時間太少,為了達到一個大致瞭解的程度,才寫筆記以理解之。文中有眾多用詞不當之處望讀者指正。
前言
學習並使用閉包的時候總會在各部落格裡面看到閉包的壞處有一條:
使用不當的閉包將會在IE(IE9之前)中造成記憶體洩漏
uhhh,為什麼在IE9之前會造成這樣的結果呢?我就開始繼續尋找答案,找到一條比較滿意的:
IE9的JavaScript引擎使用的垃圾回收演算法是引用計數法,對於迴圈引用將會導致GC無法回收“應該被回收”的記憶體。造成了無意義的記憶體佔用,也就是記憶體洩漏。
先科普一下:記憶體洩漏(Memory Leak)是指程式中己動態分配的堆記憶體由於某種原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統崩潰等嚴重後果。
那麼,我有了以下幾個疑問:
- 什麼是迴圈引用?
- GC是什麼?它是怎麼工作的?
- 為什麼引用計數演算法將會導致記憶體無法釋放?
- JavaScript(或者說JavaScript引擎)還有多少垃圾回收演算法?
所以我就“科學上網”去不存在的Google搜了一下“JS的垃圾回收機制”,但是,前5篇出來的結果一眼就能看出是互相貼上複製別人部落格的,裡面無不提到這樣一段話:
在變數進入執行環境時,會新增一個進入標記,當變數離開時,會新增一個離開標記,標記清除是GC在執行時會給所有變數加上標記,然後去掉那些還在環境中或還被環境中變數引用的變數,清除剩下還被標記的所有變數。
抱歉,我理解能力有限,不明白“離開標記”是什麼,“然後去掉”是什麼時候去掉的,具體怎麼觸發的還是自動執行的。對此,我只能:
所以,不得不自己看書了,在圖靈社群找到一本比較好的電子書《垃圾回收的演算法與實現》,看了部分之後,基本對GC有了初步瞭解(起碼知道是怎麼工作的了)。
那麼下面,就逐步解決之前提到的幾點疑惑,並且用圖文結合的方式給大家提供一個快速理解的方法。當然,建議最好還是能夠去讀一讀上面提到的那邊電子書。
什麼是GC
GC是Garbage Collection的縮寫,意為垃圾回收,Uhh,說到垃圾回收,我想到的是下面這個場景:
對!就是這個,在現實生活中我們會產生很多垃圾,總有一群人在我們還在睡覺或者外出工作的時候悄無聲息地把垃圾收走,那麼對於程式也是這樣的,在程式工作的過程中,總會產生很多'垃圾',這些垃圾是程式不用的記憶體空間(可能是在之前用過了,以後不會再用的)。那麼GC就是負責收走垃圾的,因為他工作在JavaScript引擎內部,所以對於我們前端開發者來說,GC在“一定程度上”是悄無聲息工作的(注意此處的加引號部分)。
那麼,我們明確了GC做什麼
了:
- 找到記憶體空間中的垃圾。
- 回收垃圾,讓程式設計師能再次利用這部分空間。
這裡要注意的是,不是所有語言的世界裡面都有GC
,相對來說,高階語言裡面一般會帶GC,比如Java
,JavaScript
,Python
,在沒有GC的世界裡,需要程式設計師手動管理記憶體,比如C語言我們常見的malloc/free
,其實就是memory allocation
的縮寫。當然,還有C++裡面的new/delete
。
還有一點,GC是一門古老但不過時的技術,在1960年就首次釋出了GC演算法,但是時至今日,我們仍然需要研究更為優秀的GC演算法來讓程式“更優秀”。
為什麼要使用GC
一句話:“省事兒”。
省去開發者手動管理記憶體的麻煩(不是每個開發者都能管理好),從而減少BUG的產生,把精力留給更本質的程式設計工作。
為什麼要學點GC
一句話:“為了更好地找BUG”。
作為前端開發者,其實是比較欠缺計算機體系知識的(以我自己舉例),比如作業系統,計算機組成原理和計算機網路。但是這些知識,確實解決實際開發問題的根基所在,所以,有一個更好的基礎能帶來更快的開發/維護效率,而學習GC能為我們提供部分計算機體系知識的思想。比如"標記-清除法"就和作業系統頁面置換第二次機會演算法類似,所以知識是融匯貫通的,這裡我們接觸了,那麼以後需要學習更多知識的時候就會更得心應手。
必備的基礎概念
-
堆(HEAP)是用哦關於動態存放
物件
的記憶體空間 而物件
在JavaScript裡面是引用型別
,之前在我的另一篇部落格有講JavaScript的型別。 -
mutator,這個詞意思晦澀,在GC裡面代表應用程式本身,我們暫且理解為
mutator
需要大量的記憶體。 -
allocator,mutator將需要記憶體的申請提交到此,
allocator
負責從堆中調取足夠記憶體空間供mutator
使用。
一張圖理解之:
- 活動物件/非活動物件:代表通過
mutator
引用的物件,舉個例子:
var a = {name: 'bar'} // '這個物件'被a引用,是活動物件。
a=null; // ‘這個物件’沒有被a引用了,這個物件是非活動物件。
複製程式碼
常用的幾種GC演算法
引用計數法
(圖源自《垃圾回收演算法和實現》)
顧名思義,讓所有物件實現記錄下有多少“程式”在引用自己,讓各物件都知道自己的“人氣指數”。舉一個簡單的例子:
var a = new Object(); // 此時'這個物件'的引用計數為1(a在引用)
var b = a; // ‘這個物件’的引用計數是2(a,b)
a = null; // reference_count = 1
b = null; // reference_count = 0
// 下一步 GC來回收‘這個物件’了
複製程式碼
這個方法有優勢也有劣勢:
優勢
- 可即刻回收垃圾,當被引用數值為0時,物件馬上會把自己作為空閒空間連到空閒連結串列上,也就是說。在變成垃圾的時候就立刻被回收。
- 因為是即時回收,那麼‘程式’不會暫停去單獨使用很長一段時間的GC,那麼最大暫停時間很短。
- 不用去遍歷堆裡面的所有活動物件和非活動物件
劣勢
- 計數器需要佔很大的位置,因為不能預估被引用的上限,打個比方,可能出現32位即2的32次方個物件同時引用一個物件,那麼計數器就需要32位。
- 最大的劣勢是無法解決迴圈引用無法回收的問題 這就是前文中IE9之前出現的問題
一個簡單的例子:
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2,o2的引用次數是1
o2.a = o; // o2 引用 o,o的引用此時是1
return "azerty";
}
f();
複製程式碼
fn在執行完成之後理應回收fn作用域裡面的記憶體空間,但是因為o
裡面有一個屬性引用o2
,導致o2
的引用次數始終為1,o2
也是如此,而又非專門當做閉包
來使用,所以這裡就應該使o
和o2
被銷燬。
因為演算法是將引用次數為0
的物件銷燬,此處都不為0,導致GC不會回收他們,那麼這就是記憶體洩漏
問題。
該演算法已經逐漸被 ‘標記-清除’ 演算法替代,在V8引擎裡面,使用最多的就是
標記-清除演算法
標記清除演算法
主要將GC的垃圾回收過程分為兩個階段
- 標記階段:把所有活動物件做上標記。
- 清除階段:把沒有標記(也就是非活動物件)銷燬。
標記階段
根可以理解成我們的全域性作用域,GC從全域性作用域的變數,沿作用域逐層往裡遍歷(對,是深度遍歷),當遍歷到堆中物件時,說明該物件被引用著,則打上一個標記,繼續遞迴遍歷(因為肯定存在堆中物件引用另一個堆中物件),直到遍歷到最後一個(最深的一層作用域)節點。
標記完成之後,就是這樣的:
清除階段
又要遍歷,這次是遍歷整個堆,回收沒有打上標記的物件。
這裡我們不細講如何將獲得的記憶體空間再分配的問題,這個地方有點類似磁碟管理或者記憶體管理,比如
best-fit,First-fit,Worst-fit
。以及碎片化問題的產生和解決方法。
這種方法可以解決迴圈引用問題,因為兩個物件從全域性物件出發無法獲取。因此,他們無法被標記,他們將會被垃圾回收器回收。正如圖:
優勢:
- 實現簡單,打標記也就是打或者不打兩種可能,所以就一位二進位制位就可以表示
- 解決了迴圈引用問題
缺點
- 造成碎片化(有點類似磁碟的碎片化)
- 再分配時遍次數多,如果一直沒有找到合適的記憶體塊大小,那麼會遍歷空閒連結串列(儲存堆中所有空閒地址空間的地址形成的連結串列)一直遍歷到尾端
這種GC方式是一個定時執行的任務,也就是說當程式執行一段時間後,統一GC,類似如圖:
複製演算法
複製演算法配合這張圖理解起來非常簡單,就是隻把某個空間的活動物件複製到其他空間。
將一個記憶體空間分為兩部分,一部分是From空間,另一部分是To空間,將From空間裡面的活動物件複製到To空間,然後釋放掉整個From空間,然後此刻將From空間和To空間的身份互換,那麼就完成了一次GC。
如圖所示:
還有
還有幾個GC演算法,準備在下一篇部落格用圖解的方式總結一下V8實現的GC演算法(複製,標記-清除,壓縮)。
總結
回顧我們之前的問題
- 什麼是迴圈引用?
- GC是什麼?它是怎麼工作的?
- 為什麼引用計數演算法將會導致記憶體無法釋放?
- JavaScript(或者說JavaScript引擎)還有多少垃圾回收演算法?
好好想想我們是不是都應該有個比較清楚的答案了呢?