我愛學Java之JVM中的OopMap
呼叫棧裡的引用型別資料是GC的根集合(root set)的重要組成部分;找出棧上的引用是GC的根列舉(root enumeration)中不可或缺的一環。
JVM選擇用什麼方式會影響到GC的實現:
如果JVM選擇不記錄任何這種型別的資料,那麼它就無法區分記憶體裡某個位置上的資料到底應該解讀為引用型別還是整型還是別的什麼。這種條件下,實現出來的GC就會是“保守式GC(conservative GC)”。在進行GC的時候,JVM開始從一些已知位置(例如說JVM棧)開始掃描記憶體,掃描的時候每看到一個數字就看看它“像不像是一個指向GC堆中的指標”。這裡會涉及上下邊界檢查(GC堆的上下界是已知的)、對齊檢查(通常分配空間的時候會有對齊要求,假如說是4位元組對齊,那麼不能被4整除的數字就肯定不是指標),之類的。然後遞迴的這麼掃描出去。
保守式GC的好處是相對來說實現簡單些,而且可以方便的用在對GC沒有特別支援的程式語言裡提供自動記憶體管理功能。Boehm-Demers-Weiser GC是保守式GC中的典型代表,可以嵌入到C或C++等語言寫的程式中。
小歷史故事:
微軟的JScript和早期版VBScript也是用保守式GC的;微軟的JVM也是。VBScript後來改回用引用計數了。而微軟JVM的後代,也就是.NET裡的CLR,則改用了完全準確式GC。
為了趕上在一個會議上釋出訊息,微軟最初的JVM原型只有一個月左右的時間從開工到達到符合Java標準。所以只好先用簡單的辦法來實現,也就自然選用了保守式GC。
資訊來源:Patrick Dussud在Channel 9的訪談,23分鐘左右
保守式GC的缺點有:
1、會有部分物件本來應該已經死了,但有疑似指標指向它們,使它們逃過GC的收集。這對程式語義來說是安全的,因為所有應該活著的物件都會是活的;但對記憶體佔用量來說就不是件好事,總會有一些已經不需要的資料還佔用著GC堆空間。具體實現可以通過一些調節來讓這種無用物件的比例少一些,可以緩解(但不能根治)記憶體佔用量大的問題。
2、由於不知道疑似指標是否真的是指標,所以它們的值都不能改寫;移動物件就意味著要修正指標。換言之,物件就不可移動了。有一種辦法可以在使用保守式GC的同時支援物件的移動,那就是增加一個間接層,不直接通過指標來實現引用,而是新增一層“控制代碼”(handle)在中間,所有引用先指到一個控制代碼表裡,再從控制代碼表找到實際物件。這樣,要移動物件的話,只要修改控制代碼表裡的內容即可。但是這樣的話引用的訪問速度就降低了。Sun JDK的Classic VM用過這種全handle的設計,但效果實在算不上好。
由於JVM要支援豐富的反射功能,本來就需要讓物件能瞭解自身的結構,而這種資訊GC也可以利用上,所以很少有JVM會用完全保守式的GC。除非真的是特別懶…
JVM可以選擇在棧上不記錄型別資訊,而在物件上記錄型別資訊。這樣的話,掃描棧的時候仍然會跟上面說的過程一樣,但掃描到GC堆內的物件時因為物件帶有足夠型別資訊了,JVM就能夠判斷出在該物件內什麼位置的資料是引用型別了。這種是“半保守式GC”,也稱為“根上保守(conservative with respect to the roots)”。
為了支援半保守式GC,執行時需要在物件上帶有足夠的元資料。如果是JVM的話,這些資料可能在類載入器或者物件模型的模組裡計算得到,但不需要JIT編譯器的特別支援。
前面提到了Boehm GC,實際上它不但支援完全保守的方式,也可以支援半保守的方式。GCJ和Mono都是以半保守方式使用Boehm GC的例子。
Google Android的Dalvik VM的早期版本也是使用半保守式GC的一個例子。不過到2009年中的時候Dalvik VM的內部版本就已經開始支援準確式GC了——代價是優化過的DEX檔案的體積膨脹了約9%。
其實許多較老的JVM都選擇這種實現方式。
由於半保守式GC在堆內部的資料是準確的,所以它可以在直接使用指標來實現引用的條件下支援部分物件的移動,方法是隻將保守掃描能直接掃到的物件設定為不可移動(pinned),而從它們出發再掃描到的物件就可以移動了。
完全保守的GC通常使用不移動物件的演算法,例如mark-sweep。半保守方式的GC既可以使用mark-sweep,也可以使用移動部分物件的演算法,例如Bartlett風格的mostly-copying GC。
半保守式GC對JNI方法呼叫的支援會比較容易:管它是不是JNI方法呼叫,是棧都掃過去…完事了。不需要對引用做任何額外的處理。當然代價跟完全保守式一樣,會有“疑似指標”的問題。
與保守式GC相對的是“準確式GC”,原文可以是precise GC、exact GC、accurate GC或者type accurate GC。外國人也挺麻煩的,“準確”都統一不到一個詞上⋯
是什麼東西“準確”呢?關鍵就是“型別”,也就是說給定某個位置上的某塊資料,要能知道它的準確型別是什麼,這樣才可以合理地解讀資料的含義;GC所關心的含義就是“這塊資料是不是指標”。
要實現這樣的GC,JVM就要能夠判斷出所有位置上的資料是不是指向GC堆裡的引用,包括活動記錄(棧+暫存器)裡的資料。
有幾種辦法:
1、讓資料自身帶上標記(tag)。這種做法在JVM裡不常見,但在別的一些語言實現裡有體現。就不詳細介紹了。打標記的方式在半保守式GC中倒是更常見一些,例如CRuby就是用打標記的半保守式GC。CLDC-HI比較有趣,棧上對每個slot都配對一個字長的tag來說明它的型別,通過這種方式來減少stack map的開銷;類似的實現在別的地方沒怎麼見過,大家一般都不這麼取捨。
2、讓編譯器為每個方法生成特別的掃描程式碼。我還沒見過JVM實現裡這麼做的,雖說在別的語言實現裡有見過。
3、從外部記錄下型別資訊,存成對映表。現在三種主流的高效能JVM實現,HotSpot、JRockit和J9都是這樣做的。其中,HotSpot把這樣的資料結構叫做OopMap,JRockit裡叫做livemap,J9裡叫做GC map。Apache Harmony的DRLVM也把它叫GCMap。
要實現這種功能,需要虛擬機器裡的直譯器和JIT編譯器都有相應的支援,由它們來生成足夠的元資料提供給GC。
使用這樣的對映表一般有兩種方式:
1、每次都遍歷原始的對映表,迴圈的一個個偏移量掃描過去;這種用法也叫“解釋式”;
2、為每個對映表生成一塊定製的掃描程式碼(想像掃描對映表的迴圈被展開的樣子),以後每次要用對映表就直接執行生成的掃描程式碼;這種用法也叫“編譯式”。
在HotSpot中,物件的型別資訊裡有記錄自己的OopMap,記錄了在該型別的物件內什麼偏移量上是什麼型別的資料。所以從物件開始向外的掃描可以是準確的;這些資料是在類載入過程中計算得到的。
可以把oopMap簡單理解成是除錯資訊。 在原始碼裡面每個變數都是有型別的,但是編譯之後的程式碼就只有變數在棧上的位置了。oopMap就是一個附加的資訊,告訴你棧上哪個位置本來是個什麼東西。 這個資訊是在JIT編譯時跟機器碼一起產生的。因為只有編譯器知道原始碼跟產生的程式碼的對應關係。 每個方法可能會有好幾個oopMap,就是根據safepoint把一個方法的程式碼分成幾段,每一段程式碼一個oopMap,作用域自然也僅限於這一段程式碼。 迴圈中引用多個物件,肯定會有多個變數,編譯後佔據棧上的多個位置。那這段程式碼的oopMap就會包含多條記錄。
每個被JIT編譯過後的方法也會在一些特定的位置記錄下OopMap,記錄了執行到該方法的某條指令的時候,棧上和暫存器裡哪些位置是引用。這樣GC在掃描棧的時候就會查詢這些OopMap就知道哪裡是引用了。這些特定的位置主要在:
1、迴圈的末尾
2、方法臨返回前 / 呼叫方法的call指令後
3、可能拋異常的位置
這種位置被稱為“安全點”(safepoint)。之所以要選擇一些特定的位置來記錄OopMap,是因為如果對每條指令(的位置)都記錄OopMap的話,這些記錄就會比較大,那麼空間開銷會顯得不值得。選用一些比較關鍵的點來記錄就能有效的縮小需要記錄的資料量,但仍然能達到區分引用的目的。因為這樣,HotSpot中GC不是在任意位置都可以進入,而只能在safepoint處進入。
而仍然在直譯器中執行的方法則可以通過直譯器裡的功能自動生成出OopMap出來給GC用。
平時這些OopMap都是壓縮了存在記憶體裡的;在GC的時候才按需解壓出來使用。
HotSpot是用“解釋式”的方式來使用OopMap的,每次都迴圈變數裡面的項來掃描對應的偏移量。
對Java執行緒中的JNI方法,它們既不是由JVM裡的直譯器執行的,也不是由JVM的JIT編譯器生成的,所以會缺少OopMap資訊。那麼GC碰到這樣的棧幀該如何維持準確性呢?
HotSpot的解決方法是:所有經過JNI呼叫邊界(呼叫JNI方法傳入的引數、從JNI方法傳回的返回值)的引用都必須用“控制代碼”(handle)包裝起來。JNI需要呼叫Java API的時候也必須自己用控制代碼包裝指標。在這種實現中,JNI方法裡寫的“jobject”實際上不是直接指向物件的指標,而是先指向一個控制代碼,通過控制代碼才能間接訪問到物件。這樣在掃描到JNI方法的時候就不需要掃描它的棧幀了——只要掃描控制代碼表就可以得到所有從JNI方法能訪問到的GC堆裡的物件。
但這也就意味著呼叫JNI方法會有控制代碼的包裝/拆包裝的開銷,是導致JNI方法的呼叫比較慢的原因之一。