天天看點

史上最全JVM虛拟機詳解(萬字圖文)

作者:mikechen的網際網路架構
史上最全JVM虛拟機詳解(萬字圖文)

JVM虛拟機是大廠必備技能,特别是JVM記憶體模型,JVM垃圾收集器、回收算法,以及性能優化這塊更是重中之重,本篇我就全面的來詳解JVM@mikechen

JVM概要

JVM是Java Virtual Machine(Java虛拟機)的縮寫。

虛拟機是一種抽象化的計算機,通過在實際的計算機上仿真模拟各種計算機功能來實作的。

Java虛拟機有自己完善的硬體架構,如處理器、堆棧、寄存器等,還具有相應的指令系統。

Java虛拟機屏蔽了與具體作業系統平台相關的資訊,使得Java程式隻需生成在Java虛拟機上運作的目标代碼(位元組碼),就可以在多種平台上不加修改地運作,如下圖所示:

史上最全JVM虛拟機詳解(萬字圖文)

簡單來說JVM是用來解析和運作Java程式的。

JVM記憶體模型

史上最全JVM虛拟機詳解(萬字圖文)

由上圖可以清楚的看到JVM的記憶體空間分為3大部分:

  1. 堆記憶體
  2. 方法區
  3. 棧記憶體

其中棧記憶體可以再細分為java虛拟機棧和本地方法棧,堆記憶體可以劃分為新生代和老年代,新生代中還可以再次劃分為Eden區、From Survivor區和To Survivor區。

其中一部分是線程共享的,包括 Java 堆和方法區;另一部分是線程私有的,包括虛拟機棧和本地方法棧,以及程式計數器這一小部分記憶體。

堆記憶體(Heap)

java 堆(Java Heap)是Java 虛拟機所管理的記憶體中最大的一塊。

堆是被所有線程共享的區域,在虛拟機啟動時建立的。堆裡面存放的都是對象的執行個體(new 出來的對象都存在堆中)。

此記憶體區域的唯一目的就是存放對象執行個體(new的對象),幾乎所有的對象執行個體都在這裡配置設定記憶體。

堆記憶體分為兩個部分:年輕代和老年代。我們平常所說的垃圾回收,主要回收的就是堆區。

更細一點劃分新生代又可劃分為Eden區和2個Survivor區(From Survivor和To Survivor)。

下圖中的Perm代表的是永久代,但是注意永久代并不屬于堆記憶體中的一部分,同時jdk1.8之後永久代已經被移除。

史上最全JVM虛拟機詳解(萬字圖文)

新生代 ( Young ) 與老年代 ( Old ) 的比例的值為 1:2 ( 該值可以通過參數 –XX:NewRatio 來指定 )

預設的,Eden : from : to = 8 : 1 : 1 ( 可以通過參數 –XX:SurvivorRatio 來設定 ),即:Eden = 8/10 的新生代空間大小,from = to = 1/10 的新生代空間大小。

方法區(Method Area)

方法區也稱”永久代“,它用于存儲虛拟機加載的類資訊、常量、靜态變量、是各個線程共享的記憶體區域。

在JDK8之前的HotSpot JVM,存放這些”永久的”的區域叫做“永久代(permanent generation)”。永久代是一片連續的堆空間,在JVM啟動之前通過在指令行設定參數-XX:MaxPermSize來設定永久代最大可配置設定的記憶體空間,預設大小是64M(64位JVM預設是85M)。

随着JDK8的到來,JVM不再有 永久代(PermGen)。但類的中繼資料資訊(metadata)還在,隻不過不再是存儲在連續的堆空間上,而是移動到叫做“Metaspace”的本地記憶體(Native memory。

方法區或永生代相關設定

  • -XX:PermSize=64MB 最小尺寸,初始配置設定
  • -XX:MaxPermSize=256MB 最大允許配置設定尺寸,按需配置設定
  • XX:+CMSClassUnloadingEnabled -XX:+CMSPermGenSweepingEnabled 設定垃圾不回收
  • 預設大小
  • -server選項下預設MaxPermSize為64m
  • -client選項下預設MaxPermSize為32m

虛拟機棧(JVM Stack)

java虛拟機棧是線程私有,生命周期與線程相同。建立線程的時候就會建立一個java虛拟機棧。

虛拟機執行java程式的時候,每個方法都會建立一個棧幀,棧幀存放在java虛拟機棧中,通過壓棧出棧的方式進行方法調用。

棧幀又分為:局部變量表、操作數棧、動态連接配接、方法出口等。

本地方法棧(Native Stack)

本地方法棧(Native Method Stacks)與虛拟機棧所發揮的作用是非常相似的,其差別不過是虛拟機棧為虛拟機執行Java方法(也就是位元組碼)服務,而本地方法棧則是為虛拟機使用到的Native方法服務。

程式計數器(PC Register)

程式計數器就是記錄目前線程執行程式的位置,改變計數器的值來确定執行的下一條指令,比如循環、分支、方法跳轉、異常處理,線程恢複都是依賴程式計數器來完成。

Java虛拟機多線程是通過線程輪流切換并配置設定處理器執行時間的方式實作的。為了線程切換能恢複到正确的位置,每條線程都需要一個獨立的程式計數器,是以它是線程私有的。

直接記憶體

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

JVM垃圾回收算法

史上最全JVM虛拟機詳解(萬字圖文)

1.标記-清除:

标記-清除算法将垃圾回收分為兩個階段:标記階段和清除階段。

在标記階段首先通過根節點(GC Roots),标記所有從根節點開始的對象,未被标記的對象就是未被引用的垃圾對象。然後,在清除階段,清除所有未被标記的對象。

适用場合:

  • 存活對象較多的情況下比較高效
  • 适用于年老代(即舊生代)

缺點:

  • 容易産生記憶體碎片,再來一個比較大的對象時(典型情況:該對象的大小大于空閑表中的每一塊兒大小但是小于其中兩塊兒的和),會提前觸發垃圾回收
  • 掃描了整個空間兩次(第一次:标記存活對象;第二次:清除沒有标記的對象)

2.複制算法

從根集合節點進行掃描,标記出所有的存活對象,并将這些存活的對象複制到一塊兒新的記憶體(圖中下邊的那一塊兒記憶體)上去,之後将原來的那一塊兒記憶體(圖中上邊的那一塊兒記憶體)全部回收掉

現在的商業虛拟機都采用這種收集算法來回收新生代。

适用場合:

  • 存活對象較少的情況下比較高效
  • 掃描了整個空間一次(标記存活對象并複制移動)
  • 适用于年輕代(即新生代):基本上98%的對象是”朝生夕死”的,存活下來的會很少

缺點:

  • 需要一塊兒空的記憶體空間
  • 需要複制移動對象

3. 标記-整理

複制算法的高效性是建立在存活對象少、垃圾對象多的前提下的。

這種情況在新生代經常發生,但是在老年代更常見的情況是大部分對象都是存活對象。如果依然使用複制算法,由于存活的對象較多,複制的成本也将很高。

标記-壓縮算法是一種老年代的回收算法,它在标記-清除算法的基礎上做了一些優化。

首先也需要從根節點開始對所有可達對象做一次标記,但之後,它并不簡單地清理未标記的對象,而是将所有的存活對象壓縮到記憶體的一端。之後,清理邊界外所有的空間。

這種方法既避免了碎片的産生,又不需要兩塊相同的記憶體空間,是以,其成本效益比較高。

4.分代收集

分代收集算法就是目前虛拟機使用的回收算法,它解決了标記整理不适用于老年代的問題,将記憶體分為各個年代。一般情況下将堆區劃分為老年代(Tenured Generation)和新生代(Young Generation),在堆區之外還有一個代就是永久代(Permanet Generation)。

在不同年代使用不同的算法,進而使用最合适的算法,新生代存活率低,可以使用複制算法。而老年代對象存活率高,沒有額外空間對它進行配置設定擔保,是以隻能使用标記清除或者标記整理算法。

JVM垃圾收集器有哪些?以及優劣勢比較?

史上最全JVM虛拟機詳解(萬字圖文)

1.串行Serial收集器

串行收集器是最簡單的,它設計為在單核的環境下工作(32位或者windows),你幾乎不會使用到它。它在工作的時候會暫停整個應用的運作,是以在所有伺服器環境下都不可能被使用。

使用方法:-XX:+UseSerialGC

2.并行Parallel收集器

這是JVM預設的收集器,跟它名字顯示的一樣,它最大的優點是使用多個線程來掃描和壓縮堆。缺點是在minor和full GC的時候都會暫停應用的運作。并行收集器最适合用在可以容忍程式停滞的環境使用,它占用較低的CPU因而能提高應用的吞吐(throughput)。

使用方法:-XX:+UseParallelGC

3.CMS收集器

CMS是Concurrent-Mark-Sweep的縮寫,并發的标記與清除。

這個算法使用多個線程并發地(concurrent)掃描堆,标記不使用的對象,然後清除它們回收記憶體。在兩種情況下會使應用暫停(Stop the World, STW):

1. 當初次開始标記根對象時initial mark。

2. 當在并行收集時應用又改變了堆的狀态時,需要它從頭再确認一次标記了正确的對象final remark。

這個收集器最大的問題是在年輕代與老年代收集時會出現的一種競争情況(race condition),稱為提升失敗promotion failure。對象從年輕代複制到老年代稱為提升promotion,但有時侯老年代需要清理出足夠空間來放這些對象,這需要一定的時間,它收集的速度可能趕不上不斷産生的要提升的年輕代對象的速度,這時就需要做STW的收集。STW正是CMS想避免的問題。為了避免這個問題,需要增加老年代的空間大小或者增加更多的線程來做老年代的收集以趕上從年輕代複制對象的速度。

除了上文所說的内容之外,CMS最大的問題就是記憶體空間碎片化的問題。CMS隻有在觸發FullGC的情況下才會對堆空間進行compact。如果線上應用長時間運作,碎片化會非常嚴重,會很容易造成promotion failed。為了解決這個問題線上很多應用通過定期重新開機或者手工觸發FullGC來觸發碎片整理。

對比并行收集器它的一個壞處是需要占用比較多的CPU。對于大多數長期運作的伺服器應用來說,這通常是值得的,因為它不會導緻應用長時間的停滞。但是它不是JVM的預設的收集器。

4.G1收集器

如果你的堆記憶體大于4G的話,那麼G1會是要考慮使用的收集器。它是為了更好支援大于4G堆記憶體引入的。

G1之前的JVM記憶體模型

史上最全JVM虛拟機詳解(萬字圖文)
  • 新生代:伊甸園區(eden space) + 2個幸存區
  • 老年代
  • 持久代(perm space):JDK1.8之前
  • 元空間(metaspace):JDK1.8之後取代持久代

G1收集器的記憶體模型

史上最全JVM虛拟機詳解(萬字圖文)

1)G1堆記憶體結構

