《我想進大廠》之JVM奪命連環10問

科技繆繆發表於2020-10-24

這是面試專題系列第五篇JVM篇。

說說JVM的記憶體佈局?

Java虛擬機器主要包含幾個區域:

:堆Java虛擬機器中最大的一塊記憶體,是執行緒共享的記憶體區域,基本上所有的物件例項陣列都是在堆上分配空間。堆區細分為Yound區年輕代和Old區老年代,其中年輕代又分為Eden、S0、S1 3個部分,他們預設的比例是8:1:1的大小。

:棧是執行緒私有的記憶體區域,每個方法執行的時候都會在棧建立一個棧幀,方法的呼叫過程就對應著棧的入棧和出棧的過程。每個棧幀的結構又包含區域性變數表、運算元棧、動態連線、方法返回地址。

區域性變數表用於儲存方法引數和區域性變數。當第一個方法被呼叫的時候,他的引數會被傳遞至從0開始的連續的區域性變數表中。

運算元棧用於一些位元組碼指令從區域性變數表中傳遞至運算元棧,也用來準備方法呼叫的引數以及接收方法返回結果。

動態連線用於將符號引用表示的方法轉換為實際方法的直接引用。

後設資料:在Java1.7之前,包含方法區的概念,常量池就存在於方法區(永久代)中,而方法區本身是一個邏輯上的概念,在1.7之後則是把常量池移到了堆內,1.8之後移出了永久代的概念(方法區的概念仍然保留),實現方式則是現在的後設資料。它包含類的元資訊和執行時常量池。

Class檔案就是類和介面的定義資訊。

執行時常量池就是類和介面的常量池執行時的表現形式。

本地方法棧:主要用於執行本地native方法的區域

程式計數器:也是執行緒私有的區域,用於記錄當前執行緒下虛擬機器正在執行的位元組碼的指令地址

知道new一個物件的過程嗎?

當虛擬機器遇見new關鍵字時候,實現判斷當前類是否已經載入,如果類沒有載入,首先執行類的載入機制,載入完成後再為物件分配空間、初始化等。

  1. 首先校驗當前類是否被載入,如果沒有載入,執行類載入機制
  2. 載入:就是從位元組碼載入成二進位制流的過程
  3. 驗證:當然載入完成之後,當然需要校驗Class檔案是否符合虛擬機器規範,跟我們介面請求一樣,第一件事情當然是先做個引數校驗了
  4. 準備:為靜態變數、常量賦預設值
  5. 解析:把常量池中符號引用(以符號描述引用的目標)替換為直接引用(指向目標的指標或者控制程式碼等)的過程
  6. 初始化:執行static程式碼塊(cinit)進行初始化,如果存在父類,先對父類進行初始化

Ps:靜態程式碼塊是絕對執行緒安全的,只能隱式被java虛擬機器在類載入過程中初始化呼叫!(此處該有問題static程式碼塊執行緒安全嗎?)

當類載入完成之後,緊接著就是物件分配記憶體空間和初始化的過程

  1. 首先為物件分配合適大小的記憶體空間
  2. 接著為例項變數賦預設值
  3. 設定物件的頭資訊,物件hash碼、GC分代年齡、後設資料資訊等
  4. 執行建構函式(init)初始化

知道雙親委派模型嗎?

類載入器自頂向下分為:

  1. Bootstrap ClassLoader啟動類載入器:預設會去載入JAVA_HOME/lib目錄下的jar
  2. Extention ClassLoader擴充套件類載入器:預設去載入JAVA_HOME/lib/ext目錄下的jar
  3. Application ClassLoader應用程式類載入器:比如我們的web應用,會載入web程式中ClassPath下的類
  4. User ClassLoader使用者自定義類載入器:由使用者自己定義

當我們在載入類的時候,首先都會向上詢問自己的父載入器是否已經載入,如果沒有則依次向上詢問,如果沒有載入,則從上到下依次嘗試是否能載入當前類,直到載入成功。

說說有哪些垃圾回收演算法?

標記-清除

統一標記出需要回收的物件,標記完成之後統一回收所有被標記的物件,而由於標記的過程需要遍歷所有的GC ROOT,清除的過程也要遍歷堆中所有的物件,所以標記-清除演算法的效率低下,同時也帶來了記憶體碎片的問題。

複製演算法

為了解決效能的問題,複製演算法應運而生,它將記憶體分為大小相等的兩塊區域,每次使用其中的一塊,當一塊記憶體使用完之後,將還存活的物件拷貝到另外一塊記憶體區域中,然後把當前記憶體清空,這樣效能和記憶體碎片的問題得以解決。但是同時帶來了另外一個問題,可使用的記憶體空間縮小了一半!

因此,誕生了我們現在的常見的年輕代+老年代的記憶體結構:Eden+S0+S1組成,因為根據IBM的研究顯示,98%的物件都是朝生夕死,所以實際上存活的物件並不是很多,完全不需要用到一半記憶體浪費,所以預設的比例是8:1:1。

