讀者朋友們可能已經看過太多關於Java垃圾回收相關的文章,如果沒有,牆裂安利大家看下面這篇: 看完這篇垃圾回收,和麵試官扯皮沒問題了
本文不再重複談GC演算法以及垃圾回收器,而是談談在GC發生的時候,有幾個可能被忽略的問題。搞懂這些問題,相信將對GC的理解能再加深幾分。
本文主要內容
- Q1: GC工作是如何發起的?
- Q2: Stop The World到底如何讓Java執行緒都停下來?
- Q3: 如何找到GC Roots?
- Q4: GC時如何處理四種特殊引用?
- Q5: 物件移動後,引用如何修正?
複製程式碼
Q1: GC工作是如何發起的?
垃圾回收針對不同的分割槽又分為MinorGC和FullGC,不同分割槽的觸發條件又有不同。總體來說GC的觸發分為主動和被動兩類:
- 主動:程式顯示呼叫System.gc()發起GC(不一定馬上甚至不會GC)
- 被動:記憶體分配失敗,需要清理空間
無論上面哪種情況,GC的發起的方式都是一致的:
- Step1:需要GC的執行緒發起一個
VM_Operation
操作(這是一個基類,不同垃圾回收器發起各自的子類操作,如CMS收集器發起的是VM_GenCollectFullConcurrent) - Step2:該操作投遞到一個佇列中,JVM中有一個
VMThread
執行緒專門處理佇列中的這些操作請求,該執行緒呼叫VM_Operation的evaluate
函式來處理具體每一個操作。 - Step3: VM_Operation的evaluate函式呼叫自身的
doit
虛擬函式 - Step4: 各垃圾回收器派生的VM_Operation子類覆蓋doit方法,實現各自的垃圾回收處理工作,一個典型的C++多型的使用。
Q2: Stop The World到底如何讓Java執行緒都停下來?
相信大家都聽說過STW,在執行垃圾回收的時候,需要將所有工作中的Java執行緒停下來,這樣做的原因,借用上面那篇文章中的一句話:
為啥在垃圾收集期間其他工作執行緒會被掛起?想象一下,你一邊在收垃圾,另外一群人一邊丟垃圾,垃圾能收拾乾淨嗎?
那這些Java執行緒到底是如何停下來的呢?
首先肯定不是垃圾回收執行緒去執行suspend
來將他們掛起的,想想為什麼呢?
停下來可不是讓執行緒可以停在任何地方,因為接下來要進行的GC會導致堆區的物件進行“遷徙”,如果停的不合適,執行緒醒過來後對這些物件的操作將出現無法預期的錯誤。
那停在哪裡合適呢?由此引申出另一個重要的概念:安全點,進入安全點的執行緒意味著不會改變引用的關係。
執行安全點同步是由前文所述的VMThread發起,在處理VM_Operation之前進行進入安全點同步,處理完成之後,撤銷安全點同步。
void VMThread::loop() {
while (true) {
...
_cur_vm_operation = _vm_queue->remove_next();
...
// 安全點同步開始
SafepointSynchronize::begin();
// 處理當前VM_Operation
evaluate_operation(_cur_vm_operation);
...
// 安全點同步結束
SafepointSynchronize::begin();
...
}
...
}
複製程式碼
需要注意的是,上面VMThread的工作執行緒中,並非處理所有的VMOpration都會執行安全點的同步工作,會根據VMOpration的情況處理,為求清晰簡單,上述程式碼中略去了這些邏輯。
一個Java執行緒可能處於不同的狀態,在HotSpot中,根據執行緒所處在不同的狀態,讓其進入安全點的方式也不盡相同。在HotSpot原始碼中有一大段註釋對其進行了專門的說明:
1、解釋執行位元組碼狀態
JVM虛擬機器的執行過程簡單理解就是一個超大的switch case,不斷取出位元組碼然後執行該位元組碼對應的程式碼(這只是一個簡化模型)。那JVM中肯定有一張用於記錄位元組碼和其對應程式碼塊資訊的表,這個表叫DispatchTable
,長這樣:
實際上,JVM內部有兩張這樣的表,一張正常狀態下的,一張需要進入安全點的。
在進入安全點的程式碼中,其中有一項工作就是替換上面生效的位元組碼派遣表:
恢復:
替換後的位元組碼派遣表DispatchTable
中的程式碼將會新增安全點的檢查程式碼,這裡不再展開。
2、執行native程式碼狀態
對於正在進行JNI呼叫的執行緒,SafepointSynchronize::begin中不需要特別的操作。執行native程式碼的Java執行緒,從JNI介面返回時將會主動去檢查是否需要掛起自己。
3、執行編譯後程式碼狀態
現代絕大多數的JVM都用上了一種即時編譯技術JIT,在執行過程中為加快速度,通常以方法函式為粒度對熱點執行程式碼編譯為本地機器指令的技術。
簡單來說就是發現某個函式在反覆執行,或者函式內某個程式碼塊迴圈次數很多,決定將其直接編譯成原生程式碼,不再通過中間位元組碼解釋執行。
這種情況下,不再通過通過中間位元組碼執行,當然也就不會走位元組碼派遣表,所以第一種情況下的替換位元組碼派遣表的方式對執行這種程式碼對執行緒就起不到作用了。那怎麼辦呢?
在HotSpot中採取了一種稱為主動式中斷
的方式讓執行緒進入安全點,具體來說就是在JVM中有一個記憶體頁面,執行緒在工作的平時會時不時的瞅一眼(讀一下)這個頁面,正常情況下是一切正常。而在執行GC之前,JVM中的內務總管VMthread會提前將這個記憶體頁面的訪問屬性為不可讀,這時,其他工作執行緒再去讀這個頁面,將觸發記憶體訪問異常,JVM提前安裝好的異常捕獲器這時就能接管各執行緒的執行流程,做一些GC前的準備後,接著block,將執行緒掛起。
// Roll all threads forward to a safepoint
// and suspend them all
void SafepointSynchronize::begin() {
...
os::make_polling_page_unreadable();
...
}
複製程式碼
呼叫os::make_polling_page_unreadable()
使得polling page變成不可讀,該函式根據不同作業系統平臺有不同的實現,以常見的Linux和Windows分別為例:
Linux:
void os::make_polling_page_unreadable(void) {
if (!guard_memory((char*)_polling_page,
Linux::page_size())) {
fatal("Could not disable polling page");
}
}
bool os::guard_memory(char* addr, size_t size) {
return linux_mprotect(addr, size, PROT_NONE);
}
static bool linux_mprotect(char* addr, size_t size, int prot) {
char* bottom = (char*)align_down((intptr_t)addr, os::Linux::page_size());
assert(addr == bottom, "sanity check");
size = align_up(pointer_delta(addr, bottom, 1) + size, os::Linux::page_size());
return ::mprotect(bottom, size, prot) == 0;
}
複製程式碼
最終呼叫系統級API:mprotect完成對記憶體頁面的屬性設定,熟悉Linux C/C++程式設計的朋友應該不會陌生。
Windows:
void os::make_polling_page_unreadable(void) {
DWORD old_status;
if (!VirtualProtect((char *)_polling_page,
os::vm_page_size(),
PAGE_NOACCESS,
&old_status)) {
fatal("Could not disable polling page");
}
}
複製程式碼
最終呼叫系統級API:VirtualProtect完成對記憶體頁面的屬性設定,熟悉Windows C/C++程式設計的朋友應該不會陌生。
這個特殊的頁面在哪裡? 位於runtime/os類中的靜態成員變數。
4、被阻塞狀態
因為IO、鎖同步等原因被阻塞的執行緒,在GC完成之前將一直阻塞,不會醒來。
5、在VM或處於狀態切換中
一個Java執行緒大部分的時間都在解釋執行Java位元組碼,也會在部分場景下由JVM本身拿到執行權。當執行緒處在這些特殊時刻時,JVM在切換執行緒的狀態時也將主動檢查安全點的狀態。
Q3: 如何找到GC Roots?
GC Roots都是誰?
GC的時候一般通過可達性分析演算法找出還有價值的物件,將他們複製保留,剩下的不在追溯鏈中的物件將被清理消滅。可達性分析演算法的起點是一組稱為GC Roots的東西,那麼GC Roots都是些什麼東西?它們在哪裡?
- 虛擬機器棧(棧幀中的本地變數表)中引用的物件
- 方法區中類靜態屬性引用的物件
- 方法區中常量引用的物件
- 本地方法棧中 JNI(即一般說的 Native 方法)引用的物件
現在知道了它們是誰,也知道在哪裡。但GC的時候如何去找到它們呢?就拿第一個棧中引用的物件舉例,JVM中動輒幾十個執行緒在執行,每個執行緒巢狀的函式棧幀少則十幾層,多則幾十上百層,該如何去把這些所有執行緒中存在的引用都找出來,能夠想象這將是一件耗時耗力的工程。而且要知道,執行GC的時候,是Stop The World了,時間寶貴,需要儘快完成GC,減輕因為垃圾回收造成的程式響應中斷,後邊還要進行物件引用鏈追溯、物件的複製拷貝等等工作,所以,留給GC Roots遍歷的時間並不多。
包括HotSpot在內的現代Java虛擬機器採取了用空間換時間的策略,核心思想很簡單:提前將GC Roots的位置資訊記錄起來,GC的時候,按圖索驥,快速找到它們
。
那麼問題來了,這些位置資訊存在哪裡?又是什麼樣的資料結構?執行緒在不斷執行,引用關係也在不斷變化,這些資訊如何更新?
OopMap的引出
回答這幾個問題之前,讓我們暫且忘記GC Roots這回事,先思考另外一個問題:
JVM執行緒在掃描Java棧時,發現一個64bit的數字0x0007ff3080345600,JVM如何知道這是一個指向Java堆中物件的地址(即一個引用)還是說這僅僅是一個long型的變數而已?
眾所周知,Java這門語言比起C/C++最大的一個變革之一就是擺脫了煩人的指標,解放程式設計師,不再需要用指標去管理記憶體。然而實際上,擺脫只是表面的擺脫,JVM畢竟是用C++寫出來的東西,與其說Java沒有指標,某種角度上來說,Java裡處處都是指標。只不過在Java中,我們換了一個表達:引用。
需要補充說明下的是,在早期的一些JVM實現中,引用本身只是一個控制程式碼值,是物件地址表中的一個索引值。現代JVM的引用不再採用這種方式,而是使用直接指標的方式。關於這個問題,在本文的Q6:物件移動後,引用如何修正?
還將進一步闡述。
回到剛剛的問題,為什麼JVM需要知道一個64bit的資料是一個引用還是一個long型變數?答案是如果它不知道的話,如何進行記憶體回收呢?
由此引出另一組名詞:保守式GC和準確式GC。
- 保守式GC:虛擬機器不能明確分辨上面說的問題,無法知道棧中的哪些是引用,採用保守的態度,如果一個資料看上去像是一個物件指標(比如這個數字指向堆區,那個位置剛好有一個物件頭部),那麼這種情況下就將其當作一個引用。這樣把可能不是引用的也當成了引用,現實點的說就是懶政,這種情況下是可能產生漏網之魚沒有被垃圾回收的(想想為什麼?)
- 準確式GC:相比保守式GC,這種就是明確的知道一個64bit的數字它是一個long還是一個物件的引用。現代商業JVM均採用這種更先進的方式,這種JVM能夠清清楚楚的知道棧中和物件的結構中每一個地址單元裡裝的是什麼東西,不會錯殺,更不會漏殺。
那麼,準確式GC是如何知道的這麼清除呢?答案是JVM將這些記憶體中的資料資訊做了記錄,在HotSpot中,這些資料叫OopMap。
回答上一小節中最後那個問題,GC Roots的位置資訊也就是在OopMap中。
OopMap長啥樣?
OopMap資料如何生成?
HotSpot原始碼中關於OopMap相關資料的建立程式碼分散在各個地方,可以通過在原始碼目錄下搜尋new OopMap
關鍵字找到它們,通過初步的閱讀,可以看到在函式的返回,異常的跳轉,迴圈的跳轉等地方都有它們的身影,在這些時刻,JVM將記錄OopMap相關資訊供後續GC時使用。
Q4: GC時如何處理四種特殊引用?
任何一篇關於GC的文章都會告訴我們:通過可達性演算法從GC Roots出發找出沒有引用的物件。但這裡的引用
並沒有那麼簡單。
通常我們所說的Java引用是指的強引用,除此之外還有一些引用:
強引用
:預設直接指向new出來的物件軟引用
:SoftReference弱引用
:WeakReference虛引用
:PhantomReference,也叫幽靈引用
下面先對上述幾種引用做一個簡單的介紹,預設的強引用就不說了:
軟引用
軟引用是用來描述一些還有用但並非必須的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。 ————摘自《深入理解Java虛擬機器》
總結一下就是:如果一個物件A現在只剩一個SoftReference物件還在引用它,正常情況下記憶體夠用的時候不會清理A的。但如果記憶體吃緊,那對不起,就要拿你開刀,清理A了。這也是軟引用之所以“軟
”的體現。
弱引用
弱引用也是用來描述非必須物件的,他的強度比軟引用更弱一些,被弱引用關聯的物件,在垃圾回收時,如果這個物件只被弱引用關聯(沒有任何強引用關聯他),那麼這個物件就會被回收。 ————摘自《深入理解Java虛擬機器》
弱引用比軟引用能力更弱,弱到即使是在記憶體夠用的情況下,如果物件A只被一個WeakReference物件引用,那麼對不起,也要拿你開刀。這也是弱引用之所以“弱
”的體現。
虛引用
一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來獲取一個物件的例項。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。 ————摘自《深入理解Java虛擬機器》
這位比上面弱引用更弱,甚至某種程度上來說它根本算不上引用,因為不像上面兩位可以通過get方法獲取到原始的引用,將get方法覆蓋後返回null:
public class PhantomReference<T> extends Reference<T> {
public T get() {
return null;
}
}
複製程式碼
Final引用
除了上面四種,還有一種特殊的引用叫FinalReference,該引用用於支援覆蓋了finalizer方法的類物件被清理前執行finalizer方法。
上面幾種引用的定義在HotSpot原始碼中如下:
清理策略
那麼JVM在執行GC的時候又是如何區別對待這些特殊型別的引用呢?
在HotSpot中,不管哪種垃圾回收器,在通過GC Roots遍歷完所有的引用之後,在執行物件清理之前,都會呼叫ReferenceProcessor::process_discovered_references
函式對找到需要清理的引用進行處理,這一點通過這個函式的名字也能看得出來。
而在呼叫這個函式之前,還有一個步驟:呼叫ReferenceProcessor::setup_policy設定處理策略。
函式邏輯很簡單,通過bool引數always_clear來確定當前使用_always_clear_soft_ref_policy
還是使用_default_soft_ref_policy
。
從名字可以看出一個是始終清理軟引用,一個是預設策略,來看一下這兩個策略分別是什麼:
首先是始終清理策略,就是AlwaysClearPolicy
然後是預設策略,如果當前執行是server模式,則選擇LRUMaxHeapPolicy
,否則在client模式下選擇LRUCurrentHeapPolicy
。
ReferencePolicy是一個基類,核心的虛擬函式should_clear_reference
用於外界判斷是否清理對應的引用。在HotSpot提供了四個子類用於引用的處理策略:
NeverClearPolicy
: 從不清理AlwaysClearPolicy
: 總是清理LRUCurrentHeapPolicy
: 最近未使用即清理(根據當前堆空間剩餘來評估最近時間)LRUMaxHeapPolicy
: 最近未使用即清理(根據最大可使用堆空間剩餘來評估最近時間)
那到底setup_policy設定處理策略時always_clear
是true還是false呢?因為這直接決定後續選擇針對軟引用的處理策略是LRUCurrentHeapPolicy/LRUMaxHeapPolicy
還是AlwaysClearPolicy
。
關於這一點,在HotSpot原始碼中,不同垃圾回收器處理稍有不同,但總體來說絕大多數場景下always_clear
引數都是false,只有在多次分配記憶體的嘗試均以失敗告終時,才會嘗試將其置為true,將軟引用清理掉以釋放更多的空間。
請記住上面這些策略,策略的選擇將會影響後面對軟引用的處理方式。
對特殊引用的處理邏輯分析
回到process_discoverd_references
函式,來看一下這個函式的內容:
通過變數的名稱和註釋不難看出,該函式內部依次呼叫process_discovered_reflist
完成對Soft、Weak、Final、Phantom四類特殊引用的處理。
這個函式宣告如下:
重點關注下第二個引數policy和第三個引數clear_referent。 回頭看看上面對該函式的呼叫中傳遞的引數:
引用型別 | policy | clear_referent |
---|---|---|
SoftReference | 非空 | true |
WeakReference | NULL | true |
FinalReference | NULL | false |
PhantomReference | NULL | true |
不同的引數將決定四種引用不同的命運。
進一步到process_discovered_reflist
裡邊看看,該函式內部對引用的處理分為了3個階段,我們一個個看,首先是第一階段:
第一階段:處理軟引用
從註釋可以看出,第一階段只針對軟引用SoftReference,結合上面的表格,只有處理軟引用時,policy引數非空。
而在真正執行處理的process_phase1
函式中,遍歷所有軟引用,對於不再存活的物件,通過前面提到的策略中的process_discovered_references
函式來判斷該引用是需要保留還是從待清理的列表中移除。
第二階段:剔除還存活的物件
這個階段主要工作是將那些指向物件還活著(還有其他強引用在指向它)的引用都從待清理列表中移除:
第三階段:切斷剩餘引用指向的物件
到了第三階段,則根據外部傳入的clear_referent
引數來決定對該引用是從待清理列表移除還是保留。
再次回顧下上面的表格,對於Weak、Soft、Phantom三類引用,引數clear_referent是true,意味著到了最後這個階段,該保留的都保留了,剩下的全是要消滅的。於是在這個函式中,將剩下的這些引用中的referent欄位置為null,至此,物件與這些特殊引用之間的最後一絲聯絡也被切斷,在隨後的GC中將難逃厄運。
而針對Final引用,這個引數是false,第三階段還不會將其與物件斷開。斷開的時機是在執行finalizer方法後再進行。因此在本輪GC中,一個覆蓋了finalizer方法的類物件將暫時保住了生命。
小結
看到這裡,估計大家有點亂,又是這麼多種型別引用,又是這麼多個處理階段,頭都轉運了。別怕,軒轅君第一次看的時候也是這樣,即便是現在動手來寫這篇文章,也是反覆品味原始碼,調研認證後才梳理清楚。
接下來我們對每一種型別的引用在各個階段中的情況梳理一下:
- 軟引用
- 第一階段:對於已經不再存活的物件,根據策略判定是否要從待清理列表移除
- 第二階段:將指向物件還存活的引用從待清理列表移除
- 第三階段:如果第一階段的清理策略決定清理軟引用,則到第三階段將剩下的軟引用置空,切斷與物件最後的聯絡;如果第一階段的清理策略決定不清理軟引用,則到第三階段,待清理列表為空,軟引用得以保留。
結論
:一個只被軟引用指向的物件,何時被清理,取決於清理策略,究其根源,取決於當前堆空間的使用情況
- 弱引用
- 第一階段:無處理,第一階段只處理軟引用
- 第二階段:將指向物件還存活的引用從待清理列表移除
- 第三階段:剩下的弱引用指向物件均不再存活,將弱引用置空,切斷與物件最後的聯絡
結論
:一個只被弱引用指向的物件,第一次GC就被清理
- 虛引用
- 第一階段:無處理,第一階段只處理軟引用
- 第二階段:將指向物件還存活的引用從待清理列表移除
- 第三階段:剩下的虛引用指向物件均不再存活,將弱引用置空,切斷與物件最後的聯絡
結論
:一個只被虛引用指向的物件,第一次GC就被清理
Q5: 物件移動後,引用如何修正?
目前為止我們都知道,垃圾回收的過程將伴隨著物件的“遷徙”,而一旦物件“搬家”之後,之前指向它的所有引用(包括棧裡的引用、堆裡物件的成員變數引用等等)都將失效。而之所以GC後我們的程式仍然能夠照常執行無誤,是因為JVM在這背後做了不少工作,好讓我們的程式看起來只是短暫的STW了一下,醒了之後就像什麼也沒發生過一樣,該幹嘛幹嘛。
自然而然的我們能想到這個問題:物件移動後,引用如何修正?
回答這個問題之前,先來看看在Java中,引用到底是如何“指向”物件的。 在JVM的發展歷史中,出現了兩種方案:
方案一:控制程式碼
引用本身不直接指向物件,物件的地址存在一個表格中,引用本身只是這個表中表項的索引值。這裡引用一下《深入理解Java虛擬機器》一書中的配圖:
這種思想其實很多地方都有用到,對於Windows平臺開發的朋友不會陌生,不管是Windows的視窗,還是核心物件(Mutex、Event等)都是在核心中進行描述管理,為求安全,不會直接暴露核心物件的地址,應用層只能得到一個控制程式碼值,通過這個控制程式碼進行互動。
Linux平臺的檔案描述符也是這種思想的體現。 甚至於現代作業系統使用的虛擬記憶體地址也是如此,記憶體地址並不是實體記憶體的地址,而是需要經過地址譯碼錶轉換。
這種方法的好處顯而易見,物件移動後,所有的引用本身不需修正,只需要修正這個表格中對應的物件地址即可。
弊端同樣也是顯而易見,對於物件的訪問需要經過一次“翻譯轉換”,效能上會打折扣。
方案二:直接指標
第二種方案就是直接指標的方式,沒有中間商賺差價,引用本身就是一個指標。再次引用一下《深入理解Java虛擬機器》一書中的配圖:
和第一種方式相對比,二者的優勢和弊端進行交換。
優勢:訪問物件更直接,效能上更快。 弊端:物件移動後,引用的修復工作麻煩。
以HotSpot為代表的的現代商業JVM選擇了直接指標的方式進行物件訪問定位。
這種方式下就需要對所有存在的引用值進行修改,工作量不可謂不大。
好在,在本文第三節Q3:如何找到GC Roots?
中介紹的OopMap再一次扮演了救世主的身份。
OopMap中儲存的資訊可以告訴JVM,哪些地方有引用,這份關鍵的資訊,不僅用於尋找GC Roots進行垃圾回收,同時也是用於對引用進行修正的重要指南。
參考連結:
RednaxelaFX:找出棧上的指標/引用
寫在最後
希望大家看完這篇文章不僅僅知道GC本身是怎麼一回事,還能對GC的臺前幕後的工作能多一分了解,這樣在和麵試官談到GC的時候,就可以談笑風生~多戰幾個回合
當然,限於筆者技術水平有限,萬字長文寫作費勁,文中若有行文錯誤和技術論述錯誤的地方,請一定指出,以便及時勘誤,謝謝大家。
如果覺得這篇文章有點用, 就幫我點個在看吧,再次謝謝大家。