概述:
大家都知道java相較於c、c++而言最大的優點就是JVM會幫助程式設計師去回收垃圾,實現對記憶體的自動化管理。那為什麼程式設計師還需要去了解垃圾回收和記憶體分配?答案很簡單,當需要排查各種記憶體溢記憶體洩漏等問題時,當垃圾收整合為系統達到更高併發量的瓶頸時,我們就必須對這些“自動化”的技術實施必要的監控和調節。前面介紹了java記憶體執行時區域,其中的執行緒私有區,包括程式計數器、虛擬機器棧、本地方法棧,它們都是隨著執行緒而銷燬,所以這幾個區域就不需要過多考慮如何回收的問題。而java堆和方法區這兩個區域則有著很顯著的不確定性,特別是java堆,因為java程式在執行過程中,會無時無刻產生物件,如果不進行垃圾回收,記憶體很快就會白佔滿,導致程式崩潰。所以垃圾收集器主要關注的就是java棧和方法區這倆個個區域的記憶體該如何管理。
如何判斷物件是否死亡?
引用計數演算法:
引用計數演算法是這樣的,在物件中新增一個引用計數器,每當有一個地方引用它時,計數器的值加一,當引用失效時,計數器值減一。當計數器為零時,則代表該物件沒有任何引用,即達到被回收的條件。客觀的說,引用計數演算法原理簡單、判定效率也很高,在大多數情況下都是一個不錯的演算法。但是把它作為java判斷物件的演算法,還是不太合適,其中最大的阻礙就在於,如果存在物件的迴圈引用,用引用計數演算法就無法進行回收,因為互相引用的物件,它們的計數永遠都是1,無法到達被回收的條件。
可達性分析演算法:
既然引用計數演算法無法全面的判斷java物件是否存活,那java虛擬機器是通過什麼演算法來實現的呢。其實當前主流的程式語言java、c#等都是通過可達性分析演算法來判定物件是否存活的,也被稱為根可達演算法。它的基本思路就是通過一系列稱為“GC Roots”的根物件作為其實節點集,從這些節點開始根據引用關係向下搜尋,搜尋過程所走過的路徑稱為“引用鏈”,如果某個物件到達“GC Roots”間沒有任何的引用鏈,則證明此物件是不可能再被引用的。如下圖所示,物件Object5、Object6、Object7雖然互相關聯、但是它們到“GC Roots”是不可達的,因此它們將被判定為可回收的物件。
在java體系裡面,固定可作為GC Roots的物件包括以下幾種:
- 在虛擬機器棧(棧幀中的本地變數表)中引用的物件,譬如各個執行緒被呼叫的方法堆疊中使用到的引數、區域性變數、臨時變數等。
- 在方法區中類靜態屬性引用的物件,譬如java類的引用型別靜態變數。
- 在方法區中常量引用的物件,譬如字串常量池裡的引用。
- 在本地方法棧中JNI(即通常說的Native方法)引用的物件。
- java虛擬機器內部的引用,如基本資料型別對於的class物件,一些常駐的異常物件等,還有系統類載入器
- 所有被同步鎖(synchronized關鍵字)持有的物件。
- 反映java虛擬機器內部情況的JMXBean、JVMTI中註冊的回撥、原生程式碼快取等。
再談引用:
無論是使用引用計數器還是通過可達性分析演算法判斷物件是否可被回收,判定物件是否存活都和引用離不開關係。在JDK1.2之前,java的引用是很傳統的定義:如果reference型別的資料中儲存的值代表另外一塊記憶體的起始地址,就稱該reference資料代表某個物件的引用。這種定義看起來是合理的,但是現在看來過於狹隘了,一個物件只有被引用或者未被引用兩種狀態,對於那種“食之無味,棄之可惜”的物件就顯得無能為力。在JDK1.2之後,java對引用進行了擴充,將引用分為強引用(strongly reference)、軟引用(soft reference)、弱引用(weak reference)、虛引用(phantom reference)4種,這4種引用強度依次逐漸減弱,以此來幫助虛擬機器更清晰的辨別哪些物件該回收,哪些物件該延遲迴收。
- 強引用是最傳統的“引用”的定義,是指在程式碼中通過new關鍵字建立的引用。無論任何情況下,只要強引用關係還存在,垃圾收集器就永遠不會回收被引用的物件。
- 軟引用是用來描述,那些還有用,但是非必須的物件。只被軟引用關聯的物件,在系統要發生記憶體溢位前,會把這些物件列進回收範圍之中進行回收。JDK1.2之後提供了SoftReference類來實現軟引用。
- 弱引用也是用來描述那些被必須的物件,但是它的強度比軟引用還要弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生為止。在JDK1.2之後提供WeakReference類來實現弱引用。
- 虛引用也被稱為“幽靈引用”,它是最弱的一種引用關係。一個物件是否有虛擬引用的存在,完全不會對其生存時間構成影響,也無法通過虛擬引用來取得一個物件例項。它唯一得目的就是為了能在這個物件被垃圾回收時能夠收到系統得通知。在JDK1.2之後提供了PhantomReference類來實現虛引用。
方法區的垃圾收集:
方法區主要存放的是Class型別物件以及常量池中的常量物件等,這些物件都有一個共同的特點,就是會被經常使用且不容易改變。所以有些人直接認為方法區是沒有垃圾回收行為的。跟堆相比,方法區的垃圾收集實在是價效比極低,本身方法區所佔用的記憶體就遠低於堆,比例也遠遠低於堆。所以在JDK11的ZGC乾脆取消了Class型別的解除安裝。但是當下主流還是java8,如果在程式中大量使用反射、動態代理、CGLib等位元組碼框架,動態生成JSP以及OSGI這類頻繁自定義載入器的場景中,通常需要java虛擬機器具備型別解除安裝的能力,以保證方法區造成過大的記憶體壓力。
方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的型別。判斷一個常量是否廢棄比較簡單,比如一個字串“java”在常量池中,當前系統沒有任何一個字串物件的值是“java”,就說明該物件達到了被回收的條件。但是判斷一個型別是否屬於“不再被使用的類”的條件就比較苛刻了,需要同事滿足以下三個條件:
- 該類的所有例項都已被回收。
- 載入該類的類載入器已經被回收,這個條件除非是經過精心設計的可替換類載入器的場景,如OSGi、JSP的重載入等,否則很難達成。
- 該類的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
垃圾收集演算法思想:
java虛擬機器的垃圾演算法實現有很多種,在這裡我們暫時不討論演算法的具體實現,只介紹分代收集理論和幾種演算法思想,為後續學習具體的GC演算法打下基礎。
分代收集理論:
分代收集名為理論,實質是一套大多數程式執行時機情況的經驗發則,它建立在兩個分代假說之上:
- 弱分代假說:絕大多數物件都是朝生夕滅的。
- 強分代假說;熬過越多次垃圾收集過程的物件就越難以消亡。
這兩個分代假說奠定了多款常用的垃圾收集器的一致設計原則:收集器將java堆劃分出不同的區域,然後將回收物件依據其年齡分配到不同的記憶體區域之中,然後根據不同的區域採取不同的收集方式。例如在HotSpot中,設計者把java堆劃分為新生代和老年代兩個區域,新生代區域的回收頻率遠高於老年代區域的回收頻率。
標記-清除演算法:
如它的名字一樣,演算法分為標記和清楚兩個階段,首先標記需要回收的物件,然後再清除被標記的物件,也可以反過來,標記存活的物件,然後回收未被標記的物件。它是垃圾收集演算法中最基礎的演算法,後續的收集演算法都是以其為基礎,對其缺點進行改進而得到的。它的主要缺點有兩個:
- 第一個是執行效率不穩定,如果java物件中包含大量的物件需要回收,這時需要大量的標記和清除動作,數量越大,效率就越低。
- 第二個是記憶體空間的碎片化問題,標記清除後會產生大量的不連續的記憶體碎片,空間碎片太多可能會導致以後程式執行過程中需要分配較大物件時無法找到足夠的連續的記憶體空間,從而提前觸發另外以此垃圾收集動作。
標記-複製演算法:
標記-複製演算法被簡稱為複製演算法。為了解決標記-清除演算法面對大量可回收物件時執行效率低的問題,複製演算法的設計者將記憶體按容量劃分為大小相等的兩塊區域,每次只使用其中一塊區域。當使用的一塊的記憶體用完的時候,就將存活的物件複製到另外一塊區域,將這個區域的內間一次性清除。如果存活的物件較少的情況下,該演算法效率很高,因為僅需要複製少量的物件即可,但是存在大量的記憶體存活,就會產生大量記憶體之間的複製開銷,並且這種複製演算法的代價也很明顯,我們真正使用的記憶體空間只有一半,造成了比較大的記憶體空間浪費。但是這種演算法用來回收新生代卻十分的合適,因為新生代區域的物件,恰恰符合物件數量眾多,且需要回收的物件的佔的比例也及其的高,只需要複製少部分不需要回收的物件即可。
標記-整理演算法:
上面我們提到了,標記-複製演算法十分契合新生代區域的回收,很明顯,標記-複製演算法的不適合老年代,那老年代區域,我們採用什麼樣的演算法呢。首先我們想到的是標誌-清除演算法,但是記憶體碎片化問題,無法解決。所以就誕生了標記-整理演算法,它和標記-清除演算法十分相似,唯一的區別是在清除的時候,標記清除演算法需要移動,而標記整理演算法需要通過移動將存活物件的記憶體和未使用的記憶體分開,且讓他們都連續。但是這樣必然會導致效率上的問題,還有一個致命的問題是,在移動過程中,必須要保障應用程式處於暫停狀態。所以垃圾演算法的具體實現,通常將標記-清除演算法和標記-整理演算法結合在一起使用,大部分情況下都通過標記-清除演算法去回收,當記憶體碎片達到一定程度,比如影響到物件的記憶體分配時,再採用標記-整理演算法回收一次,以獲得規整的記憶體空間。