JVM高級特性與實踐(二):對象存活判定算法(引用) 與 回收
關於垃圾回收器GC(Garbage Collection),多數人意味它是Java語言的伴生產物。事實上,GC的歷史遠比Java悠遠,於1960年誕生在MIT的Lisp是第一門真正使用內存動態分配和垃圾收集技術的語言。當Lisp尚在胚胎時期,開發人員就在思考GC需要完成的3件事情:
- 哪些內存需要回收?
- 什麽時候回收?
- 如何回收?
目前GC早已解決了以上問題,內存的動態分配與內存回收機制已經相當成熟,一切似乎“自動化”起來。而開發人員仍舊需要了解GC和內存分配等底層知識,因為在排查各種內存溢出、內存泄漏問題、垃圾收集成為系統達到更高並發量的瓶頸時,開發人員需要對這些“自動化”技術實施必要的監控和調節。
在上一篇博文中介紹了Java內存運行時區域的各個部分,其中
-
程序計數器、虛擬機棧、本地方法棧 3個區域隨著線程而生,也隨線程而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配的內存基本上是在類結構確定下來時就已知的,因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內不需要過多考慮回收問題,因為方法結束或線程結束時,內存自然跟隨著回收了。
-
而Java堆和方法區則不一樣,一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期間才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,垃圾回收器關註的是這部分內存,後續討論的“內存”分配回收也是指這一塊,尤其需要註意。
JVM高級特性與實踐(一):Java內存區域與內存溢出異常
一. 對象是否已死
在堆裏存放著Java世界中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,首要的就是確定這些對象中哪些還“存活”著,哪些已經“死去”(即不可能再被任何途徑使用的對象)。
1. 引用計數算法(Reference Counting)
(1)算法含義
很多教科書判斷對象是否存活的算法:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器加1;當引用失效時,計數器減1;任何時刻計數器為0的對象就是不可能再被使用的。
(2)算法效率分析
我相信大部分人對這個算法並不陌生,客觀地說,引用計數算法的實現簡單,判定效率也很高,大部分情況下是一個不錯的算法。例如微軟公司的COM(Component Object Model)技術、使用ActionScript3的FlashPlayer、Python語言都是用了該算法進行內存管理。但是Java虛擬機中並沒有引用該算法來管理內存,最主要的原因是它很難解決對象之間互相循環引用
(3)舉例證明
舉個例子來證明,以下代碼中的 testGC()
方法:對象objA 和對象objB都有字段instance,賦值令objA.instance = objB;
、objB.instance = objA;
,除此之外,這兩個對象再無引用,實際上這兩個對象不可能再被訪問,但是他們互相引用著對方,導致它們引用計數不為0,所以引用計數器無法通知GC收集器回收它們。
【引用計數器的缺陷】
/**
* testGC()方法執行後,objA和objB會不會被GC呢?
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 這個成員屬性的唯一意義就是占點內存,以便在能在GC日誌中看清楚是否有回收過
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假設在這行發生GC,objA和objB是否能被回收?
System.gc();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
運行結果:
[Full GC (System) [Tenured: 0k->210K(10240K), 0.0149142 secs] 4603K->210K(19456K),
[Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
- 1
- 2
- 1
- 2
結果分析:
從運行結果可發現,GC日誌中包含“4603K->210K”,意味著虛擬機並沒有因為這兩個對象互相引用就不回收它們,這也從側面說明虛擬機並不是通過引用計數算法來判斷對象是否存活的。
2 . 可達性分析算法(Reachability Analysis)
(1)算法含義
在主流的商用程序語言(Java、C#、甚至是最古老的Lisp)的實現中,都是通過可達性分析來判定對象是否存活的。此算法的基本思路是:通過一系列的稱為“GC Roots”的對象作為起點,從這些節點向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是GC Roots 到這個對象不可達)時,則證明此對象時不可用的。
(2)圖解說明
如下圖舉例所示,對象object5、object6、object7雖然互相有關聯,但是它們到GCRoots是不可達的,所以它們將會被判定為可回收對象。
(3)Java中可作為GCRoots的對象
在Java中,可作為GCRoots的對象包括以下幾種:
- 虛擬機棧(棧幀中的本地變量表)中引用的對象
- 方法區中類靜態屬性引用的對象
- 方法區中常量引用的對象
- 本地方法棧中JNI(即一般說的Native方法)引用的對象
3. 再談引用(強、軟、弱、虛引用)
(1)“引用”舊概念
無論時通過計數算法判斷對象的引用數量,還是通過可達性分析算法判斷對象的引用鏈是否可達,判定對象是否存活都與“引用”有關。在JDK1.2以前,Java中的引用定義很傳統:如果reference類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊內存代表著一個引用。
這種定義很純粹但過於狹隘,一個對象中在這種定義下只有被引用或者沒有被引用兩種狀態,缺少另外一類對象的描述:當內存空間足夠時,能保留在內存中;如果內存空間在進行垃圾收集後還是非常緊張,則可以拋棄這些對象。很多系統的緩存功能都符合這樣的應用場景。
(2)“引用”新概念
在JDK1.2之後,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,引用強度依次逐漸減弱。4種引用解釋如下:
-
強引用:就是指在程序中普遍存在的,類似
Obejct obj = new Object()
這種引用。只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。 -
軟引用:用來描述一些還有用但並非必需的對象。對於軟引用關聯的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍進行第二次回收。如果這次回收還沒有足夠的內存,才會拋出內存溢出異常。在JDK1.2之後,提供了SoftReference類來實現軟引用。
-
弱引用:也是用來描述非必需對象,但它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK1.2之後,提供了WeakReference類來實現弱引用。
-
虛引用:也稱為幽靈引用或幻影引用(好炫的稱號hhh),它是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。在JDK1.2之後,提供了PhantomReference類來實現虛引用。
4 . 生存還是死亡
to be or not to be, this is a question.
(1)宣判對象“死亡”的過程
扯遠了,回到正文來。即使在可達性分析算法中不可達的對象,也並非是要宣判“死亡”的,它們暫時都處於“緩刑”階段,要真正宣告一個對象“死亡”,至少要經歷兩次標記過程:
- 第一次標記:如果對象在進行可達性分析後發現沒有與 GC Roots相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行
finaliza()
方法。 - 第二次標記:當對象沒有覆蓋
finaliza()
方法,或者finaliza()
方法已經被虛擬機調用過,虛擬機將這兩種情況都視為“沒有必要執行”。
(2)finaliza()方法 —– 對象逃脫“死亡”的最後機會
如果這個對象被判定為有必要執行finaliza()
方法,那麽此對象將會放置在一個叫做 F-Queue 的隊列中,並在稍後由一個虛擬機自動建立的、低優先級的Finalizer線程去執行它。這裏所謂的“執行”是指虛擬機會觸發此方法,但並不承諾會等待它運行結束,原因是:如果一個對象在finaliza()
方法中執行緩慢,或者發生了死循環(更極端的情況),將很可能導致F-Queue 隊列中的其它對象永久處於等待,甚至導致整個內存回收系統崩潰。
finaliza()
方法是對象逃脫死亡命運的最後一次機會,稍後GC將對F-Queue 隊列中的對象進行第二次小規模的標記。如果對象想在finaliza()
方法中成功拯救自己,只要重新與引用鏈上的任何一個對象建立關聯即可,例如把自己(this關鍵字)賦值給某個類變量或者對象的成員變量,這樣在第二次標記時它將被移出“即將回收”的集合;如果對象這時候還沒有逃脫,基本上它就被回收了。
(3)實例證明對象的自救
通過以下代碼展示一個對象的finaliza()
被執行,但是它仍然可以存活的例子:
【一次對象自我拯救的演示】
/**
* 此代碼演示了兩點:
* 1.對象可以在被GC時自我拯救。
* 2.這種自救的機會只有一次,因為一個對象的finalize()方法最多只會被系統自動調用一次
* @author zzm
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize mehtod executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//對象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因為Finalizer方法優先級很低,暫停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面這段代碼與上面的完全相同,但是這次自救卻失敗了
SAVE_HOOK = null;
System.gc();
// 因為Finalizer方法優先級很低,暫停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
運行結果:
finalize method executed!
yes, i am still alive :)
no, i am dead :(
- 1
- 2
- 3
- 1
- 2
- 3
結果分析:
由以上結果可知,SAVE_HOOK 對象的finalize()
方法確實被GC收集器觸發過,並且在收集前成功逃脫了。
另一個值得註意的地方,代碼中有兩段一模一樣的代碼段,執行結果卻是一次逃脫成功,一次失敗。這是因為任何一個對象的finalize()
方法都只會被系統調用一次,如果對象面臨下一次回收,它的finalize()
方法不會再被執行,因此第二次逃脫行動失敗。
(4)有關finaliza()方法的建議
需要特別說的是,上面關於對象死亡時finalize()
方法的描述具有悲情色彩,作者並不建議開發人員使用這種方法拯救對象。應當盡量避免使用它,因為它不是C/C++中的析構函數,而是Java剛誕生時為了使C/C++程序員更容易接受它所做的一個妥協。它的運行代價高昂,不確定性大,無法保證各個對象的調用順序。
有些教材中描述它適合做“關閉外部資源”之類的工作,這完全是對此方法用途的一種自我安慰。finalize()
能做的工作,使用try-finally 或者其它方法都更適合、及時,所以作者建議大家可以忘掉此方法存在。
5 . 回收方法區
(1)垃圾收集
大多數人認為方法區沒有垃圾回收,Java虛擬機規範中確實說過不要求,而且在方法區中進行垃圾收集的“性價比”較低:在堆中,尤其是新生代,常規應用進行一次垃圾收集可以回收70%~95%的空間,而方法區的效率遠低於此。
方法區的垃圾收集主要回收兩部分:廢棄常量和無用類。
(2)“廢棄常量”的回收
回收廢棄常量與回收Java堆中的對象非常類似。以常量池中字面量的回收為例,例如一個字符串“abc”已經進入常量池,但是無任何String對象引用常量池的此常量,也無其它引用此字面量,此時發送內存回收,“abc”常量會被系統清理出常量池。常量池中的其他類(接口)、方法、字段的符號引用也是如此。
(3)“無用類”回收的條件
判定一個常量是否是“廢棄常量”比較簡單,而判定一個類是否是“無用類”的條件較為苛刻,需同時滿足以下3個條件:
- 該類的所有實例已被回收,也就是Java堆中不存在該類的任何實例。
- 加載該類的ClassLoader已被回收。
- 該類對應的java.lang.Class對象沒有在任何地方被引用,無法通過反射訪問該類的方法。
虛擬機可以滿足以上3個條件的無用類進行回收,這裏僅說“可以”,並非如同“對象”不使用了就必然回收。
(4)註意
在大量使用反射、動態代理、CGLib等ByteCode框架、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機具備類卸載功能,以保證方法區不會溢出。
這一章有關內容學習下來真是收貨頗豐,對象存活判定的兩種算法、引用的概念和方法區回收的判定,特別是兩種算法,面試中經常涉及,讀者需註意理解學習。
若有錯誤,歡迎指教~
JVM高級特性與實踐(二):對象存活判定算法(引用) 與 回收