天天看點

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

四、垃圾回收

終于說到垃圾回收了,我的初衷就是要搞明白垃圾回收的算法,誰知道衍生出來那麼多東西,哈哈。

5.1 常見垃圾回收政策

所謂垃圾回收,即為釋放我們不再使用的對象的記憶體,話不多說,我們一一分析目前的垃圾回收的算法,并且我們會重點說下Golang目前的三色标記算法。 要了解垃圾回收,首先我們先了解三個基本的概念, 首先是mutator和collector,這兩個名詞經常在垃圾收集算法中出現,collector指的就是垃圾收集器,而mutator是指除了垃圾收集器之外的部分,比如說我們應用程式本身。 其次就是根對象roots,我們一般有一個認知,垃圾回收都是回收堆上的記憶體,那麼我們的根對象就是堆之外的并且是被我們的程式通路得到的,比如C++的靜态變量,棧上的對象等等。這裡說一句,golang的根對象就是協程棧上的指針對象。

5.2 标記-清除

标記清除是最簡單的也是最古老的垃圾回收算法。 它的思想很簡單:

1.先從根對象開始掃描,當我們的根對象指向了某個堆上的對象,我們就認為這個對象是可達的。

2.可達對象指向的對象也是可達對象。

3.從根對象開始周遊,采用深度周遊或者廣度周遊(二者的方式網上都有說法,但是我認為都行,個人更偏向于廣度周遊,這種事一種解決的思路,具體怎麼實作看個人) 我們畫個圖看看

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

如上圖,根對象指向了A、B,說明A、B是可達的,同理F、G、D都是可達的對象,但是C、E就是不可達的對象,它們是要被清理的。 我們再來看看它的整個流程:

1.觸發垃圾回收事件發生,一般是當申請堆記憶體的時候,做一個檢測機制,或者定時回收 2.STW(Stop The World),挂起整個程式,等待GC

3.從根對象開始掃描,在每個可達的對象的header做一個标記

4.清除階段,掃描整個堆,發現是活躍對象的話(根據header的标志),則清除掉它的标志位即可。如果發現是非活躍對象即不可達對象,則把對象作為分塊,連接配接到被稱為“空閑連結清單”的單向連結清單。在之後進行配置設定時隻要周遊這個空閑連結清單,就可以找到分塊了。

5.清除完成,繼續程式。 分析算法的優缺點:

1.這個算法的優點就是思想簡單,很好實作

2.算法有一個缺點是會容易産生要多的碎片

3.配置設定的時候速度較低,我們剛才說過配置設定的是周遊空閑連結清單,最壞的情況是周遊到最後也沒有找到合适的

4.以上的問題我們都可以用其一些方法緩解,比如合并連續的區域,比如多個空閑連結清單等,但是我認為标記清楚最大的一個缺點就是STW,這個很多場景下不能容忍的,我們先來分析下為什麼要STW,想象一下,當你的标記階段正在進行中,而你的程式也在跑,突然建立了一個新對象,也是可達的,比如如下圖所示

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

當我已經标記完D之後,認為到它這就完事了,但是我們建立的新對象H就會在清除節點被認為是不可達的,最終被清理掉。在清除階段也會遇到同樣的問題。

5.3 引用計數

對引用計數比較深刻,記得剛開始寫C++的時候,所有申請的記憶體都需要自己去釋放,後來C++出了智能指針,解放了程式員釋放記憶體的操作,其實它的原理就是引用計數。 其實它的思想也很簡答:

1.我們給每個對象都配置設定一個計數器,代表有多少其他的對象引用我

2.當沒有對象再引用我的時候,就代表我沒用了,就是垃圾可以被回收了 我們來張圖看看

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

如上圖所示,我們的節點内的括号代表的是有多少對象引用自己,這裡注意以下幾個細節:

1.當對象剛被建立的時候,它就代表肯定有一個指針指向自己,是以我們對建立的對象設定計數器為1

