JVM完整詳解:記憶體分配+執行原理+回收演算法+GC引數等

mikechen的網際網路架構發表於2022-01-18

JVM完整詳解:記憶體分配+執行原理+回收演算法+GC引數等-mikechen的網際網路架構

不管是BAT面試,還是工作實踐中的JVM調優以及引數設定,或者記憶體溢位檢測等,都需要涉及到Java虛擬機器的記憶體模型、記憶體分配,以及回收演算法機制等,這些都是必考、必會技能。

JVM記憶體模型

JVM記憶體模型可以分為兩個部分,如下圖所示,堆和方法區是所有執行緒共有的,而虛擬機器棧,本地方法棧和程式計數器則是執行緒私有的。

JVM完整詳解:記憶體分配+執行原理+回收演算法+GC引數等-mikechen的網際網路架構

 

1. 堆(Heap)

堆記憶體是所有執行緒共有的,可以分為兩個部分:年輕代和老年代。下圖中的Perm代表的是永久代,但是注意永久代並不屬於堆記憶體中的一部分,同時jdk1.8之後永久代也將被移除。

JVM完整詳解:記憶體分配+執行原理+回收演算法+GC引數等-mikechen的網際網路架構

堆是java虛擬機器所管理的記憶體中最大的一塊記憶體區域,也是被各個執行緒共享的記憶體區域,該記憶體區域存放了物件例項及陣列(但不是所有的物件例項都在堆中)。

其大小通過-Xms(最小值)和-Xmx(最大值)引數設定(最大最小值都要小於1G),前者為啟動時申請的最小記憶體,預設為作業系統實體記憶體的1/64,後者為JVM可申請的最大記憶體,預設為實體記憶體的1/4,預設當空餘堆記憶體小於40%時,JVM會增大堆記憶體到-Xmx指定的大小,可通過-XX:MinHeapFreeRation=來指定這個比列;當空餘堆記憶體大於70%時,JVM會減小堆記憶體的大小到-Xms指定的大小,可通過XX:MaxHeapFreeRation=來指定這個比列,當然為了避免在執行時頻繁調整Heap的大小,通常-Xms與-Xmx的值設成一樣。堆記憶體 = 新生代+老生代+持久代。

在我們垃圾回收的時候,我們往往將堆記憶體分成新生代和老生代(大小比例1:2),新生代中由Eden和Survivor0,Survivor1組成,三者的比例是8:1:1,新生代的回收機制採用複製演算法,在Minor GC的時候,我們都留一個存活區用來存放存活的物件,真正進行的區域是Eden+其中一個存活區,當我們的物件時長超過一定年齡時(預設15,可以通過引數設定),將會把物件放入老生代,當然大的物件會直接進入老生代。老生代採用的回收演算法是標記整理演算法。

2. 方法區(Method Area)

方法區也稱”永久代“,它用於儲存虛擬機器載入的類資訊、常量、靜態變數、是各個執行緒共享的記憶體區域。預設最小值為16MB,最大值為64MB(64位JVM由於指標膨脹,預設是85M),可以通過-XX:PermSize 和 -XX:MaxPermSize 引數限制方法區的大小。

它是一片連續的堆空間,永久代的垃圾收集是和老年代(old generation)捆綁在一起的,因此無論誰滿了,都會觸發永久代和老年代的垃圾收集。不過,一個明顯的問題是,當JVM載入的類資訊容量超過了引數-XX:MaxPermSize設定的值時,應用將會報OOM的錯誤。引數是通過-XX:PermSize和-XX:MaxPermSize來設定的。

3.虛擬機器棧(JVM Stack)

描述的是java方法執行的記憶體模型:每個方法被執行的時候都會建立一個”棧幀”,用於儲存區域性變數表(包括引數)、操作棧、方法出口等資訊。每個方法被呼叫到執行完的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

