寫在前面的話:本文是在觀看尚硅谷JVM教程后,整理的學習筆記,其觀看地址如下:尚硅谷2020最新版宋紅康JVM教程
1、垃圾
1.1、什么是垃圾
垃圾(Garbage)在Java語言中是指在運行程式中沒有任何指標指向的物件,這個物件就是需要被回收的垃圾,
如果不及時對記憶體中的垃圾進行清理,那么這些垃圾物件所占用的記憶體空間就會一直保留到應用程式結束,被保留的空間也無法被其他物件所使用,極可能導致記憶體溢位,
1.2、垃圾回收
垃圾回收(Garbage Collection)即常說的GC,GC的作用就是清理記憶體中的垃圾,釋放被占用的記憶體空間,高效地利用記憶體,如果不進行垃圾回收,釋放記憶體,則記憶體遲早會被消耗完畢,最終導致程式崩潰,因為程式在運行程序中是會不斷產生物件來占用記憶體的,
除了釋放成為垃圾的物件,垃圾回收有時也可以清理記憶體里的記憶體碎片,使其能在物理空間上能連成一片,以便JVM能將記憶體分配給新的物件,
1.3、Java的垃圾回收區域
在JVM中,只有方法區和堆區有垃圾回收的行為,其中堆又是垃圾回收的重點區域,即頻繁收集新生代的垃圾,較少收集老年代,基本不動永久代/元空間,
實際上,方法區的垃圾回收性價比較低,方法區中的垃圾回收主要是常量的回收和型別的卸載,但型別的卸載條件非常苛刻,需要同時滿足一下三個條件:
①該類的所有實體都已經被回收,也就是堆中不再有該類及其子類的實體,
②加載該類的類加載器已經被回收,
③該類對應的java.lang.class物件沒有在任何地方被參考,無法在任何地方通過反射訪問該類的方法,
實際上,很難同時滿足這三個條件,如類加載器這一條,JVM所創建的3個默認的類加載器是不會被回收的,即只有自己寫的類加載器才有可能會被回收,但除非有特殊需求,大部分情況下,我們并不會為每一個用戶類實作對應的類加載器,這也意味著絕大部分的類都不會在方法區被卸載并回收,
所以,實際上垃圾回收的重點就是堆區,
2、如何判斷垃圾
2.1、參考計數演算法
垃圾回收操作應該有如下兩種行為:
①判斷那些物件屬于垃圾,
②將判斷為垃圾的物件清除,
首先是判斷物件是否存活(是否已成為垃圾),
在堆中存放著幾乎所有的Java實體,在GC執行垃圾回收時,首先需要區分出那些實體是存活的物件,那些是已經死亡的物件,只有被標記為已經死亡的物件,GC才會在執行垃圾回收時,釋放掉其所占用的記憶體空間,
當一個物件已經不再被任何存活的物件繼續參考時,就可稱之為死亡物件,即垃圾,判斷物件是否已死一般有兩種方式:參考計數演算法和可達性演算法,
參考計數演算法(Reference counting),其具體實作就是,對每個物件保存一個整型的參考計數器屬性,被參考幾次就將該屬性設為這個值,用于記錄物件被參考的情況,
比如,對于物件A,只要有任何一個物件參考了A,則A的參考計數器就加一,當參考失效時,參考計數器的值就減一,若物件A的參考計數器值為0,即表示物件A不被使用,可以進行回收,
參考計數器的優點:
實作簡單,垃圾物件便于標識,效率高,回收也沒有延遲,
參考計數器的缺點:
①每個物件都會有參考計數器欄位,這樣的做法增加了存盤空間的開銷,
②每次參考的變化都需要更新參考計數器,加法和減法的操作又增加了時間開銷,
③就是參考計數器最嚴重的缺陷,即無法處理回圈參考的物件,
比如,有物件ObjA和ObjB,這兩個物件都有一個屬性Object instance;若令ObjA.instance = ObjB;,ObjB.instance = ObjA; ,那么ObjA和ObjB的參考計數器的值就始終無法為0(因為始終有一個參考指向他們),這就意味著,即使已經沒有其他物件參考ObjA和ObjB了,這兩個物件也無法被回收,這將會導致記憶體泄漏,
有代碼如下,
public class ReferenceCountTest {
//成員變數,沒有static,即非類獨有,每個物件一份,作用就是占記憶體
private byte[] bigSize = new byte[5 * 1024 * 1024]; //5MB
Object ref = null;
public static void main(String[] args) {
ReferenceCountTest obj1 = new ReferenceCountTest();
ReferenceCountTest obj2 = new ReferenceCountTest();
/**
* 互相參考,則兩個物件中的ref屬性都保存著另一個物件的參考
*/
obj1.ref = obj2;
obj2.ref = obj1;
/**
* 此時,將參考變數obj1和obj2都置為空,
* 則在當前執行緒的虛擬機堆疊中,再無變數參考剛new出來的兩個物件
*/
obj1 = null;
obj2 = null;
/**
* 此時,兩個物件在堆疊中的參考已經為空,即除了在堆中依然保留著互相參考外,
* 再無任何參考指向它們,故應該被判定為垃圾,
*
* 1、先不顯式地執行GC,看堆區中的占用情況
* 2、顯式地執行垃圾回收,再看堆區中的占用情況
* 使用虛擬機引數列印出GC細節:-XX:+PrintGCDetaile
*/
System.gc();
}
}
則在為參考型別obj1和obj2賦值,以及為他們所指向的物件的屬性ref賦值后,他們在記憶體中關系圖如下,

