《代碼的未來》讀書筆記:內存管理與GC那點事兒


一、內存是有限的

  近年來,我們的電腦內存都有好幾個GB,也許你的電腦是4G,他的電腦是8G,公司服務器內存是32G或者64G。但是,無論內存容量有多大,總歸不是無限的。實際上,隨着內存容量的增加,軟件的內存開銷也在以同樣的速率增加着。因此,最近的計算機系統會通過“雙重”幻覺,讓我們以為內存容量是無限的。

  第一重幻覺:垃圾回收(GC)機制

  在C/C++中,內存空間的分配是由人工手動進行管理的,當需要內存空間時,要請求OS進行分配,不需要的時候則需要返回給OS。如果不再需要的內存空間沒有及時返還給OS,這些無法訪問的內存空間就會一直保留下來,造成內存的白白浪費,最終引發性能下降和產生抖動。

  將內存管理,尤其是內存空間的釋放實現自動化,這就是GC

  第二重幻覺:OS提供的虛擬內存

  所謂虛擬內存,就好比是將書桌上的比較老的文件先暫時收到抽屜里,用空出來的地方來攤開新的文件。在計算機中,體現在在內存容量不足時將不經常訪問的內存空間中的數據寫入硬盤,以增加“賬面上”可用內存容量的手段(想想我們的內存和硬盤容量對比就知道了)。

  BUT,如果在書桌和抽屜之間頻繁進行文件的交換,工作效率肯定會下降。如果每次要看一份文件都要先收拾書桌再到抽屜里面拿的話,那工作根本就無法進行了。

  虛擬內存也有同樣的缺點:硬盤的容量比內存大,但也只是相對的,速度卻非常緩慢,如果和硬盤之間的數據交換過於頻繁,處理速度就會下降,表面上看起來就像卡住了一樣,這種現象稱為抖動(Thrushing)。相信很多人都有過計算機停止響應的經歷,而造成死機的主要原因之一就是抖動。

二、GC的基本方式

2.1 標記清除方式

  標記清除是最早的GC算法,其原理是:首先從根開始將可能被引用的對象用遞歸的方式進行標記,然后將沒有標記到的對象作為垃圾進行回收

  下圖直觀地展示了標記清除算法的大致原理:

  ① 初始階段:

  ② 標記階段:

  其中,紅色背景白色字體的對象為已標記的對象。重復這一階段步驟,已標記的對象會被視為“存活”的對象,而沒有被標記的對象就將被進行回收。

  ③ 清除階段:

  將前面階段中沒有被標記的對象進行回收,這一操作被稱為清除階段。在掃描的同時,還需要將存活對象的標記清除掉,以便於下一次GC操作做好准備。標記清除算法的處理時間,是和存活對象與對象總數的總和相關的。

  標記清除算法的缺點:在分配了大量對象並且其中只有一小部分存活的情況下,所消耗的時間會大大超過必要的值,這是因為在清除階段還需要對大量死亡對象進行掃描

2.2 復制收集方式

  復制收集克服了標記清除的缺點,其基本原理是:將從根開始被引用的對象復制到另外的空間中,然后再將復制的對象所能夠引用的對象用遞歸的方式不斷復制下去

  下圖直觀地展示了復制手機的大致原理:

  ① 初始階段:

  ② 復制收集階段:

復制階段-1

復制階段-2

  ③ 清除階段:

  在清除階段會將舊空間廢棄掉,也就可以將死亡對象所占用的空間一口氣全部釋放出來,而沒有必要再次掃描每個對象。下次GC的時候,現在的新空間也就成為了下次的舊空間。

  復制收集的缺點是:和標記方式相比,將對象復制一份所需要的開銷比較大,因此在“存活”對象比例較高的情況下,反而比較不利。

2.3 引用計數方式

  引用計數方式是GC算法中最簡單也最容易實現的一種,其基本原理是:在每個對象中保存該對象的引用計數,當引用發生增減時對計數進行更新。引用計數的增減,一般發生在變量賦值、對象內容更新、函數結束(局部變量不再被引用)等時間點,當一個對象的引用計數變為0時,則說明它將來不會再被引用,因此可以釋放響應的內存空間

  下圖直觀地展示了引用計數方式的大致原理:

  ① 初始階段:

  ② 引用計數階段:

  當對象引用發生變化時,引用計數也會跟着變化。在這里,由對象B到對象D的引用失效了,於是對象D的引用計數變為0。由於對象D的引用計數變為了0,因此由對象D到對象C和對象E的引用數也分別相應減少。最后,對象D和對象E引用數變為了0,所以需要被清除。

  ③ 清除階段:

  所有引用計數變為0的對象都將被釋放,“存活”的對象則保留了下來。在整個GC處理過程中,並不需要對所有對象進行掃描。

  引用計數的優點在於:易於實現(標記清除和復制收集機制實現由難度);當對象不再被引用的瞬間就會被釋放(其他機制預測一個對象何時被釋放很困難)。

  引用計數的缺點在於:

  ① 無法釋放循環引用的對象

  ② 必須在引用發生增減時對引用計數做出正確的增減:想想漏掉了對某個對象計數的增減會怎么樣?

  ③ 引用計數管理並不適合並行處理:想想如果多個線程同時對引用計數進行增減又會怎樣?

