JVM自動內存管理機制—讀這篇就夠了

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

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

  1. Java運行時數據區
  2. 對象“已死”的判定算法
  3. 垃圾收集算法
  4. 垃圾收集器
  5. 結束語

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

一、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;當引用失效,計數器減1;當計數器為0表示該對象已死、可回收。但是它很難解決兩個對象之間相互循環引用的情況。
  2. )可達性分析算法:通過一系列稱為“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步:

  1. )初始標記:僅僅標記GC Roots能直接關聯到的對象,速度快,但是需要“Stop The World”
  2. )併發標記:就是進行追蹤引用鏈的過程,可以和用戶線程併發執行。
  3. )重新標記:修正併發標記階段因用戶線程繼續運行而導致標記發生變化的那部分對象的標記記錄,比初始標記時間長但遠比並發標記時間短,需要“Stop The World”
  4. )併發清除:清除標記為可以回收對象,可以和用戶線程併發執行

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

工作示意圖:

JVM自動內存管理機制—讀這篇就夠了

CSM收集器有3個缺點:

  1. )對CPU資源非常敏感
  2. 併發收集雖然不會暫停用戶線程,但因為佔用一部分CPU資源,還是會導致應用程序變慢,總吞吐量降低
  3. CMS的默認收集線程數量是=(CPU數量+3)/4;當CPU數量多於4個,收集線程佔用的CPU資源多於25%,對用戶程序影響可能較大;不足4個時,影響更大,可能無法接受。
  4. )無法處理浮動垃圾(在併發清除時,用戶線程新產生的垃圾叫浮動垃圾),可能出現"Concurrent Mode Failure"失敗。
  5. 併發清除時需要預留一定的內存空間,不能像其他收集器在老年代幾乎填滿再進行收集;如果CMS預留內存空間無法滿足程序需要,就會出現一次"Concurrent Mode Failure"失敗;這時JVM啟用後備預案:臨時啟用Serail Old收集器,而導致另一次Full GC的產生;
  6. )產生大量內存碎片: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較為相似):

  1. )初始標記:僅僅標記GC Roots能直接關聯到的對象,並修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時能在正確可用的Region中創建新對象,需要“Stop The World”
  2. )併發標記:從GC Roots開始進行可達性分析,找出存活對象,耗時長,可與用戶線程併發執行
  3. )最終標記:修正併發標記階段因用戶線程繼續運行而導致標記發生變化的那部分對象的標記記錄。併發標記時虛擬機將對象變化記錄在線程Remember Set Logs裡面,最終標記階段將Remember Set Logs整合到Remember Set中,比初始標記時間長但遠比並發標記時間短,需要“Stop The World”
  4. )篩選回收:首先對各個Region的回收價值和成本進行排序,然後根據用戶期望的GC停頓時間來定製回收計劃,最後按計劃回收一些價值高的Region中垃圾對象。回收時採用複製算法,從一個或多個Region複製存活對象到堆上的另一個空的Region,並且在此過程中壓縮和釋放內存;可以併發進行,降低停頓時間,並增加吞吐量。

工作示意圖:

JVM自動內存管理機制—讀這篇就夠了

最後:

給大家分享資深架構師錄製的視頻:(有Spring,MyBatis,Netty源碼分析,高併發、高性能、分佈式、微服務架構的原理,JVM性能優化、分佈式架構)等這些成為架構師必備的內容,還有阿里大牛直播講解技術!

JVM自動內存管理機制—讀這篇就夠了

後臺私信回覆“架構” 就可以免費獲得這些視頻資料!


分享到:


相關文章: