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)收集器是一種以獲取最短回收停頓時間爲目標的收集器。

它的運作過程相對於前面幾種收集器來說要更復雜一些,整個過程分爲四個步驟,包括:

  1. 初始標記(CMS initial mark)
  2. 併發標記(CMS concurrent mark)
  3. 重新標記(CMS remark)
  4. 併發清除(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. 垃圾收集算法

垃圾收集都是建立在分代收集之上的,一般而言,我們對垃圾收集分類如下:

  1. 部分收集(Partial GC):指目標不是完整收集整個 Java 堆的垃圾收集,其中又分爲:
    1. 新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集。
    2. 老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集。
  2. 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行爲。
  3. 整堆收集(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高級特性與最佳實踐_周志明》

https://blog.csdn.net/fanxing1964/article/details/79349824

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章