• 您的位置:首頁 >聚焦 >

    一文看懂 Java GC 算法背景原理與內存池劃分

    2022-04-06 07:00:55    來源:程序員客棧
    你知道的越多,不知道的就越多,業余的像一棵小草!

    你來,我們一起精進!你不來,我和你的競爭對手一起精進!

    編輯:業余草

    blog.csdn.net/qq_34115899

    推薦:https://www.xttblog.com/?p=5336

    一文看懂 Java GC 算法背景原理與內存池劃分!

    文章目錄引用計數標記-清除算法(Mark and Sweep)標記可達對象(Marking Reachable Objects)清除(Sweeping)標記-清除-整理算法(Mark-Sweep-Compact)分代假設內存池劃分年輕代(Young Gen)老年代 (Old Gen)永久代 (Perm Gen)5.4 元數據區 (Metaspace)引用計數

    通過在對象頭中分配一個空間來保存該對象被引用的次數。如果該對象被其它對象引用,則它的引用計數加一,如果刪除對該對象的引用,那么它的引用計數就減一。(一般不是一個對象被引用的次數為0了就立即釋放,出于效率考慮,系統總是會等一批對象一起處理,這樣更加高效)

    如果A對象引用B對象,B對象引用A對象,引用計數始終不為0,這種循環依賴的對象沒辦法回收。

    這種情況在計算機中叫做“ 內存泄漏 ”,該釋放的沒釋放,該回收的沒回收。如果依賴關系更復雜,計算機的內存資源很可能用滿,或者說不夠用,內存不夠用則稱為“ 內存溢出 ”。


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

    前面我們講解了引用計數里需要查找所有的對象計數和對象之間的引用關系。那么如何來查找所有對象,怎么來做標記呢?

    這個算法包含兩步:

    Marking(標記): 遍歷所有的可達對象,并在本地內存(native)中分門別類記下。Sweeping(清除): 這一步保證不可達對象所占用的內存被清除,在之后進行內存分配時可以重用。標記可達對象(Marking Reachable Objects)

    首先,有一些特定的對象被指定為 Garbage Collection Roots(GC根元素)。包括:

    局部變量活動線程(Active threads)內存中所有類的靜態字段(static field)JNI引用

    如上圖,從GC「根元素」開始掃描,到直接引用,以及其他對象(通過對象的屬性域)。所有GC訪問到的對象都被「標記」(marked)為存活對象。

    存活對象在上圖中用藍色表示。標記階段完成后,所有存活對象都被標記了。而其他對象就是從GC根元素不可達的,也就是說程序不能再使用這些不可達的對象(unreachable object)。這樣的對象被認為是垃圾,GC會在接下來的階段中清除他們。

    「標記清除算法最重要的優點,就是解決了引用計數的循環引用而導致內存泄露的問題,因為只標記可達對象,如果一系列對象形成了環,但這個環上的所有對象都是不可達的,那么在引用跟蹤的可達對象集合里就不包括環,環上的對象都屬于不可達對象,都會被清除。」

    JVM中包含了多種GC算法,如Parallel Scavenge(并行清除),Parallel Mark+Copy(并行標記+復制) 以及 CMS,他們在實現上略有不同,但理論上都采用了以上兩個步驟。

    這種方法也有不好的地方:在標記階段,需要暫停所有應用線程,去遍歷所有對象的引用關系,因為不暫停就沒法跟蹤一直在變化的引用關系圖。這種情景叫做 「Stop The World pause」 (「STW全線停頓」),也叫做GC停頓。有一個概念需要注意,可以安全地暫停線程的點叫做「安全點」(safe point)。GC過程中,有一部分操作需要等所有應用線程都到達安全點,然后暫停所有線程進行GC,這樣JVM就可以專心執行清理工作。安全點可能有多種因素觸發,但GC是最主要的原因。

    標記階段暫停的時間與堆內存大小、對象的總數沒有直接關系,而是由「存活對象」(alive objects)的數量來決定。所以增加堆內存的大小并不會直接影響標記階段占用的時間。

    清除(Sweeping)

    Mark and Sweep(標記-清除) 算法的概念非常簡單,在標記階段完成后,所有不可達對象占用的內存空間會被清除,可以用來分配新對象。

    這種算法需要使用空閑表(free-list)來記錄所有的空閑區域,以及每個區域的大小。維護空閑表增加了對象分配時的開銷。此外還存在另一個弱點 —— 明明還有很多空閑內存,但是卻都不連續,導致寫入操作越來越耗時,因為尋找一塊足夠大的空閑內存變得困難,可能最后沒有一個區域的大小能夠存放需要分配的對象,從而導致內存分配失敗(在Java中就是 OutOfMemoryError),這個就叫「內存的碎片化」。就像是擺滿棋子的圍棋盤上,一部分位置上棋子被拿掉而產生了一些零散的空位置,沒有一整片的空地方。


    標記-清除-整理算法(Mark-Sweep-Compact)

    這個算法就是在標記-清除算法的基礎上多了整理(Compact)這一步。也就是將所有被標記的對象(存活對象),遷移到內存空間的起始處,「消除了標記-清除算法的缺點」。

    但是也有缺點,是GC暫停時間會增加,因為需要將所有對象復制到另一個地方,然后修改指向這些對象的引用,這也是需要額外時間的。

    ?

    JVM中的引用是一個抽象的概念,如果GC移動某個對象,就會修改(棧和堆中)所有指向該對象的引用。

    移動/拷貝/提升/壓縮 一般來說是一個 STW的過程,所以修改對象引用是一個安全的行為。但因為要更新所有的引用,可能會影響應用程序的性能。

    ?

    碎片整理之后,分配新對象就很簡單,只需要通過「指針碰撞」(pointer bumping)即可。使用這種算法,內存空間剩余的容量一直是清楚的,不會再導致內存碎片問題。

    ?

    指針碰撞:假設Java堆中內存時完整的,已分配的內存和空閑內存分別在不同的一側,通過一個指針作為分界點,需要分配內存時,僅僅需要把指針往空閑的一端移動與對象大小相等的距離。

    ?

    在這里思考一個問題,任何一種GC算法能否高效的管理當前堆內存的所有對象?答案是否定的,因為對象的生命周期都不同,有的很快被清除,有的還會存活很長時間。于是就有了「分代假設理論」…


    分代假設

    我們前面提到過,執行垃圾收集需要停止整個應用。很明顯,對象越多則收集所有垃圾消耗的時間就越長。但可不可以只處理一個較小的內存區域呢? 為了探究這種可能性,做了一個分析圖。

    于是便有了分代假設:

    大部分新生對象很快無用;存活較長時間的對象,可能存活更長時間。

    我們可以根據對象的不同特點,把對象進行分類。基于這一假設,VM中的內存被分為「年輕代」(Young Generation)和「老年代」(Old Generation)。老年代有時候也稱為年老區(Tenured)。

    拆分為這樣兩個可清理的單獨區域,我們就可以「根據對象的不同特點,允許采用不同的算法來大幅提高GC的性能。」

    要著重強調的是,分代假設并不適用于所有程序。因為分代GC算法專門針對“要么死得快”,“否則活得長” 這類特征的對象來進行優化,此時JVM管理那種存活時間半長不長的對象就顯得非常尷尬了。


    內存池劃分

    我們把新對象的創建放在年輕代,把長期存活的對象放在老年代。這樣就可以用不同的策略去優化這兩塊對象內存的管理方式。

    年輕代(Young Gen)

    「年輕代」可以分為**新生代 (Eden Space)和兩個存活區(Survivor Spaces)——S0和S1**,新生代也叫Eden區,是分配創建新對象的地方。

    當Eden區要滿的時候,就會做一次垃圾回收,在垃圾回收的階段會先標記存活對象,然后會把Eden區里存活的對象復制到其中一個存活區,假設是S0。這個時候隨著程序的運行,對象繼續創建,因為S0在上個階段已經往里面復制了一部分對象,這時Eden區再次要滿的時候,就會把Eden區和S0里存活的對象復制到另一個存活區S1,復制完后,Eden區和S0區所有的對象占用的內存都是可以被釋放的,所以直接把Eden區和S0給清空掉。

    然后在下一個使用周期繼續在Eden區創建對象,Eden區再滿的時候,就會把Eden區和S1區里的存活對象復制到S0,再把Eden和S1清空掉。需要注意的時,通常Eden區、S0區、S1區中都只有兩個區域有數據,另外一個存活區是空的。年輕代絕大部分對象會在垃圾回收的時候被清理掉,只有少量對象會存活,所以每次垃圾回收只要把這少量對象標記出來,然后復制到其中一個存活區即可。

    ?

    每個周期里都會把少量對象從一個存活區復制到另一個存活區,所以這兩個存活區除了叫S0和S1以外,也可以叫from和to,復制的起點存活區叫from,目標存活區叫to

    ?

    注意:這里用到的算法是「標記-復制」算法,不需要做移動。因為復制了一份,原有的數據可以清空掉。如下圖:

    細心地小伙伴肯定發現了,圖中怎么還會有一個TLAB的區域?

    因為會有多個線程同時創建多個對象,所以 Eden區被劃分為多個線程本地分配緩沖區(Thread Local Allocation Buffer,簡稱TLAB)。

    通過這種緩沖區劃分,大部分對象直接由JVM在對應線程的TLAB中分配,避免與其他線程的同步操作。

    如果 TLAB中沒有足夠的內存空間,就會在共享Eden區(shared Eden space)中分配。如果共享Eden區也沒有足夠的空間,就會觸發一次年輕代GC來釋放內存空間。如果GC之后 Eden區依然沒有足夠的空閑內存區域,則對象就會被分配到老年代空間(Old Generation)。

    老年代 (Old Gen)

    從上圖可以看到,在GC后,對象有一部分去了老年代,這個細節很重要。

    如果對象經歷了一定的GC次數后仍然存活,那么它們就會挪到老年代。比如默認情況下是15次(這也是HotSpot JVM中允許的最大值),通過-XX: +MaxTenuringThreshold=15設置最大老年代的閾值。因為對象存活周期超過15次,有很大概率這個對象會繼續存活很多代,所以放在老年代。

    「如果存活區空間不夠存放年輕代中的存活對象,對象提升(Promotion)到老年代的步驟也可能更早地進行。」

    老年代內存空間通常會更大,里面的對象是垃圾的概率也更小。老年代GC發生的頻率比年輕代小很多。同時,因為預期老年代中的對象大部分是存活的,所以不再使用標記和復制(Mark and Copy)算法。而是采用移動對象的方式來實現最小化內存碎片。老年代空間的清理算法通常是建立在不同的基礎上的。原則上,會執行以下這些步驟

    先標記所有的根可達對象刪除不可達對象整理老年代空間里存活的對象,方法是將所有的存活對象復制,從老年代空間開始的地方依次存放

    整理的方法就是把所有存活對象進行復制,移動到老年代空間開始的地方,依次往后對齊存放,也就是做了內存的碎片整理。

    「為什么用這種復制移動方式處理?」 因為老年代沒有繼續分區,沒辦法像年輕代一樣有兩個存活區交替進行復制清除,只能進行整理,避免碎片過多。

    永久代 (Perm Gen)

    在Java 8之前有一個特殊的空間,稱為“永久代”(Permanent Generation)。這是存儲元數據(metadata)的地 方,比如 class信息等。此外,「這個區域中也保存有其他的數據和信息,包括內部化的字符串(internalized strings)等等。實際上這給Java開發者造成了很多麻煩,因為很難去計算這塊區域到底需要占用多少內存空間。」 預測失敗導致的結果就是產生 java.lang.OutOfMemoryError: Permgen space這種形式的錯誤。除非OutOfMemoryError確實是內存泄漏導致的,否則就只能增加 permgen的大小,例如下面的示例,就 是設置 perm gen最大空間為 256 MB:

    -XX:MaxPermSize=256m

    元數據區 (Metaspace)

    既然估算元數據所需空間那么復雜,Java 8直接刪除了永久代(Permanent Generation),改用 Metaspace。從此以后,Java中很多雜七雜八的東西都放置到普通的堆內存里。當然,像類定義(class definitions)之類的信息會被加載到 Metaspace中。元數據區位于本地內存(native memory),不再影響到普通的Java對象。默認情況下,Metaspace的大小只受限于 Java進程可用的本地內存。這樣程序就不再因為多加載了幾個類/JAR包就導致 java.lang.OutOfMemoryError: Permgen space.。注意,這種不受限制的空間也不是沒有代價的 —— 如果 Metaspace失控,則可能會導致嚴重影 響程序性能的內存交換(swapping),或者導致本地內存分配失敗。如果需要避免這種最壞情況,那么可以通過下面這樣的方式來限制 Metaspace的大小,如 256 MB

    -XX:MaxMetaspaceSize=256m


    歡迎一鍵三連~

    有問題請留言,大家一起探討學習

    關鍵詞: 對象引用 垃圾回收

    相關閱讀

    BB电子