2.當我們要更新一個指針的時候,比如A原先指向B,現在我們更改A的指向為C,此時我們會做如下的操作,首先給C的計數器加1,然後再給B的計數器減1。注意,這裡為什麼要先增加C的計數器而不是先減少B的計數器呢?假設我們的B對象與C對象是同一個對象的時候就會出現問題,假設原先B隻有被A指向,是以其計數器為1,當我們先減少B的計數器的時候,發現計數器為0,此時我們就要清理掉它,那麼接下來我們再去增加C的計數器的時候(跟B是同一個對象)就會發現該對象已經被清理了。

3.ok,我們繼續,看起來我們的揮手操作是發生在引用計數為0的時候,那麼當我們遇到某個對象的引用計數為0的時候,就直接回收就完事了嗎?不是的,想象一下,當我們要回收對象B時,雖然指向B的引用沒有了,但是B指向的對象還是可能存在的,如果我們就直接簡單的回收掉,會出現B引用的那些對象的計數永遠不會為0,是以我們要把B所有引用的對象給減1,同樣的道理如果B引用的對象被減1了後正好也變為0了,我們需要繼續遞歸的更新計數器。

我們來總結下過程:

1.當對象被建立的時候,初始化計數器為1

2.當更新指針的時候,需要檢測引用計數是否為0,如果為0則回收掉,放入空閑隊列中,并且遞歸的更新

我們來分析下它的優缺點:

1.首先,我們不難發現,引用計數方法的垃圾回收不會像标記清除那樣,專門的去做觸發的操作,或者是定時的清除。而且完全不用專門STW,因為它的回收操作完全分布在應用程式運作過程中,這是它最大的優點

2.垃圾會立刻被回收,相比較其他的垃圾回收算法,我們一旦發現引用計數為0就會被立即回收。

3.它的一個缺點就是,指針的更新需要我們去開發人員去手動的操作,如果漏了一個,就可能會出現很多無法回收的對象,造成記憶體洩露。

4.還有一個重大的bug,就是無法回收循環引用的對象

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

如上圖所示,當我們的A、B對象的情況由左邊轉換為右邊時,就會出現A、B永遠不會再被回收的情況

5.在程式中更新指針應該是一個很頻發的操作,我們相應的要頻繁的更新計數器

6.我們之前說過,當發現一個對象的引用為0的時候,我們需要繼續遞歸的去更新計數器,如果出現極壞的情況,這個遞歸會非常的深,可能會造成系統的颠簸。

7.還有一個缺點,想象一下,一個對象最多會被多少個指針指向,極端事情是機器所有的對象都會指向該對象,比如32位的機器,它的對象個數最大可達到

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

個,那麼我們的計數器就要是一個32位的資料,這可能會造成極大的空間浪費。 以上的一些缺點,很多都有相應的優化的方法,我們這裡就不細說了,感興趣的同學請自行谷歌。

5.4 節點複制

節點複制的想法比較的奇特,我們想想,标記清除算法中有兩個階段,第一步找到活躍的對象給它标記上,第二步掃描整個堆,把其餘未标記的對象清除掉,這兩個步驟能不能節省一個步驟呢?再有就是我們的對象申請的時候,都是從空閑連結清單上擷取的,找到一個合适大小的記憶體塊這個速度是比較慢的,我們知道棧記憶體的申請是很快,為什麼呢?因為棧是一塊連續的空間,申請的時候隻需要按照位址增長的申請即可,是O(1)的,堆我覺得至少得O(logN)。 ok,節點複制其實就是為了解決我們提到的以上兩個問題。先來看看它是怎麼做的吧:

1.首先它是把堆的記憶體分為兩個部分,并且是均分。一個叫From,一個To。

2.From記憶體塊就是我們記憶體配置設定的時候用的。

3.當我們要進行GC的時候,我們把活躍的對象直接複制到To記憶體空間中去,然後直接把To空間換做我們的程式使用的空間,再把From整體清空,然後再把From作為To。 思路看起來很簡單,但是操作起來還是稍微有些複雜的,我們先給出一個僞代碼,該僞代碼來自《垃圾回收的算法與實作》:

copying(){
    $free = $to_start
    for(r : $roots)
        *r = copy(*r)
    swap($from_start, $to_start)
}
copy(obj){
    if(obj.tag != COPIED)
        copy_data($free, obj, obj.size)
        obj.tag = COPIED
        obj.forwarding = $free
        $free += obj.size
    for(child : children(obj.forwarding))
        *child = copy(*child)
    return obj.forwarding
}
           

我們先看第一個函數copying()其實就是我們整體的GC:

1.首先我們把free指針指向To空間的start位置

2.然後我們周遊根對象,調用copy函數把根對象copy到To空間去,注意copy函數傳回的是對象在新的空間的位置,而且copy函數是遞歸的把所有的r對象指向的對象都複制到新的空間去,我們來詳細的看下copy函數:

a.首先判斷下這個對象是否已經被複制過了,如果複制過,則直接傳回它的新空間的指針。 b.如果未被複制過,則先把對象拷貝到新空間的$free處,然後把其标志位設定為已複制,把forwarding設定為其剛才複制的 位址(即為新空間的位址)

c.當複制完對象後,再遞歸的把對象指向的所有的對象都複制到新空間,并且把指向這些指針指向新的空間位址。

3.最後我們再交換兩個空間 可能在第二步的時候有些繞,我們給出圖例就清晰很多,同樣引用《垃圾回收的算法與實作》:

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

來分析下上圖,我們第一步先把A複制到新的空間,注意這裡我們沒有更改A對象中的指針指向,是以A'還是指向老空間的C和D,然後更新A的标志位,如圖所示

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

這樣我們就知道A已經被複制過了,然後我們進入循環遞歸,把C和D複制到了新的空間,最後我們傳回的是A的新空間的位址(看到傳回的就是forwarding指針,用處在這呢),指派給了根對象的指針

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

剩餘的就是B了,垃圾,直接清空From空間即可。 關于其優缺點,其實就是我們之前說的幾個問題:

1.配置設定速度快,既然它沒有空閑連結清單的概念,直接當成一個棧記憶體配置設定即可,速度飛起,相應的就沒有碎片化的苦惱了。

2.吞吐量高,其實就是GC速度快,如同我們之前所說,它不像标記清除那樣進行第二個階段去掃描所有的堆。

3.還有一個比較有意思的地方,通過老空間的内容複制到新空間之後,互相有引用的對象會被配置設定在距離較近的地方,還記得程式的局部性原理嗎?這會提升我們的緩存命中率

4.優點那麼好,肯定優缺點,第一個就是太浪費記憶體了,我們隻能用一半!!!

5.這裡面也有遞歸的操作,效率可能會降低,或者有可能引起棧溢出的問題。

6.同樣的,它也有STW的時間,複制清除的過程需要暫停程式。

5.5 分代收集

分代收集隻是一種思想,并非一個專門的垃圾回收算法,不過這個想法真妙,值得學習,在平常的性能優化過程中應該很有用。 所謂分代,謎底就在字面上,就是把我們堆配置設定的對象,給他分代(上一代,下一代的意思),我們這裡隻說分成新舊兩代的算法,那麼分代按照什麼分呢?按照我們的垃圾的生命周期來分,意思就是很快程式設計垃圾的那種對象被分為年輕的一代,相應的很長時間才變成垃圾的對象被稱為老的一代,我們每次隻對年輕一代的對象進行GC即可,每次GC都會給對象增加一個年齡,當到達老一代的對象的年齡的限制的時候,再把它更新為老對象,放到老對象的空間中,當老對象的空間滿的時候,我們再去GC老對象即可。

分析下:

