1. 程式人生 > >推薦收藏系列:一文理解JVM虛擬機器(記憶體、垃圾回收、效能優化)解決面試中遇到問題

推薦收藏系列:一文理解JVM虛擬機器(記憶體、垃圾回收、效能優化)解決面試中遇到問題

  • JVM棧(Java Virtual Machine Stacks): Java中一個執行緒就會相應有一個執行緒棧與之對應,因為不同的執行緒執行邏輯有所不同,因此需要一個獨立的執行緒棧,因此棧儲存的資訊都是跟當前執行緒(或程式)相關資訊的,包括區域性變數程式執行狀態方法返回值方法出口等等。每一個方法被呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。
  • 堆(Heap): 堆是所有執行緒共享的,主要是存放物件例項和陣列。處於物理上不連續的記憶體空間,只要邏輯連續即可
  • 方法區(Method Area): 屬於共享記憶體區域,儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料
  • 常量池(Runtime Constant Pool): 它是方法區的一部分,用於存放編譯期生成的各種字面量和符號引用。
  • 本地方法棧(Native Method Stacks):

其中,堆(Heap)JVM棧程式執行的關鍵,因為:

  1. 棧是執行時的單位(解決程式的執行問題,即程式如何執行,或者說如何處理資料),而堆是儲存的單位(解決的是資料儲存的問題,即資料怎麼放、放在哪兒)。
  2. 堆儲存的是物件。棧儲存的是基本資料型別和堆中物件的引用;(引數傳遞的值傳遞和引用傳遞)

那為什麼要把堆和棧區分出來呢?棧中不是也可以儲存資料嗎?

  1. 從軟體設計的角度看,棧代表了處理邏輯,而堆代表了資料,分工明確,處理邏輯更為清晰體現了“分而治之”以及“隔離”的思想。
  2. 堆與棧的分離,使得堆中的內容可以被多個棧共享(也可以理解為多個執行緒訪問同一個物件)。這樣共享的方式有很多收益:提供了一種有效的資料互動方式(如:共享記憶體);堆中的共享常量和快取可以被所有棧訪問,節省了空間。
  3. 棧因為執行時的需要,比如儲存系統執行的上下文,需要進行地址段的劃分。由於棧只能向上增長,因此就會限制住棧儲存內容的能力。而堆不同,堆中的物件是可以根據需要動態增長的,因此棧和堆的拆分,使得動態增長成為可能,相應棧中只需記錄堆中的一個地址即可。
  4. 堆和棧的結合完美體現了面向物件的設計。當我們將物件拆開,你會發現,物件的屬性即是資料,存放在堆中;而物件的行為(方法)即是執行邏輯,放在棧中。因此編寫物件的時候,其實即編寫了資料結構,也編寫的處理資料的邏輯。

1.2 堆(Heap)和JVM棧:

1.2.1 堆(Heap)

  Java堆是java虛擬機器所管理記憶體中最大的一塊記憶體空間,處於物理上不連續的記憶體空間,只要邏輯連續即可,主要用於存放各種類的例項物件。該區域被所有執行緒共享,在虛擬機器啟動時建立,用來存放物件的例項,幾乎所有的物件以及陣列都在這裡分配記憶體(棧上分配、標量替換優化技術的例外)。
  在 Java 中,堆被劃分成兩個不同的區域:新生代 ( Young )老年代 ( Old )新生代 ( Young ) 又被劃分為三個區域:EdenFrom Survivor(S0)To Survivor(S1)。如圖所示:

堆的記憶體佈局:

  這樣劃分的目的是為了使jvm能夠更好的管理記憶體中的物件,包括記憶體的分配以及回收。 而新生代按eden和兩個survivor的分法,是為了

  • 有效空間增大,eden+1個survivor;
  • 有利於物件代的計算,當一個物件在S0/S1中達到設定的XX:MaxTenuringThreshold值後,會將其挪到老年代中,即只需掃描其中一個survivor。如果沒有S0/S1,直接分成兩個區,該如何計算物件經過了多少次GC還沒被釋放。
  • 兩個Survivor區可解決記憶體碎片化

1.2.2 堆疊相關的引數

