jvm垃圾回收

回答如下三個問題,即可掌握java虛擬機垃圾回收原理。

哪些內存可以回收?

         Java內存中的程序計數器、虛擬機棧、本地方法棧等區域隨線程的產生而生,隨線程的滅亡而消息。虛擬機中的棧幀隨方法的進入和退出而有條不紊的執行入棧和出棧操作,每一個棧幀需要分配多少內存,在類結構明確的情況下就已經確定了。因此這幾個區域的內存分配和回收都具有明確性,不需要考慮回收問題,因爲方法和線程結束,內存自然就回收了。Java堆和方法區就不同了,因爲只要在程序運行期才能知道要創建哪些對象,在棧中的引用類型指向這些對象的內存起始地址,當棧中引用類型隨線程或方法結束而被回收後,這些對象就沒有被引用時,我們就需要回收這些對象,以便其它對象需要分配相應的內存,這部分的內存分配和回收都是動態的。

正如上面講,jvm垃圾收集器(garbage collection,gc)就是回收這些不存在任何引用的對象,即被稱爲已“死亡”的對象。那麼,如何判斷對象是“死亡”還是“存活”,主流的jvm採用可達性分析算法來判斷對象是否存活。可達性分析算法就是:通過gc roots(垃圾回收根節點)向下尋找被其引用的對象,經歷過的路徑,稱爲引用鏈,當對象與gc roots不存在任何引用鏈時,即對象到gcroots不可達,說明這個對象已“死亡”,可以被回收。其中,哪些是gc roost呢?gc roots主要分爲以下幾類:

         虛擬機棧中的棧幀中的引用變量引用的對象;

         方法區中的靜態變量引用的對象;

         方法區中常量引用的對象;

         本地方法棧中引用的對象;

可以看出,判斷對象是否可回收與對象引用有關,在jdk1.2之後,java對引用進行了擴充,包括強引用、軟引用、弱引用和虛引用。

強引用,類似object o=new object(),只要引用存在,那麼對象就不會被回收;

軟引用,描述一些還有用但並非必要的對象,使用softreference修飾,當內存發生溢出時,這些軟引用對象就會被回收;

弱引用,比軟引用更弱的引用,描述非必要對象,使用weakreference修飾,弱引用對象只能存活到下一次垃圾收回之間;

虛引用,它是最弱的引用的關係,爲對象這是虛引用的目的,是爲了該對象被回收時收到一個系統通知,在jdk1.2之後,使用phantomreference類來使用虛引用。

         判斷對象是“存活”還是“死亡”,可達性分析還是最終的過程,gc線程執行可達性分析後,對於那些與gc roots沒有任何引用鏈的對象,至少要經過兩次標記才能確定是否要回收。這些對象經過一次標記和過濾,過濾的條件是那些對象實現了finalize()方法,若對象沒有實現或者finalize方法已經被虛擬機執行過了,那麼這些對象就會被直接回收,對於那些實現finalize方法還沒有執行的對象,會被放入一個f-queue隊列中,虛擬機會啓動一個低優先級的線程去逐個執行隊列中的對象的finalize方法,這裏的執行是觸發這個方法,但並不是等待這個方法結束,否則若方式執行緩慢、或出現死循環,那麼隊列中的其它對象就會處在永久等待狀態。Finalize方法是對象逃出被回收的最後唯一機會,在finalize方法中若對象被其它對象引用,那麼對象就會不能被回收,這時對象會被第二次標誌。舉例說明上面的過程:

publicclassFinalizeEscapeGC {   

    publicstatic FinalizeEscapeGC SAVE_HOOK = null;

    publicvoid isAlive(){

         System.out.println("yes,i am still alive..");

    }

    @Override

    protectedvoid finalize() throws Throwable {

         // TODO Auto-generated method stub

         super.finalize();

         System.out.println("finalize method executed!");

         FinalizeEscapeGC.SAVE_HOOK = this;

    }

    publicstaticvoid main(String[] args)throws Throwable{

        SAVE_HOOK = newFinalizeEscapeGC();

         SAVE_HOOK= null;

         System.gc();

         Thread.sleep(500);

         if(SAVE_HOOK!=null){

             SAVE_HOOK.isAlive();

         }else {

             System.out.println("no i am dead..");

         }

         SAVE_HOOK = null;

         System.gc();

         Thread.sleep(500);

         if(SAVE_HOOK!=null){

             SAVE_HOOK.isAlive();

         }else {

             System.out.println("no i am dead..");

         }

    }

}

