JVM性能優化系列-(2) 垃圾收集器與內存分配策略

JVM.jpg

目前已經更新完《Java併發編程》和《Docker教程》,歡迎關注【後端精進之路】,輕鬆閱讀全部文章。

Java併發編程:

Docker教程:

JVM性能優化:

2. 垃圾收集器與內存分配策略

垃圾收集(Garbage Collection, GC)是JVM實現裏非常重要的一環,JVM成熟的內存動態分配與回收技術使Java(當然還有其他運行在JVM上的語言,如Scala等)程序員在提升開發效率上獲得了驚人的便利。理解GC,對於理解JVM和Java語言有着非常重要的作用。並且當我們需要排查各種內存溢出、內存泄漏問題時,當垃圾收集稱爲系統達到更高併發量的瓶頸時,只有深入理解GC和內存分配,才能對這些“自動化”的技術實施必要的監控和調節。

GC主要需要解決以下三個問題:

  • 哪些內存需要回收?
  • 什麼時候回收?
  • 如何回收?

下面將對這些問題進行一一介紹。

2.1 如何判斷對象存活

在堆裏存放着Java世界中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,首要的就是確定這些對象中哪些還“存活”着,哪些已經“死去”(即不可能再被任何途徑使用的對象)。

引用計數算法

引用計數器判斷對象是否存活的過程是這樣的:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器加1;當引用失效時,計數器減1;任何時刻計數器爲0的對象就是不可能再被使用的。

引用計數算法的實現簡單,判定效率也很高,大部分情況下是一個不錯的算法。它沒有被JVM採用的原因是它很難解決對象之間循環引用的問題。

可達性分析算法

在主流商用程序語言的實現中,都是通過可達性分析(tracing GC)來判定對象是否存活的。

算法的基本思路是:通過一系列的稱爲“GC Roots”的對象作爲起點,從這些節點向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是GC Roots 到這個對象不可達)時,則證明此對象時不可用的。用下圖來加以說明:

0635cbe8.png

作爲GC Roots的對象包括下面幾種:

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

2.2 各種引用

強引用

一般的Object obj = new Object() ,就屬於強引用。被強引用關聯的對象不會被回收。

軟引用

一些有用但是並非必需,用軟引用關聯的對象,系統將要發生OOM之前,這些對象就會被回收。

下面的例子中,當程序發生OOM之前,嘗試去回收軟引用所關聯的對象,導致後面獲取到的值爲null。

public class TestSoftRef {
    
    public static class User{
        public int id = 0;
        public String name = "";
        public User(int id, String name) {
            super();
            this.id = id;
            this.name = name;
        }
        @Override
        public String toString() {
            return "User [id=" + id + ", name=" + name + "]";
        }
        
    }
    
    public static void main(String[] args) {

        User u = new User(1,"Vincent");
        SoftReference<User> userSoft = new SoftReference<>(u);
        u = null;//保證new User(1,"Vincent")這個實例只有userSoft在軟引用
        
        System.out.println(userSoft.get());
        System.gc();//展示gc的時候,SoftReference不一定會被回收
        System.out.println("AfterGc");
        System.out.println(userSoft.get());//new User(1,"Vincent")沒有被回收
        List<byte[]> list = new LinkedList<>();
        
        try {
            for(int i=0;i<100;i++) {
                //User(1,"Vincent")實例一直存在
                System.out.println("********************"+userSoft.get());
                list.add(new byte[1024*1024*1]);
            }
        } catch (Throwable e) {
            //拋出了OOM異常後打印的,User(1,"Vincent")這個實例被回收了
            System.out.println("Throwable********************"+userSoft.get());
        }
        
    }
}

程序輸出結果:

Screen Shot 2019-12-19 at 8.52.43 PM.png

弱引用 WeakReference

一些有用(程度比軟引用更低)但是並非必需,用弱引用關聯的對象,只能生存到下一次垃圾回收之前,GC發生時,不管內存夠不夠,都會被回收。

下面的例子中,發生gc後,弱引用所關聯的對象被回收。

public class TestWeakRef {
    public static class User{
        public int id = 0;
        public String name = "";
        public User(int id, String name) {
            super();
            this.id = id;
            this.name = name;
        }
        @Override
        public String toString() {
            return "User [id=" + id + ", name=" + name + "]";
        }
        
    }
    
    public static void main(String[] args) {
        User u = new User(1,"Vincent");
        WeakReference<User> userWeak = new WeakReference<>(u);
        u = null;
        System.out.println(userWeak.get());
        System.gc();
        System.out.println("AfterGc");
        System.out.println(userWeak.get());
        
    }
}

輸出結果如下:

Screen Shot 2019-12-19 at 8.56.46 PM.png

虛引用

又稱爲幽靈引用或者幻影引用。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用取得一個對象。

爲一個對象設置虛引用關聯的唯一目的,就是能在這個對象被回收時收到一個系統通知

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

注意:軟引用 SoftReference和弱引用 WeakReference,可以用在內存資源緊張的情況下以及創建不是很重要的數據緩存。當系統內存不足的時候,緩存中的內容是可以被釋放的。
例如,一個程序用來處理用戶提供的圖片。如果將所有圖片讀入內存,這樣雖然可以很快的打開圖片,但內存空間使用巨大,一些使用較少的圖片浪費內存空間,需要手動從內存中移除。如果每次打開圖片都從磁盤文件中讀取到內存再顯示出來,雖然內存佔用較少,但一些經常使用的圖片每次打開都要訪問磁盤,代價巨大。這個時候就可以用軟引用構建緩存。

2.3 方法區回收

很多人認爲方法區沒有垃圾回收,Java虛擬機規範中確實說過不要求,而且在方法區中進行垃圾收集的“性價比”較低:在堆中,尤其是新生代,常規應用進行一次垃圾收集可以回收70%~95%的空間,而方法區的效率遠低於此。在JDK 1.8中,JVM摒棄了永久代,用元空間來作爲方法區的實現,下面介紹的將是元空間的垃圾回收。

元空間的內存管理由元空間虛擬機來完成。先前,對於類的元數據我們需要不同的垃圾回收器進行處理,現在只需要執行元空間虛擬機的C++代碼即可完成。在元空間中,類和其元數據的生命週期和其對應的類加載器是相同的。

話句話說,只要類加載器存活,其加載的類的元數據也是存活的,因而不會被回收掉。當一個類加載器被垃圾回收器標記爲不再存活,其對應的元空間會被回收。

2.4 垃圾收集算法

標記-清除算法(Mark-Sweep)

算法分成“標記”、“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。

算法的執行過程如下圖所示:

efc6204a.jpg

標記-清除算法的不足主要有以下兩點:

  • 空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不觸發另一次垃圾收集動作。

  • 效率問題,因爲內存碎片的存在,操作會變得更加費時,因爲查找下一個可用空閒塊已不再是一個簡單操作。

複製算法(Copying)

將可用內存按容量分成大小相等的兩塊,每次只使用其中的一塊。當這一塊內存用完,就將還存活着的對象複製到另一塊上面,然後再把已使用過的內存空間一次清理掉。

這樣做使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小爲原來的一半。 複製算法的執行過程如下圖所示:

f1cada8a.jpg

標記-整理算法(Mark-Compact)

根據老年代的特點,標記-整理(Mark-Compact)算法被提出來,主要思想爲:此算法的標記過程與標記-清除算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉邊界以外的內存。具體示意圖如下所示:

d3d3277f.jpg

分代收集算法(Generational Collection)

當前商業虛擬機的垃圾收集都採用分代收集(Generational Collection)算法,此算法相較於前幾種沒有什麼新的特徵,主要思想爲:根據對象存活週期的不同將內存劃分爲幾塊,一般是把Java堆分爲新生代和老年代,這樣就可以根據各個年代的特點採用最適合的收集算法:

  • 新生代

在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用複製算法,只需要付出少量存活對象的複製成本就可以完成收集。

  • 老年代

在老年代中,因爲對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清除”或“標記-整理”算法來進行回收。

Minor GC與複製算法

現在的商業虛擬機都使用複製算法來回收新生代。新生代的GC又叫“Minor GC”,IBM公司的專門研究表明:新生代中的對象98%是“朝生夕死”的,所以Minor GC非常頻繁,一般回收速度也比較快,同時“朝生夕死”的特性也使得Minor GC使用複製算法時不需要按照1:1的比例來劃分新生代內存空間。

  • Minor GC過程

事實上,新生代將內存分爲一塊較大的Eden空間兩塊較小的Survivor空間(From Survivor和To Survivor),每次Minor GC都使用Eden和From Survivor,當回收時,將Eden和From Survivor中還存活着的對象都一次性地複製到另外一塊To Survivor空間上,最後清理掉Eden和剛使用的Survivor空間。一次Minor GC結束的時候,Eden空間和From Survivor空間都是空的,而To Survivor空間裏面存儲着存活的對象。在下次MinorGC的時候,兩個Survivor空間交換他們的標籤,現在是空的“From” Survivor標記成爲“To”,“To” Survivor標記爲“From”。因此,在MinorGC結束的時候,Eden空間是空的,兩個Survivor空間中的一個是空的,而另一個存儲着存活的對象。

HotSpot虛擬機默認的Eden : Survivor的比例是8 : 1,由於一共有兩塊Survivor,所以每次新生代中可用內存空間爲整個新生代容量的90%(80%+10%),只有10%的容量會被“浪費”。

  • 分配擔保

上文說的98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要依賴老年代內存進行分配擔保(Handle Promotion)。如果另外一塊Survivor上沒有足夠空間存放上一次新生代收集下來的存活對象,這些對象將直接通過分配擔保機制進入老年代。

2.5 HotSpot的算法實現

枚舉根節點

  • GC鏈逐個檢查引用,會消耗比較多時間
  • GC停頓,爲了保持“一致性”,需要“Stop the world”
  • HotSpot使用一組稱爲OopMap的數據結構來記錄哪些地方存着對象的引用。在類加載過程中,HotSpot就把對象內什麼偏移量上是什麼類型的數據計算出來,在JIT編譯過程中會在特定的位置記錄下棧和寄存器中哪些位置是引用。

安全點

HotSpot沒有爲每條指令都生成OopMap,只是在特定位置記錄了這些信息,這些位置稱爲安全點。程序執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。

安全區域

安全區域是指在一段代碼片段之中,引用關係不會發生變化,在這個區域內的任何地方進行GC都是安全的。可以看成是擴展的安全點。

2.6 垃圾收集器

目前爲止並沒有一個最好的收集器,也沒有萬能的收集器,通常是根據具體情況選擇合適的收集器。

接下來要介紹的收集器如下圖所示,7種收集器分別作用於不同的區域,如果兩個收集器之間存在連線,就說明可以搭配使用。虛擬機所處的位置,代表是屬於新生代收集器還是老年代收集器。

All-GC.jpg

基本概念

1. 並行與併發

  • 並行(Parallel):指多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態。

  • 併發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不一定是並行的,可能會交替執行),用戶程序在繼續運行。而垃圾收集程序運行在另一個CPU上。

2. 吞吐量(Throughput)

吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,即

吞吐量 = 運行用戶代碼時間 /(運行用戶代碼時間 + 垃圾收集時間)

假設虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

3. Minor GC 和 Full GC

新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因爲Java對象大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。

