内存回收算法与 Hot Spot 算法实现细节
文章目錄
- 內存回收算法概述
- 對象存活判定算法
- 引用計數算法
- 可達性分析算法
- 垃圾收集算法
- 分代收集理論
- 標記-清除算法
- 標記-復制算法
- 半區復制算法
- Appel 式復制算法
- Appel 式復制算法的逃生門設計
- 標記-整理算法
- HotSpot 虛擬機實現細節
- GC Root 枚舉
- Hot Spot 實現 GC Root 枚舉
- 安全點與安全區域
- 安全點
- 安全點實際應用
- 安全區域
- 安全點與安全區域在執行 GC 如何發揮作用
- 記憶集與卡表
- 卡表的維護時機
- 并發的可達性分析
- 三色標記法
- 用戶線程與 GC 線程并發并發問題原理分析
- 增量更新
- 原始快照
- 并發的可達性分析解決方案總結
內存回收算法概述
內存回收算法,主要包括對象存活判定算法和垃圾收集算法。
對象存活判定算法
對象存活判斷,即判斷一個對象是否還處于存活狀態。解決的是哪些內存需要回收的問題
- 程序中存在有效引用的對象,即還可能被使用到的對象,稱之為存活的對象。
- 程序中不存在有效引用的對象,即已經不能再被使用到的對象,稱之為死亡的對象。
對象存活判定算法主要有引用計數算法和可達性分析算法兩種
引用計數算法
比較簡單粗暴的對象存活判定算法,算法的主要邏輯為:
在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就 +1;每當引用失效時,計數器值就 -1;任何時刻計數器值為 0 的對象就是不可能再被使用的。但是,如果出現了循環引用的變量,引用計數法是沒有辦法正確地判斷它們的存活與否的。舉例說明:
public class TestObject {Object obj; } public void method(String[] args) {TestObject a = new TestObject();TestObject b = new TestObject();// a 和 b 相互循環引用a.obj = b;b.obj = a; }在上面的例子中,兩個對象相互循環引用,如果采用引用計數算法,那么在方法結束后兩個對象的計數器值也都不為 0,所以它們永遠也沒辦法被判定為死亡的對象,對應的內存空間也永遠沒辦法回收掉。在這種情況下,就發生了內存泄漏。
可達性分析算法
目前主流的對象存活判定算法,算法的主要邏輯為:
通過一系列 GC Roots 對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑稱為**引用鏈**,如果某個對象到 GC Roots 間沒有任何引用鏈相連,則說明從 GC Roots 到這個對象之間是不可達的,即證明這個對象不可能再被使用。在 Java 計數體系中,固定可以作為 GC Roots 的對象包括以下幾種:
- 在 JVM 棧的棧幀的本地變量表中引用的對象,即正在運行的方法中所使用的局部變量。
- 類靜態變量
- JDK 自帶的類加載器
- Native 方法引用的對象
- 被 synchronized 關鍵字修飾的對象
- 正在運行的線程對象
垃圾收集算法
在解決了哪些內存需要回收的問題之后,現在來看看如何回收的問題。顯然,垃圾收集算法,就是解決如何回收的問題的。
分代收集理論
分代收集理論,就是將需要回收的區域,劃分成不同的子區域之后,在不同的子區域采用不同的收集策略。
分代收集理論建立在三個分代假說上:
- 弱分代假說:絕大多數對象都是朝生夕死的
- 強分代假說:熬過越多次垃圾收集過程的對象就越難以消亡
- 跨代引用假說:跨代引用相對于同代引用來說僅占極少數
這個理論奠定了很多垃圾回收器的共同設計原則:
收集器應該將 Java 堆劃分出不同的區域,然后將回收對象依據其年齡(對象幸存下來的垃圾收集次數),分配到不同的區域之中存儲標記-清除算法
標記-清除(Mark-Sweep)算法,是比較常用的垃圾收集算法。算法的執行流程正如其名,分為標記和清除兩個過程。
- 首先標記出所有需要回收的對象
- 回收所有被標記的對象
當然,也可以標記存活的對象,再回收未被標記的對象。算法的思想比較簡單,但是可能存在兩個問題:
- 執行效率不穩定:假設某次執行回收的過程中,需要標記的元素特別多,那么相應的,標記和清除的執行效率都會大幅下降。
- 內存空間碎片化的問題:標記和清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致以后程序運行過程中需要分配大對象時,無法找到足夠的連續內存空間而不得不提前觸發一次新的垃圾收集動作。
標記-復制算法
標記-復制算法,通常被簡稱為復制算法。目前,復制算法通常分為半區復制算法,和 Appel 式復制算法
半區復制算法
半區復制算法,是最基本的復制算法。其算法的主要流程為:
- 將內存區域根據容量劃分為大小相等的兩塊,每次分配內存只使用其中的一半(即將內存分為正在使用和備用兩塊)
- 如果正在使用的這一半內存放滿了,執行垃圾回收
- 將還存活的對象復制到備用塊上面
- 將正在使用的這一塊內存空間一全清理掉
- 將備用塊和正在使用塊的身份對調,即由當前的備用塊負責接下來的內存分配
半區復制算法簡單高效,但是存在一個巨大的問題,就是每次只使用一半的內存,對于內存空間的浪費太嚴重了。
Appel 式復制算法
針對搬去復制算法的空間浪費問題,后續開發了一種新的復制算法,即 Appel 式復制算法。算法的主要流程為:
- 將內存區域根據容量劃分為一塊較大的 Eden 區,和兩塊較小的且大小相等的 Survivor 區,每次分配內存只是用 Eden 區和其中一塊 Survivor 區
- 當 Eden 區空間放滿時,執行垃圾回收
- 將 Eden 區和正在使用的那一塊 Survivor 區的存活對象一次性復制到另一塊 Survivor 區
- 直接清理掉 Eden 區和已經使用過的那塊 Survivor 區
- 將兩塊 Survivor 區的身份對調,即由上次未使用的那塊 Survivor 區和 Eden 區一起負責接下來的內存分配
Appel 式復制算法的逃生門設計
目前 HotSpot 虛擬機默認的 Eden 區和 Survivor 區的比例為 8 : 1,即每次新生代中可用內存空間為整個新生代的 90%。
這種內存布局情況下,可能會出現空閑的 Survivor 區的容量不足以容納 Eden 區和正在使用的 Survivor 區剩余存活的對象容量總和的情況。
針對這種情況,這個算法還有一個類似逃生門的安全設計:如果發生這種情況,則會將這些這些對象直接移動到其他區域。在 HotSpot 的實現中,會將對象直接移動到老年代中,這就是 HotSpot 中的老年代空間分配擔保機制。
標記-整理算法
回顧標記-清除和復制算法,發現兩者都有缺點:
- 標記-清除算法最嚴重的問題就是可能會導致大量空間碎片
- 復制算法,如果采用半區復制,那么會浪費一半的空間;如果采用 Appel 式復制算法,那么還需要有額外的空間擔保來應對存活對象容量大于空閑 Survivor 區容量的極端情況
基于強分代假說,兩種方案似乎都不是很合適用來清理存放存活年齡較長的對象的區域。標記-整理算法應運而生,其主要流程為:
- 標記的過程仍然與標記-清除算法一致
- 標記完成后,讓所有存活的對象都向內存空間的一端進行移動
- 然后直接清理掉邊界以外的內存
可以看出來,標記-整理和標記-清除算法的本質差異在于,標記-整理是一種移動式的回收算法,而標記-清除是非移動的。
但是移動式有一個非常大的問題:在移動過程中需要暫停所有用戶線程(STW),相當于增加了每次收集時處于 STW 的時間
為了解決這個問題,可以在使用一次或幾次標記-清除后,再進行一次標記-整理,這種做法(相當于將兩個方案進行了整合)在一定程度上兼顧了兩種方案的優點。
HotSpot 虛擬機實現細節
HotSpot 是目前使用比較廣泛的虛擬機,它對于內存回收算法的實現細節主要有:
- GC Root 枚舉
- 安全點與安全區域
- 記憶集與卡表
- 并發的可達性分析
GC Root 枚舉
我們知道實施垃圾清理算法的前提是判定對象是否存活(對象存活判定算法),而 Hot Spot 虛擬機中使用的是可達性分析算法來判定對象存活的。而使用可達性分析算法來作為對象存活判定算法,那么首先需要知道哪些對象是 GC Root,再從這些 GC Root 出發,去尋找引用可達的對象
那么引發出來一個問題:Hot Spot 虛擬機是如何找到所有的 GC Root 并進行遍歷枚舉的呢?
Hot Spot 實現 GC Root 枚舉
Hot Spot 虛擬機使用的是一種叫做 Oop Map 的結構來進行 GC Root(根節點枚舉)的。意思就是說,Hot Spot 虛擬機會把所有的根節點都放到 Oop Map 中,等要執行 GC 時,會從 Oop Map 中把所有的 GC Root 遍歷一遍,這樣就可以完成所有對象的可達性分析了。
Oop Map 的維護時機有以下幾種:
- JVM 開始初始化類加載器時:此時會把類加載器對象加入到 Oop Map 中
- 類被加載的時候:此時類中的靜態變量將會被加入到 Oop Map 中
- 類被卸載的時候:此時類中的靜態變量將會從 Oop Map 中移除
- 到達安全點/安全區域時:此時會把當前虛擬機棧/本地方法棧的運行中的棧幀中的局部變量對象加入到 Oop Map 中,或者把已經銷毀的棧幀(已經退出執行的方法)中的局部變量對象從 Oop Map 中移除
- 開啟并運行一個線程時(調用一個線程的 start() 方法)時:此時會將這個運行的線程對象加入到 Oop Map 中
- 一個線程結束運行時:此時會將這個運行的線程對象從 Oop Map 中移除
安全點與安全區域
安全點
安全點,即程序運行到這個位置時,引用關系一般來說會發生變化的代碼位置,所以在。在安全點可以停下來維護 Oop Map。
也正因為安全點是停下來維護 Oop Map 的位置,所以安全點也是停下來執行 GC 的位置。
常見的安全點有:
- 開始進行方法調用時
- 單次循環結束時
- 異常被捕捉時
- 方法執行結束返回時
安全點實際應用
由上一節我們知道,安全點是停下來維護 Oop Map 的位置,也是所有用戶線程停下來執行 GC 的位置。那么在要執行 GC 時,如何讓所有線程都跑到最近的安全點然后暫停下來呢?有下面兩種方案可以解決:
- 搶先式中斷:在需要執行 GC 時,JVM 把所有用戶線程全部暫停,然后逐個檢查線程是否暫停在安全點上
- 如果發現線程正好暫停在安全點上,那么不用再做額外處理
- 如果發現線程沒有暫停在安全點上,那么就恢復這個線程的執行,一直到最近一個安全點,然后再進行暫停
- 當所有線程都暫停處理完畢之后,開始執行 GC
- 當 GC 執行結束后,喚醒所有暫停的線程
- 主動式中斷:當需要執行 GC 時,JVM 先簡單設置一個標記位(標記為當前需要執行 GC)
- 每個線程在執行過程中會去不斷地輪詢這個標志位
- 一旦發現這個標志位已經標記為當前需要執行 GC,那么就繼續執行到離當前最近的一個安全點把自己給暫停
- 當 JVM 發現所有線程都已經暫停后,開始執行 GC
- 當 GC 執行結束后,喚醒所有暫停的線程,并將標志位還原
安全區域
某一段代碼片段中,當前線程的對象引用關系不會發生變化,也正因為如此,在這個區域中的任何地方開始執行垃圾收集都是安全的,這樣的代碼片段就叫做安全區域。
安全區域的引入,主要時為了解決當要執行 GC 時,某些線程可能正在睡眠或者被阻塞,那么這些線程將無法正常響應 GC 相關動作。
安全區域的主要工作原理為:
- 當用戶線程執行到進入安全區域的代碼時,首先標識自己已經進入了安全區域(進入阻塞或者睡眠狀態一定是進入了安全區域)
- 當要執行 GC 時,如果發現某些線程正處于睡眠或阻塞狀態,或者正在安全區域時,將直接跳過不進行處理
- 當這些安全區域中的線程要離開安全區域時,首先判斷當前是否處于需要全部線程暫停(STW)的狀態
- 如果是,那么將會一直等到 JVM 釋放可以離開安全區域的信號(STW 結束),才會離開安全區域繼續執行后面的代碼
- 如果不是,那么直接離開安全區域繼續執行后面的代碼
安全點與安全區域在執行 GC 如何發揮作用
任意時刻,用戶線程只可能會有兩種運行狀態:
- 正在運行:處于 Running 狀態的線程
- 非正在運行:處于 Waiting、Timed Wait、Blocked 狀態的線程
所以,在執行 GC 時,這兩種狀態的用戶線程分別的行為是:
- 正在運行狀態的線程,發現 GC 標志位為需要執行 GC 時,會運行到到最近的一個安全點或者安全區域,然后阻塞自己
- 非正在運行狀態的線程,一定處于安全區域,如果在正在執行 GC 時自身的阻塞狀態結束,又重新恢復了運行
- 此時會先判斷標志位是否為需要執行 GC,如果是,那么將繼續阻塞一直到收到 JVM 釋放可以離開安全區域的信號(STW 結束)
記憶集與卡表
為了解決跨代引用的問題,垃圾收集器在新生代中創建了一個名叫記憶集的數據結構,用以避免根節點枚舉的時候把整個老年代對象都掃描一遍。
記憶集是一種用于記錄從非收集區域指向收集區域的指針集合的抽象數據結構。在 Hot Spot 虛擬機中,是采用卡表去實現的。
卡表的記錄精度為精確到一塊內存區域,該區域內的對象是否包含有跨代指針,如果包含,則該卡表的標志置為 dirty。
所以在執行 GC 時,只要篩選出哪些卡表的標志位是 dirty,接著把這些卡表所對應的老年代區域中的所有對象都加入根節點枚舉即可解決跨代引用問題。
卡表的維護時機
Hot Spot 虛擬機通過寫屏障來維護卡表狀態。寫屏障,即在把每一個賦值操作,都用一個中間層給包起來(可以參考代理模式的設計思想),這個中間層的結構就叫寫屏障。寫屏障的偽代碼示意如下所示:
void proxyFieldAssign(Object value) {//執行實際的賦值操作actualFieldAssign(Object value);//檢查卡表是否需要更新writeBarrierForCheckCardTable(); }可以看到,真正的賦值操作被代理了,而這個代理的結構內,就執行了維護卡表的邏輯。這就是寫屏障的作用。
并發的可達性分析
在執行 GC 之前,我們需要把所有用戶線程都在安全點上,或安全區域內停下來,然后再讓 GC 線程在進行可達性分析。換句話說,GC 線程在工作之前,必須先讓所有用戶線程都必須阻塞在一個引用關系不會發生變化的一致性快照上,然后 GC 線程再開始進行可達性分析,這看起來好像是一個必然的事情。
原因也很容易理解,因為如果在 GC 線程進行可達性分析的同時,用戶線程也在運行,那么很可能會出現對象存活狀態判定錯誤,導致 GC 機制出現嚴重 Bug。
例如,GC 線程在判定一個對象已經死亡后,如果用戶線程又對象進行了有效的引用,那么就會出現錯誤。
所以,從常規的角度來看,用戶線程和 GC 線程,是沒有辦法并發執行的。
所以在常規的垃圾收集器中,用戶線程和 GC 線程在同一時間肯定是只有一個是處于正在工作的狀態的。這也就導致了常規的垃圾收集器的垃圾收集停頓時間往往比較高。
所以,為了減少垃圾收集停頓時間,必須得想辦法強行讓用戶線程和 GC 線程并發執行,那么這個時候有沒有什么解決辦法呢?
三色標記法
為了幫助理解上述問題的解決方案,我們引入一個三色標記法來進行輔助推導。三色標記法把參與 GC 過程中的所有對象,都分為三種顏色:
- 白色:表示尚未被垃圾收集器分析過。即表示尚未被垃圾收集器訪問的對象
- 灰色:表示對象已經被垃圾收集器分析過,但是在這個對象中還至少存在一個引用沒有被分析過。即表示被垃圾收集器訪問過,但是還沒有被訪問透徹的對象
- 黑色:表示對象已經被垃圾收集器分析過,而且這個對象中的所有引用都已經被分析過了。即表示被垃圾收集器訪問透徹的對象
從上面的定義可以得出:
- 在 GC 可達性分析的初始階段,所有對象都應該是白色的
- 在一個對象被垃圾收集器分析了一半的時候,這個對象的顏色是灰色的,表示從 GC Root 到這個對象的引用路徑是可達的,但是這個對象內部的所有引用還沒有被分析完成,處于一個分析半成品的狀態
- 在一個對象被垃圾收集器完全分析之后,這個對象的顏色是黑色的,表示從 GC Root 到這個對象的引用路徑是可達的,即代表這個對象處于存活狀態
- 當一個灰色對象,被垃圾收集器訪問完全后,將會變成黑色
- 可達性分析結束后,仍處于白色狀態的對象,表示從 GC Root 到這些對象的引用路徑是不可達的,代表這些對象都已經處于非存活狀態
用戶線程與 GC 線程并發并發問題原理分析
如果 GC 線程在進行可達性分析時,用戶線程也在同時(并發地)執行,可能會導致下面的問題:
- 把死亡的對象標記為存活(白色的對象誤標成黑色):這是一種可以容忍的錯誤。這種對象稱為浮動垃圾,雖然是誤判,但是并不會直接引發問題。在后面的垃圾收集過程中,這個對象還是有機會被清理掉的。
- 把存活的對象標記為死亡(黑色的對象誤標成白色):這是不能容忍的錯誤。這種現象一旦出現,幾乎肯定會出現問題,直接引發非常致命的問題
通過上面的兩種情況,我們可以知道只有出現把存活的對象標記為死亡這種情況,我們才需要進行處理。進一步分析,只有在同時出現下面兩種場景,才會出現這種問題:
- 將灰色對象對于一個白色對象的引用全部刪除(條件 A)
- 重新插入一條從黑色對象到這個白色對象的引用(條件 B)
解決方案有兩種,分別是增量更新和原始快照
增量更新
增量更新(Incremental Update)要破壞的是條件 B。
當黑色對象插入新的對于白色對象的引用關系時,垃圾收集器將會把這個新插入的引用給記錄下來。等并發掃描結束后,再重新以這個黑色對象為根,重新掃描一遍。這個重新掃描的過程通常都需要 STW(例如 CMS 的重新標記階段, CMS remark)。
可以簡單地理解為如果出現了條件 B,那么這個黑色對象就變成了灰色對象,在并發結束后統一將這些灰色對象再重新掃描一遍。
原始快照
原始快照(Snapshot At The Beginning)要破壞的是條件 A。
當灰色對象要刪除對于白色對象的引用關系時,垃圾收集器將會把這個要刪除的引用關系給記錄下來。等并發掃描結束后,再將剛剛記錄的這個刪除的引用關系的根對象(即當時刪除應用的那個灰色對象)為根,按照記錄下來的被刪除的引用關系重新掃描一次。這個重新掃描的過程通常都需要 STW。
由上面的描述可以看出,當首次并發掃描結束后,原始快照機制開始發生作用時,垃圾收集器將會按照刪除應用之前的引用關系(當時的引用快照)再次進行掃描,這也意味著刪除之前的對象引用圖中的對象將肯定會被重新掃描到,相當于在刪除引用關系的那一刻,這些對象就都被標成了黑色。
可以簡單地理解為如果出現了條件 A,那么被刪除的白色對象以及白色對象后面的引用圖上的所有對象都被標成了黑色
并發的可達性分析解決方案總結
從上面我們可以得出一個結論:解決并發的可達性分析的核心思路,就是寧可多標(存活的對象),不能少標(存活的對象);重新掃描的過程通常需要 STW。
總結
以上是生活随笔為你收集整理的内存回收算法与 Hot Spot 算法实现细节的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 如何让服务器端持续监听客户端的请求?
- 下一篇: socket缓冲区以及阻塞模式详解