這樣,在使用的時候只使用Eden區和S0S1中的一個,每次都把存活的物件拷貝另外一個未使用的Survivor區,同時清空Eden和使用的Survivor,這樣下來記憶體的浪費就只有10%了。

如果最後未使用的Survivor放不下存活的物件,這些物件就進入Old老年代了。

PS:所以有一些初級點的問題會問你為什麼要分為Eden區和2個Survior區?有什麼作用?就是為了節省記憶體和解決記憶體碎片的問題,這些演算法都是為了解決問題而產生的,如果理解原因你就不需要死記硬背了

標記-整理

針對老年代再用複製演算法顯然不合適,因為進入老年代的物件都存活率比較高了,這時候再頻繁的複製對效能影響就比較大,而且也不會再有另外的空間進行兜底。所以針對老年代的特點,通過標記-整理演算法,標記出所有的存活物件,讓所有存活的物件都向一端移動,然後清理掉邊界以外的記憶體空間。

那麼什麼是GC ROOT?有哪些GC ROOT?

上面提到的標記的演算法,怎麼標記一個物件是否存活?簡單的通過引用計數法,給物件設定一個引用計數器,每當有一個地方引用他,就給計數器+1,反之則計數器-1,但是這個簡單的演算法無法解決迴圈引用的問題。

Java通過可達性分析演算法來達到標記存活物件的目的,定義一系列的GC ROOT為起點,從起點開始向下開始搜尋,搜尋走過的路徑稱為引用鏈,當一個物件到GC ROOT沒有任何引用鏈相連的話,則物件可以判定是可以被回收的。

而可以作為GC ROOT的物件包括:

  1. 棧中引用的物件

  2. 靜態變數、常量引用的物件

  3. 本地方法棧native方法引用的物件

垃圾回收器瞭解嗎?年輕代和老年代都有哪些垃圾回收器?

年輕代的垃圾收集器包含有Serial、ParNew、Parallell,老年代則包括Serial Old老年代版本、CMS、Parallel Old老年代版本和JDK11中的船新的G1收集器。

Serial:單執行緒版本收集器,進行垃圾回收的時候會STW(Stop The World),也就是進行垃圾回收的時候其他的工作執行緒都必須暫停

ParNew:Serial的多執行緒版本,用於和CMS配合使用

Parallel Scavenge:可以並行收集的多執行緒垃圾收集器

Serial Old:Serial的老年代版本,也是單執行緒

Parallel Old:Parallel Scavenge的老年代版本

CMS(Concurrent Mark Sweep):CMS收集器是以獲取最短停頓時間為目標的收集器,相對於其他的收集器STW的時間更短暫,可以並行收集是他的特點,同時他基於標記-清除演算法,整個GC的過程分為4步。

  1. 初始標記:標記GC ROOT能關聯到的物件,需要STW
  2. 併發標記:從GCRoots的直接關聯物件開始遍歷整個物件圖的過程,不需要STW
  3. 重新標記:為了修正併發標記期間,因使用者程式繼續運作而導致標記產生改變的標記,需要STW
  4. 併發清除:清理刪除掉標記階段判斷的已經死亡的物件,不需要STW

從整個過程來看,併發標記和併發清除的耗時最長,但是不需要停止使用者執行緒,而初始標記和重新標記的耗時較短,但是需要停止使用者執行緒,總體而言,整個過程造成的停頓時間較短,大部分時候是可以和使用者執行緒一起工作的。

G1(Garbage First):G1收集器是JDK9的預設垃圾收集器,而且不再區分年輕代和老年代進行回收。

G1的原理了解嗎?

G1作為JDK9之後的服務端預設收集器,且不再區分年輕代和老年代進行垃圾回收,他把記憶體劃分為多個Region,每個Region的大小可以通過-XX:G1HeapRegionSize設定,大小為1~32M,對於大物件的儲存則衍生出Humongous的概念,超過Region大小一半的物件會被認為是大物件,而超過整個Region大小的物件被認為是超級大物件,將會被儲存在連續的N個Humongous Region中,G1在進行回收的時候會在後臺維護一個優先順序列表,每次根據使用者設定允許的收集停頓時間優先回收收益最大的Region。

G1的回收過程分為以下四個步驟:

  1. 初始標記:標記GC ROOT能關聯到的物件,需要STW
  2. 併發標記:從GCRoots的直接關聯物件開始遍歷整個物件圖的過程,掃描完成後還會重新處理併發標記過程中產生變動的物件
  3. 最終標記:短暫暫停使用者執行緒,再處理一次,需要STW
  4. 篩選回收:更新Region的統計資料,對每個Region的回收價值和成本排序,根據使用者設定的停頓時間制定回收計劃。再把需要回收的Region中存活物件複製到空的Region,同時清理舊的Region。需要STW

總的來說除了併發標記之外,其他幾個過程也還是需要短暫的STW,G1的目標是在停頓和延遲可控的情況下儘可能提高吞吐量。

