聊一聊 JVM 的 GC

壹言發表於2021-05-22

原文連結:https://www.changxuan.top/?p=1457


 

引言

JVM 中的 GC 在技術部落格中應該算是個老生常談的話題,網路上也存在著許多質量參差不齊的文章,可以看出來大都是“複製貼上”的風格。在寫這篇文章的時候,我問了問自己“現在我算不算是在製造資料垃圾?”。

我為什麼要寫呢?其實寫這篇博文的主要目的不是給別人看的,而是想要記錄一下自己對於 JVM 中 GC 的理解與認識。我認為有一類文章存在的意義主要是用來記錄的,記錄自己對一個事物認識與思考的過程。如果你能夠將對此事物的理解轉換為成體系化的有清晰脈絡結構的文字,那麼應該說你確實是瞭解它的。所以,這篇文章就是用來記錄的,如果我的思考過程恰巧也有助於你對於 GC 的理解那自然是再好不過了。

正文

JVM 和 GC

首先要給沒有做足功課的同學介紹一下 JVM 和 GC 這兩個名詞。

JVM 的全稱是 Java Virtual Machine,也就是 Java 虛擬機器,它的主要作用是執行位元組碼檔案(class)。不過要清楚的一點是,雖然它叫 Java 虛擬機器,但它並不是只能執行 Java 語言程式碼所編譯的位元組碼檔案。就如前面說“主要作用是執行位元組碼檔案”,所以無論是什麼語言只要你的程式碼編譯後所生成的檔案格式符合 JVM 的規範,那麼就能在 JVM 上執行。例如,Kotlin、Groovy、Scala等語言,編譯後都可以執行在 Java 虛擬機器中。另外,有一個叫做《JVM 虛擬機器規範》的東西,根據這個規範誰都可以實現自己的 Java 虛擬機器。所以在技術發展的歷史長河中,出現過不止一款 Java 虛擬機器,像 Classic VM、Exact VM、HotSpot VM。其中 HotSpot VM 也是我們最熟悉、最常用的一款 ,後面我們提到的 Java 虛擬機器也都預設為 HotSpot VM。

那 GC 是什麼呢?GC 的全稱是 Garbage Collect,即“垃圾收集”,記住不是大街上的“收垃圾”!弄懂“垃圾收集”之前得先搞明白 GC 和 JVM 是個什麼關係?根據《Java 虛擬機器規範》的規定,它所管理的記憶體會被劃分為幾個不同的區域(詳情參考《Java記憶體區域與記憶體溢位異常》這篇文章)。這幾個區域中有兩個區域,一個叫“堆”,一個叫“方法區”(JDK 8之前)。由於這兩塊區域的記憶體分配與使用不確定性較大,且堆佔用的記憶體也特別大,所以《Java 虛擬機器規範》會要求虛擬機器對這兩塊區域實現“垃圾收集”。不過因為方法區具有特殊性(儲存內容導致進行回收的價效比較低),對方法區“垃圾收集”的實現不做強制要求,但是一定要實現堆上的“垃圾收集”。所以,接下來我們要說的就是堆上的“垃圾收集”。

new 關鍵字

寫過 Java 的同學,大都知道通過 new 關鍵字來建立一個物件。由於高階語言把底層工作做的實在太好,導致大多數同學並不瞭解 new 背後的細節是什麼?所以,在這裡我們得先清楚 JVM 在“看”到你的 new 關鍵字後會執行哪些操作?首先進行類載入檢查,往後依次是分配記憶體、初始化零值、設定物件頭和執行 init 方法。由於寫這篇文章不是為了把 JVM 講解的面面俱到的,所以對於上面的幾個步驟不做詳細介紹了。我們看到第二個步驟就是分配記憶體,這才是我們關注的重點。這裡所進行的分配記憶體,則會從堆上劃分一塊空間用於建立後的新物件的使用。(注:有兩種分配方式,空閒列表和指標碰撞)

看到這裡,大家應該明白建立一個新物件的時候是會佔用堆上的一部分空間的,即使所佔用的空間很小,總歸是佔了。畢竟機器上的記憶體空間是有限的,不可能說讓你無休止的只用不還。所謂,有借有還,再借不難嘛。舉個例子,在常見的 Web 專案中,伴隨著使用者的一次 Http Request 可能就會建立許多物件,但使用者在收到期望的 Http Response 後,中間建立的一些物件基本就不會用到了。在 Java 程式設計中並沒有要求程式設計師負責自己所建立物件的回收,所以這部分工作就交由了 JVM 虛擬機器來負責。而這裡說要回收的物件,也就是前面提到的“垃圾收集”中的垃圾。

如何判斷物件是否需要回收?

“有借有還,再借不難”,但是如何判斷是否把已分配給物件的記憶體還回去呢?其實就是要判斷物件還有沒有存在的價值,沒有的話就得趕緊把地方“騰倒”出來。如果你建立的物件,孤零零的沒有任何地方引用它也就可以認為應該結束它的“生命”了。基於這一原則,出現了引用計數演算法。簡單來說,就是在物件中設定一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一。所以只要發現某物件的引用計數器為零,那麼就認為它是不可能被再使用的,它在堆中所佔有的記憶體就可以被還回去了。聰明的同學肯定一眼就看出來了這個演算法的缺點。那就是當兩個物件存在互相引用的時候,會導致它們的計數器都不為零,也就會變成“長生不老”的物件。所以,JVM 並沒有採用這種演算法。

除引用計數演算法外,還有一個可達性分析演算法。這個演算法的基本思路是通過一系列稱為“GC Roots”的根物件作為起始節點集,從這些節點開始,根據引用關係向下搜尋,搜尋過程所走的路徑稱為“引用鏈”(Reference Chain),如果某個物件到 GC Roots 間沒有任何引用鏈相連,那麼就證明此物件沒有存在的價值了。演算法並不複雜,其中 GC Roots 的選取比較值得注意。在 Java 技術體系中,常作為 GC Roots 的有以下幾種:虛擬機器棧中引用的物件;方法區中類靜態屬性和常量引用的物件;Java 虛擬機器內部的引用等。當然也不止上述物件,JVM 會根據不同的垃圾收集器和收集區域動態調整 GC Roots 集合。

至此,我們知道了為什麼要進行 GC ?主要在 JVM 所管理的記憶體的哪個區域進行 GC?以及如何判斷物件是否可以被回收?(仔細想一想,回答不上來就得再翻到前面好好看一下了)

分代收集

那接下里應該準備做什麼啊?既然知道如何找到需要回收的物件,肯定是該琢磨著怎麼樣回收。

先彆著急著說“回收不就是把分配出去的記憶體收回來啊”。要知道,在計算機的世界裡有一些規矩一是講究高效率做事;二是希望既想馬兒跑,又想馬兒少吃草。所以,JVM 中實現的垃圾收集器在工作時佔用的資源要儘量少,切勿本末倒置;另外收集過程應儘量減小對使用者執行緒的影響,保證使用者執行緒能夠高效工作,從而帶給使用者良好的體驗。

基於上述的要求,聰明的人們開始思考應該怎麼樣更快、更好的 GC。這裡首先會介紹一個“分代收集”理論,或許有些突然但是並不難理解,是人們為了更好的實現收集器提出的。分代收集依賴兩個分代假說:一是弱分代假說,絕大多數物件都是朝生夕滅的;二是強分代假說,熬過越多次垃圾收集過程的物件越難被回收。仔細想想,是不是也挺合理的。常用的幾款經典垃圾收集器就將 Java 堆劃分為了不同的區域,根據物件年齡分別儲存不同區域。

有好奇的同學可能會說“我不劃分割槽域”又能怎麼樣呢?

