淺析JVM垃圾回收

1.什麼是垃圾回收?

    垃圾回收(Garbage Collection)是Java虛擬機(JVM)垃圾回收器提供的一種用於在空閒時間不定時回收無任何對象引用的對象佔據的內存空間的一種機制。

    注意:垃圾回收回收的是無任何引用的對象佔據的內存空間而不是對象本身。換言之,垃圾回收只會負責釋放那些對象佔有的內存。對象是個抽象的詞,包括引用和其佔據的內存空間。當對象沒有任何引用時其佔據的內存空間隨即被收回備用,此時對象也就被銷燬。但不能說是回收對象,可以理解爲一種文字遊戲。

分析:

    引用:如果Reference類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊內存代表着一個引用。(引用都有哪些?對垃圾回收又有什麼影響?

    垃圾:無任何對象引用的對象(怎麼通過算法找到這些對象呢?)。

    回收:清理“垃圾”佔用的內存空間而非對象本身(怎麼通過算法實現回收呢?)。

    發生地點:一般發生在堆內存中,因爲大部分的對象都儲存在堆內存中(堆內存爲了配合垃圾回收有什麼不同區域劃分,各區域有什麼不同?)。

    發生時間:程序空閒時間不定時回收(回收的執行機制是什麼?是否可以通過顯示調用函數的方式來確定的進行回收過程?

    帶着這些問題我們開始進一步的分析。

2.Java中的對象引用

  (1)強引用(Strong Reference):如“Object obj = new Object()”,這類引用是Java程序中最普遍的。只要強引用還存在,垃圾收集器就永遠不會回收掉被引用的對象。

  (2)軟引用(Soft Reference):它用來描述一些可能還有用,但並非必須的對象。在系統內存不夠用時,這類引用關聯的對象將被垃圾收集器回收。JDK1.2之後提供了SoftReference類來實現軟引用。

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

  (4)虛引用(Phantom Reference):最弱的一種引用關係,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛引用關聯的唯一目的是希望能在這個對象被收集器回收時收到一個系統通知。JDK1.2之後提供了PhantomReference類來實現虛引用。

    區分Java對象和對象引用請參考區分JAVA中的對象和引用

3.判斷對象是否是垃圾的算法。

      Java語言規範沒有明確地說明JVM使用哪種垃圾回收算法,但是任何一種垃圾回收算法一般要做2件基本的事情:(1)找到所有存活對象;(2)回收被無用對象佔用的內存空間,使該空間可被程序再次使用。

3.1引用計數算法(Reference Counting Collector)

    堆中每個對象(不是引用)都有一個引用計數器。當一個對象被創建並初始化賦值後,該變量計數設置爲1。每當有一個地方引用它時,計數器值就加1(a = b, b被引用,則b引用的對象計數+1)。當引用失效時(一個對象的某個引用超過了生命週期(出作用域後)或者被設置爲一個新值時),計數器值就減1。任何引用計數爲0的對象可以被當作垃圾收集。當一個對象被垃圾收集時,它引用的任何對象計數減1。

    優點:引用計數收集器執行簡單,判定效率高,交織在程序運行中。對程序不被長時間打斷的實時環境比較有利(OC的內存管理使用該算法)。

    缺點: 難以檢測出對象之間的循環引用。同時,引用計數器增加了程序執行的開銷。所以Java語言並沒有選擇這種算法進行垃圾回收。

    早期的JVM使用引用計數,現在大多數JVM採用對象引用遍歷(根搜索算法)。

3.2根搜索算法(Tracing Collector)

首先了解一個概念:根集(Root Set)

    所謂根集(Root Set)就是正在執行的Java程序可以訪問的引用變量(注意:不是對象)的集合(包括局部變量、參數、類變量),程序可以使用引用變量訪問對象的屬性和調用對象的方法。

    這種算法的基本思路:

 (1)通過一系列名爲“GC Roots”的對象作爲起始點,尋找對應的引用節點。

(2)找到這些引用節點後,從這些節點開始向下繼續尋找它們的引用節點。

 (3)重複(2)。

 (4)搜索所走過的路徑稱爲引用鏈,當一個對象到GC Roots沒有任何引用鏈相連時,就證明此對象是不可用的。

    Java和C#中都是採用根搜索算法來判定對象是否存活的。

標記可達對象:

    JVM中用到的所有現代GC算法在回收前都會先找出所有仍存活的對象。根搜索算法是從離散數學中的圖論引入的,程序把所有的引用關係看作一張圖。下圖3.0中所展示的JVM中的內存佈局可以用來很好地闡釋這一概念:

圖 3.0 標記(marking)對象

  首先,垃圾回收器將某些特殊的對象定義爲GC根對象。所謂的GC根對象包括:

(1)虛擬機棧中引用的對象(棧幀中的本地變量表);

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

(3)方法區中的類靜態屬性引用的對象;

(4)本地方法棧中JNI(Native方法)的引用對象。

(5)活躍線程。

    接下來,垃圾回收器會對內存中的整個對象圖進行遍歷,它先從GC根對象開始,然後是根對象引用的其它對象,比如實例變量。回收器將訪問到的所有對象都標記爲存活。

    存活對象在上圖中被標記爲藍色。當標記階段完成了之後,所有的存活對象都已經被標記完了。其它的那些(上圖中灰色的那些)也就是GC根對象不可達的對象,也就是說你的應用不會再用到它們了。這些就是垃圾對象,回收器將會在接下來的階段中清除它們。

關於標記階段有幾個關鍵點是值得注意的:

    (1)開始進行標記前,需要先暫停應用線程,否則如果對象圖一直在變化的話是無法真正去遍歷它的。暫停應用線程以便JVM可以盡情地收拾家務的這種情況又被稱之爲安全點(Safe Point),這會觸發一次Stop The World(STW)暫停。觸發安全點的原因有許多,但最常見的應該就是垃圾回收了。

    (2)暫停時間的長短並不取決於堆內對象的多少也不是堆的大小,而是存活對象的多少。因此,調高堆的大小並不會影響到標記階段的時間長短。

    (3)在根搜索算法中,要真正宣告一個對象死亡,至少要經歷兩次標記過程:

      1.如果對象在進行根搜索後發現沒有與GC Roots相連接的引用鏈,那它會被第一次標記並且進行一次篩選。篩選的條件是此對象是否有必要執行 finalize()方法(可看作析構函數,類似於OC中的dealloc,Swift中的deinit)。當對象沒有覆蓋finalize()方法,或finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲沒有必要執行。

      2.如果該對象被判定爲有必要執行finalize()方法,那麼這個對象將會被放置在一個名爲F-Queue隊列中,並在稍後由一條由虛擬機自動建立的、低優先級的Finalizer線程去執行finalize()方法。finalize()方法是對象逃脫死亡命運的最後一次機會(因爲一個對象的finalize()方法最多隻會被系統自動調用一次),稍後GC將對F-Queue中的對象進行第二次小規模的標記,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中讓該對象重新引用鏈上的任何一個對象建立關聯即可。而如果對象這時還沒有關聯到任何鏈上的引用,那它就會被回收掉。

     (4)實際上GC判斷對象是否可達看的是強引用。

    當標記階段完成後,GC開始進入下一階段,刪除不可達對象。

4.回收垃圾對象內存的算法

4.1 Tracing算法(Tracing Collector) 或 標記—清除算法

    標記—清除算法是最基礎的收集算法,爲了解決引用計數法的問題而提出。它使用了根集的概念,它分爲“標記”和“清除”兩個階段:首先標記出所需回收的對象,在標記完成後統一回收掉所有被標記的對象,它的標記過程其實就是前面的根搜索算法中判定垃圾對象的標記過程。

    優點:不需要進行對象的移動,並且僅對不存活的對象進行處理,在存活對象比較多的情況下極爲高效。

      缺點:(1)標記和清除過程的效率都不高。(這種方法需要使用一個空閒列表來記錄所有的空閒區域以及大小。對空閒列表的管理會增加分配對象時的工作量。如圖4.1所示。)。(2)標記清除後會產生大量不連續的內存碎片。雖然空閒區域的大小是足夠的,但卻可能沒有一個單一區域能夠滿足這次分配所需的大小,因此本次分配還是會失敗(在Java中就是一次OutOfMemoryError)不得不觸發另一次垃圾收集動作。如圖4.2所示。

算法示意圖:

圖 4.0  標記—清除算法


圖4.1 標記—清除算法

4.2 Compacting算法(Compacting Collector) 或 標記—整理算法

      該算法標記的過程與標記—清除算法中的標記過程一樣,但對標記後出的垃圾對象的處理情況有所不同,它不是直接對可回收對象進行清理,而是讓所有的對象都向一端移動,然後直接清理掉端邊界以外的內存。在基於Compacting算法的收集器的實現中,一般增加句柄和句柄表。

      優點:(1)經過整理之後,新對象的分配只需要通過指針碰撞便能完成(Pointer Bumping),相當簡單。(2)使用這種方法空閒區域的位置是始終可知的,也不會再有碎片的問題了。

      缺點:GC暫停的時間會增長,因爲你需要將所有的對象都拷貝到一個新的地方,還得更新它們的引用地址。

算法示意圖:

圖4.2 標記—整理算法


圖4.3 標記—整理算法

4.3 Copying算法(Copying Collector)

      該算法的提出是爲了克服句柄的開銷和解決堆碎片的垃圾回收。它將內存按容量分爲大小相等的兩塊,每次只使用其中的一塊(對象面),當這一塊的內存用完了,就將還存活着的對象複製到另外一塊內存上面(空閒面),然後再把已使用過的內存空間一次清理掉。

      複製算法比較適合於新生代(短生存期的對象),在老年代(長生存期的對象)中,對象存活率比較高,如果執行較多的複製操作,效率將會變低,所以老年代一般會選用其他算法,如標記—整理算法。一種典型的基於Coping算法的垃圾回收是stop-and-copy算法,它將堆分成對象區和空閒區,在對象區與空閒區的切換過程中,程序暫停執行。

      優點:(1)標記階段和複製階段可以同時進行。(2)每次只對一塊內存進行回收,運行高效。(3)只需移動棧頂指針,按順序分配內存即可,實現簡單。(4)內存回收時不用考慮內存碎片的出現(得活動對象所佔的內存空間之間沒有空閒間隔)。

      缺點:需要一塊能容納下所有存活對象的額外的內存空間。因此,可一次性分配的最大內存縮小了一半。

算法示意圖:

圖4.4 Copying算法


圖4.4 Copying算法

4.4  Adaptive算法(Adaptive Collector)

      在特定的情況下,一些垃圾收集算法會優於其它算法。基於Adaptive算法的垃圾收集器就是監控當前堆的使用情況,並將選擇適當算法的垃圾收集器。

5  Java的堆內存(Java Heap Memory)

      Java的堆內存基於Generation算法(Generational Collector)劃分爲新生代、年老代和持久代。新生代又被進一步劃分爲Eden和Survivor區,最後Survivor由FromSpace(Survivor0)和ToSpace(Survivor1)組成。所有通過new創建的對象的內存都在堆中分配,其大小可以通過-Xmx和-Xms來控制。

      分代收集,是基於這樣一個事實:不同的對象的生命週期是不一樣的。因此,可以將不同生命週期的對象分代,不同的代採取不同的回收算法(4.1-4.3)進行垃圾回收(GC),以便提高回收效率。

堆內存分區示意圖:

圖5.0 Java Heap Memory


圖5.1  Java Heap Memory

Java的內存空間除了堆內存還有其他部分:

1)棧

    每個線程執行每個方法的時候都會在棧中申請一個棧幀,每個棧幀包括局部變量區和操作數棧,用於存放此次方法調用過程中的臨時變量、參數和中間結果。

2)本地方法棧

    用於支持native方法的執行,存儲了每個native方法調用的狀態。

4)方法區

    存放了要加載的類信息、靜態變量、final類型的常量、屬性和方法信息。JVM用持久代(PermanetGeneration)來存放方法區,可通過-XX:PermSize和-XX:MaxPermSize來指定最小值和最大值。

詳細可以參考:Java內存區域和內存溢出

5.1堆內存分配區域:

1.年輕代(Young Generation)

      幾乎所有新生成的對象首先都是放在年輕代的。新生代內存按照8:1:1的比例分爲一個Eden區和兩個Survivor(Survivor0,Survivor1)區。大部分對象在Eden區中生成。當新對象生成,Eden Space申請失敗(因爲空間不足等),則會發起一次GC(Scavenge GC)。回收時先將Eden區存活對象複製到一個Survivor0區,然後清空Eden區,當這個Survivor0區也存放滿了時,則將Eden區和Survivor0區存活對象複製到另一個Survivor1區,然後清空Eden和這個Survivor0區,此時Survivor0區是空的,然後將Survivor0區和Survivor1區交換,即保持Survivor1區爲空, 如此往復。當Survivor1區不足以存放 Eden和Survivor0的存活對象時,就將存活對象直接存放到老年代。當對象在Survivor區躲過一次GC的話,其對象年齡便會加1,默認情況下,如果對象年齡達到15歲,就會移動到老年代中。若是老年代也滿了就會觸發一次Full GC,也就是新生代、老年代都進行回收。新生代大小可以由-Xmn來控制,也可以用-XX:SurvivorRatio來控制Eden和Survivor的比例。

