Java GC

閱讀《深入理解Java虛擬機-JVM高級特性與最佳實踐》.周志明 筆記

上一篇文章記錄了自己學習JVM運行時數據區,對內存幾個區域的劃分有了瞭解,以及會遇到的一些OOM的問題。使用1.7和1.8的JDK環境跑了幾個程序,結果有些不一樣。爲什麼有些不一樣,帶着問題進行第三章的閱讀:垃圾收集器與內存分配策略。

目前內存動態分配和內存回收技術已經相當成熟,一切看起來都進入了“自動化”時代,那麼爲什麼我們還要去了解GC和內存分配呢?答案很簡單:當需要排查各種內存溢出、內存泄露問題時,當垃圾收集成爲系統達到更高併發量的瓶頸時,我們就需要對這些“自動化”的技術實施必要的監控和調節。

-----引自 《深入理解Java虛擬機-JVM高級特性與最佳實踐》第3章

 

JVM運行時內存劃分,程序計數器、虛擬機棧、本地方法棧三個區域隨線程而生,隨線程而死;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧操作。每一個棧幀分配多少內存基本上是在類結構確定下來時就已知的,因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因爲方法結束或者線程結束時,內存自然就跟隨着回收了。而Java堆和方法區就不一樣,一個接口的多個實現需要的內存可能不一樣,一個方法的多個分支需要的內存也可能不一樣,只有在程序運行期間才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,垃圾收集器所關注的是這部分內存。

其實按照我個人理解,根據每個區域存儲的東西來看更直觀,特別是堆(Java Heap)用來存儲對象實例,那麼Java程序中Java對象實例佔用的存儲本來就很大,而且這些對象實例是後續是否會被用到(或者說後續程序不會用到),那麼可能就沒有再存儲在內存空間了,所以去主動的釋放這一部分內存空間,纔會使得GC的工作有價值。

欲回收,必先知何可收

在Java Heap裏面存放着Java世界幾乎所有的對象實例,垃圾收集器在對垃圾進行回收前,第一件事情就是要確定這些對象之間哪些還“活着”、哪些已經“死去”(即不可能再被任何途徑使用的對象)。爲了做好第一件事情,可以後很多算法,如:

引用計數算法:

給對象添加一個引用計數器,每當有一個地方引用它時,計數器就加1;當引用失效時,計數器就減1;任何計數器爲0的對象就是不可能再被使用的。客觀講,引用計數法實現簡單,判定效率高。但是目前主流的JVM並沒有使用這個算法。主要原因是它很難解決對象之間相互循環引用的問題。

可達性分析算法:

可達性分析算法基本思想:通過一系列的稱爲“GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲“引用鏈”,當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是 不可用的。那麼在Java語言中,可作爲GC Roots的對象包括以下幾種:

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

關於引用:

從JDK1.2之後,Java將引用分爲四種:

強引用:類似"Obejct obi = new Object();" 這類引用,只要強引用還存在,;垃圾收集器永遠不會回收掉被引用的對象。

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

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

/**
 * @date 2019/3/18 0018 下午 3:56
 * @Description 弱引用使用方式
 */
public class WeakReferenceDemo {

    public static void main(String[] args){
        Car car = new Car();
        WeakReference<Car> weakCar = new WeakReference<Car>(car);
        // 當要獲得weak reference引用的Obejct時
        // 首先需要判斷它是否已經被回收
        // 如果get返回爲null,則其指向的對象已經被回收
        if(weakCar.get()!=null){
            //TODO
            //do somthing
        }
    }
}

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

回收方法區:

首先方法區的垃圾回收效率是很低的。

方法區的垃圾回收,主要是永久代的垃圾回收,永久代的垃圾回收分爲兩部分內容:“廢棄常量”和“無用的類”。

如字符串“abc”沒有被String對象引用了,那麼就會被系統清理出常量池。常量池中其他類(接口)、方法、字段的符號引用也是類似。

需要注意的是一個類是否“無用的類”的條件則相對苛刻,需要滿足以下三個條件(滿女條件可以回收,不是必然回收,可設參控制):

  1. 該類所有的實例都已經被回收,也就是說Java堆中不存在該類的任何實例;
  2. 加載該類的ClassLoader已經被回收;
  3. 該類對應的java.lang.Class對象沒有被任何地方引用,無法再任何地方通過反射訪問該類的方法。

**************************************************************分割線****************************************************************************************

前面的部分是介紹對象存活判定算法。接下來需要解鎖一下垃圾收集的姿勢(知識)。

