Java記憶體模型及GC演算法

GaoJHIT發表於2020-10-12

Java記憶體模型及GC演算法

前言

學習記錄使用,原文:https://www.juejin.im/post/6874867748120297480

java記憶體模型

在這裡插入圖片描述

提升響應速度和吞吐量為目標的效能優化的關鍵就在java堆和垃圾回收器。

堆和棧的記憶體分配

  • Stack(棧)是JVM的記憶體指令區,順序分配,記憶體大小定長,速度很快;
  • Heap(堆)是JVM的記憶體資料區,分配不定長的記憶體空間;

靜態和非靜態方法的記憶體分配
非靜態方法在呼叫前,必須先new一個物件例項,獲得Stack中的地址指標,否則JVM將無法將隱含引數傳給非靜態方法。
靜態方法,只要class檔案被ClassLoader load進入JVM的Stack,該靜態方法即可被呼叫。當然此時靜態方法是獲取不到Heap中的物件屬性的。
前面提到物件例項及動態屬性都是儲存在Heap中,而Heap必須通過Stack中的地址指標才能夠被指令(類的方法)訪問到。因此可以推斷出:靜態屬性是儲存在Stack中的,而不同於動態屬性儲存在Heap中。正因為都是在Stack中,而Stack中指令和資料都是定長的,因此很容易算出偏移量,也因此不管什麼指令(類的方法),都可以訪問到類的靜態屬性。也正因為靜態屬性被儲存在Stack中,所以具有了全域性屬性。
在JVM中,靜態屬性儲存在Stack指令區記憶體區,動態屬性儲存在Heap資料記憶體區。
當一個class檔案被ClassLoader load進入JVM後,方法指令儲存在Stack中,此時Heap區沒有資料,然後程式計數器開始執行指令。如果,是靜態方法,直接依次執行指令程式碼,當然此時指令程式碼是不能訪問Heap資料區的;
如果是非靜態方法,由於隱含引數沒有值,會報錯。因此在非靜態方法執行前,要先new物件,在Heap中分配資料,並把Stack中的地址指標交給非靜態方法,這樣程式計數器依次執行指令,而指令程式碼此時能夠訪問到Heap資料區了。

  • 非靜態方法有一個隱含的傳入引數,該引數是JVM給它的;
  • 靜態方法無此隱含引數,因此也不需要new物件;
  • 靜態屬性和動態屬性;
  • 方法載入過程;

JVM記憶體模型

在這裡插入圖片描述

  1. 程式計數器
    程式計數器是用於儲存每個執行緒下一步將執行的jvm指令,如該方法為native的,則程式計數器中不儲存任何資訊。
  2. JVM棧(JVM Stack)
    JVM棧是執行緒私有的,每個執行緒建立的同時都會建立JVM棧,JVM棧中存放的為當前執行緒中區域性基本型別的變數(java中定義的八種基本型別:boolean、char、byte、short、int、long、float、double)、部分的返回結果以及Stack Frame,非基本型別的物件在JVM棧上僅存放一個指向堆上的地址。
  3. 堆(Heap)
    它是JVM用來儲存物件例項以及陣列值的區域,可以認為Java中所有通過new建立的物件的記憶體都在此分配,Heap中的物件的記憶體需要等待GC進行回收。
    (1)堆是JVM中所有執行緒共享的,因此在其上進行物件記憶體的分配均需要進行加鎖,這也導致了new物件的開銷是比較大的。
    (2)Sun Hotspot JVM為了提升物件記憶體分配的效率,對於所建立的執行緒都會分配一塊獨立的空間TLAB(Thread Local Allocation Buffer),其大小由JVM根據執行的情況計算而得,在TLAB上分配物件時不需要加鎖,因此JVM在給執行緒的物件分配記憶體時會盡量的在TLAB上分配,在這種情況下JVM中分配物件記憶體的效能和C基本是一樣高效的,但如果物件過大的話則依然是直接使用堆空間分配的。
    (3)TLAB僅作用於新生代的Eden Space,因此在編寫java程式時,通多多個小的物件比大的物件分配起來更加高效。
  4. 方法區(Method Area)
    (1)在Sun JDK中這塊區域物件的為PermanetGeneration,又稱為持久代。
    (2)方法區域存放了所載入的類資訊(名稱、修飾符等)、類中的靜態變數、類中定義為final型別的常量、類中的方法資訊,當開發人員在程式中通過Class物件中的getName、isInterface等方法來獲取時,這些資料都來源於方法區域,同時方法區域也是全域性共享的,在一定的條件下它也會被GC,當方法區域需要使用的記憶體超過其允許的大小時,會丟擲OutOfMemory的錯誤資訊。
  5. 本地方法棧(Native Method Stacks)
    JVM採用本地方法棧來支援native發放的執行,此區域用於儲存每個native方法呼叫的狀態。
  6. 執行時常量池(Runtiem Constant Pool)
    存放的為類中的固定的常量資訊、方法和Field的引用資訊等,其空間從方法區域中分配。JVM在載入類時會為每個class分配一個獨立的常量池,但是執行時常量池中的字串常量池是全域性共享的。