引數 描述
-Xms 堆記憶體初始大小,單位m、g
-Xmx 堆記憶體最大允許大小,一般不要大於實體記憶體的80%
-Xmn 年輕代記憶體初始大小
-Xss 每個執行緒的堆疊大小,即JVM棧的大小
-XX:NewRatio 年輕代(包括Eden和兩個Survivor區)與年老代的比值
-XX:NewSzie(-Xns) 年輕代記憶體初始大小,可以縮寫-Xns
-XX:MaxNewSize(-Xmx) 年輕代記憶體最大允許大小,可以縮寫-Xmx
-XX:SurvivorRatio 年輕代中Eden區與Survivor區的容量比例值,預設為8,即8:1
-XX:MinHeapFreeRatio GC後,如果發現空閒堆記憶體佔到整個預估堆記憶體的40%,則放大堆記憶體的預估最大值,但不超過固定最大值。
-XX:MaxHeapFreeRatio 預估堆記憶體是堆大小動態調控的重要選項之一。堆記憶體預估最大值一定小於或等於固定最大值(-Xmx指定的數值)。前者會根據使用情況動態調大或縮小,以提高GC回收的效率,預設70%
-XX:MaxTenuringThreshold 垃圾最大年齡,設定為0的話,則年輕代物件不經過Survivor區,直接進入年老代。對於年老代比較多的應用,可以提高效率.如果將此值設定為一個較大值,則年輕代物件會在Survivor區進行多次複製,這樣可以增加物件再年輕代的存活 時間,增加在年輕代即被回收的概率
-XX:InitialTenuringThreshold 可以設定老年代閥值的初始值
-XX:+PrintTenuringDistribution 檢視每次minor GC後新的存活週期的閾值

Note: 每次GC 後會調整堆的大小,為了防止動態調整帶來的效能損耗,一般設定-Xms、-Xmx 相等
    新生代的三個設定引數:-Xmn,-XX:NewSize,-XX:NewRatio的優先順序:
    (1).最高優先順序: -XX:NewSize=1024m和-XX:MaxNewSize=1024m
    (2).次高優先順序: -Xmn1024m (預設等效效果是:-XX:NewSize==-XX:MaxNewSize==1024m)
    (3).最低優先順序:-XX:NewRatio=2
  推薦使用的是-Xmn引數,原因是這個引數很簡潔,相當於一次性設定NewSize和MaxNewSIze,而且兩者相等。

1.3 jvm物件

1.3.1 建立物件的方式

各個方式的實質操作如下: 方式|實質 :-|:-| 使用new關鍵|呼叫無參或有參構造器函式建立 使用Class的newInstance方法|呼叫無參或有參構造器函式建立,且需要是publi的建構函式 使用Constructor類的newInstance方法|呼叫有參和私有private構造器函式建立,實用性更廣 使用Clone方法|不呼叫任何參構造器函式,且物件需要實現Cloneable介面並實現其定義的clone方法,且預設為淺複製 第三方庫Objenesis|利用了asm位元組碼技術,動態生成Constructor物件

1.3.2 jvm物件分配

在虛擬機器層面上建立物件的步驟:

步驟|解析 :-|:-| 1、判斷物件對應的類是否載入、連結、初始化| 虛擬機器遇到一條new指令,首先去檢查這個指令的引數能否在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被載入、解析和初始化。如果沒有,那麼必須先執行類的載入、解釋、初始化(類的clinit方法)。 2、為物件分配記憶體| 類載入檢查通過後,虛擬機器為新生物件分配記憶體。物件所需記憶體大小在類載入完成後便可以完全確定,為物件分配空間無非就是從Java堆中劃分出一塊確定大小的記憶體而已。 3、處理併發安全問題| 另外一個問題及時保證new物件時候的執行緒安全性:建立物件是非常頻繁的操作,虛擬機器需要解決併發問題。 虛擬機器採用了兩種方式解決併發問題:<br>(1)CAS配上失敗重試的方式保證指標更新操作的原子性;<br>(2)TLAB 把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在Java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝區,(TLAB ,Thread Local Allocation Buffer)虛擬機器是否使用TLAB,可以通過-XX:+/-UseTLAB引數來設定。 4、初始化分配到的空間|記憶體分配結束,虛擬機器將分配到的記憶體空間都初始化為零值(不包括物件頭)。這一步保證了物件的例項欄位在Java程式碼中可以不用賦初始值就可以直接使用,程式能訪問到這些欄位的資料型別所對應的零值 5、設定物件的物件頭|將物件的所屬類(即類的元資料資訊)、物件的HashCode和物件的GC分代年齡等資料儲存在物件的物件頭中 6、執行init方法進行初始化| 在Java程式的視角看來,初始化才正式開始,開始呼叫<init>方法完成初始賦值和建構函式,所有的欄位都為零值。因此一般來說(由位元組碼中是否跟隨有invokespecial指令所決定),new指令之後會接著就是執 行<init>方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全創建出來。

1.3.3 物件分配記憶體方式