在將obj1和obj2置為null后,main方法中的obj1和obj2的參考斷開,示例圖如下,

此時,除了堆中的ReferenceCountTest類物件實體1和實體2互相參考外,已經再無任何參考指向他們,按理來說,此時的物件實體1和實體2都應該被回收,但由于這兩個實體物件中的屬性ref的值仍然保存著對方的地址,故參考計數器的值依然為1,則意味著這兩個物件無法回收,這是參考計數演算法的最大的缺陷,
其實,參考計數演算法在極端情況下,也有很高的延遲性,比如,在物件連環參考的情況下:若有參考指向物件A,而物件A又指向物件B,B又指向C,C又指向D,,,;如此情況下,如果指向物件A的參考消失,那么將引發連環的回收反應,而只有上一個物件被回收,它指向的下一個物件才能在下一次的GC中被判斷為垃圾回收,這就有了延遲性,參考計數演算法就顯得不那么及時,
目前主流的JVM都沒有采用參考計數器演算法,
2.2、可達性分析演算法
當前主流的商業語言,如Java、C#等都采用了可達性分析演算法來判斷物件是否存活,這種型別的垃圾收集通常叫做追蹤性垃圾收集(Tracing Garbage Collection),
相對于參考計數演算法,可達性分析演算法不僅也有簡單高效的特點,重要的是該演算法可以有效解決回圈參考的問題,
可達性分析演算法的基本思路如下,
以根物件(GC Roots)集合為起點,按照從上到下的方式搜索被根物件集合所連接的目標物件是否可達,在可達性演算法中,記憶體中的存活物件都會被根物件集合直接或間接的連接,而死亡的物件則不會被連接,
示意圖如下,

可以看出object7、8、9、10都沒有再被根物件集合里的物件直接或者間接參考,故都被判斷為垃圾物件,但并不是被判斷為垃圾物件就必然會被回收,實際上還有機會通過finalization機制,重新被判斷為存活物件,這將在后面介紹,
使用可達性分析演算法后,存活物件都會被GC Roots集合直接或間接連接著,搜索存活物件時走過的路徑被稱為參考鏈(Reference Chian),沒有被參考鏈相連的物件就是垃圾物件,
GC Roots
我們知道只有被GC Roots集合參考的物件才會被判定為存活物件,那么GC Roots集合中又包含了什么樣的物件呢?
在Java語言中,GC Roots集合包含以下的元素:
- 1、虛擬機堆疊中參考的物件
如各個執行緒被呼叫的,方法中的參考型別的引數、參考型別的區域變數等,這些參考都保存在虛擬機堆疊對應堆疊幀的區域變數表中,這些參考指向的物件都會被視為GC Roots中的物件, - 2、本地方法堆疊中JNI(即native方法)所參考的物件
這些物件被傳入本地方法中進行呼叫,且都還沒有進行釋放, - 3、類靜態屬性參考的物件
類靜態屬性屬于類,它隨著類的生命周期存在,而類是很少被回收的(類的回收條件剛才已提到),如果類靜態屬性是一個參考型別,并且該參考指向一個物件,那么該物件也會被加入到GC Roots中, - 4、常量所參考的物件
運行時常量池中常量所參考的物件,字串常量池中的參考所指向的物件, - 5、所有被同步鎖(Synchronized關鍵字)所持有的物件,
- 6、JVM內部的參考
如類的class物件,又如一些例外物件(NullPointerException、OutOfMemoryError等)
除了上述這些固定的GC Roots集合外,根據用戶所選擇的垃圾收集器以及當前回收的記憶體區域的不同外,還會選擇其他物件“臨時加入”到GC Roots,
比如,在只針對新生代的回收中,可能在老年代中有些物件參考了新生代中的物件,為了避免這些被老年代中的物件所參考的新生代物件被回收,所以需要將與新生代中有關聯的老年代中的物件也臨時加入到GC Roots中,
應用可達性演算法的注意點
如果要使用可達性分析來判斷物件是否可回收,那么分析作業必須在一個保證一致性的快照中進行,這點不滿足,分析結果就不會準確,
意思是,在進行可達性分析的期間,系統必須停止,不能出現在分析程序中,物件的參考關系還在不斷變化的現象,所謂的一致性快照就是,在JVM運行的某個時間點進行記錄,記錄此時間點JVM的所有狀態,然后才能根據這個快照進行可達性分析,這也是為什么GC時必須進行“Stop the Word”(系統暫停)的重要原因,
3、物件的finalization機制
Java語言提供了物件的終止(finalization)機制來允許開發人員提供物件被銷毀之前的自定義邏輯處理,實作這個機制的finalize方法在Object類中,由于Object類是所有類的父類或祖先類,同時finalize方法允許在子類中被重寫,所以實際上每個類都可以實作finalize方法,
物件的finalization機制就是:
經過可達性分析后,如果某個物件無法從所有的根物件訪問,那么說明這個物件已經不再被使用了,此時就會對這些不再被使用的物件進行第一次標記,然后,會查看這些無法到達的物件是否實作了finalize方法:
-
如果物件沒有實作finalize方法,則直接可以進行回收,
-
如果物件實作了finalize方法,那么就會將物件的finalize方法交給Finalizer執行緒來執行(一個由虛擬機創建的,低優先級的后臺執行緒),GC會對這些交給Finalizer執行緒執行后的物件進行第二次標記,如果在finalize方法中,物件又重新與GC Roots進行了關聯,比如將自己(this關鍵字)賦值給某個參考鏈上的物件的屬性(如
objectA = this;),那么物件將會重新存活 ,即該物件會被移出將要進行回收的集合中,如果沒有在finalize方法中復活自己,則會被第二次標記,此時物件才可以直接被回收,
Finalizer執行緒:
Finalizer執行緒是一個后臺執行緒,用于執行finalize方法,所有實作了finalize方法的物件都會被放在一個F-Queue佇列中,Finalizer執行緒會去運行這個佇列,Finalizer執行緒執行完佇列中的一個元素,則執行緒中虛擬機堆疊存盤的這個物件的參考就會被釋放,此時GC就可以根據該物件是否還與參考鏈相連接,來進行第二次標記,并決定是否回收這個物件,
我們不應該主動去實作finalize方法,因為:
①在Finalizer執行緒執行時,如果執行緒執行緩慢(比如某個物件的finalize方法有大量的回圈),那么其他的finalize方法就會一直處于等待狀態,這也意味著含有finalize方法的物件會一直被Finalizer執行緒所參考,那么GC就無法回收這些物件,finalize方法會影響GC的效率,尤其是大量的finalize方法或者一個糟糕的finalize方法(如前面說的大量回圈),
②我們想在finalize方法中實作某種操作,比如關閉連接,但是finalize方法的執行時間是沒有保證的,Finalizer執行緒是一個優先級很低的執行緒,則意味著不會馬上執行,它何時執行完全由GC執行緒決定,即在進行GC時,才會把finalize方法交給Finalizer執行緒去執行,如果沒有進行GC那么Finalizer執行緒就不會運行,則我們的操作就由于執行時間的不確定而給程式帶來隱患,
所以強烈建議不再使用finalize方法!!
4、垃圾收集演算法
4.1、標記-清除演算法
在經過可達性演算法分析和finalization機制后,一個物件是否存活已經能夠判斷出來了,那么接下來的操作就是回收死亡物件的記憶體,目前在JVM中,常見的垃圾收集演算法有三種,分別是①標記-清除演算法②標記-復制演算法③標記-整理演算法,
標記-清除演算法是一種非常基礎和常見的垃圾收集演算法,其執行程序如下:
當堆中的有效空間(available memory)被耗盡時,就會停止整個程式(STW),然后執行標記和清除的操作,
標記:采用可達性分析演算法,從參考根節點遍歷,標記所有被參考的物件,一般是在物件的物件頭(Header)中記錄為可達物件,
清除:對堆記憶體中的所有物件進行從頭到尾的線性遍歷,如果發現某個物件在其物件頭中沒有被標記為可達物件,則就將該物件所占用的記憶體回收,
圖示如下,

