作者:京東科技 康志興
1 JVM運行時內存劃分
1.1 運行時數據區域
• 方法區
屬於共享內存區域,存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。運行時常量池,屬於方法區的一部分,用於存放編譯期生成的各種字面量和符號引用。
JDK1.8之前,Hotspot虛擬機對方法區的實現叫做永久代,1.8之後改為元空間。二者區別主要在於永久代是在JVM虛擬機中分配內存,而元空間則是在本地內存中分配的。很多類是在運行期間加載的,它們所佔用的空間完全不可控,所以改為使用本地內存,避免對JVM內存的影響。根據《Java虛擬機規範》的規定,如果方法區無法滿足新的內存分配需求時,將拋出OutOfMemoryError異常。
• 堆
線程共享,主要是存放對象實例和數組。如果在Java堆中沒有內存完成實例分配,並且堆也無法再擴展時,Java虛擬機將會拋出OutOfMemoryError異常。PS:實際上寫入時並不完全共享,JVM會為線程在堆上劃分一塊專屬的分配緩衝區來提高對象分配效率。詳見:TLAB
• 虛擬機棧
線程私有,方法執行的過程就是一個個棧幀從入棧到出棧的過程。每個方法在執行時都會創建一個棧幀(Stack Frame)用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。如果線程入棧的棧幀超過限制就會拋出StackOverFlowError,如果支持動態擴展,那麼擴展時申請內存失敗則拋出OutOfMemoryError。
• 本地方法棧
和虛擬機棧的功能類似,區別是作用於Native方法。
• 程序計數器
線程私有,記錄着當前線程所執行的字節碼的行號。其作用主要是多線程場景下,記錄線程中指令的執行位置。以便被掛起的線程再次被激活時,CPU能從其掛起前執行的位置繼續執行。唯一一個在 Java 虛擬機規範中沒有規定任何 OutOfMemoryError 情況的區域。注意:如果線程執行的是個java方法,那麼計數器記錄虛擬機字節碼指令的地址。如果為native(底層方法),那麼計數器為空。
1.2 對象的內存佈局
在 HotSpot 虛擬機中,對象分為如下3塊區域:
• 對象頭(Header)運行時數據:哈希碼、GC分代年齡、鎖狀態標誌、偏向線程ID、偏向時間戳等。類型指針:對象的類型元數據的指針,如果對象是數據,還會記錄數組長度。
• 對象實例數據(Instance Data)包含對象真正的內容,即其包括父類所有字段的值。
• 對齊填充(Padding)對象大小必須是是8字節的整數倍,所以對象大小不滿足這個條件時,需要用對齊填充來補齊。
2 標記的方法和流程
2.1 判斷對象是否需要被回收
要分辨一個對象是否可以被回收,有兩種方式:引用計數法和可達性算法。
• 引用計數法就是在對象被引用時,計數加1,引用斷開時,計數減1。那麼一個對象的引用計數為0時,説明這個對象可以被清除。這個算法的問題在於,如果A對象引用B的同時,B對象也引用A,即循環引用,那麼雖然雙方的引用計數都不為0,但如果僅僅被對方引用實際上沒有存在的價值,應該被GC掉。
• 可達性算法通過引用計數法的缺陷可以看出,從被引用一方去判定其是否應該被清理過於片面,所以我們可以通過相反的方向去定位對象的存活價值:一個存活對象引用的所有對象都是不應該被清除的(Java中軟引用或弱引用在GC時有不同判定表現,不在此深究)。這些查找起點被稱為GC Root。
2.2 哪些對象可以作為GC Root呢?
- JAVA虛擬機棧中的本地變量引用對象
- 方法區中靜態變量引用的對象
- 方法區中常量引用的對象
- 本地方法棧中JNI引用的對象
2.3 快速找到GC Root - OopMap
棧與寄存器都是無狀態的,保守式垃圾收集會直接線性掃描棧,再判斷每一串數字是不是引用,而HotSpot採用準確式垃圾收集方式,所有對象都存放在OopMap(Ordinary Object Pointer)中,當GC發生時,直接從這個map中尋找GC Root。
將GC Root存放到OopMap有兩個觸發時間點:
- 類加載完成後,HotSpot就會把對象內什麼偏移量上是什麼類型的數據計算出來。
- 即時編譯過程中,也會在特定的位置記錄下棧裏和寄存器裏哪些位置是引用。
2.4 更新OopMap的時機 - 安全點
導致OopMap更新的指令非常多,所以HotSpot只在特定位置進行記錄更新,這些位置叫做安全點。安全點位置的選取的標準是:“是否具有讓程序長時間執行”。比如方法調用、循環跳轉、異常跳出等等。
2.5 可達性分析過程
三色標記法
• 白色:表示垃圾回收過程中,尚未被垃圾收集器訪問過的對象,在可達性分析開始階段,所有對象都是白色的,即不可達。
• 黑色:被垃圾收集器訪問過的對象,且這個對象所有的引用均掃描過。黑色的對象是安全存活的,如果其他對象被訪問時發現其引用了黑色對象,該黑色對象也不會再被掃描。
• 灰色:被垃圾收集器訪問過的對象,但這個對象至少有一個引用的對象沒有被掃描過。那麼標記階段就是從GC Root的開始,沿着其引用鏈將每一個對象從白色標記為灰色最後標記為黑色的過程。
標記過程中不一致問題
由於這個階段是層層遞進的標記,所以過程中難免出現不一致的情況導致原本是黑色的對象被標記為白色,比如,當前掃描到B對象了,C對象尚未被訪問時,標記情況如下:
那麼如果這時A對象取消了對B對象的引用,而GC Root增加了對C對象的引用,GC Root作為黑色標記不會再次被掃描,那麼C對象在標記階段結束後仍然會保持白色,就會被清除掉。
解決方式
• 增量更新
當黑色對象增加了對白色對象的引用時,將其從黑色改為灰色,等併發標記階段結束後,從GC Root開始順着對象圖再將灰色對象重新掃描一次,這個掃描過程會STW,不會再次產生不一致問題。CMS就採用了這種方式。
• 原始快照(SATB)
當灰色對象刪除了白色對象的引用時,將其記錄在線程獨佔的SATB Queue中,讓其在標記階段結束後被再次掃描。 G1、Shenandoah採用了這種方式。
示例
我們通過一個例子來展示兩種處理方式的不同,比如正常標記到對象A時,將其標記為灰色:
此時,用户線程發生如下行為:
- GC Root直接引用了C
- A取消了引用B
理論上,C仍然是可達對象,不應被清除,而B不可達,應當被清除。
增量更新會記錄行為1,將GC Root標記為灰色,B不能訪問到被標記為可以回收:
等到重新標記階段再次訪問灰色的GC Root,順序將GC Root和C標記為黑色:
而原始快照會記錄行為2,將發生引用變化的對象全部記錄下來,等到重新標記階段再次訪問這些灰色,將其標記為黑色並順着對象圖掃描。
那麼最終B作為浮動垃圾就被保存下來了,只能等到下一次GC時才能被回收。
3 分代模型
3.1 分代假説
弱分代假説(WeakGenerationalHypothesis):絕大多數對象都是朝生夕滅的。 強分代假説(StrongGenerationalHypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。 跨代引用假説(IntergenerationalReferenceHypothesis):跨代引用相對於同代引用來説僅佔極少數。
上述假説是根據實際經驗得來的,由此垃圾收集器通常分為“年輕代”和“年老代”:
• 年輕代用來存放不斷生成且生命週期短暫的對象,收集動作相對高頻
• 年老代用來存放經歷多次GC仍然存活的對象,收集動作相對低頻
3.2 空間分配擔保
如果在GC後新生代存貨對象過多,Survivor無法容納,那麼將會把這些對象直接送入年老代,這就叫年老代進行了“分配擔保”。 為了保證年老代能夠足夠空間容納這些直接晉升的對象,在發生Minor GC之前,虛擬機必須先檢查年老代最大可用的連續空間,如果大於新生代所有對象總空間或者歷次晉升的平均大小,就會進行MinorGC,否則將進行FullGC以同時清理年老代。
3.3 記憶集和卡表
記憶集是一種用於記錄從非收集區域指向收集區域的指針集合的抽象數據結構。
記憶集的作用
新生代發生垃圾收集時(Minor GC),如果想確定這個新生代對象是否被年老代的對象引用,則需要掃描整個年老代,成本非常高。
如果我們能知道哪一部分年老代可能存在對新生代的引用,就可以降低掃描範圍。
所以我們可以在新生代建立一個全局數據結構叫“記憶集(Remembered Set)”,這個結構把年老代分為若干個小塊,標記了哪些小塊內存中存在引用了新生代對象的情況,等到Minor GC時,只掃描這部分存在跨代引用的內存塊即可。雖然在對象變化時增加了維護記憶集的成本,但相比垃圾收集時掃描整個年老代來説是值得的。
JVM通常在對象增加引用前設置寫屏障判斷是否發生跨代引用,如果有跨代情況,則更新記憶集。
卡表
實現記憶集時,可以有不同精度的粒度:可以指向內存地址,也可以指向某個對象,或者指向某一塊內存區域。精度越低,維護成本越低。指向某一塊內存區域的實現方式就是“卡表”。卡表通常就是一個byte數組,數組中每一個元素代表某一塊內存,其值是1或者0:當發生跨代引用時,就表示該元素“dirty”了,那麼將將其設置為1,否則就是0。
4 垃圾回收算法
4.1 標記-清除(Mark-Sweep)
GC分為兩個階段,標記和清除。首先標記所有可回收的對象,在標記完成後統一回收所有被標記的對象。
缺點是清除後會產生不連續的內存碎片。碎片過多會導致以後程序運行時需要分配較大對象時,無法找到足夠的連續內存,而不得已再次觸發GC。
4.2 標記-複製(Mark-Copy)
將內存按容量劃分為兩塊,每次只使用其中一塊。當這一塊內存用完了,就將存活的對象複製到另一塊上,然後再把已使用的內存空間一次清理掉。
這樣使得每次都是對半個內存區回收,也不用考慮內存碎片問題,簡單高效。
缺點需要兩倍的內存空間。
一種優化方式是使用eden和survivior區,具體步驟如下:
eden和survivior區默認內存空間佔比為8:1:1,同一時間只使用eden區和其中一個survivior區。標記完成後,將存活對象複製到另一個未使用的survivior區(部分年齡過大的對象將升級到年老代)。
這種做法,相比普通的兩塊空間的標記複製算法來説,只有10%的內存空間浪費,而這樣做的原因是:大部分情況下,一次young gc後剩餘的存活對象非常少。
4.3 標記-整理(Mark-Compact)
標記-整理也分為兩個階段,首先標記可回收的對象,再將存活的對象都向一端移動,然後清理掉邊界以外的內存。
此方法避免標記-清除算法的碎片問題,同時也避免了複製算法的空間問題。 一般年輕代中執行GC後,會有少量的對象存活,就會選用複製算法,只要付出少量的存活對象複製成本就可以完成收集。
而年老代中因為對象存活率高,用標記複製算法時數據複製效率較低,且空間浪費較大。所以需要使用標記-清除或者標記-整理算法來進行回收。
所以通常可以先使用標記清除算法,當碎片率高時,再使用標記整理算法。
5 最後
本篇介紹了JVM中垃圾回收器相關的基礎知識,後續會深入介紹CMS、G1、ZGC等不同垃圾收集器的運作流程和原理,歡迎關注。