1. 程式人生 > >深入理解Java虛擬機器 讀書筆記——垃圾收集器與記憶體分配策略

深入理解Java虛擬機器 讀書筆記——垃圾收集器與記憶體分配策略

第3章 垃圾收集器與記憶體分配策略

關於Java中的引用型別

  1. 強引用(Strong Reference):Object obj = new Object(); 這樣的常規引用,只要引用還在,就永遠不會回收物件。
  2. 軟引用(Soft Reference):在發生記憶體溢位之前,進行回收,如果這次回收之後還沒有足夠的記憶體,則報OOM。
  3. 弱引用(Weak Reference):生存到下一次垃圾回收之前,無論當前記憶體是否夠用,都回收掉被弱引用關聯的物件。
  4. 虛引用(Phantom Reference):卵用沒有的引用,完全不會對物件的生命週期有任何影響,也無法通過它得到物件的例項,唯一的作用也就是在物件被垃圾回收前收到一個系統通知。

垃圾回收演算法

JAVA堆

執行緒共享的,存放所有物件例項和陣列。垃圾回收的主要區域。可以分為新生代和老年代(tenured)。
新生代用於存放剛建立的物件以及年輕的物件,如果物件一直沒有被回收,生存得足夠長,老年物件就會被移入老年代。
新生代又可進一步細分為eden、survivorSpace0(s0,from space)、survivorSpace1(s1,to space)。剛建立的物件都放入eden,s0和s1都至少經過一次GC並倖存。如果倖存物件經過一定時間仍存在,則進入老年代(tenured)。
堆空間結構

方法區

執行緒共享的,用於存放被虛擬機器載入的類的元資料資訊:如常量、靜態變數、即時編譯器編譯後的程式碼。也稱為永久代

。如果hotspot虛擬機器確定一個類的定義資訊不會被使用,也會將其回收。回收的基本條件至少有:所有該類的例項被回收,而且裝載該類的ClassLoader被回收。

垃圾回收演算法

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

從根節點開始標記所有可達物件,其餘沒標記的即為垃圾物件,執行清除。但回收後的空間是不連續的。

複製演算法(copying)

將記憶體分成兩塊,每次只使用其中一塊,垃圾回收時,將標記的物件拷貝到另外一塊中,然後完全清除原來使用的那塊記憶體。複製後的空間是連續的。複製演算法適用於新生代,因為垃圾物件多於存活物件,複製演算法更高效。
在新生代序列垃圾回收演算法中,將eden中標記存活的物件拷貝未使用的s1中,s0中的年輕物件也進入s1,如果s1空間已滿,則進入老年代;這樣交替使用s0和s1,交替使用s0+eden和s1+eden,也就是說交替使用s0配合eden往s1裡面懟,或者使用s1配合eden往s0裡面懟。這種改進的複製演算法,既保證了空間的連續性,又避免了大量的記憶體空間浪費。
複製演算法過程


對複製演算法進一步優化:使用Eden/S0/S1三個分割槽
平均分成A/B塊太浪費記憶體,採用Eden/S0/S1三個區更合理,空間比例為Eden:S0:S1==8:1:1,有效記憶體(即可分配新生物件的記憶體)是總記憶體的9/10。
演算法過程:
1. Eden+S0可分配新生物件;
2. 對Eden+S0進行垃圾收集,存活物件複製到S1。清理Eden+S0。一次新生代GC結束。
3. Eden+S1可分配新生物件;
4. 對Eden+S1進行垃圾收集,存活物件複製到S0。清理Eden+S1。二次新生代GC結束。
5. goto 1。

標記-壓縮演算法(Mark-compact)

適合用於老年代的演算法(存活物件多於垃圾物件)。
標記後不復制,而是將存活物件壓縮到記憶體的一端,然後清理邊界外的所有物件。
複製-壓縮演算法過程

HotSpot的演算法實現

GC Roots節點主要在全域性性引用(常量或類靜態屬性)與執行上下文中(棧幀中的本地變量表)。如之前提過的:

JVM對那些沒有根引用的物件進行來及回收,也就是無法從根物件中追述的物件。

