深入理解Java垃圾回收——對象已死?

Java語言經過幾十年的發展,其內存分配策略與內存回收策略已經發展的相當成熟,一切看起來都進入到了“自動化”的時代了。但是在通往高級開發人員的道路上,在遇到各種內存溢出、內存泄漏的問題時,當垃圾回收成爲高併發的瓶頸時,如果不瞭解內存分配的策略、內存回收的策略,那麼肯定解決不了這些問題,註定成爲不了一名合格的高級開發人員。
在理解垃圾回收前,先在上帝視角上,不妨思考以下3個問題:
1.哪些內存需要回收?
2.什麼時候回收?
3.怎麼回收?

《深入理解JVM內存模型(運行時數據區域)》一文說過,堆內存是Java虛擬機管理的最大的一塊內存,Java中的對象也是分配存儲在這一塊區域中。方法區存放着各種常量,類型信息。一般而言,內存回收也是針對這一塊區域。而程序計數器、虛擬機棧、本地方法棧都是與線程同生共死,線程結束了,內存自然跟着釋放了。因此我們只考慮堆內存和方法區的內存回收。

在進行內存回收之前,虛擬機首先要弄清楚的是哪些內存符合回收標準?

引用計數算法

在對象中添加一個引用計數器,每有一個地方引用,計數器+1,當引用時效時,計數器-1.任何時刻當計數器的值爲0時,說明沒有任何引用指向這個對象,則這個對象就是“垃圾對象”了,可以被回收了。

客觀來說,引用計數法簡單高效,大多數情況下,這是一個內存回收很不錯的選擇。實際上也有一些語言是採用引用計數法來進行內存管理的,比如微軟的COM技術、Python語言、Squirrel(常用於遊戲腳本領域)等。但是在Java領域裏,沒有任何一款Java虛擬機採用引用計數法,因爲它看似簡單,但是在Java虛擬機要爲很多額外的場景做大量的工作,比如對象之間的循環引用。

可達性分析算法

這個算法的基本思路就是通過一系列的“GC Roots” 的根對象開始,向下遍歷整個引用關係,遍歷過程走過的路徑稱爲“引用鏈”。如果某個對象到GC Roots之間沒有任何引用鏈相連(從圖論的角度來說,從GC Roots到這個對象不可達),則證明這個對象就是“垃圾對象”了,可以被回收了。
在Java語言裏,可以作爲GC Roots的對象有以下幾種:
1.在虛擬機棧(棧楨中的局部變量表)中引用的對象,比如線程調用的方法的入參、局部變量等;
2.在本地方法棧中引用的對象;
3.在方法區中常量引用的對象,比如字符串常量池(String Table)裏的引用;
4.所有被同步鎖(synchronized)持有的對象;
5.Java虛擬機中常駐的對象,比如數據類型所對應的Class對象,一些常駐的異常對象等;
6.反映Java虛擬機內部情況的JMXBean、JVMTI中註冊的回調、本地代碼緩存等。

再談引用

不論是引用計數算法還是可達性分析算法,都離不開對象的引用。在JDK1.2版本之前,Java對引用的定義如下:如果reference類型存儲的數據代表着另一塊內存的存儲地址,就代表着該reference數據代表着某塊內存、某個對象的引用。這種定義正常情況下是正確的,但是它只能表述“被引用”和“未被引用”這兩種狀態,具備一定的狹隘性。針對一些“食之無味,棄之可惜”的對象,就顯得有些無能爲力。比如我們希望描述這樣一類的對象:當內存充足時,能夠保留在內存中,當內存緊張時,這些對象可以被拋棄。

實際上,這種“食之無味,棄之可惜”的對象在很多系統/框架的緩存功能都符合這種場景。

Java語言的開發團隊很顯然也注意到這一點,因此在JDK1.2版本對“引用”的概念進行了擴充。將引用分爲以下四種:
1.強引用

強引用是最傳統的“引用”的定義,是指在程序代碼之中普遍存在的引用賦值,即類似“Object obj=newObject()”這種引用關係。無論任何情況下,只要強引用關係還存在,垃圾收集器就永遠不會回收掉被引用的對象。

2.軟引用

軟引用是用來描述一些還有用,但非必須的對象。只被軟引用關聯着的對象,在系統將要發生內存溢出異常前(Full GC之後仍然內存不夠),會把這些對象列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的內存,纔會拋出內存溢出異常。在JDK 1.2版之後提供了SoftReference類來實現軟引用。

3.弱引用

弱引用也是用來描述那些非必須對象,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生爲止。當垃圾收集器開始工作,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK1.2版之後提供了WeakReference類來實現弱引用。

4.虛引用

虛引用也稱爲“幽靈引用”或者“幻影引用”,它是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛引用關聯的唯一目的只是爲了能在這個對象被收集器回收時收到一個系統通知。在JDK 1.2版之後提供了PhantomReference類來實現虛引用。

關於Java中這四種引用的詳細原理,以後會有文章專門介紹。

不可達的對象非死不可?

經過可達性分析算法之後,如果一個對象與GC Roots間不可達,那麼它非死不可嗎?非也!
真正宣告一個對象是否死亡,至少需要經過兩次標記。
如果對象不可達,此時將會被第一次標記;隨後會進行一次條件篩選,篩選的條件是此對象是否有必要執行finalize()方法。

判斷對象沒有必要執行finalize()有兩種情況:1.對象沒有重寫finalize()方法;2.對象重寫了finalize()方法,但是已經被調用過了。(一個對象的finalize()方法最多隻能被調用一次!

如果對象有必要執行finalize()方法,那麼該對象會被放置在F-Queue隊列中,虛擬機稍後會自動創建一個低優先級的Finalizer線程去執行這個隊列中對象的finalize()方法。

finalize()方法是對象最後一次逃脫被回收的機會。只要在finalize()方法中,重新與GC Roots相連,就可以避免被回收。Finalizer線程只負責觸發對象的finalize()方法的執行,但並不保證一定會等待它執行結束(如果finlize()方法執行緩慢或者死循環等極端情況,那麼當前線程將無限期阻塞,這將導致整個內存回收子系統崩潰,從而導致虛擬機運行崩潰!)

虛擬機在稍後會對F-Queue隊列進行第二次小規模的標記,如果對象在finalize()方法中成功地“拯救”了自己,那它就可以避免被回收。否則,這個對象將等待着垃圾收集器的回收!

方法區的回收

經典的垃圾收集器都是基於分代理論的,因此對於堆內存的回收效率很高,比如新生代通常一次可以回收70%~90%的空間。
但是對於方法區的回收,由於其回收條件非常嚴苛,因此回收效果也很不理想。實際上,《Java虛擬機規範》並沒有要求虛擬機實現方法區的垃圾收集,並且現實情況下,沒有任何一款虛擬機、任何一個垃圾收集器實現了方法區完整的垃圾收集。(在JDK11版本中的ZGC甚至就不支持類卸載)。
方法區的垃圾收集主要有兩部分:1.廢棄的常量;2.不在使用的類型。

回收廢棄常量與回收Java堆中的對象非常類似。舉個常量池中字面量回收的例子,假如一個字符串“java”曾經進入常量池中,但是當前系統又沒有任何一個字符串對象的值是“java”,換句話說,已經沒有任何字符串對象引用常量池中的“java”常量,且虛擬機中也沒有其他地方引用這個字面量。如果在這時發生內存回收,而且垃圾收集器判斷確有必要的話,這個“java”常量就將會被系統清理出常量池。常量池中其他類(接口)、方法、字段的符號引用也與此類似

判斷一個常量是否“廢棄”相對簡單,但是要判斷一個類型是否廢棄,則條件苛刻許多,它必須同時滿足以下3個條件:
1.該類的所有實例都已經被回收,也就是說在Java堆中不存在該類的任一實例及子類實例。
2.加載該類的類加載器已經被回收,這個條件十分苛刻,除非通過精心設計的可替換的類加載器場景,比如OSGi(模塊化下的類加載器)、JSP等。
3.該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問到該類的方法。

同時滿足上述3個條件的類型允許被虛擬機回收。注意,這裏說的是“允許”回收,具體是否要回收,由具體參數控制(HotSpot虛擬機提供了-Xnoclassgc參數進行控制)。
雖然說類型的卸載很苛刻,但是有某些場景下又是很有必要的,在大量使用反射、動態代理、CGlib等字節碼框架、動態生成JSP、OSGi等頻繁自定義類加載器場景中,通常都需要具備有類型卸載的能力,以免對方法區造成太大的內存壓力!

通過以上分析,我們知道了哪些對象是可以被回收的,當對象被判定爲“垃圾”對象之後,等待它的就是被垃圾收集器回收!垃圾收集器的運作機制是怎樣的,它遵循什麼樣的設計思想?請聽下回解析。

感謝閱讀,希望對你有所幫助。
特別說明:本文沒有任何商業目的,僅供交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章