堆記憶體會被切分成為很多個固定大小區域(Region),每個是連續範圍的虛拟記憶體。

堆記憶體中一個區域(Region)的大小可以通過-XX:G1HeapRegionSize參數指定,大小區間最小1M、最大32M,總之是2的幂次方。

預設把堆記憶體按照2048份均分。

2)G1堆記憶體配置設定

每個Region被标記了E、S、O和H,這些區域在邏輯上被映射為Eden,Survivor和老年代。

存活的對象從一個區域轉移(即複制或移動)到另一個區域。區域被設計為并行收集垃圾,可能會暫停所有應用線程。

如上圖所示,區域可以配置設定到Eden,survivor和老年代。此外,還有第四種類型,被稱為巨型區域(Humongous Region)。Humongous區域是為了那些存儲超過50%标準region大小的對象而設計的,它用來專門存放巨型對象。如果一個H區裝不下一個巨型對象,那麼G1會尋找連續的H分區來存儲。為了能找到連續的H區,有時候不得不啟動Full GC。

G1回收流程

在執行垃圾收集時,G1以類似于CMS收集器的方式運作。

G1收集器的階段分以下幾個步驟:

史上最全JVM虛拟機詳解(萬字圖文)

1)G1執行的第一階段:初始标記(Initial Marking )

這個階段是STW(Stop the World )的,所有應用線程會被暫停,标記出從GC Root開始直接可達的對象。

2)G1執行的第二階段:并發标記

從GC Roots開始對堆中對象進行可達性分析,找出存活對象,耗時較長。當并發标記完成後,開始最終标記(Final Marking )階段

3)最終标記(标記那些在并發标記階段發生變化的對象,将被回收)

4)篩選回收(首先對各個Regin的回收價值和成本進行排序,根據使用者所期待的GC停頓時間指定回收計劃,回收一部分Region)

最後,G1中提供了兩種模式垃圾回收模式,Young GC和Mixed GC,兩種都是Stop The World(STW)的。

JVM配置參數

1)堆棧配置相關

例子:

java -Xmx3550m -Xms3550m -Xmn2g -Xss128k-XX:MaxPermSize=16m -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxTenuringThreshold=0           

-Xmx 3550m:最大堆大小為3550m。

-Xms 3550m:設定初始堆大小為3550m。

-Xmn 2g:設定年輕代大小為2g。

-Xss 128k:每個線程的堆棧大小為128k。

-XX:MaxPermSize:設定持久代大小為16m

-XX:NewRatio=4: 設定年輕代(包括Eden和兩個Survivor區)與年老代的比值(除去持久代)。

-XX:SurvivorRatio=4:設定年輕代中Eden區與Survivor區的大小比值。設定為4,則兩個Survivor區與一個Eden區的比值為2:4,一個Survivor區占整個年輕代的1/6

-XX:MaxTenuringThreshold=0:設定垃圾最大年齡。如果設定為0的話,則年輕代對象不經過Survivor區,直接進入年老代。

2)垃圾收集器相關

-XX:+UseParallelGC

-XX:ParallelGCThreads=20

-XX:+UseConcMarkSweepGC

-XX:CMSFullGCsBeforeCompaction=5

-XX:+UseCMSCompactAtFullCollection:

-XX:+UseParallelGC:選擇垃圾收集器為并行收集器。

-XX:ParallelGCThreads=20:配置并行收集器的線程數

-XX:+UseConcMarkSweepGC:設定年老代為并發收集。

-XX:CMSFullGCsBeforeCompaction:由于并發收集器不對記憶體空間進行壓縮、整理,是以運作一段時間以後會産生“碎片”,使得運作效率降低。此值設定運作多少次GC以後對記憶體空間進行壓縮、整理。

-XX:+UseCMSCompactAtFullCollection:打開對年老代的壓縮。可能會影響性能,但是可以消除碎片

3)輔助資訊相關

-XX:+PrintGC:開啟列印 gc 資訊;

-XX:+PrintGCDetails:列印 gc 詳細資訊。

JVM調優工具

史上最全JVM虛拟機詳解(萬字圖文)
  1. Jconsole : jdk自帶,功能簡單,但是可以在系統有一定負荷的情況下使用。對垃圾回收算法有很詳細的跟蹤。
  2. JProfiler:商業軟體,功能強大。
  3. VisualVM:JDK自帶,功能強大,與JProfiler類似。
  4. MAT:MAT(Memory Analyzer Tool),一個基于Eclipse的記憶體分析工具。

JDK本身提供了很豐富的性能監控工具,除了內建式的visualVM和jConsole外,還有jstat,jstack,jps,jmap,jhat小工具,這些都是性能調優的常用工具。

JVM性能調優步驟

史上最全JVM虛拟機詳解(萬字圖文)

1.監控GC的狀态

使用各種JVM工具,檢視目前日志,分析目前JVM參數設定,并且分析目前堆記憶體快照和gc日志,根據實際的各區域記憶體劃分和GC執行時間,覺得是否進行優化。

舉一個例子:系統崩潰前的一些現象:

  • 每次垃圾回收的時間越來越長,由之前的10ms延長到50ms左右,FullGC的時間也有之前的0.5s延長到4、5s
  • FullGC的次數越來越多,最頻繁時隔不到1分鐘就進行一次FullGC
  • 年老代的記憶體越來越大并且每次FullGC後年老代沒有記憶體被釋放

之後系統會無法響應新的請求,逐漸到達OutOfMemoryError的臨界值,這個時候就需要分析JVM記憶體快照dump。

2.生成堆的dump檔案

通過JMX的MBean生成目前的Heap資訊,大小為一個3G(整個堆的大小)的hprof檔案,如果沒有啟動JMX可以通過Java的jmap指令來生成該檔案。

3.分析dump檔案

打開這個3G的堆資訊檔案,顯然一般的Window系統沒有這麼大的記憶體,必須借助高配置的Linux,幾種工具打開該檔案:

  • Visual VM
  • IBM HeapAnalyzer
  • JDK 自帶的Hprof工具
  • Mat(Eclipse專門的靜态記憶體分析工具)推薦使用

備注:檔案太大,建議使用Eclipse專門的靜态記憶體分析工具Mat打開分析。

4.分析結果,判斷是否需要優化

如果各項參數設定合理,系統沒有逾時日志出現,GC頻率不高,GC耗時不高,那麼沒有必要進行GC優化,如果GC時間超過1-3秒,或者頻繁GC,則必須優化。

注:如果滿足下面的名額,則一般不需要進行GC:

  • Minor GC執行時間不到50ms;
  • Minor GC執行不頻繁,約10秒一次;
  • Full GC執行時間不到1s;
  • Full GC執行頻率不算頻繁,不低于10分鐘1次;

5.調整GC類型和記憶體配置設定

如果記憶體配置設定過大或過小,或者采用的GC收集器比較慢,則應該優先調整這些參數,并且先找1台或幾台機器進行beta,然後比較優化過的機器和沒有優化的機器的性能對比,并有針對性的做出最後選擇。

6.不斷的分析和調整

通過不斷的試驗和試錯,分析并找到最合适的參數,如果找到了最合适的參數,則将這些參數應用到所有伺服器。

更多分布式架構系列、阿裡架構師進階系列,請檢視以下文章:

阿裡架構師進階從0到1全部合集(建議收藏)

史上最全JVM虛拟機詳解(萬字圖文)