Java虛擬機學習筆記(二):垃圾收集算法及HotSpot的算法實現

概述

垃圾收集(GC)需要考慮3件事情:

(1)哪些內存需要回收?

(2)什麼時候回收?

(3)如何回收?

前面我們已經提及程序計數器、虛擬機棧、本地方法棧3個區域隨線程而生,隨線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧的操作,每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知。而Java堆和方法區則不一樣,一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期間才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,GC所關注的是這部分內容。

對象死亡的判斷

在堆裏面存放着Java世界中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,第一件事就是要確定這些對象之中哪些還“存活”着,哪些已經“死去”。

在開始介紹GC算法之前,我們先來說明一下引用的概念。

  • 在JDK1.2以前,Java中的引用的定義很傳統:如果reference類型的數據中存儲的數值代表的是另一塊內存的起始地址,就稱這塊內存代表着一個引用。

  • 在JDK1.2之後,Java對引用的概念進行了擴充,將引用分爲強、軟、弱、虛4種,這四種引用強度依次逐漸減弱

    (1)強引用就是指程序代碼中普遍存在的,類似“Object obj = new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象

    (2)軟引用用來描述一些還有用但並非必需的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出之前,將會把這些對象列進回收範圍之內進行第二次回收。如果這次回收還沒有足夠的內存,纔會拋出OOM。在JDK1.2之後,提供了SoftReference類來實現軟引用

    (3)弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK1.2之後,提供了WeakReference類來實現弱引用

    (4)虛引用也稱爲幽靈引用或者幻影引用,它是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛引用關聯的唯一目的就是能在這個對象被垃圾收集器回收時收到一個系統通知。在JDK1.2之後,提供了PhantomReference類來實現虛引用

引用計數算法

給對象中添加一個引用計數器,每當有一個地方引用它時,計數器就加1;當引用失效時,計數器值就減1;任何時刻計數器爲0的對象就是不可能再被使用的

主流的Java虛擬機裏面沒有選用引用計數算法來管理內存,最主要的原因是它很難解決對象之間相互循環引用的問題

可達性分析算法

通過一系列的稱爲“GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈,當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的

在Java語言中,可作爲GC Roots的對象包括下面幾種:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象
  • 方法區中類靜態屬性引用的對象
  • 方法區中常量引用的對象
  • 本地方法棧中JNI引用的對象

即使在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:

(1)如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲“沒有必要執行”

(2)如果這個對象被判定爲有必要執行finalize()方法,那麼這個對象將會放置在一個叫作F-Queue的隊列之中,並在稍後由一個虛擬機自動建立的、低優先級的Finalizer線程去執行它。這裏所謂的“執行”是指虛擬機會觸發這個方法,但並不承諾會等待它運行結束,這樣做的原因是,如果一個對象在finalize()方法中執行緩慢,或者發生了死循環,將很可能會導致F-Queue隊列中其他對象永久處於等待,甚至導致整個內存回收系統的崩潰。finalize()方法是對象逃脫死亡命運的最後一次命運,稍後GC將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己–只要重新與引用鏈上的任何一個對象建立關聯即可,那在第二次標記時它將被移除出“即將回收”的集合。

注意:任何一個對象的finalize()方法都只會被系統自動調用一次,如果對象面臨下一次回收,它的finalize()方法不會被再次執行

上面講的是關於堆的GC,那麼對於方法區的GC是什麼情況呢?

方法區回收

針對方法區(或者說是HotSpot虛擬機中的永久代)的垃圾收集主要回收兩部分內容:廢棄常量無用的類。回收廢棄常量和回收Java堆中的對象非常類似,以常量池中的字面量的回收爲例,一旦一個字面量沒有其他地方引用了這個字面量,那麼這個常量就會被認爲是廢棄常量,如果此時發生內存回收,而且必要的話,這個常量就會被系統清理出常量池。

相對於廢棄常量,判定一個類是否是無用的類的條件相對苛刻許多,類需要同時滿足下面3個條件纔算是無用的類

(1)該類的所有實例都已經被回收,也就是Java堆中不存在該類的任何實例

(2)加載該類的ClassLoader已經被回收

(3)該類對應的java.lang.Class對象沒有被任何地方引用,無法在任何地方通過反射訪問該類的方法

虛擬機可以對滿足上述3個條件的無用類進行回收,這裏僅僅是“可以”,而並不是和對象一樣,不使用了就必然會回收。是否對類進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading-XX:+TraceClassUnLoading查看類加載和卸載信息,其中-verbose:class-XX:+TraceClassLoading可以在Product版的虛擬機中使用,-XX:+TraceClassUnLoading參數需要FastDebug版的虛擬機支持。

垃圾收集算法

由於垃圾收集算法的實現涉及大量的程序細節,而且各個平臺的虛擬機操作內存的方法各不相同,因此這裏只是簡單介紹幾種算法的思想及其發展過程。

標記-清除算法

標記-清除算法分爲標記清除兩個階段:

  • 標記出所有需要回收的對象
  • 標記完成後統一回收所有被標記的對象