執行main方法,控制檯提示信息:

finalize method executed!

yes,i am still alive..

noi am dead..

結果正如上文所講,但是需要說明的是,不建議使用finalize方法,它並不像是c++中的析構函數,它的執行並不明確(上面提到,jvm不會等待執行結果),無法保證各個對象執行的順序,因此不適合在此方法內做一些“關閉外部資源”的之類工作。

         上面講的是java堆中的對象回收,那麼在方法區是怎麼執行gc呢?在方法區中回收的是廢棄的常量和無用的類,常量的回收與堆中的對象回收類似,當沒有任務對象引用時,gc就可以回收這個常量。類的回收比較複雜,因爲判斷類無用條件比較苛刻,類需要滿一下三點才能算是無用的類,可被回收:

類的實例對象已回收;

加載該類的classloader已經被回收;

該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法;

滿足上面3個要求,纔可以對類回收。在大量使用反射、動態代理框架、動態生成jsp中,類的及時回收是很有必要的,無用類及時回收,才能保證永久區不會溢出。

 

什麼時候回收?

         Gc什麼時候執行內存回收呢?gc在執行時要保證所有的其它的運行線程停止的時候,才能準確的執行內存回收,爲什麼要停止其它運行線程呢?線程停止才能保證對象引用關係不再發生變化,才能保證準確的執行gc,因爲目前主流的java虛擬機都是採用準確式gc。舉個例子:

publicclassJavaTest {

    publicstaticclassDemoObject {

       String val1;

    }

 

    /**

    * @param args

    */

    publicstaticvoidmain(String[] args) throws Exception {

        [1]DemoObject demoObject = new DemoObject();

        [2]//demoObject上掛一個字符串對象

        [3]demoObject.val1 = "this is a string object";

        [4]Thread.sleep(1000000);

    }

}

假如,gc線程與代碼線程並行執行,gc線程通過掃描線程中的棧來獲取被引用的對象,當代碼線程執行到[1]時,gc線程發現demoOject對象,然後代碼線程繼續執行到[3],這時gc線程就會漏掃描對象“this is astring object”。另外,再往下一點,cpu執行運算的數據,需要將數據從內存中載入寄存器,運算完再從寄存器存入內存。對象的地址也要經過這個過程。將入一個java線程分配了一個對象A,該對象的地址存在某個寄存器中,該線程的cpu時間片到期被切換出去,這個時候gc線程開始掃描存活對象,發現沒有到地址還在寄存器的這個對象的路徑,那麼這個對象就被當成垃圾回收,顯然這是不合理的…,因此,gc執行時會出現stw(stop the world),即所有的線程都會終止,已保證對象引用關係處在一個被凍住的時間點上,不可以出現分析時對象引用關係還在不斷變化的情況,否則分析結果準確性就無法得到保證。那麼如何保證gc執行時代碼線程停止呢?jvm採用的方式是代碼線程主動中斷,那麼是如何主動中斷呢?大家知道線程在運行時,不能夠隨意中斷,否則會造成程序崩潰,下面會提到一個“安全點”的概念,gc執行要中斷代碼線程,那麼執行時間就不能太長,否則就會造成項目出現卡頓的現象,影響使用效果。由上文可知,作爲gc roots節點包括4種類型的應用對象,在現在的應用中往往方法區就能到達好幾百兆,gc線程每次執行時都進行一次gc roots節點掃描,那麼這個過程就會很耗時,不合理。因此,java採用的方式,在類加載完成時,Hotspot就把對象內什麼偏移量上是什麼類型的數據計算出來,在jit編譯過程中,會在特定的位置記錄下內存和寄存器中哪些是引用類型,並將這些數據保存在Oopmap數據結構中,在gc線程在掃描時就可以直接從Oopmap中獲取引用對象,極大提高了效率。上面提到的特定的位置就是“安全點”,當代碼線程運行到安全點時就會主動中斷線程,執行gc。安全點的選擇不能太少以至於讓gc等待時間太長,也不能太多增加運行時的負荷。因此,在實際中建議不要顯示執行system.gc()操作。另外,對於安全點的延伸是安全區域,爲什麼需要安全區域,是因爲若線程處於掛起狀態,不執行時,就無法進入安全點,因此,安全區域就是在一段代碼片段中,引用關係不會發生變化,在這個區域任意地方開始gc都是安全的。

