保守式 GC 與準確式 GC,如何在堆中找到某個對象的具體位置?
本文轉載自微信公眾號「飛天小牛肉」,作者小牛肉。轉載本文請聯系飛天小牛肉公眾號。
舉個例子:
User user = new User("Jack");
user 這個變量是存在棧中的對吧,name = Jack 的這個 User 對象是存在堆中的,創建對象自然是為了后續使用該對象,那么如何在堆中找到這個對象的具體位置呢(也稱為對象的訪問定位)?
對象的訪問定位方式是由虛擬機 GC 的具體實現來決定的,保守式 GC 使用的對象訪問定位方式是使用句柄訪問,準確式 GC 使用的對象訪問定位方式是直接指針訪問。
這里出現了幾個專有名詞哈,下面我來一一解釋 ??
老規矩,背誦版在文末。點擊閱讀原文可以直達我收錄整理的各大廠面試真題
保守式 GC 與使用句柄訪問
談到垃圾回收必然離不開對象標記算法,眾所周知,目前主流的對象標記算法就是可達性分析法,簡單來說,可達性分析法是從 GC Roots 出發(注意是 GC Roots 說明是有多個 GC Root),當某個對象到 GC Roots 沒有任何引用鏈時,則該對象判定為可回收對象。
那么什么東西可以能作為 GC Roots 呢:
- 在虛擬機棧中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等
- 在本地方法棧中 JNI(即通常所說的 Native 方法)引用的對象
- 在方法區中類靜態屬性引用的對象,譬如 Java 類的引用類型靜態變量
- 在方法區中常量引用的對象,譬如字符串常量池(String Table)里的引用
- ......
針對到對象的訪問定位(從棧中變量定位堆中對象)這個問題,我們可以就取虛擬機棧(棧幀中的本地變量表)中引用的對象來說明。
經過上面的描述,問題已經簡化成如何判斷虛擬機棧中的數據存的是一個引用還是一個基本數據?
打個比方:
從圖中可以看出,對于變量 a,JVM 在得到 a 的值后,肯定能夠立刻判斷出它不是一個引用,為什么?
因為引用是一個地址,JVM 中地址是 32 位的,也就是 8 位的 16 進制,很明顯 a 是一個 4 位 16 進制,不能作為引用(這里的專業術語叫對齊檢查)。
同時,JVM 對變量 d 也是能夠立刻判斷出它不是引用,因為 Java 堆的上下邊界是知道的,如圖中所標識的堆起始地址和最后地址,JVM 發現變量 d 的值早就超出了 Java 堆的邊界,故認為它不是引用(這里專業術語叫做上下邊界檢查)。
接下來才是重點,對于變量 b(實際是一個引用) 和變量 c(實際就是一個 int 型變量),發現他們兩個的值是一樣的,于是 JVM 就不能判斷了,在專業名稱上,基于這種方式的 GC 就稱為 “保守式 GC”,也稱為不能識別指針和非指針的 GC。
這里要說明的是,雖然圖中畫了一個從變量 b 到對象 B 實例的一個箭頭,但 JVM 肯定是不知道的,畫個箭頭只是方便我們分析的
起始,這種保守式 GC 的內存模型并不是上圖所示這般簡單。
我們試想,當執行 b = null 之后,對象 B 的實例就應該沒有任何指向了對吧,此時它就是個垃圾,應該被回收掉。
但是 JVM 錯誤的認為變量 c 的值是一個引用,因為此時 JVM 很保守,擔心會判斷錯誤,所以只好認為 c 也是一個引用,這樣,JVM 認為仍然有人在引用對象 B,所以不會回收對象 B。
這里似乎還看不出什么問題,不過就是因為模糊的檢查,一些已經死掉的對象被誤認為仍有地方引用他們,GC 也就自然不會回收他們,從而引起了無用的內存占用,造成資源浪費。僅此而已。
更嚴重的問題是,由于不知道疑似指針是否真的是指針,所以它們的值都不能改寫。
比如上面保守式 GC 把 b 和 c 都看成是對象 B 實例的引用,一旦 B 這個對象實例移動了,那么 b 和 c 的引用值都應該修改,但如果 c 變量不是一個引用,而就單純只是一個 int 型數據呢?
移動對象就意味著要修正指針,換言之,對象就不可移動了。這顯然是不可能的,GC 過程肯定伴隨存活對象的頻繁移動。
有一種辦法可以在使用保守式 GC 的同時支持對象的移動,那就是增加一個間接層,不直接通過指針來實現引用,而是添加一層 “句柄”(handle)在中間,所有引用先指到一個句柄池里,再從句柄池找到實際對象。這樣,要移動對象的話,只要修改句柄池里的內容即可。
于是保守式 GC 真正的內存模型出來了:
通過上圖,不難發現,在堆中增加了一個句柄池,當對象 B 的實例更改存放地址后,JVM 只要改變句柄值,而不用改變變量 b 和變量 c 的值,這樣 JVM 就不用犯愁了,因為不論變量 c 是不是一個引用,之后用到 c 的地方,c 的值也沒有發生變化,可以正常使用。
不過很顯然,這樣的話引用的訪問速度也就降低了。
簡單總結下保守式 GC,也稱為不能識別指針和非指針的 GC,只能通過堆的上下邊界檢查和對齊檢查去判斷是否為一個引用。保守式 GC 有兩個缺點:
- 偽引用,如同上面所說的,當 B = null 之后,本來 B 對象應該被當作垃圾回收掉的,但是有變量 c 這么個偽引用存在,JVM 不敢動手回收掉 B 對象
- 為了支持對象的移動,增加了中間層句柄池,棧中的所有引用都指向這個句柄池中的地址,然后再從句柄池中找到實際對象,但是這樣占用了堆的空間并且降低了訪問效率,需要兩次才能訪問到真正的對象。
1996 年 1 月 23 日,Sun 發布 JDK 1.0,Java 語言首次擁有了商用的正式運行環境,這個 JDK 中所帶的虛擬機是 Classic VM,它采用的就是基于句柄的對象訪問定位方式
準確式 GC 與直接指針訪問
與保守式 GC 相對的就是準確式 GC,何為準確式 GC?
就是我們準確的知道,某個位置上面是否是指針,對于 Java 來說,就是知道內存中某個位置的數據具體是什么類型,譬如內存中有一個 32 bit 的整數 123456,虛擬機將有能力分辨出它到底是一個指向了 123456 的內存地址的引用類型還是一個數值為 123456 的整數,準確分辨出哪些內存是引用類型,這也是在垃圾收集時準確判斷堆上的數據是否還可能被繼續使用的前提。
實現這種要求的方法有很多種,在 Java 中實現的方式是:從外部記錄下類型信息,存成映射表,在 HotSpot 中把這種映射表稱之為 OopMap,不同的虛擬機名稱可能不一樣,簡而言之,OopMap 就是存著一系列信息的數據結構。實現這種功能,需要虛擬機的解釋器和 JIT 編譯器支持,由他們來生成 OopMap。
現在主流的 Hotspot 虛擬機,都拋棄掉了以前 Classic VM 基于句柄(Handle)的對象查找方式,采用基于直接指針訪問的方式,這樣每次定位對象都少了一次間接查找的開銷,顯著提升執行性能
最后放上這道題的背誦版:
?? 面試官:講一下對象的訪問定位的方式
?? 小牛肉:對象的訪問定位方式是由虛擬機 GC 的具體實現來決定的,保守式 GC 使用的對象訪問定位方式是使用句柄訪問,準確式 GC 使用的對象訪問定位方式是直接指針訪問:
所謂保守式 GC 就是虛擬機無法識別指針和非指針,這會導致兩個問題,一個就是一些已經死掉的對象無法被回收,占用內存;第二個就是對象無法移動,為了解決這個問題,在堆中引入了句柄池,所有引用先指到一個句柄池里,再從句柄池找到實際對象。這樣,要移動對象的話,只要修改句柄池里的內容即可,虛擬機棧中存儲的就是對象的句柄地址。這就是使用句柄訪問,顯然它多了一次間接查找的開銷
所謂準確式 GC 就是虛擬機準確的知道內存中某個位置的數據具體是什么類型,具體的實現方式就是使用一個映射表 OopMap 記錄下類型信息,虛擬機棧中存儲的直接就是對象地址,這樣就不需要多一次間接訪問的開銷了,這就是直接指針訪問