可以看出,標記清除演算法的優點就是簡單易實作,但其缺點也同樣很明顯:
①效率不算高,因為標記和清除都要進行遍歷,這也意味著標記和清除兩個程序都會因為物件的增加而效率下降,
②這種方式清理出來的空閑空間是不連續的,產生了記憶體碎片問題,故需要維護一個空閑串列,才能知道新物件該如何分配記憶體,而碎片問題可能會導致,即使記憶體空間足夠,大物件依然有可能無法存放的問題,
注意:
在垃圾回收中所謂的清除,并不是真的把對應的記憶體置空,而是把需要清除的物件地址保存在空閑的地址串列中,等有新物件需要分配記憶體空間時,會判斷垃圾物件的位置空間是否足夠,若足夠,則分配給新物件,
4.2、標記-復制演算法
為了解決標記-清除演算法的缺陷,研究出了標記-復制演算法,
其核心思想如下:
將記憶體空間分為大小相等的兩塊,每次只使用其中的一塊,在垃圾回收時,將正在使用的記憶體塊中標記為存活的物件復制到未被使用的記憶體塊中,然后一次性清理正在使用的記憶體塊中的所有物件,交換兩個記憶體塊的角色,完成垃圾回收,
圖示如下,

復制演算法的優點有:
①復制過去后保證了空間的連續性,不會出現“碎片問題”,
②實作比較簡單,不需要空閑鏈表的存在,直接移動指標分配記憶體,所以效率很高,
復制演算法的缺點有:
①可用記憶體空間縮小了一半,浪費了原來的記憶體
②由于需要復制物件至另一半空間,故有一定的空間開銷
③因為物件地址空間被改變,所以在復制過去后,還用花費一定的時間開銷來維護物件之間的參考關系,比如,如果堆疊中的參考指向了堆中某塊記憶體,經過復制演算法后,還要把這個參考進行修改才行,
特別地,當存活的物件很多時,復制演算法的效率就會降低,因為無論是復制物件本身的開銷還是維護物件間參考的開銷都會提高,所以,復制演算法要在垃圾物件多,而存活物件少的情況下才能發揮出優勢,否則光是復制物件就耗費了許多性能,
目前標記-復制演算法主要應用在新生代中,
在新生代中,對常規的應用程式進行垃圾回收時,通常一次可以回收70%-99%的記憶體空間,回收性價比很高,所以現在的商業虛擬機(如HotSpot)都是采用復制演算法來回收新生代,

4.3、標記-整理演算法
復制演算法的高效性是建立在存活物件少,垃圾物件多的前提下的,這種情況在新生代經常發生,但在老年代,更常見的情況是大部分物件都是存活物件,如果依然使用復制演算法,由于存活物件較多,復制的成本也很高,因此,基于老年代垃圾回收的特性,需要其他演算法,
標記-清除演算法也可以應用在老年代中,但是該演算法執行完記憶體回識訓會產生記憶體碎片,故需要在標記-清除演算法上進行改進,由此研究出了標記-整理演算法,
標記-整理演算法的基本程序如下:
第一階段:即標記階段,與標記-清除演算法一樣,從根節點開始標記所有被參考的物件,
第二階段:將所有存活物件壓縮(移動)到記憶體的一端,按順序排放,
最后,清理邊界外所有的空間,
實際上,標記-整理演算法的最終效果等同于標記-清除演算法執行完成后,再進行一次記憶體碎片的整理,二者的本質差異在于,標記-清除演算法是非移動式的回收演算法,而標記-整理演算法是移動式的,

