深入理解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碼農的差別。

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