深度揭祕Java GC底層,此次讓你完全弄懂她

Java 與 C++ 之間有一堵由內存動態分配和垃圾收集技術所圍成的高牆 ---《深刻理解Java虛擬機》

咱們知道手動管理內存意味着自由、精細化地掌控,可是卻極度依賴於開發人員的水平和細心程度。html

若是使用完了忘記釋放內存空間就會發生內存泄露,再如釋放錯了內存空間或者使用了懸垂指針則會發生沒法預知的問題。java

這時候 Java 帶着 GC 來了(GC,Garbage Collection 垃圾收集,早於 Java 提出),將內存的管理交給 GC 來作,減輕了程序員編程的負擔,提高了開發效率。python

因此並非用 Java 就不須要內存管理了,只是由於 GC 在替咱們負重前行。程序員

可是 GC 並非那麼萬能的,不一樣場景適用不一樣的 GC 算法,須要設置不一樣的參數,因此咱們不能就這樣撒手無論了,只有深刻地理解它才能用好它。算法

關於 GC 內容相信不少人都有所瞭解。我最先得知有關 GC 的知識是來自《深刻理解Java虛擬機》,可是有關 GC 的內容單看這本書是不夠的。編程

當時我覺得我懂不少了,後來通過了一番教育以後才知道啥叫無知者無畏。數組

並且過了一段時間不少有關 GC 的內容都說不上來了,其實也有不少同窗反映有些知識學了就忘,有些內容當時是理解的,過一段時間啥都不記得了。緩存

大部分狀況是由於這塊內容在腦海中沒有造成體系,沒有搞懂來龍去脈,沒有把一些知識串起來安全

近期我整理了下 GC 相關的知識點,想由點及面展開有關 GC 的內容,順帶理一理本身的思路,因此輸出了這篇文章,但願對你有所幫助。微信

有關 GC 的內容其實有不少,可是對於咱們這種通常開發而言是不須要太深刻的,因此我就挑選了一些我認爲重要的整理出來,原本還有一些源碼的我也刪了,感受不必,重要的是在概念上理清。

原本還打算分析有關 JVM 的各垃圾回收器,可是文章太長了,因此分兩篇寫,下篇再發。

本篇整理的 GC 內容不限於 JVM 但大致上仍是偏 JVM,若是講具體的實現默認指的是 HotSpot。

正文

首先咱們知道根據 「Java虛擬機規範」,Java 虛擬機運行時數據區分爲程序計數器、虛擬機棧、本地方法棧、堆、方法區。

而程序計數器、虛擬機棧、本地方法棧這 3 個區域是線程私有的,會隨線程消亡而自動回收,因此不須要管理。

所以垃圾收集只須要關注堆和方法區。

而方法區的回收,每每性價比較低,由於判斷能夠回收的條件比較苛刻。

好比類的卸載須要此類的全部實例都已經被回收,包括子類。而後須要加載的類加載器也被回收,對應的類對象沒有被引用這才容許被回收。

就類加載器這一條來講,除非像特地設計過的 OSGI 等能夠替換類加載器的場景,否則基本上回收不了。

而垃圾收集回報率高的是堆中內存的回收,所以咱們重點關注堆的垃圾收集

如何判斷對象已成垃圾?

既然是垃圾收集,咱們得先判斷哪些對象是垃圾,而後再看看什麼時候清理,如何清理。

常見的垃圾回收策略分爲兩種:一種是直接回收,即引用計數;另外一種是間接回收,即追蹤式回收(可達性分析)。

你們也都知道引用計數有個致命的缺陷-循環引用,因此 Java 用了可達性分析。

那爲何有明顯缺陷的計數引用仍是有不少語言採用了呢?

好比 CPython ,由此看來引用計數仍是有點用的,因此我們就先來盤一下引用計數。

引用計數

引用計數其實就是爲每個內存單元設置一個計數器,當被引用的時候計數器加一,當計數器減小爲 0 的時候就意味着這個單元再也沒法被引用了,因此能夠當即釋放內存。

如上圖所示,雲朵表明引用,此時對象 A 有 1 個引用,所以計數器的值爲 1。

對象 B 有兩個外部引用,因此計數器的值爲 2,而對象 C 沒有被引用,因此說明這個對象是垃圾,所以能夠當即釋放內存。

