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

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

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

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

空桌子是未使用的內存,被綠色標記的是可以清除的對象,這是清除前的狀態,整整齊齊一家人是比較大的對象需要占據連續的區域。

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

如上圖所示,現在使用下半部分內存。當清理時把未被標記的復制到上面的內存,然后一次清除下半部分內存。

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

這個是回收之前

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