1. 程式人生 > >【JVM從小白學成大佬】4.Java虛擬機器何謂垃圾及垃圾回收演算法

【JVM從小白學成大佬】4.Java虛擬機器何謂垃圾及垃圾回收演算法

在Java中記憶體是由虛擬機器自動管理的,虛擬機器在記憶體中劃出一片區域,作為滿足程式記憶體分配請求的空間。記憶體的建立仍然是由程式猿來顯示指定的,但是物件的釋放卻對程式猿是透明的。就是解放了程式猿手動回收記憶體的工作,交給垃圾回收器來自動回收。

在虛擬機器中,釋放哪些不再被使用的物件所佔空間的過程稱為垃圾收集(Garbage Collection,GC)。負責垃圾收集的程式模組,成為垃圾收集器(Garbage Collector)。

既然虛擬機器已經幫我們把垃圾自動處理了,為什麼還要去了解GC和記憶體分配呢?

當需要排查各種記憶體溢位、記憶體洩漏問題時,當垃圾收整合為系統達到更高併發量的瓶頸時,我們就需要對虛擬機器的自動管理技術實施必要的監控和調節了。這也是JVM調優,故障排查,重點需要掌握的知識了。

本篇我們的重點是介紹何謂垃圾及垃圾回收演算法,那我們就要弄清到底什麼是垃圾?能不能設計一種強大的垃圾回收演算法來解決垃圾回收的所有問題?肯定是沒有的,後面介紹的每一種垃圾回收演算法都有它得天獨厚的優點,也有它避之不及的缺點。針對具體的場景,靈活運用方是上策。

希望大家能帶著如下問題進行學習,會收穫更大。

  1. 什麼是垃圾?
  2. 如何回收垃圾?
  3. 有沒有一種垃圾回收演算法能像銀彈一樣解決所有垃圾所有?
  4. GC的分類是什麼樣的?(Minor GC、Major GC、Full GC)
  5. Stop-the-world是什麼?
  6. 如何避免全堆掃描?

1 垃圾回收

在堆裡面存放著Java世界中幾乎所有的物件例項,垃圾收集器在堆進行回收前,第一件事就是要確定這些物件之中哪些還“存活”著,哪些已經“死亡”(即不可能再被任何途徑使用的物件)。垃圾回收,其實就是將已經分配出去的,但不再使用的記憶體回收,以便能夠再次分配。在Java虛擬機器的規範中,垃圾指的就是死亡的物件所佔據的堆空間。

那怎麼確定一個物件是存活還是死亡呢?

1.1 引用計數演算法

給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。也就是說,需要截獲所有的引用更新操作,並且相應地增減目標物件的計數器。

題外話:記得研一那段時間對iOS開發感興趣,找個公司去實習,現學現搞iOS開發,當時是做了一個模擬炒股的app。用的就是Objective-C,這門語言起初管理記憶體的方式就是用的這種引用計數演算法,不過後面也有了自動管理記憶體。接觸的物件多了,發現很多東西在本質的原理有非常多的相似之處。

引用計數演算法缺點:

  • 需要額外的空間來儲存計數器,以及繁瑣的更新操作。
  • 無法處理迴圈引用物件。

其中無法處理迴圈引用物件,算是引用計數法的一個重大漏洞。

1.2 可達性分析演算法

可達性是指,如果一個物件會被至少一個在程式中的變數通過直接或間接的方式被其他可達的物件引用,則稱該物件是可達的(reachable)。更準確的說,一個物件只有滿足下述兩個條件之一,就會被判斷為可達的:

  • 本身是根物件。根(Root)是指由堆以外空間訪問的物件。JVM中會將一組物件標記為根,包括全域性變數、部分系統類,以及棧中引用的物件,如當前棧幀中的區域性變數和引數。
  • 被一個可達的物件引用。

這個演算法的基本思路就是通過一系列的成為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(即從GC Roots到這個物件不可達),則證明此物件是不可用的。

GC Roots又是什麼呢?可以暫時理解為由堆外指向堆內的引用。