2.年老代(Old Generation)

      在年輕代中經歷了N次垃圾回收後仍然存活的對象,就會被放到年老代中。因此,可以認爲年老代中存放的都是一些生命週期較長的對象。內存比新生代也大很多(大概比例是1:2),當老年代內存滿時觸發Major GC即Full GC,Full GC發生頻率比較低,老年代對象存活時間比較長,存活率標記高。一般來說,大對象會被直接分配到老年代。所謂的大對象是指需要大量連續存儲空間的對象,最常見的一種大對象就是大數組。比如:

      byte[] data = new byte[4*1024*1024]

      這種一般會直接在老年代分配存儲空間。

      當然分配的規則並不是百分之百固定的,這要取決於當前使用的是哪種垃圾收集器組合和JVM的相關參數。

3.持久代(Permanent Generation)

      用於存放靜態文件(class類、方法)和常量等。持久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者調用一些class,例如Hibernate 等,在這種時候需要設置一個比較大的持久代空間來存放這些運行過程中新增的類。對永久代的回收主要回收兩部分內容:廢棄常量和無用的類。

      永久代空間在Java SE8特性中已經被移除。取而代之的是元空間(MetaSpace)。因此不會再出現“java.lang.OutOfMemoryError: PermGen error”錯誤。

5.2 堆內存分配策略明確以下三點:

(1)對象優先在Eden分配。

(2)大對象直接進入老年代。

(3)長期存活的對象將進入老年代。