這種算法基于這樣的認知,大部分對象建立後很快就變成垃圾,其餘的就是很長時間才會變成垃圾,那麼我們沒必要對這種長時間才變成垃圾的對象進行GC,浪費時間。 ok,我們來看下David Ungar研究的分代GC算法:

1.該算法把堆空間劃分為四個部分,分别是生成空間,幸存空間1,幸存空間2,老年代空間。并且我們把前三者合并成為新生代空間。

2.當對象剛建立的時候,配置設定的空間就是在生成空間。

3.當生成空間滿的時候,我們就對新生代空間進行GC,這裡是是對整個新生代空間進行GC,采用的GC的算法就是節點複制。我們看圖說話

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

看上圖,我們把幸存空間其中一個作為一個To空間。

4.另外,我們每次GC的時候,都會對對象的“年齡”加1,當判斷對象的年齡到達一定門檻值的時候,就把對象移動到老年代空間。

5.當我們對新生代對象進行GC的時候,我們注意一點,之前看節點複制的時候,我們知道是從根節點開始掃描,但是注意一有可能我們的老年代的對象也會指向新生代,是以如果我們把這點漏掉了,會多清除一些活躍的對象(至于為什麼我們稍後解釋)。為了解決這個問題,我們需要把老年代的對象掃描一遍,但是想想如果這樣做的話我們豈不是每次要GC新生代對象的時候,都要把新、老都掃描了?這樣的話我們的分代GC就沒有優點了,如下圖。

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

6.為了解決第5步的問題,David Ungar想到了一個方法,用一個記錄集來記錄那些老年代的對象指向新生代的情況。這樣的話,當我們的GC新生代的時候,從根對象與記錄集中就行,那麼這個記錄怎麼做到呢,采用的寫入屏障(write barrier)的方法。

7.那麼我們什麼時候GC老年代對象的呢?當我們發現新生代的對象的年齡到了之後,要晉升為老年代對象的時候,會先檢查老年代空間是否滿了,滿的話我們就開始老年代GC,老年代對象GC采用的就是标記清除的方法,注意這裡應該是把整個堆都進行了GC。

在看具體步驟前,先來看下每個對象标志都有哪些,在對象的頭部包含對象的種類、大小還有如下三個标志:

1.對象的年齡(age),之前已經說過,用于新生代晉升老年代使用

2.已經複制完的标志(forwarded),這是我們使用節點複制GC的時候用

3.已經向記錄集記錄完畢的标志(remembered),這是我們對新生代進行GC的時候找根節點使用

最後我們來看下具體的步驟:

1.新生代的GC過程: 先看下僞代碼

copy(obj){
    if(obj.forwarded == FALSE)
        if(obj.age < AGE_MAX)
            copy_data($to_survivor_free, obj, obj.size)
            obj.forwarded = TRUE
            obj.forwarding = $to_survivor_free
            $to_survivor_free.age++
            $to_survivor_free += obj.size
            for(child : children(obj))
                *child = copy(*child)
        else
            promote(obj)
    return obj.forwarding
}
           

(1)當我們的生成空間滿的時候,觸發了新生代的GC,執行minor_gc()函數,minor_gc()的如上

(2)我們來看下首先判斷forwarded是否已經複制,如果已經複制過了則不用再複制了,防止重複複制

(3)如果還我複制,接着判斷它的年齡,如果超過一定年齡則判定為老年代對象,然後調用promote進行晉升。

(4)如果還是年輕的對象,則更新更新forwarded和forwarding,跟我們之前看的節點複制沒什麼差別

(5)接着我們來看看promote函數