JVM堆記憶體(Heap)

JVM將堆分成了二個大區:新生代(Young)和老年代(Old),新生代又被進一步劃分為Eden和Survivor區,而Survivor由FromSpace(S0)和ToSpace(S1)組成。Young中的98%的物件都是朝生夕死,所以將記憶體分為一塊較大的Eden和兩塊較小的Survivor0、Survivor1,JVM預設的分配比例是8:1:1,每次呼叫Eden和其中的Survivor0(FromSpace),當發生回收的時候,將Eden和Survivor0(FromSpace)存貨的物件複製到Survivor1(ToSpace),然後直接清理掉Eden和Survivor0的空間。
堆模型圖如下:
在這裡插入圖片描述

新生代GC(Minor GC):
新生代通常存活時間較短,基於Copying演算法進行回收,所謂Copying演算法就是掃描存活的物件,並複製到一塊新的完全未使用的空間中,對應於新生代,就是在Eden和FromSpace或ToSpace之間copy。新生代採用空閒指標的方式來控制GC出發,指標保持最後一個分配的物件在新生代區間的位置,當有新的物件要分配記憶體時,用於檢查空間是否足夠,不夠就觸發GC。當連續分配物件時,物件會逐漸從Eden到Survivor,最後到老年代。
老年代的GC(Major GC/Full GC):
老年代與新生代不同,老年代物件存活的時間比較長、比較穩定,因此採用標記(Mark)演算法來進行回收,所謂標記就是掃面出存活的物件,然後再進行回收未被標記的物件,回收後,對於空出的空間要麼進行合併、要麼標記出來便於下次進行分配,總之目的就是要減少記憶體碎片帶來的效率損耗。

垃圾回收演算法

1、Mark-Sweep(標記-清楚演算法)
這是最基礎的垃圾回收演算法,之所以說它是最基礎的是因為它最容易實現,思想也是最簡單的。標記-清除演算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的物件,清除階段就是回收被標記的物件所佔用的空間。
在這裡插入圖片描述

標記-清除演算法實現起來比較容易,但是有一個比較嚴重的問題就是容易產生記憶體碎片,碎片太多可能會導致後續過程中需要為大物件分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作。

2、Copying(複製)演算法
為了解決Mark-Sweep演算法的缺陷,Copying演算法就被提了出來。它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用的記憶體空間一次清理掉,這樣一來就不容易出現記憶體碎片的問題。
在這裡插入圖片描述
這種演算法雖然簡單,執行高效且不容易產生記憶體碎片,但是卻對記憶體空間的使用做出了高昂的代價,因為能夠使用的記憶體縮減到原來的一半。很顯然,Copying演算法的效率跟存活物件的數目多少有很大的關係,如果存活的物件很多,那麼Copying演算法的效率將會大大降低。新生代GC演算法採用的就是這種演算法。
**3、Mark-Compact(標記-整理)演算法
為了解決Copying演算法的缺陷,充分利用記憶體空間,提出了Mark-Compact演算法。該演算法標記階段和Mark-Sweep一樣,但是再完成標記之後,它不是直接清理可回收物件,而是將存活物件都向一端移動,然後清理掉端邊界以外的記憶體。
在這裡插入圖片描述

JVM中老年代GC就是使用的這種演算法,老年代的特點是每次回收都只回收少量物件。

新生代GC:序列GC(SerialGC)、並行回收GC(ParallelScavenge)和並行GC(ParNew)

序列GC:在整個掃描和複製過程採用單執行緒的方式來進行,適用於單CPU、新生代空間較小及對暫停時間要求不是非常高的應用上,是client級別預設的GC方式,可以通過-xx:+UseSeralGC來強制指定。
並行回收GC:在整個掃描和複製過程採用多執行緒的方式來進行,適用於多CPU、對暫停時間要求較短的應用上,是server級別預設採用的GC方式,可用-XX:+UseParallelGC來強制指定,用-XX:ParalleleGCThreads=4來指定執行緒數。
並行GC:與老年代的併發GC配合使用。

老年代GC:序列GC(Serial MSC)、並行GC(Parallel MSC)和併發GC(CMS)。

序列GC(Serial MSC):client模式下的預設GC方式,可通過-XX:+UseSerialGC強制指定。每次進行全部回收,進行Compact,非常耗費時間。
並行GC(Parallel MSC):吞吐量大,但是GC的時候相應很慢:server模式下的預設GC方式,也可用-XX:+UseParallelGC=強制指定。可以在選項後加等號來制定並行的執行緒數。
併發GC(CMS):響應比並行gc快很多,但是犧牲了一定的吞吐量。

CMS垃圾回收演算法

  • CMS滿足對響應時間的重要性需求大於對吞吐量的要求;
  • 應用中存在比較多的長生命週期的物件的應用;
  • CMS用於老年代的回收,目標是儘量減少應用的暫停時間,減少full gc發生的機率,利用和應用程式執行緒併發的垃圾回收執行緒來標記清除老年代。

收集階段

  • 初始標記(Initial Mark)
    (Stop the World Event,所有應用執行緒暫停)
    從root物件開始標記存活的物件。
    暫停時間一般持續時間比較短。
  • 併發標記(Concurrent Marking)
    和Java應用程式執行緒併發執行;
    遍歷老年代的物件圖,標記出活著的物件。
    掃描從被標記的物件開始,直到遍歷完從root可達的所有物件。
  • 再次標記
    (Stop the World Event,所有應用執行緒暫停)
    查詢在併發標記階段漏過的物件,這些物件是在併發收集器完成物件跟蹤之後應用執行緒更新的。
  • 併發清理(Concurrent Sweep)
    回收在標記階段(marking phases)確定為不可達的物件。
    垃圾物件佔用的空間新增到一個空閒列表(free list),供以後的分配使用。死物件的合併可能在此時發生,請注意,存活的物件並沒有被移動。
  • 重置(Restting)
    清理資料結構,為下一個併發收集做準備。

觸發場景

與其他老年代的垃圾回收器相比,CMS在老年代空間佔滿之前就應該開始。
CMS收集會在老年代的空閒時間少於某一個閾值的時候被觸發(這個閾值可以是動態統計出來的,也可以是固定設定的),而實際的回收週期可能要延遲到下一次年輕代的回收。為什麼要這樣,前面已經有解釋了。在某些極端惡劣的情況下,物件會直接在老年代中進行分配,並且CMS回收週期開始的時候,eden區尚有非常多的物件。這個時候初始標記階段會有多於10-100倍的時間消耗。這個通常是因為要分配非常大的物件,幾兆的陣列等。為了儘量避免長時間的暫停,我們需要合理的配置。
啟動CMS設定引數:
> -XX:+UseConcMarkSweepGC

配置固定的CMS啟動閾值:
1、-XX:+UseCMSInitiatingOccupancyOnly
2、-XX:MCSInitiatingOccupancyFraction=70

如果CMS不能夠在老年代清理出足夠的空間,會導致異常,使得JVM臨時啟動Serial Old垃圾回收方式進行回收。這個會造成長時間stop-the-world暫停。全量的GC的原因可能有兩個。
- CMS垃圾回收的速度跟不上
- 老年代中有大量的記憶體碎片
一個導致CMS需要進行全量GC的原因是永久代中的垃圾。預設情況下,CMS是不回收永久代中的垃圾的。如果在你的應用中使用了多個類載入器,或者反射機制,那麼就需要對永久代進行回收。
採用引數-XX:+CMSClassUnloadingEnabled會開啟永久代的垃圾回收。

