目錄
JVM-運行時數據區域
JVM-對象及其內存布局
JVM-垃圾收集算法基礎
前言
上一篇文章對JVM的對象的內存布局以及對象創建邏輯等內容進行了梳理,本篇文章對常見的垃圾回收算法以及HotSpot垃圾回收器進行深入解析。
手動釋放內存導致的問題
在托管代碼出現之前,我們申請一片內存使用完后,需要手動釋放內存。手動釋放有以下幾個問題。
- 忘記釋放
忘記釋放內存,會導致內存溢出。程序長時間申請的內存一直不釋放。最終可能導致進程內存占滿。
- 重復釋放
忘記釋放對程序本身的執行的正確性不會產生影響,另一種更嚴重的問題是重復釋放。當已經釋放過后,該地址被其他地方重新分配。此時又再次釋放或使用了該內存,可能會導致無法預料的現象。
int* p = new int(5);
*p = 5;
delete p;
delete p;//此時p指向的地址可能被重新分配使用
部分編譯器會阻止重復釋放的問題,良好的編碼是在delete后將指針置空。
- 釋放類型錯誤
創建的是數組,釋放的不是數組。這種情況若創建的時基本類型,那么不會有什么問題,但是創建元素不是基本類型而是對象,由於new T[n]
實際分配的內存空間是n*sizeof(T) + 4
,額外的4個字節用於存放數組元素個數。在調用delete []
時,前面4個字節記錄的元素個數分別執行各個元素的析構函數,然后調用free (p-4)
釋放內存。
Tclass * p = new Tclass[20];
p[0] = 1;
delete p; //應該使用delete []
- 內存釋放錯誤
int a = 200;
int b = 500;
int *p = new int(a);
p = &b; //(1)
delete p;
p = NULL;
在delete p時會有2個問題。
- p原來指向的地址成了內存泄漏(new int產生的地址) ,因為沒釋放。
- p原先指向的是堆內存, 后續操作已經指向局部變量(棧空間)對象地址了,而棧空間是無須進行delete的,因此delete 棧空間時會報錯。
垃圾判定方法
由於手動釋放內存可能導致各種問題,因此在C、C++等非托管語言中,程序員手動進行內存管理,需要非常小心。
為了避免上述手動內存管理造成的問題,因此出現了自動管理內存的GC算法。把內存管理交給計算機,避免了手動釋放內存導致的問題。
自動內存管理做了2件事情:找出垃圾和回收垃圾,使得該內存可以重復使用。通常不需要再使用(不被引用)的對象被稱為垃圾,發現和釋放被垃圾占用的空間稱為垃圾收集。
那么如何才能確定哪些對象是垃圾?目前主要有2種方法,引用計數法和可達性分析法。
哪些對象是垃圾?
引用計數算法
1960年,George E. Collins 在論文中發布了叫作引用計數的GC算法。引用計數法中引入了一個概念,那就是“計數器”。計數器表示的是對象引用了這個對象(被引用數)。計數器是無符號的整數,用於存儲計數器的空間大小根據算法和實現而有所不同。每當有一個對象引用自己,就把自己的引用計數+1。每當一個對象不再引用自己,就把自己的引用計數-1,當引用計數為0時,則表示對象成為垃圾,內存就可以被回收。
假設一開始有ABC三個對象,其中A引用了B,A和C被根(比如棧幀中的局部變量表)引用。此時他們的計數都為1。
接下來A引用了C,B不再被A引用。此時B的引用計數歸0,B的內存就被空閑鏈表所連接,下次B的內存可以被重新分配重復使用。而C的引用計數從1變為2。
在引用計數法中,每個對象始終都知道自己的被引用數。當被引用數的值為0時,對象馬上就會把自己作為空閑空間連接到空閑鏈表。也就是對象的內存空間可以被立即回收,垃圾回收效率非常高。但是引用計數法有個致命缺點,就是無法處理循環引用。
假設A和B對象存在循環引用,在方法執行的時候他們的引用計數都為2。
當方法執行完,棧幀彈出后,A和B對象的引用計數減為1,此時由於他們計數永遠無法到0,因此他們的內存無法被釋放。
引用計數法除了無法處理循環引用的問題外,還有幾個其他的問題,比如每次指針更新都需要更新計數器,計數器需要占用空間等。為了解決引用計數法的一些缺點,衍生出許多其他的引用計數法,比如延遲引用計數法、Sticky引用計數法、Sticky引用計數法等,這里不做具體討論。
由於引用計數法無法解決循環引用問題,因此Hotspot VM的堆區域的垃圾收集算法並沒有采用引用計數法。而方法區中的運行時常量池(比如字符串表)因為不存在循環引用的問題,因此采用了引用計數法。
可達性分析法
當前主流的商用程序語言(Java、C#,上溯至前面提到的古老的Lisp)的內存管理系統,都是通過可達性分析(Reachability Analysis) 算法來判定對象是否存活的。這個算法的基本思路就是通過一系列稱為GC Roots
的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑稱為“引用鏈”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。
在JVM中GC Roots包括以下幾種:
- 虛擬機棧引用的對象(實際是棧幀中的局部變量表引用的對象),如某方法的參數、局部變量等。
- 方法區中靜態變量引用的對象,如類中的靜態引用類型變量。
- 方法區中常量引用的對象,比如字符串表的引用。
- 本地方法棧中JNI引用的對象。Java方法需要調用Native方法都需要通過JVM提供的JNI進行調用。
- 以及其他JVM內部的引用,比如如klass對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfMemoryError)等,系統類加載器,被同步鎖(synchronized關鍵字)持有的對象,本地代碼緩存等。
垃圾收集算法
通過引用計數法和可達性分析法,我們可以找出哪些是垃圾。找出垃圾,需要將垃圾清除。
前面說了引用計數法的垃圾清除非常簡單,只要計數為0時,就可以將對象占用的內存空間加入到空閑鏈表中。內存就可以被回收利用。但是可達性分析法的垃圾清除並不容易。可達性分析並不是對象變為垃圾時就能立即回收。它需要一個查找的過程,通過查找整個堆空間或部分堆空間找出垃圾。然后才能對垃圾進行清除。19世紀60年代產生了幾種垃圾收集算法,一直使用到現在。這些算法包括標記-清除、標記-復制和標記-整理。
標記-清除
最早出現的垃圾收集算法是 標記-清除(Mark-Sweep) 算法,Lisp之父和人工智能之父John McCarthy在1960年在其論文中首次提到了該GC算法。
標記-清除算法和名字一樣,包含2個步驟:
- 標記階段:遍歷
GC Roots
標記所有存活對象。 - 清除階段:遍歷整個堆,回收所有沒有被標記的對象,將垃圾對象的內存空間連接到空閑鏈表。
當然也可以標記所有不存活對象,清理所有被標記對象。
回收前內存
回收后內存
優點
- 無需移動指針
由於標記-清除算法只清除內存,而不壓縮內存,因此對象地址不會發生改變,對象不移動時可以與其他GC算法相互組合使用。 - 實現簡單
標記-清除只需要2個步驟,實現非常簡單,與其他GC算法組合起來也就很簡單了。
在Hotspot虛擬機中,標記-清除算法通常用在老年代。比如CMS垃圾收集器的老年代就是使用標記-清除算法。
缺點
-
內存碎片
可以發現標記-清除有個嚴重的問題就是會造成內存碎片。當清理階段時,會將垃圾對象空間插入到空閑鏈表中,若多個回收區域內存是連續的,還會將他們合並成一個空間。另外由於標記-清除算法會出現內存碎片,還會導致分配對象的效率降低。
-
對象分配速度慢
當分配對象的時候,需要則遍歷每個空閑鏈表的節點。通常使用空閑鏈表有三種分配方式:First-fit、Best-fit和Worst-fit。
- First-fit: First-fit優先找到第一塊大於或等於需要分配內存大小的空閑塊。如果空閑塊大於所需分配的內存時將其分割為2塊。
- Best-fit: Best-fit會遍歷整個空閑鏈表,盡可能找到最適合的內存塊,它會找到大於或等於需要分配內存大小的最小分塊。
- Worst-fit: Worst-fit會找出空閑鏈表中最大的分塊,將其分割成mutator申請的大小和分割后剩余的大小,目的是將分割后剩余的分塊最大化。但因為Worst-fit很容易生成大量小的分塊,一般不推薦使用。
-
與寫時復制技術不兼容
標記-清除 算法除了會產生內存碎片以外,還無法和寫時復制技術一起使用。由於 標記-清除 算法需要修改對象頭部的標記用於記錄對象是否存活,因此,發生GC時,存活的對象頭部的標記就會被修改,從而導致出現內存復制。寫時復制可以通過共享內存的方式將同一份內存空間共享給多個進程使用,而當內存被修改時,則需要復制進程私有的內存空間並修改。發生寫時復制后該進程只能訪問私有的空間。從而在只讀的情況下提高內存使用效率。
-
吞吐量與堆大小相關
標記-清除 算法在清除階段需要遍歷整個堆,找到沒有標記的對象進行清除。因此隨着堆增大,GC效率就會降低。
優化
為了解決 標記-清除 算法由於內存碎片導致對象分配速度慢的問題,衍生出一些優秀的算法。
- 多個空閑鏈表:將不同大小的空閑塊分組,提高內存分配速度。
- BiBOP(Big Bag Of Pages)法:把堆分割成固定大小的塊,讓每個塊只能配置同樣大小的對象。分配指定大小的對象,直接找到對應的塊進行分配。
- 位圖標記:將對象標記從對象頭提取出,放在一個單獨的位圖表格種,使得標記-清除算法與寫時復制技術兼容。
- 延遲清除法:由於清除算法需要遍歷整個堆,因此將清除步驟延遲到創建對象的步驟處理,降低最大暫停時間。清除算法會記錄上一次清除的位置,當創建對象的時候,會從上一次清除的位置繼續向后清除,因此不會遍歷整個堆。
標記-復制
標記-清除 算法高效的同時帶來了內存碎片的問題,而引用計數法又無法回收循環引用的對象。
1963年,也有“人工智能之父”之稱的Marvin L. Minsky在論文中發布了復制算法。
1969年,Fenichel提出了一種稱為“半區復制”(Semispace Copying)的垃圾收集算法,它將可用內存按容量划分為大小相等的兩塊。一塊稱為From空間,另一塊稱為To空間。將From空間的內容復制到To空間,然后把From空間進行清理。
標記-復制算法包含3個步驟:
- 划分區域:將內存分為2塊等大的區域。
- 復制:將存活的對象從From區域復制到To區域。
- 清除:將From區域進行整體清除。
- 互換:將From區域和To區域互換。原來的To區域變為新的From區域,原來的From區域變為新的To區域。
回收前內存
回收后內存
優點
-
無碎片化
從上面的過程可以發現,標記-復制 算法處理完后,內存就處於規整狀態。就不會發生實際可用內存足夠,但是卻沒有足夠的內存塊分配大對象的情況。
-
對象分配速度快
由於標記-復制算法不會產生碎片,因此分配對象的邏輯就非常簡單快速。在JVM-對象及其內存布局也提到了,當內存規整時,可以使用 指針碰撞 的方式快速的分配對象。而無需遍歷空閑鏈表。
-
GC吞吐量高
標記-復制 算法使用深度優先搜索的方式遍歷每個
GC Roots
對象。先將根對象和根對象的所有引用的子對象復制到To區域,再處理下一個根對象。在清理時直接清理整個From區域,無需遍歷整個堆。因此 標記-復制 算法的效率與堆大小無關,只與存活對象(被GC根引用的對象)數量相關。 -
與緩存兼容
標記-復制 算法會將引用相關的對象按續排放,可以充分利用緩存實現高速訪問。
缺點
-
內存利用率低
由於 標記-復制 算法將區域分為兩半,只有一半可以使用,因此會造成內存的浪費。相當於使用空間換時間,且空間的成本相對較高。
-
對象移動
標記-復制算法每次都會移動對象,因此與一些要求對象不移動的GC算法不兼容。
比如某個地址上存的不是對象地址而是具體的數值時,復制算法會導致該地址更新為新的值(地址)。
-
遞歸調用
普通的 標記-復制 算法會遞歸調用子對象重復 標記-復制 操作,就會存在執行新的方法而創建棧幀,增加額外的資源消耗。不過在1970年出現了迭代式的 標記-復制 算法,解決了這個問題。
優化
- 1970年,C. J. Cheney研究出了迭代式標記-復制GC算法,優化了傳統遞歸調用導致的資源占用問題。
- 1991年,Paul R. Wilson、Michael S. Lam和Thomas G. Moher提出的近似深度優先搜索法,解決了迭代式標記-復制GC算法由於廣度優先搜索策略造成無法重復使用緩存的問題。
- 另外還出現了多空間復制算法,優化了堆使用率。傳統的復制算法只能利用一半的堆空間。而多空間復制算法采用組合算法方式進行垃圾收集,該算法將堆分為10份,其中2份使用復制算法,另外8份采用標記-清除算法。
- 在JVM中,采用的是1989年Andrew Appel針對具備“朝生夕滅”特點的對象,研究出的半區復制分代策略,現在稱為“Appel式回收”。它僅僅使用整個堆中的一小塊使用標記-復制算法,從而提高內存使用率。
標記-整理
標記-復制 算法解決了 標記-清除 算法內存碎片的問題,但是也同時帶來另一個問題,就是內存使用率低的問題。1974年Edward Lueders提出了另外一種有針對性的 標記-整理(Mark-Compact) 算法(也可以叫標記-壓縮算法)。
標記-整理 算法包含2個步驟:
- 標記階段:標記所有可回收的對象。
- 整理階段:分為3個步驟
- 查找新地址
- 更新指針
- 移動對象。
在最基本的 標記-整理 算法,每個步驟都需要遍歷整個堆。
標記-整理 算法解決了 標記-復制 算法的空間占用問題,但是由於整理階段需要遍歷多次堆,因此性能相比其他兩種GC算法,也會差很多。可以發現每種垃圾回收算法都有自己的優點和缺點。並不存在一個完美的算法能夠同時兼顧空間和時間。
回收前內存
回收后內存
優點
- 堆利用率高
能夠使用全部的堆內存。不像 標記-清除 和 標記-復制 算法只能使用部分堆內存。 - 不會產生內存碎片
由於 標記-整理 會整理堆,使其變為規整。因此不會產生內存碎片,另外對象的分配速度也很快。
缺點
由於整理階段,需要多次遍歷整個堆。因此GC性能較差,甚至可能長時間出現停頓(STW)現象。
優化
- Robert A. Saunders研究出來的名為Two-Finger的壓縮算法,只需要搜索兩次堆,比傳統的算法少一次堆的搜索,但是該算法要求所有對象大小必須一致,且不會按對象原來的順序進行壓縮(無法使用CPU高速緩存)。
- B. K. Haddon和W. M. Waite於1967年研究出來的表格算法,該算法比較復雜,但是解決了
Two-Finger
會導致對象順序改變的問題,可以通過緩存提高訪問速度。 - Stephen M. Blackburn和Kathryn S. McKinley於2008年研究出來的ImmixGC算法,將標記-清除算法和壓縮組合使用。由於存在標記-清除算法,因此存在內存碎片的情況。另外該算法堆內存分塊處理,在極端情況可能會導致一個塊只能存一個對象導致內存使用率不高,另外該算法在壓縮的時候也不會顧及對象原始順序。
參考文檔
- 《垃圾回收算法與實現》
- 《深入理解Java虛擬機》
微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:https://www.cnblogs.com/Jack-Blog/p/14853396.html
作者:傑哥很忙
本文使用「CC BY 4.0」創作共享協議。歡迎轉載,請在明顯位置給出出處及鏈接。