JVM垃圾回收的根物件的範圍有以下幾種:

1、棧中引用的物件,引用是在棧幀中的本地變量表中的,真正的物件在堆中

2、方法區perm中的類靜態屬性引用的物件,以及常量引用的物件

3、本地方法棧中JNI(Native方法)的引用的物件

可達性分析對時間的敏感體現在GC停頓上。在物件引用關係還在不斷變化的時候是沒辦法愉快的進行GC的,所以GC進行時必須停頓所有Java執行執行緒(GC會發動大招——The World!“Stop-The-World”)。
所以這就是頻繁觸發GC會卡的一比的原因,但是HotSpot也沒有那麼蠢,它會在類載入完成的時候計算出來裡面的型別(使用OopMap,一種資料結構),也會在特定位置記錄棧和暫存器中哪些位置是引用。

垃圾收集器

一下這些垃圾收集器每個都夠我玩一年的,所以只是簡單的介紹一下得了。每個都可以寫一本厚厚的書了。

Serial收集器

Serial收集器是最基本、發展歷史最悠久的收集器。在它進行垃圾收集時,必須暫停其它所有的工作執行緒(發動大招Stop-The-World),簡單而且高效,是垃圾收集器的基本。

ParNew收集器

就是Serial收集器的多執行緒版本,除了多執行緒以外跟Serial收集器沒啥區別。

Parallel Scavenge收集器

CMS等收集器的目標是縮短垃圾收集時使用者執行緒的停頓時間,而Parallel Scavenge收集器的目標是達到一個可控的吞吐量(Throughoutput)。
吞吐量=執行使用者程式碼時間/(執行使用者程式碼時間+垃圾收集時間)

Serial Old收集器

是Serial收集器的老年代版本,使用“標記-整理”演算法。

Parallel Old收集器

是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。

CMS(Concurrent Mark Sweep)收集器

以獲取最短回收停頓時間為目的的收集器。基於“標記-清除”演算法。
分為4個步驟:

  • 初始標記
  • 併發標記
  • 重新標記
  • 併發清除

初始標記標記GC Roots能夠直接關聯到的物件,併發標記進行GC Roots Tracing,重新標記修正併發標記期間變動的那一部分物件,最後執行併發清除。
耗時最長的併發標記和併發清除步驟都是與使用者工作執行緒一起工作的,所以很快。

  • 優點:併發收集,低停頓。
  • 缺點:
    對CPU資源敏感。
    無法處理浮動垃圾,伴隨CMS程式執行時產生的新的垃圾,所以必須預留足夠的記憶體空間給使用者使用。
    因為基於“標記-清除”演算法,所以會產生碎片,碎片過多會觸發Full GC,Full GC會卡頓。

G1(Garbage-First)收集器

當今收集器技術發展的最前沿成果之一,屌的一比,但是很難理解。
特點:並行與併發;分代收集;空間整合;可預測的停頓。
之所以能建立可預測的停頓時間模型,是因為它可以避免在整個Java堆中進行全區域的垃圾收集,跟蹤各個Region裡面垃圾堆積的價值大小,後臺維護一個優先列表,優先回收價值最大的。

記憶體分配與回收策略

物件優先分配在eden分割槽

Eden分割槽沒有足夠空間時,虛擬機器將發起一次MinorGC
在下面的程式碼中,我們設定了堆的最大空間和最小空間都是20M,也就是說堆不可擴充套件。
然後設定了新生代空間為10M,剩下的10M就是老年代,XX:SurvivorRatio=8設定了Eden區與一個Survivor區的大小比例是8:1。
所以最後的結果應該是eden區是8M,s0是1M,s1是1M,old區是10M。
例項程式碼如下:

public class TestAllocation {

    private static final int _1MB = 1024 * 1024;

    /**
     * VM引數:-verbose:gc -Xmx20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     */
    public static void testAllocation(){
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];   //出現一次Minor GC
    }

    public static void main(String[] args) {
        testAllocation();
    }
}

執行結果:

Heap PSYoungGen total 9216K, used 7292K [0x00000000ff600000,
0x0000000100000000, 0x0000000100000000) eden space 8192K, 89% used
[0x00000000ff600000,0x00000000ffd1f058,0x00000000ffe00000) from
space 1024K, 0% used
[0x00000000fff00000,0x00000000fff00000,0x0000000100000000) to
space 1024K, 0% used
[0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000) ParOldGen
total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000,
0x00000000ff600000) object space 10240K, 40% used
[0x00000000fec00000,0x00000000ff000010,0x00000000ff600000) Metaspace
used 2579K, capacity 4486K, committed 4864K, reserved 1056768K class
space used 287K, capacity 386K, committed 512K, reserved 1048576K

由於使用的是JDK1.8,所以結果和書中有所不同,至於為什麼會這樣,還在調查中。

大物件直接進入老年代

大物件就是那種很長的字串和陣列(例如前面的byte陣列),大物件對於虛擬機器來說是一個壞訊息,尤其是那些“朝生夕滅”的“短命大物件”,寫程式時應當避免,因為大物件會導致還有不少空間就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。
虛擬機器提供了一個-XX:PretenureSizeThredshold引數,大於這個引數的物件直接在老年代中分配,避免在Eden區和兩個survivor區中發生大量記憶體複製(新生代採用複製演算法)

長期存活的物件將進入老年代

虛擬機器給每個物件設定了一個年齡計數器,物件在Eden區出生,經歷一次Minor GC之後會進入Survivor區,並且年齡變為1,在Survivor區中每“熬”過一次GC就成長一歲,然後成長到15歲(預設)就老了,就會進入老年代。這個進老年代的閾值可以通過引數-XX:MaxTenuringThredshold設定。

動態物件年齡判定

死板的設定老年閾值為15可能有些死板,所以虛擬機器還有比較動態的方法。如果在Survivor空間中相同年齡的物件大小總和大於Survivor空間的一半,那這個年齡也算是老年了,後續年齡大於等於該年齡的物件就可以直接扔到老年代。(一個國家平均年齡在30歲,壽命才40,那對於這個國家30歲以上就該退休了。如果某國屌的一比,平均年齡250,壽命能到300,那不得250歲再退休啊)

空間分配擔保

Minor GC之前,虛擬機器會先檢查老年代的最大可用連續空間是否能裝下當前新生代的所有物件,如果成立,這個Minor GC才確保是安全的。因為就怕Minor GC之後,一個也沒清理掉,而且Survivor也裝不下,都tm要往老年代懟,老年代如果裝不下就會觸發Full GC,就麻煩了。
如果Minor GC是安全的還好,如果Minor GC不安全,虛擬機器就會檢視HandlePromotionFailure設定值是否允許擔保失敗。
一般為了避免Full GC過於頻繁,都是會允許擔保失敗的。
如果允許擔保失敗,就會檢查老年代最大可用的連續空間是否大於歷次晉級到老年代的物件的平均大小(動態概率的手段,也就是通過以往晉升到老年代的物件的經驗,來猜測下次能不能裝下),如果猜測可以,則嘗試進行一次Minor GC,如果猜測不可以,那沒辦法了,直接Full GC吧。
像這種擔保類似貸款的模式,如果好好的突然某次Minor GC之後存活的物件突增,那就擔保失敗唄,只能再重新發起一次Full GC,浪費的時間是最多的,但是沒辦法,人生就是一場博弈。如果Full GC也沒空間了,那就OOM,徹底GG。
在JDK1.6 Update24之後,HandlePromotionFailure這個引數已經廢了,直接就用動態概率來決定下一步是嘗試Minor GC,還是直接放棄就Full GC了。

總結

記憶體回收與垃圾收集器對系統性能、併發能力的影響很大,虛擬機器也提供了大量的引數來調節它,沒有最好的方案,只能是通過結合實際的應用需求、實現方式選擇最優的收集方式才能獲取最高的效能。
所以如果想要實際虛擬機器調優,需要對每個具體收集器的行為、優勢和劣勢、調節引數有著深入的瞭解。這也是牛X的高階高併發Java工程師和一般的low b碼農的差別。