金三銀四必掌握知識點:JVM奪命連環10問

1. 說說 JVM 的內存佈局?

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

:堆是 Java 虛擬機中最大的一塊內存,是線程共享的內存區域,基本上所有的對象實例數組都是在堆上分配空間。堆區細分爲 Young 區年輕代和 Old 區老年代,其中年輕代又分爲 Eden、S0、S1 3個部分,他們默認的比例是 8:1:1 的大小。

:棧是線程私有的內存區域,每個方法執行的時候都會在棧創建一個棧幀,方法的調用過程就對應着棧的入棧和出棧的過程。每個棧幀的結構又包含局部變量表、操作數棧、動態連接、方法返回地址。

局部變量表用於存儲方法參數和局部變量。當第一個方法被調用的時候,它的參數會被傳遞至從0開始的連續的局部變量表中。

操作數棧用於一些字節碼指令從局部變量表中傳遞至操作數棧,也用來準備方法調用的參數以及接收方法返回結果。

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

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

class 文件就是類和接口的定義信息。

運行時常量池就是類和接口的常量池運行時的表現形式。

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

程序計數器:也是線程私有的區域,用於記錄當前線程下虛擬機正在執行的字節碼的指令地址。

2. 知道 new 一個對象的過程嗎?

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

  1. 首先校驗當前類是否被加載,如果沒有加載,執行類加載機制;

  2. 加載:就是從字節碼加載成二進制流的過程;

  3. 驗證:當然加載完成之後,當然需要校驗 class 文件是否符合虛擬機規範,跟我們接口請求一樣,第一件事情當然是先做個參數校驗了;

  4. 準備:爲靜態變量、常量賦默認值;

  5. 解析:把常量池中符號引用(以符號描述引用的目標)替換爲直接引用(指向目標的指針或者句柄等)的過程;

  6. 初始化:執行 static 代碼塊 (cinit) 進行初始化,如果存在父類,先對父類進行初始化。

注意:靜態代碼塊是絕對線程安全的,只能隱式被 Java 虛擬機在類加載過程中初始化調用!(此處該有問題:static 代碼塊線程安全嗎?)

當類加載完成之後,緊接着就是對象分配內存空間和初始化的過程:

  1. 首先爲對象分配合適大小的內存空間;

  2. 接着爲實例變量賦默認值;

  3. 設置對象的頭信息,對象 hashcode、GC 分代年齡、元數據信息等;

  4. 執行構造函數 (init) 初始化。

3. 知道雙親委派模型嗎?

類加載器自頂向下分爲:

  1. Bootstrap ClassLoader(啓動類加載器):默認會去加載 JAVA_HOME/lib 目錄下的 jar;

  2. Extention ClassLoader(擴展類加載器):默認去加載 JAVA_HOME/lib/ext 目錄下的 jar;

  3. Application ClassLoader(應用程序類加載器):比如我們的 Web 應用,會加載 Web 程序中 ClassPath 下的類;

  4. User ClassLoader(用戶自定義類加載器):由用戶自己定義。

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

4. 說說有哪些垃圾回收算法?

標記-清除

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

複製算法

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

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

這樣,在使用的時候只使用 Eden 區和 S0、S1 中的一個,每次都把存活的對象拷貝另外一個未使用的 Survivor 區,同時清空 Eden 和使用的 Survivor,這樣下來內存的浪費就只有10%了。

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

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

標記-整理

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

5. 什麼是 GC ROOT?有哪些 GC ROOT?

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

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

而可以作爲 GC ROOT 的對象包括:

  1. 棧中引用的對象;

  2. 靜態變量、常量引用的對象;

  3. 本地方法棧 native 方法引用的對象。

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

年輕代的垃圾收集器包含有 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 的默認垃圾收集器,不再區分年輕代和老年代進行回收。

7. 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 的目標是在停頓和延遲可控的情況下儘可能提高吞吐量。

8. 什麼時候會觸發 YGC 和 FGC?對象什麼時候會進入老年代?

當一個新的對象來申請內存空間的時候,如果 Eden 區無法滿足內存分配需求,則觸發 YGC。使用中的 Survivor 區和 Eden 區存活對象送到未使用的 Survivor 區。

如果 YGC 之後還是沒有足夠空間,則直接進入老年代分配。如果老年代也無法分配空間,觸發 FGC,FGC 之後還是放不下則報出 OOM 異常。

YGC 之後,存活的對象將會被複制到未使用的 Survivor 區。如果 S 區放不下,則直接晉升至老年代。

而對於那些一直在 Survivor 區來回複製的對象,通過 -XX:MaxTenuringThreshold 配置交換閾值,默認15次。如果超過次數同樣進入老年代。

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

9. 頻繁 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 後老年代,年輕代內存是否有減少;得到一些初步的情況來做出判斷。

2. 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 等工具進行分析,定位到代碼、修復。

10. JVM調優有什麼經驗嗎?

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

簡單的參數含義

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

  • -Xms 設置初始堆的大小,**-Xmx **設置最大堆的大小;

  • -XX:NewSize 年輕代大小,-XX:MaxNewSize 年輕代最大值,-Xmn 則是相當於同時配置 -XX:NewSize 和 -XX:MaxNewSize 爲一樣的值;

  • -XX:NewRatio 設置年輕代和年老代的比值。如果爲3,表示年輕代與老年代比值爲 1:3,默認值爲2;

  • -XX:SurvivorRatio 年輕代和兩個 Survivor 的比值。默認值爲8,代表比值爲 8:1:1;

  • -XX:PretenureSizeThreshold 當創建的對象超過指定大小時,直接把對象分配在老年代;

  • -XX:MaxTenuringThreshold 設定對象在 Survivor 複製的最大年齡閾值,超過閾值轉移到老年代;

  • -XX:MaxDirectMemorySize 當 Direct ByteBuffer 分配的堆外內存到達指定大小後,即觸發 Full GC。

調優

  • 爲了打印日誌方便排查問題最好開啓GC日誌。開啓GC日誌對性能影響微乎其微,但是能幫助我們快速排查定位問題。-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log

  • 一般設置 -Xms=-Xmx。這樣可以獲得固定大小的堆內存,減少 GC 次數和耗時,可以使得堆相對穩定;

  • -XX:+HeapDumpOnOutOfMemoryError 讓 JVM 在發生內存溢出的時候自動生成內存快照,方便排查問題;

  • -Xmn 設置新生代的大小。太小會增加 YGC,太大會減小老年代大小,一般設置爲整個堆的1/4到1/3

  • 設置 **-XX:+DisableExplicitGC **禁止系統 System.gc()。防止手動誤觸發 FGC 造成問題。

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