聊聊JVM(八)说说GC标记阶段的一些事
這篇說說GC標記階段的一些事情,嘗試把一些概念說清楚。本人不是研究JVM實現的,如果表述有問題請查看參考資料進一步學習,推薦高級語言虛擬機圈子?,里面有很多好的文章值得一看。
?
GC最簡單的理解就是先把live的對象標記出來,然后把沒有標記到的對象清除掉。那么就有幾個問題:
1. 什么是活的對象?
2. 如何標記
3. 如何清除
?
先簡單說一下清除,清除的方法常見有三種:
1. 復制
??? 優點:直接按照順序復制內容即可,只需要移動指針,老的區域可以直接全部被重用
??? 缺點:需要一塊額外的區域專門用來做復制,空間利用率降低了
2. 清除Sweep,就是直接把要清除的內存區域標記為清除,這樣新的對象可以在這些被邏輯清除的地方分配
?? 優點:不需要移動內存,直接標記即可
?? 缺點:造成大量內存碎片。給對象分配內存空間時,看的是是否有連續可用的內存空間,而不是全部的內存空間,造成內存空間總量很大,卻不得不GC
3. 壓縮Compact, 就是把要保留的對象移動到內存的一端,把剩下的另外一部分就標記為清除
?? 優點:每次壓縮后剩下的可用空間都是連續的
?? 缺點:需要大量的內存復制操作
?
現在回到標記的話題,標記就是為了解決1,2這兩個問題,確定活的對象,把他們全部標記出來。
JVM里面定義一個對象是否是活的采用的是GC root tracing(根搜索)方法,看的是reachability可達到性。所以標記的第一步就是得先獲得GC roots。
GC roots是下面這些數據的集合:
- JVM棧的棧幀中的局部變量表里的有效的局部變量(局部變量有作用域)所引用的對象
- 方法區里面的類元素對象的static引用所引用的對象
- 方法區里面的類元素對象的常量引用所引用的對象
- 本地方法棧里的引用所引用的對象
?
其實很好理解GC roots,它們要么就是活著的線程產生的JVM棧的棧幀Frame中正在被直接使用的對象(可以通過有效的局部變量獲得),要么就是JVM本身需要它們長期駐留的,比如方法區里面的類的static/final數據,它們有兩個特點:
1. 語義上是活的,還有用的
2. 可以方便獲取的
?
GC roots必須可以方便獲取,這樣GC的標記才能從這些可以快速獲取的GC roots開始,對GC roots能到達的對象圖進行標記。否則還要花很多代價才能找到標記的輸入點。最初的虛擬機很多采用保守式GC,不記錄這些信息,實現簡單但是效率低?,F在主流的虛擬機都采用準確式的GC,盡量早和方便地收集這些信息,加快整個標記的速度。
?
方法區的類對象的靜態數據和final數據作為GC roots很好理解,它們要長期存在,并且在類加載的時候就可以確定內存位置
獲取GC roots最主要的部分在解決如何快速找到JVM棧的棧幀的局部變量表中的局部變量所引用的對象
?
找出棧上的指針/引用?這篇文章很好地解釋了這個問題。大致的思路是JVM采用了OopMap這個數據結構記錄了GC roots,GC的標記開始的時候,直接從OopMap就可以獲得GC roots。OopMap記錄了特定時刻棧上(內存)和寄存器(CPU)的哪些位置是引用,通過這些引用就可以找到堆中的對象,這些對象就是GC roots. 而不需要一個一個的去判斷某個內存位置的值是不是引用。
?
關于OopMap,可以考慮三個問題:
1. OopMap何時收集數據?
在線程運行到特定位置 safepoint時,OopMap會收集數據,也就是運行到了safepoint的時候,是一個相對穩定的狀態的,可以來做snapshot。更多關于safepoint的信息看這篇:聊聊JVM(六)理解JVM的safepoint
每個被JIT編譯過后的方法運行到了一些特定的位置記錄下OopMap,記錄了執行到該方法的某條指令的時候,棧上和寄存器里哪些位置是引用。這樣GC在掃描棧的時候就會查詢這些OopMap就知道哪里是引用了。這些特定的位置被稱為safepoint, 主要在:
1、循環的末尾
2、方法臨返回前 / 調用方法的call指令后
3、可能拋異常的位置
之所以要選擇一些特定的位置來記錄OopMap,是因為如果對每條指令(的位置)都記錄OopMap的話,這些記錄就會比較大,那么空間開銷會顯得不值得。選用一些比較關鍵的點來記錄就能有效的縮小需要記錄的數據量,但仍然能達到區分引用的目的
?
2. OopMap如何采集數據?
在JIT編譯模式下,Java代碼被編譯后就確定了OopMap如何采集,它會插入相應的機器碼在safepoint的時候做這個工作。下面的代碼來自內存篇:JVM內存回收理論與實現
?
??[Verified Entry Point] ?
??? 0x026eb730: mov??? %eax,-0x8000(%esp) ?
??? ………… ?
??? ;; ImplicitNullCheckStub slow case ?
??? 0x026eb7a9: call?? 0x026e83e0???????? ; OopMap{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?????
?
可以看到在0x026eb7a9處的call指令有OopMap記錄,它指明了EBX寄存器和棧中偏移量為16的內存區域中各有一個oop的引用,這個OopMap和offset? 142處的指令關聯,從call指令開始直到0x026eb730(指令流的起始位置)+142(OopMap記錄的偏移量)=0x026eb7be,即hlt指令。換句話說執行到hlt指令時的OopMap狀態是ebx和[rsp+16]的內存位置上有oop。
call 0x026e83e0這個指令就是調用一個方法,OopMap出現在這個位置,也就是call之后,我們說了方法的call之后是一個safepoint,用OopMap記錄一下寄存器和JVM棧內存的哪些位置是引用。
更多OopMap的off是什么含義可以看這篇:?請教一下一個關于OopMap的問題? 所謂的OopMap和某條指令關聯,意思是執行這個指令時可以看到的OopMap的狀態
?
?
3. 什么是一個良好實現的OopMap?
OopMap里的數據直接關系到GC roots,也就是會被標記為活的對象的數據,所以OopMap里的數據不能少,因為少了之后就會把活的對象判別為死了。OopMap已標記的數據也不能出錯,比如這篇文章里面描述的JVM的一個BugJust fixed a 20-year-old bug…?這篇文章中描述了發現的一個Stale Oop的問題。他還描述了一下OopMap:
OopMap?- An OOP map is a list of machine registers holding OOPs at a particular point in the code.? If a GC cycle needs to happen while the program is stopped at a Safepoint, GC will consult the OopMap to find all the pointers needing updating.
?
有了OopMap就可以快速獲得GC roots,接著就可以開始標記了。標記的基本思路就是遍歷一個有向圖,節點是對象,邊是引用。不同的垃圾收集器實現的標記算法也不一樣,可以參考這篇[討論] HotSpot VM Serial GC的一個問題, 里面討論了Serial GC收集器如何標記的,它采用了Cheney算法的變種。
?
標記實際上是GC最重要的一個事情,因為只有標記成功了,就相當于確定了那些對象是活的,另外的對象就是死的。那么接下來的清除工作就可以不用stop the world,可以并發地去清除死的對象了。所以標記的時候大部分是需要stop the world的。
?
這篇里面說過聊聊JVM(四)深入理解Major GC, Full GC, CMS?CMS在initial mark和 remark這兩個階段需要stop the world,Full GC的計數器被加了2次。
- 可以理解initial mark是必須要stop the world的,目的是為了獲得堆中數據某個階段的snapshot時的GC roots,我們上面說了GC roots是可以快速獲取的,所以initial mark階段很短。
- 接下來concurrent mark就是以initial mark獲取的GC roots為起點,并發地去遍歷一個有向圖,這時候是不需要stop the world的,因為GC roots是確定的。
- 接下來的remark又要stop the world,它是為了修復一下在之前沒有stop the world的時候可能對GC roots和相關對象做的修改造成的影響,也很快。
- 這樣CMS的標記階段就完成了。清除的時候就不需要stop the world了,可以并發去清理那些死的,根本不會被引用到的對象了。
?
再看看一下Card Table卡表對GC標記的影響,在這篇聊聊JVM(一)相對全面的GC總結?中說了一下Card Marking的大致概念,就是老年代的內存空間劃分成了一個卡表,每一個卡表項大小默認是512Byte。如果一個卡表項中的老年代對象引用了新生代中的對象,那么這個卡表項就被標記成了臟 dirty,這樣在新生代做GC的時候,不需要對整個老年代進行遍歷,只需要對dirty card中的老年代對象進行掃描,這些對象對新生代GC來說,就相當于GC roots,它們可到達的新生代對象也是活的,也需要標記。
?
最后再來說一下把手動設置成Nul到底起不起作用的問題,看這篇的討論?把引用設成null真的對GC有幫助嗎???
我們知道局部變量是有作用域的,這個作用域在編譯的時候就確定了。在JIT在做優化的時候,都會做liveness analysis,如果一些局部變量的引用只是在前面使用了,后面沒有使用,那么代碼執行到后面的時候,其實前面的這些局部變量的引用的對象實際已經死了,那么這時候的OopMap是不需要再記錄之前的局部變量的引用的。比如這個例子:
?
?class Test {
public int foo(int a) {
Object obj = new Object();
while(a > 0){
a --;
}
return a;
}
public static void main(String[] args) {
new Test().foo(10);
}
}
obj這個引用在執行到while的時候實際上已經沒用了,可以判定對象已死了,被JIT編譯器優化之后的代碼相當于下面的
?
?public int foo(int a) {
new Object(); // obj變量直接就沒了,反正后面也沒用
while(a > 0){
a --;
}
return a;
}
實例生成的匯編碼也確實指明了while的時候的OopMap里面是有沒有活的引用的
?
?0x01b4b774: dec %edx ; <strong>OopMap{off=101}</strong>
;*goto
; - Test::foo@15 (line 9)
0x01b4b775: test %eax,0x180100 ;*goto
; - Test::foo@15 (line 9)
; {poll}
;; block B1 [8, 9]
所以在JIT模式下,編譯器會計算局部變量的實際作用域,對這些局部變量做liveness analysis, 把這些無意義的set null語句給優化掉。
?
參考資料:
找出棧上的指針/引用
內存篇:JVM內存回收理論與實現
Just fixed a 20-year-old bug…
[討論] HotSpot VM Serial GC的一個問題
總結
以上是生活随笔為你收集整理的聊聊JVM(八)说说GC标记阶段的一些事的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 聊聊JVM(六)理解JVM的safepo
- 下一篇: 聊聊JVM(九)理解进入safepoin