標籤:動態對象 tin lan 資料 剩餘空間 ada 調整 準備 基本
前言
在C++語言中, 程式員必須小心謹慎的處理每一項記憶體配置, 且記憶體使用量完後必須手動釋放曾經佔用的記憶體空間。當記憶體釋放不夠完全時, 即存在分配但永不釋放的記憶體塊, 就會引起"記憶體流失"問題。
而在Java語言中, 它給了程式員一個美好的承諾: 程式員無需管理記憶體, 因為JVM會有GC去自動進行記憶體回收。其實不然:
- 記憶體回收並不會按照程式員的要求, 隨時進行GC。
- 記憶體回收並不會及時的清理記憶體, 儘管有時程式需要額外的記憶體。
- 程式員不能對記憶體回收進行控制。
基於上面的事實, 我們就有必要徹底瞭解JVM的自動記憶體管理機制, 如此才能將程式控制於鼓掌之中。本篇文章就是從記憶體回收和記憶體配置這兩個知識點, 對JVM的記憶體管理機製做一個基本的瞭解。
為什麼要進行記憶體回收?
隨著程式的運行,記憶體中存在的執行個體對象、變數等資訊佔據的記憶體越來越多,如果不及時進行記憶體回收,必然會帶來程式效能的下降,甚至會因為可用記憶體不足造成一些不必要的系統異常。
哪些垃圾要進行回收?
在Java記憶體運行時地區的各個部分, 其中程式計數器、JVM棧、本地方法棧3個地區的生命週期是和線程同步的, 他們佔用的記憶體會隨著線程銷毀而自動釋放, 所以這幾個地區不需要過多的考慮記憶體回收問題。
而Java堆和方法區則不一樣, 一個介面中的多個實作類別需要的記憶體可能不一樣, 一個方法中的多個分支需要的記憶體也可能不一樣, 我們只有在程式處於運行期間才能知道會建立哪些對象, 這部分記憶體的分配和回收是動態, 所以需要進行GC。
什麼時候進行記憶體回收?
垃圾收集器在對Java堆進行回收前, 會先去確定所有的對象執行個體之中哪些還"存活"著, 哪些已經"死去"(即已經不存在任何引用)。
在很多教科書中是根據引用計數演算法來判斷對象是否可回收的: 給對象中添加一個引用計數器, 每被引用一次,計數器加1; 引用失效時,計數器減1; 當計數器在一段時間內保持為0時,該對象就被認為是可回收的。但是, 這個演算法有明顯的缺陷: 當兩個對象相互引用,但是二者已經沒有作用時,按照常規,應該對其進行記憶體回收,但是其相互引用,又不符合記憶體回收的條件,因此無法完美處理這塊記憶體清理。因此Sun的JVM並沒有採用引用計數演算法, 而是採用了可達性分析演算法來進行記憶體回收。
可達性分析演算法的基本思想是: 通過一系列的稱為"GC Roots"的對象作為起始點, 從這些節點開始向下搜尋, 搜尋所走過的路徑稱為引用鏈, 當一個對象到GC Roots沒有任何引用鏈相連時, 則證明此對象是停用。如所示, 對象object5、object6、object7雖然互相有關聯, 但是它們到GC Roots是不可達的, 所以它們將會被判定為是可回收的對象。
無論是引用計數演算法, 還是可達性分析演算法, 它們判定對象是否存活都與"引用"有關。在JDK 1.2之後, Java對引用的概念進行了擴充,引入了強、軟、弱、虛四種引用, 這4種引用強度依次逐漸減弱。關於這幾種引用的概念, 讀者可自行瞭解, 這裡就不多做贅述。
另外, 即使在可達性分析演算法中不可達的對象,也並非是"非死不可"的。如果類重寫了finalize()方法, 且沒有被虛擬機器調用過, 那麼虛擬機器會調用一次finalize()方法, 以完成最後的工作, 在此期間, 如果對象重新與引用鏈上的任何一個對象建立關聯,則該對象可以“重生”; 如果對象這時候還沒有逃脫, 那麼它就真的被回收了。
垃圾收集器在對方法區進行回收前, 會先去判定一個類是否是"無用的類", 而類需要同時滿足下面3個條件才能算是"無用的類":
- 該類的所有執行個體對象都已經被回收。
- 載入該類的ClassLoader已經被回收。
- 該類對應的java.lang.Class對象沒有在任何地方被引用, 無法在任何地方通過反射訪問該類的方法。
如何進行記憶體回收?
在Java堆中, 記憶體被分為新生代和舊生代, 兩者比例為1:2。新生代適合那些生命週期較短、頻繁建立及銷毀的對象, 舊生代適合生命週期相對較長的對象和需要大量連續記憶體空間的大對象。
如所示, 新生代中分為Eden區和Survivor區, 而Survivor區又分為大小相同的兩部分:FromSpace 和ToSpace。其中Eden區和一個Survivor區的預設空間比例為8:1, 可以用-XX:SurvivorRatio來設定大小。大多數情況下, 對象在新生代Eden區中分配, 當Eden空間不足時, 虛擬機器將發起一次Minor GC把存活的對象轉移到Survivor區中。新生代採用複製演算法收集記憶體。
舊生代中用於存放新生代中經過多次記憶體回收仍然存活的對象, 和一些需要大量連續記憶體空間的大對象。另外在JVM中還有一種動態對象年齡判定: 如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半, 年齡大於或等於該年齡的對象就可以直接進入舊生代。舊生代採用標記-整理(壓縮)演算法收集記憶體。
垃圾收集演算法
在上文中, 我們提及到複製演算法和標記-整理(壓縮)演算法, 這兩種演算法就是常見的GC演算法之一。
標記-清除演算法(Mark-Sweep)
標記-清除是最基礎的GC演算法, 分為"標記"和"清除"兩個階段: 首先標記出所有需要回收的對象, 然後掃描和回收所有被標記的對象。它有兩個不足: 其一, 標記和清除兩個過程的效率都不高;其二, 標記清除之後會產生大量不連續的記憶體片段, 空間片段太多可能會導致以後在分配大對象時, 無法找到足夠的連續記憶體而不得不提前觸發另一次GC動作。
複製演算法(Copying)
前文提到, 新生代分為1塊Eden區和2塊Survivor區, 其中Eden區和一個Survivor區的預設空間比例為8:1, 即另一塊Survivor區是閒置。在記憶體回收時, 將Eden區和Survivor區還存活著的對象一次性地複製到另一塊Survivor空間上, 然後清理掉剛才用過的Eden和Survivor空間。當第二塊Survivor空間不夠用時, 就需要依賴舊生代進行分配擔保。複製演算法適用於新生代。
標記-整理(壓縮)演算法(Mark-compact)
標記過程仍然與"標記-清除"演算法一樣, 但後續步驟不是直接對可回收對象進行清理, 而是讓所有存活的對象都向一端移動, 然後直接清理掉端邊界以外的記憶體。標記-整理演算法適用於舊生代。
分代收集演算法(Generational Collecting)
根據記憶體回收對象的特性, 不同階段最優的方式是使用合適的演算法用於本階段的記憶體回收, 分代演算法即是基於這種思想, 它將記憶體區間根據對象的特點分成幾塊, 根據每塊記憶體區間的特點, 使用不同的回收演算法, 以提高記憶體回收的效率。以Hot Spot 虛擬機器為例, 它將Java堆分為新生代和舊生代, 這樣就能根據各個年代的特點採用最適當的收集演算法。
垃圾收集器分類
基於JDK 1.7 Update 14之後的HotSpot虛擬機器包含的所有收集器如所示。
Serial收集器
串列收集器主要有兩個特點:第一,它僅僅使用單線程進行記憶體回收;第二,它獨佔式的記憶體回收。
在串列收集器進行記憶體回收時,Java 應用程式中的線程都需要暫停,等待記憶體回收的完成,這樣給使用者體驗造成較差效果。雖然如此,串列收集器卻是一個成熟、經過長時間生產環境考驗的極為高效的收集器。新生代串列處理器使用複製演算法,實現相對簡單,邏輯處理特別高效,且沒有線程切換的開銷。在諸如單 CPU 處理器或者較小的應用記憶體等硬體平台不是特別優越的場合,它的效能表現可以超過並行回收器和並發回收器。在 HotSpot 虛擬機器中,使用-XX:+UseSerialGC參數可以指定使用新生代串列收集器和舊生代串列收集器。當 JVM 在 Client 模式下運行時,它是預設的垃圾收集器。
ParNew收集器
並行收集器是工作在新生代的垃圾收集器,它只簡單地將串列回收器多線程化。它的回收策略、演算法以及參數和串列回收器一樣。
並行回收器也是獨佔式的回收器,在收集過程中,應用程式會全部暫停。但由於並行回收器使用多線程進行記憶體回收,因此,在並發能力比較強的 CPU 上,它產生的停頓時間要短於串列回收器,而在單 CPU 或者並發能力較弱的系統中,並行回收器的效果不會比串列回收器好,由於多線程的壓力,它的實際表現很可能比串列回收器差。
開啟並行回收器可以使用參數-XX:+UseParNewGC,該參數設定新生代使用並行收集器,舊生代使用串列收集器。
Parallel Scavenge收集器
新生代並行回收收集器也是使用複製演算法的收集器。從表面上看,它和並行收集器一樣都是多線程、獨佔式的收集器。但是,並行回收收集器有一個重要的特點:它非常關注系統的輸送量。
新生代並行回收收集器可以使用以下參數啟用:
- -XX:+UseParallelGC:新生代使用並行回收收集器,舊生代使用串列收集器。
- -XX:+UseParallelOldGC:新生代和舊生代都使用並行回收收集器。
另外, 並行回收收集器與並行收集器另一個不同之處在於,它支援一種自適應的 GC 調節策略,使用-XX:+UseAdaptiveSizePolicy可以開啟自適應 GC 策略。在這種模式下,新生代的大小、eden 和 survivor 的比例、晉陞舊生代的對象年齡等參數會被自動調整,以達到在堆大小、輸送量和停頓時間之間的平衡點。在手工調優比較困難的場合,可以直接使用這種自適應的方式,僅指定虛擬機器的最大堆、目標的輸送量 (GCTimeRatio) 和停頓時間 (MaxGCPauseMills),讓虛擬機器自己完成調優工作。
Serial Old收集器
舊生代串列收集器使用的是標記-壓縮演算法。和新生代串列收集器一樣,它也是一個串列的、獨佔式的記憶體回收行程。由於舊生代記憶體回收通常會使用比新生代記憶體回收更長的時間,因此,在堆空間較大的應用程式中,一旦舊生代串列收集器啟動,應用程式很可能會因此停頓幾秒甚至更長時間。雖然如此,舊生代串列回收器可以和多種新生代回收器配合使用,同時它也可以作為 CMS 回收器的備用回收器。若要啟用舊生代串列回收器,可以嘗試使用參數-XX:+UseSerialGC指定新生代、舊生代都使用串列回收器。
Parallel Old收集器
舊生代的並行回收收集器也是一種多線程並行的收集器。和新生代並行回收收集器一樣,它也是一種關注輸送量的收集器。舊生代並行回收收集器使用標記-壓縮演算法,JDK1.6 之後開始啟用。
使用-XX:+UseParallelOldGC可以在新生代和舊生代都使用並行回收收集器,這是一對非常關注輸送量的垃圾收集器組合,在對輸送量敏感的系統中,可以考慮使用。參數-XX:ParallelGCThreads也可以用於設定記憶體回收時的線程數量。
CMS (Concurrent Mark Sweep)收集器
與並行回收收集器不同,CMS 收集器主要關注於系統停頓時間。CMS 是 Concurrent Mark Sweep 的縮寫,意為並發標記清除,從名稱上可以得知,它使用的是標記-清除演算法,同時它又是一個使用多線程並發回收的垃圾收集器。
CMS 工作時,主要步驟有:初始標記、並發標記、重新標記、並發清除和並發重設。其中初始標記和重新標記是獨佔系統資源的,而並發標記、並發清除和並發重設是可以和使用者線程一起執行的。因此,從整體上來說,CMS 收集不是獨佔式的,它可以在應用程式運行過程中進行記憶體回收。
根據標記-清除演算法,初始標記、並發標記和重新標記都是為了標記出需要回收的對象。並發清理則是在標記完成後,正式回收垃圾對象;並發重設是指在記憶體回收完成後,重新初始化 CMS 資料結構和資料,為下一次記憶體回收做好準備。並發標記、並發清理和並發重設都是可以和應用程式線程一起執行的。
CMS 收集器在其主要的工作階段雖然沒有暴力地徹底暫停應用程式線程,但是由於它和應用程式線程並發執行,相互搶佔 CPU,所以在 CMS 執行期內對應用程式輸送量造成一定影響。CMS 預設啟動的線程數是 (ParallelGCThreads+3)/4),ParallelGCThreads 是新生代並行收集器的線程數,也可以通過-XX:ParallelCMSThreads 參數手工設定 CMS 的線程數量。當 CPU 資源比較緊張時,受到 CMS 收集器線程的影響,應用程式的效能在記憶體回收階段可能會非常糟糕。
由於 CMS 收集器不是獨佔式的回收器,在 CMS 回收過程中,應用程式仍然在不停地工作。在應用程式工作過程中,又會不斷地產生垃圾。這些新產生的垃圾在當前 CMS 回收過程中是無法清除的。同時,因為應用程式沒有中斷,所以在 CMS 回收過程中,還應該確保應用程式有足夠的記憶體可用。因此,CMS 收集器不會等待堆記憶體飽和時才進行記憶體回收,而是當前堆記憶體使用量率達到某一閾值時,便開始進行回收,以確保應用程式在 CMS 工作過程中依然有足夠的空間支援應用程式運行。
這個回收閾值可以使用-XX:CMSInitiatingOccupancyFraction 來指定,預設是 68。即當舊生代的空間使用率達到 68%時,會執行一次 CMS 回收。如果應用程式的記憶體使用量率增長很快,在 CMS 的執行過程中,已經出現了記憶體不足的情況,此時,CMS 回收將會失敗,JVM 將啟動舊生代串列收集器進行記憶體回收。如果這樣,應用程式將完全中斷,直到垃圾收集完成,這時,應用程式的停頓時間可能很長。因此,根據應用程式的特點,可以對-XX:CMSInitiatingOccupancyFraction 進行調優。如果記憶體增長緩慢,則可以設定一個稍大的值,大的閾值可以有效降低 CMS 的觸發頻率,減少舊生代回收的次數可以較為明顯地改善應用程式效能。反之,如果應用程式記憶體使用量率增長很快,則應該降低這個閾值,以避免頻繁觸發舊生代串列收集器。
標記-清除演算法將會造成大量記憶體片段,離散的可用空間無法分配較大的對象。在這種情況下,即使堆記憶體仍然有較大的剩餘空間,也可能會被迫進行一次記憶體回收,以換取一塊可用的連續記憶體,這種現象對系統效能是相當不利的,為瞭解決這個問題,CMS 收集器還提供了幾個用於記憶體壓縮整理的演算法。
-XX:+UseCMSCompactAtFullCollection 參數可以使 CMS 在垃圾收集完成後,進行一次記憶體磁碟重組。記憶體片段的整理並不是並發進行的。-XX:CMSFullGCsBeforeCompaction 參數可以用於設定進行多少次 CMS 回收後,進行一次記憶體壓縮。
G1收集器
G1 收集器的目標是作為一款伺服器的垃圾收集器,因此,它在輸送量和停頓控制上,預期要優於 CMS 收集器。
與 CMS 收集器相比,G1 收集器是基於標記-壓縮演算法的。因此,它不會產生空間片段,也沒有必要在收集完成後,進行一次獨佔式的磁碟重組工作。G1 收集器還可以進行非常精確的停頓控制。它可以讓開發人員指定當停頓時間長度為 M 時,記憶體回收時間不超過 N。使用參數-XX:+UnlockExperimentalVMOptions –XX:+UseG1GC 來啟用 G1 回收器,設定 G1 回收器的目標停頓時間:-XX:MaxGCPauseMills=20,-XX:GCPauseIntervalMills=200。
上述幾種垃圾收集器, 從不同角度分析, 可以將其分為不同的類型:
- 按線程數分,可以分為串列記憶體回收行程和並行記憶體回收行程。串列記憶體回收行程一次只使用一個線程進行記憶體回收;並行記憶體回收行程一次將開啟多個線程同時進行記憶體回收。在並行能力較強的 CPU 上,使用並行記憶體回收行程可以縮短 GC 的停頓時間。
- 按照工作模式分,可以分為並髮式記憶體回收行程和獨佔式記憶體回收行程。並髮式記憶體回收行程與應用程式線程交替工作,以儘可能減少應用程式的停頓時間;獨佔式記憶體回收行程 (Stop the world) 一旦運行,就停止應用程式中的其他所有線程,直到記憶體回收過程完全結束。
- 按片段處理方式可分為壓縮式記憶體回收行程和非壓縮式記憶體回收行程。壓縮式記憶體回收行程會在回收完成後,對存活對象進行壓縮整理,消除回收後的片段;非壓縮式的記憶體回收行程不進行這步操作。
- 按工作的記憶體區間,又可分為新生代記憶體回收行程和舊生代記憶體回收行程。
而要評價一個垃圾收集器的好壞, 可以用以下指標:
- 輸送量:指在應用程式的生命週期內,應用程式所花費的時間和系統總已耗用時間的比值。系統總已耗用時間=應用程式耗時+GC 耗時。如果系統運行了 100min,GC 耗時 1min,那麼系統的輸送量就是 (100-1)/100=99%。
- 記憶體回收行程負載:和輸送量相反,記憶體回收行程負載指記憶體回收行程耗時與系統運行總時間的比值。
- 停頓時間:指記憶體回收行程正在運行時,應用程式的暫停時間。對於獨佔回收器而言,停頓時間可能會比較長。使用並發的回收器時,由於記憶體回收行程和應用程式交替運行,程式的停頓時間會變短,但是,由於其效率很可能不如獨佔記憶體回收行程,故系統的輸送量可能會較低。
- 記憶體回收頻率:指記憶體回收行程多長時間會運行一次。一般來說,對於固定的應用而言,記憶體回收行程的頻率應該是越低越好。通常增大堆空間可以有效降低記憶體回收發生的頻率,但是可能會增加回收產生的停頓時間。
- 反應時間:指當一個對象被稱為垃圾後多長時間內,它所佔據的記憶體空間會被釋放。
- 堆分配:不同的記憶體回收行程對堆記憶體的分配方式可能是不同的。一個良好的垃圾收集器應該有一個合理的堆記憶體區間劃分。
小結
在本篇文章中, 我們主要從為什麼、哪些、什麼時候、以及如何進行記憶體回收等4個方面對Java的記憶體回收機製做一個基本的認識, 另外也瞭解了GC的4種演算法, 和垃圾收集器的分類概述及評價指標。
參考資料
《成神之路-基礎篇》JVM——記憶體回收
Java的記憶體回收機制