Java 面試高頻問題之 JVM

微信搜一搜
村雨遙


  • 1. JVM 內存區域?

    • 1.1 JVM 定義及組成

    • 1.2 JVM 功能

    • 1.3 線程

    • 1.4 Hotspot JVM 後臺系統線程

    • 1.5 深拷貝 vs 淺拷貝

    • 1.6 堆和棧的區別

  • 2. 運行時數據區

    • 2.1 內存劃分

    • 2.2 各內存區域功能

    • 2.3 Java 7 和 Java 8 在內存模型上的區別

    • 2.4 什麼情況下會出現堆內存溢出?

  • 3. GC 機制

    • 3.1 什麼是 GC?

    • 3.2 Java 中的對象引用

    • 3.3 判斷對象是否爲垃圾

    • 3.4 需要 GC 的內存區域

    • 3.5 回收垃圾對象內存的算法

    • 3.5 垃圾回收器

  • 4. Java 類加載機制

    • 4.1 類的生命週期

    • 4.2 JVM 加載類文件的原理

    • 4.3 類加載過程

    • 4.4 類加載過程中的具體分工

    • 4.5 總結

    • 4.6 類加載器

    • 4.7 動態模型系統(OSGI)

  • 5. 內存分配策略

    • 5.1 Minor GC vs Major GC

    • 5.2 堆內存分配原則

    • 5.3 新生代

    • 5.4 老年代

    • 5.5 永久代

  • 6. JVM 調優

    • 6.1 JVM 調優常用參數

    • 6.2 JVM 調優步驟


1. JVM 內存區域?

1.1 JVM 定義及組成

JVM 是一種用於計算設備的規範,是一個虛構出來的計算機,通過在實體機上仿真模擬各種計算機功能來實現。JVM 運行在操作系統之上,與硬件之間並沒有進行直接交互,這也就爲什麼 Java 語言只需要編譯一次,就能夠在不同平臺上運行,通常有如下組成部分:

  • 一組字節碼指令集
  • 一組寄存器
  • 一個棧
  • 一個垃圾回收堆
  • 一個存儲方法域

1.2 JVM 功能

JVM 主要功能分爲三塊:

  1. 執行 Java 代碼
  2. 內存管理
  3. 線程資源同步和交互機制

1.3 線程

指程序執行過程中的一個線程實體,JVM 允許一個應用併發執行多個線程。Hotspot JVM 中的 JVM 線程和操作系統中的線程有着直接的映射關係。

當線程本地存儲、緩衝區分配、同步對象、棧、程序計數器等資源準備好之後,就會創建一個操作系統原生線程。一旦 Java 的線程結束,操作系統原生線程也隨之被回收。操作系統作爲調度中心,負責調度並分配線程到任何可用的 CPU 上。一旦操作系統原生線程初始化完畢,就會調用 Java 線程的 run() 方法。當線程結束時,就會釋放操作系統原生線程和 Java 線程的所有資源。

1.4 Hotspot JVM 後臺系統線程

  1. 虛擬機線程:等待 JVM 到達安全點操作時出現。操作必須在獨立的線程裏執行,因爲當堆修改無法進行時,線程都需要 JVM 位於安全點。 安全點操作的類型有:stop-the-world 垃圾回收、線程棧 dump、線程暫停、線程偏向鎖(biased locking)解除
  2. 週期性任務線程:負責定時器事件(即中斷),用於調度週期性操作的執行;
  3. GC 線程:支持 JVM 中的垃圾回收活動;
  4. 編譯期線程:將字節碼( .class)動態編譯爲本地平臺相關的機器碼;
  5. 信號分發線程:接收發送到 JVM 的信號並調用對應的方法進行處理;

1.5 深拷貝 vs 淺拷貝

淺拷貝(ShallowCopy)只是增加一個指針指向已存在的內存地址,僅僅是指向被複制的內存地址,一旦原地址發生改變,則淺拷貝出來的對象也會隨之變化。所以改變其中任何一個都會導致另一個對象的變化,clone() 方法是淺拷貝;

深拷貝(DeepCopy)是增加一個指針且申請一個新的內存,使這個增加的指針指向新的內存,相當於開闢了一塊 新的內存地址 用於存放複製的對象。原對象和被拷貝出來的對象互不影響,其中任何一個改變都不會引起另一個改變。

1.6 堆和棧的區別

不同點
物理地址 不連續,性能較慢 連續,性能較快
內存 不連續,因此分配內存在 運行期動態分配大小不固定 連續,內存大小在 編譯期 確認,大小固定
存放 對象實例、數組、靜態對象 局部變量、操作數棧、指向運行時常量池的引用、方法返回地址、附加信息
可見度 對整個應用程序共享、可見 只對線程可見,生命週期同線程