promote(obj){
    //從老年代空間配置設定一個空間
    new_obj = allocate_in_old(obj)
    //判斷是否配置設定成功
    if(new_obj == NULL)
        //如果配置設定不成功,代表老年代滿了,則老年代GC
        major_gc()
        new_obj = allocate_in_old(obj)
        //再不成功,則則直接失敗
        if(new_obj == NULL)
            allocation_fail()
    //更新對象标志
    obj.forwarding = new_obj
    obj.forwarded = TRUE
    //更新指向的子對象
    for(child : children(new_obj))
        //如果還有指向新對象的老對象,則更新記錄集
        if(*child < $old_start)
            $rs[$rs_index] = new_obj
            $rs_index++
            new_obj.remembered = TRUE
            return 
}
           

注意:

我們仔細想一下,有沒有可能新生代對象指向老年代對象的情況呢,那麼我們在copy函數中,如果某個對象的子對象指向了老對象,豈不是又調用了一次promote函數,是以我認為這裡有點問題,需要在promote給new_obj更新下forwarded為true,這樣的話如果以後發現某個對象已經是老對象,copy函數中判斷

if(obj.forwarded == FALSE)
           

就會被過濾了。

看一下整體的過程

minor_gc(){
    //更新To的空間
    $to_survivor_free = $to_survivor_start
    //掃描跟對象,進行複制
    for(r : $roots)
        if(*r < $old_start)
            *r = copy(*r)
    i = 0
    //再掃描記錄集
    while(i < $rs_index)
        has_new_obj = FALSE
        for(child : children($rs[i]))
            //指向的對象必須是新生代的對象
            if(*child < $old_start)
                //執行複制
                *child = copy(*child)
                //如果複制後的指向的對象還是新對象,則需要把has_new_obj更新為true
                if(*child < $old_start)
                    has_new_obj = TRUE
        //此時判斷has_new_obj如果為false,則代表指向的對象不再是新對象,則需要把記錄集的記錄删掉
        if(has_new_obj == FALSE)
            $rs[i].remembered = FALSE
            $rs_index--
            swap($rs[i], $rs[$rs_index])
        else
            i++
    //交換from與to空間
    swap($from_survivor_start, $to_survivor_start)
}
           

2.老年代的GC 當我們把新年代的對象複制到老年代的時候,老年代可能會滿,是以我們需要對老年代進行GC,采用的是普通的标記清除,這裡注意一點,如果我們隻是清除老年代,同樣的,如果有新年代的對象指向老年代的怎麼辦呢?是以這裡的标記清除,是對整個堆進行标記清除GC。

我們最後來分析下它的優缺點:

1.優點是速度快,每次都掃描的少嘛,這就是所謂吞吐量快

2.大部分年輕對象很快就成為垃圾了,這個也不是完全确定的,如果不是這個規律,那就出問題了,新生代GC時間就會多?為什麼呢,因為記錄集可能會很多。老年代GC也會頻繁運作,為什麼呢,大部分都不是垃圾了,全都複制到老年代空間了,那麼老年代空間就會很快滿了。

5.6 三色标記法

在golang1.5之前,golang主要是采用标記清除的方法,這樣的話STW時間會很長,1.5出來後采用了三色标記法,也是我們今天主要說的。 golang為何要選擇三色标記算法呢?我們知道golang比較适合網絡高并發的服務場景,那麼如果使用STW時間較長的GC算法,對服務來說是緻命的,故而要選用STW時間較少的算法,在标記清除的基礎上發展來的三色标記出現了。 三色标記的思想其實是盡量把标記階段、清除階段與程式同時跑,它其實是一種增量式GC算法,所謂增量式其實就是把GC過程拆分出來的意思,跟我們要把最大的STW時間減少的思想吻合。

在看整個過程之前,我們先同步幾件事情:

1.在三色标記算法中,我們給對象進行了标記顔色,分别是白色、灰色、黑色,在GC開始的時候,所有的對象預設都是白色,标記完成之後,所有的可達的對象都會被标記為黑色,而灰色就是我們的中間狀态

2.我們Golang的GC的根對象,都在棧上,所謂棧其實就是協程棧。

我們來看下其整個過程:

1.首先,當要GC觸發的時候,首先,我們會初始化寫屏障(Write barrier),我們的垃圾回收器從根對象開始掃描,把所有的垃圾根對象壓入一個棧當中,并且把對象都會标記為灰色

2.從棧中pop出一個對象,把該對象所有指向的子對象都入棧,并且标記為灰色,并且把該對象标記為黑色,然後放入黑色的對象集合中

3.無限的重複第二個步驟,直到棧為空。

4.在第一步驟中我們看到有寫屏障,這個寫屏障其實就是記錄我們進行第一次掃描的時候漏掉的那些對記憶體的操作,我們會再次周遊這些記錄(稍後細說),注意這個過程會進行STW,但是時間會很短。

5.最後掃描所有的堆,把白色對象清除即可。

注意:

1.還記得我們在說golang的堆記憶體管理與協程棧管理嗎?首先棧的管理中有一個stackmap可以幫助我們尋找到到協程棧上的指針,這其實就是我們的根對象

2.Edsger W. Dijkstra提出的三色标記法中,有一個地方需要注意,在标記階段如果出現以下情況我們是要進行特殊處理的:

a.如果有新建立的對象,而指向該對象的是黑色的對象,或者指向該對象的恰好也是剛建立的根對象,這樣這個剛建立的對象會被漏标記

b.如果有如下圖的情況,某個白色對象在被灰色對象引用的情況下,被改為被黑色對象引用了,而我們知道黑色的對象其實已經被掃描過了,這樣這個白色的對象就不會被标記到。

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

這個時候我們就要用到寫屏障了,看下Edsger W. Dijkstra提出的寫屏障算法。

write_barrier(obj, field, newobj){
    //未被标記,就标記
    if(newobj.mark == FALSE)
        newobj.mark = TRUE
        push(newobj, $mark_stack)
    *field = newobj
}
           

這裡看到,隻要我們指向的對象是白色的,我們就把它标記為灰色的,這樣的話,新建立的對象以及黑色對象指向白色對象的情況就能被解決,如下圖所示。

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

3.我們說過三色标記是增量式的,具體展現在哪呢?看下它的僞代碼

incremental_mark_phase(){
    //這裡有一個MARK_MAX,這是最大掃描的個數
    for(i : 1..MARK_MAX)
        //如果灰色的棧集合不為空,則繼續掃描
        if(is_empty($mark_stack) == FALSE)
            obj = pop($mark_stack)
            for(child : children(obj))
            mark(*child)
        else
            //如果掃描完畢了,則重新掃描一遍根對象
            for(r : $roots)
                mark(*r)
            //掃描完根對象後發現有漏掉的,那麼繼續掃描
            while(is_empty($mark_stack) == FALSE)
                obj = pop($mark_stack)
                for(child : children(obj))
                    mark(*child)
            //如果發現到清除階段,則進入清除
            $gc_phase = GC_SWEEP
            $sweeping = $heap_start
            return
}
           

注意看上面的代碼的MARK_MAX,這裡的意思是從灰色的棧集合中,每次最多掃描MARK_MAX個灰色對象。也就是說把掃描階段拆開了,一次一部分,是以叫增量式。

4.我們再來看下,如果在清除階段很應用程式同時跑會有問題嗎?

(1)想象下,如果引用變化會引起清除掉活躍的對象嗎?其實是不會的,想象下什麼情況下可能會出現清除活躍對象呢,白色的對象又重新被引用了才可能會出問題,但是其實不會的,如果它原本是白色對象,就不可能被黑色對象找到了,如果它能通過某個方式找到這個白色對象,那麼在之前掃描的時候一定會把它最後标記為黑色的。