分配物件記憶體有兩種分配方式指標碰撞空閒列表
(1)如果記憶體是規整的,那麼虛擬機器將採用的是指標碰撞法(Bump The Pointer)來為物件分配記憶體。意思是所有用過的記憶體在一邊,空閒的記憶體在另外一邊,中間放著一個指標作為分界點的指示器,分配記憶體就僅僅是把指標向空閒那邊挪動一段與物件大小相等的距離罷了。 如果垃圾收集器選擇的是Serial、ParNew這種基於壓縮演算法的,虛擬機器採用這種分配方式。 一般使用帶有compact(整理)過程的收集器時,使用指標碰撞。
(2)如果記憶體不是規整的,已使用的記憶體和未使用的記憶體相互交錯,那麼虛擬機器將採用的是空閒列表法來為物件分配記憶體。意思是虛擬機器維護了一個列表,記錄上哪些記憶體塊是可用的,再分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的內容。這種分配方式成為“空閒列表(Free List)”。

Note: 選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。

1.3.4 那什麼樣的物件能夠進入老年代(Old)

那什麼樣的物件能夠進入老年代(Old)?

1.4 記憶體分配與回收策略

  1. 物件優先在Eden分配: 大多數情況下,物件在新生代Eden區中分配,當Eden區沒有足夠的空間進行分配時,虛擬機器將發起一次Minor GC;虛擬機器提供了-XX:PrintGCDetails引數,發生垃圾回收時列印記憶體回收日誌,並且在程序退出時輸出當前記憶體各區域的分配情況。
  2. 大物件直接進入老年代: 所謂的大物件就是指,需要大量連續記憶體空間的java物件,最典型的大物件就是那種很長的字串及陣列。虛擬機器提供了一個-XX:PretenureSizeThreshold引數,令大於這個設定值得物件直接在老年代中分配(這樣做的目的是避免在Eden區及兩個Survivor之間發生大量的記憶體拷貝)
  3. 長期存活的物件將直接進入老年代: 物件年齡計數器。-XX:MaxTenuringThreshold
  4. 動態物件年齡判定: 虛擬機器並不總是要求物件的年齡必須達到MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無需等到MaxTenuringThreshold中要求的年齡。
  5. 空間分配擔保: 在發生Minor GC時(前),虛擬機器會檢測之前每次晉升到老年代的平均大小(因為當次會有多少物件會存活是無法確定的,所以取之前的平均值/經驗值)是否大於老年代的剩餘空間大小,如果大於,則改為直接進行一次Full GC。如果小於,則檢視HandlePromotionFailure設定是否允許擔保失敗;如果允許,那隻會進行Minor GC;如果不允許,則也要改為進行一次Full GC。取平均值進行比較其實仍然是一種動態概率手段,也就是說如果某次Minor GC存活後的物件突增,遠遠高於平均值的話,依然會導致擔保失敗(Handle Promotion Failure),這樣會觸發Full GC。

二 垃圾回收演算法分類

2.1 引用

2.2 GC Root的物件

2.3 標記-清除(Mark—Sweep)

被譽為現代垃圾回收演算法的思想基礎。

  標記-清除演算法採用從根集合進行掃描,對存活的物件物件標記,標記完畢後,再掃描整個空間中未被標記的物件,進行回收,如上圖所示。標記-清除演算法不需要進行物件的移動,並且僅對不存活的物件進行處理,在存活物件比較多的情況下極為高效,但由於標記-清除演算法直接回收不存活的物件,因此會造成記憶體碎片。

2.4 複製演算法(Copying)

  該演算法的提出是為了克服控制代碼的開銷和解決堆碎片的垃圾回收。建立在存活物件少,垃圾物件多的前提下。此演算法每次只處理正在使用中的物件,因此複製成本比較小,同時複製過去後還能進行相應的記憶體整理,不會出現碎片問題。但缺點也是很明顯,就是需要兩倍記憶體空間。

  它開始時把堆分成 一個物件 面和多個空閒面, 程式從物件面為物件分配空間,當物件滿了,基於copying演算法的垃圾 收集就從根集中掃描活動物件,並將每個活動物件複製到空閒面(使得活動物件所佔的記憶體之間沒有空閒洞),這樣空閒面變成了物件面,原來的物件面變成了空閒面,程式會在新的物件面中分配記憶體。一種典型的基於coping演算法的垃圾回收是stop-and-copy演算法,它將堆分成物件面和空閒區域面,在物件面與空閒區域面的切換過程中,程式暫停執行。

2.5 標記-整理(或標記-壓縮演算法,Mark-Compact,又或者叫標記清除壓縮MarkSweepCompact)

  此演算法是結合了“標記-清除”和“複製演算法”兩個演算法的優點。避免了“標記-清除”的碎片問題,同時也避免了“複製”演算法的空間問題。

