關(guān)于Java與Golang的GC
本文只做GC方面的一些概述,具體細節(jié)請單獨看一些優(yōu)秀blog。
同時文章內(nèi)容為個人理解,歡迎評論指責(zé),謝謝!
一、GC的普遍解決方案
一般來說GC分為兩個部分,一部分是找到需要回收的對象,一部分是清除這些對象并執(zhí)行一些額外操作,如碎片處理。因而本文從標記和清除兩個方面來敘述
實際上所謂的清除方案如mark-sweep是使用的可達性分析的標記法,但本文將兩者分離開講。在清理部分只單論mark-sweep的清理思想,而不考慮標記細節(jié),此處請注意??
1、標記
總體來說,分為兩大類方法,第一類方法為引用計數(shù),也是最為基礎(chǔ)簡單的做法。第二類為可達性分析,即Golang包括Java所使用的標記方式
1.1、引用計數(shù)法
引用計數(shù)法很好理解,即在對象頭部隱式增加一個計數(shù)器,通過計數(shù)器來計算引用次數(shù)。
如創(chuàng)建一個新對象并賦名,此時即為可引用狀態(tài),引用計數(shù)器++。
Student stu = new Student();
此時對象的引用為stu,計數(shù)器非0,則認定為有效對象。若此時將stu置空。
stu = null;
此時無法再引用到之前創(chuàng)建的對象,即對象存在與內(nèi)存中,但引用stu已經(jīng)被抹除,此時該對象計數(shù)器清零,可以被回收。
但仔細思考一下,引用計數(shù)法有一些難以解決的問題,如考慮以下場景:類A包含類B的成員變量,同時類B也包含類A,此時分別實例化兩個類的對象,并相互引用。此時兩個類內(nèi)部的成員變量都包含對方類的引用,即使此時在主函數(shù)中將兩個類引用置為null,兩個實例在內(nèi)存中依然會相互引用。計數(shù)器不清零,二者無法調(diào)用,也無法被清理。

(上圖來自?知乎 海納 GC算法之引用計數(shù)[1])
所以可能會想到,既然我們考慮GC的根本是可不可以到達,或可不可以通過直接或間接的引用去調(diào)用對象,那么我去構(gòu)建一個引用有向圖,做深度或者廣度遍歷不就知道哪些可以留,哪些沒有用了嘛?
這就是可達性分析的原理~
1.2、可達性分析
簡單來說,就是選取幾個可靠的根節(jié)點,從根結(jié)點開始做遍歷,遍歷所有可以到達的對象,那么到不了的就視為無效對象,應(yīng)被GC回收,這種想法就很好的避免了循環(huán)引用的問題,像是Java和Go的GC都采用的這種策略。但這種方式也會有一些缺點,和算法一樣嘛,照顧了時間就要多花空間,沒有完美的算法,只有合適的算法。
可達性分析的重點在于:如何選擇合適的根節(jié)點
我們可以大概看看Java是如何選擇的,JVM的GC通常會選擇以下四類作為根節(jié)點
?VM Stack中引用的對象?方法區(qū)中類靜態(tài)引用的對象?方法區(qū)中常量引用的對象?本地方法棧中的Native方法(JNI)
Java中Running Data Area包含幾個區(qū)域,如總程序調(diào)用需要的棧區(qū),即VM Stack,動態(tài)分配的堆區(qū)heap,方法區(qū)Method Area,還有PCR程序計數(shù)器和Native本地方法區(qū)。首先棧區(qū)為根節(jié)點的主要選擇區(qū),即在棧中的引用對象需要作為根節(jié)點處理。此外,類中的靜態(tài)引用是作為類屬性非對象屬性存在,因而具有根節(jié)點特性。常量同理,這些都分配在堆區(qū)。還有本地方法棧的Native方法,Native方法本質(zhì)是不在JVM的棧中調(diào)用的,而是動態(tài)連接在C棧(或其他)中進行處理。此外,Java在處理無法到達節(jié)點時,會留有一次生存機會,去判斷是否執(zhí)行finalize()方法,此處不細講,畢竟不做Java,不是特別了解JVM。
而Go中的選擇就更簡單一些,即全局變量和G Stack中的引用指針,簡單來說就是全局量和go程中的引用指針。因為Go中沒有類的封裝概念,因而Gc Root選擇也相對簡單一些
可達性分析的具體實現(xiàn),如怎么去解引用等可深入了解一下,此處不再細解
1.3 二者對比
引用計數(shù)法很好的將標記工作平攤到日常的對象創(chuàng)建引用過程中,在對象引用時直接在頭部計數(shù)器++即可,掃描階段直接判斷所有的計數(shù)器,把為0的清理掉即可。但問題也比較突出,就是循環(huán)引用終究無法解決,這樣會造成很多無效引用擠占內(nèi)存空間。但相對來說判定效率高,速度快,GC不會對性能產(chǎn)生較大影響。
可達性分析相對來說較為準確,能夠很好的解決循環(huán)引用等問題。但可達性分析最大的問題在于,為了保持對象的一致性,即掃描階段所有對象狀態(tài)不可變更,此時需要STW(stop the world)。對于高并發(fā)的網(wǎng)絡(luò)服務(wù),STW無疑是致命的,因而之后也會提到,Go是如何優(yōu)化這些缺點的。
2、清理
清理實際上是GC中最重要的部分,即如何充分利用空間和性能,同時如何避免產(chǎn)生大量的內(nèi)存碎片,以及如何適應(yīng)不同的業(yè)務(wù)需求,這些都是各種語言選擇GC的重要標準,之后也會闡述,如為何Go不直接效仿Java成熟的GC機制,而有自己的GC路數(shù)。
以下演示圖均來自?知乎 咱們從頭到尾說一次 Java 垃圾回收[2]
2.1 mark-sweep