什麼時候會觸發YGC和FGC?物件什麼時候會進入老年代?

當一個新的物件來申請記憶體空間的時候,如果Eden區無法滿足記憶體分配需求,則觸發YGC,使用中的Survivor區和Eden區存活物件送到未使用的Survivor區,如果YGC之後還是沒有足夠空間,則直接進入老年代分配,如果老年代也無法分配空間,觸發FGC,FGC之後還是放不下則報出OOM異常。

YGC之後,存活的物件將會被複制到未使用的Survivor區,如果S區放不下,則直接晉升至老年代。而對於那些一直在Survivor區來回複製的物件,通過-XX:MaxTenuringThreshold配置交換閾值,預設15次,如果超過次數同樣進入老年代。

此外,還有一種動態年齡的判斷機制,不需要等到MaxTenuringThreshold就能晉升老年代。如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代。

頻繁FullGC怎麼排查?

這種問題最好的辦法就是結合有具體的例子舉例分析,如果沒有就說一般的分析步驟。發生FGC有可能是記憶體分配不合理,比如Eden區太小,導致物件頻繁進入老年代,這時候通過啟動引數配置就能看出來,另外有可能就是存在記憶體洩露,可以通過以下的步驟進行排查:

  1. jstat -gcutil或者檢視gc.log日誌,檢視記憶體回收情況

S0 S1 分別代表兩個Survivor區佔比

E代表Eden區佔比,圖中可以看到使用78%

O代表老年代,M代表元空間,YGC發生54次,YGCT代表YGC累計耗時,GCT代表GC累計耗時。

[GC [FGC 開頭代表垃圾回收的型別

PSYoungGen: 6130K->6130K(9216K)] 12274K->14330K(19456K), 0.0034895 secs代表YGC前後記憶體使用情況

Times: user=0.02 sys=0.00, real=0.00 secs,user表示使用者態消耗的CPU時間,sys表示核心態消耗的CPU時間,real表示各種牆時鐘的等待時間

這兩張圖只是舉例並沒有關聯關係,比如你從圖裡面看能到是否進行FGC,FGC的時間花費多長,GC後老年代,年輕代記憶體是否有減少,得到一些初步的情況來做出判斷。

  1. dump出記憶體檔案在具體分析,比如通過jmap命令jmap -dump:format=b,file=dumpfile pid,匯出之後再通過Eclipse Memory Analyzer等工具進行分析,定位到程式碼,修復

這裡還會可能存在一個提問的點,比如CPU飆高,同時FGC怎麼辦?辦法比較類似

  1. 找到當前程式的pid,top -p pid -H 檢視資源佔用,找到執行緒
  2. printf “%x\n” pid,把執行緒pid轉為16進位制,比如0x32d
  3. jstack pid|grep -A 10 0x32d檢視執行緒的堆疊日誌,還找不到問題繼續
  4. dump出記憶體檔案用MAT等工具進行分析,定位到程式碼,修復

JVM調優有什麼經驗嗎?

要明白一點,所有的調優的目的都是為了用更小的硬體成本達到更高的吞吐,JVM的調優也是一樣,通過對垃圾收集器和記憶體分配的調優達到效能的最佳。

簡單的引數含義

首先,需要知道幾個主要的引數含義。

  1. -Xms設定初始堆的大小,-Xmx設定最大堆的大小
  2. -XX:NewSize年輕代大小,-XX:MaxNewSize年輕代最大值,-Xmn則是相當於同時配置-XX:NewSize和-XX:MaxNewSize為一樣的值
  3. -XX:NewRatio設定年輕代和年老代的比值,如果為3,表示年輕代與老年代比值為1:3,預設值為2
  4. -XX:SurvivorRatio年輕代和兩個Survivor的比值,預設8,代表比值為8:1:1
  5. -XX:PretenureSizeThreshold 當建立的物件超過指定大小時,直接把物件分配在老年代。
  6. -XX:MaxTenuringThreshold設定物件在Survivor複製的最大年齡閾值,超過閾值轉移到老年代
  7. -XX:MaxDirectMemorySize當Direct ByteBuffer分配的堆外記憶體到達指定大小後,即觸發Full GC

調優

  1. 為了列印日誌方便排查問題最好開啟GC日誌,開啟GC日誌對效能影響微乎其微,但是能幫助我們快速排查定位問題。-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log
  2. 一般設定-Xms=-Xmx,這樣可以獲得固定大小的堆記憶體,減少GC的次數和耗時,可以使得堆相對穩定
  3. -XX:+HeapDumpOnOutOfMemoryError讓JVM在發生記憶體溢位的時候自動生成記憶體快照,方便排查問題
  4. -Xmn設定新生代的大小,太小會增加YGC,太大會減小老年代大小,一般設定為整個堆的1/4到1/3
  5. 設定-XX:+DisableExplicitGC禁止系統System.gc(),防止手動誤觸發FGC造成問題

- END -

相關文章