5.3 對垃圾回收機制說明以下三點:

      新生代GC(Minor GC/Scavenge GC):發生在新生代的垃圾收集動作。因爲Java對象大多都具有朝生夕滅的特性,因此Minor GC非常頻繁(不一定等Eden區滿了才觸發),一般回收速度也比較快。在新生代中,每次垃圾收集時都會發現有大量對象死去,只有少量存活,因此可選用複製算法來完成收集。

    老年代GC(Major GC/Full GC):發生在老年代的垃圾回收動作。Major GC,經常會伴隨至少一次Minor GC。由於老年代中的對象生命週期比較長,因此Major GC並不頻繁,一般都是等待老年代滿了後才進行Full GC,而且其速度一般會比Minor GC慢10倍以上。另外,如果分配了Direct Memory,在老年代中進行Full GC時,會順便清理掉Direct Memory中的廢棄對象。而老年代中因爲對象存活率高、沒有額外空間對它進行分配擔保,就必須使用標記—清除算法或標記—整理算法來進行回收。

      新生代採用空閒指針的方式來控制GC觸發,指針保持最後一個分配的對象在新生代區間的位置,當有新的對象要分配內存時,用於檢查空間是否足夠,不夠就觸發GC。當連續分配對象時,對象會逐漸從Eden到Survivor,最後到老年代。

      用Java VisualVM來查看,能明顯觀察到新生代滿了後,會把對象轉移到舊生代,然後清空繼續裝載,當老年代也滿了後,就會報outofmemory的異常,如下圖所示:

圖5.2 垃圾回收分析

如何使用Java VisualVM 進行垃圾回收的監視和分析請參考:垃圾回收的監視和分析

6  垃圾回收器(GC)

6.1 按執行機制劃分Java有四種類型的垃圾回收器:

(1)串行垃圾回收器(Serial Garbage Collector)

(2)並行垃圾回收器(Parallel Garbage Collector)

(3)併發標記掃描垃圾回收器(CMS Garbage Collector)

(4)G1垃圾回收器(G1 Garbage Collector)

圖6.0 GC

     每種類型都有自己的優勢與劣勢,在很大程度上有 所不同並且可以爲我們提供完全不同的應用程序性能。重要的是,我們編程的時候可以通過向JVM傳遞參數選擇垃圾回收器類型。每種類型理解每種類型的垃圾回收器並且根據應用程序選擇進行正確的選擇是非常重要的。

1、串行垃圾回收器

      串行垃圾回收器通過持有應用程序所有的線程進行工作。它爲單線程環境設計,只使用一個單獨的線程進行垃圾回收,通過凍結所有應用程序線程進行工作,所以可能不適合服務器環境。它最適合的是簡單的命令行程序(單CPU、新生代空間較小及對暫停時間要求不是非常高的應用)。是client級別默認的GC方式。

通過JVM參數-XX:+UseSerialGC可以使用串行垃圾回收器。

2、並行垃圾回收器

      並行垃圾回收器也叫做 throughput collector 。它是JVM的默認垃圾回收器。與串行垃圾回收器不同,它使用多線程進行垃圾回收。相似的是,當執行垃圾回收的時候它也會凍結所有的應用程序線程。

      適用於多CPU、對暫停時間要求較短的應用上,是server級別默認採用的GC方式。可用-XX:+UseParallelGC來強制指定,用-XX:ParallelGCThreads=4來指定線程數。

3、併發標記掃描垃圾回收器

      併發標記垃圾回收使用多線程掃描堆內存,標記需要清理的實例並且清理被標記過的實例。併發標記垃圾回收器只會在下面兩種情況持有應用程序所有線程。

(1)當標記的引用對象在Tenured區域;

(2)在進行垃圾回收的時候,堆內存的數據被併發的改變。

      相比並行垃圾回收器,併發標記掃描垃圾回收器使用更多的CPU來確保程序的吞吐量。如果我們可以爲了更好的程序性能分配更多的CPU,那麼併發標記上掃描垃圾回收器是更好的選擇相比並發垃圾回收器。

通過JVM參數 XX:+USeParNewGC 打開併發標記掃描垃圾回收器。

以上各種GC機制是需要組合使用的,指定方式由下表所示:

表6.0  不同垃圾回收器的組合方式

6.2 垃圾回收的JVM配置

運行的垃圾回收器類型:

表6.1  GC類型

GC的優化配置:

表6.2  GC優化配置

使用JVM GC 參數的例子:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseSerialGC -jar java-application.jar

6.3 HotSpot(JDK 7)虛擬機提供的幾種垃圾收集器

      垃圾收集算法是內存回收的理論基礎,而垃圾收集器就是內存回收的具體實現。用戶可以根據自己的需求組合出各個年代使用的收集器。

1.Serial(SerialMSC)(Copying算法)

      Serial收集器是最基本最古老的收集器,它是一個單線程收集器,並且在它進行垃圾收集時,必須暫停所有用戶線程。Serial收集器是針對新生代的收集器,採用的是Copying算法。

2.Serial Old (標記—整理算法)

      Serial Old收集器是針對老年代的收集器,採用的是Mark-Compact算法。它的優點是實現簡單高效,但是缺點是會給用戶帶來停頓。

2.ParNew (Copying算法)

      ParNew收集器是新生代收集器,Serial收集器的多線程版本。使用多個線程進行垃圾收集,在多核CPU環境下有着比Serial更好的表現。

