1. 為什么要了解垃圾收集和記憶體分配?主要針對虛擬機的哪些區域?
垃圾收集(Garbage Collection)經過半個世紀的發展,記憶體動態分配與記憶體回收技術已經相當成熟,似乎進入了“自動化”時代,
但是, 當需要排查各種記憶體溢位、記憶體泄漏問題時,當垃圾收集成為系統達到更高并發量的瓶頸時,我們就需要深入了解并對這些“自動化”技術實施必要的監控和調節,
程式計數器、虛擬機堆疊、本地方法堆疊3個區域隨執行緒而生,隨執行緒而滅,堆疊中的堆疊幀隨著方法的進入和退出而有條不紊地執行著出堆疊和入堆疊操作,當方法或執行緒結束時,記憶體就會隨之回收,
Java堆和方法區則有顯著的不確定性:一個介面的多個實作類需要的記憶體可能會不一樣,一個方法所執行的不同條件分支所需要的記憶體也不一樣,只有處于運行期間,我們才能知道程式究竟會創建哪些物件,創建多少個物件,這部分記憶體的分配和回收是動態的,垃圾回收器正式負責分配和回收這部分記憶體,
2. 如何判斷物件是“存活”還是“死去”?
2.1 參考計數演算法
在物件中添加一個參考計數器,每當有一個地方參考它時,計數器值加一;參考失效時,計數器值就減一;任何時刻計數器為零的物件就是不可能再被使用的,
這個簡單的演算法需要配合大量額外處理才能保證正確作業,比如單純的參考計數很難解決物件之間的回圈參考問題,參考博客:https://www.codenong.com/176745/,
2.2 可達性分析演算法
通過一系列稱為“GC roots”的根物件作為起始節點集,從這些節點開始根據參考關系向下搜索,搜索程序中所走過的路徑稱為“參考鏈”(Reference Chain),如果某個物件到GC Roots間沒有任何參考鏈相連,則說明此物件不可能再被使用,
GC Roots物件包括以下幾種:
- 在虛擬機堆疊(堆疊幀中的本地變數表)中參考的物件,比如當前正在運行的方法所使用到的引數,區域變數,臨時變數等
- 在方法區中類靜態屬性參考的物件,比如Java類的參考型別靜態變數
- 在方法區中常量參考的物件,比如字串常量池里的參考
- 在本地方法堆疊中JNI參考的物件
- Java虛擬機內部的參考,如基本資料型別對應的Class物件,一些常駐的例外物件等,還有系統類加載器
- 所有被同步鎖(synchronized關鍵字)持有的物件
- 反映Java虛擬機內部情況的JMXBean、JVMTI中注冊的回呼、本地代碼快取
除了這些固定的GC Roots集合以外,根據用戶所選用的垃圾收集器以及當前回收的記憶體區域不同,還會有其他物件加入,比如后面會提到的分代收集和區域回收(Partial GC),某個區域里的物件可能被堆中其他區域的物件所參考,這時需要把關聯區域的物件一并加入GC Roots集合中去,
2.3 參考
- 強參考 類似Object obj = new Object(), 只要強參考關系還存在,垃圾收集器就永遠不會回收掉被參考的物件
- 軟參考 描述一些還有用,但非必須的物件,在系統將要發生記憶體溢位例外前,會把這些物件列進回收范圍中進行二次回收,
- 弱參考 描述一些非必須的物件,但是它的強度比軟參考更弱,被弱參考關聯的物件只能生存到下一次垃圾收集發生為止
- 虛參考 是最弱的一種參考關系,無法通過虛參考來取得一個物件實體,為一個物件設定虛參考關聯的唯一目的是在物件被收集器回收時收到一個系統通知,
2.4 回收方法區
方法區垃圾收集的性價比較低:在Java堆中,尤其在新生代中,對常規應用進行一次垃圾收集通常可以回收70%至99%的記憶體空間,方法區回收囿于苛刻的判定條件,其區域垃圾收集的回收成果往往遠低于此,
方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的型別,
假如一個字串“Java”曾經進入常量池中,但是當前系統又沒有任何一個字串物件的值是“Java”,且虛擬機中沒有其他地方參考這個字面量,如果這時發生記憶體回收,而且垃圾收集器判斷確有必要的話,這個常量將會被系統清理出常量池,常量池中其他類(介面)、方法、欄位的符號參考的判斷也與此類似,
對于判斷一個型別是否屬于“不再被使用的類”的條件:
- 該類所有的實體都已經被回收,也就是Java堆中不存在該類及其任何派生子類的實體
- 加載該類的類加載器已經被回收,這個條件除非是精心設計的可替換類加載器的場景,如OSGi、JSP的重加載等,否則通常是很難達成的
- 該類對應的java.lang.Class物件沒有在任何地方被參考,無法在任何地方通過反射訪問該類的方法
在大量使用反射、動態代理、CGLib等位元組碼框架,動態生成JSP以及OSGi這類頻繁自定義類加載器的場景中,通常需要Java虛擬機具備型別卸載能力,以保證不會對方法區造成過大的記憶體壓力
3. 追蹤式垃圾收集演算法
3.1 分代收集理論
分代收集理論建立在如下假說之上:
1) 弱分代假說(Weak Generational Hypothesis)絕大多數物件都是朝生夕滅的
2) 強分代假說(Strong Generational Hypothesis)熬過越多次垃圾收集程序的物件就越難以消亡
上面兩個假說共同奠定了多款常用垃圾收集器的一致設計原則:垃圾收集器應該將Java堆劃分出不同的區域,然后將回收物件按照其年齡(年齡即物件熬過垃圾收集程序的次數)分配到不同區域之中存盤,
如果一個區域中大多數物件都是朝生夕滅的,那么把它們幾種放在一起,每次收集只關注怎樣保存少量存活而不是去標記那些大量將要被回收的物件,就能以較低的代價回收大量的記憶體;對于剩下難以消亡的物件,把它們集中放在一起,虛擬機便可以使用較低的頻率來回收這個區域,這樣同時兼顧了垃圾收集的時間開銷和記憶體空間的有效利用,
3) 跨帶參考假說
如前文提到過的,由于物件不是孤立的,所以物件之間會存在跨帶參考,
存在互相參考關系的兩個物件,應該是傾向于同時生存或者同時消亡的,
在新生代建立一個全域的資料結構(記憶集,后續會有介紹),這個結構把老年代劃分成若干小塊,表示出老年代的哪一塊記憶體存在跨帶參考,此后,當發生Minor GC時,只有包含了跨帶參考的小塊記憶體里的物件才會被加入到GC Roots進行掃描,雖然這種方法需要在物件改變參考關系時維護記錄資料的正確性,會增加一些運行時的開銷,但比起收集時掃描整個老年代來說仍是劃算的,
- 部分收集(Partial GC)
- 新生代收集(Minor GC/ Young GC)
- 老年代收集(Major GC/ Old GC)
- 混合收集 (Mixed GC)
- 整堆收集(Full GC)
3.2 標記 - 清除演算法
首先標記出所有需要回收的物件,標記完成后,統一回收掉所有被標記的物件,該演算法有兩個缺點:
- 執行效率不穩定,如果Java堆中包含大量物件,而且大部分都是需要回收的,這時必須進行大量的標記和清除動作,導致標記和清除的執行效率隨物件數量增長而降低
- 記憶體空間的碎片化問題,標記和清除會產生大量不連續的記憶體碎片,當需要分配較大物件時,由于無法找到連續的記憶體空間進行存盤,需要進行另一次垃圾收集動作
3.3 標記 - 復制演算法
把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,默認Eden和Survivor的大小比例為8:1.將物件載入Eden區中,當記憶體接近用完時,將還存活的物件移動到survivor區中,并將Eden區清除,如果存活的物件太大,survivor區存放不了,就通過分配擔保機制直接放入老年代,
該演算法不用考慮空間碎片的復雜情況,只要移動堆頂指標,按順序分配即可,
3.4 標記 - 整理演算法
標記 - 復制演算法在物件存活率較高時就要進行較多的復制操作,效率會降低,
標記 - 整理演算法的標記程序仍然與標記 - 清除演算法一樣,后續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向記憶體空間一端移動,然后直接清理掉邊界以外的記憶體,
· 
如果移動存活物件,尤其是在老年代這種每次回收都有大量物件存活區域,移動存活物件并更新所有參考這些物件的地方需要全程暫停用戶應用程式才能進行,即“Stop The World”.
而標記 - 清除演算法這種不考慮移動和整理存活物件的話,空間碎片化問題依賴更為復雜的記憶體分配器和記憶體訪問器來解決,比如通過“磁區空閑分配鏈表”來解決記憶體分配問題(計算機硬碟存盤大檔案就不要求物理連續的磁盤空間,能夠在碎片化的磁盤上存盤和訪問就是通過硬碟磁區表實作的),
是否移動物件都存在弊端,移動則記憶體回收時會更復雜,不移動記憶體分配時會更復雜,HotSpot虛擬機里面關注吞吐量的Parallel Old收集器基于標記 - 整理演算法的,關注延遲的CMS收集器則是基于標記 - 清除演算法的,
4. HotSpot演算法實作細節
4.1 根節點列舉
隨著Java應用越做越大,光是方法區的大小就有數百上千兆,里面的類、常量更是恒河沙數,如要逐個檢查以這里為起點的參考會耗費很多時間,
迄今為止,所有收集器在根節點列舉必須暫停用戶執行緒,Stop The World,當用戶執行緒停下來以后,并不需要一個不漏的檢查完所有執行背景關系和全域的參考位置,HotSpot虛擬機使用一組稱為OopMap的資料結構,一旦類加載完成,HotSpot會把物件內什么偏移量上是什么型別的資料計算出來,在即時編譯程序中,也會在特定的位置記錄下堆疊里和暫存器里哪些位置是參考,
下面代碼清單3-3是HotSpot虛擬機客戶端模式下生成的一段String::hashCode()方法的本地代碼,可以看到0x026eb7a9處的call指令有OopMap記錄,它指明了EBX暫存器和堆疊中偏移量為16的記憶體區域中各有一個普通物件指標(Ordinary Object Pointer)的參考,有效范圍為從call指令開始直到0x026eb730(指令流的起始位置)+142(OopMap記錄的偏移量)= 0x026eb7be,即hlt指令為止,
[Verified Entry Point] 0x026eb730: mov %eax, -0x8000(%esp) …… ;; ImplicitNullCheckStub slow case 0x026eb7a9: call 0x026e83e0 ; opMap{ebx=Oop[16]=Oop off=142} ; *caload ; -java.lang.String: hashCode@48(line 1489) ; {runtime_call} 0x026eb7ae: push $0x83c5c18 ; {external_word} 0x026eb7b3: call 0x026eb7b8 0x026eb7b8: pusha 0x026eb7b9: call 0x0822bec0 ; {runtime_call} 0x026eb7be: hlt
4.2 安全點
在OopMap的協助下,HotSpot可以快速準確地完成GC Roots列舉,但是,如果為每一條指令都生成對應的OopMap,需要大量的額外存盤空間,
實際上HotSpot并非為每條指令都生成OopMap,只是在指定位置記錄了這些資訊,這些位置稱為安全點,用戶程式必須執行到安全點后才會暫停,安全點位置選取基本上以“是否具有讓程式長時間執行的特征”為標準進行選定,“長時間執行”最明顯的特征就是指令序列的復用,例如方法呼叫、回圈跳轉、例外跳轉等,所以具有這些功能的指令才會產生安全點,
另一個需要考慮的問題是如何在垃圾收集發生時讓所有執行緒都跑到最近的安全點:
- 搶先式中斷(Preemptive Suspension)系統首先把所有執行緒全部中斷,如果發現有用戶執行緒中斷的地方不在安全點上,就恢復這條執行緒,讓其跑到安全點上,再中斷,目前幾乎沒有虛擬機采用這種方式了,
- 主動式中斷(Voluntary Suspension)不直接對執行緒操作,僅僅簡單地設定一個標志位,各個執行緒執行程序時會不停地主動輪詢這個標志,一旦發現中斷標志為真時就自己在最近的安全點上主動中斷掛起,
HotSpot使用記憶體保護陷阱的方式,把輪詢操作精簡至只有一潭訓編指令的程度,下表程式清單的test指令就是HotSpot生成的輪詢指令,當需要暫停用戶執行緒時,虛擬機把0x160100的記憶體頁設定為不可讀,那執行緒執行到test指令時就會產生一個自陷例外信號,然后在預先注冊的例外處理器中掛起執行緒實作等待,這樣僅通過一潭訓編指令便完成安全點輪詢和觸發執行緒中斷了,
0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460}
; *invokeinterface size
; -Client1: main@113(line 23)
; {virtual_call}
0x01b6d62c: nop ; OopMap{[60]=Oop off=461}
; *if_icmplt
; -Client1: main@118(line 23)
0x01b6d62d: test %eax, 0x160100; {poll}
0x01b6d633: mov 0x50(%esp), %esi
0x01b6d637: cmp %eax, %esi
4.3 安全區域
安全點機制保證了程式執行時,在不太長的時間就會遇到可進入垃圾收集程序的安全點,但是,程式不執行的時候呢?所謂的程式不執行就是沒有分配處理器時間,典型的場景便是用戶執行緒處于Sleep狀態或者Blocked狀態,這時候執行緒無法回應虛擬機的中斷請求,不能在走到安全的地方去中斷掛起自己,
安全區域是指能夠確保某一段代碼片段之中,參考關系不會發生變化,在這個區域中任意地方開始垃圾收集都是安全的,
當用戶執行緒執行到安全區域里面的代碼時,首先會標識自己進入到安全區域,當虛擬機需要發起垃圾收集時就不必去管這些已宣告自己在安全執行緒內的執行緒了,
當執行緒要離開安全區域時,需要檢查根節點是否完成了根節點列舉,如果沒有,需要一直等待,直到收到可以離開安全區域的信號,
4.4 記憶集與卡表
前面提到過為了解決跨代參考的問題,垃圾收集器會在新生代建立名為記憶集的資料結構,
記憶集是一種用于記錄從非收集區域指向收集區域的指標集合的抽象資料結構,記錄全部含跨代參考物件的實作方案,無論是空間占用還是維護成本都相當高昂,實際場景中收集器只需要通過記憶集判斷某一塊非收集區域是否存有指向了收集區域的指標即可,即卡精度,
卡表是記憶集的一種具體實作,它定義了記憶集的記錄精度、堆記憶體的映射關系等,
CARD_TABLE [ this address >> 9 ] = 0;
位元組陣列CARD_TABLE的每一個元素都對應著其標識的記憶體區域中一塊特定大小的記憶體塊,這個記憶體塊被稱作“卡頁”(Card Page),一般來說,卡頁的大小都是以2的N次冪的位元組數,一個卡頁的記憶體中通常包含不止一個物件,只要卡頁內有一個(或更多)物件的欄位存在著跨代指標,就將對應卡表的陣列元素的值標識為1,稱這個元素變臟,在垃圾收集發生時,只要篩選出卡表中變臟的元素,就能輕易得出哪些卡頁記憶體塊中包含跨代指標,把它們加入GC Roots中一并掃描,
4.5 寫屏障
通過記憶集可以縮減GC Roots掃描范圍問題,但是卡表元素如何維護,例如,誰來把它們變臟?
假如是解釋執行的位元組碼,虛擬機負責每條位元組碼指令的執行,有充分的介入空間;但在編譯執行的場景中呢?即時編譯后的代碼已經是純粹的機器指令流了,
在HotSpot虛擬機里通過寫屏障(write barrier)技術維護卡表狀態的,寫屏障可以看做在虛擬機層面對“參考型別欄位賦值”這個動作的AOP切面,在參考物件賦值時會產生一個環形通知,供程式執行額外的動作,即賦值的前后都在寫屏障的覆寫范圍內,
void oop_field_store(oop* field, oop new_value) { // 參考欄位賦值操作 *field = new_value; // 寫后屏障 post_write_barrier(field, new_value); }
應用寫屏障后,虛擬機就會為所有賦值操作生成相應的指令,一旦收集器在寫屏障中增加了更新卡表操作,無論更新的是不是老年代對新生代物件的參考,每次只要對參考進行更新,將會產生額外的開銷,不過這個開銷相比于Minor GC時,掃描整個老年代的代價相比低得多,
除了寫屏障開銷外,卡表在高并發場景下還面臨著“偽共享”問題,現代中央處理器的快取系統中是以快取行為單位存盤的,當多執行緒修改互相獨立的變數時,如果這些變數恰好共享同一個快取行,就會彼此影響(寫回,無效化或者同步)而導致性能降低,
為了避免偽共享問題,一個簡單的解決方案是不采用無條件的寫屏障,而是先檢查卡表標記,只有當卡表元素被標記過,才將其標記為變臟,
4.6 并發的可達性分析
從GC Roots繼續往下遍歷物件圖,這一步驟的停頓時間與Java堆容量成正比關系,引入三色標記把遍歷物件圖程序中遇到的物件,按照“是否訪問過”標記為以下三種顏色:
- 白色:物件尚未被垃圾收集器訪問過,在可達性分析開始階段,所有物件都是白色的,若分析結束階段,仍然是白色物件,即代表不可達,
- 黑色:物件已被垃圾收集器訪問過,且這個物件的所有參考都已經掃描過,黑色的物件代表它是安全存活的,
- 灰色:物件已經被垃圾收集器訪問過,但這個物件上至少有一個參考沒有被掃描過
如果用戶執行緒與收集器并發作業,收集器在物件圖上標記顏色,同時用戶執行緒在修改參考關系——即修改物件圖的結構,這會造成兩種結果:
1) 把原本消亡的物件錯標為存活,將產生一點逃過本次收集的浮動垃圾
2) 把原本存活的物件錯誤標記為已消亡,程式會因此發生錯誤
Wilson在1994年提出當且僅當滿足以下兩個條件時,會產生物件消失問題:
- 賦值器插入了一潭訓多條從黑色物件到白色物件的參考
- 賦值器洗掉了全部灰色物件到改白色物件的直接或間接參考
由此產生了兩種解決方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)
增量更新破壞第一個條件,當黑色物件插入新的指向白色物件的參考關系時,就將這個新插入的參考記錄下來,等并發掃描結束后,再將記錄過的參考關系中的黑色物件為根,重新掃描一次,
原始快照破壞第二個條件,當灰色物件要洗掉指向白色物件的參考關系時,就將這個要洗掉的參考記錄下來,在并發掃描結束后,再將這些記錄過的參考關系中的灰色物件為根,重新掃描一次,
以上對參考關系記錄的插入還是洗掉,虛擬機的記錄操作都是通過寫屏障實作的,
5. 經典垃圾收集器
如果兩個收集器之間存在連線,就說明他們可以搭配使用,

