jvm垃圾回收三部曲

概述

早在半個世紀之前,第一個使用了內存動態分配和垃圾收集技術的語言Lisp就已經誕生了,從那時,人們就在思考關於gc須要完成的三件事請:java

  1. 哪些內存須要回收
  2. 何時回收
  3. 如何回收

直到今天已經有愈來愈多的語言開始內置內存動態分配和垃圾收集技術。通過長時間的發展,這些技術已經至關成熟,一切都看起來已經進入「自動化」,那爲何咱們還要去學習gc和內存分配呢?當咱們須要去拍查內存溢出和內存泄露時,當垃圾收集成爲系統達到高併發量的瓶頸時,咱們就須要揭開這些「自動化」技術的內幕,去實施必要的監控和調節。ios


上篇講到jvm運行時內存區域主要包括這麼幾部分區域:算法

  1. 程序計數器
  2. 虛擬機棧
  3. 本地方法棧
  4. 堆內存
  5. 方法區

其中程序計數器,虛擬機棧和本地方法棧都會隨着線程而生,隨着線程而滅,正常狀況下不會出現內存溢出和泄露的問題,無需對這塊區域多作關心。後文討論的內存區域都是堆內存或方法區。緩存

對象已死嗎

在堆裏幾乎放着java世界裏全部的對象實例,垃圾收集器對齊進行回收的第一件事就是要判斷須要回收哪些對象,哪些對象已死(也就是哪些對象已經不可能用到了,但仍是存在於堆內存當中)。服務器


  • 引用計數算法
    引用計數算法定義很簡單,給對象添加一個引用計數器,每當有一個地方引用它時,就進行+1,每當有一個地方失效時,就進行-1。任什麼時候刻當計數器爲0時的對象都是不可能再被使用的。這種算法實現簡單,斷定效率也很高,是一個很不錯的算法,也有一些很是著名的案例,例如微軟公司的COM技術,Python語言和在遊戲腳本領域應用很是普遍的Squirrel都使用了引用計數算法來管理,但至少目前爲主流的java商用虛擬機沒有選用其來管理內存,其根本緣由是它很難解決對象之間互相引用的問題,看下面一個小例子:
/**
     * vmargs:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xms20m -Xmx20m
     */
    public class Main {
    
        static class ReferenceCount {
            private Object object;
            //大對象,用來感知gc是否被回收
            private byte[] bigObject = new byte[1024 * 1024 * 2];
        }
    
        public static void main(String[] args) {
    
            ReferenceCount referenceCountA = new ReferenceCount();//對象A被引用1次
            ReferenceCount referenceCountB = new ReferenceCount();//對象B被引用1次
    
            referenceCountA.object=referenceCountB;//對象B被引用兩次
            referenceCountB.object=referenceCountA;//對象A被引用兩次
            //引用失效-1,對象A和對象B均被引用1次
            referenceCountA=null;
            referenceCountB=null;
            //執行fullgc,查看堆內存使用量來判斷是否回收
            System.gc();
    
        }
    }

其gc日誌爲:圖片描述多線程

或許你們還看不懂gc日誌,但沒關心,咱們只須要關注紅色區域,進行System.gc後堆內存區域只用了463k,很明顯對象AB已經被回收了。併發


  • 可達性分析算法
    在主流的商用語言中(java、C#甚至Lisp當中)的主流實現中,都是採用可達性分析算法來斷定對象是否存活的。這個算法的基本思路就是經過一系列的「GC Roots」的對象做爲起點,從這些起點開始向下搜索所走過的路徑稱爲引用鏈,當一個對象到「GC Roots」沒有任何引用鏈相連,就證實這個對象是不可用的。以下圖所示:
    圖片描述

    在java語言中,能夠做爲GC Roots的對象包括下面4種:
    1.虛擬機棧(棧幀中的本地變量表)中引用的對象
    2.方法區中類靜態屬性引用的對象
    3.方法區中常量引用的對象
    4.本地方法中JNI(jdk裏的native方法)引用的對象oracle

  • java的四種引用

    不管經過哪一種算法去判斷對象是否存活都與引用相關。在java中,分爲強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,其引用關係依次下降。jvm

    強引用:java中最多見的引用,引用計數算法的ReferenceCount referenceCountA = new ReferenceCount()就是典型的強引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。
    軟引用:用來描述一些還有用但並非必須存在的對象(能夠與緩存的功能做類比),對於軟銀用的對象,在系統即將發生內存溢出異常以前,將會把這些對象列入到第二次回收範圍中進行回收,若是回收以後仍是沒有足夠的內存,將拋出內存溢出異常。jdk提供了SoftRerence來實現軟引用
    弱引用:它的做用和軟引用相似,區別在於引用關係更弱。只能存活到下次gc發生以前。當gc時,不管當前內存是否足夠都會回收掉弱引用的關聯的對象。jdk提供了WeakReference引用。
    虛引用:它是最弱的一種引用關係,爲一個對象設置虛引用關聯的惟一目的就是這個對象被收集器回收時收到一個系統通知。jdk提供了PhantomReference來實現虛引用。高併發

  • 回收方法區
    在hotspot中,你們更緣由將其稱爲永久代(jdk1.8廢除永久代,metaspace元空間出現,咱不討論)。永久代主要回收兩部份內容:廢棄常量和無用的類。以一個字符串「abc」已經進入了常量池中,可是系統中沒有一個String對象指向abc的,也沒有其餘地方引用了這個字面量,當發生垃圾回收時,而且必要的話,這個常量就會被系統清理出常量池。常量池中的其餘類(接口)、方法、字段的符號引用也與此相似。斷定一個常量是否存活比較簡單,而要斷定一個類是不是無用的類的條件就要苛刻不少。必須得知足如下三個條件:1.該類的全部實例已經被回收2.加載該類的ClassLoader已經被回收3.該類對應的Class對象沒有在其餘任何地方被引用,沒法再任何地方經過反射訪問該類的方法。只有知足以上三個條件,這個類纔有可能被回收。是否對類進行回收,hotspot虛擬機提供了-Xnoclassgc參數進行控制。

垃圾收集算法

  • 標記-清除算法

    標記清除算法(Mark-sweep)是最基礎的算法,其過程如同名字同樣分爲標記和清除兩個階段:首先標記出全部須要回收的對象,標記完成後統一回收全部被標記的對象。之因此說它是最基礎的算法是由於後續的收集算法都是在它的基礎上對其不足進行改進而獲得的(但同時也會暴露其餘問題,沒有最合適,只有更合適)。它的不足主要有兩個,一個是效率問題,標記和清除的效率都不高。另外一個是空間問題,會產生大量的內存碎片,碎片太多可能會致使之後分配大對象沒法找到足夠的連續內存而不得不提早觸發另外一次垃圾收集動做。執行過程以下圖所示:
    圖片描述

  • 複製算法
    爲了解決標記清除算法的效率問題,複製算法出現了。他將可用內存按大小分爲大小相等的兩份,每次只使用其中的一份,當其中一分內存使用完了,就將還存活着的對象複製到另一塊上面,而後再將已使用的那分內存空間一次清理掉。這種算法只對整個半區進行內存回收,內存分配時也再也不須要考慮內存碎片的問題,只要每次移動堆頂指針,按順序分配內存便可,實現簡單,運行高效。缺點是將可用內存縮小了一半,代價過高。執行過程以下所示:
    圖片描述

    目前的商業虛擬機都採用複製算法來回收新生代。新生代中的對象98%都是朝生夕死的,因此咱們不須要嚴格的按照1:1的比例來劃份內存空間。目前的商用虛擬機都將新生代內存劃分爲Eden和兩塊Survivor空間,其比例默認爲8:1:1,每次只使用Eden和其中一塊Survivor空間。當回收內存時,會將Eden和已經使用的Survivor空間中存活的對象一次性的複製到另一塊Survivor空間,複製完成而後清理Eden區域和剛纔用過的Survivor空間。每次只有10%的內存不可用算是對複製算法的一個優化,是能夠被接受的。另外前面有提到,通常場景98%的對象都是朝生夕死,可是咱們沒有辦法保證Eden和其中一塊使用的Survivor空間存活的對象必定比另一塊未使用的Survivor空間小,若是未使用的Survivor空間不夠用時,須要依賴老年代進行分配擔保(Handle Promotion)。

  • 標記-整理算法
    複製收集算法尤爲適合新生代,由於新生代對象通常情境下都是朝生夕死的。可是若是在對象存活率較高甚至極端狀況下達到100%的存活率,就要進行較多的複製操做,效率將會變得極其低下,又由於須要額外的空間進行擔保,因此老年代不會選用複製算法(老年代的對象通常都活的比較久)。
    根據老年代對象的特色,就提出了標記整理算法。第一步還是標記,但後續步驟再也不是清除而是整理,讓全部存活的對象都向一端移動,而後直接清理掉端邊界之外的內存。執行過程以下圖所示:
    圖片描述
  • 分代收集算法
    前面有提到新生代和老年代,實際上是根據對象存活週期的不一樣來劃分的。新生代中,每次垃圾收集的對象都會有大批死去,只有少許存活,那就用複製算法,只須要少許存活對象的複製成本就能夠完成收集。而老年代對象存活率高,又沒有額外空間對它進行分配擔保,就必須使用標記整理和表表情清除算法來進行回收,這就是所謂的分代收集算法。

枚舉根節點

從可達性分析中從GC Roots節點找引用鏈這個操做爲例,能夠做爲GC Roots的節點主要爲常量、靜態、和棧幀中的本地變量表,如今不少程序僅僅方法區就有數百兆,若是逐個檢查裏面的引用,那麼必然會消耗不少的時間。另外,可達性分析對執行時間的敏感還體如今GC停頓上,由於這項分析工做必須在一個確保一致性的快照中進行。這個一致性的意思是整個分析期間這個程序看起來就像被凍結在某個時間點同樣,不能夠出現分析過程當中對象引用關係還在不斷變化的狀況,這點若是不知足的話結果準確性就無從談起了。這點是致使gc進行時程序必須停頓全部java工做線程的一個重要緣由,Sun公司將其稱爲STW(Stop the World)。聽起來很酷,但這頗有多是形成接口超時等其餘問題的罪魁禍首。

垃圾收集器

若是說收集算法是垃圾回收的方法論,那麼垃圾收集器就是垃圾回收的具體實現。虛擬機規範並無規定虛擬機如何去實現,所以不一樣廠商不一樣版本的虛擬機提供的垃圾收集器均可能會有很大差異。本文只討論JDK1.7Update14以後的hotspot虛擬機,以下圖所示:
圖片描述

Young generation表明年輕代,Tenured generation表明老年代。總共有7款收集器,他們都負責回收本身所在的區域,收集器之間的實線表明着這兩款收集器能夠合做,一塊兒收集整個堆內存。其中G1是JDK1.7以後才正式被oracle定義爲商用虛擬機,G1回收整個新生代和部分老年代比較特殊,本文暫不討論。


在講解收集器以前,先給你們介紹下32位和64位的jdk。32位系統只能夠裝32位jdk,64位系統二者均可以裝,但推薦安裝64位jdk。在32位的jdk下,虛擬機的模式是可選的,默認爲client模式,能夠經過修改配置文件爲server模式,但在64位的jdk下,虛擬機只能爲server模式。目前大部分服務器甚至不少我的電腦都是64bit,也就是默認server模式。


在接下來介紹的六款收集器中,只有serial和serial old是單線程回收內存的收集器。其餘都是多條線程回收內存的,有的是並行,有的是併發,在介紹這幾款收集器以前,咱們先講解下並行和併發在垃圾回收這個上衣文語境中所表明的含義:

1.並行:多條垃圾收集線程並行工做,但用戶線程仍在等待狀態
2.併發:用戶線程與垃圾收集線程同時執行(可能會交替執行),用戶程序在運行在一部分cpu上,而垃圾回收運行在另外一部分cpu上。
  1. serial收集器
    serial收集器是一款歷史很悠久的收集器,在jdk1.3以前是新生代的惟一選擇。這是一個單線程的收集器,它只會佔用一個cpu啓動一個線程去回收內存,但它也是會致使stw的。下圖爲serila和serial old收集器工做的示意圖:
    圖片描述
    直到今天,它依然是虛擬機運行在client模式下默認的新生代收集器,在用戶的桌面場景應用中,分配給虛擬機管理的內存通常不會太大,停頓時間徹底能夠控制在一百毫秒之內,只要不是頻繁發生,這點停頓是能夠接受的。
  2. parnew收集器
    parnew收集器就是serial收集器的多線程版本,除了使用多個線程進行垃圾回收以外,其他行爲包括serial收集器全部可用的控制參數、收集算法、stw、對象分配規則、回收策略鬥魚serial如出一轍。在實現上,兩種收集器也共用了不少代碼。parnew和serial Old收集器工做的示意圖:
    圖片描述
    parnew收集器是虛擬機server模式下默認的新生代虛擬機,可是它和serial相比除了是多線程收集外並無其餘的特點,其中一個與性能無關但很重要的緣由,目前除了serial收集器,它是惟一一個能夠cms共同工做的一個收集器。在jdk1.5,hotspot推出了一款劃時代意義的垃圾收集器-----cms收集器,這款收集器是真正意義上的第一款併發收集器,他第一次實現了讓用戶線程和垃圾收集線程基本上同時工做(但stw仍是存在的,稍後會講解cms收集器的內容)。不幸的是,cms做爲老年代的收集器,卻只能和serial和parnew收集器共同工做,parnew收集器也是使用-XX:+UseConcMarkSweepGC參數後的默認新生代收集器。
  3. parallel scavenge收集器
    parallel scavenge收集器也是一個新生代的收集器和parnew大體同樣,但它的關注點與其餘收集器不一樣。其餘收集器都是儘量地縮短垃圾收集時用戶線程的停頓時間,而parallel scavenge收集器的目的是爲了達到一個可控制的吞吐量(Throughput)。吞吐量就是cpu用於運行用戶代碼的時間與cpu工做時間的比值,即吞吐量的計算應該爲:吞吐量=用戶線程的工做時間/(用戶線程的工做時間和垃圾收集的時間),虛擬機總共運行100分鐘,垃圾收集器運行2分鐘,那吞吐量就是98%。停頓時間越短,就越適合須要與用戶交互的系統程序,能夠良好的提高用戶體驗,而吞吐量越高說明cpu的利用效率越高,能夠儘快完成的程序的運算任務,主要適合後臺運算而不須要太多的交互任務。
    parallel scavenge收集器提供了兩個參數用於控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMills和直接設置吞吐量大小的-XX:GCTimeRatio參數。XX:MaxGCPauseMills容許的值是一個大於0的毫秒數,收集器儘量保證內存回收花費的時間不超過此值,但並非將此參數設的越小系統的垃圾收集速度就越快,它是犧牲了新生代空間和吞吐量來換取的:收集500MB的新生代確定比收集1GB的新生代快,但換來的代價是 ygc會更頻繁一些。原來10秒一次ygc,一次停頓100ms,如今5秒一次ygc,一次停頓60ms。停頓時間在降低,但吞吐量也降下來了。GCTimeRatio參數的值應當是一個大於0小於100的整數,也就是垃圾收集時間佔總時間的比率,若是把參數設置爲9,那容許的最大gc時間就佔總時間的10%,計算方法是這樣子的(1/(1+9))。
    parallel scavenge收集器還提供了一個參數-XX:+UserAdaptiveSizePolicy,這是一個開關參數,當這個參數打開後,就不須要手工指定新生代的大小、Eden與Survivor的比例、晉升老年代對象的大小等參數了,虛擬機會根據運行狀況尋找最合適的配比。若是對於收集器運做不太瞭解,能夠只配置-Xmx設置最大堆,配置MaxGCPauseMills設置最大停頓時間或GCTimeRatios來設置吞吐量給虛擬機來創建一個優化目標,具體的細節參數交給parallel scavenge收集器來自動調配。
  4. serial old 收集器
    serial Old是serial收集起的老年代版本,一樣的他也是一個單線程收集器,使用標記整理算法(注意新生代使用的都是複製算法,前面有提到),這個收集器的主要意義也是給client模式下虛擬機來使用。若是在server模式下,它還有兩大用途:一種用途是JDK1.5以及以前的版本中配合parallel scavenge使用的,另外一種用途就是做爲cms收集器的備用方案,在這款收集器發生Concurrent Mode Failure時切換爲Serial old收集器
  5. parallel old收集器
    parallel old收集器是parallel scavenge收集器的老年代版本,使用多線程和標記整理算法,在jdk1.6纔開始提供的。因此在此以前新生代的parallel scavenge一直處於十分尷尬的狀態,若是新生代使用了parallel scavenge收集器,老年代只能與serial old配合(parallel scavenge收集器沒法與cms工做)。因爲serila old收集器在服務器應用性能上的拖累,使用parallel scavenge收集器也未必可以得到吞吐量最大化的效果,其緣由是由於serial old是單線程的沒法充分利用服務器多cpu的處理能力,在老年代很大且硬件比較高級的環境中,這種組合的吞吐量還不必定有parnew+cms的組合給力。直到parallel old收集器出現後,吞吐量優先收集器纔有了比較名副其實的組合,在注重吞吐量和cpu資源很是敏感的狀況下,均可以優先考慮parallel old+parallel scavenge收集器,其工做過程以下所示:
    圖片描述
  6. cms收集器
    cms(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器。目前很大一部分的java應用集中在互聯網站或者B/S的系統服務端上,這類應用很是重視響應速度,但願停頓時間最短,cms收集器就很是符合這類需求。
    cms是一款基於標記清除算法的收集器,在jdk1.5中提出,也是hotspot第一款併發收集器。它的運做過程相對於前面幾款收集器複雜一些,正題分爲4個步驟:

    7. 初始標記(CMS initial mark):初始標記須要stw,它僅僅只是標記下GC Roots能直接關聯到的對象,速度很快。
    
    7. 併發標記(CMS concurrent mark):不須要stw,這個階段是根據初始標記獲得的存活對象進行遞歸標記可達的對象。
    8. 從新標記(CMS remark):併發標記這個階段是併發執行的(用戶線程也在工做),可能發生對象晉升到老年代或者大對象直接分配老年代或者新老年代對象的引用關係被更新等等,對於這些變更的引用關係須要從新標記更正,而且發生stw,會比初始標記時間要長,但遠比並發標記時間短。
    9. 併發清除(CMS concurrent sweep):併發清除就很簡單了,以前已經整理出存活對象,直接清除就是了。

因爲最耗時間的併發標記和併發清除均可以和用戶線程一塊兒工做,因此整體上來講cms收集器的內存回收過程是與用戶線程一塊兒兵法執行的。cms的工做流程以下圖所示:
圖片描述

cms是一款優秀的收集器,併發收集和低停頓都是他的特色,但它有3個明顯的缺點:

  1. cms收集器對cpu資源很是敏感,由於是併發的會去搶奪cpu資源,形成應用程序忽然變慢,總吞吐量下降的狀況。
  2. cms基於標記清除算法實現的,沒法清除浮動垃圾,可能出現Concurrent Mode Failure失敗致使另外一次Full GC的產生。因爲cms的併發清理階段用戶線程還在運行着,必然會有新的垃圾不斷產生,這部分垃圾出如今標記以後,cms沒法在當次收集過程回收它,只能留到下一次回收,所以cms不能像其餘年老代收集器同樣等到年老代幾乎被填滿了再進行收集,須要預留一部分空間提供併發清理時的程序運做使用。在jdk1.5中cms收集器當老年代使用了68%的空間後就會被激活,在jdk1.5以後,已經將閾值提高至92%。若是Cms預留內存不夠用,將會發生Concurrent Mode Failure,這時將會啓動備用方案,臨時啓動serial old來進行老年代的手機,這樣停頓時間就很長了。這個閾值能夠經過參數-XX:CMSInitiatingOccupancyFraction來設置,設置的過高,老年代增加的又比較快,就會致使大量Concurrent Mode failure出現,性能反而下降。
  3. cms是基於一款標記清楚算法實現的收集器,這種算法會致使大量空間碎片產生。這將會給分配大對象帶來麻煩,每每老年代空間還不少,可是沒法找到足夠大的連續空間來分配當前對象,不得不觸發一次Full GC。爲了解決這個問題,CMS收集器提供了一個開關參數:-XX:+UseCMSCompactAtFullCollection(默認開啓),用於cms收集器頂不住要進行fullGC 時開啓內存碎片的合併整理過程,這個過程是沒法併發的,空間碎片問題沒有了,但停頓時間不得不變長。cms還提供了一個參數-XX:CMSFullGCsBeforeCompaction來設置執行多少次不壓縮的Full GC後跟着來一次壓縮整理。

總結

本篇博客主要講解了垃圾回收算法,內存區域的一些細節和收集器大體的工做流程。經過分析比較各個收集器,咱們發現沒有最好的收集器組合,更沒有萬能的收集器組合。咱們只能經過場景分析來定最合適的收集器。

下節預告

1.gc日誌的閱讀2.內存分配和回收策略3.虛擬機提供的性能監控工具

相關文章
相關標籤/搜索