(2)如果在清除階段出現新的對象呢?我們知道新建立的對象預設是白色的,确實有可能是被清除的,那麼我們怎麼做呢,很簡單,我們知道清除階段是掃描整個堆記憶體,其實我們是可以知道目前清除到什麼位置,那麼我們建立的新對象判定下,如果新對象的指針位置已經被掃描過了,那麼就不用作任何操作,不會被誤清除,如果在目前掃描的位置的後面,那就要注意了,我們直接把該對象的顔色标記為黑色,這樣就不會被誤清除了。如下圖所示

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

5.我們來分析下優缺點:

(1)最大的優點是STW時間很短

(2)缺點就是吞吐量低,原因就是有寫入屏障

6.不同的寫入屏障政策 為了防止在掃描階段,與程式同時跑的話,出現白色的活躍的對象(被灰色)在還未被标記的時候,從被會被灰色對象指向改為被黑色對象指向,我們上面說過一種寫入屏障的政策是記錄黑色直線白色的引用,其實還有其他幾種方式,我們來說一下。 (1)Steele算法 該算法不同的地方就在于,當發現黑色對象指向白色對象的時候,就把黑色對象标記為灰色即可。這樣的話,該對象就會被再次掃描,相應的那個白色對象也會被掃描到,如圖所示

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

(2)湯淺算法 該算法在mark的時候,不會再次掃描根對象,同樣我們需要注意兩個問題,新生成的對象,以及引用的變更,對于新生成的對象就直接标記為黑色,對于引用的變更,該算法的寫入屏障是記錄指針删除的操作,記錄的條件是,當有指針删除的操作的時候,并且被删除的指引指向的是白色的對象,那麼我們就把這個白色的對象給标記成灰色就行了,如圖所示

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

六、Golang的記憶體逃逸

6.1 何為記憶體逃逸

我們在寫C++或者C的時候都知道一個事實,你自己申請的堆記憶體需要自己釋放,因為它們存儲在堆上,而且一般自己malloc或者new出來的記憶體,都是在堆上的,那麼golang是否也這樣呢?

答案不是的,之前我們也說到過一些,就是程式開發人員在程式中申請的變量或者說記憶體,是存儲在堆上還是棧上,是golang的編譯器來決定的,那麼何謂記憶體逃逸呢,舉一個最簡單的例子,看如下的代碼

func add (a int) *int{
    b := a + 1
    return &b
}
           

如果這段代碼在C++程式中,該函數傳回的b的位址,由于b是局部變量,存儲在棧上,當函數執行完成後棧上的資源釋放那麼如果其他地方再用b的位址去通路b的話,就會出現通路空指針,或者通路到錯誤内容。 但是,如上的代碼它是golang的,就不會出現這樣的問題,為何呢?其實golang編譯器在編譯這段代碼的時候,發現該函數傳回了局部變量的位址,它就認為該變量不太适合存儲在棧上,于是它指令申請該變量的時候存儲到堆上,這樣的話函數執行完,也不會釋放掉b的内容,我們就可以在其他地方開心的使用b的内容了。 明白了如上的原因,我們來再次說明下編譯器的作用:編譯器來決定變量是存儲在堆上還是棧上。 比如,你在golang中New一個對象或者make一個對象,對象不一定會存儲在堆上。 那麼我們再解釋下記憶體逃逸的定義:變量突破了自己的作用範圍,在作用範圍外能被通路的現象就叫記憶體逃逸,即為通過逃逸把棧上的記憶體配置設定到堆上了。

優缺點:

1.這樣做的好處是,不用程式開發人員太多的關注局部變量作用範圍的問題,尤其是如上的問題。

2.那麼它有壞處嗎,必然是有的,想象下,如上這種情況,本應該在棧存儲的變量最後都跑到堆上了,這勢必會使得堆上的記憶體變多,帶來的就是GC的壓力,另外,申請堆上的記憶體與申請棧記憶體的速度是沒發比的,那麼我們怎麼解決呢?這就是逃逸分析。

6.2 逃逸分析作用

所謂逃逸分析,就是我們找到那些記憶體逃逸的現象,盡量去避免(當然不一定要完全的避免)。我們先來看下它的好處