3.Parallel Scavenge (Copying算法)

      Parallel Scavenge收集器是一個新生代的多線程收集器(並行收集器),它在回收期間不需要暫停其他用戶線程,其採用的是Copying算法,該收集器與前兩個收集器有所不同,它主要是爲了達到一個可控的吞吐量。追求高吞吐量,高效利用CPU。吞吐量一般爲99%。 吞吐量= 用戶線程時間/(用戶線程時間+GC線程時間)。適合後臺應用等對交互相應要求不高的場景。

4.Parallel Old(ParallelMSC)(標記—整理算法)

      Parallel Old是Parallel Scavenge收集器的老年代版本(並行收集器),使用多線程和Mark-Compact算法。吞吐量優先。

5.CMS  (標記—整理算法)

      CMS(Current Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器,它是一種併發收集器,採用的是Mark-Sweep算法。高併發、低停頓,追求最短GC回收停頓時間,CPU佔用比較高。響應時間快,停頓時間短,多核CPU 追求高響應時間的選擇。

6.G1

      G1收集器是當今收集器技術發展最前沿的成果,它是一款面向服務端應用的收集器,它能充分利用多CPU、多核環境。因此它是一款並行與併發收集器,並且它能建立可預測的停頓時間模型。

      G1垃圾回收器適用於堆內存很大的情況,他將堆內存分割成不同的區域,並且併發的對其進行垃圾回收。G1也可以在回收內存之後對剩餘的堆內存空間進行壓縮。併發掃描標記垃圾回收器在STW情況下壓縮內存。G1垃圾回收會優先選擇第一塊垃圾最多的區域。

通過JVM參數 –XX:+UseG1GC 使用G1垃圾回收器。

Java 8 的新特性:

      在使用G1垃圾回收器的時候,通過 JVM參數 -XX:+UseStringDeduplication 。 我們可以通過刪除重複的字符串,只保留一個char[]來優化堆內存。這個選擇在Java 8 u 20被引入。

      我們給出了全部的幾種Java垃圾回收器,需要根據應用場景,硬件性能和吞吐量需求來決定使用哪一種。

      新生代收集器使用的收集器:Serial、PraNew、Parallel Scavenge。

      老年代收集器使用的收集器:Serial Old、Parallel Old、CMS。

表 6.1 HotSpot 1.6 JVM 垃圾回收器

7  垃圾回收執行時間和注意事項

    GC分爲Scavenge GC和Full GC。

    Scavenge GC :發生在Eden區的垃圾回收。

    Full GC :對整個堆進行整理,包括Young、Tenured和Perm。Full GC因爲需要對整個堆進行回收,所以比Scavenge GC要慢,因此應該儘可能減少Full GC的次數。在對JVM調優的過程中,很大一部分工作就是對於FullGC的調節。

    有如下原因可能導致Full GC:

    1.年老代(Tenured)被寫滿;

    2.持久代(Perm)被寫滿;

    3.System.gc()被顯示調用;

    4.上一次GC之後Heap的各域分配策略動態變化.

7.1  與垃圾回收時間有關的兩個函數

1.  System.gc()方法

    命令行參數監視垃圾收集器的運行:

    使用System.gc()可以不管JVM使用的是哪一種垃圾回收的算法,都可以請求Java的垃圾回收。在命令行中有一個參數-verbosegc可以查看Java使用的堆內存的情況,它的格式如下:

    java -verbosegc classfile

    需要注意的是,調用System.gc()也僅僅是一個請求(建議)。JVM接受這個消息後,並不是立即做垃圾回收,而只是對幾個垃圾回收算法做了加權,使垃圾回收操作容易發生,或提早發生,或回收較多而已。

2.  finalize()方法

    概述:在JVM垃圾回收器收集一個對象之前,一般要求程序調用適當的方法釋放資源。但在沒有明確釋放資源的情況下,Java提供了缺省機制來終止該對象以釋放資源,這個方法就是finalize()。它的原型爲:

protected void finalize() throws Throwable

在finalize()方法返回之後,對象消失,垃圾收集開始執行。原型中的throws Throwable表示它可以拋出任何類型的異常。

    意義:之所以要使用finalize(),是存在着垃圾回收器不能處理的特殊情況。假定你的對象(並非使用new方法)獲得了一塊“特殊”的內存區域,由於垃圾回收器只知道那些顯示地經由new分配的內存空間,所以它不知道該如何釋放這塊“特殊”的內存區域,那麼這個時候Java允許在類中定義一個finalize()方法。

    特殊的區域例如:1)由於在分配內存的時候可能採用了類似 C語言的做法,而非JAVA的通常new做法。這種情況主要發生在native method中,比如native method調用了C/C++方法malloc()函數系列來分配存儲空間,但是除非調用free()函數,否則這些內存空間將不會得到釋放,那麼這個時候就可能造成內存泄漏。但是由於free()方法是在C/C++中的函數,所以finalize()中可以用本地方法來調用它。以釋放這些“特殊”的內存空間。2)又或者打開的文件資源,這些資源不屬於垃圾回收器的回收範圍。

    換言之,finalize()的主要用途是釋放一些其他做法開闢的內存空間,以及做一些清理工作。因爲在Java中並沒有提夠像“析構”函數或者類似概念的函數,要做一些類似清理工作的時候,必須自己動手創建一個執行清理工作的普通方法,也就是override Object這個類中的finalize()方法。比如:銷燬通知。

    一旦垃圾回收器準備好釋放對象佔用的存儲空間,首先會去調用finalize()方法進行一些必要的清理工作。只有到下一次再進行垃圾回收動作的時候,纔會真正釋放這個對象所佔用的內存空間。

    JAVA裏的對象並非總會被垃圾回收器回收。1 對象可能不被垃圾回收,2 垃圾回收並不等於“析構”,3 垃圾回收只與內存有關。也就是說,並不是如果一個對象不再被使用,是不是要在finalize()中釋放這個對象中含有的其它對象呢?不是的。因爲無論對象是如何創建的,垃圾回收器都會負責釋放那些對象佔有的內存。

    當 finalize() 方法被調用時,JVM 會釋放該線程上的所有同步鎖。

