Java虛擬機之對象存活判斷與垃圾回收算法
本文主要教書在java虛擬機垃圾回收機制中,如何判斷對象是否存活和圖解垃圾回收算法。

一、概述
對于java程序員來說,多少聽過GC、垃圾回收機制這些名詞。不過到底什么是垃圾回收,哪些是垃圾,怎么進行回收呢?本文將會給出答案。
二、垃圾回收機制
垃圾回收(英語:Garbage Collection,縮寫為GC),在計算機科學(xué)中是一種自動的存儲器管理機制。當(dāng)一個計算機上的動態(tài)存儲器不再需要時,就應(yīng)該予以釋放,以讓出存儲器,這種存儲器資源管理,稱為垃圾回收。

為了方便大家理解,我就畫了一個形象的圖,一家飯店有好多桌子(連續(xù)的內(nèi)存區(qū)域),顧客(對象)來店里吔飯,但是這些顧客很社會,自己不會吃完了就走,得讓店家往外面趕。以前是老板娘來干這活(手動釋放內(nèi)存),現(xiàn)在引進了吃完飯滾蛋機器人(垃圾回收機制)來叫吃完的顧客滾蛋。
產(chǎn)生:首先,垃圾回收并不是java的伴生產(chǎn)物。最早使用垃圾回收的語言是1960年誕生的Lisp,垃圾回收器的目的是減輕程序員的負擔(dān),同時也減少程序員犯錯的機會。現(xiàn)在,經(jīng)過半個多世紀的發(fā)展,目前垃圾回收技術(shù)已經(jīng)相當(dāng)成熟,并且大多數(shù)語言都支持垃圾回收,例如Python、Erlang、C#、Java等。
為什么要了解GC和內(nèi)存分配?
當(dāng)我們需要排查各種內(nèi)存泄漏、內(nèi)存溢出,當(dāng)垃圾收集成為系統(tǒng)達到高并發(fā)的瓶頸時,就需要對這種自動化技術(shù)進行監(jiān)督和調(diào)節(jié)。(吃完飯滾蛋機器人也不是萬能的,也需要老板娘來調(diào)節(jié)機器人參數(shù))
三、哪些內(nèi)存需要回收
首先,我們知道程序計數(shù)器、虛擬機棧、本地方法棧這三個區(qū)域是線程私有的,它們是與線程同生共死的;棧幀是伴隨著方法執(zhí)行進棧,方法結(jié)束出棧,在類結(jié)構(gòu)確定后,每個棧幀占多大內(nèi)存基本確定。所以這幾個區(qū)域并不需要進行管理。
然后,java堆和方法區(qū)是內(nèi)存共享的,一個接口有多個實現(xiàn)類,不同的類需要的內(nèi)存可能不同,一個方法的不同的分支需要的內(nèi)存可能不同。我們只有在系統(tǒng)運行時才能確定需要創(chuàng)建哪些對象,這里是垃圾回收器的主戰(zhàn)場。
垃圾收集策略
引用計數(shù)算法(Reference Counting)
給對象添加一個計數(shù)器,每當(dāng)一個地方引用它時,計數(shù)器就加1,引用失效是就減1。當(dāng)計數(shù)器為0時,這個對象就不會就不會再被使用了——對象死亡。
引用計數(shù)算法實現(xiàn)容易,效率很不錯,在Python、Ruby等語言都使用了這種算法。但是主流java虛擬機并沒有使用這種算法來管理內(nèi)存,因為無法解決對象的循環(huán)引用問題。
- public class ReferenceCounting {
- public static void main(String[] args) {
- Dog dog1 = new Dog();
- Dog dog2 = new Dog();
- // 狗1和狗2對象之間互相引用
- dog1.setSon(dog2);
- dog2.setSon(dog1);
- // 將兩個對象的引用設(shè)置為空
- dog1 = null;
- dog2 = null;
- System.gc();
- }
- }
- class Dog {
- private Dog son;
- public Dog getSon() {
- return son;
- }
- public void setSon(Dog son) {
- this.son = son;
- }
- }
在啟動參數(shù)里設(shè)置-XX:+PrintGCDetails這個參數(shù),打印日志
- [GC 7926K->480K(502784K), 0.0023280 secs]
- [Full GC 480K->316K(502784K), 0.0098820 secs]
可已清楚的看到盡管兩個對象互相引用,但仍被回收,所以hotspot并不是引用計數(shù)算法算法。
跟蹤收集器(Tracing garbage collection)
目前主流的虛擬機java、C#都是使用Tracing garbage collection來判斷對象是否存活的,以致于當(dāng)人們提到垃圾回收時就會想到Tracing garbage collection。
基本思想:定義一些GC Roots的對象為起始點,追蹤對象是否能通過一個引用鏈(a chain of references )達到這些確定的GC Roots對象上,那些無法達到這些跟對象(root object)的對象將被視為已死亡。這種算法實際實現(xiàn)會復(fù)雜多變。