2. 運行時數據區

2.1 內存劃分

根據 JVM 規範,JVM 運行時數據區可以分爲如下區域:

  • 方法區(Method Area)
  • 堆區(Heap)
  • 虛擬機棧(VM Stack)
  • 本地方法棧(Native Method Stack)
  • 程序計數器(Program Counter Register)

所有線程私有的數據區域生命週期都與線程同步,隨着用戶線程的創建而創建,線程的結束而銷燬。而線程共享的數據區域則是隨着虛擬機的啓動而創建,隨着虛擬機的關閉而銷燬。

2.2 各內存區域功能

  1. 方法區

方法區存放要 加載的類信息(類名、修飾符等)、靜態變量、構造函數、final 常量、類中字段和方法等信息。該內存區域是全局共享的,在一定條件下也會出發 GC 機制。一旦超出其內存允許大小,就會拋出 OOM。

在 Hotspot JVM 中,方法區對應 持久代運行時常量池(Runtime Constant Pool) 是方法區中的一部分,用於存儲 編譯器生成的常量和引用

  1. 堆區

虛擬機中內存最大的一塊,GC 發生最頻繁的區域,被所有線程共享,在虛擬機啓啓動時創建,主要用於 存放對象實例以及數組,所有 new 出來的對象都存放在該區。現代的 JVM 採用 分代收集算法,所以又可以細分爲:新生代(Eden、From Survivor、To Survivor)和老年代

  1. 虛擬機棧

佔用操作系統內存,每個線程對應一個虛擬機棧,屬於線程私有,生命週期同線程一樣,每個方法執行時均產生一個棧幀(Stack Frame),用於 存儲局部變量表、動態鏈接、操作數棧、方法出口和異常分派等信息。當方法被調用時,棧幀入棧,當方法調用結束時,棧幀出棧。

局部變量表 中存放了方法相關的局部變量,包括各種基本數據類型及對象的引用地址等,因此其 內存空間在編譯期就可以確定,運行時不再改變。

此外,虛擬機棧中定義了兩種異常:StackOverFlowError 和 OOM

  1. 本地方法棧

本地方法棧用於調用 native 方法的執行,存儲了每個 native 方法的執行狀態。本地方法棧和虛擬機棧的區別在於:虛擬機棧中執行 Java 方法,而本地方法棧中執行 native 方法

  1. 程序計數器

程序計數器是一塊很小的內存區域,不在 RAM 中,而是直接劃分在 CPU 上,是當前線程所執行的字節碼的行號指示器其作用是:JVM 在解釋字節碼文件時,存儲當前線程執行的字節碼行號(每個程序計數器只能記錄一個線程的行號),字節碼解析器的工作就是通過改變該計數器的值,來選取下一條需要執行的字節碼指令。分支、循環、跳轉、異常處理以及線程恢復等基礎功能均依賴於該計數器完成,各個 JVM 所採用的方式不一樣,是 JVM 中唯一一個沒有規定任何 OutOfMemoryError 的區域

2.3 Java 7 和 Java 8 在內存模型上的區別

Java 8 中取消了永久代,用元空間(Metaspace)代替,元空間是存在本地內存(Native memory)中的;

2.4 什麼情況下會出現堆內存溢出?

堆內存中存儲對象實例,所以只要不斷創建對象,並保證 GC roots 到對象之間有可達路徑來避免 GC 機制清除這些對象。就會在對象數量達到最大堆容量限制後,產生內存溢出異常;

3. GC 機制

推薦閱讀:

深入理解 JVM 的內存結構及 GC 機制[1]

JVM 垃圾回收[2]

淺析 JAVA 的垃圾回收機制(GC)[3]

3.1 什麼是 GC?

GC(Garbage Collection,垃圾回收)機制是 JVM 垃圾回收器提供的 一種用於在空閒時間不定時回收無任何引用對象引用的對象所佔據的內存空間的一種機制。回收的只是對象所佔據的內存空間而非對象本身,即只負責釋放對象所佔有的內存。

GC 機制是區別 Java 和 C++ 等語言的一個重要特性。C++ 中,當我們不再需要某些內存時,需要手動實現垃圾回收,但是 Java 中不用我們手動去實現垃圾回收,JVM 已經自帶垃圾回收機制,我們只需要專注於業務開發就可以了。

3.2 Java 中的對象引用

JDK1.2 之後,Java 引用主要分爲如下幾種(從上到下引用強度逐漸減弱),日常程序設計中,使用最多的就是 強引用和弱引用

  • 強引用
  • 軟引用
  • 弱引用
  • 虛引用
  1. 強引用

