最全JVM完整詳解:內存分配+運行原理+回收算法+GC參數等

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) 的時間用於垃圾收集。

---END--

我是Mike,10餘年BAT一線大廠架構技術傾囊相授。每篇深度技術文,都是我花上2-5天時間精心創作的,大家如果看了覺得還行,謝謝【點贊+轉發+收藏】一鍵三連支持下。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章