JVM GC的這幾個問題你瞭解嗎?

個人博客請訪問 http://www.x0100.top          

本文不再重複談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進行垃圾回收,同時也是用於對引用進行修正的重要指南。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章