初識垃圾回收
翻譯原文 => plumbr Java GC handbook
乍一看,垃圾回收所做的事情應當恰如其名——查找并清除垃圾。事實上卻恰恰相反。垃圾回收是用來跟蹤所有仍在使用的對象,然后將剩余的對象標記為垃圾。牢記了這點之后,我們再來更加深入地了解下這個被稱為“垃圾回收”的自動化內存回收在Java虛擬機中到底是如何實現的。
在介紹細節之前,我們從介紹垃圾回收的基本特性,核心概念和實現方法等這些基礎知識開始。
注意:這些內容是基于Oracle的Hotspot和OpenJDK的實現來介紹的,在其他的其他的運行時或者JVM版本,比如JRockit或者IBM J9上,本文所描述有些方面會完全不適用
手動管理內存
在介紹現代版的垃圾回收之前,我們先來簡單地回顧下需要手動地顯式分配及釋放內存的那些日子。如果你忘了去釋放內存,那么這塊內存就無法重用了。這塊內存被占有了卻沒被使用。這種場景被稱之為內存泄露。
下面是用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;
}
有過C語言經驗的人可以深刻的體會到,你很容易就會忘了釋放內存。內存泄露曾經是個非常普遍的問題。你只能通過不斷地修復自己的代碼來與它們進行抗爭。因此,需要有一種更優雅的方式來自動釋放無用內存,從而消除人為錯誤的可能性。這種自動化過程被稱為垃圾回收(簡稱GC)。
智能指針
自動垃圾回收早期的一種實現便是通過析構器。例如,我們在C++里面可以通過使用vector來做同樣的事情,當vector對象離開作用域時,vector的析構器會被自動調用以回收內存。
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;
}
但是在更復雜的情況下,特別是在多線程之間共享對象時,僅僅依賴析構器是無法實現自動內存回收的。這個時候,引用計數技術作為最簡單的垃圾回收器應運而生。對于每個對象,你知曉它被引用了幾次,當計數器歸零時,這個對象就可以被安全地回收掉了。C++的共享指針就是一個非常著名的例子:
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;
}
現在,為了避免在下次該函數被調用時,重新讀取元素,我們可能希望把這些元素緩存起來。在這種情況下,當該vector對象離開作用域時是不能銷毀該vector對象的。因此我們可以使用共享指針,它會記錄這個對象被引用的次數。如果你將它傳遞給別人則計數加一,當它離開了作用域后便會減一。一旦這個計數為零,共享指針會自動地刪除底層對應的vector。
自動內存管理
在上面的C++代碼中,我們還得顯式地聲明我們需要使用內存管理。那如果所有的對象都采用這個機制會怎樣呢?那簡直就太方便了,這樣開發人員便無需考慮清理內存的事情了。運行時會自動知曉哪些內存不再使用了,然后釋放掉它。也就是說,它自動地回收了這些垃圾。第一代的垃圾回收器是1959年Lisp引入的,這項技術迄今為止一直在不斷演進。
引用計數
剛才我們用C++的共享指針所演示的想法可以應用到所有的對象上來。許多語言比如說Perl, Python以及PHP,采用的都是這種方式。這個通過一張圖可以很容易說明:
綠色的云所指向的對象表示仍然被程序使用。從技術層面上來說,這有點像是正在執行的某個方法里面的局部變量,亦或是靜態變量之類的。不同編程語言的情況可能會不一樣,因此這并不是我們關注的重點。
藍色的圓圈代表的是內存中的活著的對象,可以看到有多少對象引用了它們。灰色圓圈的對象是已經沒有任何人引用的了。因此,它們屬于垃圾對象,可以被垃圾回收器清理掉。
看起來還不錯對吧?沒錯,不過這里存在著一個重大的缺陷。很容易會出現一些孤立的環,它們中的對象都不在任何域內,但彼此卻互相引用導致引用數不為0。下面便是一個例子:
看到了吧,紅色部分其實就是應用程序不再使用的垃圾對象。由于引用計數的缺陷,因此會存在內存泄露。
有幾種方法可以解決這一問題,比如說使用特殊的“弱”引用,或者使用一個單獨的算法回收循環引用。之前提到的Perl,Python以及PHP等語言,都是使用類似的方法來回收循環引用的,不過這已經超出本文講述的范圍了。我們準備詳細介紹下JVM所采用的方法。
標記刪除
首先,JVM對于對象可達性的定義要明確一些。它可不像前面那樣用綠色的云便含糊了事的,而是有著非常明確及具體的垃圾回收根對象(Garbage Collection Roots)的定義:
- 局部變量
- 活動線程
- 靜態字段
- JNI引用
- 其它(后面將會討論到)
JVM通過標記刪除的算法來記錄所有可達(存活)對象,同時確保不可達對象的那些內存能夠被重用。這包含兩個步驟:
- 標記是指遍歷所有可達對象,然后在本地內存中記錄這些對象的信息
- 刪除會確保不可達對象的內存地址可以在下一次內存分配中使用。
JVM中的不同GC算法,比如說Parallel Scavenge,Parallel Mark+Copy, CMS都是這一算法的不同實現,只是各階段略有不同而已,從概念上來講仍然是對應著上面所說的那兩個步驟。
這種實現最重要的就是不會再出現泄露的對象環了:
缺點就是應用程序的線程需要被暫停才能完成回收,如果引用一直在變的話你是無法進行計數的。這個應用程序被暫停以便JVM可以進行整理活動的情況又被稱為Stop The World pause(STW)。這種暫停被觸發的可能性有很多,不過垃圾回收應該是最常見的一種。