在Java語言中,可以作為GC Roots的物件包括下面幾種:

  • 虛擬機器棧(棧幀中的本地變量表)中引用的物件。
  • 方法區中類靜態屬性引用的物件。
  • 方法區中常量引用的物件。
  • 本地方法棧中JNI(即一般說的Native方法)引用的物件。
  • 已啟動且未停止的Java執行緒。

可達性分析演算法可以解決引用計數演算法不能解決的迴圈引用問題。舉個例子,即便物件a和b相互引用,只要從GC Roots出發無法到達a或者b,那麼可達性分析便不會將它們加入存活物件合集之中。

關於Java中的引用的定義及分類(強引用、軟引用、弱引用、虛引用)會在單獨出一篇進行詳細介紹,Java引用的內容雖然有點冷門,但是很多公司面試的常考點。

可達性分析演算法本身雖然很簡明,但是在實踐中還是有不少其他問題需要解決的。比如,在多執行緒環境下,其他執行緒可能會更新已經訪問過的物件中的引用,從而造成誤報(將引用設定為null)或者漏報(將引用設定為未被訪問過的物件)。誤殺還可以接受,Java虛擬機器至多損失了部分垃圾回收的機會。漏報就問題大了,因為垃圾回收器可能回收事實上仍被引用的物件記憶體。一旦從原引用訪問已經被回收了的物件,則很有可能會直接導致Java虛擬機器奔潰。

2 垃圾回收演算法

上面我們介紹什麼是Java中的垃圾,接下來我們就開始介紹如何高效的回收這些垃圾。

2.1 標記-清除演算法

標記-清除(Mark-Sweep)演算法可以分為兩個階段:

  • 標記階段:標記出所有可以回收的物件。
  • 清除階段:回收所有已被標記的物件,釋放這部分空間。

該演算法存在如下不足:

  1. 記憶體碎片。由於Java虛擬機器的堆中物件必須是連續分佈的,因此可能出現總空閒記憶體足夠,但是無法分配的極端情況。無法找到足夠的連續記憶體,而不得不提前觸發一次垃圾收集動作。
  2. 分配效率較低。如果是一塊連續的記憶體空間,那麼我們可以通過指標加法(pointer bumping)來做分配。而對於空閒列表,Java虛擬機器則需要逐個訪問列表中的項,來查詢能夠放入新建物件的空閒記憶體。

標記-清除演算法的示意圖如下:

2.2 複製演算法

複製演算法的過程如下:

  • 劃分區域:將記憶體區域按比例劃分為1個Eden區作為分配物件的“主戰場”和2個倖存區(即Survivor空間,劃分為2個等比例的from區和to區)。
  • 複製:收集時,打掃“戰場”,將Eden區中仍存活的物件複製到某一塊倖存區中。
  • 清除:由於上一階段已確保仍存活的物件已被妥善安置,現在可以“清理戰場”了,釋放Eden區和另一塊倖存區。
  • 晉升:如在“複製”階段,一塊倖存區接納不了所有的“倖存”物件。則直接晉升到老年代。

該演算法解決了記憶體碎片化問題,但堆空間的使用效率極其低下。在物件存活率較高時,需要進行較多的複製操作,效率會變得很低。

2.3 標記-整理演算法

該演算法分為兩個階段:

  • 標記階段:標記出所有可以回收的物件。
  • 壓縮階段:將標記階段的物件移動到空間的一端,釋放剩餘的空間。

該演算法的標記過程與標記-清除演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

解決了記憶體碎片的問題,也規避了複製演算法只能利用一半記憶體區域的弊端。看起來很美好,但它對記憶體變動更頻繁,需要整理所有存活物件的引用地址,在效率上比複製演算法要差很多。

標記-整理演算法的示意圖如下:

2.4 分代收集演算法

分代收集演算法倒並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。

在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用標記-清理演算法或標記-整理演算法來進行回收。

3 HotSpot演算法實現

3.1 列舉根節點

以可達性分析中從GC Roots節點找引用鏈這個操作為例,可作為GC Roots的節點主要在全域性性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中。上面介紹可達性分析演算法時有詳細介紹GC Roots,可以參看上面。

3.2 安全點(Safepoint)

安全點,即程式執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。Safepoint的選定既不能太少以至於讓GC等待時間太長,也不能過於頻繁以致於過分增大執行時的負荷。

