Java虛擬機GC的根:識別堆空間中活躍對象,JVM內部實現優化引入

大數據架構師 發佈 2022-12-03T20:15:33.140190+00:00

以JVM為例,JVM為了能執行Java代碼,實現了一套完整的編譯、解釋、執行框架,其中編譯是一個獨立的模塊,執行是另一個模塊。

GC的根

垃圾回收的根和虛擬機運行時緊密結合,理解起來並不容易。

需要回答兩個問題:哪些是垃圾回收的根?如何實現標記?

以JVM為例,JVM為了能執行Java代碼,實現了一套完整的編譯、解釋、執行框架,其中編譯是一個獨立的模塊,執行是另一個模塊。

而GC的根既與執行框架相關,又與編譯相關,除此之外,GC的根還與語言特性和JVM的實現相關。

在JVM中存在兩種類型的根:強根弱根。強根是GC的真正根,用於識別堆空間中的活躍對象;弱根並非用於識別活躍對象,只是為了支持語言特性(如Java的引用)或者JVM內部實現的優化而引入的。

強根

強根這個概念相對容易理解,這裡使用線程棧來演示這個概念。假設JVM執行一段Java程序,如下所示:

int a = 2;

Object obj1 = new Object();

Object c = new Object();

{

MyObject d = new MyObject(); //假設MyObject已經定義,且MyObject中有一個成

員變量f指向Object

d. f = c;

// 地點一

}

// 地點二

現在來模擬一下JVM執行過程中內存的使用情況,在代碼的地點一,內存布局如圖2-14所示。

其中圖2-14中棧空間的使用通常在編譯時就可以確定,堆空間通常是在運行時才能確定。每一個局部變量a、b、c、d在棧中都有一個槽位(slot)與之對應,這樣在程序中才能訪問到它們指向的對象或者數值。

這裡稍微提示一下,代碼d.f = c並不是將棧中c的值賦值給d.f,而是將c指向的堆地址賦值給d.f。

當代碼執行到地點二時,內存布局如圖2-15所示。

此時因為變量作用域,變量d在棧中將無法訪問(實際上該槽位被其他的變量使用),變量d因為已經死亡,其對應堆中的內存(圖中灰色空間)也應該可以被回收重用。

基於棧變量可以找到堆空間中所有活躍的對象。當然,如果變量d在GC執行時死亡,在活躍對象的遍歷過程中並不能知道變量d是否存在過,也無法知道變量d指向的內存空間。整個GC結束後只能得到所有活躍對象所占用的內存空間,所以追蹤的GC算法都是管理活躍對象(將活躍對象賦值到新的空間,即複製算法,或者從整個空間中剔除活躍對象後,採用列表的方式管理自由空間),從而達到內存重用的目的。

當然實現層面可能還有更多細節需要考慮,例如在棧中一個槽位存放的值到底是指向堆空間的變量(即指針)還是一個立即數(在上述代碼中變量a就是一個立即數),對於立即數對象,GC並不需要遍歷(因為沒有在堆空間中分配內存)。但是GC執行時並不知道槽位到底是一個地址還是一個立即數,如果做不精確的GC,可以把立即數也「當作」指針,只要立即數在堆空間的訪問範圍內,也會把對應的內存空間進行標記;如果做精確的GC,則必須區分立即數和指針,所以通常需要額外的信息來保存指針信息(例如使用額外的位圖來描述棧空間的哪些槽位是指針),在GC執行時藉助額外的信息就可以進行精確的回收。

經研究發現,通常不精確的GC和精確的GC相比,性能會有15%~40%的差距。

從棧變量作為根的例子可以看出,如果缺少某一個根,則必然會遺漏一些活躍對象,從而導致GC會訪問非法內存。所以必須找到所有的強根並且逐一遍歷,才能保證垃圾回收的正確性。

Java引用引入的弱根

Java語言中的引用主要指軟引用(soft reference)、弱引用(weakreference)和虛引用(phantom reference)。

另外,Java中的Finalize也是通過引用實現的,JDK定義了一種新的引用類型FinalReference,其處理和虛引用非常類似。

引用的處理和GC關係非常密切。在Java語言層面對於不同類型的引用有不同的定義,簡單總結如下:

1)軟引用:聲明為軟引用的對象在垃圾回收時只有滿足一些條件才會進行回收,這些條件程式設計師可以設置,比如通過參數SoftRefLRUPolicyMSPerMB設置軟引用對象的存活時間。

2)弱引用:在垃圾回收執行時,如果發現內存不足聲明為弱引用的對象就會被回收。

3)虛引用:使用虛引用需要定義一個引用隊列,虛引用關聯的對象在Java應用層面無法直接訪問,而是通過引用線程(reference thread,這是一個Java應用的線程,JVM在啟動時會生成該線程)處理引用隊列來訪問。所以虛引用對象的回收依賴於引用隊列中的對象是否被執行,如果引用隊列中的對象還沒有被處理,則不能回收,否則就可以被GC回收。

4)Finalize:如果Java的類重載了Finalize()函數,則需要通過Finalize線程(Finalizer Thread,這是一個Java應用的線程,JVM在啟動時會生成該線程)處理。定義了Finalize()函數的對象類似於定義了虛引用,如果在GC執行過程中發現Finalize線程尚未執行對象的Finalize()函數,則對象不會被回收,否則對象就可以被回收。

可以發現Java語言中引用的處理和GC緊密相關。根據是否需要額外的線程執行額外的動作可以分為兩類,對於這兩類GC過程,處理方法有所不同:

1)軟引用/弱引用:在GC執行過程中,首先要通過強根掃描所有活躍對象,如果發現對象的元數據屬於Java語言中的軟引用/弱引用,則需要額外記錄下來,在強根遍歷結束後再根據GC的策略來決定是否回收引用對象占用的內存空間。

2)虛引用/Finalize引用:在GC執行過程中,首先要通過強根掃描所有活躍對象,如果發現對象的元數據屬於Java語言中的虛引用或者Finalize引用,則需要額外記錄下來,然後將引用類型的對象單獨保留起來,當GC結束後,引用線程處理過的對象就可以在下一次GC執行過程中進行回收。注意,定義了Finalize()函數的對象處理在對象生成期間就知道需要進行額外處理,所以生成的對象會自動添加到Finalize引用中。

從上面的描述中可以看出,當GC處理Java語言的引用特性時,需要額外地對引用對象進行處理,對於軟引用/弱引用,在強根掃描結束以後就可以根78據策略進行回收;對於虛引用/Finalize引用,在本次GC時不能進行回收,通常需要在後續的GC過程中才能真正進行回收,且能否執行回收依賴於引用線程/Finalizer線程是否處理過對象,只有處理過的對象才能在後續的GC中被回收,如果對象沒有處理過,JVM需要繼續記錄這些對象,並保持這些對象活躍。而這些對象明顯不屬於GC回收時識別的活躍對象,但是為了支持引用特性又必須將其記錄下來,保持程序運行語義的正確性,所以JVM內部引入了弱根來記錄這些對象。

JVM優化實現引入的弱根

在Java語言的發展過程中,JVM的研究者發現在JVM內部可以優化實現,從而節約內存或者提高程序執行的效率。為了達到這樣的目的,JVM內部也需要引入一些弱根來保證程序運行的正確性。

這裡以字符串為例來演示JVM的一個弱根。Java類庫中String類提供了一個intern()方法用於優化JVM內存字符串的存儲,intern()方法用來返回常量池中的某字符串。其目的是當Java程序中存在多個相同的字符串時可以共用一個JVM的底層對象表示,從而節約空間。代碼片段如下:

String str1 = new String("abc");

String str2 = new String("abc");

str1.intern();

str2.intern();

在示例中,str1和str2都執行了intern()方法,JVM在執行時會優化底層的存儲,可以簡單地理解intern()方法的功能是:在JVM裡面使用一個StringTable(使用hash table實現)存儲字符串對象,如果StringTable中已經存在該字符串,則直接返回常量池中該對象的引用;否則,在StringTable中加入該對象,然後返回引用。

str1.intern()執行後,在StringTable中使用hash table存儲這個String對象。因為str1對應的字符數組對象並不在StringTable中,所以它會被加入StringTable中。如圖2-16所示,圖中用圓表示對象(這裡我們忽略外部的引用根信息)。


當執行str2.intern()時,首先計算str2的hash code,然後用hash code和str2的字符數組對象在StringTable查找是否已經存儲了String對象,並且比較存儲的String對象hash code與字符串數組是否相同,如果相同,則不需要再次把字符串放入StringTable中了,並且返回str1這個對象。

JVM在內部使用了StringTable來存儲字符串intern的結果,其結構如圖2-17所示。

通過StringTable的方式方便共享字符串對象,但是會帶來回收方面的問題。如果所有的共享變量都死亡,StringTable中的共享對象也應該釋放。但什麼時候可以回收或者釋放StringTable占用的內存呢?在GC執行過程中,當強根遍歷完成後,需要再次遍歷StringTable,如果發現沒有任何相關的引用,則StringTable中的共享對象可以釋放,這個時候就可以回收了。可以看出,當GC的強根遍歷完成後需要額外針對StringTable遍歷來完成一些內存的釋放,而StringTable和GC執行過程中對象的活躍性並無任何關係,僅僅是JVM內部設計帶來的額外遍歷,這樣的根也稱為弱根。

從上面的介紹可以看出,對於弱根,如果不進行遍歷,則會導致一定程度的內存泄露,但是並不會影響Java程序正確地執行。為了保障GC執行的性能,在新生代回收中通常不回收這類弱根。當然由於JVM內存設計的複雜性,在一些新生代回收實現中也會處理這類弱根,其原因涉及對另外一些特性的支持的影響(例如類回收或者字符串去重等),這裡不再展開介紹。

JVM中根的構成

JVM中根的構成非常複雜,根據程序執行的語義、語言特性的支持及JVM內部優化實現,可以將根劃分為Java根、JVM根和其他根。

Java根用於找到Java程序執行時產生的對象,包括兩類,分別為:類元數據對象,主要利用類加載器來跟蹤Java程序運行時加載的類元數據對象。

Java對象,主要通過線程棧幀跟蹤Java程序的活躍對象。

JVM根主要指JVM為了運行Java程序所產生的一些對象,這些對象可以簡單地被認為是全局對象。主要有:

Universe,Java程序運行時需要一些全局對象,比如Java支持8種基本類型,這些基本類型的信息需要對象來描述(基本類型的描述信息作為全局對象是為了性能考慮),這些對象就存放在Universe中。

Monitor,全局監視器對象,對於Monitor對象主要是用於鎖相關,可能存在只有Monitor對象引用到內存空間的對象,所以Monitor是JVM的根之一。

JNI,JVM執行本地代碼時使用API產生的對象,例如通過JNI API在堆中創建對象,這些對象只在JNI API中使用,所以需要單獨管理這些對象。

JVMTI,使用JVM提供的接口用於調試、分析Java程序。使用JVMTI API時也會分配新的對象。

System Dictionary,JVM在設計類加載時,對於基本的類,比如Java中經常使用的基礎類,會通過系統加載器加載這些類,而這些類在運行Java程序一直都需要,所以這些類被單獨加載,單獨標記。

Management,是JVM提供的內存管理API,用於JVM內存的統計信息,在使用這些API時需要創建Java對象,所以需要標記。

AOT,在JDK 9之後引入了提前編譯。在AOT的編譯過程中會把全局對象和編譯優化的代碼對象放在可執行文件中,當執行時會用到這些對象,所以在回收時需要標記。

其他根主要有:

語言特性的弱引用。

JVM弱根,例如管理Java中String中intern產生的對象、編譯後代碼等。

這些根共同構成了GC根集合,實際上根的確定和虛擬機運行時密切相關,而運行時又非常複雜,限於篇幅,本文無法對根詳細介紹,有興趣的讀者可以參考其他文獻。

需要注意的是,對於弱根的處理在不同的GC實現中有所不同,主要原因是弱根通常涉及內部資源的釋放,整個流程耗時較多,在一些回收中會把弱根當作強根對待(即不釋放弱根相關的內部資源),以加快GC的執行。

本文給大家講解的內容是Java虛擬機和垃圾回收基礎知識:GC的根

  1. 下篇文章給大家講解的內容是JVM中垃圾回收相關的基本知識:安全點,解釋+編譯+本地+JVM內部並發線程進入安全點
  2. 感謝大家的支持!
關鍵字: