《深入理解 Java 虛擬機》讀書筆記:垃圾收集器與內存分配策略

正文

垃圾收集器關注的是 Java 堆和方法區,因爲這部分內存的分配和回收是動態的。只有在程序處於運行期間時才能知道會創建哪些對象,也才能知道需要多少內存。

虛擬機棧和本地方法棧則不需要過多考慮回收的問題,因爲棧中每一個棧幀分配多少內存基本上是在類結構確定下來時就已知的,因此這幾個區域的內存分配和回收具有確定性。

一、對象已死嗎

垃圾收集器在對堆進行回收前,第一件事就是要確定堆中對象哪些還“存活”着,哪些已“死去”(即不可能再被任何途徑使用的對象)。

1、 引用計數算法

給對象添加一個引用計數器,每當有一個地方引用它時,計數器值加 1;當引用失效時,計數器值減 1;任何時刻計數器爲 0 的對象就是不可能再被使用的。

優點:實現簡單,判定效率高。

缺點:很難解決對象之間相互循環引用的問題。

2、可達性分析算法

通過一系列被稱爲“GC Roots”的對象作爲起點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈,當一個對象到 GC Roots 沒有任何引用鏈相連時,則此對象不可用。

Java 語言中,可作爲 GC Roots 的對象:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象。
  • 方法區中類靜態屬性引用的對象。
  • 方法區中常量引用對象。
  • 本地方法棧中 JNI(即 Native 方法)引用的對象。

可達性分析算法中不可達的對象,至少要經歷兩次標記過程,纔會被回收。

  1. 發現沒有與 GC Roots 相連的引用鏈時,進行第一次標記。
  2. 當對象覆蓋了 finalize() 方法,並且沒有被調用過時,將會被放入一個叫做 F-Queue 的隊列中,稍後 GC 將對 F-Queue 中的對象進行第二次標記。如果在 finalize() 方法中,對象沒有重新與引用鏈上的一個對象建立關聯,那麼將會被回收。

3、四種引用

無論是引用計數算法,還是可達性分析算法,判斷對象是否存活都與“引用”有關。Java 中有 4 種引用,按強度由強至弱依次爲:強引用、軟引用、弱引用、虛引用。

  • 強引用:類似“Object obj = new Object()”的引用。只要強引用還存在,對象就永遠不會回收。
  • 軟引用:用來描述一些還有用但並非必需的對象。內存不足時,對象有可能被回收。可通過 SoftReference 類實現軟引用。
  • 弱引用:用來描述非必需的對象,但強度比軟引用弱。GC時,無論內存是否足夠,對象都會被回收。可通過 WeakReference 類來實現弱引用。
  • 虛引用:也稱幽靈引用或幻影引用,虛引用不會對對象的生存時間構成影響。虛引用的唯一作用就是能在對象被回收時收到一個系統通知。可通過 PhantomReference 類實現虛引用。

4、回收方法區

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。

如何判定廢棄常量:

  • 常量池中的常量(字面量、符號引用)沒有在任何地方被引用。

如何判定無用的類:

  • 該類的所有實例都已被回收。
  • 加載該類的 ClassLoader 已被回收。
  • 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

二、垃圾收集算法

1、標記-清除算法

分爲“標記”和“清除”兩個階段。首先標記出所有需要回收的對象,然後再統一回收所有被標記的對象。

該算法會產生大量不連續的內存碎片,因而在分配較大對象時,可能會由於無法找到足夠的連續內存而不得不提前觸發一次 GC。

2、複製算法

將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中一塊。當一塊內存用完時,就將還存活的對象複製到另一塊,然後再把已使用過的內存空間一次清理掉。

該算法的代價是始終會有一塊內存被“浪費”掉。

由於新生代的對象 98% 是“朝生夕死”,因此並不需要按 1:1 的比例來劃分內存空間。現在的商業虛擬機,是將內存劃分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor。當回收時,將 Eden 和 Survivor 中還存活的對象複製到另一塊 Survivor 上,最後清理掉 Eden 和使用過的 Survivor。

HotSpot 虛擬機默認 Eden 和 Survivor 的大小比例是 8:1。

分配擔保機制:
當另一塊 Survivor 沒有足夠空間來存放存活對象時,則需要其他內存(老年代)進行分配擔保,將對象移入其他內存(老年代)。

3、標記-整理算法

首先標記出所有需要回收的對象,然後將所有存活對象向一端移動,最後直接清理掉端邊界以外的內存。

4、分代收集算法

根據對象存活週期的不同,將 Java 堆劃分爲新生代和老年代,然後根據各個年代的特點採用最適當的收集算法。

  • 新生代:採用複製算法。因爲新生代每次 GC 都有大量對象死去,故只需付出少量存活對象的複製成本即可完成 GC。
  • 老年代:採用“標記-清除”或“標記-整理”算法。因爲老年代中對象存活率高,而且沒有額外空間進行分配擔保。

三、HotSpot 的算法實現

1、枚舉根節點

可達性分析時,需要枚舉 GC Roots 節點,以便標記出所有的不可用對象。

可作爲 GC Roots 的節點主要在全局引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中。如果逐個檢查裏面的引用,會消耗很多時間。因此,目前主流的 Java 虛擬機使用準確式 GC 來完成 GC Roots 枚舉。

Stop The World(STW):
可達性分析期間,不可以出現對象引用關係還在不斷變化的情況。因此 GC 時,必須停頓所有 Java 執行線程,此時整個執行系統看起來就像被凍結某個時間點上。

準確式 GC:
虛擬機可以直接得知哪些地方存放着對象引用,因此 STW 時,不需要一個不漏地檢查所有執行上下文和全局的引用位置。

HotSpot 中準確式 GC 的實現:
HotSpot 使用一組稱爲 OopMap 的數據結構來記錄對象的引用位置。這樣,GC 在掃描時就可以直接得知對象的引用位置信息。

類加載完成時,HotSpot 會把對象內什麼偏移量上是什麼類型的數據計算出來記錄到 OopMap 中。JIT 編譯過程中,也會在 OopMap 中記錄下棧和寄存器中哪些位置是引用。

2、安全點

HotSpot 只在特定的位置上記錄了 OopMap,這些位置稱爲安全點。

程序執行時,只有到達安全點才能停頓下來進行 GC。因爲只有到達安全點,才能訪問到 OopMap 記錄。

如何在 GC 時讓線程跑到最近的安全點再停頓下來:

  • 搶先式中斷:GC 時,先中斷所有線程,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它跑到安全點上。
  • 主動式中斷:GC 時,設置一箇中斷標誌,各個線程執行時主動去輪詢這個標誌,發現中斷標誌爲真時就自己中斷掛起。

3、安全區域

安全區域是指一段代碼片段中,引用關係不會發生變化。在這個區域中的任意地方開始 GC 都是安全的。可以把安全區域看做是被擴展了的安全點。

爲什麼需要安全區域:
當線程沒有分配 CPU 時間時,將無法響應 JVM 的中斷請求,跑到安全點中斷掛起,JVM 也不太可能等待線程重新被分配 CPU 時間。這種情況就需要安全區域來解決。

安全區域的使用:

  1. 線程執行到安全區域的代碼時,標識自己進入了安全區域。
  2. JVM 發起 GC 時,不用管進入安全區域的線程。
  3. 線程要離開安全區域時,必須檢查系統是否完成了根節點枚舉(或整個 GC 過程)。如果完成了,線程就繼續執行,否則必須等待,直到收到可以離開安全區域的信號。

四、垃圾收集器

1、Serial 收集器

  • 最基本、歷史最悠久的收集器。
  • 單線程收集器:使用一個 CPU 或一條線程進行垃圾收集。
  • 新生代收集器,是運行在 Client 模式下的虛擬機的默認新生代收集器。
  • 簡單而高效,單個 CPU 下,沒有線程交互的開銷。

2、ParNew 收集器

  • Serial 收集器的多線程版本。
  • 新生代收集器,是許多運行在 Server 模式下的虛擬機中首選的新生代收集器。
  • 除了 Serial 收集器外,目前只有它能與 CMS 收集器配合工作。
  • 默認開啓的收集線程數與 CPU 數量相同。

3、Parallel Scavenge 收集器

  • 多線程收集器。
  • 新生代收集器。
  • 關注吞吐量,即 CPU 用於運行用戶代碼的時間與 CPU 總消耗時間的比值。高吞吐量可以高效利用 CPU 時間,儘快完成程序的運算任務,適合於在後臺運算而不需要太多交互的任務。
  • 可開啓自適應調節策略,把內存管理的調優任務交給虛擬機去完成。

自適應調節策略:
虛擬機根據當前系統的運行情況收集性能監控信息,動態調整虛擬機參數以提供最合適的停頓時間或最大的吞吐量。

4、Serial Old 收集器

  • Serial 收集器的老年代版本。
  • 單線程收集器。
  • 使用“標記-整理”算法。
  • 給 Client 模式下的虛擬機使用。

5、Parallel Old 收集器

  • Parallel Scavenge 收集器的老年代版本。
  • 多線程收集器。
  • 使用“標記-整理”算法。
  • 在注重吞吐量以及 CPU 資源敏感的場合,可優先考慮 Parallel Scavenge 加 Parallel Old 收集器。

6、CMS 收集器

  • CMS:Concurrent Mark Sweep。
  • 併發收集器:垃圾收集線程與用戶線程(基本上)同時工作。
  • 使用“標記-清除”算法。
  • 關注點是如何縮短垃圾收集時用戶線程的停頓時間。停頓時間短意味着響應速度快,因此它適合於需要與用戶交互的應用。

CMS 運作過程:

  1. 初始標記:標記 GC Roots 能直接關聯到的對象,需要 STW。
  2. 併發標記:進行 GC Roots Tracing 的過程,即可達性分析。
  3. 重新標記:修正併發標記期間引用關係發生變化的那一部分對象的標記記錄,需要 STW。
  4. 併發清除:清除垃圾對象。

CMS 的缺點:

  • 對 CPU 資源非常敏感。併發階段雖然不會導致用戶線程停頓,但是會因爲佔用了一部分線程(或者說 CPU 資源)導致應用程序變慢,總吞吐量會降低。
  • 無法處理浮動垃圾。併發清除階段產生的垃圾稱爲“浮動垃圾”,這部分垃圾只能等下次 GC 再清除。
  • 會產生大量內存碎片。內存碎片過多時會提前觸發 Full GC,CMS 收集器默認會在 Full GC 時開啓內存碎片的合併整理過程。

7、G1 收集器

  • G1:Garbage-First。
  • 是一款面向服務端應用的垃圾收集器。

G1 特點:

  • 並行與併發
  • 分代收集
  • 空間整合:G1 從整體上看是基於“標記-整理”算法,從局部上(兩個 Region 之間)看是基於複製算法。因此,不會產生內存空間碎片。
  • 可預測的停頓:G1 能通過建立可預測的停頓時間模型,讓使用者明確指定在 M 毫秒的時間片段內,消耗在垃圾收集上的時間不得超過 N 毫秒。

Region:
G1 將整個 Java 堆劃分爲多個大小相等的獨立區域(Region),雖然還保留新生代和老年代的概念,但新生代和老年代不再是物理隔離的,而是一部分 Region(不需要連續)的集合。

可預測的時間停頓模型:
G1 之所以能建立可預測的時間停頓模型,是因爲它可以有計劃地避免在整個 Java 堆中進行全區域的垃圾收集。

G1 跟蹤各個 Region 的垃圾堆積的價值大小(回收所獲得的空間大小及所需時間),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的 Region(Garbage-First 名稱的由來)。

G1 運作過程:

  1. 初始標記:標記 GC Roots 能直接關聯的對象,並修改 TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確可用的 Region 中創建新對象。需要 STW。
  2. 併發標記:進行可達性分析。
  3. 最終標記:修正併發標記期間引用關係發生變化的那一部分對象的標記記錄。需要 STW。
  4. 篩選回收:對各個 Region 的回收價值和成本進行排序,根據用戶所期望的 GC 停頓時間制定回收計劃。

五、內存分配與回收策略

1、對象優先在 Eden 分配

  • 大多數情況下,對象在新生代的 Eden 區中分配。
  • 當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC。

2、大對象直接進入老年代

  • 大對象是指需要大量連續內存空間的 Java 對象。
  • 經常出現大對象容易導致內存還有不少空間時,就提前觸發 GC 以獲取足夠的連續空間來安置它們。
  • 由於新生代採用複製算法收集內存,因此爲了避免在 Eden 區及兩個 Survivor 區之間發生大量的內存複製,大對象將直接進入老年代。

3、長期存活的對象進入老年代

  • 虛擬機給每個對象定義了一個對象年齡計數器。
  • 對象在 Eden 出生並經過一次 Minor GC 後仍然存活,並且能被 Survivor 容納的話,將移入 Survivor 中,並且對象年齡設爲 1。
  • 對象在 Survivor 中每“熬過”一次 Minor GC,則年齡加 1,當對象年齡增加到一定程度(默認 15 歲),將會晉升到老年代。

4、動態對象年齡判定

  • 爲了更好地適應不同程序的內存狀況,虛擬機並不要求對象必須達到某個年齡才能晉升老年代。
  • 如果 Survivor 中相同年齡的對象大小總和,大於 Survivor 空間的一半,則大於等於該年齡的對象直接進入老年代。

5、空間分配擔保

  • 當出現大量對象在 Minor GC 後仍然存活的情況,就需要老年代進行分配擔保,讓 Survivor 無法容納的對象直接進入老年代。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章