1.最大的好處應該是減少gc的壓力,不逃逸的對象配置設定在棧上,當函數傳回時就回收了資源,不需要gc标記清除。

2.因為逃逸分析完後可以确定哪些變量可以配置設定在棧上,棧的配置設定比堆快,性能好

3.同步消除,如果你定義的對象的方法上有同步鎖,但在運作時,卻隻有一個線程在通路,此時逃逸分析後的機器碼,會去掉同步鎖運作。 那麼我們怎麼發現代碼中的記憶體逃逸現象呢? golang的編譯器自帶了指令,開不開心。 go run -gcflags '-m -l' xxx.go 我們先來試一試如下的代碼:

package main

func add (a int) *int{
  a= a + 1
  return &a
}

func main() {
    a := 1
    c :=add(a)
    _ = c
}
           

我們來編譯下

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

那麼我們怎麼可以修改的沒有記憶體的逃逸呢?

package main

func add (a *int) *int{
  *a= *a + 1
  return a
}

func main() {
    a := 1
    c :=add(&a)
    _ = c
}
           
夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

看到沒有,這裡我們把參數的輸入就是指針,傳回的還是我們傳進去的指針,并不會發生記憶體的逃逸。

6.3 Golang的閉包原理

6.3.1匿名函數

所謂匿名函數就是沒有名字的函數,我們在golang的代碼中經常看到,常見的匿名函數一般如下形式

//不帶參數,a()輸出1
a := func() {
    fmt.Println(1)
}
//帶參數a(1)輸出1
b := func(arg int) {
    fmt.Println(arg)
}
//帶傳回值,c()的傳回值為5
c := func() int {
    fmt.Println(4)
    return 5
}
           

我們先列出來這幾種函數,為我們接下來看閉包打下一點基礎

6.3.2 golang的閉包

我們先不給閉包下定義,我們先看下現象

package main

import "fmt"

func A() func(int) int {
    sum := 0
    return func(bb int) int {
        sum += bb
        fmt.Println("bb=", bb, "tsum=", sum)
        return sum
    }
}

func main() {
    a := A()
    a(0)
    a(1)
    a(5)
}
           

看下運作的結果

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

我們看到一個奇怪的現象,sum的值貌似被累加了,為什麼呢?這裡其實就是形成了一個閉包,當建立a函數的時候,A函數把sum組裝給了這個傳回函數,組裝進去的是它的引用,是以引起了累加現象,另外我們從逃逸分析方面驗證下

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

看到沒有,裝載進a函數的事sum的引用,并且逃逸到了堆上,是以在A函數執行完之後,并不會釋放掉sum。 另外我們注意掉貌似bb也被逃逸了啊,為什麼它沒有被累加呢,仔細看我們的bb是傳回的函數a的參數穿進去的,是值傳遞喔。 我們繼續看如下的代碼

package main

import "fmt"

func A() func(int) int {
    sum := 0
    return func(bb int) int {
        sum += bb
        fmt.Println("bb=", bb, "tsum=", sum)
        return sum
    }
}

func main() {
    a := A()
    c := A()
    a(0)
    a(5)
    c(10)
    c(20)
}
           
夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

喲,看起來不對啊,sum沒有被四次累加啊,看下逃逸分析

夥伴算法的核心思想是回收時進行相鄰塊的合并_從垃圾回收解開Golang記憶體管理的面紗之三垃圾回收...

還是發現不了啊,難道不對?其實仔細向下,我們調用了兩次A函數,sum變量是在A函數中被建立的,是以a函數的sum的引用與c函數中的sum的引用必然不是一個啊,是以這也就是它們各自行程了自己的閉包。

七、最後

為了要了解垃圾回收,扯出來一大堆,文中可能有一些技術上的錯誤或者錯别字,請大家不吝指出,感謝! 技術的成長總是點點滴滴的,希望我能一直保持對技術的熱情。