皮皮爆肝 32 道高頻 JVM 面試題(附答案)

JVM 32

內存區域劃分 8

Q1:運行時數據區是什麼?

虛擬機在執行 Java 程序的過程中會把它所管理的內存劃分爲若干不同的數據區,這些區域有各自的用途、創建和銷燬時間。

線程私有:程序計數器、Java 虛擬機棧、本地方法棧。

線程共享:Java 堆、方法區。


Q2:程序計數器是什麼?

程序計數器是一塊較小的內存空間,可以看作當前線程所執行字節碼的行號指示器。字節碼解釋器工作時通過改變計數器的值選取下一條執行指令。分支、循環、跳轉、線程恢復等功能都需要依賴計數器完成。是唯一在虛擬機規範中沒有規定內存溢出情況的區域。

如果線程正在執行 Java 方法,計數器記錄正在執行的虛擬機字節碼指令地址。如果是本地方法,計數器值爲 Undefined。


Q3:Java 虛擬機棧的作用?

Java 虛擬機棧來描述 Java 方法的內存模型。每當有新線程創建時就會分配一個棧空間,線程結束後棧空間被回收,棧與線程擁有相同的生命週期。棧中元素用於支持虛擬機進行方法調用,每個方法在執行時都會創建一個棧幀存儲方法的局部變量表、操作棧、動態鏈接和方法出口等信息。每個方法從調用到執行完成,就是棧幀從入棧到出棧的過程。

有兩類異常:① 線程請求的棧深度大於虛擬機允許的深度拋出 StackOverflowError。② 如果 JVM 棧容量可以動態擴展,棧擴展無法申請足夠內存拋出 OutOfMemoryError(HotSpot 不可動態擴展,不存在此問題)。


Q4:本地方法棧的作用?

本地方法棧與虛擬機棧作用相似,不同的是虛擬機棧爲虛擬機執行 Java 方法服務,本地方法棧爲虛本地方法服務。調用本地方法時虛擬機棧保持不變,動態鏈接並直接調用指定本地方法。

虛擬機規範對本地方法棧中方法的語言與數據結構無強制規定,虛擬機可自由實現,例如 HotSpot 將虛擬機棧和本地方法棧合二爲一。

本地方法棧在棧深度異常和棧擴展失敗時分別拋出 StackOverflowError 和 OutOfMemoryError。


Q5:堆的作用是什麼?

是虛擬機所管理的內存中最大的一塊,被所有線程共享的,在虛擬機啓動時創建。堆用來存放對象實例,Java 裏幾乎所有對象實例都在堆分配內存。堆可以處於物理上不連續的內存空間,邏輯上應該連續,但對於例如數組這樣的大對象,多數虛擬機實現出於簡單、存儲高效的考慮會要求連續的內存空間。

堆既可以被實現成固定大小,也可以是可擴展的,可通過 -Xms-Xmx 設置堆的最小和最大容量,當前主流 JVM 都按照可擴展實現。如果堆沒有內存完成實例分配也無法擴展,拋出 OutOfMemoryError。


Q6:方法區的作用是什麼?

方法區用於存儲被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。

JDK8 之前使用永久代實現方法區,容易內存溢出,因爲永久代有 -XX:MaxPermSize 上限,即使不設置也有默認大小。JDK7 把放在永久代的字符串常量池、靜態變量等移出,JDK8 中永久代完全廢棄,改用在本地內存中實現的元空間代替,把 JDK 7 中永久代剩餘內容(主要是類型信息)全部移到元空間。

虛擬機規範對方法區的約束寬鬆,除和堆一樣不需要連續內存和可選擇固定大小/可擴展外,還可以不實現垃圾回收。垃圾回收在方法區出現較少,主要目標針對常量池和類型卸載。如果方法區無法滿足新的內存分配需求,將拋出 OutOfMemoryError。


Q7:運行時常量池的作用是什麼?

運行時常量池是方法區的一部分,Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表,用於存放編譯器生成的各種字面量與符號引用,這部分內容在類加載後存放到運行時常量池。一般除了保存 Class 文件中描述的符號引用外,還會把符號引用翻譯的直接引用也存儲在運行時常量池。

運行時常量池相對於 Class 文件常量池的一個重要特徵是動態性,Java 不要求常量只有編譯期才能產生,運行期間也可以將新的常量放入池中,這種特性利用較多的是 String 的 intern 方法。

