Note
2012-04-19 更新影片
感謝 OSDC.tw 2012 的工作人員,今年剛好有一場講的就是 Java 的 GC,而且影片已經出來了。當你看完這篇之後,也可以看看下面這個影片,是非常好的簡介,從很基本的 Garbage Collection 介紹起到目前 OpenJDK 的 GC 現況都有。
為什麼 Java 不用歸還記憶體
上一篇我們講到了我們所 new 出來的 Java 的物件,實際上都存放在 Heap 上,並且可以透過名叫 Reference 但實際上就是 Pointer 的變數去操作這些 Java 物件。
而我們在更前面一點也提到,當你在 C 語言裡面使用 malloc 向 C 語言的 runtime 要到 Heap 上的空間後,一定要用 free 來把空間還回去,否則會造成記憶體洩漏的情況。
但你應該也會注意到,我們在寫 Java 程式的時候,沒有在釋放記憶體的,好像頂多就是寫個 myObject = null 這樣的指令,而書上就會告訴你這樣就可以把 myObject 用的記憶體回收掉的樣子。
事實上,myObject = null 是在釋放記憶體的這種說法是有點問題的,我們等下會看到。
現在你需要有的觀念是,我們在 Java 中向 JVM 要回來的記憶體空間,是由 JVM 自己決定要不要還回去,而不是由程式設計師決定!這是很重要的觀念--換句話說,在寫 Java 程式的時候,你永遠無法切確地指出在 Heap 上你的物件所佔的區塊,到底是在哪個時間底被釋放。
而 Java 用來管理與決定這些從 Heap 上要來的空間是不是要被還回去的方法,是一個叫做 Garbage Collection 的機制。
什麼是 Garbage Collection
Garbage Collection,翻成中文就是「垃圾收集」,通常簡稱 GC。
他就有點像是 Heap 上的清道夫一樣,會不時地去尋視看看現在的 Heap 裡面,有沒有不可能再被你的程式存取到的物件--如果他發現了 Heap 上面有你的程式再也拿不到的物件,那他就會認為那個物件是佔據記憶體的垃圾,可以把他拿去回收,配置給其他人用。
至於 Java 裡 GC 的機制是怎麼判斷你的程式能不能用到某個物件,我們後面會提到。
Garbage Collection 收集與釋放的對象
在 Java 的世界裡頭,GC 佔了很重要的角色,他會幫你管理和釋放 Heap 上你已經用不到物件所佔的空間,並把他配置給其他需要用到的物件。
所以接下來這個問題就很重要了--GC 所管理和回收的記憶體空間,到底是以什麼東西為單位?
如果你去看 The Java HotSpot Virtual Machine 和 The Java™ Language Specification 裡關於 GC 的說明的話,就會發現 Java 裡的 GC 作用的層級都是「物件」。
也就是說,就 Java Programmer 的觀點來看,GC 所回收的記憶體是一個個用不到的「物件」所佔據的 Heap 空間,而不是零碎的記憶體區塊。
到底什麼是 Java 物件?
在 Java 當中,對於「物件 / object」這個詞有相當嚴謹的定義,同樣的,在 The Java™ Language Specification 這份規格書中,4.3.1 中對於 object 的定義如下:
An object is a class instance or an array.
我們已經知道 array 就是我們常用的陣列,那 class instance 又是什麼呢?簡單的說,就是當你在程式碼中寫下 new MyClass() 這樣的程式碼時,Java 在 Heap 上配置給你的那個東西,也就是我們所熟悉的物件。
請注意這裡隱含了兩個意義:
- Java 裡,你用那八個以小寫開頭的 primitive type 宣告的變數不是物件
- 既然物件是存在 Heap 當中,我們一定要透過 Reference 才能存取到他
Java GC 的運作方式
現在我們已經知道 GC 的最主要的工作之一是想辦法找出 Heap 當中已經不能被程式使用到的物件,這樣才能知道哪些物件所佔的空間可以被釋放,但這要怎麼做呢?
基本上目前絕大部份的 Java VM 用的方法,都是基於一個叫做 Mark and Sweep 的演算法概念,雖然為了 GC 的效率上而有一些變型,但基礎的概念是相同的。
這個演算法的想法很簡單:
- Heap 上的物件分成可以被程式「接觸」到,和不能被程式接觸到這兩種類別
- 想辦法把所有可以被「接觸」到的物件打個勾,說這些是不能動的物件,是 Programmer 還有可能操作到的物件
- 當步驟二結束後,那些沒有被標記到的物件就被視為垃圾,因為理論上我們不可能再透過任何方式拿到那個物件了。
- 接著,GC 就可以安全地清除那些沒有被標記到的物件
順道一提,這篇文章中有一個互動式的 Java Applet 可以玩,可以讓你模擬在 Heap 中生成物件 (Allocate Fish)、將 Reference 指到物件(Assign Reference)和 GC 的過程,有興趣的可以玩玩看。
GC 如何判斷一個物件可不可以被接觸
所以現在要回答的一個問題,就是 GC 要如何判斷某個物件能不能被某個程式接觸到呢?
回想一下我們到現在所知道的幾個事實:
- 所有的 Java 物件都放在 Heap 上
- 在 Java 裡我們是透過名叫 Reference 的 Pointer 去取得 Heap 中的物件來操作
沒錯!你答對了--只要一個物件在 Java 程式裡,再也沒辦法讓寫程式的人尋著 Reference 去找到他,那他就被視作無法被接觸到,而會被當成垃圾。
myObject = null 實際上的意義
再回過頭來討論,在 Java 當中我們要怎麼樣讓 GC 認為一個物件已經是「不可被接觸到」的狀態呢?
你可能會在很多教你寫 Java 的書當中看到,他會告訴你物件不用的時候,需要把他設為 null 來釋放記憶體,但這句話到底是什麼意思呢?
講了一堆理論,我們還是直接來看程式碼的行為會比較清楚:
public class SimpleObject
{
private int x = 10;
public static void main(String [] args)
{
SimpleObject object1;
SimpleObject object2;
object1 = new SimpleObject();
object2 = new SimpleObject();
object1.x = 20;
object1 = null;
// 假設 GC 在這個時候被執行
// System.out.println(object1.x); // 會丟出 NullPointerException
}
}
我們現在應該很清楚這隻程式到 object1.x 為止的記憶體狀態了,所以這邊的重點在於 object1 = null 這行到底會發生什麼事。
你現在應該已經知道,object1 這個變數實際上是存在 Stack 上面,而我們用 = 號來指定東西給 object1 時,實際上是改變 Stack 上 object1 這個變數的內容了。
所以如果我們把記憶體狀態畫出來,就會變成像下面這個樣子:
現在你可以看到,實際上 null 存放的地方是 Stack 上的變數,而當某個 reference 變數他存的地址被設成 null 時,其語意是「這個 reference 不再指到任何東西」,他存的是一個特殊的數字,代表不指到任何記憶體地址。
而現在看到上面的圖,你也應該很清楚怎麼樣判斷一個物件是不是「無法被接觸」了--只要你沒辦法從你的 Call stack 當中的箭頭找到他時,這個物件就可以被當成是「無法被接觸」了。
換句話說,當我們經過了 object1 = null 這一行時,原本 object1 所指到 object 實際上在我們的程式裡就不可能被用到了,因為所有在 Call Stack 上的變數都無法讓我們找他,而這時 GC 就會把這個物件當成是可以回收的狀態。
同時現在你也應該知道,為什麼如果在最後一行加上 System.out.println(object1.x); 的話,Java 會丟出 java.lang.NullPointerException 這個錯誤了。因為 null 代表的是不指到任何記憶體地址,你自然不能用 . 號去取得那個記憶體地址的內容了。
如果物件裡還有其他的 Reference 呢?
上面我們看的是很簡單的例子,類別裡面的成員變數就只有基本資料型態而已。那如果我們的成員變數裡面又有其他的 Reference,而且還指到了其他的物件,那記憶體狀態會是什麼,什麼樣的物件又會被視為可回收呢?
還是一樣,一段 Code 和幾張圖抵過千言萬語,直接來看程式碼唄:
class AnotherObject
{
private int x = 30;
}
public class SimpleObject
{
private int x = 10;
private double y = 20.0;
private AnotherObject z = new AnotherObject();
public static void main (String [] args)
{
SimpleObject object1;
SimpleObject object2;
object1 = new SimpleObject();
object2 = new SimpleObject();
object1.x = 20;
object1.z = null;
object2 = object1;
}
}
在上一篇文章中,我們已經知道了當 object1.x = 20 這行程式碼執行完之後,記憶體當裡面的架構會長得像下面一樣:
接下來我們用 object1.z = null 把 object1 裡的 z 這個變數的內容設成 null,所以現在記憶體狀態會變成像下面一樣:
現在你會發現,我們的 Heap 上有一個 AnotherObject 再也無法從 Call Stack 上的任何箭頭找到,所以可以被 GC 視為是垃圾。
另外你也應該發現,object1 所指到的物件裡的 z 變數,他所佔的那塊空間,仍然好端端地活在那個 SimpleObject 物件中,沒有被標上藍色,這是很重要的一個觀念--
- 當某個的 Java 物被 GC 視為可回收時,若原本指到他的那個 reference 是某個物件的成變數,那其所需要的「存地址」的空間都還會被繼續佔用,只是他存放的已經不是原本的記憶體地址,可能指到其他的新物件,也可能是 null。
- 一個物件的成員變數所佔的空間,只有在該物件本身被視為可回收之後,才有可能被釋放。
- 換句話說,Java 物件的成員變數的生命週期和物件的生命週期是一致的。
最後,我們來看看將 object2 設成 object1 之後會發生什麼事情。
object2 = object1 由於兩邊都是「地址」,所以這個動作實際上是把 Stack 上 object1 所存的記憶體位置,複製一份到現在 Stack 上 object2 變數所在的地方,而就語意上來看,就是現在 object1 和 object2 都指到同一個物件。
由於 object2 已經被設為原來 object1 所指到的物件,現在 Call Stack 上沒有任何 Reference 指到 Heap 上方的那個 SimpleObject,所以他被標成藍色是理所當然的。
接著問題就來了,被標成藍色的那個 SimpleObject 裡的 z 所指到的那個 AnotherObject 到底該不該被視為可以被回收的垃圾呢?
請回想我們對於垃圾的判斷--無法從 Call Stack 上的箭頭尋線找到的物件。
現在你應該很清楚了,因為我們無法從 Call Stack 上找到藍色的那個 SimpleObject 物件,自然也就無法透過那個 SimpleObject 物件的 z 找到 AnotherObject,所以 AnotherObject 可以被標上藍色。
所以我們最終的記憶體狀態會像下面一樣:
而如果 GC 在這個時間點執行,並且決定要清掉所有垃圾的話,那些被標成藍色的物件就會被清掉,變成下像面一樣,而原本被藍色物件佔據的空間就可以再配置給其他需要的物件:
這邊還是要再重提一次,雖然 object1.z 所指到的物件已經被回收消失了,但 object1.z 那個存 reference 的空間還是存在那個活著的 SimpleObject 當中的--Java 的 GC 回收的是「物件」,而不是單獨的「成員變數」,這一點是想要理解 Java 的記憶體管理機制時很重要的一點。
在方法中製造物件
還記得我們在第二篇裡提到的那個 C 語言的記憶體洩漏的例子嗎?
如果這次我們把那隻程式改成如下的 Java 程式碼,在程式碼裡 malloc 改成 new 一個物件,但是在函式結束前沒有把指到物件的 reference 變數設成 null,這時又會發生什麼事情呢?
public class SimpleObject
{
private int x = 10;
void requireHeap()
{
SimpleObject object = new SimpleObject();
System.out.println(x);
}
public static void main (String [] args)
{
requireHeap();
}
}
綜合之前所談的 Call Stack 和區域變數的關係,以及 Heap 和 Java 物件還有 Reference 的互動,現在你應該很容易可以畫出來,在程式執行到 System.out.println(x); 時的記憶體狀態了:
但接下來問是就來了,當 requireHeap() 返回之後會發生什麼事呢?
還記得放在 Stack 上的區域變數的存活時間只到函數返回前嗎?所以當我們的 requireHeap() 結束,返回 main() 後,放在 Stack 上的 requireHeap() 裡的區域變數就消失了,圖會變像下面這樣:
你會發現,現在 Call Stack 上已經沒有任何箭頭可以讓你指到原本的 SimpleObject 了,而在這樣的情況下,這個 SimpleObject 當然可以被標成藍色,視為是可以回收的物件了。
換句話說,就算你呼叫了一百次的 requireHeap(),也只會在 Heap 上產生一百個可以被視為垃圾的物件,而 Java 的 GC 會在需要記憶體時去釋放你那些垃圾物件所佔的空間。
所以 Java 不會有 Memory Leak 嗎?
不,雖然 Java 有很好用的 GC 來讓寫程式的人不用太花腦力自行去釋放記憶體,但當你的程式複雜到一定程度的時候,很有可能你不知不覺地在無意間一直持有著某個實際上已經不會再被使用的物件的 reference。
關於這個問題,如果你有興趣,你可以看 StackOverflow 上的這個討論,裡面有一些有趣的例子,但你也應該會注意到,要在 Java 中刻意製造 Memory Leak 好像不像 C 語言這麼簡單。
為什麼整數的成員變數不可能被 GC 單獨回收
到這邊,我們已經把 Java 的變數分類、物件的定義,以及 Java 物件在 Heap 裡的生成和如何被 GC 給回收介紹過一次了。
現在你應該可以知道為什麼我會在上一篇講 Android 的文章中提到,雖然我不清楚那個整數變數忽然不預期地被歸零的真正原因,但卻認為不太可能是 GC 把他回收掉所造成的理由了。
因為在 Java 裡,一個成員變數的生命週期和物件是一致的,只有在該物件被 GC 消滅之後,他所佔的空間才會被釋放出來。
再從另外一個角度來看,在 Java 裡面,GC 的作用單位是一個 Java 物件,但在 Java 裡整數是 primitive type 而不是物件,一個根本不是物件的 primitive type 的變數所佔據的空間,會直接被 GC 幹走,也是不可思議的。
所以「一個屬於 primitive type 的成員變數被 GC 回收」這件事,本身就與 Java 對於記憶體的操作模型相違背。
除非你用的 Java 環境的 GC 有 bug,又或者是那個 GC 完全無視 Java 對於物件和變數的生命週期的定義,故意在 Heap 上的某個物件裡挖一個洞給你跳,不然正常來說,型態為整數的成員變數,其所佔用的空間不可能在所屬物件被消滅之前被回收,而這兩種情況,都不會是 Java 的 GC 的正常行為。
這點在 Android 上也是一樣的,因為雖然 Android 用的是 Dalvik VM,但他的 GC 對於物件的回收機制和一般的 Java VM 並沒有什麼不同,有興趣的話可以參考這個 Google IO 的演講。
整數真的不會被回收嗎?
是的,只要你的 Java VM 所用的 GC 沒有 bug,也沒有亂挖洞給你跳的話。
在正常的情況下,你宣告為物件成員變數的整數變數所佔的空間,在那個物件被回收之前,是不會被 GC 動到的,除非--你以為他是個整數,但實際上他根本是個 Java 物件。
這是因為 Java 有一個叫 Auto Boxing / Unboxing 的機制,會讓你很方便的把 primitive type 的變數變成 Integer / Long / Double... 等的物件,但這個時候,常常都會有一些陷阱在。
Auto-boxing 範例
至於什麼是 Auto-boxing 呢?下面的程式碼可以表現出這件事情:
public class TrapObject
{
private int x = 10;
private Integer y = null;
public static void main (String [] args)
{
TrapObject object = new TrapObject();
object.y = object.x;
System.out.println("object.x:" + object.x);
System.out.println("object.y:" + object.y);
System.out.println("hashCode:" + System.identityHashCode(object.y));
object.x = 20;
System.out.println("object.x:" + object.x);
System.out.println("object.y:" + object.y);
System.out.println("hashCode:" + System.identityHashCode(object.y));
object.y = 30;
System.out.println("object.x:" + object.x);
System.out.println("object.y:" + object.y);
System.out.println("hashCode:" + System.identityHashCode(object.y));
}
}
現在的你,看到這樣的程式碼,應該可以說出以下的幾件事情:
- main() 函式裡的 object 變數是放在 Stack 上,而且是個 Reference 變數,存的是記憶體地址
- TrapObject 裡的 x 是 primitve type,也就是他存的是「數值」。
- TrapObject 裡的 y 是大寫開頭的 Integer,而且還可以被指為 null,所以他一定是 Reference,存的是地址。
接著我們同樣一步一步地把程式執行的時候的情況畫出來,一開始是用 new TrapObject() 建立物件:
接著會執行 object.y = object.x; 這行……咦?!
不是說 object.y 是 Reference 而 object.x 是整數嗎?為什麼整數可以被指派給一個 Reference 呢?
答案是……Java 實際上會幫你把 object.y = object.x 這句話,翻譯成 object.y = new Integer(object.x)!
看到 new 這個關鍵字了嗎?沒錯,我們實際上是在 Heap 上多產生出一個 Integer 物件,並且再把這個 Integer 物件的記憶體地址指派給 object.y 這個 Reference,所現在實際上的記憶體狀態是這樣的:
所以在這個時間點把 object.x 和 object.y 印出來的話,就會像下面一樣,其中 hashCode 是 Java 從 Integer 物件所在的記憶體地址所算出來的值。
object.x:10
object.y:10
hashCode:1560511937
接著,我們再把 object.x 設成 20,這個時候你應該已經可以判斷出 object.x 是 primitive type,所以我們改的是 TrapObject 裡 x 的值,變成下面這樣子:
而因為那個 Integer 物件,完全沒被我們動到,所以再次輸出的結果,會是 object.x 被更動了,但 object.y 還是維持原樣:
object.x:20
object.y:10
hashCode:1560511937
最後,我們再來看看 object.y = 30 這行會做什麼事情……
沒錯,你答對了!Java 又幫我們在 Heap 上多生出了一個 Integer 物件,並且把 object.y 這個 reference 指到的地址改成新的 Integer 物件,變成像下面一樣:
所以現在再把他印出來的話,會發現 object.y 的值不但更改了,甚至連從記憶體地址算出來的 object.y 的 hashCode 都改了,就是因為現在的 object.y 已經指到了一個全新的物件的原因。
object.x:20
object.y:30
hashCode:306344348
在上面的例子,我們就可以看到一個「整數被 GC 回收」的假象,但實際上被 GC 回收的卻是「一個存放整數的盒子 (Integer 物件)」這樣的物件。
而且只要你沒把 object.y 設成 null 的話,那目前 object.y 所指到的整數在 TrapObject 被標成垃圾前,也不可能被回收,因為 GC 在標記要清除物件的時候,一定可以從 TrapObject 裡的 y 找到他,並幫他打勾。
其他 Auto-boxing 的場合
在 Java 中最容易遇到 Auto-Boxing 的地方,應該就是你在用各類的 Collection 時了!
例如你可能很習慣當要一個不定長度的陣列時來存放整數時,用 ArrayList 來做,寫成像下面的程式碼一樣:
import java.util.ArrayList;
public class TrapObject
{
public static void main (String [] args)
{
ArrayList<Integer> list = new ArrayList<Integer>();
list.add(1);
list.add(2);
list.add(3);
}
}
這個時候你應該會發現,ArrayList 裡放的是 Integer!而 Integer 是實實在在的 Reference Type,所以實際上這段程式碼會像下面一樣,在 Heap 上產生三個 Integer 物件。
import java.util.ArrayList;
public class TrapObject
{
public static void main (String [] args)
{
ArrayList<Integer> list = new ArrayList<Integer>();
list.add(new Integer(1));
list.add(new Integer(2));
list.add(new Integer(3));
}
}
小結
這次我們花了相當的篇幅,來說明 Java 中如何解決釋放 Heap 上所使用到的空間,GC 的作用單位,以及 Java 的類別裡面,物件成員變數的生命週期,還有資料型態對於 GC 的影響……等等。
到這邊,我們終於把 Stack / Heap 上的變數稍微帶過一遍了,但你應該也發現目前我們完全沒有提到當 C 語言的函式或 Java 的方法帶有參數時,會發生什麼事情。
所以下一次,我們就會來看看 C 語言函式裡面的參數到底在搞什麼鬼。
參考資料
如果你想要知道 Java VM (特別是 Sun / Oracle官方的 HotSpot VM)實際上如何管理記憶體,可以參照以下兩份文件:
回響