上文也提過,實際上mark-sweep使用的可達性分析,即根節(jié)點掃描的方式,但此處僅僅講清理過程。
mark-sweep是最基礎(chǔ)簡單的清理辦法,即標記需要回收的節(jié)點,然后清除節(jié)點即可。但這種做法明顯有很多問題,即產(chǎn)生較多的內(nèi)存碎片。因而這種算法大部分情況下需要改進使用!
2.2 copying

復(fù)制算法就要機智一些,把內(nèi)存分割成兩個相同的區(qū)域,每次只使用一半。當發(fā)生GC的時候,會把其中一塊中的存活對象復(fù)制到另一塊內(nèi)存中,原先內(nèi)存清空。這種方法相對來說不會產(chǎn)生大量的內(nèi)存碎片,但問題在于變相減半了內(nèi)存空間,這顯然也是無法接受的。同時如果GC比較頻繁的話,會涉及到大量的內(nèi)存復(fù)制,降低性能。
2.3 mark-compact

和mark-sweep比較相似,但是其多了一步整理的過程,即將存活對象全部左移,再清理可回收對象。這種方法有效的避免了內(nèi)存碎片的產(chǎn)生,同時也不會像復(fù)制算法變相減小內(nèi)存空間,但需要頻繁的內(nèi)存移動操作,對性能也有一定的影響。
2.4 分代策略
可見,沒有完美的算法,只有適合的算法。幾種回收方式各有優(yōu)劣,因而根據(jù)對象回收的實際情況選擇對應(yīng)的回收策略才是機智操作。JVM所采用的回收策略即分代策略,其將內(nèi)存劃分為幾個代,每個代都有自己的特點,如新生代需要頻繁的GC,大部分對象創(chuàng)建消亡極快,因而實際存活對象很少,使用復(fù)制算法可以不用額外處理回收對象,直接清空對應(yīng)內(nèi)存即可。而老年代存活幾率大,且可能包含一些大對象(之后詳細敘述),因而使用mark- compact就更加合適。
JVM的具體回收策略之后會再詳細敘述,此處僅提出分代的思想。
2.5 三色標記法
三色標記法本質(zhì)就是mark-sweep,但在可達性算法標記的時候,采用三色的標記策略,實現(xiàn)并發(fā)的回收。實際三色標記是一種標記的策略而非清理,但為了避免結(jié)構(gòu)混亂,放在這里一起講。
三色標記法將對象分為黑,灰和白三種(你喜歡紅黃藍也一個道理),黑色即為確認存活對象,灰色是當前分析對象,白色是不可達對象或未分析對象。簡單來說把根節(jié)點標記為灰色push,之后pop出來再做可達性分析,把指向節(jié)點染灰并壓棧,原節(jié)點染黑壓黑棧,這樣最終遍歷完,清理白色節(jié)點。
但實際情況沒有這么簡單,Golang采用的就是三色標記法,原因和具體細節(jié)請繼續(xù)往下看~
二、Java垃圾回收機制
1、Java分代策略
Java使用的是分代策略,分代策略的最大好處就是因地制宜,根據(jù)對象不同情況選擇不同解決方案。

