https://heapdump.cn/monographic/detail/37/4420851
正文:
1、什麼是垃圾回收?
顧名思義,垃圾收集(Garbage Collection)的意思就是 —— 找到垃圾並進行清理。但現有的垃圾收集實現卻恰恰相反: 垃圾收集器跟蹤所有正在使用的物件,並把其餘部分當做垃圾。記住這一點以後, 我們再深入講解記憶體自動回收的原理,探究 JVM 中垃圾收集的具體實現, 。
不摳細節, 先從基礎開始, 介紹垃圾收集的一般特徵、核心概念以及實現演算法。
2、手動記憶體管理(Manual Memory Management)
當今的自動垃圾收集演算法極為先進, 但我們先來看看什麼是手動記憶體管理。在那個時候, 如果要儲存共享資料, 必須顯式地進行 記憶體分配(allocate)和記憶體釋放(free)。如果忘記釋放, 則對應的那塊記憶體不能再次使用。記憶體一直被佔著, 卻不再使用,這種情況就稱為記憶體洩漏(memory leak
)。
以下是用C語言來手動管理記憶體的一個示例程式:
int send_request() { size_t n = read_size(); int *elements = malloc(n * sizeof(int)); if(read_elements(n, elements) < n) { // elements not freed! return -1; } // … free(elements) return 0; }
可以看到,如果程式很長,或者結構比較複雜, 很可能就會忘記釋放記憶體。記憶體洩漏曾經是個非常普遍的問題, 而且只能透過修復程式碼來解決。因此,業界迫切希望有一種更好的辦法,來自動回收不再使用的記憶體,完全消除可能的人為錯誤。這種自動機制被稱為 垃圾收集(Garbage Collection
,簡稱GC)。
智慧指標(Smart Pointers)
第一代自動垃圾收集演算法, 使用的是引用計數(reference counting)。針對每個物件, 只需要記住被引用的次數, 當引用計數變為0時, 這個物件就可以被安全地回收(reclaimed)了。一個著名的示例是 C++ 的共享指標(shared pointers):
int send_request() { size_t n = read_size(); vector<int> elements = vector<int>(n); if(read_elements(elements.size(), &elements[0]) < n) { return -1; } return 0; }
shared_ptr
被用來跟蹤引用的數量。作為引數傳遞時這個數字加1, 在離開作用域時這個數字減1。當引用計數變為0時, shared_ptr
自動刪除底層的 vector。需要向讀者指出的是,這種方式在實際程式設計中並不常見, 此處僅用於演示。
int send_request() { size_t n = read_size(); auto elements = make_shared<vector<int>>(); // read elements store_in_cache(elements); // process elements further return 0; }
現在,為了避免在下次呼叫函式時讀取元素,我們可能需要快取它們。在這種情況下,當它超出範圍時銷燬向量不是一種選擇。因此,我們使用 shared_ptr。它跟蹤對它的引用數量。這個數字隨著你的傳遞而增加,隨著它離開範圍而減少。一旦引用數達到零, shared_ptr 就會 自動刪除底層向量。
3、自動記憶體管理(Automated Memory Management)
上面的C++程式碼中,我們要顯式地宣告什麼時候需要進行記憶體管理。但不能讓所有的物件都具備這種特徵呢? 那樣就太方便了, 開發者不再耗費腦細胞, 去考慮要在何處進行記憶體清理。執行時環境會自動算出哪些記憶體不再使用,並將其釋放。換句話說, 自動進行收集垃圾。第一款垃圾收集器是1959年為Lisp語言開發的, 此後 Lisp 的垃圾收集技術也一直處於業界領先水平。
引用計數(Reference Counting)
剛剛演示的C++共享指標方式, 可以應用到所有物件。許多語言都採用這種方法, 包括 Perl、Python 和 PHP 等。下圖很好地展示了這種方式:
圖中綠色的雲(GC ROOTS) 表示程式正在使用的物件。從技術上講, 這些可能是當前正在執行的方法中的區域性變數,或者是靜態變數一類。在某些程式語言中,可能叫法不太一樣,這裡不必摳名詞。
藍色的圓圈表示可以引用到的物件, 裡面的數字就是引用計數。然後, 灰色的圓圈是各個作用域都不再引用的物件。灰色的物件被認為是垃圾, 隨時會被垃圾收集器清理。
看起來很棒, 是吧! 但這種方式有個大坑, 很容易被迴圈引用(detached cycle
) 給搞死。任何作用域中都沒有引用指向這些物件,但由於迴圈引用, 導致引用計數一直大於零。如下圖所示:
看到了嗎? 紅色的物件實際上屬於垃圾。但由於引用計數的侷限, 所以存在記憶體洩漏。
當然也有一些辦法來應對這種情況, 例如 “弱引用”(‘weak’ references), 或者使用另外的演算法來排查迴圈引用等。前面提到的 Perl、Python 和PHP 等語言, 都使用了某些方式來解決迴圈引用問題, 但本文不對其進行討論。下面介紹JVM中使用的垃圾收集方法。
標記-清除(Mark and Sweep)
首先, JVM 明確定義了什麼是物件的可達性(reachability)。我們前面所說的綠色雲這種只能算是模糊的定義, JVM 中有一類很明確很具體的物件, 稱為 垃圾收集根元素(Garbage Collection Roots
),包括:
- 區域性變數(Local variables)
- 活動執行緒(Active threads)
- 靜態域(Static fields)
- JNI引用(JNI references)
- 其他物件(稍後介紹 …)
JVM使用標記-清除演算法(Mark and Sweep algorithm), 來跟蹤所有的可達物件(即存活物件), 確保所有不可達物件(non-reachable objects)佔用的記憶體都能被重用。其中包含兩步:
-
Marking
(標記): 遍歷所有的可達物件,並在本地記憶體(native)中分門別類記下。 -
Sweeping
(清除): 這一步保證了,不可達物件所佔用的記憶體, 在之後進行記憶體分配時可以重用。
JVM中包含了多種GC演算法, 如Parallel Scavenge(並行清除), Parallel Mark+Copy(並行標記+複製) 以及 CMS, 他們在實現上略有不同, 但理論上都採用了以上兩個步驟。
標記清除演算法最重要的優勢, 就是不再因為迴圈引用而導致記憶體洩露:
而不好的地方在於, 垃圾收集過程中, 需要暫停應用程式的所有執行緒。假如不暫停,則物件間的引用關係會一直不停地發生變化, 那樣就沒法進行統計了。這種情況叫做 STW停頓(Stop The World pause
, 全線暫停), 讓應用程式暫時停止,讓JVM進行記憶體清理工作。有很多原因會觸發 STW停頓, 其中垃圾收集是最主要的因素。