如何回收?

查看具體的jvm參數,垃圾收集器使用的是哪個?

掌握Gc回收垃圾原理需要理解Java虛擬機收集垃圾採用什麼方法?具體使用什麼垃圾收集器?jvm垃圾回收採用的算法包括以下幾種類型:

標記-清除算法

         標記-清除算法就是gc通過可達性分析後,標記需要回收的對象,然後清除這些對象。這種算法的缺點是:一個是效率不高,標記清除效率很低;另一個是空間問題,垃圾回收後,導致內存出現大量不連續碎片,當需要分配一個較大對象時就會可能無法找到足夠的空間,而導致提前執行另一次垃圾收集動作。

複製算法

         複製算法是將內存分爲兩塊區域,每次只使用其中的一塊,gc只對其中一塊區域執行垃圾回收,然後將存活的對象,複製到另一塊區域中。這種算法解決了標記-清除算法帶來的效率問題,但是一下子就把可用 的內存空間縮減到一半,使其另一半區域無法得到應用,造成空間利用率很低。

標記-整理算法

         標記-整理算法是在標記-清除算法基礎上,將存活的對象移動到一端,然後直接清理掉端邊界以外的內存,這種算法不會造成大量不連續碎片空間的問題。

分代收集算法

         就目前來說,主流的虛擬機根據對象存活的週期,將java堆劃分爲新生代和老年代,將方法區劃分爲永久代。新生代又劃分爲eden區和兩個survivor區,eden區與survivor區的默認比例是8:1。主流的虛擬機都採用分代收集算法,根據ibm研究,大部分對象都是“早生夕滅”,因此,在新生代化爲eden區、survivor1區和survivor2區,新生成的對象在eden區,每次只使用一個survivor區。新生代執行垃圾回收時將eden區和其中一個survivor區中存活的對象全部複製到另一個survivor區中,並清理掉eden區和剛纔survivor區中使用的空間。可知,分代收集算法只能算是java虛擬機垃圾收集的總體方法,具體每代的收集算法也不相同,但是基本是上面講的三個方法,比如此段講的新生代使用的算法就是複製算法的原理。

         總之,上面講的收集算法只是內存回收的方法論,jvm中內存回收具體實現是垃圾收集器,hotspot虛擬機針對不同版本的虛擬機、不同的代,包含多種垃圾收集器:

Serial收集器

         Serial收集器是最基本、歷史最悠久的收集器,它是一個單線程收集器,線程執行時會中斷其它所有的工作線程,目前這款收集器是虛擬機運行在client模式下的默認收集器,即c/s項目中。

Parnew收集器

         Parnew收集器是serial收集器的多線程版本,在jdk1.5版本中新增加的cms收集器用於老年代內存回收,除了serial收集器,只有parnew收集器能與cms收集器配合工作,用於新生代內存回收。

Parallel scavenge收集器

         Parallel scavenge收集器是用於新生代,採用複製算法,以吞吐量爲目標的收集器。所謂吞吐量就是cpu運行工作代碼的時間與總耗時比值,即吞吐量=cpu工作代碼時間/(cpu工作代碼時間+垃圾收集時間)。在jdk1.7和1.8中默認新生代默認使用此收集器。

Serial old收集器

         Serial old是serial收集器用於老年代,它同樣是單線程收集器,使用標記-整理算法。Serial old收集器與ps marksweep收集器實現非常接近,很多時候它倆可以相互替換,由於cms收集器只能和parnew適配器配合使用,因此若老年代使用serial old(ps marksweep),那麼新生代一般使用parallel scavenge收集器。

Parallel old收集器

         Parallel old是parallel scavenge用於老年代的收集器,使用多線程和“標記-整理”算法。