開始畫圖,現(xiàn)在我們設(shè)置GC Roots,有面的碗和點菜單。那些碗里是空的在點菜單上還沒名字的人會被標記為綠色,存活下來的有,左上角碗里有面的人,等上面的非單身狗,整整齊齊一家人雖然左右兩個都是空面,點菜單上也沒有,但是缺被中間的人引用,而中間的人恰好碗里有面!這就是“追蹤吃完飯不走的人方法”。
在java中,會設(shè)置如下對象為GC Roots:
- 虛擬機棧(棧幀的本地變量表)中引用的對象:也就是局部變量引用的對象
- 方法區(qū)中類靜態(tài)屬性引用的對象:public static Dog dog= new Dog();
- 方法區(qū)中常量引用對象:public static final HashMap map = new HashMap();
- 本地方法棧JNI中引用的對象。
可達性分析算法(Reachability analysis):
如果大家讀過周志明老師的深入了解java虛擬機一定會知道可達性分析這個名詞,也就是這里的Tracing garbage collection。開始我以為是兩種不同的叫法,不過我使用google搜索Reachability analysis時并每有找到和垃圾回收相關(guān)的信息,百度查到的可達性分析算法基本全部出自深入了解java虛擬機wiki百科里對可達性分析的描述是用于確定分布式系統(tǒng)可以達到全局狀態(tài)。而java的垃圾回收策略是Tracing garbage collection。所以我懷疑可能是深入了解java虛擬機用錯了名詞。
逃逸分析(Escape analysis)
逃逸分析將對象堆上分配(heap allocations)轉(zhuǎn)到棧上分配(Stack allocations),從而減少很多垃圾回收的工作。在編譯時判定在函數(shù)內(nèi)分配的對象是否被外部方法或線程調(diào)用,如果沒有則會將對象分配到棧中,減少垃圾回收工作。
引用
在jdk1.2之后,java對引用的概念進行了擴充,將引用分為了強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)四種。
- 強引用就是指在程序代碼之中普遍存在的,類似”Object obj = new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象
- 軟引用是用來描述一些還有用但并非必需的對象,對于軟引用關(guān)聯(lián)著的對象,在系統(tǒng)將要發(fā)生內(nèi)存溢出異常之前,將會把這些對象列進回收范圍進行第二次回收。如果這次回收還沒有足夠的內(nèi)存,才會拋出內(nèi)存溢出異常。在JDK1.2之后,提供了SoftReference類來實現(xiàn)軟引用
- 弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關(guān)聯(lián)的對象,只能生存到下一次垃圾收集發(fā)生之前。當(dāng)垃圾收集器工作時,無論當(dāng)前內(nèi)存是否足夠,都會回收掉只被弱引用關(guān)聯(lián)的對象。在JDK1.2之后,提供了WeakReference類來實現(xiàn)弱引用
- 虛引用也成為幽靈引用或者幻影引用,它是最弱的一中引用關(guān)系。一個對象是否有虛引用的存在,完全不會對其生存時間構(gòu)成影響,也無法通過虛引用來取得一個對象實例。為一個對象設(shè)置虛引用關(guān)聯(lián)的唯一目的就是能在這個對象被收集器回收時收到一個系統(tǒng)通知。在JDK1.2之后,提供給了PhantomReference類來實現(xiàn)虛引用
一個可以被遺忘的關(guān)鍵字——finalize
當(dāng)一個決定一個對象是否需要被回收時需要經(jīng)歷兩個標記過程。第一次是追蹤對象是否與GC Roots相連,如果沒有進行標記,第二次是判斷對象未重寫finalize方法,或者finalize方法已經(jīng)被調(diào)用過,此時對象徹底死亡。
finalize方法如果重寫且未被調(diào)用會將對象放到一個低優(yōu)先級甚至不執(zhí)行的隊列F-Queue中,之后調(diào)用對象的finalize方法,如果在方法中對象被GC Roots引用,對象自救成功。但是F-Queue可能不會執(zhí)行,所以這種子救方法并這可靠。有些教程推薦finallize來釋放資源,那為什么不用try-finally來做呢?
這個關(guān)鍵字可以忘記了。
四、垃圾收集算法
標記-清除(Mark-Sweep)算法
標記清除算法包括兩個階段,首先標記出需要回收的對象(標記方法就在上面),在標記完成后,統(tǒng)一回收所有被標記的對象。標記清楚算法是一所有垃圾回收算法的基礎(chǔ),后續(xù)算法都是根據(jù)其不足進行改新。
缺點:
- 效率低,標記和清除兩個過程效率都不高;
- 空間零碎,標記清楚之后會產(chǎn)生大量吧連續(xù)的內(nèi)存碎片,空間碎片太多,當(dāng)有大對象需要分配空間時會提前觸發(fā)gc。