三、GC的改良方式

GC的基本算法,大體上都逃不出上述三種方式以及它們的衍生品。現在,通過對這三種方式進行融合,出現了一些更加高級的方式。

3.1 分代回收方式

  由於GC和程序處理的本質是無關的,因此它所消耗的時間越短越好。分代回收的目的是為了在程序運行期間,將GC所消耗的時間盡量縮短。

  分代回收的基本思路是:大部分對象都會在短時間內成為垃圾,而經過一定時間依然存活的對象往往擁有較長的壽命。如果壽命長的對象更容易存活下來,壽命短的對象則會被很快廢棄。那么,對分配不久的“年輕”對象進行重點掃描,應該就可以更有效地回收大部分垃圾

  在分代回收方式中,對象會按照生成時間進行分代,剛剛生成不久的年輕對象划為新生代(Young generation),而存活了較長時間的對象划為老生代(Old generation)。對於不同的實現方式,可能還會划分更多的代,

  在.NET中,CLR就將內存中的對象分為了三代,每執行N次0代的回收,才會執行一次1代的回收,而每執行N次1代的回收,才會執行一次2代的回收。當某個對象實例在GC執行時被發現仍然在被使用,它將被移動到下一個代中上,下圖直觀地展示了CLR對三個代的回收操作:

Three Generations

  回想剛剛說到的幾種基本回收方式,我們可以將其組合一下來為分代回收奠定實現基礎。

  (1)首先,從根開始一次常規掃描,找到“存活”對象。這個步驟可以采用標記清除或復制收集,不過大多數分代回收的實現都采用了復制收集算法。不過在掃描的過程中,如果遇到被划分到更高級別的代的對象則不對該對象繼續進行遞歸掃描。這樣一來,需要掃描的對象數量就大幅度減少

  (2)其次,將第一次掃描后殘留下來的對象划分到更高級別的代上。具體來說,如果是用復制收集算法的話,只要將復制目標空間設置為更高級別的代就可以。而如果用標記清除算法的話,則大多采用在對象上設置某種級別標志的方式。但是,被分配到更高的級別的代上后,該對象所占用的內存空間的時間也會隨之增加,如何確保及時利用和釋放的平衡點也是需要考慮的。

3.2 增量回收方式

  在對實時性要求很高的程序中,往往更重視縮短GC的最大中斷時間(想想車輛制動控制程序因為GC而延遲響應的話后果是不堪設想的),必須能夠對GC所產生的中斷時間做出預測(例如將最多只能中斷10ms作為附加條件)。

  因此,為了維持程序的實時性,不等到GC全部完成,而是將GC操作細分成多個部分逐一執行,這種方式就被稱為“增量回收”(Incremental GC)。

  由於增量回收的過程是漸進式的,可以將中斷時間控制在一定長度之內,另外由於由於中斷操作需要消耗一定的時間,GC所消耗的總時間也會增加

3.3 並行回收方式

  在多核環境中,可以通過利用多線程發揮多CPU的性能,並行回收正是通過最大限度地利用多CPU的處理能力來進行GC操作的一種方式。

  並行回收的基本原理是:在原有程序運行的同時進行GC操作。相對於在一個CPU上進行GC任務分割的增量回收來說,並行回收可以利用多CPU的性能,盡可能讓這些GC任務並行(同時)進行。

  不過,要讓GC操作完全並行並且一點都不影響原有程序的運行是做不到的。因此,在GC操作的某些特定階段,還是需要暫停原有程序的運行。

四、GC大一統理論

  像標記清除和復制收集之類的算法是從根開始掃描以判斷對象生死的算法,被稱為跟蹤回收(Tracing GC)。而引用計數算法則是當對象之間的引用關系發生變化時,通過對引用計數進行更新來判定對象生死。

  2004年IBM研究中心發表了一篇論文,提出了一個理論:任何一種GC算法都是跟蹤回收和引用計數兩種方式的組合,兩者的關系正如“物質”和“反物質”一樣,是相互對立的。對其中一方進行改善的技術之中,必然存在對另一方進行改善的技術,而其結果只是兩者的組合而已

參考資料

(1)本文全文源自Ruby之父松本行弘的《代碼的未來》一書!

(2)霍旭東,《不得不知的CLR中的GC》【好文一篇,值得閱讀】

(3)cposture,《GC/垃圾回收簡介

(4)周旭龍,《.NET基礎拾遺之內存管理基礎

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM