Chapter 3-4. GC - Basics
Last updated
Last updated
談到GC, 通常我們要思考三件事情:
When - 什麼時候要回收?
What - 哪些記憶體要被回收?
How - 怎麼回收?
關於這三件事, 會在 Chapter 3-10的部分做個總結.
在前面的Chapter 3-1裡, 我們提到了JVM裡各個記憶體區塊及其特性, 其中, Program Counter Register/JVM Stack/Native Method Stack這三塊的生命週期基本上是跟執行緒一樣的, 且stack中要分配多少記憶體也是在class結構確定下來後就已經可以計算出來的(大部分情況下啦). 因此這幾個區塊的記憶體分配/回收都具有確定性, 因此不太需要過多去考慮回收的問題. 然而, Java Heap/Method Area就沒有這麼好了, 譬如說一個介面的多個實作, 其各別所需要的記憶體空間可能就差很多, 一個方法中的多個分支(if...else這類)所導致的記憶體需求也會差很多, 這些都只能在runtime的時候才能知道要建立哪些物件, 故這兩個區塊的記憶體分配/回收都是動態的, 而這就是GC所關注的部分. 在之後的文章裡, 探討的記憶體分配/回收基本上就會專注在這兩個區塊上.
GC在對heap中進行回收之前, 第一件事就是要確定這些物件之中哪些還活著, 哪些已經死了. 關於這部分, 常見的有以下兩種作法:
Reference Counting: 這不是一個完善的做法, 但還是在這邊紀錄一下, 其主要概念是說, 在物件中添加一個counter, 每當有一個地方參照這個物件時, counter就+1; 當參照失效時, counter就-1; 任何時刻當counter變0的話, 就表示這個物件不可能再被使用了. 這在客觀上來說好像很正確, 大部分情況下可能都沒問題, 但就是有例外. 試想一下, 如果物件之間出現了互相循環參照的情況下, 而外界對這些物件之間的參照又斷掉了的情況下, 這些物件之間的counter就不會全都是0了. 這感覺就像是一群人落難飄到荒島上, 外界以為這些人已經死了, 但他們卻還在世界上的某個角落活著, 且彼此之間有關連, 可能想合作逃出荒島之類的. 不管怎樣, 在主流的JVM的實作中, 基本上不會選擇這種作法. 我在這邊有寫一段程式並且附上GC log, 示範了這種情況, 有興趣的可以去看看, 可以發現JVM不是按照這種方式去做回收的.
Reachability Analysis: 在大部分的商用解決方案裡, 幾乎都是透過這種方式去做判定的. 這個演算法的核心概念是通過一系列稱為"GC Roots"的物件作為起點, 從這些節點開始向下搜尋, 搜尋的路徑稱為reference chain, 當一個物件到GC Roots沒有任何reference chain相連的話, 就表示這個物件是不可用的了. 如下圖所示: 物件5~7看似有關聯, 但他們跟GC Roots之間是不可達的, 所以可以被回收. 而在Java中, 可作為GC Roots的物件大致上有以下幾種:
JVM Stack(這裡指Local Variable Table)中參照的物件
Method Area中靜態屬性參照的物件
Method Area中常數參照的物件
Native Method Stack中JNI(就是Native method)參照的物件
About Reference: 不管用上面提到的哪種方式去判斷, 判斷物件是否存活都跟參照有關, 在JDK1.2之前 參照的定義很單純 --- "若reference type的資料中儲存的資料代表的是另外一塊記憶體的起始位置, 就稱這塊記憶體代表著一個reference". 這很純粹, 但也有點不夠, 試想若有一種物件是"當記憶體空間還足夠時, 就留它一命; 但若記憶體在進行GC後還是很吃緊, 就回收掉這些它", 這在某些系統的cache功能都很適合這種情境. 所以, 在JDK1.2之後, Java對reference的概念進行了擴充, 把Reference分成以下四種, 按強度由強至低排列如下:
Strong Reference: 這種就是你常看到的"Object obj = new Object();", 只要strong reference還存在, GC就不會收走它們.
Soft Reference: 這是指一些還有用但並非必須的物件. 在系統將要發生OOM之前, 會把這些物件列入回收名單中進行第二次回收, 若回收後還是沒有足夠記憶體, 才拋OOM. 關於這部分, 有SoftReference class可以用(我是沒用過啦).
Weak Reference: 一樣是描述非必要物件, 但強度更低, 被這種reference關聯到的物件只能活到下一次GC發生之前. GC在工作時, 無論當前記憶體是否足夠, 都會回收掉只被weak reference關聯的物件. 關於這部分, 有WeakReference class可用(我也沒用過).
Phantom Reference: 這是最弱的reference, 為一個物件設置這種reference的唯一目的就是能夠在這個物件被GC時收到一個系統通知. 關於這部分, 有PhantomReference class可以用(對, 我還是沒用過).
Dead or Alive: 即便是在reachability analysis中不可達的物件, 也並非是絕對會死的, 這時候這些物件基本上是處在緩刑的階段, 要真正宣告一個物件的死亡, 至少要經歷過兩次標記過程 --- 若物件在進行reachability analysis後發現沒有跟GC Roots相連, 它會被第一次標記並且進行一次篩選, 這個篩選就是此物件是否有必要執行finalize() method. 當物件沒有override finalize(), 或是finalize()已經被JVM呼叫過了, 那就是沒必要執行了. 倘若這個物件有必要執行finalize(), 那這個物件就會被放到一個叫做F-Queue的佇列裡面, 並在稍後由一個由JVM自動建立且優先順序比較低的Finalizer thread去執行它. 但這邊的執行僅僅只是說JVM會去觸發這個方法, 但並沒有承諾會等這個方法跑完, 原因很簡單: 若一個物件在finalize()中執行得很慢, 甚至出現了dead lock, 這樣F-Queue裡的其它物件難不成要一直等然後導致GC crash嗎? 而在finalize()中, 物件可以透過重新將自己跟reference chain上的任一物件建立關聯來讓自己逃離被回收的命運, 譬如把this關鍵字assign給某個變數或是物件的instance variable. 那在GC第二次標記時, 這個物件就會被移出"即將回收"的集合; 反之, 就真的被回收了. 這邊實作了一個範例, 示範了自救的過程, 但物件第二次自救還是會失敗, 原因是因為任何一個物件的finalize()都只會被系統呼叫一次, 若物件面對下一次回收, finalize()是不會被執行的. 其實這裡只是想示範finalize()只會被呼叫一次, 不是說要學怎麼靠這方法自救, 因為我覺得這並不是finalize()的真正用意, 所以看看就好了.
Method Area的回收: 前面的章節有提到過, 在Method Area做回收的CP值很低, 舉個例子, 通常在Java Heap的新生代中, 一次的GC大概可以收掉7~9成的垃圾, 而Method Area(或是說HotSpot的永久代)卻遠低於這個數字. 由於此區的主要回收大概只有兩部分內容 --- 廢棄的constant跟沒用的class. 回收廢棄的constant還算簡單, 但是判定一個class是不是沒用的class就比較麻煩了, 其要同時滿足以下三個條件才可以成立:
該class所有的instance都已經被回收, 就是說Java Heap裡面已經不存在該class的任何instance了
加載該class的class loader已經被回收了
該class對應的java.lang.Class物件沒有於任何地方被參照, 無法在任何地方透過reflection存取該class的方法
從這邊大概可以想到一件事, 在大量使用reflection/dynamic proxy/CGLib等byte code的framework(如Spring), 或是動態生成JS以及OSGi這類頻繁自定義ClassLoader的情境下都需要JVM具備class unload的功能, 以保證永久代不會overflow (這在JDK8已經有改善了).
這三個詞搞得我很煩, 所以特別記錄一下.
又稱為輕度GC, 其本質上是要收集新生代(這裡指Eden與Survivor)的垃圾. 這個定義基本上是很清楚且被廣泛認可的. 不過有些事情是可以特別提一下的:
Minor GC基本上都是在JVM沒辦法分配記憶體給新物件的時候被驅動, 譬如說Eden區滿了. 所以記憶體分配率越高, Minor GC發生的頻率就越高.
每當這區滿了, 其所有內容都會被copy到另一個survivor區, 而其Heap指標又會從另一半空閒記憶體的開頭開始繼續移動以分配記憶體. 這基本上跟Mark-Sweep/Mark-Compact就是不一樣的, 故在Eden與Survivor中不會有碎片(fragmentation)產生.
在Minor GC發生的時候, 老年代基本上是被忽略的. 從老年代到新生代的參照會被認為是GC Roots, 但從新生代到老年代的參照通常會在標記階段被忽略.
在一般的認知裡, Minor GC會觸發Stop-The-World (STW), 停止所有application thread. 其實對大多數的應用程式來說, 這種pause所造成的latency根本都是很微不足道的(negligible). 畢竟在絕大多數的場合下, 大部分存在於Eden區的物件都會被當作是回收目標且沒有機會進入Survivor/老年代. 除非大多數新生物件都不符合被GC的條件, Minor GC造成的latency才會成為一個要好好考慮的點.
結論, Minor GC其實很單純 - 就是要把新生代清乾淨(cleans the Young generation).
通常指清理老年代(Tenured Generation), 出現Major GC, 經常會伴隨著至少一次的Minor GC(這並不是絕對的, 譬如說Parallel Scavenge的收集策略裡就有直接選擇Major GC的策略選擇過程). Major GC的速度通常會比Minor GC慢10倍以上.
清理整個Java Heap, 即新生代與老年代.
對於上面兩個詞(Major GC/Full GC), 不管是在JVM Spec還是GC相關的研究論文裡, 其實都沒有一個正式/官方的明確定義. 講到這其實會覺得有點複雜跟困惑. 畢竟在很多情況下, Major GC都是被Minor GC驅動的, 所以要分開這兩者幾乎不太可能. 另一方面, 對很多現代的垃圾收集器來說, 它們也只是部分地清理了老年代, 所以用清理這個詞也只能說是部分正確.
所以我覺得與其在那邊擔心這到底叫Major GC還是Full GC, 你乾脆去關注當前的GC有沒有觸發STW或是GC是否有並發地(concurrently)跟application thread一起運作會比較重要. 畢竟不管是Minor/Major/Full GC, 監控應用程式的latency或是吞吐量並且將其關聯至GC事件並且解讀背後發生的事情才是我們真正想要的結果.