JVM自動記憶體管理機制—讀這篇就夠了

之前看過JVM的相關知識,當時沒有留下任何學習成果物,有些遺憾。這次重新複習了下,並透過部落格來做下筆記(只能記錄一部分,因為寫部落格真的很花時間),也給其他同行一些知識分享。

Java自動記憶體管理機制包含兩部分:記憶體分配和記憶體回收,要想理解記憶體分配和回收的機制,則需要了解下Java記憶體區域(Java執行時資料區),這篇隨筆將按照下面的線索進行逐步解析:

Java執行時資料區

物件“已死”的判定演算法

垃圾收集演算法

垃圾收集器

結束語

好,接下來我們一一來看。

一、Java執行時資料區

根據《Java虛擬機器規範》的規定,Java虛擬機器所管理的記憶體將會包括如下幾個執行時資料區域

JVM自動記憶體管理機制—讀這篇就夠了

程式計數器:用來記錄當前執行緒所執行的位元組碼指令的行號指示器。位元組碼計時器需要透過改變改值來選取下一條需要執行的位元組碼指定,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個指示器來完成。程式計數器是唯一一個沒有規定任何OutOfMemoryError情況的區域。

Java虛擬機器棧:虛擬機器棧描述的是Java方法執行的記憶體模型,每個方法執行時都會建立一個棧幀用來儲存區域性變量表(存放編譯器可知的各種基本資料型別、物件引用和returnAddress型別,所需的記憶體空間在編譯器完成分配)、運算元棧、動態連結、方法出口等資訊。Java虛擬機器棧有兩種異常情況:OutOfMemoryError(擴充套件時無法申請到足夠記憶體)和StackOverflowError(執行緒請求的棧深度大於虛擬機器所允許的深度)。

本地方法棧:同Java虛擬機器棧類似,只不過Java虛擬機器棧為虛擬機器執行Java方法服務,本地方法棧為虛擬機器使用Native方法服務。HotSpot直接將兩個棧合二為一。也規定了兩種異常:OutOfMemoryError和StackOverflowError。

堆:JVM所管理的記憶體中最大的一塊,也是GC管理的主要區域。理論上所有的物件例項和陣列都要在堆上分配。堆的大小是可以擴充套件的,透過-Xms和-Xms控制,並且堆無法擴充套件的時候就會報OutOfMemoryError異常。

方法區:用來儲存JVM載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是為了和堆區分開來,它也叫Non-Heap(非堆)。方法區無法滿足記憶體分配需求時,報OutOfMemoryError異常。

直接記憶體:並不是虛擬機器執行時資料區的一部分,也不是JVM規範中定義的記憶體區域,但是卻被經常使用。JDK1。4中新加入的NIO類,引入了基於通道和緩衝區的I/O方式,他可以直接分配對外記憶體,以提高效能。不收堆大小的限制,但是會受物理記憶體的約束。也會報OutOfMemoryError異常。

附棧到堆的關聯例子(基於HotSpot):

JVM自動記憶體管理機制—讀這篇就夠了

二、物件“已死”的判定演算法

由於程式計數器、Java虛擬機器棧、本地方法棧都是執行緒獨享,其佔用的記憶體也是隨執行緒生而生、隨執行緒結束而回收。而Java堆和方法區則不同,執行緒共享,是GC的所關注的部分。

在堆中幾乎存在著所有物件,GC之前需要考慮哪些物件還活著不能回收,哪些物件已經死去可以回收。

有兩種演算法可以判定物件是否存活:

)引用計數演算法:給物件中新增一個引用計數器,每當一個地方應用了物件,計數器加1;當引用失效,計數器減1;當計數器為0表示該物件已死、可回收。但是它很難解決兩個物件之間相互迴圈引用的情況。

)可達性分析演算法:透過一系列稱為“GC Roots”的物件作為起點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連(即物件到GC Roots不可達),則證明此物件已死、可回收。Java中可以作為GC Roots的物件包括:虛擬機器棧中引用的物件、本地方法棧中Native方法引用的物件、方法區靜態屬性引用的物件、方法區常量引用的物件。

在主流的商用程式語言(如我們的Java)的主流實現中,都是透過可達性分析演算法來判定物件是否存活的。

