GC垃圾回收機制: 淺析與理解

言己發表於2017-01-12

對垃圾回收進行分析前,我們先來了解一些基本概念

基本概念

  • 記憶體管理:記憶體管理對於程式語言至關重要。彙編允許你操作所有東西,或者說要求你必須全權處理所有細節更合適。C 語言中雖然標準庫函式提供一些記憶體管理支援,但是對於之前呼叫 malloc 申請的記憶體,還是依賴於你親自 free 掉。從C++、Python、Swift 和 Java 開始,才在不同程度上支援記憶體管理。
  • 記憶體壓縮:對記憶體碎片進行壓縮。(和win10的那個“記憶體壓縮”不太一樣啦)
  • win10記憶體壓縮:實體記憶體已經見底,將一部分不常使用的記憶體資料打包壓縮起來,等到有程式需要訪問那些資料的時候,再解壓縮出來。
  • 引用與指標:
    1. 引用被建立的同時必須被初始化(指標則可以在任何時候被初始化)。
    2. 不能有NULL 引用,引用必須與合法的儲存單元關聯(指標則可以是NULL)。
    3. 一旦引用被初始化,就不能改變引用的關係(指標則可以隨時改變所指的物件)。
    4. 引用只是某塊記憶體的別名。
    5. 實際上“引用”可以做的任何事情“指標”也都能夠做,為什麼還要“引用” 這東西? 答案是“用適當的工具做恰如其分的工作”。比如說,某人需要一份證明,本來在檔案上蓋 上公章的印子就行了,如果把取公章的鑰匙交給他,那麼他就獲得了不該有的權利。(什麼情況下,就用什麼對策)
    6. 為什麼還要說“只有指標,沒有引用是一個重要改變?”?答案是雖然引用在某些情況下好用,但他也會導致致命錯誤。如下:

    這是非常有害的,毫無疑問。結果將是不確定的(編譯器能產生一些輸出,導致任何事情都有可能發生),應該躲開寫出這樣程式碼的人除非他們同意改正錯誤。如果你擔心這樣的程式碼會出現在你的軟體裡,那麼你最好完全避免使用引用,要不然就去讓更優秀的程式設計師去做。

    1. 最後上附圖,幫助理解
      951959132-57551c91b8c7e_articlex
  • 堆(heap)和棧(stack)
    1. 平常說的“堆疊”其實是棧。
    2. 棧,就是那些由編譯器在需要的時候分配,在不需要的時候自動清除的變數的儲存區。裡面的變數通常是區域性變數、函式引數等。
    3. 堆,就是那些由new分配的記憶體塊,他們的釋放編譯器不去管,由我們的應用程式去控 制,一般一個new就要對應一個delete。如果程式設計師沒有釋放掉,那麼在程式結束後,作業系統會自動回收。
  • 程式的棧結構
    1. 程式的地址空間佈局: 程式執行靠四個東西:程式碼、棧、堆、資料段。程式碼段主要存放的就是可執行檔案(通常可執行檔案內,含有以二進位制編碼的微處理器指令,也因此可執行檔案有時稱為二進位制檔案)中的程式碼;資料段存放的就是程式中全域性變數和靜態變數;堆中是程式的動態記憶體區域,當程式使用malloc或new得到的記憶體是來自堆的;棧中維護的是函式呼叫的上下文,離開了棧就不可能實現函式的呼叫。
    2. 棧幀: 也叫活動記錄,儲存的是一個函式呼叫所需要維護的所有資訊。如下: 1.函式的返回地址和引數

    2.臨時變數:包括函式的非靜態區域性變數以及編譯器自動生成的其它臨時變數 3.儲存的上下文:包括在函式呼叫前後需要儲存不變的暫存器值

    1. 就是它,先上圖
      1274163861-57551bae31bcf_articlex]

    1.返回地址:一個main函式中斷執行的執行點.
    2.ebp:指向函式活動記錄的一個固定位置,ebp又被稱為幀指標.固定位置是,這樣在函式返回的時候,ebp就可以通過這個恢復到呼叫前的值。
    3.esp始終指向棧頂,因此隨著函式的執行,它總是變化的。
    4.入棧順序:先壓此次呼叫函式引數入棧,接著是main函式返回地址,然後是ebp等暫存器。

    1. Link:C程式的函式棧作用機理(這個講得好,有例項,所以不再熬述)

這裡我們對比了解不同的 “找到需要標記的物件”的方法

可回收物件的判定

  • 引用計數法

給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時, 計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。如下圖所示:
2670883414-575570832981b_articlex

  • 可達性分析演算法(根搜尋演算法)

從GC Roots(每種具體實現對GC Roots有不同的定義)作為起點,向下搜尋它們引用的物件,可以生成一棵引用樹,樹的節點視為可達物件,反之視為不可達。如下圖所示:
2741760332-575571dcc5c67_articlex

  • 接下來補充幾個概念幫助理解(以java為例):
  1. GC Roots物件:

    上述如圖,關於root區域的詳細解釋參考這裡

4193018156-57565ada2975d_articlex


這裡我們介紹幾種不同的 “標記物件”的方法