運行時常量池是方法區的一部分,受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError。


Q8:直接內存是什麼?

直接內存不屬於運行時數據區,也不是虛擬機規範定義的內存區域,但這部分內存被頻繁使用,而且可能導致內存溢出。

JDK1.4 中新加入了 NIO 這種基於通道與緩衝區的 IO,它可以使用 Native 函數庫直接分配堆外內存,通過一個堆裏的 DirectByteBuffer 對象作爲內存的引用進行操作,避免了在 Java 堆和 Native堆來回複製數據。

直接內存的分配不受 Java 堆大小的限制,但還是會受到本機總內存及處理器尋址空間限制,一般配置虛擬機參數時會根據實際內存設置 -Xmx 等參數信息,但經常忽略直接內存,使內存區域總和大於物理內存限制,導致動態擴展時出現 OOM。

由直接內存導致的內存溢出,一個明顯的特徵是在 Heap Dump 文件中不會看見明顯的異常,如果發現內存溢出後產生的 Dump 文件很小,而程序中又直接或間接使用了直接內存(典型的間接使用就是 NIO),那麼就可以考慮檢查直接內存方面的原因。


內存溢出 5

Q1:內存溢出和內存泄漏的區別?

內存溢出 OutOfMemory,指程序在申請內存時,沒有足夠的內存空間供其使用。

內存泄露 Memory Leak,指程序在申請內存後,無法釋放已申請的內存空間,內存泄漏最終將導致內存溢出。


Q2:堆溢出的原因?

堆用於存儲對象實例,只要不斷創建對象並保證 GC Roots 到對象有可達路徑避免垃圾回收,隨着對象數量的增加,總容量觸及最大堆容量後就會 OOM,例如在 while 死循環中一直 new 創建實例。

堆 OOM 是實際應用中最常見的 OOM,處理方法是通過內存映像分析工具對 Dump 出的堆轉儲快照分析,確認內存中導致 OOM 的對象是否必要,分清到底是內存泄漏還是內存溢出。

如果是內存泄漏,通過工具查看泄漏對象到 GC Roots 的引用鏈,找到泄露對象是通過怎樣的引用路徑、與哪些 GC Roots 關聯才導致無法回收,一般可以準確定位到產生內存泄漏代碼的具***置。

如果不是內存泄漏,即內存中對象都必須存活,應當檢查 JVM 堆參數,與機器內存相比是否還有向上調整的空間。再從代碼檢查是否存在某些對象生命週期過長、持有狀態時間過長、存儲結構設計不合理等情況,儘量減少程序運行期的內存消耗。


Q3:棧溢出的原因?

由於 HotSpot 不區分虛擬機和本地方法棧,設置本地方法棧大小的參數沒有意義,棧容量只能由 -Xss 參數來設定,存在兩種異常:

StackOverflowError: 如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError,例如一個遞歸方法不斷調用自己。該異常有明確錯誤堆棧可供分析,容易定位到問題所在。

OutOfMemoryError: 如果 JVM 棧可以動態擴展,當擴展無法申請到足夠內存時會拋出 OutOfMemoryError。HotSpot 不支持虛擬機棧擴展,所以除非在創建線程申請內存時就因無法獲得足夠內存而出現 OOM,否則在線程運行時是不會因爲擴展而導致溢出的。


Q4:運行時常量池溢出的原因?

String 的 intern 方法是一個本地方法,作用是如果字符串常量池中已包含一個等於此 String 對象的字符串,則返回池中這個字符串的 String 對象的引用,否則將此 String 對象包含的字符串添加到常量池並返回此 String 對象的引用。

在 JDK6 及之前常量池分配在永久代,因此可以通過 -XX:PermSize-XX:MaxPermSize 限制永久代大小,間接限制常量池。在 while 死循環中調用 intern 方法導致運行時常量池溢出。在 JDK7 後不會出現該問題,因爲存放在永久代的字符串常量池已經被移至堆中。


Q5:方法區溢出的原因?

方法區主要存放類型信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。只要不斷在運行時產生大量類,方法區就會溢出。例如使用 JDK 反射或 CGLib 直接操作字節碼在運行時生成大量的類。很多框架如 Spring、Hibernate 等對類增強時都會使用 CGLib 這類字節碼技術,增強的類越多就需要越大的方法區保證動態生成的新類型可以載入內存,也就更容易導致方法區溢出。

JDK8 使用元空間取代永久代,HotSpot 提供了一些參數作爲元空間防禦措施,例如 -XX:MetaspaceSize 指定元空間初始大小,達到該值會觸發 GC 進行類型卸載,同時收集器會對該值進行調整,如果釋放大量空間就適當降低該值,如果釋放很少空間就適當提高。


創建對象 5

Q1:創建對象的過程是什麼?

字節碼角度

  • NEW: 如果找不到 Class 對象則進行類加載。加載成功後在堆中分配內存,從 Object 到本類路徑上的所有屬性都要分配。分配完畢後進行零值設置。最後將指向實例對象的引用變量壓入虛擬機棧頂。
  • **DUP:** 在棧頂複製引用變量,這時棧頂有兩個指向堆內實例的引用變量。兩個引用變量的目的不同,棧底的引用用於賦值或保存局部變量表,棧頂的引用作爲句柄調用相關方法。
  • INVOKESPECIAL: 通過棧頂的引用變量調用 init 方法。

執行角度

① 當 JVM 遇到字節碼 new 指令時,首先將檢查該指令的參數能否在常量池中定位到一個類的符號引用,並檢查引用代表的類是否已被加載、解析和初始化,如果沒有就先執行類加載。

② 在類加載檢查通過後虛擬機將爲新生對象分配內存。

③ 內存分配完成後虛擬機將成員變量設爲零值,保證對象的實例字段可以不賦初值就使用。

④ 設置對象頭,包括哈希碼、GC 信息、鎖信息、對象所屬類的類元信息等。

⑤ 執行 init 方法,初始化成員變量,執行實例化代碼塊,調用類的構造方法,並把堆內對象的首地址賦值給引用變量。


Q2:對象分配內存的方式有哪些?

對象所需內存大小在類加載完成後便可完全確定,分配空間的任務實際上等於把一塊確定大小的內存塊從 Java 堆中劃分出來。

指針碰撞: 假設 Java 堆內存規整,被使用過的內存放在一邊,空閒的放在另一邊,中間放着一個指針作爲分界指示器,分配內存就是把指針向空閒方向挪動一段與對象大小相等的距離。

空閒列表: 如果 Java 堆內存不規整,虛擬機必須維護一個列表記錄哪些內存可用,在分配時從列表中找到一塊足夠大的空間劃分給對象並更新列表記錄。

選擇哪種分配方式由堆是否規整決定,堆是否規整由垃圾收集器是否有空間壓縮能力決定。使用 Serial、ParNew 等收集器時,系統採用指針碰撞;使用 CMS 這種基於清除算法的垃圾收集器時,採用空間列表。


Q3:對象分配內存是否線程安全?

對象創建十分頻繁,即使修改一個指針的位置在併發下也不是線程安全的,可能正給對象 A 分配內存,指針還沒來得及修改,對象 B 又使用了指針來分配內存。

解決方法:① CAS 加失敗重試保證更新原子性。② 把內存分配按線程劃分在不同空間,即每個線程在 Java 堆中預先分配一小塊內存,叫做本地線程分配緩衝 TLAB,哪個線程要分配內存就在對應的 TLAB 分配,TLAB 用完了再進行同步。


Q4:對象的內存佈局瞭解嗎?

對象在堆內存的存儲佈局可分爲對象頭、實例數據和對齊填充。

對象頭佔 12B,包括對象標記和類型指針。對象標記存儲對象自身的運行時數據,如哈希碼、GC 分代年齡、鎖標誌、偏向線程 ID 等,這部分佔 8B,稱爲 Mark Word。Mark Word 被設計爲動態數據結構,以便在極小的空間存儲更多數據,根據對象狀態複用存儲空間。

類型指針是對象指向它的類型元數據的指針,佔 4B。JVM 通過該指針來確定對象是哪個類的實例。

實例數據是對象真正存儲的有效信息,即本類對象的實例成員變量和所有可見的父類成員變量。存儲順序會受到虛擬機分配策略參數和字段在源碼中定義順序的影響。相同寬度的字段總是被分配到一起存放,在滿足該前提條件的情況下父類中定義的變量會出現在子類之前。

對齊填充不是必然存在的,僅起佔位符作用。虛擬機的自動內存管理系統要求任何對象的大小必須是 8B 的倍數,對象頭已被設爲 8B 的 1 或 2 倍,如果對象實例數據部分沒有對齊,需要對齊填充補全。