7.2  觸發主GC的條件

    1)當應用程序空閒時,即沒有應用線程在運行時,GC會被調用。因爲GC在優先級最低的線程中進行,所以當應用忙時,GC線程就不會被調用,但以下條件除外。

    2)Java堆內存不足時,GC會被調用。當應用線程在運行,並在運行過程中創建新對象,若這時內存空間不足,JVM就會強制地調用GC線程,以便回收內存用於新的分配。若GC一次之後仍不能滿足內存分配的要求,JVM會再進行兩次GC作進一步的嘗試,若仍無法滿足要求,則 JVM將報“out of memory”的錯誤,Java應用將停止。

    3)在編譯過程中作爲一種優化技術,Java 編譯器能選擇給實例賦 null 值,從而標記實例爲可回收。

    由於是否進行主GC由JVM根據系統環境決定,而系統環境在不斷的變化當中,所以主GC的運行具有不確定性,無法預計它何時必然出現,但可以確定的是對一個長期運行的應用來說,其主GC是反覆進行的。

7.3  減少GC開銷的措施

    根據上述GC的機制,程序的運行會直接影響系統環境的變化,從而影響GC的觸發。若不針對GC的特點進行設計和編碼,就會出現內存駐留等一系列負面影響。爲了避免這些影響,基本的原則就是儘可能地減少垃圾和減少GC過程中的開銷。具體措施包括以下幾個方面:

(1)不要顯式調用System.gc()

    此函數建議JVM進行主GC,雖然只是建議而非一定,但很多情況下它會觸發主GC,從而增加主GC的頻率,也即增加了間歇性停頓的次數。

(2)儘量減少臨時對象的使用

    臨時對象在跳出函數調用後,會成爲垃圾,少用臨時變量就相當於減少了垃圾的產生,從而延長了出現上述第二個觸發條件出現的時間,減少了主GC的機會。

(3)對象不用時最好顯式置爲Null

    一般而言,爲Null的對象都會被作爲垃圾處理,所以將不用的對象顯式地設爲Null,有利於GC收集器判定垃圾,從而提高了GC的效率。

(4)儘量使用StringBuffer,而不用String來累加字符串

    由於String是固定長的字符串對象,累加String對象時,並非在一個String對象中擴增,而是重新創建新的String對象,如Str5=Str1+Str2+Str3+Str4,這條語句執行過程中會產生多個垃圾對象,因爲對次作“+”操作時都必須創建新的String對象,但這些過渡對象對系統來說是沒有實際意義的,只會增加更多的垃圾。避免這種情況可以改用StringBuffer來累加字符串,因StringBuffer是可變長的,它在原有基礎上進行擴增,不會產生中間對象。

(5)能用基本類型如Int,Long,就不用Integer,Long對象

    基本類型變量佔用的內存資源比相應對象佔用的少得多,如果沒有必要,最好使用基本變量。

(6)儘量少用靜態對象變量

    靜態變量屬於全局變量,不會被GC回收,它們會一直佔用內存。

(7)分散對象創建或刪除的時間

    集中在短時間內大量創建新對象,特別是大對象,會導致突然需要大量內存,JVM在面臨這種情況時,只能進行主GC,以回收內存或整合內存碎片,從而增加主GC的頻率。集中刪除對象,道理也是一樣的。它使得突然出現了大量的垃圾對象,空閒空間必然減少,從而大大增加了下一次創建新對象時強制主GC的機會。

7.4  關於垃圾回收的幾點補充