三、垃圾收集演算法

1、標記-清除演算法

最基礎的演算法,分標記和清除兩個階段:首先標記處所需要回收的物件,在標記完成後統一回收所有被標記的物件。

它有兩點不足:一個效率問題,標記和清除過程都效率不高;一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片(類似於我們電腦的磁碟碎片),空間碎片太多導致需要分配大物件時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾回收動作。

JVM自動記憶體管理機制—讀這篇就夠了

2、複製演算法

為了解決效率問題,出現了“複製”演算法,他將可用記憶體按容量劃分為大小相等的兩塊,每次只需要使用其中一塊。當一塊記憶體用完了,將還存活的物件複製到另一塊上面,然後再把剛剛用完的記憶體空間一次清理掉。這樣就解決了記憶體碎片問題,但是代價就是可以用內容就縮小為原來的一半。

JVM自動記憶體管理機制—讀這篇就夠了

3、標記-整理演算法

複製演算法在物件存活率較高時就會進行頻繁的複製操作,效率將降低。因此又有了標記-整理演算法,標記過程同標記-清除演算法,但是在後續步驟不是直接對物件進行清理,而是讓所有存活的物件都向一側移動,然後直接清理掉端邊界以外的記憶體。

JVM自動記憶體管理機制—讀這篇就夠了

4、分代收集演算法

當前商業虛擬機器的GC都是採用分代收集演算法,這種演算法並沒有什麼新的思想,而是根據物件存活週期的不同將堆分為:新生代和老年代,方法區稱為永久代(在新的版本中已經將永久代廢棄,引入了元空間的概念,永久代使用的是JVM記憶體而元空間直接使用物理記憶體)。

這樣就可以根據各個年代的特點採用不同的收集演算法。

JVM自動記憶體管理機制—讀這篇就夠了

新生代中的物件“朝生夕死”,每次GC時都會有大量物件死去,少量存活,使用複製演算法。新生代又分為Eden區和Survivor區(Survivor from、Survivor to),大小比例預設為8:1:1。

老年代中的物件因為物件存活率高、沒有額外空間進行分配擔保,就使用標記-清除或標記-整理演算法。

新產生的物件優先進去Eden區,當Eden區滿了之後再使用Survivor from,當Survivor from 也滿了之後就進行Minor GC(新生代GC),將Eden和Survivor from中存活的物件copy進入Survivor to,然後清空Eden和Survivor from,這個時候原來的Survivor from成了新的Survivor to,原來的Survivor to成了新的Survivor from。複製的時候,如果Survivor to 無法容納全部存活的物件,則根據老年代的分配擔保(類似於銀行的貸款擔保)將物件copy進去老年代,如果老年代也無法容納,則進行Full GC(老年代GC)。

大物件直接進入老年代:JVM中有個引數配置-XX:PretenureSizeThreshold,令大於這個設定值的物件直接進入老年代,目的是為了避免在Eden和Survivor區之間發生大量的記憶體複製。

長期存活的物件進入老年代:JVM給每個物件定義一個物件年齡計數器,如果物件在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納,將被移入Survivor並且年齡設定為1。沒熬過一次Minor GC,年齡就加1,當他的年齡到一定程度(預設為15歲,可以透過XX:MaxTenuringThreshold來設定),就會移入老年代。但是JVM並不是永遠要求年齡必須達到最大年齡才會晉升老年代,如果Survivor 空間中相同年齡(如年齡為x)所有物件大小的總和大於Survivor的一半,年齡大於等於x的所有物件直接進入老年代,無需等到最大年齡要求。

四、垃圾收集器

垃圾收集演算法是方法論,垃圾收集器是具體實現。JVM規範對於垃圾收集器的應該如何實現沒有任何規定,因此不同的廠商、不同版本的虛擬機器所提供的垃圾收集器差別較大,這裡只看HotSpot虛擬機器。

JDK7/8後,HotSpot虛擬機器所有收集器及組合(連線)如下:

JVM自動記憶體管理機制—讀這篇就夠了

1。Serial收集器