線程共享方法區(qū)和堆區(qū),主要動態(tài)創(chuàng)建都在堆上,因而堆也是主要的回收空間。
JVM將堆區(qū)分為兩個部分,新生代和老年代,新生代存新對象,老年代存老對象,但具體的抉擇調(diào)度很復(fù)雜。
堆空間三分之一為新生代,初始創(chuàng)建的絕大部分對象都在新生代,而新生代又分為三個區(qū)域,分別是4/5的Eden區(qū),和兩個1/10的交替區(qū),圖中稱from,to區(qū),實際上可以理解為,兩個區(qū)一模一樣,有的文章會把他們叫做S0和S1,或者其他叫法,但實際上這兩個區(qū)交替使用,沒有差別。
2、Java GC簡要過程
每次執(zhí)行Minor GC,都會清理新生代區(qū)域,具體做法是:
每次創(chuàng)建對象都在Eden區(qū)中,如當前from區(qū)包含上次GC都剩余存活對象,則此次GC清理Eden區(qū)和from區(qū),將兩個區(qū)中所有的存活對象轉(zhuǎn)移到to區(qū)中,并清空Eden區(qū)和from區(qū)。下次GC則將Eden和to的存活對象,轉(zhuǎn)移到from。這樣就很清晰了,實際上這是一種復(fù)制算法的改進策略,即不直接進行二分分塊,而是用Eden做緩沖。當然這種做法的前提是,新生代98%的對象都是創(chuàng)建即銷毀的,可以理解為挺不過一次GC,所以雖然每次存活對象最大區(qū)域才是新生代的1/10,但空間也是足夠使用的。同時如果空間不夠,會有其他策略進行處理,往老年代轉(zhuǎn)移。
好處就是不會產(chǎn)生內(nèi)存碎片,而且由于使用復(fù)制算法,存活對象比例較低,性能也較強,但需要注意,這些操作都是在STW的前提下的,即執(zhí)行GC需要掛起所有線程去清理堆區(qū)。而Minor GC速度較快,STW時間極短,因而幾乎無法察覺。但在高并發(fā)的網(wǎng)絡(luò)服務(wù)面前,這就是一個致命的缺點,這也是Go不是用分代策略,使用三色標記法的根本原因之一。
?Minor GC 清理新生代?Major GC 清理老年代?Full GC 全清
3、對象轉(zhuǎn)移策略
而實際上,當from或to區(qū)不足以存儲的時候,JVM就會將對象轉(zhuǎn)移到老年代,此外當對象存活過15次GC后也會被送入老年代,一些大對象,如超長字符串數(shù)組也會被直接安排到老年代,而不會在新生代中頻繁GC復(fù)制。
當執(zhí)行一次Minor GC后,會根據(jù)之前每次Minor GC轉(zhuǎn)移到老年代的對象空間進行判斷,如老年代還剩1MB的空間,但之前每次Minor GC平均會將2MB的對象送入老年代,這時JVM就會出現(xiàn)擔(dān)保風(fēng)險,即它無法擔(dān)保老年代剩余空間足夠應(yīng)付Minor GC轉(zhuǎn)移過來的對象,所以此時會進行一次Major GC以釋放老年代的空間。但可能這次只轉(zhuǎn)移1KB的對象,但由于擔(dān)保機制,JVM還是會執(zhí)行Major GC。
三、Golang垃圾回收機制
1、標記-清除法(v1.3之前)
初代的golang垃圾回收機制非常簡陋,即go runtime在一定條件下(主動或被動),暫停所有任務(wù)(STW),執(zhí)行mark&sweep操作,執(zhí)行完清理過程后再啟動任務(wù)的執(zhí)行。在需要回收較多廢棄對象的時候,會出現(xiàn)較長時間的STW停頓。go設(shè)計初衷便是為了支持CSP模型下的并發(fā)任務(wù)處理,同時原生高程度的web支持也使其適用于應(yīng)對輕量級web服務(wù)的搭建,因而v1.3之前的STW對于高并發(fā)網(wǎng)絡(luò)服務(wù)是難以忍受的,因而只能通過控制內(nèi)存分配數(shù)量,或手動管理來解決高并發(fā)場景,golang針對這一問題在后續(xù)不斷的進行了改進。
2、標記-STW-清除法(v1.3)
實際使用STW的原因在于,需要在標記階段去掃描所有對象,以分辨是否需要回收。如果此時不作限制,放任程序變更對象狀態(tài),便會導(dǎo)致錯誤的gc。而清除階段由于不再需要掃描整個程序的對象狀態(tài),因而實際上在清除階段是不需要STW的,所以在v1.3版本,go runtime分離了mark和sweep的操作,mark期間執(zhí)行STW,結(jié)束后并發(fā)執(zhí)行g(shù)c和其他業(yè)務(wù)邏輯。同時如果存在多核處理器,go會試圖使用額外的核心處理gc,盡量不影響業(yè)務(wù)代碼的運行。
3、三色標記法(v1.5之后)
此時,golang的gc是“非分代的、非移動的、并發(fā)的、三色的標記清除垃圾回收器”,這種gc方式簡稱三色標記法。