空桌子是未使用的內(nèi)存,被綠色標記的是可以清除的對象,這是清除前的狀態(tài),整整齊齊一家人是比較大的對象需要占據(jù)連續(xù)的區(qū)域。

這是清除之后的狀態(tài),內(nèi)存碎片太多,當(dāng)分配比較大的整整齊齊一家人時就會提前觸發(fā)新的GC。
復(fù)制(Copying)算法
為了解決效率問題,出現(xiàn)了復(fù)制算法,可以將內(nèi)存劃分為大小相等的兩塊,每次只使用其中一塊,當(dāng)這塊內(nèi)存用完將存活的對象復(fù)制到另一塊內(nèi)存上去,將使用過的內(nèi)存一次清除掉。這種算法效率高,但太浪費空間。

如上圖所示,現(xiàn)在使用下半部分內(nèi)存。當(dāng)清理時把未被標記的復(fù)制到上面的內(nèi)存,然后一次清除下半部分內(nèi)存。

現(xiàn)在商業(yè)虛擬機大多都采用這種算法來回收新生代。但并不是按照1:1來分配內(nèi)存的,因為IBM做過專門研究,在新生代中對象98%都是朝生幕死的。
將內(nèi)存劃分為一塊較大的Eden空間和兩塊較小的Survivor空間。每次使用Eden和其中一塊Survivor,回收時將存活的對象復(fù)制到另一塊Survivor中,清除Eden和被使用的Survivor。一般Eden,Survivor1,Survivor2比例為8:1:1,這樣只有10%的內(nèi)存會被浪費。
這里如果將Eden翻譯為伊甸,對象出生的地方,Survivor幸存者,回收后幸存的對象,會比較好理解吧。
如果回收后對象對象真的超過了10%,Survivor空間不夠時,需要依賴其他內(nèi)存(老年代)進行分配擔(dān)保(Handle Promotion)。
標記整理算法
復(fù)制收集算法并不適用于對象存活率較高的情況。當(dāng)對象存活過多,需要復(fù)制的對象就會變多,效率將會下降。而且如果不想浪費50%的空間,就需要利用額外的空間進行分配擔(dān)保,所以老年代并不適用這種算法。
根據(jù)老年代的特點,有人提出的標記整理算法,將對象標記后,會將存活的對象都向一端移動,然后直接清楚掉邊界以外的內(nèi)存。

這個是回收之前

這個是回收之后
分代收集算法
這種算法是指根據(jù)對象的存活周期將內(nèi)存劃分為幾塊,一般是把java堆分為新生代和老年代。對于每次垃圾收集都有大量對象死亡的新生代,采用復(fù)制算法;對于存活代高,又沒有額外空間擔(dān)保的老年代采用標記-清楚或標記-清理算法。
增量收集器
序?qū)⑺鶕碛械膬?nèi)存空間分成若干分區(qū)。程序運行所需的存儲對象會分布在這些分區(qū)中,每次只對其中一個分區(qū)進行回收操作,從而避免程序全部運行線程暫停來進行回收,允許部分線程在不影響回收行為而保持運行,并且降低回收時間,增加程序響應(yīng)速度。
五、總結(jié)
本文介紹了什么是垃圾回收,java虛擬機的垃圾回收策略,包括引用計數(shù)法、追蹤垃圾回收和逃逸分析,又用飯店的形式介紹了幾種垃圾回收算法,包括標記-清除、復(fù)制算法、標記-整理算法。
原文:https://icdream.github.io/2019/01/10/jvm03/