Serial收集器是最基本、歷史最久的收集器,曾是新生代手機的唯一選擇。他是單執行緒的,只會使用一個CPU或一條收集執行緒去完成垃圾收集工作,並且它在收集的時候,必須暫停其他所有的工作執行緒,直到它結束,即“Stop the World”。停掉所有的使用者執行緒,對很多應用來說難以接受。比如你在做一件事情,被別人強制停掉,你心裡奔騰而過的“羊駝”還數的過來嗎?

儘管如此,它仍然是虛擬機器執行在client模式下的預設新生代收集器:簡單而高效(與其他收集器的單個執行緒相比,因為沒有執行緒切換的開銷等)。

工作示意圖:

JVM自動記憶體管理機制—讀這篇就夠了

2。ParNew收集器

ParNew收集器是Serial收集器的多執行緒版本,除了使用了多執行緒之外,其他的行為(收集演算法、stop the world、物件分配規則、回收策略等)同Serial收集器一樣。

是許多執行在Server模式下的JVM中首選的新生代收集器,其中一個很重還要的原因就是除了Serial之外,只有他能和老年代的CMS收集器配合工作。

工作示意圖:

JVM自動記憶體管理機制—讀這篇就夠了

3。Parallel Scavenge收集器

新生代收集器,並行的多執行緒收集器。它的目標是達到一個可控的吞吐量(就是CPU執行使用者程式碼的時間與CPU總消耗時間的比值,即 吞吐量=行使用者程式碼的時間/[行使用者程式碼的時間+垃圾收集時間]),這樣可以高效率的利用CPU時間,儘快完成程式的運算任務,適合在後臺運算而不需要太多互動的任務。

4。Serial Old收集器

Serial 收集器的老年代版本,單執行緒,“標記整理”演算法,主要是給Client模式下的虛擬機器使用。

另外還可以在Server模式下:

JDK 1。5之前的版本中雨Parallel Scavenge 收集器搭配使用

可以作為CMS的後背方案,在CMS發生Concurrent Mode Failure是使用

工作示意圖:

JVM自動記憶體管理機制—讀這篇就夠了

5。Parallel Old收集器

Parallel Scavenge的老年代版本,多執行緒,“標記整理”演算法,JDK 1。6才出現。在此之前Parallel Scavenge只能同Serial Old搭配使用,由於Serial Old的效能較差導致Parallel Scavenge的優勢發揮不出來,尷了個尬~~

Parallel Old收集器的出現,使“吞吐量優先”收集器終於有了名副其實的組合。在吞吐量和CPU敏感的場合,都可以使用Parallel Scavenge/Parallel Old組合。組合的工作示意圖如下:

JVM自動記憶體管理機制—讀這篇就夠了

6。CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器,停頓時間短,使用者體驗就好。

基於“標記清除”演算法,併發收集、低停頓,運作過程複雜,分4步:

)初始標記:僅僅標記GC Roots能直接關聯到的物件,速度快,但是需要“Stop The World”

)併發標記:就是進行追蹤引用鏈的過程,可以和使用者執行緒併發執行。

)重新標記:修正併發標記階段因使用者執行緒繼續執行而導致標記發生變化的那部分物件的標記記錄,比初始標記時間長但遠比並發標記時間短,需要“Stop The World”

)併發清除:清除標記為可以回收物件,可以和使用者執行緒併發執行

由於整個過程耗時最長的併發標記和併發清除都可以和使用者執行緒一起工作,所以總體上來看,CMS收集器的記憶體回收過程和使用者執行緒是併發執行的。

工作示意圖:

JVM自動記憶體管理機制—讀這篇就夠了

CSM收集器有3個缺點:

)對CPU資源非常敏感

併發收集雖然不會暫停使用者執行緒,但因為佔用一部分CPU資源,還是會導致應用程式變慢,總吞吐量降低。

CMS的預設收集執行緒數量是=(CPU數量+3)/4;當CPU數量多於4個,收集執行緒佔用的CPU資源多於25%,對使用者程式影響可能較大;不足4個時,影響更大,可能無法接受。

)無法處理浮動垃圾(在併發清除時,使用者執行緒新產生的垃圾叫浮動垃圾),可能出現“Concurrent Mode Failure”失敗。