三色標記法大體過程如下:
1.創(chuàng)建白,灰,黑三個集合,并將所有對象放入白色集合2.從根節(jié)點遍歷對象(非遞歸,類似廣度優(yōu)先),將遍歷到的對象轉(zhuǎn)移到灰色集合3.遍歷灰色對象,將引用對象轉(zhuǎn)移到灰色集合,原灰色對象轉(zhuǎn)移到黑色集合,重復(fù)直至無灰色對象4.通過寫屏障檢測對象變化5.清理白色對象
三色標記法易于實現(xiàn)并發(fā)回收,即在程序運行的同時進行g(shù)c,不需要長時間暫停整個程序。這種mark操作可以漸進執(zhí)行而不需要每次掃描整個內(nèi)存,有效減少STW時間。
但此時也會有很多問題存在,當同時出現(xiàn)以下情況,就會出現(xiàn)錯誤的gc:
?變更引用,導(dǎo)致白色對象被黑色對象引用?破壞引用,導(dǎo)致白色對象與灰色對象的可達性關(guān)系被破壞
此時白色對象本質(zhì)上是存活對象,但由于無法再被掃描到,所以會被誤清除。因而我們需要一些額外的機制,在盡量少的STW的前提下,保證gc的準確性和可靠性。
其實主要的問題就在于對象引用狀態(tài)的變更,只要破壞以上某一點,就可以保證回收的穩(wěn)定性
3.1 Dijkstra插入屏障
在增加引用的時候,如果某個對象被引用,則需放入灰色集合,而不能放入白色集合。這樣就保證無論如何,不會出現(xiàn)黑色引用白色的情況。
但插入屏障存在兩個問題:
1.插入屏障在一次回收過程中可能會有殘留對象存在,導(dǎo)致刪除引用后,對象直到下一次gc才能被回收。2.在標記階段,每次的引用插入操作都需要調(diào)用插入屏障,進而導(dǎo)致性能下降。因而go團隊將插入屏障應(yīng)用到了堆區(qū)的回收中,棧中使用原三色標記法。這就導(dǎo)致為了gc的準確性,需要在完成標記時啟動STW對棧區(qū)再次掃描。
3.2 Yuasa刪除屏障
在刪除引用的時候,如果被刪除的對象自身為白色或灰色,會被標記為灰色。這就保持刪除引用時,始終保持一條灰色可達的通路,進而不會出現(xiàn)破壞引用,導(dǎo)致白色對象不可達的情況。但回收精度不高,對象即使被刪除,引用指針依然可以存活一輪。GC開始時需要STW掃描堆棧來記錄初始快照,從而保護初始狀態(tài)下的存活對象。
3.3 混合寫屏障(v1.8之后)
混合寫屏障結(jié)合兩者特點,通過以下方式實現(xiàn)并發(fā)穩(wěn)定的gc:
1.將棧上的對象全部掃描并標記為黑色2.GC期間,任何在棧上創(chuàng)建的新對象,均為黑色。3.被刪除的對象標記為灰色。4.被添加的對象標記為灰色。
由于要保證棧的運行效率,混合寫屏障是針對于堆區(qū)使用的。即棧區(qū)不會觸發(fā)寫屏障,只有堆區(qū)觸發(fā),由于棧區(qū)初始標記的可達節(jié)點均為黑色節(jié)點,因而也不需要第二次STW下的掃描。本質(zhì)上是融合了插入屏障和刪除屏障的特點,解決了插入屏障需要二次掃描的問題。同時針對于堆區(qū)和棧區(qū)采用不同的策略,保證棧的運行效率不受損。
此時golang包含混合寫屏障的并發(fā)三色標記法正式形成,“非分代的、非移動的、并發(fā)的、三色的標記清除垃圾回收器”能夠避免STW,適應(yīng)高并發(fā)場景,效率較高。
四、總結(jié)
綜上,本文從大方向介紹了普遍的GC思想,并描述了Java與Golang采用的不同GC策略。
通過對比可以發(fā)現(xiàn),兩種語言采用的GC各有優(yōu)劣。為了適應(yīng)業(yè)務(wù),做出很多獨特的優(yōu)化和改進,使語言在適用領(lǐng)域能夠最大程度發(fā)揮作用。
Java發(fā)展時間長,GC機制較完善,效率高,碎片化小,兼顧了各種場景下的垃圾回收,充分利用堆棧空間。而Golang將更多的精力放在了并發(fā)解決上,也是其一貫的設(shè)計思想,相對Java來說機制不夠完善,但針對并發(fā)的處理有獨特的方式,因而或許更適合高并發(fā)的高GC的業(yè)務(wù)場景。
因此,Golang的三色標記法,是“非分代的、非移動的”(不同于Java),同時也是高度“并發(fā)的”回收機制。同時Golang團隊也在不停的優(yōu)化GC策略,包括一些異步的搶占式調(diào)度等,相信Golang會越來越好!
References
[1]?知乎 海納 GC算法之引用計數(shù):?https://zhuanlan.zhihu.com/p/27939756[2]?知乎 咱們從頭到尾說一次 Java 垃圾回收:?https://zhuanlan.zhihu.com/p/73628158
推薦閱讀