宣告週期與執行緒相同,是執行緒私有的。棧幀由三部分組成:區域性變數區、運算元棧、幀資料區。區域性變數區被組織為以一個字長為單位、從0開始計數的陣列,和區域性變數區一樣,運算元棧也被組織成一個以字長為單位的陣列。但和前者不同的是,它不是通過索引來訪問的,而是通過入棧和出棧來訪問的,可以看作為臨時資料的儲存區域。

除了區域性變數區和運算元棧外,java棧幀還需要一些資料來支援常量池解析、正常方法返回以及異常派發機制。這些資料都儲存在java棧幀的幀資料區中。

區域性變數表: 存放了編譯器可知的各種基本資料型別、物件引用(引用指標,並非物件本身),其中64位長度的long和double型別的資料會佔用2個區域性變數的空間,其餘資料型別只佔1個。

區域性變數表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的區域性變數是完全確定的,在執行期間棧幀不會改變區域性變數表的大小空間。

4.本地方法棧(Native Stack)

與虛擬機器棧基本類似,區別在於虛擬機器棧為虛擬機器執行的java方法服務,而本地方法棧則是為Native方法服務。(棧的空間大小遠遠小於堆)

5.程式計數器(PC Register)

是最小的一塊記憶體區域,它的作用是當前執行緒所執行的位元組碼的行號指示器,在虛擬機器的模型裡,位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、異常處理、執行緒恢復等基礎功能都需要依賴計數器完成。

6.直接記憶體

直接記憶體並不是虛擬機器記憶體的一部分,也不是Java虛擬機器規範中定義的記憶體區域。jdk1.4中新加入的NIO,引入了通道與緩衝區的IO方式,它可以呼叫Native方法直接分配堆外記憶體,這個堆外記憶體就是本機記憶體,不會影響到堆記憶體的大小.

JVM垃圾回收演算法

1.標記清除

JVM完整詳解:記憶體分配+執行原理+回收演算法+GC引數等-mikechen的網際網路架構

原理:

  •  從根集合節點進行掃描,標記出所有的存活物件,最後掃描整個記憶體空間並清除沒有標記的物件(即死亡物件)

適用場合:

  •  存活物件較多的情況下比較高效
  •  適用於年老代(即舊生代)

缺點:

  •  標記清除演算法帶來的一個問題是會存在大量的空間碎片,因為回收後的空間是不連續的,這樣給大物件分配記憶體的時候可能會提前觸發full gc。

2.複製演算法

JVM完整詳解:記憶體分配+執行原理+回收演算法+GC引數等-mikechen的網際網路架構

原理:

  •  從根集合節點進行掃描,標記出所有的存活物件,並將這些存活的物件複製到一塊兒新的記憶體(圖中下邊的那一塊兒記憶體)上去,之後將原來的那一塊兒記憶體(圖中上邊的那一塊兒記憶體)全部回收掉

適用場合:

  •  存活物件較少的情況下比較高效
  •  掃描了整個空間一次(標記存活物件並複製移動)
  •  適用於年輕代(即新生代):基本上98%的物件是”朝生夕死”的,存活下來的會很少

缺點:

  •  需要一塊兒空的記憶體空間
  •  需要複製移動物件

3.標記整理

JVM完整詳解:記憶體分配+執行原理+回收演算法+GC引數等-mikechen的網際網路架構

原理:

  •  從根集合節點進行掃描,標記出所有的存活物件,最後掃描整個記憶體空間並清除沒有標記的物件(即死亡物件)(可以發現前邊這些就是標記-清除演算法的原理),清除完之後,將所有的存活物件左移到一起。

適用場合:

  •  用於年老代(即舊生代)

缺點:

  •  需要移動物件,若物件非常多而且標記回收後的記憶體非常不完整,可能移動這個動作也會耗費一定時間
  •  掃描了整個空間兩次(第一次:標記存活物件;第二次:清除沒有標記的物件)

優點:

  •  不會產生記憶體碎片

4.分代收集演算法

當前商業虛擬機器的垃圾收集都採用“分代收集”(Generational Collection)演算法,這種演算法並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。

專門研究表明,新生代中的物件98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor[1]。當回收時,將Eden和Survivor中還存活著的物件一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的記憶體會被“浪費”。當然,98%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保(Handle Promotion)。

在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記—清理”或者“標記—整理”演算法來進行回收。

垃圾回收器

1.Serial收集器

Serial收集器是最古老的收集器,它的缺點是當Serial收集器想進行垃圾回收的時候,必須暫停使用者的所有程式,即stop the world。到現在為止,它依然是虛擬機器執行在client模式下的預設新生代收集器,與其他收集器相比,對於限定在單個CPU的執行環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾回收自然可以獲得最高的單執行緒收集效率。

2.ParNew收集器

ParNew收集器是Serial收集器新生代的多執行緒實現,注意在進行垃圾回收的時候依然會stop the world,只是相比較Serial收集器而言它會執行多條程式進行垃圾回收。

ParNew收集器在單CPU的環境中絕對不會有比Serial收集器更好的效果,甚至由於存線上程互動的開銷,該收集器在通過超執行緒技術實現的兩個CPU的環境中都不能百分之百的保證能超越Serial收集器。當然,隨著可以使用的CPU的數量增加,它對於GC時系統資源的利用還是很有好處的。它預設開啟的收集執行緒數與CPU的數量相同,在CPU非常多(譬如32個,現在CPU動輒4核加超執行緒,伺服器超過32個邏輯CPU的情況越來越多了)的環境下,可以使用-XX:ParallelGCThreads引數來限制垃圾收集的執行緒數。

3.Parallel Scavenge收集器

Parallel是採用複製演算法的多執行緒新生代垃圾回收器,似乎和ParNew收集器有很多的相似的地方。但是Parallel Scanvenge收集器的一個特點是它所關注的目標是吞吐量(Throughput)。所謂吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量=執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間)。停頓時間越短就越適合需要與使用者互動的程式,良好的響應速度能夠提升使用者的體驗;而高吞吐量則可以最高效率地利用CPU時間,儘快地完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。

4.CMS收集器

CMS(Concurrent Mark Swep)收集器是一個比較重要的回收器,現在應用非常廣泛,我們重點來看一下,CMS一種獲取最短回收停頓時間為目標的收集器,這使得它很適合用於和使用者互動的業務。從名字(Mark Swep)就可以看出,CMS收集器是基於標記清除演算法實現的。它的收集過程分為四個步驟:

  1.  初始標記(initial mark)
  2.  併發標記(concurrent mark)
  3.  重新標記(remark)
  4.  併發清除(concurrent sweep)

注意初始標記和重新標記還是會stop the world,但是在耗費時間更長的併發標記和併發清除兩個階段都可以和使用者程式同時工作。

不過由於CMS收集器是基於標記清除演算法實現的,會導致有大量的空間碎片產生,在為大物件分配記憶體的時候,往往會出現老年代還有很大的空間剩餘,但是無法找到足夠大的連續空間來分配當前物件,不得不提前開啟一次Full GC。

為了解決這個問題,CMS收集器預設提供了一個-XX:+UseCMSCompactAtFullCollection收集開關引數(預設就是開啟的),用於在CMS收集器進行FullGC完開啟記憶體碎片的合併整理過程,記憶體整理的過程是無法併發的,這樣記憶體碎片問題倒是沒有了,不過停頓時間不得不變長。虛擬機器設計者還提供了另外一個引數-XX:CMSFullGCsBeforeCompaction引數用於設定執行多少次不壓縮的FULL GC後跟著來一次帶壓縮的(預設值為0,表示每次進入Full GC時都進行碎片整理)。

不幸的是,它作為老年代的收集器,卻無法與jdk1.4中已經存在的新生代收集器Parallel Scavenge配合工作,所以在jdk1.5中使用cms來收集老年代的時候,新生代只能選擇ParNew或Serial收集器中的一個。