可以看到,被標記的存活物件將被整理,按照記憶體地址依次排列,而未被標記的記憶體將被清理掉,如此一來,當我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比標記-清除演算法需要維護一個空閑串列顯然少了許多開銷,但是由于還要移動物件,所以實際上標記-整理演算法的執行效率低于標記-清除演算法,
標記-整理演算法的優點有:
①消除了標記-清除演算法中產生的碎片問題,我們需要給新物件分配記憶體時,只需要一個記憶體的起始地址即可,
②消除了復制演算法中,記憶體減半的高額代價,
標記-整理演算法的缺點有:
①從效率上看,標記-整理演算法要低于復制演算法和標記-清除演算法,
②移動物件的同時,如果物件被其他物件參考,則還要調整參考地址
③移動程序中,需要全程暫停用戶的應用程式(STW)
三種演算法的對比
| 標記-整理演算法(Mark-Compact) | 標記-清除演算法(Mark-Sweep) | 標記-復制演算法(Mark-Copying) | |
|---|---|---|---|
| 速度 | 最慢 | 中等 | 最快 |
| 空間開銷 | 少,不堆積碎片 | 少,堆積碎片 | 多,通常需要存活物件的2倍大小,不堆積碎片 |
| 移動物件 | 是 | 否 | 是 |
所以沒有最優的演算法,主要是看應用場景,
4.4、分代收集演算法
前面提到的三種垃圾收集演算法,并沒有哪一種能完全取代其他演算法,它們都具有各自的優勢和特點,同樣的這三種演算法都無法對所有型別(長生命周期、短生命周期、大物件、小物件)的物件進行回收,因此,根據不同型別的死亡物件,采用不同的垃圾收集演算法,這樣的演算法應用被稱為分代收集演算法(Generational Collection),嚴格來說分代收集演算法應該是一種垃圾收集的理論,
分代收集演算法基于這樣一個事實:不同物件的生命周期不同,因此不同生命周期的物件可以采用不同的收集方式,以便提高回收效率,分代收集演算法根據物件的不同型別將記憶體劃分為不同的區域,一般將堆劃分為新生代和老年代,
在Java程式運行中,會產生大量的物件,其中有些物件是與業務息息相關,比如Http請求中的Session物件,執行緒、Socket連接,這些物件跟業務直接掛鉤,因此生命周期較長,而有些物件的生命周期則較短,如String物件,由于其不可變的特性,系統會產生大量這些物件,有些物件甚至只使用一次即可回收,因此,使用分代垃圾收集演算法,性價比最好,
目前,幾乎所有的垃圾收集器都采用了分代收集演算法執行垃圾回收,
- 在堆區中新生代的特點是:區域相對老年代較小,物件生命周期短,存活率低,垃圾回收頻繁,
在這種情況下,復制演算法的回收整理速度是最快的,復制演算法的效率只和當前存活物件的多少有關,因此很適合新生代的回收,而復制演算法記憶體利用率不高的問題,通過兩個survivor區的設計得到了緩解,默認情況下,新生代和老年代在堆中的比例是1:2,而新生代中Eden區和兩個survivor區的比例為8:1:1,所以實際上只有新生代記憶體中的1/10來作為復制演算法所需的空閑區域,因此浪費的記憶體空間并不算大,
- 在堆中老年代的特點是:區域較大,物件生命周期長,存活率高,回收不如新生代頻繁,
在這種情況下,會存在大量的存活物件,復制演算法明顯不合適,故一般是由標記-清除演算法來實作或者是由標記-清除演算法和標記-整理演算法混合實作,原因如下:
①標記階段的開銷實際上是與存活物件的數量成正比(因為要遍歷所有物件)
②清除階段的開銷與所管理的區域的大小成正比(因為要遍歷所管理的記憶體區域)
③壓縮階段的開銷與存活獨享的數量成正比(因為要移動物件)
分代思想被現有的虛擬機廣泛使用,幾乎所有的垃圾回收器都會區分新生代和老年代,
4.5、增量收集演算法
上述的演算法在垃圾回收程序中都不可避免的處于一種Stop The World 的狀態,在STW狀態下,程式所有的用戶執行緒都會掛起,暫停一切正常作業,等待垃圾回收的完成,如果垃圾回收時間過長,應用程式被掛起很久,將嚴重影響用戶體驗或者系統的穩定性,為了解決這一問題,即對實時垃圾收集演算法的研究直接導致了增量收集(Incremental Collecting)演算法的出現,
增量收集演算法的基本思想如下:
如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那么可以讓垃圾收集執行緒和應用執行緒交替執行,每次,垃圾收集執行緒只收集一小片區域的記憶體空間,接著切換到應用程式執行緒,如此反復,直到垃圾收集完成,
增量收集演算法的基礎仍然是傳統的標記-清除和復制演算法,增量收集演算法通過對執行緒間沖突的處理,允許垃圾收集執行緒以分階段的方式完成垃圾標記、清理或者復制作業,
增量收集演算法的優點有:
使用這種方式,由于在垃圾回收程序中,間斷性地還執行了應用程式代碼,故減少了系統的停頓時間,
增量收集演算法的缺點有:
因為執行緒切換和背景關系轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降,
4.6、磁區演算法
一般來說,在相同條件下,堆空間越大,一次GC時所需要的的時間就越長,有關GC產生的停頓也就越長,為了更好地控制GC產生的停頓時間,將一塊大的記憶體區域分割為多個小塊,根據目標的停頓時間,每次合理的回收若干小塊,而不是整個堆空間,從而減少一次GC產生的停頓,
分代演算法按照物件的生命周期長短劃分為兩個部分,磁區演算法將堆空間劃分成連續的不同小區域,
每一塊小區域都獨立使用,獨立回收,這種演算法的好處是可以控制一次回收多少個小區間,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/222457.html
標籤:Java
上一篇:openvn 安裝和配置
下一篇:技術點17:AJAX請求