通過使用以下的選項,可以使得CMS充分利用多核:

  • -XX:+CMSConcurrentMTEnabled 在併發階段,可以利用多核
  • -XX:+ConcGCThreads 指定執行緒數量
  • -XX:+ParallelGCThreads 指定在stop-the-world過程中,垃圾回收的執行緒數,預設是cpu的個數
  • -XX:+UseParNewGC 年輕代採用並行的垃圾回收器

CMS的缺點

  • CMS佔用CPU資源,4個CPU以上才能更好發揮CMS優勢
    CMS併發階段,它不會導致使用者執行緒停頓,但會因為佔用了一部分執行緒(或CPU資源)而導致應用程式變慢,總吞吐量會降低。
    CMS預設啟動的回收執行緒是(CPU數量+3)/4,也就是當CPU在4個以上時,併發回收時垃圾收集執行緒最多佔用不超過25%的CPU資源。但是當CPU不足4個時(比如2個),那麼CMS對使用者程式的影響就可能變得很大,如果CPU負載本來就比較大的時候,還分出一半的運算能力區執行收集器執行緒,就可能導致使用者程式的執行速度忽然降低50%,這也很讓人受不了。
    為了解決這種情況,虛擬機器提供了一種稱為“增量式併發器”(Increnmental Concurrent Mark Sweep/i-CMS)的CMS收集器變種,所做的事情和單CPU年代PC機作業系統使用搶佔式來模擬多工機制的思想一樣,就是在併發標記和併發清理的時候讓GC執行緒、使用者執行緒交替執行,儘量減少GC執行緒的獨佔資源的時間,這樣整個垃圾收集的過程會更長,但對使用者程式的影響就會顯得少一些,速度下降也就沒有那麼明顯,但是目前版本中,i-CMS已經被生命為"deprecated"。
  • 產生浮動垃圾
    CMS收集器無法處理浮動垃圾(Floating Garbage),可能出現"Concurrent Mode Failure"失敗而導致另一次Full GC。
    原因:
    CMS併發清理階段,同時使用者執行緒還在執行著,伴隨程式的執行自然會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在本次收集中處理掉它們,只好等下一次GC時再將其清理掉。
    這一部分垃圾就成為“浮動垃圾"。也是由於在來及收集階段使用者執行緒還需要執行,即還需要預留足夠的記憶體空間給使用者執行緒使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供併發收集時的程式運作使用。
    在預設設定下,CMS收集器在老年代使用了68%的空間後就會被啟用,這是一個偏保守的設定,如果在應用中老年代增長不是太快,可以適當調高引數-XX:CMSInitiatingOccupancyFraction的值來提高出發百分比,以便降低記憶體回收次數以獲取更好的效能。要是CMS執行期間預留的記憶體無法滿足程式需要,就會出現一次”Concurent Mode Failure"失敗,這時候虛擬機器將啟動後備預案:臨時啟用Serial Old收集器來重新機型老年代的垃圾收集 ,這樣停頓時間就很長了。所有說引數-XX:CMSInitiatingOccupancyFraction設定的太高會很容易導致大量"Concurrent Mode Failure"失敗,效能反而降低。
  • 產生大量的空間碎片
    CMS是一款基於“標記-清除”演算法實現的收集器,這意味著手機結束時會產生大量空間碎片。空間碎片過多時,將會給大物件分配帶來很大的麻煩,往往會出現老年代很大的空間剩餘,但是無法找到足夠大的連續空間來分配當前物件,不得不提前觸發一次Full GC.
    為了解決這個問題,CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開關引數,使用者在“享受”完Full GC服務之後額外免費附送一個碎片整理過程,記憶體整理的過程是無法併發的。
    空間碎片問題沒有了,但停頓時間不得不邊長了。虛擬機器設計者們還提供了另外一個引數-XX:CMSFullGCsBeforeCompaction,這個引數用於設定在執行多少次不壓縮的Full GC後,跟著來一次帶壓縮。

相關文章