經過上述的說明,可以發現垃圾回收有以下的幾個特點:

    (1)垃圾收集發生的不可預知性:由於實現了不同的垃圾回收算法和採用了不同的收集機制,所以它有可能是定時發生,有可能是當出現系統空閒CPU資源時發生,也有可能是和原始的垃圾收集一樣,等到內存消耗出現極限時發生,這與垃圾收集器的選擇和具體的設置都有關係。

    (2)垃圾收集的精確性:主要包括2 個方面:(a)垃圾收集器能夠精確標記活着的對象;(b)垃圾收集器能夠精確地定位對象之間的引用關係。前者是完全地回收所有廢棄對象的前提,否則就可能造成內存泄漏。而後者則是實現歸併和複製等算法的必要條件。所有不可達對象都能夠可靠地得到回收,所有對象都能夠重新分配,允許對象的複製和對象內存的縮並,這樣就有效地防止內存的支離破碎。

    (3)現在有許多種不同的垃圾收集器,每種有其算法且其表現各異,既有當垃圾收集開始時就停止應用程序的運行,又有當垃圾收集開始時也允許應用程序的線程運行,還有在同一時間垃圾收集多線程運行。

    (4)垃圾收集的實現和具體的JVM 以及JVM的內存模型有非常緊密的關係。不同的JVM 可能採用不同的垃圾收集,而JVM 的內存模型決定着該JVM可以採用哪些類型垃圾收集。現在,HotSpot 系列JVM中的內存系統都採用先進的面向對象的框架設計,這使得該系列JVM都可以採用最先進的垃圾收集。

    (5)隨着技術的發展,現代垃圾收集技術提供許多可選的垃圾收集器,而且在配置每種收集器的時候又可以設置不同的參數,這就使得根據不同的應用環境獲得最優的應用性能成爲可能。

針對以上特點,我們在使用的時候要注意:

    (1)不要試圖去假定垃圾收集發生的時間,這一切都是未知的。比如,方法中的一個臨時對象在方法調用完畢後就變成了無用對象,這個時候它的內存就可以被釋放。

    (2)Java中提供了一些和垃圾收集打交道的類,而且提供了一種強行執行垃圾收集的方法--調用System.gc(),但這同樣是個不確定的方法。Java 中並不保證每次調用該方法就一定能夠啓動垃圾收集,它只不過會向JVM發出這樣一個申請,到底是否真正執行垃圾收集,一切都是個未知數。

    (3)挑選適合自己的垃圾收集器。一般來說,如果系統沒有特殊和苛刻的性能要求,可以採用JVM的缺省選項。否則可以考慮使用有針對性的垃圾收集器,比如增量收集器就比較適合實時性要求較高的系統之中。系統具有較高的配置,有比較多的閒置資源,可以考慮使用並行標記/清除收集器。

    (4)關鍵的也是難把握的問題是內存泄漏。良好的編程習慣和嚴謹的編程態度永遠是最重要的,不要讓自己的一個小錯誤導致內存出現大漏洞。

(5)儘早釋放無用對象的引用。大多數程序員在使用臨時變量的時候,都是讓引用變量在退出活動域(scope)後,自動設置爲null,暗示垃圾收集器來收集該對象,還必須注意該引用的對象是否被監聽,如果有,則要去掉監聽器,然後再賦空值。

8  補充:

8.1  Java內存泄露

      (1)靜態集合類像HashMap、Vector等的使用最容易出現內存泄露,這些靜態變量的生命週期和應用程序一致,所有的對象Object也不能被釋放,因爲他們也將一直被Vector等應用着。

Static Vector v = new Vector();

for (int i = 1; i<100; i++)

{

Object o = new Object();

v.add(o);

o = null;

}

      在這個例子中,代碼棧中存在Vector 對象的引用 v 和 Object 對象的引用 o 。在 For 循環中,我們不斷的生成新的對象,然後將其添加到 Vector 對象中,之後將 o 引用置空。問題是當 o 引用被置空後,如果發生 GC,我們創建的 Object 對象是否能夠被 GC 回收呢?答案是否定的。因爲, GC 在跟蹤代碼棧中的引用時,會發現 v 引用,而繼續往下跟蹤,就會發現 v 引用指向的內存空間中又存在指向 Object 對象的引用。也就是說盡管o 引用已經被置空,但是 Object 對象仍然存在其他的引用,是可以被訪問到的,所以 GC 無法將其釋放掉。如果在此循環之後, Object 對象對程序已經沒有任何作用,那麼我們就認爲此 Java 程序發生了內存泄漏。

      (2)各種連接,數據庫連接,網絡連接,IO連接等沒有顯示調用close關閉,不被GC回收導致內存泄露。

      (3)監聽器的使用,在釋放對象的同時沒有相應刪除監聽器的時候也可能導致內存泄露。

8.2  GC性能調優

      Java虛擬機的內存管理與垃圾收集是虛擬機結構體系中最重要的組成部分,對程序(尤其服務器端)的性能和穩定性有着非常重要的影響。性能調優需要具體情況具體分析,而且實際分析時可能需要考慮的方面很多,這裏僅就一些簡單常用的情況作簡要介紹。

      我們可以通過給Java虛擬機分配超大堆(前提是物理機的內存足夠大)來提升服務器的響應速度,但分配超大堆的前提是有把握把應用程序的Full GC頻率控制得足夠低,因爲一次Full GC的時間造成比較長時間的停頓。控制Full GC頻率的關鍵是保證應用中絕大多數對象的生存週期不應太長,尤其不能產生批量的、生命週期長的大對象,這樣才能保證老年代的穩定。

      Direct Memory在堆內存外分配,而且二者均受限於物理機內存,且成負相關關係。因此分配超大堆時,如果用到了NIO機制分配使用了很多的Direct Memory,則有可能導致Direct Memory的OutOfMemoryError異常,這時可以通過-XX:MaxDirectMemorySize參數調整Direct Memory的大小。

      除了Java堆和永久代以及直接內存外,還要注意下面這些區域也會佔用較多的內存,這些內存的總和會受到操作系統進程最大內存的限制:1、線程堆棧:可通過-Xss調整大小,內存不足時拋出StackOverflowError(縱向無法分配,即無法分配新的棧幀)或OutOfMemoryError(橫向無法分配,即無法建立新的線程)。

      Socket緩衝區:每個Socket連接都有Receive和Send兩個緩衝區,分別佔用大約37KB和25KB的內存。如果無法分配,可能會拋出IOException:Too many open files異常。關於Socket緩衝區的詳細介紹參見我的Java網絡編程系列中深入剖析Socket的幾篇文章。

      JNI代碼:如果代碼中使用了JNI調用本地庫,那本地庫使用的內存也不在堆中。

      虛擬機和GC:虛擬機和GC的代碼執行也要消耗一定的內存。

9  代碼分析垃圾回收過程

public class SlotGc{

               public static void main(String[] args){

                          byte[] holder = new byte[32*1024*1024];

                           System.gc();

                }

}

      代碼很簡單,就是向內存中填充了32MB的數據,然後通過虛擬機進行垃圾收集。在Javac編譯後,在終端執行如下指令:java -verbose:gc SlotGc來查看垃圾收集的結果,得到如下輸出信息:

[GC 208K->134K(5056K), 0.0017306 secs]

[Full GC 134K->134K(5056K), 0.0121194 secs]

[Full GC 32902K->32902K(37828K), 0.0094149 sec]

      注意第三行,“->”之前的數據表示垃圾回收前堆中存活對象所佔用的內存大小,“->”之後的數據表示垃圾回收堆中存活對象所佔用的內存大小,括號中的數據表示堆內存的總容量,0.0094149 sec 表示垃圾回收所用的時間。

      從結果中可以看出,System.gc(()運行後並沒有回收掉這32MB的內存,這應該是意料之中的結果,因爲變量holder還處在作用域內,虛擬機自然不會回收掉holder引用的對象所佔用的內存。

修改代碼如下:

public class SlotGc{

                    public static void main(String[] args){

            {                    byte[] holder = new byte[32*1024*1024];

                    }

                    System.gc();

           }

}

      加入花括號後,holder的作用域被限制在了花括號之內,因此,在執行System.gc()時,holder引用已經不能再被訪問,邏輯上來講,這次應該會回收掉holder引用的對象所佔的內存。但查看垃圾回收情況時,輸出信息如下:

[GC 208K->134K(5056K), 0.0017100 secs]

[Full GC 134K->134K(5056K), 0.0125887 secs]

[Full GC 32902K->32902K(37828K), 0.0089226 secs]

      很明顯,這32MB的數據並沒有被回收。下面我們再做如下修改:

public class SlotGc{

                public static void main(String[] args){

        {

                              byte[] holder = new byte[32*1024*1024];

                              holder = null;

                         }

                  System.gc();

        }

}

這次得到的垃圾回收信息如下:

[GC 208K->134K(5056K), 0.0017194 secs]

[Full GC 134K->134K(5056K), 0.0124656 secs]

[Full GC 32902K->134K(37828K), 0.0091637 secs]

說明這次holder引用的對象所佔的內存被回收了。

      首先明確一點:holder能否被回收的根本原因是局部變量表中的Slot是否還存有關於holder數組對象的引用。

      在第一次修改中,雖然在holder作用域之外進行回收,但是在此之後,沒有對局部變量表的讀寫操作,holder所佔用的Slot還沒有被其他變量所複用。所以作爲GC Roots一部分的局部變量表仍保持者對它的關聯。這種關聯沒有被及時打斷,因此GC收集器不會將holder引用的對象內存回收掉。 在第二次修改中,在GC收集器工作前,手動將holder設置爲null值,就把holder所佔用的局部變量表中的Slot清空了,因此,這次GC收集器工作時將holder之前引用的對象內存回收掉了。

      當然,我們也可以用其他方法來將holder引用的對象內存回收掉,只要複用holder所佔用的slot即可,比如在holder作用域之外執行一次讀寫操作。

      爲對象賦null值並不是控制變量回收的最好方法,以恰當的變量作用域來控制變量回收時間纔是最優雅的解決辦法。另外,賦null值的操作在經過虛擬機JIT編譯器優化後會被消除掉,經過JIT編譯後,System.gc()執行時就可以正確地回收掉內存,而無需賦null值。

參考文章:

深入理解 Java 垃圾回收機制 - Andy趙 - 博客園

【深入Java虛擬機(8)】:Java垃圾收集機制 - ImportNew

Java GC系列(2):Java垃圾回收是如何工作的? - ImportNew

GC算法基礎 - ImportNew

Java垃圾回收機制 - ywl925 - 博客園

Java垃圾回收機制 - zsuguangh的專欄 - 博客頻道 - CSDN.NET

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