天天看點

深入了解Java虛拟機——Java對象是否存活

深入了解Java虛拟機——Java對象是否存活的判斷方法

判斷對象是否存活的算法是這樣的:在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被使用的。客觀地說,引用計數算法(Reference Counting)雖然占用了一些額外的記憶體空間來進行計數,但它的原理簡單,判定效率也很高,在大多數情況下它都是一個不錯的算法。也有一些比較著名的應用案例,例如微軟COM(Component Object Model)技術、使用ActionScript 3的FlashPlayer、Python語 言以及在遊戲腳本領域得到許多應用的Squirrel中都使用了引用計數算法進行記憶體管理。但是,在Java領域,至少主流的Java虛拟機裡面都沒有選用引用計數算法來管理記憶體,主要原因是,這個看似簡單的算法有很多例外情況要考慮,必須要配合大量額外處理才能保證正确地工作,譬如單純的引用計數 就很難解決對象之間互相循環引用的問題。

從運作結果中可以清楚看到記憶體回收日志中包含“4603K->210K”,意味着虛拟機并沒有因為這兩 個對象互相引用就放棄回收它們,這也從側面說明了Java虛拟機并不是通過引用計數算法來判斷對象 是否存活的。

目前主流的商用程式語言的記憶體管理子系統,都是通過可達性分析(Reachability Analysis)算法來判定對象是否存活的。這個算法的基本思路就是通過一系列稱為“GC Roots”的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜尋,搜尋過程所走過的路徑稱為“引用鍊”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鍊相連, 或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。如下圖對象object 5、object 6、object 7雖然互有關聯,但是它們到GC Roots是不可達的, 是以它們将會被判定為可回收的對象。

深入了解Java虛拟機——Java對象是否存活

在Java技術體系裡面,固定可作為GC Roots的對象包括以下幾種:

在虛拟機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。 在方法區中類靜态屬性引用的對象,譬如Java類的引用類型靜态變量。 在方法區中常量引用的對象,譬如字元串常量池(String Table)裡的引用。 本地方法棧中JNI(即通常所說的Native方法)引用的對象。 Java虛拟機内部的引用,如基本資料類型對應的Class對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。 所有被同步鎖(synchronized關鍵字)持有的對象。 反映Java虛拟機内部情況的JM XBean、JVM TI中注冊的回調、本地代碼緩存等。

除了這些固定的GC Roots集合以外,根據使用者所選用的垃圾收集器以及目前回收的記憶體區域不 同,還可以有其他對象“臨時性”地加入,共同構成完整GC Roots集合。譬如将會提到的分代收集和局部回收(Partial GC),如果隻針對Java堆中某一塊區域發起垃圾收集時(如最典型的隻針對新生代的垃圾收集),必須考慮到記憶體區域是虛拟機自己的實作細節(在使用者視角裡任何記憶體區域都是不可見的),更不是孤立封閉的,是以某個區域裡的對象完全有可能被位于堆中其他區域的對象所引用,這時候就需要将這些關聯區域的對象也一并加入GC Roots集合中去,才能保證可達性分析的正确性。

無論是通過引用計數算法判斷對象的引用數量,還是通過可達性分析算法判斷對象是否引用鍊可 達,判定對象是否存活都和“引用”離不開關系。在JDK 1.2版之前,Java裡面的引用是很傳統的定義: 如果reference類型的資料中存儲的數值代表的是另外一塊記憶體的起始位址,就稱該reference資料是代表某塊記憶體、某個對象的引用。這種定義并沒有什麼不對,隻是現在看來有些過于狹隘了,一個對象在這種定義下隻有“被引用”或者“未被引用”兩種狀态,對于描述一些“食之無味,棄之可惜”的對象就顯得無能為力。譬如我們希望能描述一類對象:當記憶體空間還足夠時,能保留在記憶體之中,如果記憶體空 間在進行垃圾收集後仍然非常緊張,那就可以抛棄這些對象——很多系統的緩存功能都符合這樣的應 用場景。