Q5:對象的訪問方式有哪些?

Java 程序會通過棧上的 reference 引用操作堆對象,訪問方式由虛擬機決定,主流訪問方式主要有句柄和直接指針。

句柄: 堆會劃分出一塊內存作爲句柄池,reference 中存儲對象的句柄地址,句柄包含對象實例數據與類型數據的地址信息。優點是 reference 中存儲的是穩定句柄地址,在 GC 過程中對象被移動時只會改變句柄的實例數據指針,而 reference 本身不需要修改。

直接指針: 堆中對象的內存佈局就必須考慮如何放置訪問類型數據的相關信息,reference 存儲對象地址,如果只是訪問對象本身就不需要多一次間接訪問的開銷。優點是速度更快,節省了一次指針定位的時間開銷,HotSpot 主要使用直接指針進行對象訪問。


垃圾回收 7

Q1:如何判斷對象是否是垃圾?

**引用計數:**在對象中添加一個引用計數器,如果被引用計數器加 1,引用失效時計數器減 1,如果計數器爲 0 則被標記爲垃圾。原理簡單,效率高,但是在 Java 中很少使用,因爲存在對象間循環引用的問題,導致計數器無法清零。

**可達性分析:**主流語言的內存管理都使用可達性分析判斷對象是否存活。基本思路是通過一系列稱爲 GC Roots 的根對象作爲起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程走過的路徑稱爲引用鏈,如果某個對象到 GC Roots 沒有任何引用鏈相連,則會被標記爲垃圾。可作爲 GC Roots 的對象包括虛擬機棧和本地方法棧中引用的對象、類靜態屬性引用的對象、常量引用的對象。


Q2:Java 的引用有哪些類型?

JDK1.2 後對引用進行了擴充,按強度分爲四種:

強引用: 最常見的引用,例如 Object obj = new Object() 就屬於強引用。只要對象有強引用指向且 GC Roots 可達,在內存回收時即使瀕臨內存耗盡也不會被回收。

軟引用: 弱於強引用,描述非必需對象。在系統將發生內存溢出前,會把軟引用關聯的對象加入回收範圍以獲得更多內存空間。用來緩存服務器中間計算結果及不需要實時保存的用戶行爲等。

弱引用: 弱於軟引用,描述非必需對象。弱引用關聯的對象只能生存到下次 YGC 前,當垃圾收集器開始工作時無論當前內存是否足夠都會回收只被弱引用關聯的對象。由於 YGC 具有不確定性,因此弱引用何時被回收也不確定。

虛引用: 最弱的引用,定義完成後無法通過該引用獲取對象。唯一目的就是爲了能在對象被回收時收到一個系統通知。虛引用必須與引用隊列聯合使用,垃圾回收時如果出現虛引用,就會在回收對象前把這個虛引用加入引用隊列。


Q3:有哪些 GC 算法?

標記-清除算法

分爲標記和清除階段,首先從每個 GC Roots 出發依次標記有引用關係的對象,最後清除沒有標記的對象。

執行效率不穩定,如果堆包含大量對象且大部分需要回收,必須進行大量標記清除,導致效率隨對象數量增長而降低。

存在內存空間碎片化問題,會產生大量不連續的內存碎片,導致以後需要分配大對象時容易觸發 Full GC。

標記-複製算法

爲了解決內存碎片問題,將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中一塊。當使用的這塊空間用完了,就將存活對象複製到另一塊,再把已使用過的內存空間一次清理掉。主要用於進行新生代。

實現簡單、運行高效,解決了內存碎片問題。代價是可用內存縮小爲原來的一半,浪費空間。

HotSpot 把新生代劃分爲一塊較大的 Eden 和兩塊較小的 Survivor,每次分配內存只使用 Eden 和其中一塊 Survivor。垃圾收集時將 Eden 和 Survivor 中仍然存活的對象一次性複製到另一塊 Survivor 上,然後直接清理掉 Eden 和已用過的那塊 Survivor。HotSpot 默認Eden 和 Survivor 的大小比例是 8:1,即每次新生代中可用空間爲整個新生代的 90%。

標記-整理算法

標記-複製算法在對象存活率高時要進行較多複製操作,效率低。如果不想浪費空間,就需要有額外空間分配擔保,應對被使用內存中所有對象都存活的極端情況,所以老年代一般不使用此算法。