書上講的很清楚,各個平臺的虛擬機操作內存的方法各不相同,所以無法去一一介紹具體程序的代碼實現。只了瞭解到垃圾收集算法即可。

垃圾收集算法

"標記-清除"算法:顧名思義,兩個階段,首先標記處多有需要回收的對象(前文中提到,可達性分析算法),標記完成之後統一回收多有被標記的對象。缺點:一個是效率問題,標記和清除兩個過程的效率都不高;另外一個是空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能導致以後再程序運行過程中需要分配較大對象時,無法找到足夠的連續內訓而得不到提前觸發另一次垃圾收集動作。

"複製"算法:複製算法是爲了解決“標記-清除”算法效率低的問題,它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中一塊。當一塊內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉!這樣每次都可以對整個搬去進行內存回收,也不用考慮到內存碎片等複雜情況。但是付出的代價就是可用內存縮小爲原來的一半,代價有些高!現在很多商業虛擬機都採用這種回收算法來回收新生代,因爲新生代的對象98%是“朝生夕死”,所以使用區域和備用區域也不是按照1:1劃分,而是將內存華爲爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另外一塊Survivor。最後清理掉Eden和剛纔使用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1。當然這樣的比例劃分是經過研究的。但是針對有些項目工程來說可能就是會超過10%的對象存貨下來,那麼當Survivor空間不夠使用時,就需要依賴其他內存(此處指老年代)進行分擔分配擔保。所以,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存貨對象時,這些對象將直接通過分配擔保機制進入老年代。

“標記-整理”算法:複製收集算法在對象存活率較高時就要進行較多的複製操作,效率會跟着下降。另外,如果不想浪費50%的空間,就需要額外的空間進行分配擔保,以應對被使用內存中所有對象100%都存活下來的極端情況,所以在老年代一般不能直接使用這種算法。所以針對老年代的特點,“標記-整理”算法就出來了。在“標記-清除”算法基礎之上,在後續的步驟中,不是直接對可回收的對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。

“分代收集”算法:其實前兩種算法當中已經提到了新生代和老年代,當前商業虛擬機的垃圾收集都採用了“分代收集”算法,根據對象存活週期的不同,將內存劃分爲幾塊。一般是把Java堆劃分爲新生代和老年代。那麼新生代就是用“複製”算法,而老年代就使用“標記-清除”或者“標記-整理”算法。

 

**************************************************************分割線****************************************************************************************

簡單學習一下HotSpot的算法實現

Java_version

爲什麼要了解HotSpot呢?看上圖!

枚舉根節點

第一部分提到現在用的主流虛機在判斷對象是否存活的時候,使用可達性分析算法。即,從GC Roots節點找引用鏈,可以作爲GC Roots的節點主要在全局性的引用(例如方法區中的常量或類靜態屬性(在1.8中,永久代不存在,取而代之的是元空間,不與堆相連的一段內存))。

在可達性分析期間,分析工作必須在一個能確保一致性的快照中進行——一致性是指在真個分析期間整個執行系統看起來就像被凍結在某個時間點上,不能再出現對象引用關係發生變化的情況。所以這點要求導致GC進行時必須停頓所有的Java執行線程。

插入一個概念“準確式GC”:虛擬機知道內存中某個位置的數據具體是什麼類型!由一組稱爲OopMap的數據計算出來。

在OopMap的協助下,枚舉根節點可以很快完成。

安全點:能讓Java工作線程停頓下來,可以進行GC的位置。一般在指令序列複用的地方,如方法調用、循環跳轉、異常跳轉等,在這些功能的指令下才會產生安全點(Safepoint)。

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

 **************************************************************分割線****************************************************************************************

垃圾收集器

內存回收是如何進行的,是由虛擬機所採用的GC收集器決定的,而通常虛擬機中往往不止有一種GC收集器。所以接下來還是要了解一下垃圾收集器的。

 前面的收集算法是內存回收的方法論,垃圾收集器就是內存回收的實現。

Java虛擬機規範沒有對垃圾收集器做任何要求,由不同廠商去做實現。

Serial收集器:該收集器屬於單線程的收集器,不僅意味着它只會使用一個CPU或者一條收集線程去完成垃圾收集工作,更重要的是它在進行垃圾收集時,必須暫停其他所有工作線程,知道它收集結束。優點:簡單高效,單CPU的環境來說,Serial收集器由於沒有線程交互的開銷,分配給虛擬機管理的內存一般來說不會很大,專心做垃圾收集自然獲得最高的單線程收集效率。兩百兆內新生代的收集,停頓時間再百毫秒之內。所以說,收集不頻繁的情況下,是可以接受的。(JVM Client模式下,默認的新生代收集器)

ParNew收集器:該收集器其實就是Serial收集器的多線程版本(線程數默認爲CPU的數量,可以通過參數-XX:ParallelGCThreads來限制)。除了使用多條線程進行垃圾收集之外,其餘行爲包括所有控制參數、收集算法、Stop The World、對象分配規則、回收策略等都和Serial收集器完全一樣。(需要注意的是,只在新生代使用),永久代沒有。(許多運行在Server模式下的虛機中首選的新生代收集器),除了性能原因之外,就是目前除了Serial之外只有ParNew能與CMS收集器配合使用。

預備知識:提到垃圾收集器,會有並行和併發兩種。並行是指:多條垃圾收集線程並行工作,但是此時用戶線程仍然處於等待狀態;併發是指:用戶線程和垃圾收集線程同時執行(不一定是並行,有可能是交替進行),用戶線程在運行,而垃圾收集程序運行在另一個CPU上。

Parallel Scavenge收集器:首先,這種收集器也是用於新生代的並行收集器,採用複製算法。是一種“吞吐量優先”收集器,也就是說它的關注點是在“吞吐量”(參數可設置,設置最大停頓時間或者吞吐量大小的設置兩個參數)。另外這種收集器有個好處是:虛擬機會根據當前系統的運行情況收集性能監控信息,動態調用這些參數以提供最合適的停頓時間或者最大吞吐量,這種調節方式成爲GC自適應的調節策略(-XX:+UseAdaptiveSizePolicy)。

補充知識:關於上邊提到“吞吐量”的問題。所謂吞吐量就是CPU用於運行用戶代碼的時間和CPU總耗時的比值,即:吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間)。那麼適合什麼樣的場景呢?高吞度量可以高效地利用CPU時間,儘快完成程序的運算任務,主要適合在後臺運算而不需要太多交互的任務。而停頓時間約旦就越適合需要與用戶交互的程序,良好的響應速度能提升用戶體驗。

Serial Old收集器:該版本是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用“標記-整理”算法。這個收集器的主要意義在於給Client模式下的虛擬機使用。若果在Server模式下,那麼它還有兩大用途:一種是在JDK1.5之前的版本中與Parallel Scavenge收集器搭配使用,另外一種用途是作爲CMS收集器的後備預案,在併發收集器發生Concurrent Mode Failure時使用。

Parallel Old收集器:Parallel Old是Parallel Scavenge收集器的老年代版本,使用多線程和“標記-整理”算法。

CMS收集器:該收集器是一種以獲取最短回收停頓時間爲目標的收集器。目前很大一部分的Java應用集中在互聯網站或者B/S系統的服務器上,這類應用尤其重視服務器的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS基於“標記-清除”算法實現。該收集器的收集過程分爲四步:

  • 初始標記(CMS initial mark),只標記一下GC Roots能直接關聯到的對象,速度快。
  • 併發標記(CMS concurrent mark),進行GC Roots Tracing的過程
  • 重新標記(CMS remark),爲了修正併發標記期間因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄。這段時間會比初始標記時間稍長,遠比並發標記時間短。
  • 併發清除(CMS concurrent sweep )

這四個步驟中初始標記和重新標記過程是需要Stop The World的。而併發標記和併發清除耗時較長,但是可以與用戶線程一起進行。

CMS缺點:(1)對CPU資源非常敏感(因爲併發標記和清除都需要佔用CPU);(2)無法處理浮動垃圾(一次標記之後到下次標記之前,用戶線程產生的垃圾),可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生;(3)由“標記-清除”算法引起的,容易產生比較多的空間碎片。可能有人會問爲什不使用“標記-整理”算法呢?整理是需要時間的呀!

G1收集器:當今收集器技術發展的前沿成果之一。面向服務端應用的垃圾收集器。G1具備的特徵:

  1. 併發與並行,充分利用多CPU(CPU或CPU核)停頓時間縮短,甚至可以 將收集和用戶線程併發執行
  2. 分代收集,分代概念在G1中保留但是G1可以不與其他收集器配合而且對熬過多次GC的舊對象有更好的回收效果
  3. 空間整合,整體基於“標記-整理”,局部採用“複製算法,所以不會產生空間碎片,分配大對象時,不會因爲無法找到連續內存空前而提前觸發下一次GC;
  4. 可預測的停頓,同CMS一樣關注降低停頓時間,而且還能建立可預測的停頓時間模型(將堆劃分成多個Region進行監控),能讓使用者明確指定一個長度爲M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。接近實時Java的垃圾收集器特徵了。