標記-整理演算法採用標記-清除演算法一樣的方式進行物件的標記,但在清除時不同,在回收不存活的物件佔用的空間後,會將所有的存活物件往左端空閒空間移動,並更新對應的指標。標記-整理演算法是在標記-清除演算法的基礎上,又進行了物件的移動,因此成本更高,但是卻解決了記憶體碎片的問題。在基於Compacting演算法的收集器的實現中,一般增加控制代碼和控制代碼表。

2.6 分代回收策略(Generational Collecting)

  基於這樣的事實:不同的物件的生命週期是不一樣的。因此,不同生命週期的物件可以採取不同的回收演算法,以便提高回收效率。

  新生代由於其物件存活時間短,且需要經常gc,因此採用效率較高的複製演算法,其將記憶體區分為一個eden區和兩個suvivor區,預設eden區和survivor區的比例是8:1,分配記憶體時先分配eden區,當eden區滿時,使用複製演算法進行gc,將存活物件複製到一個survivor區,當一個survivor區滿時,將其存活物件複製到另一個區中,當物件存活時間大於某一閾值時,將其放入老年代。老年代和永久代因為其存活物件時間長,因此使用標記清除或標記整理演算法

總結:

  • 新生代:複製演算法(新生代回收的頻率很高,每次回收的耗時很短,為了支援高頻率的新生代回收,虛擬機器可能使用一種叫做卡表(Card Table)的資料結構,卡表為一個位元位集合,每個位元位可以用來表示老年代的某一區域中的所有物件是否持有新生代對,

2.7 垃圾回收器

垃圾回收器的任務是識別和回收垃圾物件進行記憶體清理,不同代可使用不同的收集器:

  • 新生代收集器使用的收集器:Serial、ParNew、Parallel Scavenge
  • 老年代收集器使用的收集器:Serial Old(MSC)、Parallel Old、CMS。

總結:

  1. Serial old和新生代的所有回收器都能搭配;也可以作為CMS回收器的備用回收器;
  2. CMS只能和新生代的Serial和ParNew搭配,而且ParNew是CMS預設的新生代回收器;
  3. 並行(Parallel):指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態
  4. 併發(Concurrent):指使用者執行緒和垃圾收集執行緒同時執行(但不一定是並行的,可能是交替執行),使用者程式繼續執行,而垃圾收集程式執行在另外的CPU上。

三. GC的執行機制

  Java 中的堆(deap) 也是 GC 收集垃圾的主要區域。 由於物件進行了分代處理,因此垃圾回收區域、時間也不一樣。GC有兩種型別:Scavenge GC(Minor GC)Full GC(Major GC)

  • Scavenge GC(Minor GC): 一般情況下,當新物件生成(age=0),並且在Eden申請空間失敗時,就會觸發Scavenge GC,對Eden區域進行GC,清除非存活物件,並且把尚且存活的物件移動到Survivor區(age+1)。然後整理(其實是複製過去就順便整理了)Survivor的兩個區。這種方式的GC是對年輕代的Eden區進行,不會影響到年老代。因為大部分物件都是從Eden區開始的,同時Eden區不會分配的很大,所以Eden區的GC會頻繁進行。因而,一般在這裡需要使用速度快、效率高的演算法(即複製-清理演算法),使Eden去能儘快空閒出來。Java 中的大部分物件通常不需長久存活,具有朝生夕滅的性質。
  • Full GC: 對整個堆進行整理,包括Young、Tenured和Perm。Full GC因為需要對整個對進行回收,所以比Scavenge GC要慢,因此應該儘可能減少Full GC的次數。在對JVM調優的過程中,很大一部分工作就是對於FullGC的調節。

3.1 觸發Full GC執行的場景

3.2 Young GC觸發條件

3.3 新生物件GC收回流程

  基於大多數新生物件都會在GC中被收回的假設。新生代的GC 使用複製演算法,(將年輕代分為3部分,主要是為了生命週期短的物件儘量留在年輕代。老年代主要存放生命週期比較長的物件,比如快取)。可能經歷過程:

  1. 物件建立時,一般在Eden區完成記憶體分配(有特殊);
  2. 當Eden區滿了,再建立物件,會因為申請不到空間,觸發minorGC,進行young(eden+1survivor)區的垃圾回收;
  3. minorGC時,Eden和survivor A不能被GC回收且年齡沒有達到閾值(tenuring threshold)的物件,會被放入survivor B,始終保證一個survivor是空的;
  4. 當做第3步的時候,如果發現survivor滿了,將這些物件copy到old區(分配擔保機制);或者survivor並沒有滿,但是有些物件已經足夠Old,也被放入Old區 XX:MaxTenuringThreshold;(回顧下物件進入老年代的情況)
  5. 直接清空eden和survivor A;
  6. 當Old區被放滿的之後,進行fullGC。

3.4 GC日誌

GC日誌相關引數:

  • -XX:+PrintGC:輸出GC日誌
  • -XX:+PrintGCDetails:輸出GC的詳細日誌
  • -XX:+PrintGCTimeStamps:輸出GC的時間戳(以基準時間的形式)
  • -XX:+PrintGCApplicationStoppedTime:列印垃圾回收期間程式暫停的時間
  • -XX:+PrintGCApplicationConcurrentTime:列印每次垃圾回收前,程式未中斷的執行時間
  • -XX:+PrintHeapAtGC:在進行GC的前後打印出堆的資訊
  • -XX:+PrintTLAB:檢視TLAB空間的使用情況
  • -XX:PrintTenuingDistribution:檢視每次minor GC後新的存活週期的閾值
  • -XX:PrintReferenceFC:用來跟蹤系統內的(softReference)軟引用,(weadReference)弱引用,(phantomReference)虛引用,顯示引用過程

案例分析:-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime一起使用

    Application time: 0.3440086 seconds
    Total time for which application threads were stopped: 0.0620105 seconds
    Application time: 0.2100691 seconds
    Total time for which application threads were stopped: 0.0890223 seconds

得知應用程式在前344毫秒中是在處理實際工作的,然後將所有執行緒暫停了62毫秒,緊接著又工作了210ms,然後又暫停了89ms。

2796146K->2049K(1784832K)] 4171400K->2049K(3171840K), [Metaspace: 3134K->3134K(1056768K)], 0.0571841 secs] [Times: user=0.02 sys=0.04, real=0.06 secs]
Total time for which application threads were stopped: 0.0572646 seconds, Stopping threads took: 0.0000088 seconds