由此能夠知曉引用計數須要佔據額外的存儲空間,若是自己的內存單元較小則計數器佔用的空間就會變得明顯。

其次引用計數的內存釋放等於把這個開銷平攤到應用的平常運行中,由於在計數爲 0 的那一刻,就是釋放的內存的時刻,這其實對於內存敏感的場景很適用。

若是是可達性分析的回收,那些成爲垃圾的對象不會立馬清除,須要等待下一次 GC 纔會被清除。

引用計數相對而言概念比較簡單,不過缺陷就是上面提到的循環引用。

那像 CPython 是如何解決循環引用的問題呢?

首先咱們知道像整型、字符串內部是不會引用其餘對象的,因此不存在循環引用的問題,所以使用引用計數並無問題。

那像 List、dictionaries、instances 這類容器對象就有可能產生循環依賴的問題,所以 Python 在引用計數的基礎之上又引入了標記-清除來作備份處理。

可是具體的作法又和傳統的標記-清除不同,它採起的是找不可達的對象,而不是可達的對象。

Python 使用雙向鏈表來連接容器對象,當一個容器對象被建立時,它被插入到這個鏈表中,當它被刪除時則移除。

而後在容器對象上還會添加一個字段 gc_refs,如今我們再來看看是如何處理循環引用的:

  1. 對每一個容器對象,將 gc_refs 設置爲該對象的引用計數。
  2. 對每一個容器對象,查找它所引用的容器對象,並減小找到的被引用的容器對象的 gc_refs 字段。
  3. 將此時 gc_refs 大於 0 的容器對象移動到不一樣的集合中,由於 gc_refs 大於 0 說明有對象外部引用它,所以不能釋放這些對象。
  4. 而後找出 gc_refs 大於 0 的容器對象所引用的對象,它們也不能被清除。
  5. 最後剩下的對象說明僅由該鏈表中的對象引用,沒有外部引用,因此是垃圾能夠清除。

具體以下圖示例,A 和 B 對象循環引用, C 對象引用了 D 對象。

爲了讓圖片更加清晰,我把步驟分開截圖了,上圖是 1-2 步驟,下圖是 3-4 步驟。

最終循環引用的 A 和 B 都能被清理,可是天下沒有免費的午飯,最大的開銷之一是每一個容器對象須要額外字段。

還有維護容器鏈表的開銷。根據 pybench,這個開銷佔了大約 4% 的減速

至此咱們知曉了引用計數的優勢就是實現簡單,而且內存清理及時,缺點就是沒法處理循環引用,不過能夠結合標記-清除等方案來兜底,保證垃圾回收的完整性。

因此 Python 沒有解決引用計數的循環引用問題,只是結合了非傳統的標記-清除方案來兜底,算是曲線救國。

其實極端狀況下引用計數也不會那麼及時,你想假如如今有一個對象引用了另外一個對象,而另外一個對象又引用了另外一個,依次引用下去。

那麼當第一個對象要被回收的時候,就會引起連鎖回收反應,對象不少的話這個延時就凸顯出來了。

可達性分析

可達性分析其實就是利用標記-清除(mark-sweep),就是標記可達對象,清除不可達對象。至於用什麼方式清,清了以後要不要整理這都是後話。

標記-清除具體的作法是按期或者內存不足時進行垃圾回收,從根引用(GC Roots)開始遍歷掃描,將全部掃描到的對象標記爲可達,而後將全部不可達的對象回收了。

所謂的根引用包括全局變量、棧上引用、寄存器上的等。

看到這裏你們不知道是否有點感受,咱們會在內存不足的時候進行 GC,而內存不足時也是對象最多時,對象最多所以須要掃描標記的時間也長。

因此標記-清除等於把垃圾積累起來,而後再一次性清除,這樣就會在垃圾回收時消耗大量資源,影響應用的正常運行。

因此纔會有分代式垃圾回收和僅先標記根節點直達的對象再併發 tracing 的手段。

但這也只能減輕沒法根除。

我認爲這是標記-清除和引用計數的思想上最大的差異,一個攢着處理,一個把這種消耗平攤在應用的平常運行中。

而不論標記-清楚仍是引用計數,其實都只關心引用類型,像一些整型啥的就不須要管。

因此 JVM 還須要判斷棧上的數據是什麼類型,這裏又能夠分爲保守式 GC、半保守式 GC、和準確式 GC。

保守式 GC

保守式 GC 指的是 JVM 不會記錄數據的類型,也就是沒法區份內存上的某個位置的數據究竟是引用類型仍是非引用類型。

所以只能靠一些條件來猜想是否有指針指向。好比在棧上掃描的時候根據所在地址是否在 GC 堆的上下界以內,是否字節對齊等手段來判斷這個是否是指向 GC 堆中的指針。

之因此稱之爲保守式 GC 是由於不符合猜想條件的確定不是指向 GC 堆中的指針,所以那塊內存沒有被引用,而符合的卻不必定是指針,因此是保守的猜想。

我再畫一張圖來解釋一下,看了圖以後應該就很清晰了。

前面咱們知道能夠根據指針指向地址來判斷,好比是否字節對齊,是否在堆的範圍以內,可是就有可能出現剛好有數值的值就是地址的值。

這就混亂了,因此就不能肯定這是指針,只能保守認爲就是指針。

所以確定不會有誤殺對象的狀況。只會有對象已經死了,可是有疑似指針的存在指向它,誤覺得它還活着而放過了它的狀況發生。

因此保守式 GC 會有放過一些「垃圾」,對內存不太友好。

而且由於疑似指針的狀況,致使咱們沒法確認它是不是真的指針,因此也就沒法移動對象,由於移動對象就須要改指針

有一個方法就是加個中間層,也就是句柄層,引用會先指到句柄,而後再從句柄表找到實際對象。

因此直接引用不須要改變,若是要移動對象只須要修改句柄表便可。不過這樣訪問就多了一層,效率就變低了。

半保守式GC

半保守式GC,在對象上會記錄類型信息而其餘地方仍是沒有記錄,所以從根掃描的話仍是同樣,得靠猜想。

可是獲得堆內對象了以後,就能準確知曉對象所包含的信息了,所以以後 tracing 都是準確的,因此稱爲半保守式 GC。

如今能夠得知半保守式 GC 只有根直接掃描的對象沒法移動,從直接對象再追溯出去的對象能夠移動,因此半保守式 GC 可使用移動部分對象的算法,也可使用標記-清除這種不移動對象的算法。

而保守式 GC 只能使用標記-清除算法。

準確式 GC

相信你們看下來已經知道準確意味 JVM 須要清晰的知曉對象的類型,包括在棧上的引用也能得知類型等。

能想到的能夠在指針上打標記,來代表類型,或者在外部記錄類型信息造成一張映射表。

HotSpot 用的就是映射表,這個表叫 OopMap。

在 HotSpot 中,對象的類型信息裏會記錄本身的 OopMap,記錄了在該類型的對象內什麼偏移量上是什麼類型的數據,而在解釋器中執行的方法能夠經過解釋器裏的功能自動生成出 OopMap 出來給 GC 用。

被 JIT 編譯過的方法,也會在特定的位置生成 OopMap,記錄了執行到該方法的某條指令時棧上和寄存器裏哪些位置是引用。