使用最普遍的引用,也是我們日常使用的大多數引用,如 String str = "村雨遙"。若一個對象具有強引用,就 相當於生活中必備的物品,垃圾回收器絕對不會回收它,當內存空間不足時,JVM 寧願拋出 OOM 錯誤,也不會隨意回收具有強引用的對象來解決內存不足問題,因此強引用是造成 Java 內存泄露 的主要原因之一。

  1. 軟引用

若一個對象只具有軟引用,則 相當於生活中可有可無的物品。若內存空間充足,則垃圾回收器不會回收它,一旦內存空間不足,則會回收這些對象的內存。只要垃圾回收器未回收這個對象的內存,則該對象能夠被程序使用,通過使用軟引用可以實現內存敏感的高速緩存,加速 JVM 對垃圾內存的回收速度,同時維護系統的運行安全,防止 OOM 等問題的產生

  1. 弱引用

一若個對象只具有弱引用,則 相當於生活中可有可無的物品。 軟引用和弱引用的區別在於:只擁有弱引用的對象具有更短暫的生命週期,在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現具有弱引用的對象,則無論當前內存空間是否充足,都會回收它的內存。 但一般垃圾回收器的線程優先級很低,因此不會很快就回收具有弱引用的對象。

此外 軟引用和弱引用都可以和一個引用隊列聯合使用,一旦他們所引用的對象被垃圾回收,JVM 就會將這個引用加入到相關的引用隊列中。

  1. 虛引用

形同虛設的一個引用,不會決定對象的聲明週期,一個對象僅持有虛引用,則任何時候都可能被垃圾回收器回收,主要用來跟蹤對象被垃圾回收的活動

虛引用與軟引用和弱引用的區別虛引用必須和引用隊列聯合使用。當垃圾回收器準備回收一個對象時,若發現該對象具有虛引用,則會在回收該對象的內存前,將該虛引用加入到與之關聯的引用隊列中。程序能夠通過判斷引用隊列中是否已經加入虛引用,來了解被引用的對象是否將要被垃圾回收器回收。

  1. 總結
引用類型 回收階段
強引用 發生 GC 時不被回收
軟引用 有用但非必須的對象,發生內存溢出前被回收
弱引用 有用但非必須的引用,下一次 GC 時被回收
虛引用 無法通過虛引用獲取對象,用 PhantomReference 實現虛引用,其用途是在 GC 時返回一個通知

3.3 判斷對象是否爲垃圾

如上圖所示,要判斷一個對象是否爲垃圾,通常有如下兩種方法:

  • 引用計數算法

爲每個對象創建一個引用計數,有對象引用時計數器 +1,引用被釋放時計數 -1,當計數器爲 0 時就可以被回收,但是存在 不能解決循環引用 的問題。

  • 可達性分析算法

從 GC Roots 開始向下搜索,搜索所走過的路徑稱爲引用鏈。當一個對象到 GC Roots 沒有任何引用鏈相連時,則說明該對象能夠被回收。若在 GC Roots 和一個對象間沒有可達路徑,則稱該對象是不可達的。

3.4 需要 GC 的內存區域

對於 JVM 內存佈局而言,線程獨享的區域爲:程序計數器、JVM 棧、本地方法棧,三者都跟線程 “共生死”,所以不需要 GC。但是由線程共享的:堆區、方法區 則是 GC 的重點關注對象。

3.5 回收垃圾對象內存的算法

  • 標記 - 清除算法
  • 複製算法
  • 標記 - 整理算法
  • 分代收集算法
  1. 標記 - 清除算法

分爲 標記清除 階段:首先標記出所有需要回收的對象,然後統一回收被標記的對象所佔用的空間;

  • 優點: 實現簡單,不用對象進行移動;
  • 缺點: 標記、清除過程效率低;清除後產生了 大量不連續的內存碎片,提高了垃圾回收的頻率;
源自網絡
  1. 複製算法

針對效率問題而提出的算法,通過將內存劃分爲帶下相同的兩塊,每次使用其中的一塊,當其中一塊的內存被佔滿後,就將其中還存活着的對象複製到另一塊中,最後將使用過的空間一次性清理,這樣就保證了每次的內存回收都是對內存區間的一半進行回收

  • 優點:按順序分配內存即可,實現簡單、運行高效、不用考慮內存碎片;

  • 缺點:可用內存大小縮小爲原來的一半,對象存活率高時將頻繁進行復制,效率變低;

源自網絡
  1. 標記 - 整理算法

結合標記 - 清除算法和複製算法,標記過程同 標記 - 清除算法,但和後續過程中不是直接回收可回收對象,而是 讓所有存活的對象向一端移動,然後直接清理端邊界之外的內存

  • 優點:解決了 標記-清理 算法存在的內存碎片問題;
  • 缺點:仍需要進行局部對象移動,一定程度上降低了效率;
源自網絡
  1. 分代收集算法

現在的虛擬機的垃圾回收器基本都採用分代收集算法,它會根據對象存活週期的不同將內存劃分爲不同的塊,一般將 Java 堆劃分爲新生代和老年代,然後根據各年代的特點選擇合適的垃圾回收算法。

  • 新生代中,每次收集都會收集大量對象,所以可以選擇複製算法,只要付出少量複製成本就能完成垃圾收集;
  • 老年代的對象存活機率很高,而且沒有額外空間對其進行分配擔保,所以只能選擇 “標記 - 整理算法” 或 ”標記 - 清除算法“ 來進行垃圾回收,而我們一般都是選擇 “標記 - 整理算法”

3.5 垃圾回收器

垃圾回收算法是方法論,具體實現就是垃圾收集器 進行垃圾收集時,必須暫停其他所有工作線程,這一過程也叫 Stop The World。常見的垃圾回收器有如下幾種:

  • Serial 收集器
  • ParNew 收集器
  • Parallel Scavenge 收集器
  • Serial Old 收集器
  • Parallel Old 收集器
  • CMS 收集器
  • G1 收集器
  1. Serial 收集器(單線程 + 複製算法)

Serial (串行)收集器 是最基本,使用時間最久的垃圾收集器,使用複製算法。它是一個 單線程 的收集器,但並非意味着它只會用一條垃圾回收線程去完成垃圾回收,而是說它在進行垃圾回收工作的同時 必須暫停其他所有的工作線程,直到垃圾回收完成,是運行在客戶端模式下的虛擬機的首選,能夠與 CMS 收集器協同工作新生代單線程收集器,標記和清理均爲單線程,優點是簡單高效。

  1. ParNew 收集器(多線程 + Serial)

Serial 收集器 的多線程版本,除開是使用多線程進行垃圾回收,其他機制(如控制參數、回收算法、回收策略等)都和 Serial 收集器保持一致,是運行在服務器模式下的虛擬機的首選,除開 Serial 收集器外,只有它能夠與 CMS 收集器配合使用新生代並行收集器。

  1. Parallel Scavenge 收集器(多線程 + 複製算法)

Parallel Scavenge 收集器也是 使用複製算法的多線程收集器但 Parallel Scavenge 重點關注吞吐量(CPU 用於運行用戶代碼的時間與 CPU 總消耗時間的比值),以便能夠最高效率的利用 CPU,適合於在後臺運算而無需太多交互的任務。而 CMS 收集器更多關注的是用戶線程的停頓時間(最大化提高用戶體驗)

  1. Serial Old 收集器(單線程 + 標記-整理算法)

Serial 收集器用於老年代的版本,是一個 單線程標記-整理算法 的收集器,主要是 運行在 Client 下的 Java 虛擬機默認的老年代垃圾收集器 主要有兩大用途:

  • 在 JDK 1.5 及之前的版本中與 Parallel Scavenge 收集器共同使用;
  • 作爲 CMS 收集器的後備方案;
  1. Parallel Old 收集器(多線程 + 標記-整理算法)

Parallel Old 是 Parallel Scavenge 的老年代版本,使用 多線程“標記 - 整理算法”,在注重吞吐量和 CPU 資源的場景下,可以優先考慮 Parallel Old 收集器和 Parallel Scavenge 收集器。

  1. CMS 收集器(多線程 + 標記-清除算法)

CMS(Current Mark Sweep)收集器是一種 以獲取最短垃圾回收停頓時間爲目標的收集器,重點關注用戶體驗。是 HotSpot 虛擬機中第一個真正意義上的併發收集器,第一次實現了垃圾回收線程和用戶線程同時工作

CMS 收集器是基於 “標記- 清除算法” 實現,相比其他垃圾回收器更加複雜,通常可以將整個回收過程總結爲如下四步:

  • 初始標記(stop the world):暫停所有其他線程,同時記錄下與根節點 root 直接關聯的對象,速度快;
  • 併發標記:同時開始 GC 和用戶線程,用一個 閉包結構 去記錄可達對象。但由於用戶線程可能會不斷更新引用域,所以標記過程結束後並不能保證所有可達對象都包含進來,GC 線程無法保證可達性分析的實時性,不用暫停工作線程。
  • 重新標記(stop the world):爲了修正併發標記過程中用戶線程更新而產生的未被包含進閉包的可達對象,該階段的停頓時間會比初始標記階段的時間更長,但是遠遠比並發標記階段所用時間短,仍然需要暫停所有工作線程。
  • 併發清除:開啓用戶線程,同時 GC 線程對未標記的區域做清掃,不需要暫停工作線程。

雖然 CMS 作爲垃圾收集器有着 併發收集、低停頓 等優點,但是也存在三個比較明顯的缺點:

  • 對於 CPU 的資源十分敏感
  • 無法處理浮動垃圾
  • 由於使用的是 標記 - 清除算法,所以會 導致收集結束後產生大量空間碎片
  1. G1 收集器

G1(Garbage-First)收集器是 面向服務器的垃圾回收器,主要針對配備多個處理器和大內存的機器,以極高頻率滿足 GC 停頓時間的同時還具有高吞吐量,總結下來有如下特點:

  • 並行與併發:充分利用多核和大內存的優勢,用多個 CPU 來縮短暫停其他所有的工作線程的停頓時間。有的垃圾回收器需要通過暫停 Java 線程來執行 GC 動作,但 G1 收集器能夠通過併發的方法來讓 Java 線程繼續執行;
  • 分代收集:G1 收集器可以獨立管理整個 GC 過程,但是仍然保留了分代的概念;
  • 空間整合:不同於 CMS 的 ”標記 - 清除算法“,G1 從整體來看是基於 ” 標記 - 整理算法“ 實現,但是實際上局部是基於 ” 複製算法“ 實現;
  • 可預測的停頓:相對於 CMS 的另一個優勢,G1 和 CMS 都關注於用戶交互體驗(降低停頓時間),但 G1 除開低停頓外,還能夠建立可預測的停頓時間模型,將用戶指定在 M ms 的時間段內;

G1 收集器的運行過程大概可以分爲如下 4 個步驟:

  • 初始標記
  • 併發標記
  • 最終標記
  • 篩選回收

G1 收集器在後臺維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的 Region。這種使用 Region 劃分內存空間以及有優先級的區域回收方式,保證了 G1 收集器在有限時間內可以儘可能高的收集效率(把內存化整爲零)。

4. Java 類加載機制

JVM 把描述類的數據從 Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型,這就是虛擬機的類加載機制。

4.1 類的生命週期

類從被加載到虛擬機內存中開始,然後到卸載出內存爲止。其生命週期包括如下 5 個階段:

  • 加載
  • 連接(又可進一步劃分爲 驗證、準備和解析 過程)
  • 初始化
  • 使用
  • 卸載

4.2 JVM 加載類文件的原理

Java 中的類都需要經過類加載器加載到 JVM 中後才能運行,而類加載器本身就是一個類,它的工作是將 .class 文件從硬盤讀取到內存。類裝載一般有兩種方式:

  1. 隱式裝載

程序在運行過程中碰到通過 new 等方式生成對象時,隱式調用類裝載器加載對應的類到 JVM 中;

  1. 顯式裝載

通過 class.forname() 等方法,顯式加載所需的類;

一般來講,Java 類的加載是動態的,它不會一次性將所有類全部加載後再運行,而是先將保證程序運行的基礎類完全加載到 JVM 中,而其他類則是在需要的時候再進行加載。

4.3 類加載過程

類文件需要加載到虛擬機中才能夠正常使用和運行,通常虛擬機加載類文件的步驟主要有如下 3 階段:

加載 -> 連接 -> 初始化

其中連接又可進一步細分爲:驗證 -> 準備 -> 解析。在這個過程中各個階段都是 按照順序開始,而不是按照順序進行或完成,這些階段通常都是交叉混合進行,在一個階段執行過程中調用或激活另一個階段,然後接下來具體介紹下類加載過程中每個階段所做的工作。

4.4 類加載過程中的具體分工

  1. 加載

加載處於類加載過程中的第一個階段,該階段會在內存中生成一個代表該類的 java.lang.Class 對象,作爲方法區該類的各種數據的入口,總結下來主要完成如下 3 件事情:

  • 通過全類名獲取定義該類的二進制字節流

  • 將字節流所代表的靜態存儲結構轉換爲方法區的運行時數據結構

  • 在堆中生成一個代表該類的 Class 對象,作爲方法區中這些數據的訪問入口

注意:第一件事中的二進制字節流不僅僅可以從 Class 文件中獲取,還能夠從各種 jar、war 包、網絡(Applet)或者由其他文件生成(JSP 應用)等。一個非數組類的加載可控性較強,允許我們自定義類加載器來控制字節流的獲取方式(即重寫一個類加載器的 loadClass() 方法);而數組類型則不需要通過類加載器創建,而是由 JVM 直接創建。 所有的類均有類加載器加載,其作用就是將 .class 文件加載到內存中。

  1. 驗證

進行驗證的目的在於 確保 Class 文件中的字節流包含的信息符合當前虛擬機的要求,而不會威脅到虛擬機自身安全。不同虛擬機可能有不同的驗證實現,但是基本都會有如下 4 個階段的驗證:

文件格式的驗證、元數據的驗證、字節碼驗證、符號引用驗證

  1. 準備

