天天看點

保守式 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 呢:

  1. 在虛拟機棧中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等
  2. 在本地方法棧中 JNI(即通常所說的 Native 方法)引用的對象
  3. 在方法區中類靜态屬性引用的對象,譬如 Java 類的引用類型靜态變量
  4. 在方法區中常量引用的對象,譬如字元串常量池(String Table)裡的引用
  5. ......

針對到對象的通路定位(從棧中變量定位堆中對象)這個問題,我們可以就取虛拟機棧(棧幀中的本地變量表)中引用的對象來說明。

經過上面的描述,問題已經簡化成如何判斷虛拟機棧中的資料存的是一個引用還是一個基本資料?

打個比方:

保守式 GC 與準确式 GC,如何在堆中找到某個對象的具體位置?

從圖中可以看出,對于變量 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 真正的記憶體模型出來了:

保守式 GC 與準确式 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 使用的對象通路定位方式是直接指針通路:

  1. 所謂保守式 GC 就是虛拟機無法識别指針和非指針,這會導緻兩個問題,一個就是一些已經死掉的對象無法被回收,占用記憶體;第二個就是對象無法移動,為了解決這個問題,在堆中引入了句柄池,所有引用先指到一個句柄池裡,再從句柄池找到實際對象。這樣,要移動對象的話,隻要修改句柄池裡的内容即可,虛拟機棧中存儲的就是對象的句柄位址。這就是使用句柄通路,顯然它多了一次間接查找的開銷
  2. 所謂準确式 GC 就是虛拟機準确的知道記憶體中某個位置的資料具體是什麼類型,具體的實作方式就是使用一個映射表 OopMap 記錄下類型資訊,虛拟機棧中存儲的直接就是對象位址,這樣就不需要多一次間接通路的開銷了,這就是直接指針通路

心之所向,素履以往,我是小牛肉,小夥伴們下篇文章再見 👋