你這麼想一下,假如不劃分割槽域就相當於所有物件都在這麼一塊記憶體上儲存著。當開始收集的時候,肯定要進行標記(不同的收集器標記策略不同,不過按當前經典垃圾收集器的策略總會在某個標記階段暫停所有使用者執行緒的)需要回收的物件,在標記過程時需要暫停訪問此記憶體區域的所有使用者執行緒,這個“一刀切”的策略固然簡單,但卻不是最好的。假如,根據各物件的年齡分別儲存在不同的區域,“朝生夕滅”的物件放在一個區域,“年齡大”的物件放在一個區域。這樣針對不同的區域可以使用不同的策略來回收,對於“活躍”區域的收集頻率可以高一些,針對某個區域 GC 時也並不會影響使用者執行緒訪問其它區域等。

在商用的 Java 虛擬機器中,一般都至少會把 Java 堆劃分為新生代(Young Generation)和老年代(Old Generation)兩個區域。新生代,就是前面我們說的存放“朝生夕滅”的物件的區域。當然每次在新生代區域回收過後,達到一定“年齡”的物件則會被放到老年代中。現在,我們可以來分析這兩塊區域各自的特點了。

在新生代上的 GC 被稱為 Minor GC/Young GC,由於此處的物件不易“存活“,從而在一次收集過後應該會產生大量的空閒記憶體。在老年代上的 GC 被稱為 Major GC/Old GC,每次收集可能只能回收很少物件。除了會單獨在新生代和老年代上收集,在記憶體嚴重不足時還可能會觸發整堆收集(Full GC),要儘量減少 Full GC 的出現。

垃圾回收演算法

既然知道了為什麼要對 Java 堆劃分割槽域以及各區域的特點,我們可以認識一下三種“垃圾回收”演算法。你也可以認為是“垃圾回收”的方法論。

“標記-清除”演算法

“標記-清除”演算法,標記指的則是標記物件(標記待回收或非回收的都行,只要能區分出兩類物件即可),清除則是把無用的物件從記憶體中清除掉。可以看出,這是比較簡單的一種策略,先發現後清除。不過,這種演算法會產生許多記憶體碎片,由於過多記憶體碎片的存在可能在出現較大物件時無發分配從而導致再次觸發一次垃圾收集動作;而且隨著物件數量的增加標記和清除的時長也會增加。

“標記-複製”演算法

“標記-複製”演算法,標記不做過多介紹了。說一下複製策略,這個演算法是將記憶體空間分為兩部分,各佔一半。但是隻給 JVM 使用一部分的記憶體,另一部分空著。另一部分什麼時候用呢?就是在使用的這部分記憶體上發生 GC 時,把未標記的物件(存活)全都複製到另一部分空閒記憶體中並放到一起,然後在把之前使用的那部分記憶體資料全都清理掉,輪換著使用兩塊空間。其實這是以犧牲空間為代價,來解決的記憶體碎片問題。這種方式對於記憶體分配也特別友好,如果不在乎空間浪費的問題,這似乎是個特別好的方法。不過,在“寸記憶體,寸金”的計算機中,怎麼可能不在乎空間!所以,有人提出了優化版本的演算法——Appel 式回收。改進後的版本,並沒有簡單直接的將空間對等分為兩部分,而是分為了三部分。Appel 式回收把新生代分為了一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,工作時只使用 Eden 和 其中一塊 Survivor 空間(HotSpot VM 中預設 Eden:Survivor 為 8:1)。如果發生了 GC,那麼就將 Eden 和 Survivor 中存活的物件複製到另一塊空閒的 Survivor 空間中。改進後的演算法大大減少了備用空間的大小,通過這種劃分解決了十分浪費記憶體空間的問題。能夠進行這種劃分也得益於新生代每次 GC 會回收掉大部分物件的特點。不過,可能會出現備用空間 Survivor 過小,導致一次 GC 後放不下所有的存活物件的情況,所以還需要老年代做分配擔保。這個演算法就比較適合在新生代使用。

“標記-整理”演算法

“標記-整理”演算法,正如其名,在標記完成之後,先將所有的存活物件移向記憶體空間的一端,然後再清除邊界以外的記憶體。通過在標記後對記憶體的“整理”動作,從而避免了“標記-清除”演算法產生大量記憶體碎片的問題。演算法示意圖如下:

“標記-整理”演算法示意圖

經典垃圾收集器

“光說不練假把式,光練不說傻把式”,所以不能只講理論層面的知識,接下來介紹幾款垃圾收集器。總之,前面所講的所有內容最終都是要為實現垃圾收集器服務的,畢竟收集器才是最終“幹活”的。

經典垃圾收集器

看到上圖,可能對你認識這幾款收集器並沒有太大作用。所以,還需要下面這幅圖來幫助你理解和記憶。

HotSpot 虛擬機器的垃圾收集器

Serial 收集器

Serial 收集器(標記-複製),從圖中可以看出它是工作在新生代的收集器。由於“出生早”,所以策略也很簡單,在進行 GC 時只有一個 GC 執行緒工作(單執行緒),而且還需要暫停其他所有的工作執行緒(Stop The World)。當然,也不能只看到它的缺點,目前它仍是 HotSpot VM 客戶端模式下預設的新生代垃圾收集器,由於策略簡單它記憶體資源消耗最少的收集器。在單核處理器的伺服器中,由於沒有執行緒互動的開銷,正好可以獲得最高的單執行緒收集效率。

ParNew 收集器

ParNew 收集器(標記-複製),作為 Serial 的多執行緒版本並沒有帶來更多的創新,通常與 CMS 配合工作。它預設開啟的收集執行緒數與處理器核心數量相同,不過也可以使用 -XX:ParallelGCThreads 引數來設定GC 執行緒數量。

Parallel Scavenge 收集器

Parallel Scavenge 收集器(標記-複製),同樣是工作在新生代能夠並行收集的多執行緒收集器。它追求使 JVM 達到一個可控制的吞吐量(執行使用者程式碼時間與執行使用者程式碼加上執行GC實際的比值)。

吞吐量 = 執行使用者程式碼時間/(執行使用者程式碼時間+垃圾執行時間)

為了進行有效控制,Parallel Scavenge 提供了兩個引數來進行精確控制,一個是用來控制最大垃圾收集停頓時間的 -XX: MaxGCPauseMillis 引數;一個是用於設定吞吐量大小的 -XX: GCTimeRatio 引數。

-XX: MaxGCPauseMillis 引數可以設定為一個打於 0 的毫秒數,收集器盡力保證每次GC所花費的時間不超過這個值。當然如果設定的過小,收集器為了保證不超過此值往往會頻繁的進行 GC。

-XX: GCTimeRatio 引數可以設定為一個大於 0 小於 100 的整數。假設設定為 n,那麼1/(n+1) 則是垃圾收集時間佔總時間的比率,即系統將花費不超過總時間的1/(n+1)用於垃圾收集。(GC 參考文件中描述為“-XX: GCTimeRatio=nnn, The ratio of garbage Collection time to application time is 1/(1+nnn)”)。通過簡單的公式推導也可以證明 1/(n+1) 為吞吐量的倒數。

另外,這款收集器還有一個優點是隻要設定好基本的引數如堆大小,最大停頓時間,吞吐量,其它具體細節引數的調節可以由它本身完成,這是 Parallel Scavenge 的自適應調節策略。

Serial Old 收集器

Serial Old 收集器(標記-清除),看名字就能知道這是 Serial 的老年代版本,同樣也是單執行緒工作。

Parallel Old 收集器

Parallel Old 收集器(標記-整理),通過與 Parallel Scavenge 收集器搭配工作形成“吞吐量優先”的收集器組合。在一些注重吞吐量或者處理器資源稀缺的場合,可以優先考慮此組合。

CMS 收集器

CMS 收集器(標記-清除)全稱為 Concurrent Mark Sweep ,此處理器以獲取最短回收停頓時間為目標。對於部署伺服器上以網站形式提供服務的系統,可以採用此收集器從而給使用者帶來良好的互動體驗。

CMS 的 GC 分為四個步驟:1.初始標記;2.併發標記;3.重新標記;4.併發清除。在初始標記和重新標記階段會“Stop The Word”,不過這兩階段耗時都很小。雖然它具有併發收集、低停頓的優點,但同時對於處理器資源十分敏感。另外,在進行併發標記和併發清除時,由於同時使用者執行緒也在執行所以還會產生新的待回收物件,這種物件稱之為“浮動垃圾”。因為採用了標記-清除演算法,同樣也面臨著記憶體碎片的問題。

Garbage First 收集器

Garbage First 收集器(標記-整理、標記-複製)也被稱為 G1 收集器,其實這是一款與前面所有收集器劃分記憶體區域的出發點都不同的收集器,它開創了收集器面向區域性的設計思路和基於 Region 的記憶體佈局形式。作為一款主要面向服務端應用的垃圾收集器,開發團隊對於它的期望就是替代 CMS 。

G1 仍是遵循分代收集理論設計,但是沒有直接把 Java 堆分成新生代與老年代這種佈局。而是把連續的 Java 堆劃分為多個大小相等的獨立區域(Region),每個區域都可以根據需要扮演新生代的 Eden、Survivor空間,或者老年代空間。G1 對扮演不同角色的 Region 採用不同的策略進行處理。不過,在 JVM 中總會有一些大物件存在,所以還有一類特殊的 Region——Humongous 區域,用來儲存大物件。對於大物件的定義是大小超過了一個 Region 容量一半的物件。G1 通過把管理的 Java 堆的粒度細化到 Region 大小,這樣每次收集到的記憶體空間都是 Region 大小的整數倍。通過細化後,再配合相應的資料結構就能有計劃的控制停頓時間來進行收集。

G1 的GC分為以下四個步驟:1.初始標記;2.併發標記;3.最終標記;4.篩選回收。如果機器有較大的記憶體空間可以使用,還是推薦使用 G1 進行 GC的。

其它收集器

有一些低延遲收集器包括 Shenandoah 收集器、ZGC 收集器。

總結

本文主要是講了關於 JVM 中 GC 相關的知識,限於篇幅有些地方並沒有進行詳細介紹。所以,如果要認真學習相關知識還是需要閱讀專門的書籍。另外,在附錄中又補充了一些不太適合放入上文的知識點。

附錄

關於引用

在正文中,由於文章結構原因並未對引用做過多介紹。所以,在附錄裡進行了補充說明。我們可以看到,在正文中出現了許多次“引用”這個詞語。其實在 JVM 中,為了便於管理和進行區別把引用分為了四類,按照引用強度由強到弱的順序排列分別是強引用 、軟引用、弱引用、虛引用。

什麼是強引用?舉個列子,Object obj = new Object();這就屬於強引用。只要引用關係還存在,垃圾收集器就不可能回收被引用的物件。

軟引用則是用來描述一些非必須的物件,只有當系統在發生記憶體溢位之前垃圾收集器才會將軟引用關聯的物件回收掉。

弱引用的強度更低,一旦發生GC 被關聯的物件就會被回收掉。

虛引用是最弱的一種引用關係,它對於物件的存活完全沒有影響,它存在的意義是為能在這個物件被收集器回收是收到一個系統通知。

回收物件

當物件被第一次標記後,其實也不一定就要回收掉,還是有“起死回生”的機會的。第一次被標記後,隨後還會再進行一次篩選,篩選的條件就是此物件是否有必要執行 finalize() 方法。如果需要執行,那麼在執行 finalize() 時重新與 GC Roots 集合中的物件建立關聯關係即能在第二次小規模標記時避免被回收。當然,如果物件沒有覆蓋 finalize() 方法,或者已經被呼叫過 finalize() 方法那麼它就不可能“起死回生”了。

參考資料

[1] 《深入理解 Java 虛擬機器》

相關文章