Cms收集器

         Cms是一個以縮短垃圾收集停頓時間爲目標的收集器,採用“標記-清除”算法,用於老年代。Cms收集器執行垃圾回收需要4個過程:初始標記、併發標記、重新標記、併發清除。初始標記和重新標記都需要中斷其它工作線程,初始標記就是標記與gc roots相關聯的對象,並行標記是gcroots tracing過程,重新標記是修正併發標記過程中產生的新的引用關係。併發清除就是清空非關聯對象,併發標記和併發清除顧名思義同工作線程併發執行。

         Cms是一款優秀的收集器,非常適合應用在b/s系統的服務端,因爲這類應用尤其注重服務端的響應速度,希望系統停頓時間最短。另外,cms採用“標記-清除”算法,這種算法會造成大量不連續的碎片,空間碎片過多,會對大對象的分配帶來很大的麻煩。因此,cms收集器提供了一個參數-XX:+UseCMSCompactFullCollection開關參數(默認開啓),表示進行fullgc時,cms收集器是否執行內存碎片整理。在碎片整理期間,工作線程是中斷的,爲了減少停頓時間,cms收集器還提供了一個參數-XX:CMSFullGCsBeforeCompaction,表示執行多少次不帶壓縮的fullgc,跟着來一次壓縮的(默認爲0,每次進入fullgc都進行碎片整理)。

G1收集器

         G1收集器是目前最技術最前沿的垃圾收集器,它的主要特點是:高併發和並行,利用多cpu來縮短停頓時間。分代回收,其它的收集器都是針對整個新生代或老年代,當使用g1收集器時,內存佈局與其它收集器有很大不同,雖然還存在新生代和老年代的概念,但是新生代和老年代不再物理隔離,它們都是一部分region的集合,g1跟蹤各個region裏面的垃圾堆積的價值大小(所獲的空間大小以及回收所需要的時間經驗值),在後臺維護一個優先列表,每次在允許的收集時間內,優先收集價值最大的region,從而獲得更高的收集效率。空間整理,g1收集器從整體來看是採用“標記-整理”算法,從局部看是採用“複製”算法,即將對象從一個region複製到另一個region中,這就意味着g1收集器不存在碎片整理的問題,不需要進行fullgc,減少停頓。可預測性內存回收,g1收集器建立可預測的停頓時間,能讓使用者指定在一個時間m毫秒內,用於垃圾回收的時間不超過n毫秒。目前g1收集器在jdk9中已是默認的垃圾收集器

         總之,本文講了虛擬機內存分配和回收的原理以及各個不同垃圾收集器的原理。理解垃圾回收的根本目的是知道如何提高虛擬機的性能,因爲垃圾回收造成的停頓往往成爲高併發高性能的瓶頸。其實,根據本文可得知,提高虛擬機性能包括兩個方面:降低停頓時間和提高吞吐量。像parallel scavenge收集器用於新生代,以提高吞吐量爲目標,而cms、g1等收集器主要是降低停頓時間爲目標。

         如何理解吞吐量與停頓時間呢?個人認爲有個比喻非常形象,就是車輛通過一段路程與等待紅綠燈的過程。車輛通過路程的吞吐量=車輛通過路程時間/車輛通過路徑時間+等待紅綠燈時間,假如車輛通過這段路程需要100分鐘,等待紅綠燈時間是1分鐘,那麼吞吐量就是99%,當路上的車輛很多,車輛可能要等待好幾個紅綠燈的變化才能通過,那麼等待時間就會增加,意味着吞吐量降低,那麼如何提高吞吐量呢?增加道路寬度,只需要一個紅綠燈的變化就可通過,像parallel scavenge收集器就是利用這方式,通過多線程、多cpu達到高吞吐量,從而通過的車輛更多。但是,車輛等待時間即垃圾回收時間沒有變短,cms、g1等收集器是通過降低垃圾收集時間(紅綠燈等待時間)來達到通過更多的車輛,並且車輛駕駛人員的體會也會更好,因爲提車等待的時間更短,大部分時間車輛都是在行駛中,沒有人願意停車等待,虛擬機也是同樣的道理,執行代碼的時間越長,停頓越短帶來的用戶體驗越好。當我們請求應用時,沒有人願意看到應用出現卡頓的現象,系統越流暢效果越好。可知,吞吐量與低停頓是兩個不同的目標,關注吞吐量一般是一些用於處理大量數據的應用,吞吐量越大,數據的處理越快;低停頓應用更多的是交互請求,停頓時間越低,請求交互實時效果越好。

         使用jconsole工具,查看jdk1.7(或者jdk1.8)版本中默認垃圾收集器使用狀況如下:

可知,新生代使用了ps scavenge收集器,老年代使用了psmarksweep收集器。


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