併發清除時需要預留一定的記憶體空間,不能像其他收集器在老年代幾乎填滿再進行收集;如果CMS預留記憶體空間無法滿足程式需要,就會出現一次“Concurrent Mode Failure”失敗;這時JVM啟用後備預案:臨時啟用Serail Old收集器,而導致另一次Full GC的產生;

)產生大量記憶體碎片:CMS基於“標記-清除”演算法,清除後不進行壓縮操作產生大量不連續的記憶體碎片,這樣會導致分配大記憶體物件時,無法找到足夠的連續記憶體,從而需要提前觸發另一次Full GC動作。

7。G1收集器

G1(Garbage-First)是JDK7-u4才正式推出商用的收集器。G1是面向服務端應用的垃圾收集器。它的使命是未來可以替換掉CMS收集器。

G1收集器特性:

並行與併發:能充分利用多CPU、多核環境的硬體優勢,縮短停頓時間;能和使用者執行緒併發執行。分代收集:G1可以不需要其他GC收集器的配合就能獨立管理整個堆,採用不同的方式處理新生物件和已經存活一段時間的物件。空間整合:整體上看採用標記整理演算法,區域性看採用複製演算法(兩個Region之間),不會有記憶體碎片,不會因為大物件找不到足夠的連續空間而提前觸發GC,這點優於CMS收集器。可預測的停頓:除了追求低停頓還能建立可以預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不超N毫秒,這點優於CMS收集器。

為什麼能做到可預測的停頓?

是因為可以有計劃的避免在整個Java堆中進行全區域的垃圾收集。G1收集器將記憶體分大小相等的獨立區域(Region),新生代和老年代概念保留,但是已經不再物理隔離。G1跟蹤各個Region獲得其收集價值大小,在後臺維護一個優先列表;每次根據允許的收集時間,優先回收價值最大的Region(名稱Garbage-First的由來);這就保證了在有限的時間內可以獲取儘可能高的收集效率。

物件被其他Region的物件引用了怎麼辦?

JVM自動記憶體管理機制—讀這篇就夠了

判斷物件存活時,是否需要掃描整個Java堆才能保證準確?在其他的分代收集器,也存在這樣的問題(而G1更突出):新生代回收的時候不得不掃描老年代?無論G1還是其他分代收集器,JVM都是使用Remembered Set來避免全域性掃描:每個Region都有一個對應的Remembered Set;每次Reference型別資料寫操作時,都會產生一個Write Barrier暫時中斷操作;然後檢查將要寫入的引用指向的物件是否和該Reference型別資料在不同的Region(其他收集器:檢查老年代物件是否引用了新生代物件);如果不同,透過CardTable把相關引用資訊記錄到引用指向物件的所在Region對應的Remembered Set中;進行垃圾收集時,在GC根節點的列舉範圍加入Remembered Set,就可以保證不進行全域性掃描,也不會有遺漏。

JVM自動記憶體管理機制—讀這篇就夠了

不計算維護Remembered Set的操作,回收過程可以分為4個步驟(與CMS較為相似):

)初始標記:僅僅標記GC Roots能直接關聯到的物件,並修改TAMS(Next Top at Mark Start)的值,讓下一階段使用者程式併發執行時能在正確可用的Region中建立新物件,需要“Stop The World”

)併發標記:從GC Roots開始進行可達性分析,找出存活物件,耗時長,可與使用者執行緒併發執行

)最終標記:修正併發標記階段因使用者執行緒繼續執行而導致標記發生變化的那部分物件的標記記錄。併發標記時虛擬機器將物件變化記錄線上程Remember Set Logs裡面,最終標記階段將Remember Set Logs整合到Remember Set中,比初始標記時間長但遠比並發標記時間短,需要“Stop The World”

)篩選回收:首先對各個Region的回收價值和成本進行排序,然後根據使用者期望的GC停頓時間來定製回收計劃,最後按計劃回收一些價值高的Region中垃圾物件。回收時採用複製演算法,從一個或多個Region複製存活物件到堆上的另一個空的Region,並且在此過程中壓縮和釋放記憶體;可以併發進行,降低停頓時間,並增加吞吐量。

工作示意圖:

JVM自動記憶體管理機制—讀這篇就夠了