Java 與 C++ 之間有一堵由內存動態分配和垃圾收集技術所圍成的垃圾高墻 ---《深入理解Java虛擬機》
我們知道手動管理內存意味著自由、精細化地掌控,回收但是底層卻極度依賴于開發人員的水平和細心程度。
如果使用完了忘記釋放內存空間就會發生內存泄露,次讓徹底再如釋放錯了內存空間或者使用了懸垂指針則會發生無法預知的弄懂問題。
這時候 Java 帶著 GC 來了(GC,深度Garbage Collection 垃圾收集,早于 Java 提出),將內存的管理交給 GC 來做,減輕了程序員編程的負擔,提升了開發效率。
所以并不是用 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,現在咱們再來看看是如何處理循環引用的:
對每個容器對象,將 gc_refs 設置為該對象的引用計數。 對每個容器對象,查找它所引用的容器對象,并減少找到的被引用的容器對象的 gc_refs 字段。 將此時 gc_refs 大于 0 的容器對象移動到不同的集合中,因為 gc_refs 大于 0 說明有對象外部引用它,因此不能釋放這些對象。 然后找出 gc_refs 大于 0 的容器對象所引用的對象,它們也不能被清除。 最后剩下的對象說明僅由該鏈表中的對象引用,沒有外部引用,所以是垃圾可以清除。
具體如下圖示例,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,記錄了執行到該方法的某條指令時棧上和寄存器里哪些位置是引用。
這些特定的位置主要在:
循環的末尾( 非 counted 循環) 方法臨返回前 / 調用方法的call指令后 可能拋異常的位置
這些位置就叫作安全點(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 結束之后灰色對象將全部沒了,剩下黑色的為存活對象,白色的為垃圾。
一般增量式標記-清除會分為三個階段:
根查找,需要暫停應用線程,找到根直接引用的對象。 標記階段,和應用線程并發執行。 清除階段。
這里解釋下 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 減少對應用程序運行的影響。
多標其實還能接受,漏標的話就必須處理了,我們可以總結一下為什么會發生漏標:
mutator 插入黑色對象 A 到白色對象 C 的一個引用 mutator 刪除了灰色對象 B 到白色對象 C 的一個引用
只要打破這兩個條件任意一個就不會發生漏標的情況。
這時候可以通過以下手段來打破兩個條件:
利用寫屏障在黑色引用白色對象時候,將白色對象置為灰色,這叫增量更新。
利用寫屏障在灰色對象刪除對白色對象的引用時,將白色對象置為灰,其實就是保存舊的引用關系。這叫STAB(snapshot-at-the-beginning)。
總結
至此有關垃圾回收的關鍵點和思路都差不多了,具體有關 JVM 的垃圾回收器等我下篇再作分析。
現在我們再來總結一下。
關于垃圾回收首先得找出垃圾,而找出垃圾分為兩個流派,一個是引用計數,一個是可達性分析。
引用計數垃圾回收的及時,對內存較友好,但是循環引用無法處理。
可達性分析基本上是現代垃圾回收的核心選擇,但是由于需要統一回收比較耗時,容易影響應用的正常運行。
所以可達性分析的研究方向就是往如何減少對應用程序運行的影響即減少 STW(stop the world) 的時間。
因此根據對象分代假說研究出了分代收集,根據對象的特性劃分了新生代和老年代,采取不同的收集算法,提升回收的效率。
想方設法的拆解 GC 的步驟使得可以與應用線程并發,并且采取并行收集,加快收集速度。
還有往評估的方向的延遲回收或者說回收部分垃圾來減少 STW 的時間。
總的而言垃圾回收還是很復雜的,因為有很多細節,我這篇就是淺顯的紙上談兵,不要被我的標題騙了哈哈哈哈。
最后
這篇文章寫了挺久的,主要是內容很多如何編排有點難,我也選擇性的剪了很多了,但還是近 1W 字。
期間也查閱了很多資料,不過個人能力有限,如果有紕漏的地方請抓緊聯系我。
巨人的肩膀
http://arctrix.com/nas/python/gc/
https://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html
《The Garbage Collection Handbook 》
https://www.iteye.com/blog/user/rednaxelafx R大的博客
https://www.jianshu.com/u/90ab66c248e6 占小狼的博客
特別推薦一個分享架構+算法的優質內容,還沒關注的小伙伴,可以長按關注一下:
長按訂閱更多精彩▼
如有收獲,點個在看,誠摯感謝
免責聲明:本文內容由21ic獲得授權后發布,版權歸原作者所有,本平臺僅提供信息存儲服務。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯系我們,謝謝!