JVM 第二篇:垃圾收集器以及演算法
本文內容過於硬核,建議有 Java 相關經驗人士閱讀。
0. 引言
一說到 JVM ,大多數人第一個想到的可能就是 GC ,今天我們就來聊一聊和 GC 關係最大的垃圾收集器以及垃圾收集演算法,希望能通過本篇文章,讓各位同學對 GC 有一個初步大體的認知。
1. 運行時數據區
JVM 在執行的時候會把它所管理的記憶體劃分為幾個不同的數據區域。這些區域有各自的用途,以及創建和銷毀的時間,有的區域隨著虛擬機進程的啟動而一直存在,有些區域則是依賴用戶執行緒的啟動和結束而建立和銷毀。根據《Java虛擬機規範》的規定,Java虛擬機所管理的記憶體將會包括以下幾個運行時數據區域:
1.1 程式計數器
指向當前執行緒所執行的位元組碼的行號,其實就是一小塊記憶體,記錄著當前程式運行到哪了位元組碼解釋器的工作就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。分支,循環,跳轉,異常處理,執行緒回復等都需要依賴這個計數器來完成。
由於Java的多執行緒是通過執行緒輪流切換完成的,一個執行緒沒有執行完時就需要一個東西記錄它執行到哪了,下次搶佔到了CPU資源時再從這開始,這個東西就是程式計數器,正是因為這樣,所以它也是「執行緒私有」的記憶體。
如果一個執行緒執行一個主要方法,這個計數器記錄的是正在執行的虛擬機位元組碼指令的地址;如果正在執行的是一個本地方法,這個計數器的值則為空,此記憶體區域是唯一一個在Java的虛擬機規範中沒有規定任何OutOfMemoryError異常情況的區域。
1.2 Java 虛擬機棧
與程式計數器一樣, Java 虛擬機棧(Java Virtual Machine Stack)也是現成私有的,它的生命周期與執行緒相同。
虛擬機棧描述的是 Java 方法執行的執行緒記憶體模型:每個方法被執行的時候, Java 虛擬機都會同步創建一個棧幀(Stack Frame)用於存儲局部變數表、操作數棧、動態連接、方法出口等資訊。每一個方法被調用直至執行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。
經常有人把 Java 記憶體區域籠統地劃分為堆記憶體(Heap)和棧記憶體(Stack),這種劃分方式直接繼承自傳統的 C 、 C++ 程式的記憶體布局結構,在 Java 語言里就顯得有些粗糙了,實際的記憶體區域劃分要比這更複雜。不過這也說明了程式設計師最關注的實際上是「堆」和「棧」兩塊,這裡的「棧」通常指的就是 Java 虛擬機棧,或者更多情況下只是指虛擬機棧中的局部變數表部分。
1.3 本地方法棧
本地方法棧(Native Method Stacks)與虛擬機棧所發揮的作用是非常相似的,其區別只是虛擬機棧為虛擬機執行Java方法(也就是位元組碼)服務,而本地方法棧則是為虛擬機使用到的本地(Native)方法服務。
1.4 Java 堆
Java 堆(Java Heap)是虛擬機所管理的記憶體中最大的一塊。 Java 堆是被所有執行緒共享的一塊記憶體區域,在虛擬機啟動時創建。此記憶體區域的唯一目的就是存放對象實例, Java 世界裡「幾乎」所有的對象實例都在這裡分配記憶體。
1.5 方法區
方法區(Method Area)與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於存儲已被虛擬機載入的類型資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等數據。雖然《Java虛擬機規範》中把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫作「非堆」(Non-Heap),目的是與 Java 堆區分開來。
說到這裡,不得不提一下「永久代(Permanent Generation)」這個概念,大多數的程式設計師,都是在 Hotspot 虛擬機上進行開發、部署程式的,因此很多人都願意把方法區稱之為永久代,實際上這兩者並不是一個等價的關係,而僅僅只是 Hotspot 團隊使用「永久代」來實現方法區,這樣使得 Hotspot 可以像管理 Java 堆記憶體一樣管理這部分記憶體,實際上現在回過頭來看,當年使用「永久代」來實現方法區並不是一個好主意,這種設計導致 Java 更容易遇到記憶體溢出的問題。因為永久代有 -XX:MaxPermSize 的上限,即使不設置也有默認大小,甚至在一些大型項目中,啟動參數不設置這個直接就啟動失敗,這種項目我接觸過不止一個。。。
那有沒其他隊方法區的實現方案,當然有,比如 BEA JRockit、IBM J9 ,是不存在永久代概念的,在 JRockit 和 J9 當中,只要沒有觸碰到進程可用的記憶體上限,就不會有問題,在 32 位系統中上限是 4GB 。
2. 垃圾收集器
2.1 Serial 收集器
Serial 收集器是最基礎、歷史最悠久的收集器,曾經(在JDK 1.3.1之前)是 HotSpot 虛擬機新生代收集器的唯一選擇。
Serial 是一個單執行緒的收集器,這個單執行緒並不是僅僅局限在它在進行垃圾回收的時候是單執行緒工作的,更重要的是它在進行 GC 的時候,是需要所有的執行緒都停掉的,直到它工作結束才能繼續工作。
2.2 PerNew 收集器
PerNew 收集器實質上是 Serial 的多執行緒並行版本。
PerNew 除了支援多執行緒並行收集以外,與 Serial 並沒有太多不一樣的地方,但它卻是運行在服務端模式下的 HotSpot 虛擬機,尤其是 JDK 7 之前的遺留系統中首選的新生代收集器。
這其中有一個很重要的原因,和功能、性能都無關,是因為目前除了 Serial 以外, PerNew 是唯一一個可以和 CMS 配合工作。
2.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一款新生代收集器,它同樣是基於標記-複製演算法實現的收集器,也是能夠並行收集的多執行緒收集器。
看起來和 PerNew 貌似沒啥區別,但是 Parallel Scavenge 的關注點是達到一個可控制的吞吐量(Throughput)。
吞吐量 = 運行用戶程式碼時間 / (運行用戶程式碼時間 + 運行垃圾收集時間)
Parallel Scavenge 收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的 -XX:MaxGCPauseMillis
參數以及直接設置吞吐量大小的 -XX:GCTimeRatio
參數。
- -XX:MaxGCPauseMillis: 參數允許的值是一個大於 0 毫秒數,收集器將儘力保證記憶體回收花費的時間不超過用戶設定值。
- -XX:GCTimeRatio: 參數的值則應當是一個大於0小於100的整數,也就是垃圾收集時間佔總時間的比率,相當於吞吐量的倒數。
2.4 Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它同樣是一個單執行緒收集器,使用標記-整理演算法。
2.5 Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支援多執行緒並發收集,基於標記-整理演算法實現。這個收集器是直到 JDK 6 時才開始提供的。
2.6 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。
它的運作過程相對於前面幾種收集器來說要更複雜一些,整個過程分為四個步驟,包括:
- 初始標記(CMS initial mark)
- 並發標記(CMS concurrent mark)
- 重新標記(CMS remark)
- 並發清除(CMS concurrent sweep)
在這個過程中,「初始標記」和「重新標記」這兩個過程仍然需要停止當前所有的進程,然後單獨進行執行。
初始標記僅僅只是標記一下 GC Root 能關聯到的對象,速度很快。
並發標記是從 GC Roots 的直接關聯對象開始遍歷整個對象圖的過程,這個過程雖然耗時較多但是不需要停止用戶執行緒,可以和用戶執行緒一起並行。
重新標記則是為了修正並發標記期間,因用戶程式繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段停頓的時間會比初始標記的時間長,但是也遠比並發標記的時間短。
並發清除,在這個階段是清理刪除掉已經進行標記判斷死亡的對象,由於不需要移動存活的對象,所以這個階段也可以和用戶執行緒一起並發的進行。
2.7 Garbage First 收集器
Garbage First 就是後來大名鼎鼎的 G1 收集器, G1 收集器是是垃圾收集器技術發展歷史上的里程碑式的成果,它開創了收集器面向局部收集的設計思路和基於 Region 的記憶體布局形式。
- 初始標記:僅僅只是標記一下 GC Roots 能直接關聯到的對象,並且修改 TAMS 指針的值,讓下一階段用戶執行緒並發運行時,能正確地在可用的 Region 中分配新對象。這個階段需要停頓執行緒,但耗時很短,而且是借用進行 Minor GC 的時候同步完成的,所以 G1 收集器在這個階段實際並沒有額外的停頓。
- 並發標記:從 GC Root 開始對堆中對象進行可達性分析,遞歸掃描整個堆里的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程式並發執行。當對象圖掃描完成以後,還要重新處理SATB記錄下的在並發時有引用變動的對象。
- 最終標記:對用戶執行緒做另一個短暫的暫停,用於處理並發階段結束後仍遺留下來的最後那少量的 SATB 記錄。
- 篩選回收:負責更新 Region 的統計數據,對各個 Region 的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,可以自由選擇任意多個 Region 構成回收集,然後把決定回收的那一部分 Region 的存活對象複製到空的 Region 中,再清理掉整箇舊 Region 的全部空間。這裡的操作涉及存活對象的移動,是必須暫停用戶執行緒,由多條收集器執行緒並行完成的。
從上述階段的描述可以看出, G1 收集器除了並發標記外,其餘階段也是要完全暫停用戶執行緒的,換言之,它並非純粹地追求低延遲,官方給它設定的目標是在延遲可控的情況下獲得儘可能高的吞吐量,所以才能擔當起「全功能收集器」的重任與期望。
3. 垃圾收集演算法
垃圾收集都是建立在分代收集之上的,一般而言,我們對垃圾收集分類如下:
- 部分收集(Partial GC):指目標不是完整收集整個 Java 堆的垃圾收集,其中又分為:
- 新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集。
- 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行為。
- 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。
3.1 標記-清除演算法
標記-清除演算法,是最早出現同時也是最基礎的垃圾收集演算法,後續的大多數演算法都是以標記-清除演算法為基礎,對其缺點進行改進而來的。
標記-清除演算法的具體執行過程如下:
這個演算法有兩個大的缺陷:
- 執行效率不穩定:如果這時的 Java 堆中包含了大量的對象,而且其中大部分是需要回收的,這時就必須進行大量的標記和清除動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低。
- 記憶體空間碎片化:標記、清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致當以後在程式運行過程中需要分配較大對象時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。
3.2 標記-複製演算法
標記-複製演算法是一種半區複製的垃圾回收演算法,它將記憶體大小按照容量劃分為大小相等的兩塊,每次只使用其中的一塊,當這一塊用完了,再將還活著的對象一次複製到另一塊上面,然後將已使用過的全部清除掉,具體執行過程如下:
這種方案的缺陷顯而易見,那就是可用記憶體直接縮小了一半。
IBM 公司針對新生代「朝生夕滅」的特點做出了更量化的詮釋:新生代中的對象有 98% 熬不過第一輪的收集,因此無需按照 1:1 的比例來劃分新生代的記憶體空間。
Andrew Appel 針對具備「朝生夕滅」特點的對象,提出了一種更優化的半區複製分代策略,現在稱為「Appel式回收」。
HotSpot 虛擬機的 Serial 、 ParNew 等新生代收集器均採用了這種策略來設計新生代的記憶體布局。
具體做法是把新生代分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次記憶體分配至分配 Eden 空間和一塊 Survivor 空間,發生垃圾回收時,將 Eden 和 Survivor 中仍然存活的對象一次性複製到另外一塊 Survivor 空間上,然後直接清理掉 Eden 和已用過的那塊 Survivor 空間。
由於沒有人可以確定的說每次垃圾回收存活的對象都能放入 Survivor 空間中,所以,這種垃圾回收演算法還設計了一個「逃生門」的安全設計,就是如果 Survivor 空間無法容納一次垃圾回收後的對象,就需要依賴其他區域(大多數是老年代)進行分配擔保(Handle Promotion)。
3.3 標記-整理演算法
由於老年代大多數的對象都不會被回收,所以標記-複製演算法並不適用於老年代的垃圾回收演算法,這時,就需要一個新的演算法來應對老年代的垃圾回收。
標記-整理演算法應運而生,標記-整理演算法和標記-清除演算法非常的像,標記-整理演算法後續的步驟並不是直接對可回收的垃圾進行整理,而是讓所有存活的對象都想空間的一端進行移動,然後處理掉其餘的記憶體,具體執行過程如下:
參考
《深入理解Java虛擬機:JVM高級特性與最佳實踐_周志明》
//blog.csdn.net/fanxing1964/article/details/79349824