您好,登錄后才能下訂單哦!
本篇內容主要講解“Java垃圾回收機制的原理是什么”,感興趣的朋友不妨來看看。本文介紹的方法操作簡單快捷,實用性強。下面就讓小編來帶大家學習“Java垃圾回收機制的原理是什么”吧!
Java 垃圾回收機制
1. 垃圾回收主要關注 Java 堆
圖摘自《碼出高效》
Java 內存運行時區域中的程序計數器、虛擬機棧、本地方法棧隨線程而生滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知的(盡管在運行期會由 JIT 編譯器進行一些優化),因此這幾個區域的內存分配和回收都具備確定性,不需要過多考慮回收的問題,因為方法結束或者線程結束時,內存自然就跟隨著回收了。
而 Java 堆不一樣,一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處于運行期間時才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,垃圾收集器所關注的是這部分內存。
2. 判斷哪些對象需要被回收
有以下兩種方法:
引用計數法給對象添加一引用計數器,被引用一次計數器值就加 1;當引用失效時,計數器值就減 1;計數器為 0 時,對象就是不可能再被使用的,簡單高效,缺點是無法解決對象之間相互循環引用的問題。
可達性分析算法通過一系列的稱為 "GC Roots" 的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到 GC Roots 沒有任何引用鏈相連時,則證明此對象是不可用的。此算法解決了上述循環引用的問題。
在Java語言中,可作為 GC Roots 的對象包括下面幾種:a. 虛擬機棧(棧幀中的本地變量表)中引用的對象。b. 方法區中類靜態屬性引用的對象。c. 方法區中常量引用的對象。d. 本地方法棧中 JNI(Native方法)引用的對象。
作為 GC Roots 的節點主要在全局性的引用與執行上下文中。要明確的是,tracing gc必須以當前存活的對象集為 Roots,因此必須選取確定存活的引用類型對象。
GC 管理的區域是 Java 堆及方法區,虛擬機棧、本地方法棧不被 GC 所管理,因此選用這些區域內引用的對象作為 GC Roots,是不會被 GC 所回收的。
其中虛擬機棧和本地方法棧都是線程私有的內存區域,只要線程沒有終止,就能確保它們中引用對象的存活,在方法區中類靜態屬性引用的對象顯然是存活的,常量引用的對象在當前可能存活,也可能是 GC Roots 的一部分。
3. 強、軟、弱、虛引用
JDK1.2 以前,一個對象只有被引用和沒有被引用兩種狀態。
后來,Java 對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)以及虛引用(Phantom Reference)4 種,這 4 種引用強度依次逐漸減弱。
強引用就是指在程序代碼之中普遍存在的,類似"Object obj=new Object()"這類的引用,垃圾收集器永遠不會回收存活的強引用對象。
軟引用:還有用但并非必需的對象。在系統 將要發生內存溢出異常之前 ,將會把這些對象列進回收范圍之中進行第二次回收。
弱引用也是用來描述非必需對象的,被弱引用關聯的對象 只能生存到下一次垃圾收集發生之前 。當垃圾收集器工作時,無論內存是否足夠,都會回收掉只被弱引用關聯的對象。
虛引用是最弱的一種引用關系。無法通過虛引用來取得一個對象實例 。為一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。
圖摘自《碼出高效》
4. 可達性分析算法
不可達的對象將暫時處于“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:
鴻蒙官方戰略合作共建——HarmonyOS技術社區
如果對象在進行可達性分析后發現沒有與 GC Roots 相連接的引用鏈,那它將會被第一次標記并且進行一次篩選,篩選的條件是此對象是否有必要執行 finalize() 方法。
當對象沒有覆蓋 finalize() 方法,或者 finalize() 方法已經被虛擬機調用過,虛擬機將這兩種情況都視為“沒有必要執行”,直接進行第二次標記。
如果這個對象被判定為有必要執行 finalize() 方法,那么這個對象將會放置在一個叫做 F-Queue 的隊列之中,并在稍后由一個由虛擬機自動建立的、低優先級的 Finalizer 線程去執行它。
這里所謂的“執行”是指虛擬機會觸發這個方法,但并不承諾會等待它運行結束,因為如果一個對象在 finalize() 方法中執行緩慢,將很可能會一直阻塞 F-Queue 隊列,甚至導致整個內存回收系統崩潰,測試程序:
public class FinalizerTest { public static FinalizerTest object; public void isAlive() { System.out.println("I'm alive"); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("method finalize is running"); object = this; } public static void main(String[] args) throws Exception { object = new FinalizerTest(); // 第一次執行,finalize方法會自救 object = null; System.gc(); Thread.sleep(500); if (object != null) { object.isAlive(); } else { System.out.println("I'm dead"); } // 第二次執行,finalize方法已經執行過 object = null; System.gc(); Thread.sleep(500); if (object != null) { object.isAlive(); } else { System.out.println("I'm dead"); } } }
引用自 Java GC的那些事
輸出如下:
Copymethod finalize is running I'm alive I'm dead
如果不重寫finalize(),輸出將會是:
CopyI'm dead I'm dead
從執行結果可以看出:
第一次發生 GC 時,finalize() 方法的確執行了,并且在被回收之前成功逃脫;第二次發生 GC 時,由于 finalize() 方法只會被 JVM 調用一次,object 被回收。
值得注意的是,使用 finalize() 方法來“拯救”對象是不值得提倡的,它的運行代價高昂,不確定性大,無法保證各個對象的調用順序。finalize() 能做的工作,使用 try-finally 或者其它方法都更適合、及時。
5. Java 堆永久代的回收
永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。
回收廢棄常量與回收 Java 堆中的對象非常類似。以常量池中字面量的回收為例,假如一個字符串"abc"已經進入了常量池中,但是當前系統沒有任何一個 String 對象是叫做"abc"的,也沒有其他地方引用了這個字面量,如果這時發生內存回收,而且必要的話,這個"abc"常量就會被系統清理出常量池。常量池中的其他類(接口)、方法、字段的符號引用也與此類似。
類需要同時滿足下面 3 個條件才能算是“無用的類”:
該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例。
加載該類的 ClassLoader 已經被回收。
該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
虛擬機可以對滿足上述 3 個條件的無用類進行回收,這里說的僅僅是“可以”,而并不是和對象一樣,不使用了就必然會回收。
在大量使用反射、動態代理、CGLib 等 ByteCode 框架、動態生成 JSP 以及 OSGi 這類頻繁自定義 ClassLoader 的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。
垃圾收集算法
一共有 4 種:
標記-清除算法
復制算法
標記整理算法
分代收集算法
1. 標記-清除算法
最基礎的收集算法是“標記-清除”(Mark-Sweep)算法,分為“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成后統一回收所有被標記的對象。
它的主要不足有兩個:
鴻蒙官方戰略合作共建——HarmonyOS技術社區
效率問題,標記和清除兩個過程的效率都不高。
空間問題,標記清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致以后在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。
標記—清除算法的執行過程如下圖。
2. 復制算法
為了解決回收效率問題,一種稱為“復制”(Copying)的收集算法出現了,它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。
這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等復雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小為了原來的一半。復制算法執行過程如下圖:
現在的商業虛擬機都采用這種算法來回收新生代,IBM 研究指出新生代中的對象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例來劃分內存空間,而是將內存分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor 。
當回收時,將 Eden 和 Survivor 中還存活著的對象一次性地復制到另外一塊的 Survivor 空間上,最后清理掉 Eden 和剛才用過的 Survivor 空間。HotSpot 虛擬機默認 Eden:Survivor = 8:1,也就是每次新生代中可用內存空間為整個新生代容量的 90%(其中一塊 Survivor 不可用),只有 10% 的內存會被“浪費”。
當然,98%的對象可回收只是一般場景下統計的數據,我們沒有辦法保證每次回收都只有不多于 10% 的對象存活,當 Survivor 空間不夠用時,需要依賴其他內存(這里指老年代)進行分配擔保(Handle Promotion)。
內存的分配擔保就好比我們去銀行借款,如果我們信譽很好,在 98% 的情況下都能按時償還,于是銀行可能會默認我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認為沒有風險了。
內存分配擔保也一樣,如果另外一塊 Survivor 空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代。
關于對新生代進行分配擔保的內容,在本章講解垃圾收集器執行規則時還會再詳細講解。
3. 標記-整理算法
復制算法在對象存活率較高時就要進行較多的復制操作,效率將會變低。更關鍵的是,如果不想浪費 50% 的空間,就需要有額外的空間進行內存分配擔保,以應對被使用的內存中所有對象都是 100% 存活的極端情況,所以在老年代一般不能直接選用這種算法。
根據老年代的特點,有人提出了另外一種“標記-整理”(Mark-Compact)算法,標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存,“標記-整理”算法示意圖如下:
4. 分代收集算法
當前商業虛擬機的垃圾收集都采用“分代收集”算法—Generational Collection,根據對象存活周期的不同將內存劃分為幾塊并采用不用的垃圾收集算法。
一般把 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最適當的收集算法。
在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用復制算法,只需要付出少量存活對象的復制成本就可以完成垃圾收集。而老年代中因為對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記—清理”或者“標記—整理”算法來進行回收。
HotSpot 的算法實現
1. 枚舉根節點
以可達性分析中從 GC Roots 節點找引用鏈這個操作為例,可作為 GC Roots 的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,現在很多應用僅僅方法區就有數百兆,如果要逐個檢查這里面的引用,那么必然會消耗很多時間。
另外,可達性分析對執行時間的敏感性還體現在 GC 停頓上,因為這項分析工作必須不可以出現分析過程中對象引用關系還在不斷變化的情況,否則分析結果準確性就無法得到保證。這點是導致 GC 進行時必須停頓所有運行的 Java 執行線程(Sun將這件事情稱為"Stop The World")的其中一個重要原因,即使是在號稱(幾乎)不會發生停頓的 CMS 收集器中,枚舉根節點時也是必須要停頓的。
因此,目前的主流 Java 虛擬機使用的都是準確式 GC(即虛擬機可以知道內存中某個位置的數據具體是什么類型。),所以當執行系統停頓下來后,并不需要一個不漏地檢查完所有執行上下文和全局的引用位置,虛擬機應當是有辦法直接得知哪些地方存放著對象引用。
在 HotSpot 的實現中,使用一組稱為 OopMap 的數據結構來達到這個目的的,在類加載完成的時候,HotSpot 就把對象內什么偏移量上是什么類型的數據計算出來,在 JIT 的編譯過程中,也會在特定的位置記錄棧和寄存器中哪些位置是引用,因此 GC 在掃描時就可以直接得知這些信息了。
2. 安全點(Safepoint)
在 OopMap 的協助下,HotSpot 可以快速且準確地完成 GC Roots 枚舉,但一個很現實的問題隨之而來:可能導致引用關系變化,換種說法即 OopMap 內容變化的指令非常多,如果為每一條指令都生成對應的 OopMap,那將會需要大量的額外空間,這樣 GC 的空間成本將會變得很高。
實際上,HotSpot 也的確沒有為每條指令都生成 OopMap,前面已經提到,只是在特定的位置記錄了這些信息,這些位置稱為安全點,即程序執行時并非在所有地方都能停頓下來開始 GC ,只有在到達安全點時才能暫停。
Safepoint 的選定既不能太少以致于 GC 過少,也不能過于頻繁以致于過分增大運行時的負荷。
對于 Safepoint,另一個需要考慮的問題是如何在 GC 發生時讓所有線程都“跑”到最近的安全點上再停頓下來。這里有兩種方案可供選擇:搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension)。
其中搶先式中斷不需要線程的執行代碼主動去配合,在 GC 發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它“跑”到安全點上。現在幾乎沒有虛擬機實現采用搶先式中斷來暫停線程從而響應 GC 事件。
而主動式中斷的思想是當 GC 需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標志,各個線程執行時主動去輪詢這個標志,發現中斷標志為真時就自己中斷掛起。輪詢標志的地方和安全點是重合的,另外再加上創建對象需要分配內存的地方。
3. 安全區域(Safe Region)
使用 Safepoint 似乎已經完美地解決了如何進入 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 的信號為止。
垃圾收集器
如果說收集算法是內存回收的方法論,那么垃圾收集器就是內存回收的具體實現。這里討論的收集器基于JDK 1.7 Update 14之后的 HotSpot 虛擬機,這個虛擬機包含的所有收集器如下圖所示
上圖展示了 7 種作用于不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機所處的區域,則表示它是屬于新生代收集器還是老年代收集器。接下來將逐一介紹這些收集器的特性、基本原理和使用場景,并重點分析 CMS 和 G1 這兩款相對復雜的收集器,了解它們的部分運作細節。
1. Serial收集器(串行收集器)
Serial 收集器是最基本、發展歷史最悠久的收集器,曾經是虛擬機新生代收集的唯一選擇。這是一個單線程的收集器,但它的“單線程”的意義并不僅僅說明它只會使用一個 CPU 或一條收集線程去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束。
"Stop The World"這個名字也許聽起來很酷,但這項工作實際上是由虛擬機在后臺自動發起和自動完成的,在用戶不可見的情況下把用戶正常工作的線程全部停掉,這對很多應用來說都是難以接受的。下圖示意了 Serial/Serial Old 收集器的運行過程。
實際上到現在為止,該收集器依然是虛擬機運行在 Client 模式下的默認新生代收集器。它也有著優于其他收集器的地方:簡單而高效(與其他收集器的單線程比),對于限定單個 CPU 的環境來說,Serial 收集器由于沒有線程交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。
在用戶的桌面應用場景中,分配給虛擬機管理的內存一般來說不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的內存,桌面應用基本上不會再大了),停頓時間完全可以控制在幾十毫秒最多一百多毫秒以內,只要不是頻繁發生,這點停頓是可以接受的。所以,Serial 收集器對于運行在 Client 模式下的虛擬機來說是一個很好的選擇。
2. ParNew收集器
ParNew 收集器其實就是 Serial 收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其余行為包括 Serial 收集器可用的所有控制參數(-XX:HandlePromotionFailure 以及設置閥值的-XX:PretenureSizeThreshold 、-XX:SurvivorRatio 等)、收集算法、Stop The World、對象分配規則、回收策略等都與 Serial 收集器完全一樣,在實現上,這兩種收集器也共用了相當多的代碼。ParNew 收集器的工作過程如下圖所示:
ParNew 收集器除了多線程收集之外,其他與 Serial 收集器相比并沒有太多創新之處,但它卻是許多運行在 Server 模式下的虛擬機中首選的新生代收集器,其中有一個與性能無關但很重要的原因是,除了 Serial 收集器外,目前只有它能與 CMS 收集器(并發收集器,后面有介紹)配合工作。
ParNew 收集器在單 CPU 的環境中不會有比 Serial 收集器更好的效果,甚至由于存在線程交互的開銷,該收集器在通過超線程技術實現的兩個 CPU 的環境中都不能百分之百地保證可以超越 Serial 收集器。
當然,隨著可以使用的 CPU 的數量的增加,它對于 GC 時系統資源的有效利用還是很有好處的。它默認開啟的收集線程數與 CPU 的數量相同,在 CPU 非常多(如 32 個)的環境下,可以使用 -XX:ParallelGCThreads 參數來限制垃圾收集的線程數。
注意,從 ParNew 收集器開始,后面還會接觸到幾款并發和并行的收集器。這里有必要先解釋兩個名詞:并發和并行。這兩個名詞都是并發編程中的概念,在談論垃圾收集器的上下文語境中,它們可以解釋如下。
并行(Parallel):指多條垃圾收集線程并行工作,但此時用戶線程仍然處于等待狀態。
并發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不一定是并行的,可能會交替執行),用戶程序在繼續運行,而垃圾收集程序運行于另一個 CPU 上。
3. Parallel Scanvenge收集器
Parallel Scavenge 收集器是一個新生代收集器,它也是使用復制算法的收集器,又是并行的多線程收集器……看上去和 ParNew 都一樣,那它有什么特別之處呢?
Parallel Scavenge 收集器的特點是它的關注點與其他收集器不同,CMS 等收集器的關注點是盡可能地縮短垃圾收集時用戶線程的停頓時間,而 Parallel Scavenge 收集器的目標則是達到一個可控制的吞吐量(Throughput)。
所謂吞吐量就是 CPU 用于運行用戶代碼的時間與 CPU 總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),虛擬機總共運行了 100 分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99% 。
停頓時間越短就越適合需要與用戶交互的程序,良好的響應速度能提升用戶體驗,而高吞吐量則可以高效率地利用 CPU 時間,盡快完成程序的運算任務,主要適合在后臺運算而不需要太多交互的任務。
Parallel Scavenge收集器提供了兩個參數用于精確控制吞吐量,分別是控制最大垃圾收集停頓時間的 -XX:MaxGCPauseMillis 參數以及直接設置吞吐量大小的 -XX:GCTimeRatio 參數。
MaxGCPauseMillis 參數允許的值是一個大于 0 的毫秒數,收集器將盡可能地保證內存回收花費的時間不超過設定值。
不過大家不要認為如果把這個參數的值設置得稍小一點就能使得系統的垃圾收集速度變得更快,GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的:系統把新生代調小一些,收集300MB 新生代肯定比收集 500MB 快吧,這也直接導致垃圾收集發生得更頻繁一些,原來10秒收集一次、每次停頓100毫秒,現在變成5秒收集一次、每次停頓70毫秒。停頓時間的確在下降,但吞吐量也降下來了。
GCTimeRatio 參數的值應當是一個 0 到 100 的整數,也就是垃圾收集時間占總時間的比率,相當于是吞吐量的倒數。如果把此參數設置為 19,那允許的最大 GC 時間就占總時間的 5%(即 1/(1+19)),默認值為 99 ,就是允許最大 1%(即 1/(1+99))的垃圾收集時間。
由于與吞吐量關系密切,Parallel Scavenge 收集器也經常稱為“吞吐量優先”收集器。除上述兩個參數之外,Parallel Scavenge 收集器還有一個參數 -XX:+UseAdaptiveSizePolicy 值得關注。這是一個開關參數,當這個參數打開之后,就不需要手工指定新生代的大小參數 -Xmn、Eden 與 Survivor 區的比例參數 -XX:SurvivorRatio、晉升老年代對象的年齡閥值 -XX:PretenureSizeThreshold 等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱為 GC 自適應的調節策略(GC Ergonomics)。
4. Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它同樣是一個單線程收集器,使用“標記-整理”算法。這個收集器的主要意義也是在于給 Client 模式下的虛擬機使用。如果在 Server 模式下,那么它主要還有兩大用途:一種用途是在 JDK 1.5 以及之前的版本中與 Parallel Scavenge 收集器搭配使用,另一種用途就是作為 CMS 收集器的后備預案,在并發收集發生 Concurrent Mode Failure 時使用。這兩點都將在后面的內容中詳細講解。Serial Old 收集器的工作過程如下圖所示:
5. Parellel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多線程和“標記-整理”算法。這個收集器是在 JDK 1.6 中才開始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直處于比較尷尬的狀態。
原因是,如果新生代選擇了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外別無選擇(Parallel Scavenge 收集器無法與 CMS 收集器配合工作)。
由于老年代 Serial Old 收集器在服務端應用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整體應用上獲得吞吐量最大化的效果,由于單線程的老年代收集中無法充分利用服務器多 CPU 的處理能力,在老年代很大而且硬件比較高級的環境中,這種組合的吞吐量甚至還不一定有 ParNew 加 CMS 的組合“給力”。
直到 Parallel Old 收集器出現后,“吞吐量優先”收集器終于有了比較名副其實的應用組合,在注重吞吐量以及 CPU 資源敏感的場合,都可以優先考慮 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作過程如下圖所示:
6. CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。
目前很大一部分的 Java 應用集中在互聯網站或者 B/S 系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS 收集器就非常符合這類應用的需求。
從名字(包含"Mark Sweep")上就可以看出,CMS 收集器是基于“標記—清除”算法實現的,它的運作過程相對于前面幾種收集器來說更復雜一些,整個過程分為4個步驟,包括:
初始標記(CMS initial mark)
并發標記(CMS concurrent mark)
重新標記(CMS remark)
并發清除(CMS concurrent sweep)
其中,初始標記、重新標記這兩個步驟仍然需要"Stop The World"。初始標記僅僅只是標記一下 GC Roots 能直接關聯到的對象,速度很快,并發標記階段就是進行 GC RootsTracing 的過程,而重新標記階段則是為了修正并發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比并發標記的時間短。
由于整個過程中耗時最長的并發標記和并發清除過程收集器線程都可以與用戶線程一起工作,所以,從總體上來說,CMS 收集器的內存回收過程是與用戶線程一起并發執行的。
CMS 是一款優秀的收集器,它的主要優點在名字上已經體現出來了:并發收集、低停頓,但是 CMS 還遠達不到完美的程度,它有以下 3 個明顯的缺點:
第一、導致吞吐量降低。CMS 收集器對 CPU 資源非常敏感。其實,面向并發設計的程序都對 CPU 資源比較敏感。在并發階段,它雖然不會導致用戶線程停頓,但是會因為占用了一部分線程(或者說CPU資源)而導致應用程序變慢,總吞吐量會降低。
CMS 默認啟動的回收線程數是(CPU數量+3)/4,也就是當 CPU 在4個以上時,并發回收時垃圾收集線程不少于 25% 的 CPU 資源,并且隨著 CPU 數量的增加而下降。但是當 CPU 不足 4 個(譬如2個)時,CMS 對用戶程序的影響就可能變得很大,如果本來 CPU 負載就比較大,還分出一半的運算能力去執行收集器線程,就可能導致用戶程序的執行速度忽然降低了 50%,其實也讓人無法接受。
第二、CMS 收集器無法處理浮動垃圾(Floating Garbage),可能出現"Concurrent Mode Failure"失敗而導致另一次 Full GC(新生代和老年代同時回收) 的產生。由于 CMS 并發清理階段用戶線程還在運行著,伴隨程序運行自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之后,CMS 無法在當次收集中處理掉它們,只好留待下一次 GC 時再清理掉。這一部分垃圾就稱為“浮動垃圾”。
也是由于在垃圾收集階段用戶線程還需要運行,那也就還需要預留有足夠的內存空間給用戶線程使用,因此 CMS 收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供并發收集時的程序運作使用。
在 JDK 1.5 的默認設置下,CMS 收集器當老年代使用了 68% 的空間后就會被激活,這是一個偏保守的設置,如果在應用中老年代增長不是太快,可以適當調高參數 -XX:CMSInitiatingOccupancyFraction 的值來提高觸發百分比,以便降低內存回收次數從而獲取更好的性能,在 JDK 1.6 中,CMS 收集器的啟動閾值已經提升至 92% 。
要是 CMS 運行期間預留的內存無法滿足程序需要,就會出現一次"Concurrent Mode Failure"失敗,這時虛擬機將啟動后備預案:臨時啟用 Serial Old 收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了,參數 -XX:CM SInitiatingOccupancyFraction 設置得太高很容易導致大量"Concurrent Mode Failure"失敗,性能反而降低。
第三、產生空間碎片。CMS 是一款基于“標記—清除”算法實現的收集器,這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很大空間剩余,但是無法找到足夠大的連續空間來分配當前對象,不得不提前觸發一次 Full GC 。
為了解決這個問題,CMS 收集器提供了一個 -XX:+UseCMSCompactAtFullCollection 開關參數(默認就是開啟的),用于在CMS收集器頂不住要進行 FullGC 時開啟內存碎片的合并整理過程,內存整理的過程是無法并發的,空間碎片問題沒有了,但停頓時間不得不變長。虛擬機設計者還提供了另外一個參數 -XX:CMSFullGCsBeforeCompaction,這個參數是用于設置執行多少次不壓縮的 Full GC 后,跟著來一次帶壓縮的(默認值為0,表示每次進入Full GC時都進行碎片整理)。
7. G1 收集器
G1(Garbage-First)收集器是當今收集器技術發展的最前沿成果之一,從JDK 9 版本開始將 G1 變成默認的垃圾收集器,它是一款面向服務端應用的垃圾收集器,。HotSpot 開發團隊賦予它的使命是(在比較長期的)未來可以替換掉 JDK 1.5 中發布的 CMS 收集器。與其他 GC 收集器相比,G1 具備如下特點。
并行與并發:G1 能充分利用多 CPU、多核環境下的硬件優勢,使用多個CPU(CPU或者CPU核心)來縮短 Stop-The-World 停頓的時間,部分其他收集器原本需要停頓 Java 線程執行的 GC 動作,G1 收集器仍然可以通過并發的方式讓 Java 程序繼續執行。
分代收集: 與其他收集器一樣,分代概念在 G1 中依然得以保留。雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但它能夠采用不同的方式去處理新創建的對象和已經存活了一段時間、熬過多次 GC 的舊對象以獲取更好的收集效果。
空間整合: 與 CMS 的“標記—清理”算法不同,G1 從整體來看是基于“標記—整理”算法實現的收集器,從局部(兩個 Region 之間)上來看是基于“復制”算法實現的,但無論如何,這兩種算法都意味著 G1 運作期間不會產生內存空間碎片,收集后能提供規整的可用內存。這種特性有利于程序長時間運行,分配大對象時不會因為無法找到連續內存空間而提前觸發下一次 GC 。
可預測的停頓: 這是 G1 相對于 CMS 的另一大優勢,降低停頓時間是 G1 和 CMS 共同的關注點,但 G1 除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時 Java(RTSJ)的垃圾收集器的特征了。
在 G1 之前的其他收集器進行收集的范圍都是整個新生代或者老年代,而 G1 不再是這樣。使用 G1 收集器時,Java 堆的內存布局就與其他收集器有很大差別,它將整個 Java 堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分 Region (不需要連續)的集合。
G1 收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1 在后臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的 Region(這也就是Garbage-First名稱的來由),保證了 G1 收集器在有限的時間內可以獲取盡可能高的收集效率。
在 G1 收集器中,Region 之間的對象引用以及其他收集器中的新生代與老年代之間的對象引用,虛擬機都是使用 Remembered Set 來避免全堆掃描的。
G1 中每個Region 都有一個與之對應的 Remembered Set,虛擬機發現程序在對 Reference 類型的數據進行寫操作時,會產生一個 Write Barrier 暫時中斷寫操作,檢查 Reference 引用的對象是否處于不同的 Region 之中(在分代的例子中就是檢查是否老年代中的對象引用了新生代中的對象),如果是,便通過 CardTable 把相關引用信息記錄到被引用對象所屬的 Region 的 Remembered Set 之中。當進行內存回收時,在 GC 根節點的枚舉范圍中加入 Remembered Set 即可保證不對全堆掃描也不會有遺漏。
如果不計算維護 Remembered Set 的操作,G1 收集器的運作大致可劃分為以下幾個步驟:
鴻蒙官方戰略合作共建——HarmonyOS技術社區
初始標記(Initial Marking)
并發標記(Concurrent Marking)
最終標記(Final Marking)
篩選回收(Live Data Counting and Evacuation)
G1 的前幾個步驟的運作過程和 CMS 有很多相似之處。
初始標記階段僅僅只是標記一下 GC Roots 能直接關聯到的對象,并且修改 TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序并發運行時,能在正確可用的 Region 中創建新對象,這階段需要停頓線程,但耗時很短。
并發標記階段是從 GC Root 開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序并發執行。
而最終標記階段則是為了修正在并發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機將這段時間對象變化記錄在線程 Remembered Set Logs 里面,最終標記階段需要把 Remembered Set Logs 的數據合并到 Remembered Set 中,這階段需要停頓線程,但是可并行執行。
最后在篩選回收階段首先對各個 Region 的回收價值和成本進行排序,根據用戶所期望的 GC 停頓時間來制定回收計劃,從Sun公司透露出來的信息來看,這個階段其實也可以做到與用戶程序一起并發執行,但是因為只回收一部分 Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。通過下圖可以比較清楚地看到G1收集器的運作步驟中并發和需要停頓的階段。
GC 日志
閱讀 GC 日志是處理 Java 虛擬機內存問題的基礎技能,它只是一些人為確定的規則,沒有太多技術含量。
每一種收集器的日志形式都是由它們自身的實現所決定的,換而言之,每個收集器的日志格式都可以不一樣。但虛擬機設計者為了方便用戶閱讀,將各個收集器的日志都維持一定的共性,例如以下兩段典型的 GC 日志:
33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs] 100.667:[Full GC[Tenured:0 K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
最前面的數字33.125: 和 100.667: 代表了 GC 發生的時間,這個數字的含義是從 Java 虛擬機啟動以來經過的秒數。
GC 日志開頭的 [GC 和 [Full GC 說明了這次垃圾收集的停頓類型,而不是用來區分新生代 GC 還是老年代 GC 的。
如果有 Full ,說明這次 GC 是發生了 Stop-The-World 的,例如下面這段新生代收集器 ParNew 的日志也會出現 [Full GC(這一般是因為出現了分配擔保失敗之類的問題,所以才導致 STW)。如果是調用 System.gc() 方法所觸發的收集,那么在這里將顯示 [Full GC(System)。
[Full GC 283.736:[ParNew:261599K->261599K(261952K),0.0000288 secs]
接下來的 [DefNew、[Tenured、[Perm 表示 GC 發生的區域,這里顯示的區域名稱與使用的 GC 收集器是密切相關的,例如上面樣例所使用的 Serial 收集器中的新生代名為 "Default New Generation",所以顯示的是 [DefNew。如果是 ParNew 收集器,新生代名稱就會變為 [ParNew,意為 "Parallel New Generation"。如果采用 Parallel Scavenge 收集器,那它配套的新生代稱為 PSYoungGen,老年代和永久代同理,名稱也是由收集器決定的。
后面方括號內部的 3324K->152K(3712K) 含義是GC 前該內存區域已使用容量 -> GC 后該內存區域已使用容量 (該內存區域總容量)。而在方括號之外的 3324K->152K(11904K) 表示 GC 前 Java 堆已使用容量 ->GC 后 Java 堆已使用容量 (Java 堆總容量)。
再往后,0.0025925 secs 表示該內存區域 GC 所占用的時間,單位是秒。有的收集器會給出更具體的時間數據,如 [Times:user=0.01 sys=0.00,real=0.02 secs] ,這里面的 user、sys 和 real 與 Linux 的 time 命令所輸出的時間含義一致,分別代表用戶態消耗的 CPU 時間、內核態消耗的 CPU 事件和操作從開始到結束所經過的墻鐘時間(Wall Clock Time)。
CPU 時間與墻鐘時間的區別是,墻鐘時間包括各種非運算的等待耗時,例如等待磁盤 I/O、等待線程阻塞,而 CPU 時間不包括這些耗時,但當系統有多 CPU 或者多核的話,多線程操作會疊加這些 CPU 時間,所以讀者看到 user 或 sys 時間超過 real 時間是完全正常的。
垃圾收集器參數總結
JDK 1.7 中的各種垃圾收集器到此已全部介紹完畢,在描述過程中提到了很多虛擬機非穩定的運行參數,在表3-2中整理了這些參數供讀者實踐時參考:
內存分配與回收策略
對象的內存分配,往大方向講,就是在堆上分配,對象主要分配在新生代的Eden區上。少數情況下也可能會直接分配在老年代中,分配的規則并不是百分之百固定的,其細節取決于當前使用的是哪一種垃圾收集器組合,還有虛擬機中與內存相關的參數的設置。
圖摘自《碼出高效》
1. 對象優先在 Eden 分配
大多數情況下,對象在新生代Eden 區中分配。當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC。
虛擬機提供了 -XX:+PrintGCDetails 這個收集器日志參數,告訴虛擬機在發生垃圾收集行為時打印內存回收日志,并且在進程退出的時候輸出當前的內存各區域分配情況。
private static final int_1MB=1024 * 1024; /** *VM參數:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails -XX:SurvivorRatio=8 */ public static void testAllocation () { byte[] allocation1,allocation2,allocation3,allocation4; allocation1 = new byte[2 * _1MB]; allocation2 = new byte[2 * _1MB]; allocation3 = new byte[2 * _1MB]; allocation4 = new byte[4 * _1MB];//出現一次Minor GC }
運行結果:
[GC[DefNew:6651K->148K(9216K),0.0070106 secs]6651K->6292K(19456K), 0.0070426 secs][Times:user=0.00 sys=0.00,real=0.00 secs] Heap def new generation total 9216K,used 4326K[0x029d0000,0x033d0000,0x033d0000) eden space 8192K,51%used[0x029d0000,0x02de4828,0x031d0000) from space 1024K,14%used[0x032d0000,0x032f5370,0x033d0000) to space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000) tenured generation total 10240K,used 6144K[0x033d0000,0x03dd0000,0x03dd0000) the space 10240K,60%used[0x033d0000,0x039d0030,0x039d0200,0x03dd0000) compacting perm gen total 12288K,used 2114K[0x03dd0000,0x049d0000,0x07dd0000) the space 12288K,17%used[0x03dd0000,0x03fe0998,0x03fe0a00,0x049d0000) No shared spaces configured.
上方代碼的 testAllocation() 方法中,嘗試分配 3 個 2MB 大小和 1 個 4MB 大小的對象,在運行時通過 -Xms20M、-Xmx20M、-Xmn10M 這 3 個參數限制了 Java 堆大小為 20MB ,不可擴展,其中 10MB 分配給新生代,剩下的 10MB 分配給老年代。-XX:SurvivorRatio=8 決定了新生代中 Eden 區與一個 Survivor 區的空間比例是 8:1,從輸出的結果也可以清晰地看到 eden space 8192K、from space 1024K、to space 1024K 的信息,新生代總可用空間為 9216KB(Eden區+1個Survivor區的總容量)。
執行 testAllocation() 中分配 allocation4 對象的語句時會發生一次 Minor GC,這次 GC 的結果是新生代 6651KB 變為 148KB ,而總內存占用量則幾乎沒有減少(因為 allocation1、allocation2、allocation3 三個對象都是存活的,虛擬機幾乎沒有找到可回收的對象)。
這次 GC 發生的原因是給 allocation4 分配內存的時候,發現 Eden 已經被占用了 6MB,剩余空間已不足以分配 allocation4 所需的 4MB 內存,因此發生 Minor GC。GC 期間虛擬機又發現已有的 3 個 2MB 大小的對象全部無法放入 Survivor 空間(Survivor 空間只有 1MB 大小),所以只好通過分配擔保機制提前轉移到老年代去。
這次 GC 結束后,4MB 的 allocation4 對象順利分配在 Eden 中,因此程序執行完的結果是 Eden 占用 4MB(被allocation4占用),Survivor 空閑,老年代被占用 6MB(被allocation1、allocation2、allocation3占用)。通過 GC 日志可以證實這一點。
2. Minor GC 與 Full GC 的區別
新生代 GC(Minor GC):指發生在新生代的垃圾收集動作,因為 Java 對象大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
老年代 GC(Major GC/Full GC):指發生在老年代的 GC,出現了 Major GC,經常會伴隨至少一次的 Minor GC(但非絕對的,在 Parallel Scavenge 收集器的收集策略里就有直接進行 Major GC 的策略選擇過程)。Major GC 的速度一般會比 Minor GC 慢 10 倍以上。
3. 大對象直接進入老年代
所謂的大對象是指,需要大量連續內存空間的 Java 對象,最典型的大對象就是那種很長的字符串以及數組( byte[] 數組就是典型的大對象)。大對象對虛擬機的內存分配來說就是一個壞消息(特別是短命大對象,寫程序的時候應當避免),經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。
虛擬機提供了 -XX:PretenureSizeThreshold 參數,令大于這個設置值的對象直接在老年代分配。這樣做的目的是避免在 Eden 區及兩個 Survivor 區之間發生大量的內存復制。
private static final int_1MB=1024 * 1024; /** *VM參數:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8 *-XX:PretenureSizeThreshold=3145728 */ public static void testPretenureSizeThreshold () { byte[] allocation; allocation = new byte[4 * _1MB];//直接分配在老年代中 }
運行結果:
Heap
def new generation total 9216K,used 671K[0x029d0000,0x033d0000,0x033d0000)
eden space 8192K,8%used[0x029d0000,0x02a77e98,0x031d0000)
from space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
to space 1024K,0%used[0x032d0000,0x032d0000,0x033d0000)
tenured generation total 10240K,used 4096K[0x033d0000,0x03dd0000,0x03dd0000)
the space 10240K,40%used[0x033d0000,0x037d0010,0x037d0200,0x03dd0000)
compacting perm gen total 12288K,used 2107K[0x03dd0000,0x049d0000,0x07dd0000)
the space 12288K,17%used[0x03dd0000,0x03fdefd0,0x03fdf000,0x049d0000)
No shared spaces configured.
執行以上代碼中的 testPretenureSizeThreshold() 方法后,我們看到 Eden 空間幾乎沒有被使用,而老年代的 10MB 空間被使用了 40%,也就是 4MB 的 allocation 對象直接就分配在老年代中,這是因為 PretenureSizeThreshold 參數被設置為 3MB(就是 3145728,這個參數不能像 -Xmx 之類的參數一樣直接寫 3MB),因此超過 3MB 的對象都會直接在老年代進行分配。
注意 PretenureSizeThreshold 參數只對 Serial 和 ParNew 兩款收集器有效,Parallel Scavenge 收集器不認識這個參數,Parallel Scavenge 收集器一般并不需要設置。如果遇到必須使用此參數的場合,可以考慮 ParNew 加 CMS 的收集器組合。
4. 長期存活的對象將進入老年代
虛擬機給每個對象定義了一個對象年齡(Age)計數器。
如果對象在 Eden 出生并經過第一次 Minor GC 后仍然存活,并且能被 Survivor 容納的話,將被移動到 Survivor 空間中,并且對象年齡設為 1 。對象在 Survivor 區中每“熬過”一次 Minor GC,年齡就增加 1 歲,當它的年齡增加到一定程度(默認為 15 歲),就將會被晉升到老年代中。
對象晉升老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 設置。
5. 動態對象年齡判定
為了能更好地適應不同程序的內存狀況,無須等到 MaxTenuringThreshold 中要求的年齡,同年對象達到 Survivor 空間的一半后,他們以及年齡大于他們的對象都將直接進入老年代。
6. 空間分配擔保
在發生 Minor GC 之前,虛擬機會先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間,如果這個條件成立,那么 Minor GC 可以確保是安全的。
只要老年代的連續空間大于新生代對象總大小或者歷次晉升的平均大小就會進行 Minor GC ,否則將進行 Full GC 。
到此,相信大家對“Java垃圾回收機制的原理是什么”有了更深的了解,不妨來實際操作一番吧!這里是億速云網站,更多相關內容可以進入相關頻道進行查詢,關注我們,繼續學習!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。