這些特定的位置主要在:

  1. 循環的末尾(非 counted 循環
  2. 方法臨返回前 / 調用方法的call指令後
  3. 可能拋異常的位置

這些位置就叫做安全點(safepoint)

那爲何要選擇這些位置插入呢?由於若是對每條指令都記錄一個 OopMap 的話空間開銷就過大了,所以就選擇這些個關鍵位置來記錄便可。

因此在 HotSpot 中 GC 不是在任何位置都能進入的,只能在安全點進入。

至此咱們知曉了能夠在類加載時計算獲得對象類型中的 OopMap,解釋器生成的 OopMap 和 JIT 生成的 OopMap ,因此 GC 的時候已經有充足的條件來準確判斷對象類型。

所以稱爲準確式 GC。

其實還有個 JNI 調用,它們既不在解釋器執行,也不會通過 JIT 編譯生成,因此會缺乏 OopMap。

在 HotSpot 是經過句柄包裝來解決準確性問題的,像 JNI 的入參和返回值引用都經過句柄包裝起來,也就是經過句柄再訪問真正的對象。

這樣在 GC 的時候就不用掃描 JNI 的棧幀,直接掃描句柄表就知道 JNI 引用了 GC 堆中哪些對象了。

安全點

咱們已經提到了安全點,安全點固然不是隻給記錄 OopMap 用的,由於 GC 須要一個一致性快照,因此應用線程須要暫停,而暫停點的選擇就是安全點。

咱們來捋一遍思路。首先給個 GC 名詞,在垃圾收集場景下將應用程序稱爲 mutator 。

一個能被 mutator 訪問的對象就是活着的,也就是說 mutator 的上下文包含了能夠訪問存活對象的數據。

這個上下文其實指的就是棧、寄存器等上面的數據,對於 GC 而言它只關心棧上、寄存器等哪一個位置是引用,由於它只須要關注引用。

可是上下文在 mutator 運行過程當中是一直在變化的,因此 GC 須要獲取一個一致性上下文快照來枚舉全部的根對象。

而快照的獲取須要中止 mutator 全部線程,否則就得不到一致的數據,致使一些活着對象丟失,這裏說的一致性其實就像事務的一致性。

而 mutator 全部線程中這些有機會成爲暫停位置的點就叫 safepoint 即安全點。

openjdk 官網對安全點的定義是:

A point during program execution at which all GC roots are known and all heap object contents are consistent. From a global point of view, all threads must block at a safepoint before the GC can run.

不過 safepoint 不只僅只有 GC 有用,好比 deoptimization、Class redefinition 都有,只是 GC safepoint 比較知名。

咱們再來想一下能夠在哪些位置放置這個安全點。

對於解釋器來講其實每一個字節碼邊界均可以成爲一個安全點,對於 JIT 編譯的代碼也能在不少位置插入安全點,可是實現上只會在一些特定的位置插入安全點。

由於安全點是須要 check 的,而 check 須要開銷,若是安全點過多那麼開銷就大了,等於每執行幾步就須要檢查一下是否須要進入安全點。

其次也就是咱們上面提到的會記錄 OopMap ,因此有額外的空間開銷。

那 mutator 是如何得知此時須要在安全點暫停呢?

其實上面已經提到了是 check,再具體一些還分解釋執行和編譯執行時不一樣的 check。

在解釋執行的時候的 check 就是在安全點 polling 一個標誌位,若是此時要進入 GC 就會設置這個標誌位。

而編譯執行是 polling page 不可讀,在須要進入 safepoint 時就把這個內存頁設爲不可訪問,而後編譯代碼訪問就會發生異常,而後捕獲這個異常掛起即暫停。

這裏可能會有同窗問,那此時阻塞住的線程咋辦?它到不了安全點啊,總不能等着它吧?

這裏就要引入安全區域的概念,在這種引用關係不會發生變化的代碼段中的區域稱爲安全區域。

在這個區域內的任意地方開始 GC 都是安全的,這些執行到安全區域的線程也會標識本身進入了安全區域,

因此會 GC 就不用等着了,而且這些線程若是要出安全區域的時候也會查看此時是否在 GC ,若是在就阻塞等着,若是 GC 結束了那就繼續執行。

可能有些同窗對counted 循環有點疑問,像for (int i...) 這種就是 counted 循環,這裏不會埋安全點。

因此說假設你有一個 counted loop 而後裏面作了一些很慢的操做,因此頗有可能其餘線程都進入安全點阻塞就等這個 loop 的線程完畢,這就卡頓了。

分代收集

前面咱們提到標記-清除方式的 GC 其實就是攢着垃圾收,這樣集中式回收會給應用的正常運行帶來影響,因此就採起了分代收集的思想。

由於研究發現有些對象基本上不會消亡,存在的時間很長,而有些對象出來沒多久就會被咔嚓了。這其實就是弱分代假說和強分代假說。

因此將堆分爲新生代和老年代,這樣對不一樣的區域能夠根據不一樣的回收策略來處理,提高回收效率。

好比新生代的對象有朝生夕死的特性,所以垃圾收集的回報率很高,須要追溯標記的存活對象也不多,所以收集的也快,能夠將垃圾收集安排地頻繁一些。

新生代每次垃圾收集存活的對象不多的話,若是用標記-清除算法每次須要清除的對象不少,所以能夠採用標記-複製算法,每次將存活的對象複製到一個區域,剩下了直接所有清除便可。

可是樸素的標記-複製算法是將堆對半分,可是這樣內存利用率過低了,只有 50%。

因此 HotSpot 虛擬機分了一個 Eden 區和兩個Survivor,默認大小比例是8∶1:1,這樣利用率有 90%。

每次回收就將存活的對象拷貝至一個 Survivor 區,而後清空其餘區域便可,若是 Survivor 區放不下就放到
老年代去,這就是分配擔保機制。

而老年代的對象基本上都不是垃圾,因此追溯標記的時間比較長,收集的回報率也比較低,因此收集頻率安排的低一些。

這個區域因爲每次清除的對象不多,所以能夠用標記-清除算法,可是單單清除不移動對象的話會有不少內存碎片的產生,因此還有一種叫標記-整理的算法,等於每次清除了以後須要將內存規整規整,須要移動對象,比較耗時。

因此能夠利用標記-清除和標記-整理二者結合起來收集老年代,好比平日都用標記-清除,當察覺內存碎片實在太多了就用標記-整理來配合使用。

可能還有不少同窗對的標記-清除,標記-整理,標記-複製算法不太清晰,沒事,我們來盤一下。

標記-清除

分爲兩個階段:

標記階段:tracing 階段,從根(棧、寄存器、全局變量等)開始遍歷對象圖,標記所遇到的每一個對象。

清除階段:掃描堆中的對象,將爲標記的對象做爲垃圾回收。

基本上就是下圖所示這個過程:

清除不會移動和整理內存空間,通常都是經過空閒鏈表(雙向鏈表)來標記哪一塊內存空閒可用,所以會致使一個狀況:空間碎片

這會使得明明總的內存是夠的,可是申請內存就是不足。

並且在申請內存的時候也有點麻煩,須要遍歷鏈表查找合適的內存塊,會比較耗時。

因此會有多個空閒鏈表的實現,也就是根據內存分塊大小組成不一樣的鏈表,好比分爲大分塊鏈表和小分塊鏈表,這樣根據申請的內存分塊大小遍歷不一樣的鏈表,加快申請的效率。

固然還能夠分更多個鏈表。

還有標記,標記的話通常咱們會以爲應該是標記在對象身上,好比標記位放在對象頭中,可是這對寫時複製不兼容。

等於每一次 GC 都須要修改對象,假設是 fork 出來的,實際上是共享一塊內存,那修改必然致使複製。

因此有一種位圖標記法,其實就是將堆的內存某個塊用一個位來標記。就像咱們的內存是一頁一頁的,堆中的內存能夠分紅一塊一塊,而對象就是在一塊,或者多塊內存上。

根據對象所在的地址和堆的起始地址就能夠算出對象是在第幾塊上,而後用一個位圖中的第幾位在置爲 1 ,代表這塊地址上的對象被標記了。

並且用位圖表格法不只能夠利用寫時複製,清除也更加高效,若是標記在對象頭上,那麼須要遍歷整個堆來掃描對象,如今有了位圖,能夠快速遍歷清除對象。

可是不管是標記對象頭仍是利用位圖,標記-清除的碎片問題仍是處理不了。

所以就引出了標記-複製和標記-整理。

標記-複製

首先這個算法會把堆分爲兩塊,一塊是 From、一塊是 To。

對象只會在 From 上生成,發生 GC 以後會找到全部存活對象,而後將其複製到 To 區,以後總體回收 From 區。

再將 To 區和 From 區身份對調,即 To 變成 From , From 變成 To,我再用圖來解釋一波。

能夠看到內存的分配是緊湊的,不會有內存碎片的產生

不須要空閒鏈表的存在,直接移動指針分配內存,效率很高。

對 CPU緩存親和性高,由於從根開始遍歷一個節點,是深度優先遍歷,把關聯的對象都找到,而後內存分配在相近的地方。

這樣根據局部性原理,一個對象被加載了那它所引用的對象也同時被加載,所以訪問緩存直接命中。、

固然它也是有缺點的,由於對象的分配只能在 From 區,而 From 區只有堆一半大小,所以內存的利用率是 50%。

其次若是存活的對象不少,那麼複製的壓力仍是很大的,會比較慢。

而後因爲須要移動對象,所以不適用於上文提到的保守式 GC。

固然我上面描述的是深度優先就是遞歸調用,有棧溢出風險,還有一種 Cheney 的 GC 複製算法,是採用迭代的廣度優先遍歷,具體不作分析了,有興趣自行搜索。

標記-整理

標記-整理其實和標記-複製差很少,區別在於複製算法是分爲兩個區來回複製,而整理不分區,直接整理。

算法思路仍是很清晰的,將存活的對象往邊界整理,也沒有內存碎片,也不須要複製算法那樣騰出一半的空間,因此內存利用率也高。

缺點就是須要對堆進行屢次搜索,畢竟是在一個空間內又標記,又移動的,因此總體而言花費的時間較多,並且若是堆很大的狀況,那麼消耗的時間將更加突出。

至此相信你對標記-清除、標記-複製和標記-整理都清晰了,讓咱們再回到剛纔提到的分代收集。

跨代引用

咱們已經根據對象存活的特性進行了分代,提升了垃圾收集的效率,可是像在回收新生代的時候,有可能有老年代的對象引用了新生代對象,因此老年代也須要做爲根,可是若是掃描整個老年代的話效率就又下降了。

因此就搞了個叫記憶集(Remembered Set)的東西,來記錄跨代之間的引用而避免掃描總體非收集區域。

所以記憶集就是一種用於記錄從非收集區域指向收集區域的指針集合的抽象數據結構。根據記錄的精度分爲

  • 字長精度,每條記錄精確到機器字長。
  • 對象精度,每條記錄精確到對象。
  • 卡精度,每條記錄精確到一塊內存區域。

最多見的是用卡精度來實現記憶集,稱之爲卡表。

我來解釋下什麼叫卡。

拿對象精度來距離,假設新生代對象 A 被老年代對象 D 引用了,那麼就須要記錄老年代 D 所在的地址引用了新生代對象。

那卡的意思就是將內存空間分紅不少卡片。假設新生代對象 A 被老年代 D 引用了,那麼就須要記錄老年代 D 所在的那一塊內存片有引用新生代對象。

也就是說堆被卡切割了,假設卡的大小是 2,堆是 20,那麼堆一共能夠劃分紅 10 個卡。

由於卡的範圍大,若是此時 D 旁邊在同一個卡內的對象也有引用新生代對象的話,那麼就只須要一條記錄。

通常會用字節數組來實現卡表,卡的範圍也是設爲 2 的 N 次冪大小。來看一下圖就很清晰了。

假設地址從 0x0000 開始,那麼字節數組的 0號元素表明 0x0000~0x01FF,1 號表明0x0200~0x03FF,依次類推便可。

而後到時候回收新生代的時候,只須要掃描卡表,把標識爲 1 的髒表所在內存塊加入到 GC Roots 中掃描,這樣就不須要掃描整個老年代了。

用了卡表的話佔用內存比較少,可是相對字長、對象來講精度不許,須要掃描一片。因此也是一種取捨,到底要多大的卡。

還有一種多卡表,簡單的說就是有多張卡表,這裏我畫兩張卡表示意一下。

上面的卡表表示的地址範圍更大,這樣能夠先掃描範圍大的表,發現中間一塊髒了,而後再經過下標計算直接獲得更具體的地址範圍。

這種多卡表在堆內存比較大,且跨代引用較少的時候,掃描效率較高。

而卡表通常都是經過寫屏障來維護的,寫屏障其實就至關於一個 AOP,在對象引用字段賦值的時候加入更新卡表的代碼。

這其實很好理解,說白了就是當引用字段賦值的時候判斷下當前對象是老年代對象,所引用對象是新生代對象,因而就在老年代對象所對應的卡表位置置爲 1,表示髒,待會須要加入根掃描。

不過這種將老年代做爲根來掃描會有浮動垃圾的狀況,由於老年代的對象可能已經成爲垃圾,因此拿垃圾來做爲根掃描出來的新生代對象也頗有多是垃圾。

不過這是分代收集必須作出的犧牲。

增量式 GC

所謂的增量式 GC 其實就是在應用線程執行中,穿插着一點一點的完成 GC,來看個圖就很清晰了

這樣看起來 GC 的時間跨度變大了,可是 mutator 暫停的時間變短了。

對於增量式 GC ,Dijkstra 等人抽象除了三色標記算法,來表示 GC 中對象三種不一樣情況。

三色標記算法

白色:表示還未搜索到的對象。
灰色:表示正在搜索還未搜索完的對象。
黑色:表示搜索完成的對象。

下面這圖從維基百科搞得,雖然說顏色沒對上,可是意思是對的(black 畫成了藍色,grey畫成了黃色)。

我再用文字概述一下三色的轉換。

GC 開始前全部對象都是白色,GC 一開始全部根可以直達的對象被壓到棧中,待搜索,此時顏色是灰色。

而後灰色對象依次從棧中取出搜索子對象,子對象也會被塗爲灰色,入棧。當其全部的子對象都塗爲灰色以後該對象被塗爲黑色。

當 GC 結束以後灰色對象將所有沒了,剩下黑色的爲存活對象,白色的爲垃圾。

通常增量式標記-清除會分爲三個階段:

  1. 根查找,須要暫停應用線程,找到根直接引用的對象。
  2. 標記階段,和應用線程併發執行。
  3. 清除階段。

這裏解釋下 GC 中兩個名詞的含義。

併發:應用線程和 GC 線程一塊兒執行。
並行:多個 GC 線程一塊兒執行。

看起來好像三色標記沒啥問題?來看看下圖。

第一個階段搜索到 A 的子對象 B了,所以 A 被染成了黑色,B 爲灰色。此時須要搜索 B。

可是在 B 開始搜索時,A 的引用被 mutator 換給了 C,而後此時 B 到 C 的引用也被刪了。

接着開始搜索 B ,此時 B 沒有引用所以搜索結束,這時候 C 就被當垃圾了,所以 A 已經黑色了,因此不會再搜索到 C 了。

這就是出現漏標的狀況,把還在使用的對象當成垃圾清除了,很是嚴重,這是 GC 不容許的,寧願放過,不能殺錯。

還有一種狀況多標,好比 A 變成黑色以後,根引用被 mutator 刪除了,那其實 A 就屬於垃圾,可是已經被標記爲黑色了,那就得等下次 GC 清除了。

這其實就是標記過程當中沒有暫停 mutator 而致使的,但這也是爲了讓 GC 減小對應用程序運行的影響。

多標其實還能接受,漏標的話就必須處理了,咱們能夠總結一下爲何會發生漏標:

  1. mutator 插入黑色對象 A 到白色對象 C 的一個引用
  2. mutator 刪除了灰色對象 B 到白色對象 C 的一個引用

只要打破這兩個條件任意一個就不會發生漏標的狀況。

這時候能夠經過如下手段來打破兩個條件:

利用寫屏障在黑色引用白色對象時候,將白色對象置爲灰色,這叫增量更新。

利用寫屏障在灰色對象刪除對白色對象的引用時,將白色對象置爲灰,其實就是保存舊的引用關係。這叫STAB(snapshot-at-the-beginning)

總結

至此有關垃圾回收的關鍵點和思路都差很少了,具體有關 JVM 的垃圾回收器等我下篇再做分析。

如今咱們再來總結一下。

關於垃圾回收首先得找出垃圾,而找出垃圾分爲兩個流派,一個是引用計數,一個是可達性分析。

引用計數垃圾回收的及時,對內存較友好,可是循環引用沒法處理。

可達性分析基本上是現代垃圾回收的核心選擇,可是因爲須要統一回收比較耗時,容易影響應用的正常運行。

因此可達性分析的研究方向就是往如何減小對應用程序運行的影響即減小 STW(stop the world) 的時間。

所以根據對象分代假說研究出了分代收集,根據對象的特性劃分了新生代和老年代,採起不一樣的收集算法,提高回收的效率。

千方百計的拆解 GC 的步驟使得能夠與應用線程併發,而且採起並行收集,加快收集速度。

還有往評估的方向的延遲迴收或者說回收部分垃圾來減小 STW 的時間。

總的而言垃圾回收仍是很複雜的,由於有不少細節,我這篇就是淺顯的紙上談兵,不要被個人標題騙了哈哈哈哈。

最後

這篇文章寫了挺久的,主要是內容不少如何編排有點難,我也選擇性的剪了不少了,但仍是近 1W 字。

期間也查閱了不少資料,不過我的能力有限,若是有紕漏的地方請抓緊聯繫我。

巨人的肩膀

http://arctrix.com/nas/python...

https://openjdk.java.net/grou...

《The Garbage Collection Handbook 》

https://www.iteye.com/blog/us... R大的博客

https://www.jianshu.com/u/90a... 佔小狼的博客


微信搜索【yes的練級攻略】,關注 yes,從一點點到億點點,咱們下篇見。
相關文章
相關標籤/搜索