ParNew收集器是使用-XX:+UseConcMarkSweepGC選項啟用CMS收集器之後的預設新生代收集器,也可以使用-XX:+UseParNewGC選項來強制指定它。

5.G1收集器

G1收集器是一款面向服務端應用的垃圾收集器。HotSpot團隊賦予它的使命是在未來替換掉JDK1.5中釋出的CMS收集器。與其他GC收集器相比,G1具備如下特點:

  1.  並行與併發:G1能更充分的利用CPU,多核環境下的硬體優勢來縮短stop the world的停頓時間。
  2.  分代收集:和其他收集器一樣,分代的概念在G1中依然存在,不過G1不需要其他的垃圾回收器的配合就可以獨自管理整個GC堆。
  3.  空間整合:G1收集器有利於程式長時間執行,分配大物件時不會無法得到連續的空間而提前觸發一次GC。
  4.  可預測的非停頓:這是G1相對於CMS的另一大優勢,降低停頓時間是G1和CMS共同的關注點,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。

在使用G1收集器時,Java堆的記憶體佈局和其他收集器有很大的差別,它將這個Java堆分為多個大小相等的獨立區域,雖然還保留新生代和老年代的概念,但是新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。

雖然G1看起來有很多優點,實際上CMS還是主流。

與GC相關的常用引數

除了上面提及的一些引數,下面補充一些和GC相關的常用引數:

  •  -Xmx: 設定堆記憶體的最大值。
  •  -Xms: 設定堆記憶體的初始值。
  •  -Xmn: 設定新生代的大小。
  •  -Xss: 設定棧的大小。
  •  -PretenureSizeThreshold: 直接晉升到老年代的物件大小,設定這個引數後,大於這個引數的物件將直接在老年代分配。
  •  -MaxTenuringThrehold: 晉升到老年代的物件年齡。每個物件在堅持過一次Minor GC之後,年齡就會加1,當超過這個引數值時就進入老年代。
  •  -UseAdaptiveSizePolicy: 在這種模式下,新生代的大小、eden 和 survivor 的比例、晉升老年代的物件年齡等引數會被自動調整,以達到在堆大小、吞吐量和停頓時間之間的平衡點。在手工調優比較困難的場合,可以直接使用這種自適應的方式,僅指定虛擬機器的最大堆、目標的吞吐量 (GCTimeRatio) 和停頓時間 (MaxGCPauseMills),讓虛擬機器自己完成調優工作。
  •  -SurvivorRattio: 新生代Eden區域與Survivor區域的容量比值,預設為8,代表Eden: Suvivor= 8: 1。
  •  -XX:ParallelGCThreads:設定用於垃圾回收的執行緒數。通常情況下可以和 CPU 數量相等。但在 CPU 數量比較多的情況下,設定相對較小的數值也是合理的。
  •  -XX:MaxGCPauseMills:設定最大垃圾收集停頓時間。它的值是一個大於 0 的整數。收集器在工作時,會調整 Java 堆大小或者其他一些引數,儘可能地把停頓時間控制在 MaxGCPauseMills 以內。
  •  -XX:GCTimeRatio:設定吞吐量大小,它的值是一個 0-100 之間的整數。假設 GCTimeRatio 的值為 n,那麼系統將花費不超過 1/(1+n) 的時間用於垃圾收集。

 

關於作者:mikechen,十餘年BAT架構經驗,資深技術專家,曾任職阿里、淘寶、百度。

歡迎關注個人公眾號:mikechen的網際網路架構,十餘年BAT架構經驗傾囊相授!

在公眾號選單欄對話方塊回覆【架構】關鍵詞,即可檢視我原創的300期+BAT架構技術系列文章與1000+大廠面試題答案合集。

JVM完整詳解:記憶體分配+執行原理+回收演算法+GC引數等

相關文章