準備階段是正式爲類變量分配內存同時設置類變量初始值的階段,這些內存都將在方法區中分配,此時需要注意如下幾點:

  • 此時進行內存分配的 僅包括類變量(static),不包括實例變量,實例變量隨對象實例化時一塊分配在 Java 堆
  • 設置的初始值通常情況下是數據類型的默認零值,而不是在 Java 代碼中被顯式賦予的值,但如果變量被 final 修飾,那麼該變量在準備階段就被賦值成了指定的值,而不是爲其賦予默認零值;
數據類型 默認零值
byte 0
short 0
char \u0000
int 0
long 0L
float 0.0f
double 0.0D
boolean false
reference null
  1. 解析

解析是 虛擬機將常量池中的符號引用轉化爲直接引用的過程,主要針對的是類、接口、字段、類方法、接口方法、方法類型、方法句柄以及調用限定符等 7 類符號

所謂符號引用,就是用一組符號來描述目標,可以是任何字面量。直接引用 就是 直接指向目標的指針、相對偏移量或一個間接定位到目標的句柄。在程序實際運行時,只有符號引用是不夠的,舉個例子:在程序執行方法時,系統需要明確知道這個方法所在的位置。Java 虛擬機爲每個類都準備了一張方法表來存放類中所有的方法。當需要調用一個類的方法的時候,只要知道這個方法在方法表中的偏移量就可以直接調用該方法。通過解析操作符號引用就可以直接轉變爲目標方法在類中方法表的位置,從而使得方法可以被調用。

解析主要針對 類或接口、字段、類方法、接口方法 四類符號進行引用,分別對應於常量池中的 CONSTANT_Class_infoCONSTANT_Field_infoCONSTANT_Method_infoCONSTANT_InterfaceMethod_info

  • 類或接口的解析:判斷所要轉換爲的直接引用時對數組類型,還是普通對象類型的引用,從而進行不同的解析;
  • 字段解析:對字段進行解析時,現在本類中查找是否包含有簡單名稱和字段描述符都與目標相匹配的字段,如果沒有就按照繼承關係從上往下遞歸搜索該類所實現的各個接口和它們的父接口。還沒有找到就繼續按繼承關係從上往下遞歸搜索父類,直到找到 x 相匹配的字段。
  1. 初始化

類加載過程中的最後一步,也是 真正執行類中定義的 Java 代碼(字節碼)。 準備階段中,類變量已經被賦予了一次初始值,但在初始化階段,會根據我們制定的主觀計劃去初始化類變量和其他資源,從另一個角度來講就是:初始化階段就是執行類構造器 <clinit>() 方法的過程

<clinit>() 方法是帶鎖線程安全,所以在多線程環境下進行類初始化可能導致死鎖。對於初始化階段,一般只有如下幾種情況,必須對類進行初始化(只有主動使用類纔會初始化類):

  • 遇到 new、getstatic、putstatic、invokestatic 其中之一時:
    • 當 JVM 執行 new指令時會初始化類,即當程序創建一個類的實例對象;
    • 當 JVM 執行 getstatic 指令時會初始化類,即程序訪問類的靜態變量(不是靜態常量,常量會被加載到運行時常量池);
    • 當 JVM 執行 putstatic指令時會初始化類,即程序給類的靜態變量賦值;
    • 當 JVM 執行 invokestatic指令時會初始化類,即程序調用類的靜態方法;
  • 使用 java.lang.reflect 包中的方法對類進行反射調用時 ,如果類未初始化,就需要觸發其初始化;
  • 初始化一個類,如果其父類還未初始化,則優先觸發其父類的初始化;
  • 當虛擬機啓動時,需要定義一個要執行的主類 ,虛擬機會首先先初始化這個類;
  • MethodHandleVarHandle 都可以看作是輕量級的反射調用機制,如果要使用這兩個調用, 就必須先使用 findStaticVarHandle 來初始化要調用的類;

4.5 總結

縱觀整個類的加載過程,除了在 加載階段用戶可以自定義類加載器參與,其餘所有動作都完全由虛擬機來主導。 而到了初始化階段,纔是真正執行 Java 程序代碼,但僅限於 <clinit>() 方法。總結起來就是 類加載過程中主要是將 Class 文件(準確地講,應該是類的二進制字節流)加載到虛擬機內存中,真正執行字節碼的操作,在加載完成後才真正開始。

4.6 類加載器

在類加載過程中,加載階段需要用到類加載器。所謂類加載器,就是 實現通過類的權限定名獲取該類的二進制字節流的代碼塊。接下來總結一下類加載器的相關知識。

推薦閱讀:

https://juejin.im/post/6844903633574690824#heading-5

4.6.1 類加載器的分類

