垃圾回收(GC)浅谈
關于內存
計算機通過兩個機制,去實現內存的高效使用。
第一種機制是虛擬內存。硬盤的容量其實是遠遠大于內存的(RAM),虛擬內存會在內存不足的時候,把不經常訪問的內存的數據寫到硬盤里。雖然說硬盤容量比較大,但是它的訪問速度卻很慢。如果內存和硬盤交換數據過于頻繁,處理速度就會下降,計算機就會看上去像卡死了一樣,這種現象被叫做抖動(Thrushing)。造成電腦藍屏的主要原因之一就是抖動。
第二種機制就是垃圾回收(GC)。
虛擬內存的東西在計算機組成原理和操作系統的教科書里有相關的章節去講。由于內容很多我就不多敘述了。主要來講一下GC的事情。
GC
之前學習java以及參加java相關的面試,被問到關于相關GC的事情一直很是頭疼,看了好多遍還是記不住,腦袋里只有隱隱約約的一些關鍵字,什么老年代、新生代、full GC什么的。具體流程一被問就GG。
現在想想,其實GC就是計算機幫助你去進行內存的回收。你用不到的數據如果一直占著內存,那么你的程序能用的內存就越來越少,所以需要進行內存的管理。比如你在寫C、C++程序的時候,需要自己去管理內存,在堆上申請的內存最后需要自己手動釋放,釋放的內存會被操作系統重新利用。如果你認為某些內存空間“可能還會被用到”,或者是干脆忘記釋放內存,這些無法訪問的內存空間就會一直保留下來,造成內存浪費,最終導致性能下降和產生抖動。管理大量分配的內存空間,對于人來說其實是很困難的。
對于像Java、go這些語言,它們有自己的一套內存管理的機制,內存空間釋放的自動化也就是GC,從某種程度上解放了程序員的雙手。
術語
垃圾(Garbage)
垃圾(Garbage)就是需要回收的對象。作為編程人員你知道對象什么時候不需要,但是計算機無法判斷。因此,如果程序直接或者間接地引用一個對象,那么它就會被計算機標記為“存活”;相反的,沒有被引用到的對象就被視為“死亡”。把這些“死亡”的對象找出來,然后作為垃圾進行回收,這就是GC的本質。
根(Root)
根(Root),就是判斷對象是否可被引用的起始點。不同語言和編譯器對根有不同規定,但是基本上是將變量和運行棧空間作為根。
GC算法
標記清除方式
標記清除原理非常簡單,首先從根開始,將可能被引用的對象用遞歸的方式進行標記,然后將沒有標記上的對象作為垃圾進行回收。
圖1.1
圖1.1的(1)顯示了隨著程序的運行進而分配出的一些對象的狀態,一個對象可以對其他的對象進行引用。(2)部分GC開始執行,從根開始對可能被引用的對象打上標記。大多數情況下,這種標記是通過對象內部的標志(Flag)來實現的。被標記的對象我們將它涂黑。
(3)中,被標記的對象所能夠引用的對象也被打上標記。重復這一步驟,可以將從根開始可能被間接引用到的對象全部打上標記。到此為止的操作,稱作標記階段(Mark phase)。標記階段完成是,被標記的對象就被看做是“存活”對象。
(4)中,將全部對象按順序掃描一遍,將沒有標記的對象進行回收。這一步操作稱作清除階段(Sweep phase)。在掃描的同時,還需要將存活對象的標記清除掉,以便下一次GC去處理。
標記清除算法的處理時間,是和存活對象數與對象總數兩者的和相關的。
標記清除方式的優點:可以處理循環引用的對象。缺點:在分配了大量對象,并且其中只有一小部分存活的情況下,由于清除階段還要對大量“死亡”的對象進行掃描,會導致消耗大量不必要的時間。
復制收集方式
復制收集(Copy and Collection)方式試圖解決標記清除的缺點。這種算法中,會從根開始,被引用的對象復制到另外的空間中,然后再將復制的對象所能夠引用的對象遞歸的方式不斷復制下去。
圖1.2
(1)是GC開始前的狀態。(2)部分中,舊對象空間之外,準備出一塊新空間,然后將可能從根被引用的對象復制到新空間中。(3)部分中,從已經復制的對象開始,再將可以被引用的對象復制到新空間中(串到了后面),“死亡”對象就被留存在了舊空間中。
(4)中,將舊空間廢棄掉,就可以將這部分所占的空間一下子全部釋放掉,而沒有必要再掃描每個對象。下次GC的時候,現在的新空間就當做了未來的舊空間。
通過圖1.2可以發現,復制收集方式,只有類似標記清除的標記階段,而不存在需要掃描所有對象的情況。但是相比之下,復制收集將對象復制一份所需要的開銷會比較大,因此在“存活”對象比例較高的情況下,反而會比較不利。
這種算法的另一個好處就是它具有局部性(Locality)。在復制收集過程中,會按照對象被引用的順序將對象復制到新空間中。因此關系較近的對象被放在距離較近的內存空間中的可能性會提供,這被稱為局部性。局部性高的情況,內存緩存會更容易有效運作,程序的性能也會得到提高。
引用計數方式
引用計數的基本原理是,在每一個對象中保存該對象的引用計數,當引用發生增減時對計數進行更新。這讓我想到了C++里的智能指針(shared_ptr),曾經被面試手寫shared_ptr的場景歷歷在目啊。
引用計數的增減,一般發生在變量賦值、對象內容更新、函數結束(局部變量不再被引用)等時間點。當一個對象的引用計數變為0的時候,則說明它將來不會再被引用,因此可以釋放相應的內存空間。
圖1.3
(1)里面,所有對象中都保存著自己被多少其他的對象進行引用的數量(引用計數)。(2)中,當對象引用發生變化的時候,引用計數也跟著變。這里由于B到D的引用失效了,于是對象D的引用計數變為0,由于D的引用計數為0,因此由D到對象C和E的引用數也分別相應減少。結果,對象E的引用計數變為0,于是E也對應釋放掉了。
(3)中,引用計數為0的對象被釋放,“存活”的對象保留了下來。能夠注意到,在整個GC的過程中,并不需要對所有對象進行掃描。
這種方式最大優點,就是容易實現。它的另外一個優點就是,當對象不再被引用的瞬間就會被釋放。其它的GC機制很難預測對象什么時候會被釋放,而這種方式是立即被釋放的。因此,由GC產生的中斷時間(Pause time)就比較短。
當然這種方式也有缺點。最大的缺點就是,無法釋放循環引用的對象。圖1.4中A、B、C之間互相循環引用,它的引用計數永遠不會為0,也就永遠不會被釋放。
圖1.4
它的另外一個缺點就是,如果在必要的增減計數的時候遺漏掉了增減操作,或者是增減計數出錯,就會產生內存錯誤。如果是手動管理計數就很容易產生bug。
它的最后一個缺點就是引用計數管理不適合并行處理。如果多個線程同時對引用計數進行增減,引用數值就會產生不一致的問題從而導致內存錯誤。因此引用計數必須采用獨占的方式。如果引用計數頻繁發生,每次需要使用加鎖等并發控制機制的話,也會造成很大的額外開銷。
改良版GC
GC的基本算法,大體上都是上面的三種方式或者是它們的衍生品。現在通過三種方式進行融合,出現一些其他高級的GC方式,即分代回收、增量回收和并行回收。有些情況也會對這些改良版的gc方式進行組合使用。
分代回收(Generational GC)
分代回收的目的,就是在程序運行期間,將GC所消耗的時間盡量的縮短。
它基于這樣一個一般程序的性質,即大部分對象都會在短時間內成為垃圾,經過一定時間依然存活的對象往往有較長的壽命。對于剛分配不久的“年輕”對象進行重點掃描,應該可以更有效的回收大部分垃圾。
分代回收中,按照對象生成時間進行分代。剛剛生成不久的對象劃分為新生代(Young generation),而存活了較長時間的對象劃分為舊生代(Old generation)。不同實現可能還會有更多劃分。如果上面的對象壽命假說成立的話,只要掃描回收新生代對象,就可以回收掉廢棄對象中的很大一部分。
這種只掃描新生代對象的回收操作,被稱作小回收(Minor GC)。它的具體步驟如下。
首先從根開始一次常規掃描,找到“存活”對象。可以使用復制收集算法或者標記清除算法,但是大部分使用了復制收集。需要注意的是,在掃描的過程中,如果遇到屬于舊生代的對象,則不對該對象進行遞歸掃描。這樣需要掃描的對象就會大量減少。
然后,將第一次掃描后殘留下來的對象劃分到舊生代。具體來說,如果使用復制收集算法,只要將復制目標空間設置為舊生代就可以了;標記清除方式的話,則采用在對象上設置某種標志的方式。
現在有一個問題,如果有從舊生代到新生代對象的引用怎么辦?如果只掃描新生代,那么舊生代對新生代的引用就不會被檢測到。這樣一來,如果一個年輕的對象只有來自舊生代的引用,就會被誤認為“死亡”。因此在分代回收中,會對對象的更新進行監視,將從舊生代對新生代的引用,記錄在記錄集(remembered set)的表中。在執行小回收過程中,這個記錄集也作為一個根(root)來對待。
圖1.5
沒有引用任何其它對象的舊生代F對象,會通過大回收(Full gc)操作進行回收。保證分代回收正確工作,必須使記錄集的內容保持更新。記錄引用的子程序工作方式如下。設有兩個對象A、B,當對A內容進行改寫,并加入對B的引用時,如果A屬于舊生代,B屬于新生代,則將該引用添加到記錄集中。
這種檢查程序需要對所有涉及修改對象內容的地方進行保護,因此也被稱為寫屏障(Write barrier)。寫屏障也用在很多其他GC算法中,比如Go的gc算法也用到了寫屏障。
雖然舊生代中的對象壽命一般比較長,但是最終也會“死亡”。隨著程序運行,舊生代中“死亡”的對象不斷增加。為了避免這些“死亡”的舊生代對象白占內存空間,偶爾需要對包括舊生代在內的全部區域進行一次掃描回收。這種以全部區域為對象的GC操作叫Full GC
分代收集通過減少GC中掃描的對象數量進而縮短GC帶來的平均中斷時間,但是最終還是需要一次Full GC,因此最大中斷時間并沒有改善。GC的性能也會被程序行為、分代數量、Full GC的觸發條件等因素大幅左右。
增量回收
實時性要求很高的程序中,相比縮短GC平均中斷時間,縮短GC的最大中斷時間更加重要。
GC最大的中斷時間要限定在一個時間范圍內,但是一般GC算法沒辦法保證,因為GC產生的中斷時間和對象的數量和狀態有關系。因此為了維持程序的實時性,不等到GC全部完成,而是將GC操作細分成多個部分逐一執行。這種方式就是增量回收(Incremental GC)
增量回收過程中程序本身會繼續運行,為了避免對象之間的引用關系改變而導致GC回收出錯,增量回收也使用了寫屏障,來講新被引用的對象作為掃描的起始點記錄下來。
由于增量回收過程式分步漸進式的,可以將中斷時間控制在一定長度之內。但是相應的中斷操作需要消耗一定時間,GC消耗的總時間會相應增加。
并行回收
現在的計算機基本上都有多個CPU核心。而并行回收方式正是通過最大限度利用多CPU的處理能力來進行GC操作。
并行回收的基本原理是,在原有程序運行的同時進行GC操作,這點和增量回收是相似的。相對于在一個CPU上進行任務分割的增量回收方式,并行回收可以利用多CPU的性能,盡可能讓這些GC任務并行(同時)進行。由于程序功能運行和GC操作是同時發生的,就會遇到和前面相同的問題,所以并行回收也需要利用寫屏障來對對象當前的狀態信息保持更新。
但是讓GC完全并行,而不影響原有程序運行時做不到的,在GC的某些特定階段還是需要暫停原有程序的運行。
總結
目前的GC算法基本上都是上述算法的變體或者是組合,例如java里的分代回收,golang中的三色標記法。有時候因為對象之間的引用狀態發生了變化,結果導致了本來是“存活”對象卻被回收掉,這時候需要寫屏障(Write barrier)來進行記錄和保護。當我們理解了上面的事情,其他具體編程語言的GC算法就不難理解了。
參考資料
轉載于:https://juejin.im/post/5cf0ffa7f265da1ba56b052a
總結
以上是生活随笔為你收集整理的垃圾回收(GC)浅谈的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Linux - MiniFtp实现
- 下一篇: 第一次实验总结