5.1 Serial收集器
Serial收集器是最基礎、歷史最悠久的收集器,是一個單執行緒作業的收集器,迄今為止,它是HotSpot虛擬機運行在客戶端模式下的默認新生代收集器,與其他收集器的單執行緒相比,其簡單高效垃圾收集的停頓時間可以控制在十幾、幾十毫秒,最多一百多毫秒以內,
5.2 Parallel Scavenge收集器
Parallel Scavenge收集器是一款新生代收集器,基于標記-復制演算法實作的收集器,且能夠并行收集,CMS收集器關注于盡可能地縮短垃圾收集時用戶執行緒的停頓時間,Parallel Scavenge收集器則是達到一個可控制的吞吐量(Throughput),吞吐量是處理器用于運行用戶代碼的時間與處理器總消耗時間的比值,
5.3 CMS收集器
CMS(Concurrent Mark Sweep) 收集器是一種以獲取最短回收停頓時間為目標的收集器, 目前很大一部分的Java應用集中在互聯網網站或者基于瀏覽器的B/S系統的服務端上, 這類應用通常都會較為關注服務的回應速度, 希望系統停頓時間盡可能短, 以給用戶帶來良好的互動體驗, CMS收集器就非常符合這類應用的需求,
CMS收集器基于標記清除演算法實作的,整個程序分為四個步驟:
- 初始標記 標記GC Roots能直接關聯到的物件
- 并發標記 從GC Roots遍歷整個物件圖
- 重新標記 修正物件標記,參考4.6 增量更新
- 并發清除 清理洗掉掉標記程序中判斷為已經死亡的物件
CMS收集器有以下三個缺點:
- CMS收集器對處理器資源非常敏感,雖然它不會導致用戶執行緒停頓,但由于占用了一部分執行緒(或者說處理器計算能力)而導致應用程式變慢,降低總吞吐量,
- CMS處理器無法處理浮動垃圾,有可能出現“Concurrent Mode Failure”失敗進而導致另一次完全“Stop The World”的Full GC 的產生,在CMS的并發標記和并發清理階段,用戶執行緒還在繼續運行,程式自然就會伴隨物件的產生而出現垃圾,但這一部分垃圾物件出現在標記程序結束后,CMS只能留待下一次垃圾收集時再清理掉,由于用戶執行緒在垃圾收集階段還在運行,需要預留足夠的記憶體空間提供給用戶執行緒使用,如果預留的記憶體無法滿足程式分配新物件的需要,就會出現“Concurrent Mode Failure”失敗,虛擬機不得不凍結用戶執行緒,臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就會加長,
- CMS是基于標記清除演算法實作的,存在大量的空間碎片,對大物件的分配造成麻煩,
5.4 Garbage First收集器
G1是一款面向服務端應用的垃圾收集器,G1把連續的Java堆劃分為多個大小相等的獨立區域,每一個區域都可以根據需要扮演新生代的Eden空間、Survivor空間或者老年代空間,Region中還有一類特殊的Humongous區域,專門用來存盤大物件,雖然G1保留了新生代和老年代的概念,但新生代和老年代不再是固定的,它們都是一系列區域的動態集合,

G1收集器的運作程序大致可以分為以下四個步驟:
- 初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的物件,并且修改TAMS指標的值(G1為每一個區域設計了兩個名為TAMS的指標,把區域中的一部分空間劃分出來用于并發回收程序中的新物件分配)讓下一階段用戶執行緒并發運行時,能正確地在可用的Region中分配新物件,
- 并發標記(Concurrent Marking):從GC Roots開始對堆中物件進行可達性分析,遞回掃描整個堆里的物件圖,找出要回收的物件,
- 最終標記(Final Marking):用于處理并發階段結束后仍遺留下來的少量SATB記錄,
- 篩選回收(Live Data Counting and Evacuation):負責更新Region的統計資料,對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,把決定回收的那部分Region存活物件復制到空的Region中,再清理掉整個舊Region的全部空間,這里涉及存活物件的移動,所以需要暫停用戶執行緒,

從G1開始, 最先進的垃圾收集器的設計導向都不約而同地變為追求能夠應付應用的記憶體分配速率(Allocation Rate) ,而不追求一次把整個Java堆全部清理干凈,這樣,應用在分配,同時收集器在收集,只要收集的速度能跟得上物件分配的速度,那一切就能運作得很完美,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/298022.html
標籤:其他