JVM 中內置了 3 個重要的類加載器,具體如下,除開 BootstrapClassLoader 之外,其他加載器均繼承自 java.lang.ClassLoader,而且都是由 Java 實現;

  1. BootstrapClassLoader(啓動類加載器):最頂層的加載器,由 C++ 實現,虛擬機自身的一部分,負責加載 %JAVA_HOME%/lib 目錄下的 jar 包和類或者通過 -Xbootclasspath 參數所指定的路徑中的所有類;
  2. ExtensionClassLoader(擴展類加載器):主要負責加載 ``%JAVA_HOME%/lib/ext 目錄下的 jar 包和類,或者系統變量java.ext.dirs` 所指定的路徑下的 jar 包;
  3. ApplicationClassLoader(應用程序類加載器):面向用戶的加載器,負責加載當前應用 classpath 下的所有 jar 包和類;
  4. 其他類加載器,一般是自己自定義的一些類加載器,通過繼承 java.lang.ClassLoader 實現自定義的類加載器;

4.6.2 雙親委派模型

雙親委派模型

如上圖中的雙親委派模型:當一個類收到了類加載的請求時,它不會立即去加載這個類,而是把這個請求委派給父類加載器去完成,每一層的類加載器都是如此。這樣一來所有的類加載請求最終都會被傳送到頂層的啓動類加載器中,只有當父加載無法完成加載請求(它的加載路徑下未找到所需的類)時,子加載器纔會嘗試去加載類。

每次類加載時,先判斷當前類是否已經被加載過,如果已經被加載過,則直接返回,否則纔會去嘗試加載。

4.6.3 雙親委派模型的優點

通過雙親委派模型,保證了 Java 程序的穩定運行,能夠避免類的重複加載(JVM 區別不同類的方式是僅根據類名來判斷,相同的類文件如果被不同的類加載器加載,就會產生不同的類),同時也保證了 Java 核心 API 不受篡改。不管最終是哪個加載器來加載類,最終都是委託給頂層的啓動類加載器進行加載,從而保證了 使用不同的類加載器最終得到的都是同樣一個 Object 對象

4.6.4 如何實現與破壞雙親委派模型

  • 實現

要實現雙親委派模型,需要每次通過先委派父類加載器加載,然後再自己加載;

  • 破壞

雙親委派模型並非強制性約束,只是更爲推薦的一種類加載器的實現方式,如果我們想要自己完成某些操作,那麼就可以自定義實現,從而 “破壞” 該模型。通常可以通過如下 3 種 方式來進行:

  1. 重寫 loadClass() 方法
  2. 利用線程上下文加載器(Thread Context ClassLoader),這個類加載器可以通過 java.lang.Thread 類的 setContextClassLoaser() 方法進行設置,如果創建線程時還未設置,它將會從父線程中繼承 一個,如果在應用程序的全局範圍內均未設置過,那這個類加載器默認就是應用程序類加載器
  3. 爲了實現熱插拔,熱部署,模塊化,意思是添加一個功能或減去一個功能不用重啓,只需要把這模塊連同類加載器一起換掉就可以實現代碼的熱替換

4.7 動態模型系統(OSGI)

4.7.1 定義

OSGI(Open Service Gateway Initiative)是面向 Java 的動態模型系統,是 Java 動態模塊化系統的一系列規範,提供在多種網絡設備上無需重啓的的動態改變構造的功能。爲了最小化耦合度和促使這些耦合度可管理,OSGI 提供了一種面向服務的架構,使得這些組件動態地發現對方。總結而言,OSGI 的主要職責就是讓開發者能創建動態化、模塊化的 Java 系統

4.7.2 OSGI 框架

從概念上而言,主要可以分爲三層:

  • Module Layer:模塊層主要涉及包及共享的代碼;
  • Lifecycle Layer:生命週期層主要涉及 Bundle 的運行時生命週期管理;
  • Service Layer:服務層主要涉及模塊間的交互與通信;

5. 內存分配策略

5.1 Minor GC vs Major GC

  1. Minor GC

指發生在新生代的 GC,因爲 Java 對象更新比較快,所以 Minor GC 十分頻繁,一般回收速度也比較快。採用 複製算法,其過程包括:複製 -> 清空 -> 互換

複製:Eden、SurvivorFrom 複製到 SurvivorTo,同時年齡 +1,一旦年齡達到老年標準,則賦值到老年代區;

清空:複製之後,接着清空 Eden、SurvivorFrom 區中的對象;

互換:清空後,將 SurvivorTo 和 SurvivorFrom 互換,原來的 SurvivorTo 成爲下一次 GC 時的 SurvivorFrom 區;

  1. Major GC

指發生在老年代的 GC,出現 Major GC 一般至少伴隨一次 Minor GC,Major GC 的速度通常比 Minor GC 慢上 10 倍 以上。採用 標記-清除算法,MajorGC 會產生內存碎片,當內存不足時,就將拋出 OOM 異常;

5.2 堆內存分配原則

內存分代

對象的內存分配通常是在 Java 堆上進行分配,對象主要分配在新生代的 Eden 區,若啓動本地線程緩存,則按照線程優先在 TLAB 上分配。少數情況下也會直接在老年代上進行分配。總的而言分配規則不固定,取決於哪種垃圾回收器組合以及虛擬機相關參數,但虛擬機對於內存的分配一般都會遵循如下原則:

  1. 對象優先分配在 Eden 區

大多情況下,對象均在新生代 Eden 區分配,當 Eden 區空間不足以分配時,虛擬機就將進行一次 Minor GC。若經過 GC 後還是沒有足夠空間,則將啓用分配擔保機制在老年代中分配內存。

  1. 大對象直接進入老年代

所謂大對象一般指的是需要大量連續內存空間的對象,如數組,大對象不能頻繁出現,否則將導致內存充足時提前觸發 GC,以便獲取充足的連續空間來存放大對象;

  1. 長期存活對象進入老年代

虛擬機採用分代收集的思想來管理內存,則內存回收是就必須判斷對象應該存放的內存帶。因此虛擬機會給每個對象定義一個對象年齡的計數器,若對象位於 Eden 區出生,且能夠被 Survivor 容納,則該對象將被移動到 Survivor 空間,此時設置對象年齡爲 1.對象在 Survivor 中每經過一次 Minor GC 且未被回收,年齡就 +1,當年齡到達一定程度時(默認爲 15)就進入老年代;

5.3 新生代

用於存放新生對象,一般佔據堆的 1/3。由於我們要頻繁創建對象,所以在該區域會頻繁出發 MinorGC。又可以分爲:

  • Eden 區
  • SuivivorFrom 區
  • SurvivorTo 區
  1. Eden 區

新建對象的存放地(若對象佔用內存過大,則直接分配到老年代),當 Eden 內存不足時出發 MinorGC,新生代發生一次垃圾回收;

  1. SurvivorFrom

上一次 GC 的倖存者,作爲這一次 GC 的被掃描者;

  1. SuivivorTo

保留一次 MinorGC 過程中的倖存者;

5.4 老年代

存放生命週期較長的內存對象。老年代中對象一般都比較穩定,因此 MajorGC 不會頻繁執行,在執行 MajorGC 前一般都進行了一次 MinorGC,使得新生代對象晉身老年代,導致空間不足才觸發。當無法找到足夠大的連續空間分配給新創建的較大對象時也會提前觸發一次 MajorGC 進行垃圾回收來騰出空間。

5.5 永久代

內存中的永久保存區域,主要存放 類和 Meta(元數據)的信息,類在被加載時被放入永久代,不同於存放實例的區域,GC 不會在主程序運行期對永久代進行清理,因此會導致永久代會隨着加載的類的增多而不斷縮小,直到拋出 OOM 異常

Java 8 以後,永久代被元數據區取代,其本質類似於永久代。兩者最大的區別在於:元空間不在虛擬機中,而是使用本地內存,因此其大小隻受本地內存限制類的元數據放入 Native Memory,字符串池和類的靜態變量放入 Java 堆

6. JVM 調優

6.1 JVM 調優常用參數

  • -Xms2g:初始化堆大小爲 2g
  • -Xmx2g:堆最大內存爲 2g
  • -XX:NewRatio=4:設置年輕和老年代的內存比例爲 1:4
  • -XX:SurvivorRatio=8:設置新生代 Eden 和 Survivor 比例爲 8:2
  • -XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器組合
  • -XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器組合
  • -XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器組合
  • -XX:+PrintGC:開啓打印 GC 信息
  • -XX:+PrintGCDetail:打印 GC 詳細信息

6.2 JVM 調優步驟

  1. 分析 GC 日誌及 dump 文件,判斷是否需要優化,確定瓶頸問題點;
  2. 確定 JVM 調優量化目標;
  3. 確定 JVM 調優參數;
  4. 調優一臺服務器,對比觀察調優前後的差異;
  5. 不斷分析和調整,直到找到合適的 JVM 參數配置;
  6. 找到最合適的參數,講這些參數應用到所有服務器,並進行後序跟蹤;

參考資料

[1]

深入理解 JVM 的內存結構及 GC 機制: https://juejin.im/post/6844903513248497677

[2]

JVM 垃圾回收: https://snailclimb.gitee.io/javaguide/#/docs/java/jvm/JVM%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6?id=_3-%e5%9e%83%e5%9c%be%e6%94%b6%e9%9b%86%e7%ae%97%e6%b3%95

[3]

淺析 JAVA 的垃圾回收機制(GC): https://www.jianshu.com/p/5261a62e4d29



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

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