不足:

(1)效率問題。標記和清除兩個過程的效率都不高

(2)空間問題。標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作

下面是標記-清除算法的執行流程:

標記-清除

複製算法

爲了解決標記-清除算法中的效率問題,複製收集算法出現了。它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這樣使得每次都是整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。

不足:

  • 內存縮小爲了原來的一半

下面是複製算法的執行流程:

在這裏插入圖片描述

現在的商業虛擬機都採用這種收集算法來回收新生代。新生代中的對象98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1。也就是每次新生代中可用內存空間爲整個新生代容量的90%,只有10%的內存會被“浪費”。當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要依賴其它內存(這裏指老年代)進行分配擔保。因此,當一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代。

標記-整理算法(針對老年代)

複製收集算法在對象存活率較高時就要進行較多的複製操作,效率將會變低。根據老年代的特點,有人提出了“標記-整理”算法,標記過程仍然與“標記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉邊界以外的內存。

下面是標記-整理算法的執行流程:

在這裏插入圖片描述

分代收集算法

當前商業虛擬機的垃圾收集都採用“分代收集”算法,根據對象存活週期的不同將內存劃分爲幾塊。一般是把Java堆分爲新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集算法。新生代選用複製算法;老年代則使用“標記-清理”或者“標記-整理”。

HotSpot的算法實現

枚舉根結點

以GC Roots結點找引用鏈這個操作爲例,可作爲GC Roots的結點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,現在很多應用僅僅方法區就有數百兆,如果要逐個檢查這裏面的引用,那麼必然會消耗很多時間。

另外,可達性分析對執行時間的敏感還體現在GC停頓上,因爲這項分析工作必須在一個能保證一致性的快照中進行–這裏的一致性是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關係還在不斷變化的情況,該點不滿足的話分析結果準確性就無法得到保證

目前主流的Java虛擬機使用的都是準確式GC,所以當執行系統停頓下來後,並不需要一個不漏地檢查完所有執行上下文和全局的引用位置,虛擬機應當是有辦法直接得知哪些地方存放着對象引用。

在HotSpot的實現中,是使用一組稱爲OopMap的數據結構來達到這個目的的,在類加載完成的時候,HotSpot就把對象內什麼偏移量上是什麼類型的數據計算出來,在JIT編譯過程中,也會在特定的位置(安全點)記錄下棧和寄存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些信息了。

安全點

在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots枚舉,但一個很現實的問題隨之而來:可能導致引用關係的變化,或者說OopMap內容變化的指令非常多,如果爲每一條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。這個時候就出現了一個叫作安全點的概念,即程序執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停

Safepoint的選定既不能太少以至於讓GC等待時間長,也不能過於頻繁以至於過分增大運行時的負荷。所以,安全點的選定基本上是以程序“是否具有讓程序長時間執行的特徵”爲標準進行選定的,因爲每條指令執行的時間都非常短暫,程序不太可能因爲指令流長度太長這個原因而過長時間運行,“長時間執行”的最明顯特徵就是指令序列複用,例如方法調用、循環跳轉、異常跳轉等,所以具有這些功能的指令纔會產生Safepoint。

對於Safepoint,另一個需要考慮的問題是如何在GC發生時讓所有線程(這裏不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來。這裏由兩種方案可供選擇:
(1)搶先式中斷
(2)主動式中斷
搶先式中斷不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它“跑”到安全點上。
主動式中斷的思想是當GC需要中斷線程時,不直接對線程操作,僅僅簡單地設置一個標誌,各個線程執行時主動去輪詢這個標誌,發現中斷標誌爲真時就自己中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上創建對象需要分配內存的地方。

現在幾乎沒有虛擬機實現採用搶先式中斷來暫停線程從而響應GC事件

安全區域

Safepoint機制保證了程序執行時,在不太長的時間內就會遇到可進入GC的Safepoint。但是,程序“不執行”的時候呢?所謂的程序不執行就是沒有分配CPU時間,典型的例子就是線程處於Sleep狀態或者Blocked狀態,這時候線程無法響應JVM的中斷請求,“走”到安全的地方去中斷掛起,JVM也明顯不太可能等待線程重新被分配CPU時間。對於這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指在一段代碼片段中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的

也可以把Safe Region看做是被擴展了的Safepoint

在線程執行到Safe Region中的代碼時,首先標識自己已經進入了Safe Region,那樣,當在這段時間裏JVM要發起GC時,就不用管標識自己爲Safe Region狀態的線程了。在線程要離開Safe Region時,它要檢查系統是否已經完成了根結點枚舉(或者是整個GC過程),如果完成了,那線程就繼續執行,否則它就必須等待直到收到可以安全離開Safe Region的信號爲止。

關於HotSpot虛擬機如何去發起內存回收的問題已經進行了簡單的介紹,但是虛擬機如何具體地進行內存回收動作是由虛擬機所採用的GC收集器決定的,而通常虛擬機中往往不止有一種GC收集器。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章