老年代使用標記-整理算法,標記過程與標記-清除算法一樣,但不直接清理可回收對象,而是讓所有存活對象都向內存空間一端移動,然後清理掉邊界以外的內存。

標記-清除與標記-整理的差異在於前者是一種非移動式算法而後者是移動式的。如果移動存活對象,尤其是在老年代這種每次回收都有大量對象存活的區域,是一種極爲負重的操作,而且移動必須全程暫停用戶線程。如果不移動對象就會導致空間碎片問題,只能依賴更復雜的內存分配器和訪問器解決。


Q4:你知道哪些垃圾收集器?

序列號

最基礎的收集器,使用複製算法、單線程工作,只用一個處理器或一條線程完成垃圾收集,進行垃圾收集時必須暫停其他所有工作線程。

Serial 是虛擬機在客戶端模式的默認新生代收集器,簡單高效,對於內存受限的環境它是所有收集器中額外內存消耗最小的,對於處理器核心較少的環境,Serial 由於沒有線程交互開銷,可獲得最高的單線程收集效率。

新品

Serial 的多線程版本,除了使用多線程進行垃圾收集外其餘行爲完全一致。

ParNew 是虛擬機在服務端模式的默認新生代收集器,一個重要原因是除了 Serial 外只有它能與 CMS 配合。自從 JDK 9 開始,ParNew 加 CMS 不再是官方推薦的解決方案,官方希望它被 G1 取代。

並行清理

新生代收集器,基於複製算法,是可並行的多線程收集器,與 ParNew 類似。

特點是它的關注點與其他收集器不同,Parallel Scavenge 的目標是達到一個可控制的吞吐量,吞吐量就是處理器用於運行用戶代碼的時間與處理器消耗總時間的比值。

串行舊

Serial 的老年代版本,單線程工作,使用標記-整理算法。

Serial Old 是虛擬機在客戶端模式的默認老年代收集器,用於服務端有兩種用途:① JDK5 及之前與 Parallel Scavenge 搭配。② 作爲CMS 失敗預案。

平行老

Parallel Scavenge 的老年代版本,支持多線程,基於標記-整理算法。JDK6 提供,注重吞吐量可考慮 Parallel Scavenge 加 Parallel Old。

不育系

以獲取最短回收停頓時間爲目標,基於標記-清除算法,過程相對複雜,分爲四個步驟:初始標記、併發標記、重新標記、併發清除。

初始標記和重新標記需要 STW(Stop The World,系統停頓),初始標記僅是標記 GC Roots 能直接關聯的對象,速度很快。併發標記從 GC Roots 的直接關聯對象開始遍歷整個對象圖,耗時較長但不需要停頓用戶線程。重新標記則是爲了修正併發標記期間因用戶程序運作而導致標記產生變動的那部分記錄。併發清除清理標記階段判斷的已死亡對象,不需要移動存活對象,該階段也可與用戶線程併發。

缺點:① 對處理器資源敏感,併發階段雖然不會導致用戶線程暫停,但會降低吞吐量。② 無法處理浮動垃圾,有可能出現併發失敗而導致 Full GC。③ 基於標記-清除算法,產生空間碎片。

G1

開創了收集器面向局部收集的設計思路和基於 Region 的內存佈局,主要面向服務端,最初設計目標是替換 CMS。

G1 之前的收集器,垃圾收集目標要麼是整個新生代,要麼是整個老年代或整個堆。而 G1 可面向堆任何部分來組成回收集進行回收,衡量標準不再是分代,而是哪塊內存中存放的垃圾數量最多,回收受益最大。

跟蹤各 Region 裏垃圾的價值,價值即回收所獲空間大小以及回收所需時間的經驗值,在後臺維護一個優先級列表,每次根據用戶設定允許的收集停頓時間優先處理回收價值最大的 Region。這種方式保證了 G1 在有限時間內獲取儘可能高的收集效率。

G1 運作過程:

  • **初始標記:**標記 GC Roots 能直接關聯到的對象,讓下一階段用戶線程併發運行時能正確地在可用 Region 中分配新對象。需要 STW 但耗時很短,在 Minor GC 時同步完成。
  • **併發標記:**從 GC Roots 開始對堆中對象進行可達性分析,遞歸掃描整個堆的對象圖。耗時長但可與用戶線程併發,掃描完成後要重新處理 SATB 記錄的在併發時有變動的對象。
  • **最終標記:**對用戶線程做短暫暫停,處理併發階段結束後仍遺留下來的少量 SATB 記錄。
  • **篩選回收:**對各 Region 的回收價值排序,根據用戶期望停頓時間制定回收計劃。必須暫停用戶線程,由多條收集線程並行完成。

可由用戶指定期望停頓時間是 G1 的一個強大功能,但該值不能設得太低,一般設置爲100~300 ms。


Q5:ZGC 瞭解嗎?

JDK11 中加入的具有實驗性質的低延遲垃圾收集器,目標是儘可能在不影響吞吐量的前提下,實現在任意堆內存大小都可以把停頓時間限制在 10ms 以內的低延遲。

基於 Region 內存佈局,不設分代,使用了讀屏障、染色指針和內存多重映射等技術實現可併發的標記-整理,以低延遲爲首要目標。

ZGC 的 Region 具有動態性,是動態創建和銷燬的,並且容量大小也是動態變化的。


Q6:你知道哪些內存分配與回收策略?

對象優先在 Eden 區分配

大多數情況下對象在新生代 Eden 區分配,當 Eden 沒有足夠空間時將發起一次 Minor GC。

大對象直接進入老年代

大對象指需要大量連續內存空間的對象,典型是很長的字符串或數量龐大的數組。大對象容易導致內存還有不少空間就提前觸發垃圾收集以獲得足夠的連續空間。

HotSpot 提供了 -XX:PretenureSizeThreshold 參數,大於該值的對象直接在老年代分配,避免在 Eden 和 Survivor 間來回複製。

長期存活對象進入老年代

虛擬機給每個對象定義了一個對象年齡計數器,存儲在對象頭。如果經歷過第一次 Minor GC 仍然存活且能被 Survivor 容納,該對象就會被移動到 Survivor 中並將年齡設置爲 1。對象在 Survivor 中每熬過一次 Minor GC 年齡就加 1 ,當增加到一定程度(默認15)就會被晉升到老年代。對象晉升老年代的閾值可通過 -XX:MaxTenuringThreshold 設置。

動態對象年齡判定

爲了適應不同內存狀況,虛擬機不要求對象年齡達到閾值才能晉升老年代,如果在 Survivor 中相同年齡所有對象大小的總和大於 Survivor 的一半,年齡不小於該年齡的對象就可以直接進入老年代。

空間分配擔保

MinorGC 前虛擬機必須檢查老年代最大可用連續空間是否大於新生代對象總空間,如果滿足則說明這次 Minor GC 確定安全。

如果不滿足,虛擬機會查看 -XX:HandlePromotionFailure 參數是否允許擔保失敗,如果允許會繼續檢查老年代最大可用連續空間是否大於歷次晉升老年代對象的平均大小,如果滿足將冒險嘗試一次 Minor GC,否則改成一次 FullGC。

冒險是因爲新生代使用複製算法,爲了內存利用率只使用一個 Survivor,大量對象在 Minor GC 後仍然存活時,需要老年代進行分配擔保,接收 Survivor 無法容納的對象。


Q7:你知道哪些故障處理工具?

jps:虛擬機進程狀況工具

功能和 ps 命令類似:可以列出正在運行的虛擬機進程,顯示虛擬機執行主類名稱以及這些進程的本地虛擬機唯一 ID(LVMID)。LVMID 與操作系統的進程 ID(PID)一致,使用 Windows 的任務管理器或 UNIX 的 ps 命令也可以查詢到虛擬機進程的 LVMID,但如果同時啓動了多個虛擬機進程,必須依賴 jps 命令。

jstat:虛擬機統計信息監視工具

用於監視虛擬機各種運行狀態信息。可以顯示本地或遠程虛擬機進程中的類加載、內存、垃圾收集、即時編譯器等運行時數據,在沒有 GUI 界面的服務器上是運行期定位虛擬機性能問題的常用工具。

參數含義:S0 和 S1 表示兩個 Survivor,E 表示新生代,O 表示老年代,YGC 表示 Young GC 次數,YGCT 表示 Young GC 耗時,FGC 表示 Full GC 次數,FGCT 表示 Full GC 耗時,GCT 表示 GC 總耗時。

jinfo:Java 配置信息工具