G1收集器運作大致分爲幾個步驟(不考慮一些數據維護工作):

  1. 初始標記
  2. 併發標記
  3. 最終標記
  4. 篩選回收

關於G1的應用場景,畢竟G1追求低停頓,所以如果應用追求低停頓的話,可以嘗試G1。但是如果追求高吞吐就沒有什麼優勢了。

關於垃圾收集器的總結(有線相連,可以搭配使用):

垃圾收集器

收集器名稱 工作方式 關注點 收集算法 適用內存區域
Serial 單線程 短停頓時間 複製 新生代
ParNew 並行 短停頓時間 複製 新生代
Parallel Scavenge 並行 高吞吐量 複製 新生代
CMS 併發 短停頓時間 標記-清除 老年代
Serial Old 單線程 短停頓時間 標記-整理 老年代
Parallel Old 並行 吞吐量 標記-整理 老年代
G1 併發 短停頓時間 整體“標記-整理”,局部複製 新生代和老年代

 

**************************************************************分割線****************************************************************************************

內存分配與回收策略

Java技術體系所提倡的自動內存管理最終可以歸結爲自動化解決兩個問題:給對象分配內存以及回收分配給對象的內存。而前邊的大篇幅內容講的都是關於回收的內容。那麼下邊還是需要了解一下分配內存的事情。

對象的內存分配,大方向講,就是在堆上分配(但也可能經過JIT變異後被拆散爲標量類型並間接地棧上分配),對象主要分配在新生代的Eden上,如果啓動了而本地線程分配緩衝,將按線程優先在TLAB上分配。少數情況下可以能直接分配在老年代(與分配策略有關,後文會提到),分配規則並不是固定不變的,細節決定於當前使用的垃圾收集器組合以及虛擬機中和內存相關的參數設置。幾種內存分配策略如下:

1.對象優先在Eden分配

大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。

補充知識:

GC類型(按照範圍) 收集範圍 觸發條件
Minor GC 新生代 Eden區滿
Major GC 老年代 一般由Minjor GC觸發
Full GC 整個堆(新生代和永久代) (1)調用System.gc,系統建議執行Full GC,但是不必然執行;
(2)老年代空間不足;
(3)方法區空間不足;
(4)通過Minor GC後進入老年代的對象大小大於老年代的可用內存;
(5)永久代滿時,並且導致Class、Method元信息的卸載(只針對還存在永久代的虛機)
備註:關於Major GC和Full GC沒有什麼能說的很明白的資料,不做詳細瞭解先

 2.大對象直接進入老年代

所謂的大對象是指:需要大量連續內存空間的Java對象。最典型的對象就是很長的字符串以及數組。經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。虛擬機提供-XX:PretenureSizeThreshold參數設置。

3.長期存活的對象將直接進入老年代

虛擬機給對象定義了一個對象年齡計數器。對於產生於Eden區的對象,經過一次Minor GC並且移動到了To Survivor Space,那麼年齡就增1。虛擬機提供參數-XX:MaxTenuringThreshold(默認15)。

但是不一定到這個閾值。還有一種情況:如果在From Survivor Space空間中相同年齡的對象大小之和大於這個空間的一半,那麼年齡在這個值以及之上的對象都會被移動到老年代。

4.空間分配擔保

爲了說明爲什麼需要空間擔保。來描述一個場景:“在Minor GC過程中,出現了極端的情況新生代中的所有對象都存活下來的,所以在進行復制的時候,就需要有一部分對象進入到老年代了,但是老年代可能無法容納下這些對象。”所以老年代的空間就需要有一個保障。

在JDK 6 update 24之前的處理方式

(1)檢查老年代最大可用的連續空間是否大於新生所有對象總空間,如果成立,直接到(4);

(2)如果不成立,虛擬機查看HandlePromotionFailure設置值是否允許擔保失敗,如果允許進入(3),否則進行(5)

(3)檢查老年代最大可用連續空間值是哦福大於歷次晉升到老年代對象的平均大小,如果大於,則進行(4),否則(5)

(4)Minor GC

(5)Full GC

之後的處理方式(福利之音):

只判斷老年代連續空間大於新生代對象總大小或者歷次晉升的平均大小就會進行Minor GC,否則就Full GC。

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