-
引子
Java作為一個安全語言揉燃,不會向程序員提供內(nèi)存管理api迎卤,而是把這個任務交給垃圾收集器墩弯,程序員不需要關心對象分配在哪它匕,也不需要關心對象如何分配/回收內(nèi)存展融。本文會詳細介紹這一機制。
對象什么時候死?
傳統(tǒng)靜態(tài)編譯器會使用『活性分析』算法來確定一個對象的生存期:如果一個變量持有一個值豫柬,那么這個變量的生存期以『對這個變量的第一次寫』開始告希,以『對這個變量的最后一次讀結束』,在這期間烧给,該變量都是活躍的燕偶。
這種方式確實可以確定變量的生存期,但是只能局限于某個方法內(nèi)的局部變量创夜,因為java是個動態(tài)性很強的語言杭跪,擁有虛方法(多態(tài))和異常拋出這種運行期才能明確的過程跳轉,是的驰吓,根本無法靜態(tài)分析出下個代碼塊是啥涧尿,又怎么能確定變量的生存期呢(比如發(fā)生參數(shù)傳遞的方法跳轉)。
但『活性分析』也不是一無是處檬贰,編譯器為保證程序的局部性姑廉,會對方法進行『逃逸分析』,分析出方法的局部變量翁涤,輔助『寄存器分配』這樣的優(yōu)化桥言。
ps:『逃逸分析』不屬于GC的范疇,他只是輔助runtime做一些優(yōu)化葵礼。逃逸分析由-XX:+DoEscapeAnalysis參數(shù)控制号阿,jdk1.8默認開啟。
class 活性分析案例1 {
int a = 1;
void 優(yōu)化前(){
while(1w次){
a++;
}
print(a);
}
void 優(yōu)化后(){
//棧上分配
int temp = a;
while(1w次){
temp++;
}
print(temp);
}
}
class 活性分析案例2 {
void test(){
for(int i = 0; i < 5000000; i++){
createObject();
}
}
public static void createObject(){
//加鎖
synchronized (new Object()){
}
}
}
案例1:
局部優(yōu)化鸳粉。
案例2:
第一個點:逃逸分析后對象直接進行『標量替換』扔涧,使test方法結束后直接回收對象,減少垃圾收集器壓力。
第二個點:逃逸分析后進行鎖消除枯夜,直接忽略synchronized關鍵字弯汰。
ps: 標量替換:Java的原始類型無法被分解可以被看作『標量』,引用(指針)也無法被分解可以被看作『標量』湖雹。而一個對象一定是標量的聚合體咏闪,把對象分解為標量后可以被編譯器優(yōu)化,在棧/寄存器上分配摔吏。
注意鸽嫂!Java對象在實際的JVM實現(xiàn)中可能在GC堆上分配空間,也可能在棧上分配空間舔腾,也可能完全就消失了溪胶。
由此可以得出,一個對象什么時候該被回收稳诚,這個時間點難以精準確定哗脖,因為這需要預測程序未來的行為。 但是可以用對象是否可達來近似對象是否已死扳还,當發(fā)現(xiàn)一個對象不可達的時候才避,這個對象肯定已經(jīng)死過了(可能死好一會了)。雖然回收的時間肯定會晚于對象死的時間氨距,但這絕對是及時性和分析復雜度的一個合理的妥協(xié)桑逝!下面會詳細介紹幾種『可達算法』。
引用計數(shù)(RC)
判斷一個對象是否可達俏让,很直觀的一種方式引用計數(shù)楞遏,就是把對象的引用數(shù)目用計數(shù)器維護起來,當對象被
引用時首昔,就遞增計數(shù)器寡喝;當引用被覆蓋時就遞減計數(shù)器,當計數(shù)器減少至0的時候勒奇,這個對象就需要被回收预鬓,回收
當前對象時需要減少當前對象引用的所有對象的計數(shù)器。
比如S對象引用了A赊颠、B格二、C,那么S被回收時竣蹦,A顶猜、B、C三個對象的計數(shù)器要遞減痘括,這個過程會被持續(xù)傳遞驶兜!
下面介紹一個簡單的引用計數(shù)實現(xiàn):
首先是引用計數(shù)指令的抽象,如下:
incRC RC遞增
decRC RC遞減
testRC 判斷RC是否降為0,如果是抄淑,進行傳遞回收
RC指令需要編譯器插樁到代碼中,比如:
1. 對象obj1的一個引用加載到棧上驰后,比如Ref ref1 = obj1 肆资,需要插入incRC obj1。
2. ref1從指向obj1切換為obj2灶芝,比如Ref ref1 = obj2郑原,需要插入incRC obj2、decRC obj2夜涕、testRC obj2犯犁。
3. ref1指向obj1,ref1指向切換為NULL女器,然后ref2指向obj1酸役,ref1指向切換為NULL。需要插入incRC obj1驾胆、decRC obj1涣澡、testRC obj1、incRC obj2丧诺、decRC obj2入桂、testRC obj2
這種RC存在的問題:
1. 可以發(fā)現(xiàn),RC可能會帶來巨大的運行時開銷驳阎。而這些開銷很多都是冗余的抗愁,比如上述的案例3,這一連串的incRc呵晚、decRC蜘腌、testRC其實完全可以替換為一個testRC。
2. 多線程應用程序中更新RC需要原子指令劣纲,原子指令都是代價昂貴的
3. A引用B逢捺,B引用A,這就形成了循環(huán)引用癞季,然而引用計數(shù)沒有維護依賴網(wǎng)劫瞳,所以根本無法檢測出是否循環(huán)引用。雖然沒有其他變量引用他們绷柒,但A和B的引用計數(shù)始終無法歸零志于,產(chǎn)生了A和B這倆漂浮垃圾。
對象追蹤
上面提到過RC主要問題是沒有維護『依賴網(wǎng)』废睦,對象追蹤可以解決這一問題伺绽,主要分為兩步:
- 根集枚舉。確定執(zhí)行上下文(棧、寄存器奈应、全局變量)中的所有槽位澜掩。
- 堆追蹤。遍歷對象鄰接圖,直到所有對象都被訪問到杖挣。
通常情況下肩榕,對象追蹤不能在用戶程序活躍的時候進行,因為這個時候執(zhí)行上下文和對象圖都在持續(xù)變化惩妇。這個時候
對象追蹤和用戶程序執(zhí)行是競爭的關系株汉。因此GC開始對象追蹤時候需要暫停用戶程序,也叫STW(stop-the-world)歌殃。
ps:并不是所有的語言都能進行根集枚舉乔妈,非安全語言可能會讓編譯器很迷惑,比如在整數(shù)變量中保存引用氓皱。
分代回收
有這么一個假設"大部分對象都會die young路召,沒有die young的對象生命期都會很長",這個假設來源于很多應用的行為分析匀泊。
基于這個假設:那么可以讓對象都在young gen中創(chuàng)建优训,然后對其頻繁回收,由于大部分對象都會die young各聘,所以young gen非常適合copy算法揣非,減少碎片的同時還能提高GC效率。
Hotsport VM把young gen分為了eden躲因、survival兩個區(qū)域早敬,其中survival又分成了from、to兩個區(qū)域大脉。一個對象的young gen之旅如下:
- 對象先在eden區(qū)分配
- eden達到閾值觸發(fā)young gc搞监,進行復制-清除,將幸存對象copy至to
- 回收from區(qū)镰矿,同樣復制-清除琐驴,copy至to
- to變?yōu)閒rom,from變?yōu)閠o
- 以此輪回下去秤标。這個過程中對象每次從from copy到to绝淡,年齡都會+1
- 對象在某次copy中年齡達到了閾值,copy到old gen
ps: 為什么要把survival分成兩塊呢苍姜?
首先是為什么需要survival區(qū)域牢酵?這源于young gen的對象年齡設計,每次對象年齡增長總要有個區(qū)域用來復制(總不能直接復制到old gen吧衙猪,這樣年齡就沒意義了)馍乙,所以就有了survival布近。
為什么要分成兩塊呢?首先要明白復制算法一定要有個區(qū)域用于復制丝格。如果只有一塊撑瞧,那么在survival就只能使用標記清除算法,容易產(chǎn)生內(nèi)存碎片铁追,違背了young gen設計的初衷季蚂。也有人說,那為什么不能再來一次內(nèi)存整理呢琅束?我猜測是內(nèi)存整理的開銷遠遠大于復制,索性浪費一小塊內(nèi)存用來復制反而更實在算谈。
下面是幾種基本GC算法的性能對比:
mark-sweep | mark-compact | copy | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空間開銷 | 少(但會堆積碎片) | 少(不堆積碎片) | 通常需要活對象的2倍大猩鳌(不堆積碎片) |
移動? | 否 | 是 | 是 |
CMS垃圾收集器
CMS(Concurrent Mark Sweep)收集器是一個以『低延遲』為特點的垃圾收集器然眼,適合主流的B/S系統(tǒng)艾船。
從命名也能看出來,CMS能和用戶程序并發(fā)執(zhí)行高每,他的回收區(qū)域為「old gen」屿岂,其回收過程如下:
- 初始標記:根集枚舉,列舉所有GC ROOT能直接關聯(lián)到的對象鲸匿。由于CMS并不是whole heap垃圾收集器爷怀,所以CMS回收old gen時,必須把young gen算作是ROOT带欢,那么CMS的GC ROOT包含棧运授、寄存器、全局變量以及young gen乔煞。(同樣的道理吁朦,young gen GC也要把old gen作為GC ROOT)
- 并發(fā)標記:開始對象追蹤,標記所有可達的對象渡贾,這個過程與用戶線程并發(fā)執(zhí)行
- 重新標記:重新掃描2步中「write barrier」維護的「記憶集」以及GC ROOT逗宜,該階段需要STW
- 并發(fā)清除:清除對象
并發(fā)標記: 既然CMS的特點是低延遲,他的STW時間要足夠小空骚。為了達到這一目的:GC線程(回收器)和用戶線程(修改器)應盡量并發(fā)纺讲。而在并發(fā)過程中,回收器的標記階段需要遍歷對象鄰接圖府怯,這個時候對象鄰接圖是在變化的刻诊,大致有兩種變化:
- 修改器修改對象的引用字段,這可能會導致某個對象死亡牺丙。假設在活躍對象集L中则涯,標記結束后仍然可達的對象為集合$L复局,那么有$L為L的子集。
- 修改器創(chuàng)建了新的對象粟判,這些對象可能一直可達亿昏,也有可能死亡。假設標記階段創(chuàng)建的新的對象為集合N档礁,標記結束后角钩,他們中仍然可達的對象為集合$N,那么有 $N為N的子集呻澜。
可以得出結論递礼,經(jīng)過并發(fā)標記后,真正活躍的對象集為 $L+$N 羹幸,且 L + N ? $L +$N 脊髓。
為了保證正確性,并發(fā)標記期間不能丟失任何活躍的對象(不能錯殺)栅受,那么可以用所有活躍對象的超集 L + N 将硝,并發(fā)標記結束后,再掃描一遍屏镊,不就可以保證正確性了么依疼,這種方式叫做原始快照,也就是SATB(Snapshot-At-The-Beginning)而芥。
還有另外一個思路律罢,并發(fā)標記結束后,找到真正的活躍對象 $L + $N蔚出,這種方式叫做增量更新弟翘,也就是INC( Incremental Update),而CMS采用的就是 INC骄酗。
不管是SATB還是INC稀余,都需要一個維護引用變更的機制,這個機制就是 寫屏障趋翻。下面提供INC的偽代碼:
//src:某個對象睛琳,slot:某個槽,new_ref;新引用
write_barrier_ref(Object* src,object** slot,Object* new_ref){
*slot = new_ref();
if( is_marked(src) ){
remember(new_ref());
}
}
代碼解釋:
如果當前對象已經(jīng)被標記了踏烙,對這個對象的某個槽賦予新引用時师骗,write_barrier需要記憶new_ref。如果當前對象沒有被標記讨惩,write_barrier不需要記憶任何東西辟癌,等當前對象被標記的時候自然會去掃描他的new_ref。
案例:
在并發(fā)標記的過程中荐捻,a黍少、b寡夹、A都已經(jīng)被標記,這個時候修改器斷開A和B之間的引用厂置,讓b引用B菩掏,如果write_barrier沒有記憶這個引用變更,對象B就會被錯殺昵济。既然write_barrier記憶了會被錯殺的對象智绸,那么應該在并發(fā)標記之后來一次重新標記,保證正確性访忿!重新標記稍后介紹瞧栗。
標記過程中的三色標記: 假設對象圖為 G,其中 $G 都已經(jīng)被掃描過海铆,那么 (G - $G) 部分還沒被掃描沼溜。假如這個時候修改器在修改對象圖,那么會產(chǎn)生兩種效果:
- $G 的一部分對象死亡游添,但作為漂浮垃圾保留。(write_barrier只處理新引用)
- (G-$G)的一部分對象失去連接通熄,只與$G 中的對象連接唆涝。
第一種效果不會導致正確性的問題,而第二種則會導致對象被錯殺唇辨。實際上廊酣,CMS的標記過程并不是非黑即白,還有一個灰
赏枚。下面用三色標記術語來解釋上述問題亡驰。
掃描過的對象 $G 為黑色。未掃描的對象(G - $G) 為白色饿幅》踩瑁灰色為掃描過,但slot沒有掃描完全栗恩,如圖中的灰色對象透乾。灰色對象是已經(jīng)確定可達磕秤,但是他引用的對象還未完全確定乳乌。一個對象不會直接從黑色變成白色。
如圖所示市咆,在并發(fā)標記過程中汉操,修改器修改引用,使黑色對象直接引用白色對象蒙兰,如果沒有STAB或者INC寫屏障磷瘤,這個白色對象會被錯殺芒篷,因為掃描過的黑色對象不會再被掃描。
按照這個思路膀斋,在三色標記的過程中梭伐,有兩種情況會導致對象被錯殺:
1. 原本在(G - $G)中的可達對象,現(xiàn)在只有從$G中掃描過的對象到達仰担。這種情況會被INC寫屏障追蹤糊识。
2. 原本在(G - $G)中的可達對象,現(xiàn)在只有從非堆位置到達摔蓝,如棧赂苗、寄存器等GC根節(jié)點。這種情況不會被INC寫屏障追蹤贮尉!
那么從三色標記的角度拌滋,解釋了為什么需要STAB、INC寫屏障猜谚。也可以得出败砂,重新標記是必須的,而且需要掃描「記憶集(寫屏障)」和 「GC根節(jié)點」魏铅。還有幾個點要注意昌犹!
-
CMS的GC根節(jié)點包含了整個young gen。可能會有這樣的疑問览芳,為什么young gen不去維護remembered set斜姥,而是在CMS標記時,掃描整個young gen沧竟?使用remembered set的初衷就是為了減少掃描的非收集區(qū)域大小铸敏,只掃描有變化的部分,然而young gen的引用變更實在是太頻繁了悟泵,給young gen維護remembered set會產(chǎn)生很大的開銷杈笔,而且影響吞吐量!索性就把整個young gen都作為GC Root(掃描但不被收集)魁袜。
所以在重新標記階段桩撮,CMS會全堆掃描,所以young gen對象數(shù)目會直接影響重新標記階段的STW時長峰弹,如果觀察GC日志發(fā)現(xiàn)重新標記階段耗時長且young gen使用率高店量,可以使用參數(shù)CMSScavengeBeforeRemark 強制讓CMS在在重新標記前進行一次并發(fā)預清理(Minor GC) 。當然不加這個參數(shù)鞠呈,CMS也會進行并發(fā)預清理融师,只是需要等到下次Minor GC的到來,為了避免這個階段沒有等到Minor GC而陷入無限等待蚁吝,CMS提供了CMSMaxAbortablePrecleanTime參數(shù)來配置等待時間旱爆,默認為5秒舀射。
注:astore1,等修改棧指針的字節(jié)碼不會引發(fā)write barrier怀伦,能引發(fā)write barrier的字節(jié)碼只有putfield脆烟,putstatic和aastore。年輕代GC就是Minor GC