可回收物件的標記

  • 保守法將所有堆上對齊的字都認為是指標,那麼有些資料就會被誤認為是指標。於是某些實際是數字的假指標,會背誤認為指向活躍物件,導致記憶體洩露(假指標指向的物件可能是死物件,但依舊有指標指向——這個假指標指向它)同時我們不能移動任何記憶體區域。
  • 編譯器提示法如果是靜態語言,編譯器能夠告訴我們每個類當中指標的具體位置,而一旦我們知道物件時哪個類例項化得到的,就能知道物件中所有指標。這是JVM實現垃圾回收的方式,但這種方式並不適合JS這樣的動態語言
  • 標記指標法標記指標法:這種方法需要在每個字末位預留一位來標記這個欄位是指標還是資料。這種方法需要編譯器支援,但實現簡單,而且效能不錯。V8採用的是這種方式。
  • 點陣圖標記(Go語言為例)
  1. 非侵入式標記位定義
    既然垃圾回收演算法要求給物件加上垃圾回收的標記,顯然是需要有標記位的。一般的做法
    會將物件結構體中加上一個標記域,一些優化的做法會利用物件指標的低位進行標記,這
    都只是些奇技淫巧罷了。Go沒有這麼做,它的物件和C的結構體物件完全一致,使用的是
    非侵入式的標記位。
  2. 具體實現
    堆區域對應了一個標記點陣圖區域,堆中每個字(不是byte,而是word)都會在標記位區域
    中有對應的標記位。每個機器字(32位或64位)會對應4位的標記位。因此,64位系統中
    相當於每個標記點陣圖的位元組對應16個堆中的位元組。雖然是一個堆位元組對應4位標記位,但標記點陣圖區域的記憶體佈局並不是按4位一組,而是
    16個堆位元組為一組,將它們的標記位資訊打包儲存的。每組64位的標記點陣圖從上到下依
    次包括:

    這樣設計使得對一個型別的相應的位進行遍歷很容易。

    前面提到堆區域和堆地址的標記點陣圖區域是分開儲存的,其實它們是以
    mheap.arena_start地址為邊界,向上是實際使用的堆地址空間,向下則是標記點陣圖區
    域。以64位系統為例,計算堆中某個地址的標記位的公式如下:

    然後就可以通過 (標記位 & 垃圾回收標記位),(標記位 & 分配位),等來測試相應的位。

    (也就是說,本來64位是一個字,需要4位標記位。但是,為了與字長相對,16個標記位
    放一起(剛好一個字長)一起表示16個字。並且每類標記位都放在一起
    AA..AABB…BB)

  • 接下來補充幾個概念幫助理解:
  1. 為什麼要判斷哪些是資料,哪些是指標?
    假設堆中有一個long的變數,它的值是8860225560。但是我們不知道它的型別是
    long,所以在進行垃圾回收時會把個當作指標處理,這個指標引用到了0x2101c5018位
    置。假設0x2101c5018碰巧有某個物件,那麼這個物件就無法被釋放了,即使實際上已
    經沒任何地方使用它。由於沒有型別資訊,我們並不知道這個結構體成員不包含指標,因此我們只能對結構體
    的每個位元組遞迴地標記下去,這顯然會浪費很多時間。
    (能不能清除 變成了概率事件)。
  2. 垃圾收集器(CMS收集器為例) 幾個階段:初始標記
    併發標記
    最終標記
    篩選回收初始標記僅僅是標記一下GC Roots能直接關聯到的物件,速度很快,併發標記就是進行
    GC Roots Trancing的過程,而重新標記階段則是為了修正併發標記期間因使用者程式繼
    續執行而導致標記產生變動那一部分物件的標記記錄,這個階段的停頓時間比初始標記稍
    長一些,但遠比並發標記時間短。
  3. stop the world
    因為垃圾回收的時候,需要整個的引用狀態保持不變,否則判定是垃圾,等我稍後回
    收的時候它又被引用了,這就全亂套了。所以,GC的時候,其他所有的程式執行處於暫停
    狀態,卡住了。
    這個概念提前引入,在這裡進行對比,效果會更好些。與標記階段對比,stop the world發生在回收階段。

這裡我們介紹幾種不同的垃圾回收演算法

垃圾回收演算法

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

標記-清除演算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的物件,清除階段就是回收被標記的物件所佔用的空間。

優點是簡單,容易實現。缺點是容易產生記憶體碎片,碎片太多可能會導致後續過程中需要為大物件分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作。(因為沒有對不同生命週期的物件採用不同演算法,所以碎片多,記憶體容易滿,gc頻率高,耗時,看了後面的方法就明白了)

2082573477-5755806326acf_articlex

  • 分代回收演算法

根據物件存活的生命週期將記憶體劃分為若干個不同的區域。不同區域採用不同演算法(複製演算法,標記整理演算法),這就是分代回收演算法。

一般情況下將堆區劃分為老年代(Old Generation)和新生代(Young Generation),老年代的特點是每次垃圾收集時只有少量物件需要被回收,而新生代的特點是每次垃圾回收時都有大量的物件需要被回收,那麼就可以根據不同代的特點採取最適合的收集演算法。

1.新生代回收

新生代使用Scavenge演算法進行回收。在Scavenge演算法的實現中,主要採用了Cheney演算法。

新生代的空間劃分比例為什麼是比例為8:1:1(不是按照上面演算法中說的1:1)?

Eden空間和兩塊Survivor空間的工作流程是怎樣的?
2945480921-57562fd919c54_articlex

具體的執行過程是怎樣的?

最終當scanPtr和allocationPtr重合,說明覆制結束。 注意:如果指向老生代我們就不必考慮它了。(通過寫屏障)

物件何時晉升?

2.老生代回收

Mark-Sweep(標記清除)

Mark-Compact(標記整理)

  • 補充概念方便理解

1.觸發GC(何時發生垃圾回收?)

2.寫屏障(一個老年代的物件需要引用年輕代的物件,該怎麼辦?)

3.深度、廣度優先搜尋(為什麼新生代用廣度搜尋,老生代用深度搜尋)

 

相關文章