安全點的初始目的並不是讓其他執行緒停下,而是找到一個穩定的執行狀態。在這個執行狀態下,Java虛擬機器的堆疊不會發生變化。這麼一來,垃圾回收器便能夠“安全”地執行可達性分析。只要不離開這個安全點,Java虛擬機器便能夠在垃圾回收的同時,繼續執行這段原生代碼。

程式執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。安全點的選定基本上是以程式“是否具有讓程式長時間執行的特徵”為標準進行選定的。“長時間執行”的最明顯特徵就是指令序列複用,例如方法呼叫、迴圈跳轉、異常跳轉等,所以具有這些功能的指令才會產生Safepoint。

對於安全點,另一個需要考慮的問題就是如何在GC發生時讓所有執行緒(這裡不包括執行JNI呼叫的執行緒)都“跑”到最近的安全點上再停頓下來。

兩種解決方案:

  • 搶先式中斷(Preemptive Suspension)

    搶先式中斷不需要執行緒的執行程式碼主動去配合,在GC發生時,首先把所有執行緒全部中斷,如果發現有執行緒中斷的地方不在安全點上,就恢復執行緒,讓它“跑”到安全點上。現在幾乎沒有虛擬機器採用這種方式來暫停執行緒從而響應GC事件。

  • 主動式中斷(Voluntary Suspension)

    主動式中斷的思想是當GC需要中斷執行緒的時候,不直接對執行緒操作,僅僅簡單地設定一個標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起。輪詢標誌地地方和安全點是重合的,另外再加上建立物件需要分配記憶體的地方。

3.3 安全區域

指在一段程式碼片段中,引用關係不會發生變化。在這個區域中任意地方開始GC都是安全的。也可以把Safe Region看作是被擴充套件了的Safepoint。

4 擴充套件知識

4.1 GC分類

Minor GC:

  • 針對新生代。
  • 指發生在新生代的垃圾收集動作,因為java物件大多都具備朝生夕死的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
  • 觸發條件:Eden空間滿時。

Major GC:

  • 針對老年代。
  • 指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge 收集器的收集策略裡就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。
  • 觸發條件:Minor GC 會將物件移到老年代中,如果此時老年代空間不夠,那麼觸發 Major GC。

Full GC:

  • 清理整個堆空間。一定意義上Full GC 可以說是 Minor GC 和 Major GC 的結合。
  • 觸發條件:呼叫System.gc();老年代空間不足;空間分配擔保失敗。

4.2 Stop-the-world

GC進行時必須停頓所有Java執行執行緒,這就是Stop-the-world。

可達性分析時必須在一個能確保一致性的快照中進行,這裡“一致性”的意思是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中物件引用關係還在不斷變化的情況,這一點不滿足的話分析結果準確性就無法得到保證。

Stop-the-world是通過安全點機制來實現的。當Java虛擬機器接收到Stop-the-world請求,它便會等待所有的執行緒都到達安全點,才允許請求Stop-the-world的執行緒進行獨佔的工作。

4.3 卡表

有個場景,老年代的物件可能引用新生代的物件,那標記存活物件的時候,需要掃描老年代中的所有物件。因為該物件擁有對新生代物件的引用,那麼這個引用也會被稱為GC Roots。那不是得又做全堆掃描?成本太高了吧。

HotSpot給出的解決方案是一項叫做卡表(Card Table)的技術。該技術將整個堆劃分為一個個大小為512位元組的卡,並且維護一個卡表,用來儲存每張卡的一個標識位。這個標識位代表對應的卡是否可能存有指向新生代物件的引用。如果可能存在,那麼我們就認為這張卡是髒的。

在進行Minor GC的時候,我們便可以不用掃描整個老年代,而是在卡表中尋找髒卡,並將髒卡中的物件加入到Minor GC的GC Roots裡。當完成所有髒卡的掃描之後,Java虛擬機器便會將所有髒卡的標識位清零。

想要保證每個可能有指向新生代物件引用的卡都被標記為髒卡,那麼Java虛擬機器需要截獲每個引用型例項變數的寫操作,並作出對應的寫標識位操作。

卡表能用於減少老年代的全堆空間掃描,這能很大的提升GC效率