Java筆記 - 物件參照級別之分析 (一)
這系列會簡單介紹一下 Java 中的物件參照級別有哪些/差異是什麼. 老實說, 出社會工作幾年了, 這一塊仍然是我一直沒有很懂的一個部分, 因為平常用不太到 strong reference 之外的其他參照級別, 希望在寫這幾篇的過程中可以更了解一些.
關於 Java heap 以及 object life cycle
在 Java 裡, 所有的物件都是儲存在記憶體的 heap 中的, 且必須透過 new 關鍵字來建立物件. 區域變數則是儲存在記憶體的 stack 中, 但區域變數可以持有指向某物件的指標 (pointer), 這些指標又稱為參照 (references). 首先, 請看一下以下這段程式碼:

下面這張圖顯示了在這個方法中, heap 與 stack 之間的關係:

Stack 可以被分成多個 “frames”, 每個 frame 包含了每個 call tree 中的各個方法之參數 (parameter) 與區域變數 (local variables). 其中, 指向物件的那些變數 — 在這邊就是 bar 這個參數以及 baz 這個區域變數, 它們都指向了存活於 heap 中的物件.
現在回到 foo() 這個方法, 看到第一行, 這裡在記憶體上分配 (allocate) 了一個 Integer 物件. 在這背後, JVM 首先會嘗試為這個要被建立的物件在 heap 區塊裡找到足夠的記憶體空間 — 若以 32-bit 的 JVM 來講, 大概就是 12 bytes 的記憶體大小. 如果找到了足夠的記憶體空間, 就可以呼叫 Integer 的 constructor, 並且傳入 bar 所代表的字串值並且初始化物件. 最後, JVM 會將指向這個物件的參照存到變數 baz 中.
這是正常的狀況, 那出包的話呢? 出包的情況有很多, 這邊我們關心的是這樣一種情境 — 當 new 關鍵字無法找到足夠的 12 bytes 記憶體給這個物件時. 這時候, 在噴出 OutOfMemoryError (OOM) 之前, JVM 會呼叫 GC (Garbage Collector) 試著在記憶體上清理出空間來用.
Garbage Collection
我們都知道, 當你透過 new 關鍵字在 memory heap 裡面分配物件之後, Java 並沒有給你一個 delete 操作來將這些分配到 heap 上的東西移除. 以上面的 foo 方法為例, 當其回傳後, 變數 baz 就已經結束其生命週期了 (out of scope), 但其原先指向的那個物件還是留存在 heap 上. 事實當然不是這樣, 不然現實世界中以 Java 開發的軟體系統就會整天 OOM 了. 關於這部分, Java 提供了垃圾收集器 (Garbage Collector) 來清理這些不再被參照到的物件.
當你的程式想要建立新物件但是記憶體又不夠時, GC 就會開始運作 (這只是 GC 會運作的眾多情境之一). 此時剛才要求記憶體的執行緒會被停住 (suspended), 然後 GC 會開始在 heap 中查看是否有物件是不再被程式使用到的, 有的話就把空間清出來. 如果 GC 沒有辦法釋放出足夠的記憶體, 而此時 JVM 也沒辦法去擴張 heap 的話, new 關鍵字就會失效並且噴出 OOM. 通常這時候你的程式也會跟著 shut down.
How to Distinguish the Object Can be Collected?
關於 GC 用來判斷物件是否可以被回收的演算法, 有人可能會以為是使用 reference counting 這類的演算法, 實際上並不是, 主要是因為 reference counting 有個缺點: 對於物件出現 ”孤島參照” 這種情況時, reference counting 就沒有辦法正確的回收這些物件了. 事實上, 在大部分的商用 JVM 解決方案裡, 都是使用一種稱為 “Reachability Analysis” 的演算法去判定一個物件是否可以被回收的. 這個演算法的核心概念是通過一系列稱為”GC Roots”的物件作為起點, 從這些節點開始向下搜尋, 搜尋的路徑稱為reference chain, 當一個物件到GC Roots沒有任何reference chain相連的話, 就表示這個物件是不可用的了. 如下圖所示:

- JVM Stack (這裡指 Local Variable Table)中參照的物件
- Method Area 中靜態屬性參照的物件
- Method Area 中常數參照的物件
- Native Method Stack 中 JNI (就是 Native method)參照的物件
用比較常見的例子的話, 像是:
- 方法參數
- 區域變數
- 當前執行的表達式 (executing expression) 中的運算元 (operands)
- 靜態類別的成員變數
關於可達性分析, 是非常重要的, 因為了解了 root reference 的概念, 才能了解什麼是 “強參照 (strong reference)”: 若你可以沿著一個參照鍊 (reference chain) 的根部 (root) 依序找到某個特定的物件, 那麼這個物件就是被強參照著的 (“strongly referenced”), 意味著其不會被 GC 給回收掉.
講到這裡, 再回頭看一下上面的 foo 方法: 參數 bar 以及區域變數 baz 只有在方法還在執行時才是強參照, 當方法結束, 它們都會 out of scope, 然後被它們參照到的物件都會變成可收集的狀態 (eligible for collection).
換個角度想, 如果 foo 方法有回傳其本身建立的 Integer 物件之參照的話 (就是 baz), 表示那個物件對於所有呼叫 foo 方法的其它方法來說, 都是強參照.
現在再來看一下這段 code:

變數 foo 是 root reference, 其會指向後面 new 出來的 LinkedList 物件. 在這個 foo list 裡面可以是零或多個元素, 每個都指向自己的前一個物件 (successor). 當第二行呼叫了 add 方法, 就可以加入新的元素 (Integer 型別的 123). 這個元素鍊基本上也是一種強參照, 表示第二行建立的 Integer(123) 是不可回收的. 不過, 當 foo 之後脫離了其本身的生命週期後, foo list 以及其內部的所有物件都將會變成可回收的.
Finalize
在 C++ 這類的語言裡, 我們可以透過 destructor 來釋放記憶體上那些透過 new / malloc 關鍵字建立的物件. 在 Java 中, 則是由 GC 來為我們清理記憶體, 所以在 Java 中並沒有所謂的 destructor. 然而, 記憶體並不是唯一需要被清掃的部分. 舉個例子, FileOutputStream: 當你建立了其 instance 之後, 其會從作業系統分配一個檔案描述符 (file descriptor). 如果你在關閉這個 stream 物件之前將所有與之有關聯的參照都 out of scope 的話, 對於作業系統上的這個 file descriptor 會發生什麼事呢? 答案是在這個 stream 物件上會有一個 finalize 方法: 一個在物件被 GC 之前會讓 JVM 進行呼叫的方法. 以下就是這個方法的實作內容:

其中, close() 的實作內容如下:

其主要的功用就是關閉當前的 stream, 同時也會釋放那些跟作業系統有關的系統資源, 如 file descriptor, 以及 flush 任何的 buffer, 以確保所有資料有適當地寫入磁碟裡.
在 Java 中, 所有物件都可以實作 finalize, 只要如上圖的方式宣告 finalize 方法即可. 不過, 在大多數的場景下, 並不會推薦使用 finalize, 原因可以參考這篇文章, 這邊就不贅述了.
Object Life Cycle
總結一下到目前看過的東西, 一個物件的生命週期可以被總結成下面這張簡單的流程圖:

圖中的陰影部分代表了物件處於強可達狀態(strongly reachable)的時間, 可達狀態有很多種, 這是一種跟 reference object 有關的術語, 之後會大量提到.