實時查看和調整虛擬機各項參數,使用 jps 的 -v 參數可以查看虛擬機啓動時顯式指定的參數,但如果想知道未顯式指定的參數值只能使用 jinfo 的 -flag 查詢。

jmap:Java 內存映像工具

用於生成堆轉儲快照,還可以查詢 finalize 執行隊列、Java 堆和方法區的詳細信息,如空間使用率,當前使用的是哪種收集器等。和 jinfo 一樣,部分功能在 Windows 受限,除了生成堆轉儲快照的 -dump 和查看每個類實例的 -histo 外,其餘選項只能在 Linux 使用。

jhat:虛擬機堆轉儲快照分析工具

JDK 提供 jhat 與 jmap 搭配使用分析 jmap 生成的堆轉儲快照。jhat 內置了一個微型的 HTTP/Web 服務器,生成堆轉儲快照的分析結果後可以在瀏覽器查看。

jstack:Java 堆棧跟蹤工具

用於生成虛擬機當前時刻的線程快照。線程快照就是當前虛擬機內每一條線程正在執行的方法堆棧的集合,生成線程快照的目的通常是定位線程出現長時間停頓的原因,如線程間死鎖、死循環、請求外部資源導致的長時間掛起等。線程出現停頓時通過 jstack 查看各個線程的調用堆棧,可以獲知沒有響應的線程在後臺做什麼或等什麼資源。


類加載機制 7

Q1:Java 程序是怎樣運行的?

  • 首先通過 Javac 編譯器將 .java 轉爲 JVM 可加載的 .class 字節碼文件。

    Javac 是由 Java 編寫的程序,編譯過程可以分爲:① 詞法解析,通過空格分割出單詞、操作符、控制符等信息,形成 token 信息流,傳遞給語法解析器。② 語法解析,把 token 信息流按照 Java 語法規則組裝成語法樹。③ 語義分析,檢查關鍵字使用是否合理、類型是否匹配、作用域是否正確等。④ 字節碼生成,將前面各個步驟的信息轉換爲字節碼。

    字節碼必須通過類加載過程加載到 JVM 後纔可以執行,執行有三種模式,解釋執行、JIT 編譯執行、JIT 編譯與解釋器混合執行(主流 JVM 默認執行的方式)。混合模式的優勢在於解釋器在啓動時先解釋執行,省去編譯時間。

  • 之後通過即時編譯器 JIT 把字節碼文件編譯成本地機器碼。

    Java 程序最初都是通過解釋器進行解釋執行的,當虛擬機發現某個方法或代碼塊的運行特別頻繁,就會認定其爲"熱點代碼",熱點代碼的檢測主要有基於採樣和基於計數器兩種方式,爲了提高熱點代碼的執行效率,虛擬機會把它們編譯成本地機器碼,儘可能對代碼優化,在運行時完成這個任務的後端編譯器被稱爲即時編譯器。

  • 還可以通過靜態的提前編譯器 AOT 直接把程序編譯成與目標機器指令集相關的二進制代碼。


Q2:類加載是什麼?

Class 文件中描述的各類信息都需要加載到虛擬機後才能使用。JVM 把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型,這個過程稱爲虛擬機的類加載機制。

與編譯時需要連接的語言不同,Java 中類型的加載、連接和初始化都是在運行期間完成的,這增加了性能開銷,但卻提供了極高的擴展性,Java 動態擴展的語言特性就是依賴運行期動態加載和連接實現的。

一個類型從被加載到虛擬機內存開始,到卸載出內存爲止,整個生命週期經歷加載、驗證、準備、解析、初始化、使用和卸載七個階段,其中驗證、解析和初始化三個部分稱爲連接。加載、驗證、準備、初始化階段的順序是確定的,解析則不一定:可能在初始化之後再開始,這是爲了支持 Java 的動態綁定。


Q3:類初始化的情況有哪些?

① 遇到 newgetstaticputstaticinvokestatic 字節碼指令時,還未初始化。典型場景包括 new 實例化對象、讀取或設置靜態字段、調用靜態方法。

② 對類反射調用時,還未初始化。

③ 初始化類時,父類還未初始化。

④ 虛擬機啓動時,會先初始化包含 main 方法的主類。

⑤ 使用 JDK7 的動態語言支持時,如果 MethodHandle 實例的解析結果爲指定類型的方法句柄且句柄對應的類還未初始化。

⑥ 接口定義了默認方法,如果接口的實現類初始化,接口要在其之前初始化。

其餘所有引用類型的方式都不會觸發初始化,稱爲被動引用。被動引用實例:① 子類使用父類的靜態字段時,只有父類被初始化。② 通過數組定義使用類。③ 常量在編譯期會存入調用類的常量池,不會初始化定義常量的類。

接口和類加載過程的區別:初始化類時如果父類沒有初始化需要初始化父類,但接口初始化時不要求父接口初始化,只有在真正使用父接口時(如引用接口中定義的常量)纔會初始化。


Q4:類加載的過程是什麼?

加載

該階段虛擬機需要完成三件事:① 通過一個類的全限定類名獲取定義類的二進制字節流。② 將字節流所代表的靜態存儲結構轉化爲方法區的運行時數據區。③ 在內存中生成對應該類的 Class 實例,作爲方法區這個類的數據訪問入口。

驗證

確保 Class 文件的字節流符合約束。如果虛擬機不檢查輸入的字節流,可能因爲載入有錯誤或惡意企圖的字節流而導致系統受攻擊。驗證主要包含四個階段:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

驗證重要但非必需,因爲只有通過與否的區別,通過後對程序運行期沒有任何影響。如果代碼已被反覆使用和驗證過,在生產環境就可以考慮關閉大部分驗證縮短類加載時間。

準備

爲類靜態變量分配內存並設置零值,該階段進行的內存分配僅包括類變量,不包括實例變量。如果變量被 final 修飾,編譯時 Javac 會爲變量生成 ConstantValue 屬性,準備階段虛擬機會將變量值設爲代碼值。

解析

將常量池內的符號引用替換爲直接引用。

符號引用以一組符號描述引用目標,可以是任何形式的字面量,只要使用時能無歧義地定位目標即可。與虛擬機內存佈局無關,引用目標不一定已經加載到虛擬機內存。

直接引用是可以直接指向目標的指針、相對偏移量或能間接定位到目標的句柄。和虛擬機的內存佈局相關,引用目標必須已在虛擬機的內存中存在。

初始化

直到該階段 JVM 纔開始執行類中編寫的代碼。準備階段時變量賦過零值,初始化階段會根據程序員的編碼去初始化類變量和其他資源。初始化階段就是執行類構造方法中的 `` 方法,該方法是 Javac 自動生成的。


Q5:有哪些類加載器?

自 JDK1.2 起 Java 一直保持三層類加載器:

  • 啓動類加載器

    在 JVM 啓動時創建,負責加載最核心的類,例如 Object、System 等。無法被程序直接引用,如果需要把加載委派給啓動類加載器,直接使用 null 代替即可,因爲啓動類加載器通常由操作系統實現,並不存在於 JVM 體系。

  • 平臺類加載器

    從 JDK9 開始從擴展類加載器更換爲平臺類加載器,負載加載一些擴展的系統類,比如 XML、加密、壓縮相關的功能類等。

  • 應用類加載器

    也稱系統類加載器,負責加載用戶類路徑上的類庫,可以直接在代碼中使用。如果沒有自定義類加載器,一般情況下應用類加載器就是默認的類加載器。自定義類加載器通過繼承 ClassLoader 並重寫 findClass 方法實現。


Q6:雙親委派模型是什麼?

類加載器具有等級制度但非繼承關係,以組合的方式複用父加載器的功能。雙親委派模型要求除了頂層的啓動類加載器外,其餘類加載器都應該有自己的父加載器。

一個類加載器收到了類加載請求,它不會自己去嘗試加載,而將該請求委派給父加載器,每層的類加載器都是如此,因此所有加載請求最終都應該傳送到啓動類加載器,只有當父加載器反饋無法完成請求時,子加載器纔會嘗試。

類跟隨它的加載器一起具備了有優先級的層次關係,確保某個類在各個類加載器環境中都是同一個,保證程序的穩定性。


Q7:如何判斷兩個類是否相等?

任意一個類都必須由類加載器和這個類本身共同確立其在虛擬機中的唯一性。

兩個類只有由同一類加載器加載纔有比較意義,否則即使兩個類來源於同一個 Class 文件,被同一個 JVM 加載,只要類加載器不同,這兩個類就必定不相等。

本文分享自微信公衆號 - JAVA高級架構(gaojijiagou)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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