上篇我們介紹了Java運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法3個區域隨線程而生,隨線程而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行出棧和入棧操作,棧幀中分配的內存基本上是在類結構確定下來時就是已知的。因此這幾個內存區域的分配和回收都具備穩定性,方法結束或者線程結束,內存相應的就被回收了。垃圾收集所關注的主要是Java堆和方法區這部分內存,因為這部分內存的分配和回收都是動態的,我們只有在程序運行期間才直到會創建哪些對象,本篇后續所說的“內存”分配與回收也僅指這一部分內存。
1.如何確定回收
一般來說,一個對象如果需要回收,第一件事就是要確定這個對象是否已經“死去”,那么這種“死去”的狀態怎么來判斷呢?
1.1可達性分析算法
在主流商用程序語言(Java、C#等)的主流實現中,都是通過可達性分析(Reachability Analysis)來判斷對象是否存活的,基本思路就是通過一系列稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑成為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則說明對象是不可達的。如下圖,對象5、6、7到GC Roots是不可達的,它們將會被判定為可回收的對象:
在Java語言中,可作為GC Roots的對象包括以下幾種:
- 虛擬機棧(棧幀中的本地變量表)中引用的對象。
- 方法區中類靜態屬性引用的對象。
- 方法區中常量引用的對象。
- 本地方法棧中JNI(Native方法)引用的對象。
這里擴展說明另外一個判斷對象是否存活的算法-引用計數算法:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器加1;引用失效時,計數器減1;任何時刻計數器為0的對象就是不能在被使用的。可觀來說,引用計數算法實現簡單,判定效率也高。但是在主流的Java虛擬機中沒有選擇這個算法,主要原因是它很難解決對象之間相互循環引用的問題。
無論是通過可達性分析算法還是引用計數算法,判斷對象是否存活都與“引用”有關。Java對“引用”的概念定義了四種(從強到弱):
- 強引用(Strong Reference):程序代碼中普遍存在在,類似
Object o = new Object
這種,只要強引用存在,GC就永遠不會回收被引用的對象。 - 軟引用(Soft Reference):用來描述一些還有用但非必需的對象。當內存足夠使用時,不會被回收。在將要發生內存溢出之前,將會把這些軟引用對象列入回收范圍之內進行二次回收。如果這次回收還沒有足夠的內存,才會拋出內存溢出異常,使用SoftReference類來實現軟引用。
- 弱引用(Weak Reference):也用來描述非必需對象,被弱引用關聯的對象只能存活到下次垃圾收集之前。使用WeakReference實現。
- 虛引用(Phantom Refernce):虛引用是最弱的一種引用關系,一個對象是否有虛引用,完全不會對其生存時間構成影響,也無法通過虛引用獲得一個對象實例。為一個對象設置虛引用的唯一目的就是能在這個對象被回收時收到一個系統通知。使用PhantomRefernce實現。
1.2 對象真正“死亡”
即使在可達性分析算法中不可達的對象,也不是立即“死亡”的,要真正宣告一個對象的死亡,至少要經歷兩次標記過程:
- 如果對象在可達性分析之后發現沒有GC Roots的引用鏈,那么它首先會被第一次標記并進行第一次篩選,篩選的條件是**此對象是否有必要執行
finalize()
方法。 - **當對象沒有覆蓋
finalize()
方法,或者finalize()
方法已經被虛擬機調用過,虛擬機將這兩種情況定為“沒有必要執行”。
如果一個對象被判定有必要執行finalize()
方法,那么這個對象將會被放置在F-Queue隊列中,并在稍后由一個由虛擬機自動建立的、低優先級的Finalizer線程去執行它。這里所謂的“執行”是指虛擬是會觸發這個方法,但并不承諾會等待它運行結束。如果對象在finalize()
中成功拯救了自己-重新與引用鏈關聯上,例如this賦值給某個類變量或成員變量,那么在第二次標記時它就會被移除出“即將回收”的集合。需要特么說明的是,finalize()方法的運行代價高昂,不確定性大,無法保證各個對象的調用順序,它所能做的工作,使用try-finally
或其他方式都可以做的更好,所以筆者建議大家可以忘記這個方法的存在。
2.永久代的回收
在HotSpot中,我們常稱方法區為“永久代”。它的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。
廢棄常量:以常量池中字面量為例,加入一個字符串“abc”已經在常量池中,但是系統中沒有任何一個String對象引用常量池中的“abc”常量,如果這時發生內存回收,這個“abc”常量就會被清理出去。常量池中的其他類(接口)、方法、字段的符號引用也與之類似。
無用的類:類需要滿足以下三個條件才會被判定為“無用的類”:
- 該類所有的實例都已經被回收,也就說Java堆中不存在該類的任何實例。
- 加載該類的ClassLoader已經被回收。
- 該類對應的
java.lang.Class
對象沒有任何地方被引用,無法在任何地方通過反射訪問該類的方法。
3. 垃圾收集算法
3.1 標記-清除算法
“標記-清除”(Mark-Sweep)算法是最基礎的收集算法,分為“標記”和“清除”兩個部分:首先標記出所有需要回收的對象,在標記完成后同意回收所有被標記的對象,它的標記過程就是上文中所講述的可達性分析算法。現在虛擬機的收集算法大多都是基于“標記-清除”改進而得到的。這個算法的主要不足有兩個:一是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之后會產生大量的內存碎片,空間碎片太多可能會導致以后在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。,標記-清除算法的執行過程如下圖:
3.2 復制算法
“復制”(Copying)算法解決了“標記-清除”算法的效率問題。它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊內存用完了,就將還存活著的對象復制到另一塊內存上,然后再把已使用過的內存空間一次清理掉。這種算法的代價是將內存縮小為原來的一半。復制算法執行過程如下:
IBM研究表明,新生代中的對象98%都是“朝生夕死”的,所以并不需要按照1:1的比例來劃分內存空間,而是將內存分為一塊較大的Eden空間和兩塊較小的Survivor控件,每次使用Eden和其中一塊Survivor(HotSpot就是這種布局)。當回收時,將Eden和Survivor中還存活的對象一次性地復制到另外一塊Survivor空間上,最后清理掉Eden和剛才用過的Survivor空間。HotSpot默認Eden和Survivor的大小比例是8:1,也就是每次新生代中可用內存為整個新生代容量的90%(80%+10%),只有10%的空間會浪費。當然98%的可回收對象只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多于10%的對象存活,當Survivor空間不夠用時,需要依賴老年代的內存進行分配擔保(Handle Promotion)。如果另一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入到老年代中。
3.3 標記-整理算法
“標記-整理(Mark-Compact)算法”主要針對老年代,標記過程仍然同“標記-清除”算法一樣,但后續步驟不是直接對可回收的對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存,“標記-整理”算法執行過程如下:
3.4 分代收集算法
當前商業虛擬機的垃圾收集都采用“分代收集(Generational Collection)”算法,這種算法并沒有什么新的思想,只是根據對象存活周期的不同將內存劃分為幾塊。例如Java堆的新生代和老年代,這樣就可以針對各個代的特點采用最合適的算法。新生代中對象朝生夕死,就選擇復制算法,主要付出少量存活對象的復制成本就可以完成收集。而老年代中因為對象存活率高、沒有額外空間對其進行分配擔保,就必須使用“標記-清理”或“標記-整理”算法進行回收。
4. HotSpot的算法實現
在HotSpot中實現對象存活判定算法和垃圾收集算法時,必須對算法的執行效率有嚴格的考量,才能保證虛擬機高效運行。下面我們來詳細看一下這些算法的實現。
4.1 枚舉根節點
從可達性分析中從GC Roots節點找引用鏈這個操作為例,可作為GC Roots的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,現在很多引用僅僅方法區就有數百兆,如果要逐個檢查這些引用,必然會消耗很多時間。
可達性分析工作必須在一個能確保一致性的快照中進行-這里“一致性”的意思是指在分析期間整個執行系統看起來就像被凍結在某個時間點上,不能出現分析過程中對象引用關系還在不斷變化的情況。這點也是導致GC進行時必須停頓所有Java執行線程(Sun稱之為“Stop The World”)的一個重要原因。
目前主流Java虛擬機使用的都是準確式GC,即虛擬機可以知道內存中某個位置的數據具體是什么類型。譬如有一個32位的整數123456,它到底是一個reference類型指向123456的內存地址還是一個數值為123456的整數,虛擬機將有能里分辨出來,這樣才能在GC的時候準確判斷對上的數據是否還可能被使用。所以當執行系統停頓下來后,并不需要一個不漏的檢查完所有執行上下文和全局的引用位置。在HotSpot實現中,使用一組OopMap的數據結構來得知哪些地方存放著對象引用。在類加載完之后,HotSpot就把對象內偏移量對應的數據類型計算出來,在JIT編譯時也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣,在GC掃描時就可以直接得知這些信息了。
4.2 安全點和安全區域
在OopMap的協助下,HotSpot可以快速準確的完成GC Root枚舉,但一個很現實的問題隨之而來:可能導致引用關系變化,或者說OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。
上面已近提到,HotSpot只是在“特定的位置”記錄了這些信息,這些位置稱為“安全點(Safepoint)”,即程序執行時只有在到達安全點時才能暫停。安全點的選定基本上是以程序“是否具有讓程序長時間執行的特征”為標準進行選定的-因為每條執行的時間都非常短暫,程序不太可能因為指令流長度太長這個原因而過長時間運行,“長時間執行”的最明顯特征就是指令序列復用,這些指定的位置主要在:循環的末尾 、方法臨返回前 / 調用方法的call指令后 、可能拋異常的位置 。確定了安全點,那么如何在GC發生時讓所有線程都“跑”到最近的安全點再停頓下來呢?現在多數虛擬機都采用主動式中斷,當GC需要中斷線程時,不直接對線程操作,僅僅簡單地設置一個標志,各個線程執行時主動輪詢這個標志,發現中斷標志為真時就自己中斷掛起。
另外一種情況,如果在程序“不執行”的時候如何進入GC的安全點呢,例如線程sleep或blocked,這時候線程無法響應JVM的中斷請求,也不太可能等待線程重新被分配CPU執行時間,這種情況就需要安全區域(safe region)解決。安全區域是指在一段代碼片段之中,引用關系不會發生變化,在這個區域的任何地方GC都是安全的。在線程執行到safe region中的代碼時,首先標識自己已經進入了safe region,這樣,當在這段時間JVM發起GC時,就不用管標識自己為safe region狀態的線程了。