在JDK 1.2版之後,Java對引用的概念進行了擴充,将引用分為強引用(Strongly Re-ference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱。

強引用是最傳統的“引用”的定義,是指在程式代碼之中普遍存在的引用指派,即類似“Object obj=new Object()”這種引用關系。無論任何情況下,隻要強引用關系還存在,垃圾收集器就永遠不會回收掉被引用的對象。 軟引用是用來描述一些還有用,但非必須的對象。隻被軟引用關聯着的對象,在系統将要發生内 存溢出異常前,會把這些對象列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的記憶體, 才會抛出記憶體溢出異常。在JDK 1.2版之後提供了SoftReference類來實作軟引用。 弱引用也是用來描述那些非必須對象,但是它的強度比軟引用更弱一些,被弱引用關聯的對象隻 能生存到下一次垃圾收集發生為止。當垃圾收集器開始工作,無論目前記憶體是否足夠,都會回收掉隻 被弱引用關聯的對象。在JDK 1.2版之後提供了WeakReference類來實作弱引用。 虛引用也稱為“幽靈引用”或者“幻影引用”,它是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象執行個體。為一個對象設定虛引用關聯的唯一目的隻是為了能在這個對象被收集器回收時收到一個系統通知。在JDK 1.2版之後提供了Phant omReference類來實作虛引用。

即使在可達性分析算法中判定為不可達的對象,也不是“非死不可”的,這時候它們暫時還處于“緩 刑”階段,要真正宣告一個對象死亡,至少要經曆兩次标記過程:如果對象在進行可達性分析後發現沒有與GC Roots相連接配接的引用鍊,那它将會被第一次标記,随後進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。假如對象沒有覆寫finalize()方法,或者finalize()方法已經被虛拟機調用過,那麼虛拟機将這兩種情況都視為“沒有必要執行”。

如果這個對象被判定為确有必要執行finalize()方法,那麼該對象将會被放置在一個名為F-Queue的隊列之中,并在稍後由一條由虛拟機自動建立的、低排程優先級的Finalizer線程去執行它們的finalize() 方法。這裡所說的“執行”是指虛拟機會觸發這個方法開始運作,但并不承諾一定會等待它運作結束。 這樣做的原因是,如果某個對象的finalize()方法執行緩慢,或者更極端地發生了死循環,将很可能導緻F-Queue隊列中的其他對象永久處于等待,甚至導緻整個記憶體回收子系統的崩潰。finalize()方法是對象逃脫死亡命運的最後一次機會,稍後收集器将對F-Queue中的對象進行第二次小規模的标記,如果對象要在finalize()中成功拯救自己——隻要重新與引用鍊上的任何一個對象建立關聯即可,譬如把自己 (this關鍵字)指派給某個類變量或者對象的成員變量,那在第二次标記時它将被移出“即将回收”的集 合;如果對象這時候還沒有逃脫,那基本上它就真的要被回收了。從下面中可以看到一個對象的finalize()被執行,但是它仍然可以存活。

從上面的運作結果可以看到,SAVE_HOOK對象的finalize()方法确實被垃圾收集器觸發過,并且在被收集前成功逃脫了。

另外一個值得注意的地方就是,代碼中有兩段完全一樣的代碼片段,執行結果卻是一次逃脫成 功,一次失敗了。這是因為任何一個對象的finalize()方法都隻會被系統自動調用一次,如果對象面臨下一次回收,它的finalize()方法不會被再次執行,是以第二段代碼的自救行動失敗了。

還有一點需要特别說明,上面關于對象死亡時finalize()方法的描述可能帶點悲情的藝術加工,并不鼓勵大家使用這個方法來拯救對象。相反,建議大家盡量避免使用它,因為它并不能等同于C和C++語言中的析構函數,而是Java剛誕生時為了使傳統C、C++程式員更容易接受Java所做出的一 項妥協。它的運作代價高昂,不确定性大,無法保證各個對象的調用順序,如今已被官方明确聲明為不推薦使用的文法。有些教材中描述它适合做“ 關閉外部資源”之類的清理性工作,這完全是對finalize() 方法用途的一種自我安慰。finalize()能做的所有工作,使用try -finally或者其他方式都可以做得更好、 更及時,是以建議大家完全可以忘掉Java語言裡面的這個方法。

有些人認為方法區(如HotSpot虛拟機中的元空間或者永久代)是沒有垃圾收集行為的,《Java虛拟機規範》中提到過可以不要求虛拟機在方法區中實作垃圾收集,事實上也确實有未實作或未能完整實作方法區類型解除安裝的收集器存在(如JDK 11時期的ZGC收集器就不支援類解除安裝),方法區垃圾收集的“成本效益”通常也是比較低的:在Java堆中,尤其是在新生代中,對正常應用進行一次垃圾收集通常可以回收70%至99%的記憶體空間,相比之下,方法區回收囿于苛刻的判定條件,其區域垃圾收集的回收成果往往遠低于此。

方法區的垃圾收集主要回收兩部分内容:廢棄的常量和不再使用的類型。回收廢棄常量與回收Java堆中的對象非常類似。舉個常量池中字面量回收的例子,假如一個字元串“ java”曾經進入常量池中,但是目前系統又沒有任何一個字元串對象的值是“ java”,換句話說,已經沒有任何字元串對象引用常量池中的“ java”常量,且虛拟機中也沒有其他地方引用這個字面量。如果在這時發生記憶體回收,而且垃圾收集器判斷确有必要的話,這個“ java”常量就将會被系統清理出常量池。常量池中其他類(接 口)、方法、字段的符号引用也與此類似。

判定一個常量是否“廢棄”還是相對簡單,而要判定一個類型是否屬于“不再被使用的類”的條件就比較苛刻了。需要同時滿足下面三個條件:

該類所有的執行個體都已經被回收,也就是Java堆中不存在該類及其任何派生子類的執行個體。 加載該類的類加載器已經被回收,這個條件除非是經過精心設計的可替換類加載器的場景,如OSGi、JSP的重加載等,否則通常是很難達成的。 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射通路該類的方法。

Java虛拟機被允許對滿足上述三個條件的無用類進行回收,這裡說的僅僅是“被允許”,而并不是 和對象一樣,沒有引用了就必然會回收。關于是否要對類型進行回收,HotSpot虛拟機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading檢視類加載和解除安裝資訊,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虛拟機中使用,-XX:+TraceClassUnLoading參數需要FastDebug版的虛拟機支援。

在大量使用反射、動态代理、CGLib等位元組碼架構,動态生成JSP以及OSGi這類頻繁自定義類加載 器的場景中,通常都需要Java虛拟機具備類型解除安裝的能力,以保證不會對方法區造成過大的記憶體壓力。