應用執行緒被強制暫停了57ms來進行垃圾回收。其中又有8ms是用來等待所有的應用執行緒都到達安全點。

只要設定-XX:+PrintGCDetails 就會自動帶上-verbose:gc和-XX:+PrintGC

33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]    
100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
  1. 最前面的數字“33.125:”和“100.667:”代表了GC發生的時間,這個數字的含義是從Java虛擬機器啟動以來經過的秒數。
  2. GC日誌開頭的“[GC”和“[Full GC”說明了這次垃圾收集的停頓型別,而不是用來區分新生代GC還是老年代GC的。如果有“Full”,說明這次GC是發生了Stop-The-World的。
  3. 接下來的“[DefNew”、“[Tenured”、“[Perm”表示GC發生的區域,這裡顯示的區域名稱與使用的GC收集器是密切相關的,例如上面樣例所使用的Serial收集器中的新生代名為“Default New Generation”,所以顯示的是“[DefNew”。如果是ParNew收集器,新生代名稱就會變為“[ParNew”,意為“Parallel New Generation”。如果採用Parallel Scavenge收集器,那它配套的新生代稱為“PSYoungGen”,老年代和永久代同理,名稱也是由收集器決定的。
  4. 後面方括號內部的“3324K->152K(3712K)”含義是“GC前該記憶體區域已使用容量-> GC後該記憶體區域已使用容量 (該記憶體區域總容量)”。而在方括號之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量 -> GC後Java堆已使用容量 (Java堆總容量)”。
  5. 再往後,“0.0025925 secs”表示該記憶體區域GC所佔用的時間,單位是秒。有的收集器會給出更具體的時間資料
  6. [Full GC 283.736: [ParNew: 261599K->261599K(261952K), 0.0000288 secs] 新生代收集器ParNew的日誌也會出現“[Full GC”(這一般是因為出現了分配擔保失敗之類的問題,所以才導致STW)。如果是呼叫System.gc()方法所觸發的收集,那麼在這裡將顯示“[Full GC (System)”。

3.5 減少GC開銷的措施

從程式碼上:

從JVM引數上調優上:

3.6 記憶體溢位分類

四. 總結-JVM調優相關

4.1 調優目的

4.2 JVM效能調優所處的層次

4.3 JVM調優流程

4.4 效能監控工具

  調優的最終目的都是為了令應用程式使用最小的硬體消耗來承載更大的吞吐。jvm的調優也不例外,jvm調優主要是針對垃圾收集器的收集效能優化,令執行在虛擬機器上的應用能夠使用更少的記憶體以及延遲獲取