文章收錄地址:Java-Bang
專注于系統(tǒng)架構锭环、高可用棚唆、高性能旱函、高并發(fā)類技術分享
在讀博士的時候资厉,我曾經(jīng)寫過一個統(tǒng)計 Java 對象生命周期的動態(tài)分析,并且用它來跑了一些基準測試罢吃。
其中一些程序的結果楚午,恰好驗證了許多研究人員的假設,即大部分的 Java 對象只存活一小段時間尿招,而存活下來的小部分 Java 對象則會存活很長一段時間矾柜。
(pmd 中 Java 對象生命周期的直方圖阱驾,紅色的表示被逃逸分析優(yōu)化掉的對象)
之所以要提到這個假設,是因為它造就了 Java 虛擬機的分代回收思想怪蔑。簡單來說里覆,就是將堆空間劃分為兩代,分別叫做新生代和老年代缆瓣。新生代用來存儲新建的對象喧枷。當對象存活時間夠長時,則將其移動到老年代弓坞。
Java 虛擬機可以給不同代使用不同的回收算法隧甚。對于新生代,我們猜測大部分的 Java 對象只存活一小段時間渡冻,那么便可以頻繁地采用耗時較短的垃圾回收算法戚扳,讓大部分的垃圾都能夠在新生代被回收掉。
對于老年代族吻,我們猜測大部分的垃圾已經(jīng)在新生代中被回收了帽借,而在老年代中的對象有大概率會繼續(xù)存活。當真正觸發(fā)針對老年代的回收時超歌,則代表這個假設出錯了宜雀,或者堆的空間已經(jīng)耗盡了。
這時候握础,Java 虛擬機往往需要做一次全堆掃描,耗時也將不計成本悴品。(當然禀综,現(xiàn)代的垃圾回收器都在并發(fā)收集的道路上發(fā)展,來避免這種全堆掃描的情況苔严。)
今天這一篇我們來關注一下針對新生代的 Minor GC定枷。首先,我們來看看 Java 虛擬機中的堆具體是怎么劃分的届氢。
Java 虛擬機的堆劃分
前面提到欠窒,Java 虛擬機將堆劃分為新生代和老年代。其中退子,新生代又被劃分為 Eden 區(qū)岖妄,以及兩個大小相同的 Survivor 區(qū)。
默認情況下寂祥,Java 虛擬機采取的是一種動態(tài)分配的策略(對應 Java 虛擬機參數(shù) -XX:+UsePSAdaptiveSurvivorSizePolicy)荐虐,根據(jù)生成對象的速率,以及 Survivor 區(qū)的使用情況動態(tài)調整 Eden 區(qū)和 Survivor 區(qū)的比例丸凭。
當然福扬,你也可以通過參數(shù) -XX:SurvivorRatio 來固定這個比例腕铸。但是需要注意的是厕九,其中一個 Survivor 區(qū)會一直為空浊洞,因此比例越低浪費的堆空間將越高。
通常來說扛施,當我們調用 new 指令時汽烦,它會在 Eden 區(qū)中劃出一塊作為存儲對象的內(nèi)存涛菠。由于堆空間是線程共享的,因此直接在這里邊劃空間是需要進行同步的刹缝。
否則碗暗,將有可能出現(xiàn)兩個對象共用一段內(nèi)存的事故。如果你還記得前兩篇我用“停車位”打的比方的話梢夯,這里就相當于兩個司機(線程)同時將車停入同一個停車位言疗,因而發(fā)生剮蹭事故。
Java 虛擬機的解決方法是為每個司機預先申請多個停車位颂砸,并且只允許該司機停在自己的停車位上噪奄。那么當司機的停車位用完了該怎么辦呢(假設這個司機代客泊車)?
答案是:再申請多個停車位便可以了人乓。這項技術被稱之為 TLAB(Thread Local Allocation Buffer勤篮,對應虛擬機參數(shù) -XX:+UseTLAB,默認開啟)色罚。
具體來說碰缔,每個線程可以向 Java 虛擬機申請一段連續(xù)的內(nèi)存,比如 2048 字節(jié)戳护,作為線程私有的 TLAB金抡。
這個操作需要加鎖,線程需要維護兩個指針(實際上可能更多腌且,但重要也就兩個)梗肝,一個指向 TLAB 中空余內(nèi)存的起始位置,一個則指向 TLAB 末尾铺董。
接下來的 new 指令巫击,便可以直接通過指針加法(bump the pointer)來實現(xiàn),即把指向空余內(nèi)存位置的指針加上所請求的字節(jié)數(shù)精续。
我猜測會有留言問為什么不把 bump the pointer 翻譯成指針碰撞坝锰。這里先解釋一下,在英語中我們通常省略了 bump up the pointer 中的 up驻右。在這個上下文中 bump 的含義應為“提高”什黑。另外一個例子是當我們發(fā)布軟件的新版本時,也會說 bump the version number堪夭。
如果加法后空余內(nèi)存指針的值仍小于或等于指向末尾的指針愕把,則代表分配成功拣凹。否則,TLAB 已經(jīng)沒有足夠的空間來滿足本次新建操作恨豁。這個時候嚣镜,便需要當前線程重新申請新的 TLAB。
當 Eden 區(qū)的空間耗盡了怎么辦橘蜜?這個時候 Java 虛擬機便會觸發(fā)一次 Minor GC菊匿,來收集新生代的垃圾。存活下來的對象计福,則會被送到 Survivor 區(qū)跌捆。
前面提到,新生代共有兩個 Survivor 區(qū)象颖,我們分別用 from 和 to 來指代佩厚。其中 to 指向的 Survivior 區(qū)是空的。
當發(fā)生 Minor GC 時说订,Eden 區(qū)和 from 指向的 Survivor 區(qū)中的存活對象會被復制到 to 指向的 Survivor 區(qū)中抄瓦,然后交換 from 和 to 指針,以保證下一次 Minor GC 時陶冷,to 指向的 Survivor 區(qū)還是空的钙姊。
Java 虛擬機會記錄 Survivor 區(qū)中的對象一共被來回復制了幾次。如果一個對象被復制的次數(shù)為 15(對應虛擬機參數(shù) -XX:+MaxTenuringThreshold)埂伦,那么該對象將被晉升(promote)至老年代煞额。另外,如果單個 Survivor 區(qū)已經(jīng)被占用了 50%(對應虛擬機參數(shù) -XX:TargetSurvivorRatio)沾谜,那么較高復制次數(shù)的對象也會被晉升至老年代立镶。
總而言之,當發(fā)生 Minor GC 時类早,我們應用了標記 - 復制算法,將 Survivor 區(qū)中的老存活對象晉升到老年代嗜逻,然后將剩下的存活對象和 Eden 區(qū)的存活對象復制到另一個 Survivor 區(qū)中涩僻。理想情況下,Eden 區(qū)中的對象基本都死亡了栈顷,那么需要復制的數(shù)據(jù)將非常少逆日,因此采用這種標記 - 復制算法的效果極好。
Minor GC 的另外一個好處是不用對整個堆進行垃圾回收萄凤。但是室抽,它卻有一個問題,那就是老年代的對象可能引用新生代的對象靡努。也就是說坪圾,在標記存活對象的時候晓折,我們需要掃描老年代中的對象。如果該對象擁有對新生代對象的引用兽泄,那么這個引用也會被作為 GC Roots漓概。
這樣一來,豈不是又做了一次全堆掃描呢病梢?
卡表
HotSpot 給出的解決方案是一項叫做卡表(Card Table)的技術胃珍。該技術將整個堆劃分為一個個大小為 512 字節(jié)的卡,并且維護一個卡表蜓陌,用來存儲每張卡的一個標識位觅彰。這個標識位代表對應的卡是否可能存有指向新生代對象的引用。如果可能存在钮热,那么我們就認為這張卡是臟的填抬。
在進行 Minor GC 的時候,我們便可以不用掃描整個老年代霉旗,而是在卡表中尋找臟卡痴奏,并將臟卡中的對象加入到 Minor GC 的 GC Roots 里。當完成所有臟卡的掃描之后厌秒,Java 虛擬機便會將所有臟卡的標識位清零读拆。
由于 Minor GC 伴隨著存活對象的復制,而復制需要更新指向該對象的引用鸵闪。因此檐晕,在更新引用的同時,我們又會設置引用所在的卡的標識位蚌讼。這個時候辟灰,我們可以確保臟卡中必定包含指向新生代對象的引用。
在 Minor GC 之前篡石,我們并不能確保臟卡中包含指向新生代對象的引用芥喇。其原因和如何設置卡的標識位有關。
首先凰萨,如果想要保證每個可能有指向新生代對象引用的卡都被標記為臟卡继控,那么 Java 虛擬機需要截獲每個引用型實例變量的寫操作,并作出對應的寫標識位操作胖眷。
這個操作在解釋執(zhí)行器中比較容易實現(xiàn)武通。但是在即時編譯器生成的機器碼中,則需要插入額外的邏輯珊搀。這也就是所謂的寫屏障(write barrier冶忱,注意不要和 volatile 字段的寫屏障混淆)。
寫屏障需要盡可能地保持簡潔境析。這是因為我們并不希望在每條引用型實例變量的寫指令后跟著一大串注入的指令囚枪。
因此派诬,寫屏障并不會判斷更新后的引用是否指向新生代中的對象,而是寧可錯殺眶拉,不可放過千埃,一律當成可能指向新生代對象的引用。
這么一來忆植,寫屏障便可精簡為下面的偽代碼 [1]放可。這里右移 9 位相當于除以 512,Java 虛擬機便是通過這種方式來從地址映射到卡表中的索引的朝刊。最終耀里,這段代碼會被編譯成一條移位指令和一條存儲指令。
CARD_TABLE [this address >> 9] = DIRTY;
雖然寫屏障不可避免地帶來一些開銷拾氓,但是它能夠加大 Minor GC 的吞吐率( 應用運行時間 /(應用運行時間 + 垃圾回收時間) )冯挎。總的來說還是值得的咙鞍。不過房官,在高并發(fā)環(huán)境下,寫屏障又帶來了虛共享(false sharing)問題 [2]续滋。
在介紹對象內(nèi)存布局中我曾提到虛共享問題翰守,講的是幾個 volatile 字段出現(xiàn)在同一緩存行里造成的虛共享。這里的虛共享則是卡表中不同卡的標識位之間的虛共享問題疲酌。
在 HotSpot 中蜡峰,卡表是通過 byte 數(shù)組來實現(xiàn)的。對于一個 64 字節(jié)的緩存行來說朗恳,如果用它來加載部分卡表湿颅,那么它將對應 64 張卡,也就是 32KB 的內(nèi)存粥诫。
如果同時有兩個 Java 線程油航,在這 32KB 內(nèi)存中進行引用更新操作,那么也將造成存儲卡表的同一部分的緩存行的寫回怀浆、無效化或者同步操作劝堪,因而間接影響程序性能。
為此揉稚,HotSpot 引入了一個新的參數(shù) -XX:+UseCondCardMark,來盡量減少寫卡表的操作熬粗。其偽代碼如下所示:
if (CARD_TABLE [this address >> 9] != DIRTY) CARD_TABLE [this address >> 9] = DIRTY;
總結與實踐
今天我介紹了 Java 虛擬機中垃圾回收具體實現(xiàn)的一些通用知識搀玖。
Java 虛擬機將堆分為新生代和老年代,并且對不同代采用不同的垃圾回收算法驻呐。其中灌诅,新生代分為 Eden 區(qū)和兩個大小一致的 Survivor 區(qū)芳来,并且其中一個 Survivor 區(qū)是空的。
在只針對新生代的 Minor GC 中猜拾,Eden 區(qū)和非空 Survivor 區(qū)的存活對象會被復制到空的 Survivor 區(qū)中即舌,當 Survivor 區(qū)中的存活對象復制次數(shù)超過一定數(shù)值時,它將被晉升至老年代挎袜。
因為 Minor GC 只針對新生代進行垃圾回收顽聂,所以在枚舉 GC Roots 的時候,它需要考慮從老年代到新生代的引用盯仪。為了避免掃描整個老年代紊搪,Java 虛擬機引入了名為卡表的技術,大致地標出可能存在老年代到新生代引用的內(nèi)存區(qū)域全景。