老年代GC(Major GC / Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裏就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。

Serial/Serial Old 收集器

Serial是一個“單線程”的新生代收集器,使用複製算法,它只會使用一個CPU或者一條收集器線程去完成垃圾收集工作,並且它在垃圾收集時,必須暫停所有其他的工作線程,直到它收集結束。“Stop The World”會在用戶不可見的情況下,把用戶的工作線程全部停掉。

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

下圖是 Serial/Serial Old 收集器運行示意圖:

GC-Serial.jpg

上圖中,新生代是Serial收集器採用複製算法老年代是Serial Old收集器採用標記-整理算法。Serial雖然是一個缺點鮮明的收集器,但它依然是虛擬機在Client模式下的默認收集器,它也有優點,比如簡單高效(與其他收集器單線程相比),對於單個CPU來說,Serial由於沒有線程交互的開銷,效率比較高

ParNew 收集器

ParNew收集器是Serial收集器的多線程版本,也是使用複製算法的新生代收集器,它除了使用多條線程進行垃圾收集以外,其他的比如收集器的控制參數、收集算法、Stop-The-World、對象分配規則、回收策略都和Serial收集器完全一樣。

下圖是 ParNew/Serial Old 收集器運行示意圖:

GC-ParNew.jpg

上圖中,新生代是ParNew收集器採用複製算法,老年代是Serial Old收集器採用標記-整理算法。ParNew是許多Server模式下虛擬機的首選新生代收集器,因爲它能與CMS收集器配合工作。CMS收集器是HotSpot虛擬機中第一個併發的垃圾收集器,CMS第一次實現了讓用戶線程與垃圾收集線程同時工作。

Parallel Scavenge(ParallerGC)/ Parallel Old 收集器

Parallel Scavenge也是使用複製算法的新生代收集器,並且也是一個並行的多線程收集器。Parallel收集器跟其它收集器關注GC停頓時間不同,它關注的是吞吐量。低停頓時間適合需要與用戶交互的程序,而高吞吐量可以高效率的利用CPU時間,能儘快完成運算任務,適合用於後臺計算較多而交互較少的任務。

Parallel收集器提供了兩個虛擬機參數用以控制吞吐量,-XX:MaxGCPauseMillis參數可以控制垃圾收集的最大停頓時間,-XX:GCTimeRatio參數可以直接設置吞吐量大小。

-XX:MaxGCPauseMillis的值是一個大於0的毫秒數,使用它減小GC停頓時間是犧牲吞吐量和新生代空間換來的,例如系統把新生代調小,收集300M的新生代肯定比500M的快,這也導致垃圾收集發生的更頻繁,原來10秒收集一次每次停頓100毫秒,現在5秒收集一次每次停頓70毫秒,停頓時間下降了,但是吞吐量也下降了。

-XX:GCTimeRatio的值是一個0到100的整數,通過它我們告訴JVM吞吐量要達到的目標值,-XX:GCTimeRatio=N指定目標應用程序線程的執行時間(與總的程序執行時間)達到N/(N+1)的目標比值。例如,它的默認值是99,就是說要求應用程序線程在整個執行時間中至少99/100是活動的(GC線程佔用其餘的1/100),也就是說,應用程序線程應該運行至少99%的總執行時間。

除這兩個參數外,還有一個參數-XX:-UseAdaptiveSizePolicy值得關注,這是一個開關參數,當它打開之後,就不需要手工指定新生代大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、晉升老年代對象年齡(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據系統的運行情況收集性能監控信息,動態的調整這些參數來提高GC性能,這種調節方式稱爲GC自適應調節策略。這個參數是默認激活的,自適應行爲也是JVM優勢之一。

Parallel Old收集器是Parallel Scavenge的老年代版本,使用多線程和標記-整理算法。此收集器在JDK1.6中開始出現,在Parallel Old出現之前,只有Serial Old能夠與Parallel Scavenge收集器配合使用。由於Serial Old這種單線程收集器的性能拖累,導致在老年代比較大的場景下,Parallel Scavenge和Serial Old的組合吞吐量甚至還不如ParNew加CMS的組合。而有了Parallel Old收集器之後,Parallel Scavenge與Parallel Old成了名副其實的吞吐量優先的組合,在注重吞吐量和CPU資源敏感的場景下,都可以優先考慮這對組合。

下圖是 Parallel Scavenge(ParallerGC)/ Parallel Old 收集器運行示意圖:

GC-Parallel.jpg

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是基於標記-清除算法老年代收集器,它以獲取最短回收停頓時間爲目標。CMS是一款優秀的收集器,特點是併發收集、低停頓,它的運行過程稍微複雜些,分爲4個步驟:

  1. 初始標記(CMS initial mark)
  2. 併發標記(CMS concurrent mark)
  3. 重新標記(CMS remark)
  4. 併發清除(CMS concurrent sweep)

4個步驟中只有初始標記、重新標記這兩步需要“Stop The World”。初始標記只是標記一下GC Roots能直接關聯的對象,速度很快。併發標記是進行GC Roots Tracing的過程,也就是從GC Roots開始進行可達性分析。重新標記則是爲了修正併發標記期間因用戶線程繼續運行而導致標記發生變動的那一部分記錄。併發清理當然就是進行清理被標記對象的工作。

下圖是 CMS 收集器運行示意圖:

GC-CMS.jpg

整個過程中,併發標記與併發清除過程耗時最長,但它們都可以與用戶線程一起工作,所以整體上說,CMS收集器的內存回收過程是與用戶線程一起併發執行的。

但是CMS收集器也並不完美,它有以下3個缺點:

  1. CMS收集時對CPU資源非常敏感,併發階段雖然不會導致用戶線程停頓,但是會因爲佔用CPU資源導致應用程序變慢、總吞吐量變低。
  2. CMS收集器無法處理浮動垃圾(Floating Garbage),可能會產生Full GC。浮動垃圾就是在併發清理階段,依然在運行的用戶線程產生的垃圾。這部分垃圾出現在標記過程之後,CMS無法在當次集中處理它們,只能等下一次GC時清理。
  3. CMS是基於標記-清除算法的收集器,可能會產生大量的空間碎片,從而無法分配大對象而導致Full GC提前產生。

由於存在浮動垃圾,以及用戶線程正在運行,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供併發收集時的程序運作使用。可以使用-XX:CMSInitialOccupyFraction參數調整默認CMS收集器的啓動閾值。要是CMS運行期間預留的內存無法滿足程序需要,就會出現一次“Concurrent Mode Failure”失敗,這時虛擬機將啓動後備預案:臨時啓用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。所以說參數-XX:CMSInitiatingOccupancyFraction設置得太高很容易導致大量“Concurrent Mode Failure”失敗,性能反而降低。
-XX:+UseCMSCompactAtFullCollection用於在CMS收集器頂不住要進行FullGC時開啓內存碎片的合併整理過程,內存整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間不得不變長。-XX:CMSFullGCsBeforeCompaction,這個參數是用於設置執行多少次不壓縮的Full GC後,跟着來一次帶壓縮的(默認值爲0,表示每次進入FullGC時都進行碎片整理)。

G1收集器

G1(Garbage-First)收集器是面向服務端應用的垃圾收集器,它被寄予厚望以用來替換CMS收集器。在G1之前的收集器中,收集的範圍要麼是整個新生代要麼就是老年代,而G1不再從物理上區分新生代老年代,G1可以獨立管理整個Java堆。它將Java堆劃分爲多個大小相等的獨立區域(Region),雖然還有新生代老年代的概念,但不再是物理隔離的,而都是一部分Region(不需要連續)的集合。

與其他收集器相比,G1收集器的特點有:

  • 並行與併發:G1能充分利用多CPU或者多核心的CPU,來縮短Stop The World的停頓時間。
  • 分代收集:雖然G1收集器可以獨立管理整個GC堆,但它能採用不同的方式處理“新對象”和“老對象”,以達到更好的收集效果。
  • 空間整合:G1從整體看是基於標記-整理算法的,從局部看(兩個Region之間)是基於複製算法實現的,這兩個算法在收集時都不會產生空間碎片,這樣就有連續可用的內存用以分配大對象。
  • 可預測的停頓:G1除了追求低停頓外,還能建立可預測的停頓時間模型,可以明確指定一個最大停頓時間(-XX:MaxGCPauseMillis),停頓時間需要不斷調優找到一個理想值,過大過小都會拖慢性能。

G1收集器之所以能建立可預測的停頓時間模型,是因爲它可以避免在整個Java堆中進行全區域的垃圾收集,G1根據各個Region裏垃圾堆積的價值大小(回收所獲空間大小及所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region,這也是Garbage-First名稱的由來。

G1收集器的Region如下圖所示:

GC-G1-Region.jpg

圖中的E代表是Eden區,S代表Survivor,O代表Old區,H代表humongous表示巨型對象(大於Region空間的對象)。從圖中可以看出各個區域邏輯上並不是連續的,並且一個Region在某一個時刻是Eden,在另一個時刻就可能屬於老年代。G1在進行垃圾清理的時候就是將一個Region的對象拷貝到另外一個Region中。

避免全堆掃描:G1中引入了Remembered Set(記憶集)。每個Region中都有一個Remembered Set,記錄的是其他Region中的對象引用本Region對象的關係(誰引用了我的對象)。所以在垃圾回收時,在GC根節點的枚舉範圍中加入Remembered Set即可保證不對全堆掃描也不會有遺漏。G1裏面還有另外一種數據結構叫Collection Set,Collection Set記錄的是GC要收集的Region的集合,Collection Set裏的Region可以是任意代的。在GC的時候,對於跨代對象引用,只要掃描對應的Collection Set中的Remembered Set即可。

G1收集器的收集過程如下圖所示:

G1.jpg

如圖所示,G1收集過程有如下幾個階段:

  • 初始標記(Initial Marking):標記一下GC Roots能關聯到的對象,需要停頓線程但是耗時短,會停頓用戶線程(Stop the World)
  • 併發標記(Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,找出存活對象,這階段耗時長但是可以與用戶線程併發執行。
  • 最終標記(Final Marking):修正在併發標記階段,因用戶線程繼續運行而導致標記產生變動的那一部分標記記錄,這階段需要停頓用戶線程(Stop the World),但是可並行執行。
  • 篩選回收(Live Data Counting and Evacuation):會對各個Region的回收價值和成本進行排序,根據用戶期望的GC停頓時間來制定回收計劃,該階段也是會停頓用戶線程(Stop the World)。

以下是對所有垃圾收集器的總結:

Screen Shot 2019-12-19 at 11.06.27 PM.png

Screen Shot 2019-12-19 at 11.06.40 PM.png

常用的垃圾收集器參數

以下是JVM中常用的垃圾收集器參數:

VM參數 描述
-XX:+UseSerialGC 指定Serial收集器+Serial Old收集器組合執行內存回收
-XX:+UseParNewGC 指定ParNew收集器+Serilal Old組合執行內存回收
-XX:+UseParallelGC 指定Parallel收集器+Serial Old收集器組合執行內存回收
-XX:+UseParallelOldGC 指定Parallel收集器+Parallel Old收集器組合執行內存回收
-XX:+UseConcMarkSweepGC 指定CMS收集器+ParNew收集器+Serial Old收集器組合執行內存回收。優先使用ParNew收集器+CMS收集器的組合,當出現ConcurrentMode Fail或者Promotion Failed時,則採用ParNew收集器+Serial Old收集器的組合
-XX:+UseG1GC 指定G1收集器併發、並行執行內存回收
-XX:+PrintGCDetails 打印GC詳細信息
-XX:+PrintGCTimeStamps 輸出GC的時間戳(以基準時間的形式)
-XX:+PrintGCDateStamps 輸出GC的時間戳(以日期的形式)
-XX:+PrintHeapAtGC 在進行GC的前後打印出堆的信息
-XX:+PrintTenuringDistribution 在進行GC時打印survivor中的對象年齡分佈信息
-Xloggc:$CATALINA_HOME/logs/gc.log 指定輸出路徑收集日誌到日誌文件
-XX:NewRatio 新生代與老年代(new/old generation)的大小比例(Ratio). 默認值爲 2
-XX:SurvivorRatio eden/survivor 空間大小的比例(Ratio). 默認值爲 8
-XX:GCTimeRatio GC時間佔總時間的比率,默認值99%,僅在Parallel Scavenge收集器時生效
-XX:MaxGCPauseMills 設置GC最大停頓時間,僅在Parallel Scavenge收集器時生效
-XX:PretensureSizeThreshold 直接晉升到老年代的對象大小,大於這個參數的對象直接在老年代分配
-XX:MaxTenuringThreshold 提升老年代的最大臨界值(tenuring threshold). 默認值爲 15
-XX:UseAdaptiveSizePolicy 動態調整Java堆中各個區域的大小及進入老年代的年齡
-XX:HandlePromotionFailure 是否允許分配擔保失敗,即老年代的剩餘空間不足以應付新生代整個Eden和Survivor中對象都存活的極端情況
-XX:ParallelGCThreads 設置垃圾收集器在並行階段使用的線程數,默認值隨JVM運行的平臺不同而不同
-XX:ParallelCMSThreads 設定CMS的線程數量
-XX:ConcGCThreads 併發垃圾收集器使用的線程數量. 默認值隨JVM運行的平臺不同而不同
-XX:CMSInitiatingOccupancyFraction 設置CMS收集器在老年代空間被使用多少後觸發垃圾收集,默認68%
-XX:+UseCMSCompactAtFullCollection 設置CMS收集器在完成垃圾收集後是否要進行一次內存碎片的整理
-XX:CMSFullGCsBeforeCompaction 設定進行多少次CMS垃圾回收後,進行一次內存壓縮
-XX:+CMSClassUnloadingEnabled 允許對類元數據進行回收
-XX:CMSInitiatingPermOccupancyFraction 當永久區佔用率達到這一百分比時,啓動CMS回收
-XX:UseCMSInitiatingOccupancyOnly 表示只在到達閥值的時候,才進行CMS回收
-XX:InitiatingHeapOccupancyPercent 指定當整個堆使用率達到多少時,觸發併發標記週期的執行,默認值是45%
-XX:G1HeapWastePercent 併發標記結束後,會知道有多少空間會被回收,再每次YGC和發生MixedGC之前,會檢查垃圾佔比是否達到此參數,達到了纔會發生MixedGC
-XX:G1ReservePercent 設置堆內存保留爲假天花板的總量,以降低提升失敗的可能性. 默認值是 10
-XX:G1HeapRegionSize 使用G1時Java堆會被分爲大小統一的的區(region)。此參數可以指定每個heap區的大小. 默認值將根據 heap size 算出最優解. 最小值爲 1Mb, 最大值爲 32Mb

2.7 內存分配策略

對象優先在Eden區分配

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

大對象直接進入老年代

所謂的大對象是指,需要大量連續內存空間的Java對象,最典型的大對象就是很長的字符串以及數組。大對象對虛擬機的內存分配來說是一個壞消息,經常出現大對象容易導致內存還有不少空間時,就提前觸發GC以獲取足夠的連續空間來安置它們。

虛擬機提供了一個-XX:PretenureSizeThreshold參數,令大於這個設置值的對象直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的內存複製。缺省爲0,表示絕不會直接分配在老年代。

長期存活的對象將進入老年代

虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生,並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且對象年齡設爲1。對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認爲15歲),就將會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置。

動態對象年齡判定

爲了能更好地適應不同程序的內存狀況,虛擬機並不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

空間分配擔保

新生代中有大量的對象存活,survivor空間不夠,當出現大量對象在MinorGC後仍然存活的情況(最極端的情況就是內存回收後新生代中所有對象都存活),就需要老年代進行分配擔保,把Survivor無法容納的對象直接進入老年代.只要老年代的連續空間大於新生代對象的總大小或者歷次晉升的平均大小,就進行Minor GC,否則FullGC。

2.8 Full GC的觸發條件

對於Minor GC,其觸發條件非常簡單,當Eden區空間滿時,就將觸發一次Minor GC。而Full GC則相對複雜,因此本節我們主要介紹Full GC的觸發條件。

  • 調用System.gc()

此方法的調用是建議JVM進行Full GC,雖然只是建議而非一定,但很多情況下它會觸發 Full GC,從而增加Full GC的頻率,也即增加了間歇性停頓的次數。因此強烈建議能不使用此方法就不要使用,讓虛擬機自己去管理它的內存,可通過-XX:+ DisableExplicitGC來禁止RMI調用System.gc()。

  • 老年代空間不足

老年代空間不足的常見場景爲前文所講的大對象直接進入老年代、長期存活的對象進入老年代等,當執行Full GC後空間仍然不足,則拋出如下錯誤:
Java.lang.OutOfMemoryError: Java heap space
爲避免以上兩種狀況引起的Full GC,調優時應儘量做到讓對象在Minor GC階段被回收、讓對象在新生代多存活一段時間及不要創建過大的對象及數組。

  • 空間分配擔保失敗

前文介紹過,使用複製算法的Minor GC需要老年代的內存空間作擔保,如果出現了HandlePromotionFailure擔保失敗,則會觸發Full GC。

  • JDK 1.7及以前的永久代空間不足

在JDK 1.7及以前,HotSpot虛擬機中的方法區是用永久代實現的,永久代中存放的爲一些class的信息、常量、靜態變量等數據,當系統中要加載的類、反射的類和調用的方法較多時,Permanet Generation可能會被佔滿,在未配置爲採用CMS GC的情況下也會執行Full GC。如果經過Full GC仍然回收不了,那麼JVM會拋出如下錯誤信息:
java.lang.OutOfMemoryError: PermGen space
爲避免PermGen佔滿造成Full GC現象,可採用的方法爲增大PermGen空間或轉爲使用CMS GC。

在JDK 1.8中用元空間替換了永久代作爲方法區的實現,元空間是本地內存,因此減少了一種Full GC觸發的可能性。

  • Concurrent Mode Failure

執行CMS GC的過程中同時有對象要放入老年代,而此時老年代空間不足(有時候“空間不足”是CMS GC時當前的浮動垃圾過多導致暫時性的空間不足觸發Full GC),便會報Concurrent Mode Failure錯誤,並觸發Full GC。

2.9 新生代配置實戰

關於新生代的配置,主要有下面三種參數:

-XX:NewSize/MaxNewSize : 新生代的size和最大size,該參數優先級最高。
-Xmn(可以看成NewSize= MaxNewSize):新生代的大小,該參數優先級次高。
-XX:NewRatio: 表示比例,例如=2,表示 新生代:老年代 = 1:2,該參數優先級最低。

還有參數:-XX:SurvivorRatio 表示Eden和Survivor的比值,缺省爲8,表示 Eden:FromSurvivor:ToSurvivor= 8:1:1

下面舉例參數配置進行實戰,程序中生成了10個大小爲1M的數組,

public class NewSize {

    public static void main(String[] args) {
        int cap = 1*1024*1024;//1M
        byte[] b1 = new byte[cap];
        byte[] b2 = new byte[cap];
        byte[] b3 = new byte[cap];
        byte[] b4 = new byte[cap];
        byte[] b5 = new byte[cap];
        byte[] b6 = new byte[cap];
        byte[] b7 = new byte[cap];
        byte[] b8 = new byte[cap];
        byte[] b9 = new byte[cap];
        byte[] b0 = new byte[cap];
    }
}
  1. -Xms20M -Xmx20M -XX:+PrintGCDetails –Xmn2m -XX:SurvivorRatio=2

沒有垃圾回收,數組都在老年代。

Screen Shot 2019-12-20 at 2.49.47 PM.png

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -Xmn7m -XX:SurvivorRatio=2

發生了垃圾回收,新生代存了部分數組,老年代也保存了部分數組,發生了晉升現象。

Screen Shot 2019-12-20 at 2.52.01 PM.png

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -Xmn15m -XX:SurvivorRatio=8

新生代可以放下所有的數組,老年代沒放。

Screen Shot 2019-12-20 at 2.55.46 PM.png

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -XX:NewRatio=2

發生了垃圾回收,出現了空間分配擔保,而且發生了FullGC。

Screen Shot 2019-12-20 at 2.58.30 PM.png

2.10 內存泄漏和內存溢出

  • 內存溢出:實實在在的內存空間不足導致;

  • 內存泄漏:該釋放的對象沒有釋放,多見於自己使用容器保存元素的情況下。

下面舉例說明,例子中實現了一個基本的棧,注意看出棧的部分,爲了幫助GC,當出棧完成後,手動將棧頂的引用清空,有助於後續元素的gc。這裏如果不清空,當元素出棧後,棧頂原來的位置還有該元素的引用,所以可能造成無法對已經出棧的元素進行回收,造成內存泄露。

public class Stack {
    
    public  Object[] elements;
    private int size = 0;//指示器,指示當前棧頂的位置

    private static final int Cap = 16;

    public Stack() {
        elements = new Object[Cap];
    }

    //入棧
    public void push(Object e){
        elements[size] = e;
        size++;
    }

    //出棧
    public Object pop(){
        size = size-1;
        Object o = elements[size];
        elements[size] = null;//help gc
        return o;
    }
    
    public static void main(String[] args) {
        Stack stack = new Stack();
        Object o = new Object();
        System.out.println("o="+o);
        stack.push(o);
        Object o1 =  stack.pop();
        System.out.println("o1="+o1);
        
        System.out.println(stack.elements[0]);
    }
}

2.11 淺堆和深堆

淺堆 :(Shallow Heap)是指一個對象所消耗的內存。例如,在32位系統中,一個對象引用會佔據4個字節,一個int類型會佔據4個字節,long型變量會佔據8個字節,每個對象頭需要佔用8個字節。

深堆 :這個對象被GC回收後,可以真實釋放的內存大小,也就是隻能通過對象被直接或間接訪問到的所有對象的集合。通俗地說,就是指僅被對象所持有的對象的集合。

舉例:對象A引用了C和D,對象B引用了C和E。那麼對象A的淺堆大小隻是A本身,不含C和D,而A的實際大小爲A、C、D三者之和。而A的深堆大小爲A與D之和,由於對象C還可以通過對象B訪問到,因此不在對象A的深堆範圍內。

2.12 jdk工具

jps

列出當前機器上正在運行的虛擬機進程
-p:僅僅顯示VM 標示,不顯示jar,class, main參數等信息.
-m:輸出主函數傳入的參數. 下的hello 就是在執行程序時從命令行輸入的參數
-l: 輸出應用程序主類完整package名稱或jar完整名稱.
-v: 列出jvm參數, -Xms20m -Xmx50m是啓動程序指定的jvm參數

jstat

是用於監視虛擬機各種運行狀態信息的命令行工具。它可以顯示本地或者遠程虛擬機進程中的類裝載、內存、垃圾收集、JIT編譯等運行數據,在沒有GUI圖形界面,只提供了純文本控制檯環境的服務器上,它將是運行期定位虛擬機性能問題的首選工具。

假設需要每250毫秒查詢一次進程2764垃圾收集狀況,一共查詢20次,那命令應當是:jstat-gc 2764 250 20

常用參數:
-class (類加載器)
-compiler (JIT)
-gc (GC堆狀態)
-gccapacity (各區大小)
-gccause (最近一次GC統計和原因)
-gcnew (新區統計)
-gcnewcapacity (新區大小)
-gcold (老區統計)
-gcoldcapacity (老區大小)
-gcpermcapacity (永久區大小)
-gcutil (GC統計彙總)
-printcompilation (HotSpot編譯統計)

jinfo

查看和修改虛擬機的參數jinfo –sysprops 可以查看由System.getProperties()取得的參數
jinfo –flag 未被顯式指定的參數的系統默認值
jinfo –flags(注意s)顯示虛擬機的參數
jinfo –flag +[參數] 可以增加參數,但是僅限於由java -XX:+PrintFlagsFinal –version查詢出來且爲manageable的參數
jinfo –flag -[參數] 可以去除參數
Thread.getAllStackTraces();

jmap

用於生成堆轉儲快照(一般稱爲heapdump或dump文件)。jmap的作用並不僅僅是爲了獲取dump文件,它還可以查詢finalize執行隊列、Java堆和永久代的詳細信息,如空間使用率、當前用的是哪種收集器等。和jinfo命令一樣,jmap有不少功能在Windows平臺下都是受限的,除了生成dump文件的-dump選項和用於查看每個類的實例、空間佔用統計的-histo選項在所有操作系統都提供之外,其餘選項都只能在Linux/Solaris下使用。
jmap -dump:live,format=b,file=heap.bin
Sun JDK提供jhat(JVM Heap Analysis Tool)命令與jmap搭配使用,來分析jmap生成的堆轉儲快照。

jhat

jhat dump文件名
後屏幕顯示“Server is ready.”的提示後,用戶在瀏覽器中鍵入http://localhost:7000/就可以訪問詳情.

jstack

(Stack Trace for Java)命令用於生成虛擬機當前時刻的線程快照。線程快照就是當前虛擬機內每一條線程正在執行的方法堆棧的集合,生成線程快照的主要目的是定位線程出現長時間停頓的原因,如線程間死鎖、死循環、請求外部資源導致的長時間等待等都是導致線程長時間停頓的常見原因。
在代碼中可以用java.lang.Thread類的getAllStackTraces()方法用於獲取虛擬機中所有線程的StackTraceElement對象。使用這個方法可以通過簡單的幾行代碼就完成jstack的大部分功能,在實際項目中不妨調用這個方法做個管理員頁面,可以隨時使用瀏覽器來查看線程堆棧。

jconsole

Java提供的GUI監視與管理平臺。

visualvm

和jconsole類似,但是通過插件擴展,可以具備遠優於jconsole的可視化功能。


參考:

  • http://www.cellei.com/blog/2018/04251
  • https://crowhawk.github.io/2017/08/15/jvm_3/
  • https://meandni.com/2019/01/11/jvm_note2/
  • https://juejin.im/post/5d7ba549e51d453b5e465bd4#heading-13

    本文由『後端精進之路』原創,首發於博客 http://teckee.github.io/ , 轉載請註明出處

搜索『後端精進之路』關注公衆號,立刻獲取最新文章和價值2000元的BATJ精品面試課程

後端精進之路.png

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