圖解 JVM 核心知識點(面試版)

一、基本概念

1.1 OpenJDK

自 1996 年 JDK 1.0 發佈以來,Sun 公司在大版本上發行了 JDK 1.1JDK 1.2JDK 1.3JDK 1.4JDK 5JDK 6 ,這些版本的 JDK 都可以統稱爲 SunJDK 。之後在 2006 年的 JavaOne 大會上,Sun 公司宣佈將 Java 開源,在隨後的一年多裏,它陸續將 JDK 的各個部分在 GPL v2(GNU General Public License,version 2)協議下開源,並建立了 OpenJDK 組織來對這些代碼進行獨立的管理,這就是 OpenJDK 的來源,此時的 OpenJDK 擁有當時 sunJDK 7 的幾乎全部代碼。

1.2 OracleJDK

在 JDK 7 的開發期間,由於各種原因的影響 Sun 公司市值一路下跌,已無力推進 JDK 7 的開發,JDK 7 的發佈一直被推遲。之後在 2009 年 Sun 公司被 Oracle 公司所收購,爲解決 JDK 7 長期跳票的問題,Oracle 將 JDK 7 中大部分未能完成的項目推遲到 JDK 8 ,並於 2011 年發佈了JDK 7,在這之後由 Oracle 公司正常發行的 JDK 版本就由 SunJDK 改稱爲 Oracle JDK。

在 2017 年 JDK 9 發佈後,Oracle 公司宣佈從此以後 JDK 將會在每年的 3 月和 9 月各發佈一個大版本,即半年發行一個大版本,目的是爲了避免衆多功能被捆綁到一個 JDK 版本上而引發的無法交付的風險。

在 JDK 11 發佈後,Oracle 同步調整了 JDK 的商業授權,宣佈從 JDK 11 起將以前的商業特性全部開源給 OpenJDK ,這樣 OpenJDK 11 和 OracleJDK 11 的代碼和功能,在本質上就完全相同了。同時還宣佈以後都會發行兩個版本的 JDK :

  • 一個是在 GPLv2 + CE 協議下由 Oracle 開源的 OpenJDK;
  • 一個是在 OTN 協議下正常發行的 OracleJDK。

兩者共享大部分源碼,在功能上幾乎一致。唯一的區別是 Oracle OpenJDK 可以在開發、測試或者生產環境中使用,但只有半年的更新支持;而 OracleJDK 對個人免費,但在生產環境中商用收費,可以有三年時間的更新支持。

1.3 HotSpot VM

它是 Sun/Oracle JDK 和 OpenJDK 中默認的虛擬機,也是目前使用最爲廣泛的虛擬機。最初由 Longview Technologies 公司設計發明,該公司在 1997 年被 Sun 公司收購,隨後 Sun 公司在 2006 年開源 SunJDK 時也將 HotSpot 虛擬機一併進行了開源。之後 Oracle 收購 Sun 以後,建立了 HotRockit 項目,並將其收購的另外一家公司(BEA)的 JRockit 虛擬機中的優秀特性集成到 HotSpot 中。HotSpot 在這個過程裏面移除掉永久代,並吸收了 JRockit 的 Java Mission Control 監控工具等功能。到 JDK 8 發行時,採用的就是集兩者之長的 HotSpot VM 。

我們可以在自己的電腦上使用 java -version 來獲得 JDK 的信息:

C:\Users> java -version
java version "1.8.0_171"   # 如果是openJDK, 則這裏會顯示:openjdk version
Java(TM) SE Runtime Environment (build 1.8.0_171-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.171-b11, mixed mode) # 使用的是HotSpot虛擬機,默認爲服務端模式

二、Java 內存區域

https://github.com/heibaiying

2.1 程序計數器

程序計數器(Program Counter Register)是一塊較小的內存空間,它可以看做是當前線程所執行的字節碼的行號指示器。字節碼解釋器通過改變程序計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要該計數器來完成。每條線程都擁有一個獨立的程序計數器,各條線程之間的計數器互不影響,獨立存儲。

2.2 Java虛擬機棧

Java 虛擬機棧(Java Virtual Machine Stack)也爲線程私有,它描述的是 Java 方法執行的線程內存模型:每個方法被執行的時候,Java 虛擬機都會同步創建一個棧幀,用於存儲局部變量表、操作數棧、動態連接、方法出口等信息。方法從調用到結束就對應着一個棧幀從入棧到出棧的過程。在《Java 虛擬機規範》中,對該內存區域規定了兩類異常:

  • 如果線程請求的棧深度大於虛擬機所允許的棧深度,將拋出 StackOverflowError 異常;
  • 如果 Java 虛擬機棧的容量允許動態擴展,當棧擴展時如果無法申請到足夠的內存會拋出 OutOfMemoryError 異常。

2.3 本地方法棧

本地方法棧(Native Method Stacks)與虛擬機棧類似,其區別在於:Java 虛擬機棧是爲虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則是爲虛擬機使用到的本地(Native)方法服務。

2.4 Java堆

Java 堆(Java Heap)是虛擬機所管理的最大一塊的內存空間,它被所有線程所共享,用於存放對象實例。Java 堆可以處於物理上不連續的內存空間中,但在邏輯上它應該被視爲是連續的。Java 堆可以被實現成固定大小的,也可以是可擴展的,當前大多數主流的虛擬機都是按照可擴展來實現的,即可以通過最大值參數 -Xmx 和最小值參數 -Xms 進行設定。如果 Java 堆中沒有足夠的內存來完成實例分配,並且堆也無法再擴展時,Java 虛擬機將會拋出 OutOfMemoryError 異常。

2.5 方法區

方法區(Method Area)也是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。方法區也被稱爲 “非堆”,目的是與 Java 堆進行區分。《Java 虛擬機規範》規定,如果方法區無法滿足新的內存分配需求時,將會拋出 OutOfMemoryError 異常。

運行時常量池(Runtime Constant Pool)是方法區的一部分,用於存放常量池表(Constant Pool Table),常量池表中存放了編譯期生成的各種符號字面量和符號引用。

三、對象

3.1 對象的創建

當我們在代碼中使用 new 關鍵字創建一個對象時,其在虛擬機中需要經過以下步驟:

1. 類加載過程

當虛擬機遇到一條字節碼 new 指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個符號引用,並且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,就必須先執行相應的類加載過程。

2. 分配內存

在類加載檢查通過後,虛擬機需要新生對象分配內存空間。根據 Java 堆是否規整,可以有以下兩種分配方案:

  • 指針碰撞:假設 Java 堆中內存是絕對規整的,所有使用的內存放在一邊,所有未被使用的內存放在另外一邊,中間以指針作爲分界點指示器。此時內存分配只是將指針向空閒方向偏移出對象大小的空間即可,這種方式被稱爲指針碰撞。

https://github.com/heibaiying

  • 空閒列表:如果 Java 堆不是規整的,此時虛擬機需要維護一個列表,記錄哪些內存塊是可用的,哪些是不可用的。在進行內存分配時,只需要從該列表中選取出一塊足夠的內存空間劃分給對象實例即可。

注:Java 堆是否規整取決於其採用的垃圾收集器是否帶有空間壓縮整理能力,後文將會介紹。

除了分配方式外,由於對象創建在虛擬機中是一個非常頻繁的行爲,此時需要保證在併發環境下的線程安全:如果一個線程給對象 A 分配了內存空間,但指針還沒來得及修改,此時就可能出現另外一個線程使用原來的指針來給對象 B 分配內存空間的情況。想要解決這個問題有兩個方案:

  • 方式一:採用同步鎖定,或採用 CAS 配上失敗重試的方式來保證更新操作的原子性。
  • 方式二:爲每個線程在 Java 堆中預先分配一塊內存,稱爲本地線程分配緩衝(Thread Local Allocation Buffer,TLAB)。線程在進行內存分配時優先使用本地緩衝,當本地緩衝使用完成後,再向 Java 堆申請分配,此時 Java 堆採用同步鎖定的方式來保證分配行爲的線程安全。

3. 對象頭設置

將對象有關的元數據信息、對象的哈希碼、分代年齡等信息存儲到對象頭中。

4. 對象初始化

調用對象的構造函數,即 Class 文件中的 <init>() 來初始化對象,爲相關字段賦值。

3.2 對象的內存佈局

在 HotSpot 虛擬機中,對象在堆內存中的存儲佈局可以劃分爲以下三個部分:

1. 對象頭 (Header)

對象頭包括兩部分信息:

  • Mark Word:對象自身的運行時數據,如哈希碼、GC 分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等,官方統稱爲 Mark Word 。
  • 類型指針:對象指向它類型元數據的指針,Java 虛擬機通過這個指針來確定該對象是哪個類的示例。需要說明的是並非所有的虛擬機都必須要在對象數據上保留類型指針,這取決於對象的訪問定位方式(詳見下文)。

2. 實例數據 (Instance Data)

即我們在程序代碼中定義的各種類型的字段的內容,無論是從父類繼承而來,還是子類中定義的都需要記錄。

3. 對齊填充 (Padding)

主要起佔位符的作用。HotSpot 虛擬機要求對象起始地址必須是 8 字節的整倍數,即間接要求了任何對象的大小都必須是 8 字節的整倍數。對象頭部分在設計上就是 8 字節的整倍數,如果對象的實例數據不是 8 字節的整倍數,則由對齊填充進行補全。

3.3 對象的訪問定位

對象創建後,Java 程序就可以通過棧上的 reference 來操作堆上的具體對象。《Java 虛擬機規範》規定 reference 是一個指向對象的引用,但並未規定其具體實現方式。主流的方式方式有以下兩種:

  • 句柄訪問:Java 堆將劃分出一塊內存來作爲句柄池, reference 中存儲的是對象的句柄地址,而句柄則包含了對象實例數據和類型數據的地址信息。
  • 指針訪問reference 中存儲的直接就是對象地址,而對象的類型數據則由上文介紹的對象頭中的類型指針來指定。

通過句柄訪問對象:

https://github.com/heibaiying

通過直接指針訪問對象:

https://github.com/heibaiying

句柄訪問的優點在於對象移動時(垃圾收集時移動對象是非常普遍的行爲)只需要改變句柄中實例數據的指針,而 reference 本生並不需要修改;指針訪問則反之,由於其 reference 中存儲的直接就是對象地址,所以當對象移動時, reference 需要被修改。但針對只需要訪問對象本身的場景,指針訪問則可以減少一次定位開銷。由於對象訪問是一項非常頻繁的操作,所以這類減少的效果會非常顯著,基於這個原因,HotSpot 主要使用的是指針訪問的方式。

四、垃圾收集算法

在 Java 虛擬機內存模型中,程序計數器、虛擬機棧、本地方法棧這 3 個區域都是線程私有的,會隨着線程的結束而銷燬,因此在這 3 個區域當中,無需過多考慮垃圾回收問題。垃圾回收問題主要發生在 Java 堆和方法區上。

4.1 Java 堆回收

在 Java 堆上,垃圾回收的主要內容是死亡對象(不可能再被任何途徑使用的對象)。而判斷對象是否死亡有以下兩種方法:

1. 引用計數法

在對象中添加一個引用計數器,對象每次被引用時,該計數器加一;當引用失效時,計數器的值減一;只要計數器的值爲零,則代表對應的對象不可能再被使用。該方法的缺點在於無法避免相互循環引用的問題:

objA.instance = objB
objB.instance = objA    
objA = null;
objB = null;
System.gc();

如上所示,此時兩個對象已經不能再被訪問,但其互相持有對對方的引用,如果採用引用計數法,則兩個對象都無法被回收。

2. 可達性分析

上面的代碼在大多數虛擬機中都能被正確的回收,因爲大多數主流的虛擬機都是採用的可達性分析方法來判斷對象是否死亡。可達性分析是通過一系列被稱爲 GC Roots 的根對象作爲起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑被稱爲引用鏈(Reference Chain),如果某個對象到 GC Roots 間沒有任何引用鏈相連,這代表 GC Roots 到該對象不可達, 此時證明此該對象不可能再被使用。

https://github.com/heibaiying

在 Java 語言中,固定可作爲 GC Roots 的對象包括以下幾種:

  • 在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等;
  • 在方法區中類靜態屬性引用的對象,譬如 Java 類中引用類型的靜態變量;
  • 在方法區中常量引用的對象,譬如字符串常量池(String Table)裏的引用;
  • 在本地方法棧中的 JNI(即 Native 方法)引用的對象;
  • Java 虛擬機內部的引用,如基本數據類型對應的 Class 對象,一些常駐的異常對象(如 NullPointException,OutOfMemoryError 等)及系統類加載器;
  • 所有被同步鎖(synchronized 關鍵字)持有的對象;
  • 反應 Java 虛擬機內部情況的 JMXBean,JVMTI 中註冊的回調,本地代碼緩存等。

除了這些固定的 GC Roots 集合以外,根據用戶所選用的垃圾收集器以及當前回收的內存區域的不同,還可能會有其他對象 “臨時性” 地加入,共同構成完整的 GC Roots 集合。

3. 對象引用

可達性分析是基於引用鏈進行判斷的,在 JDK 1.2 之後,Java 將引用關係分爲以下四類:

  • 強引用 (Strongly Reference) :最傳統的引用,如 Object obj = new Object() 。無論任何情況下,只要強引用關係還存在,垃圾收集器就永遠不會回收掉被引用的對象。
  • 軟引用 (Soft Reference) :用於描述一些還有用,但非必須的對象。只被軟引用關聯着的對象,在系統將要發生內存溢出異常之前,會被列入回收範圍內進行第二次回收,如果這次回收後還沒有足夠的內存,纔會拋出內存溢出異常。
  • 弱引用 (Weak Reference) :用於描述那些非必須的對象,強度比軟引用弱。被弱引用關聯對象只能生存到下一次垃圾收集發生時,無論當前內存是否足夠,弱引用對象都會被回收。
  • 虛引用 (Phantom Reference) :最弱的引用關係。爲一個對象設置虛引用關聯的唯一目的只是爲了能在這個對象被回收時收到一個系統通知。

4. 對象真正死亡

要真正宣告一個對象死亡,需要經過至少兩次標記過程:

  • 如果對象在進行可達性分析後發現 GC Roots 不可達,將會進行第一次標記;
  • 隨後進行一次篩選,篩選的條件是此對象是否有必要執行 finalized() 方法。如果對象沒有覆蓋 finalized() 方法,或者 finalized() 已經被虛擬機調用過,這兩種情況都會視爲沒有必要執行。如果判定結果是有必要執行,此時對象會被放入名爲 F-Queue 的隊列,等待 Finalizer 線程執行其 finalized() 方法。在這個過程中,收集器會進行第二次小規模的標記,如果對象在 finalized() 方法中重新將自己與引用鏈上的任何一個對象進行了關聯,如將自己(this 關鍵字)賦值給某個類變量或者對象的成員變量,此時它就實現了自我拯救,則第二次標記會將其移除 “即將回收” 的集合,否則該對象就將被真正回收,走向死亡。

4.2 方法區回收

在 Java 堆上進行對象回收的性價比通常比較高,因爲大多數對象都是朝生夕滅的。而方法區由於回收條件比較苛刻,對應的回收性價比通常比較低,主要回收兩部分內容:廢棄的常量和不再使用的類型。

4.3 垃圾收集算法

1. 分代收集理論

當前大多數虛擬機都遵循 “分代收集” 的理論進行設計,它建立在強弱兩個分代假說下:

  • 弱分代假說 (Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。
  • 強分代假說 (Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。
  • 跨帶引用假說 (Intergenerational Reference Hypothesis):基於上面兩條假說還可以得出的一條隱含推論:存在相互引用關係的兩個對象,應該傾向於同時生存或者同時消亡。

強弱分代假說奠定了垃圾收集器的設計原則:收集器應該將 Java 堆劃分出不同的區域,然後將回收對象依據其年齡(年齡就是對象經歷垃圾收集的次數)分配到不同的區域中進行存儲。之後如果一個區域中的對象都是朝生夕滅的,那麼收集器只需要關注少量對象的存活而不是去標記那些大量將要被回收的對象,此時就能以較小的代價獲取較大的空間。最後再將難以消亡的對象集中到一塊,根據強分代假說,它們是很難消亡的,因此虛擬機可以使用較低的頻率進行回收,這就兼顧了時間和內存空間的開銷。

2. 回收類型

根據分代收集理論,收集範圍可以分爲以下幾種類型:

  • 部分收集 (Partial GC):具體分爲:
    • 新生代收集(Minor GC / Young GC):只對新生代進行垃圾收集;
    • 老年代收集(Major GC / Old GC):只對老年代進行垃圾收集。需要注意的是 Major GC 在有的語境中也用於指代整堆收集;
    • 混合收集(Mixed GC):對整個新生代和部分老年代進行垃圾收集。
  • 整堆收集 (Full GC):收集整個 Java 堆和方法區。

3. 標記-清除算法

它是最基礎的垃圾收集算法,收集過程分爲兩個階段:首先標記出所有需要回收的對象,在標記完成後,統一回收掉所有被標記的對象;也可以反過來,標記存活對象,統一回收所有未被標記的對象。

https://github.com/heibaiying

它主要有以下兩個缺點:

  • 執行效率不穩定:如果 Java 堆上包含大量需要回收的對象,則需要進行大量標記和清除動作;
  • 內存空間碎片化:標記清除後會產生大量不連續的空間,從而可能導致無法爲大對象分配足夠的連續內存。

4. 標記-複製算法

標記-複製算法基於 ”半區複製“ 算法:它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中一塊,當這一塊的內存使用完了,就將還存活着的對象複製到另外一塊上面,然後再把已經使用過的那塊內存空間一次性清理掉。其優點在於避免了內存空間碎片化的問題,其缺點如下:

  • 如果內存中多數對象都是存活的,這種算法將產生大量的複製開銷;
  • 浪費內存空間,內存空間變爲了原有的一半。

https://github.com/heibaiying

基於新生代 “朝生夕滅” 的特點,大多數虛擬機都不會按照 1:1 的比例來進行內存劃分,例如 HotSpot 虛擬機會將內存空間劃分爲一塊較大的 Eden 和 兩塊較小的 Survivor 空間,它們之間的比例是 8:1:1 。 每次分配時只會使用 Eden 和其中的一塊 Survivor ,發生垃圾回收時,只需要將存活的對象一次性複製到另外一塊 Survivor 上,這樣只有 10% 的內存空間會被浪費掉。當 Survivor 空間不足以容納一次 Minor GC 時,此時由其他內存區域(通常是老年代)來進行分配擔保。

5. 標記-整理算法

標記-整理算法是在標記完成後,讓所有存活對象都向內存的一端移動,然後直接清理掉邊界以外的內存。其優點在於可以避免內存空間碎片化的問題,也可以充分利用內存空間;其缺點在於根據所使用的收集器的不同,在移動存活對象時可能要全程暫停用戶程序:

https://github.com/heibaiying

五、經典垃圾收集器

並行與併發是併發編程中的專有名詞,在談論垃圾收集器的上下文語境中,它們的含義如下:

  • 並行 (Parallel):並行描述的是多條垃圾收集器線程之間的關係,說明同一時間有多條這樣的線程在協同工作,此時通常默認用戶線程是處於等待狀態。

  • 併發 (Concurrent):併發描述的是垃圾收集器線程與用戶線程之間的關係,說明同一時間垃圾收集器線程與用戶線程都在運行。但由於垃圾收集器線程會佔用一部分系統資源,所以程序的吞吐量依然會受到一定影響。

HotSpot 虛擬機中一共存在七款經典的垃圾收集器:

https://github.com/heibaiying

注:收集器之間存在連線,則代表它們可以搭配使用。

5.1 Serial 收集器

Serial 收集器是最基礎、歷史最悠久的收集器,它是一個單線程收集器,在進行垃圾回收時,必須暫停其他所有的工作線程,直到收集結束,這是其主要缺點。它的優點在於單線程避免了多線程複雜的上下文切換,因此在單線程環境下收集效率非常高,由於這個優點,迄今爲止,其仍然是 HotSpot 虛擬機在客戶端模式下默認的新生代收集器:

https://github.com/heibaiying

5.2 ParNew 收集器

他是 Serial 收集器的多線程版本,可以使用多條線程進行垃圾回收:

https://github.com/heibaiying

5.3 Parallel Scavenge 收集器

Parallel Scavenge 也是新生代收集器,基於 標記-複製 算法進行實現,它的目標是達到一個可控的吞吐量。這裏的吞吐量指的是處理器運行用戶代碼的時間與處理器總消耗時間的比值:

吞吐量 = 運行用戶代碼時間 \ (運行用戶代碼時間 + 運行垃圾收集時間)

Parallel Scavenge 收集器提供兩個參數用於精確控制吞吐量:

  • -XX:MaxGCPauseMillis:控制最大垃圾收集時間,假設需要回收的垃圾總量不變,那麼降低垃圾收集的時間就會導致收集頻率變高,所以需要將其設置爲合適的值,不能一味減小。
  • -XX:MaxGCTimeRatio:直接用於設置吞吐量大小,它是一個大於 0 小於 100 的整數。假設把它設置爲 19,表示此時允許的最大垃圾收集時間佔總時間的 5%(即 1/(1+19) );默認值爲 99 ,即允許最大 1%( 1/(1+99) )的垃圾收集時間。

5.4 Serial Old 收集器

從名字也可以看出來,它是 Serial 收集器的老年代版本,同樣是一個單線程收集器,採用 標記-整理 算法,主要用於給客戶端模式下的 HotSpot 虛擬機使用:

https://github.com/heibaiying

5.5 Paralled Old 收集器

Paralled Old 是 Parallel Scavenge 收集器的老年代版本,支持多線程併發收集,採用 標記-整理 算法實現:

https://github.com/heibaiying

5.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器,基於 標記-清除 算法實現,整個收集過程分爲以下四個階段:

  1. 初始標記 (inital mark):標記 GC Roots 能直接關聯到的對象,耗時短但需要暫停用戶線程;
  2. 併發標記 (concurrent mark):從 GC Roots 能直接關聯到的對象開始遍歷整個對象圖,耗時長但不需要暫停用戶線程;
  3. 重新標記 (remark):採用增量更新算法,對併發標記階段因爲用戶線程運行而產生變動的那部分對象進行重新標記,耗時比初始標記稍長且需要暫停用戶線程;
  4. 併發清除 (inital sweep):併發清除掉已經死亡的對象,耗時長但不需要暫停用戶線程。

https://github.com/heibaiying

其優點在於耗時長的 併發標記 和 併發清除 階段都不需要暫停用戶線程,因此其停頓時間較短,其主要缺點如下:

  • 由於涉及併發操作,因此對處理器資源比較敏感。
  • 由於是基於 標記-清除 算法實現的,因此會產生大量空間碎片。
  • 無法處理浮動垃圾(Floating Garbage):由於併發清除時用戶線程還是在繼續,所以此時仍然會產生垃圾,這些垃圾就被稱爲浮動垃圾,只能等到下一次垃圾收集時再進行清理。

5.7 Garbage First 收集器

Garbage First(簡稱 G1)是一款面向服務端的垃圾收集器,也是 JDK 9 服務端模式下默認的垃圾收集器,它的誕生具有里程碑式的意義。G1 雖然也遵循分代收集理論,但不再以固定大小和固定數量來劃分分代區域,而是把連續的 Java 堆劃分爲多個大小相等的獨立區域(Region)。每一個 Region 都可以根據不同的需求來扮演新生代的 Eden 空間、Survivor 空間或者老年代空間,收集器會根據其扮演角色的不同而採用不同的收集策略。

https://github.com/heibaiying

上面還有一些 Region 使用 H 進行標註,它代表 Humongous,表示這些 Region 用於存儲大對象(humongous object,H-obj),即大小大於等於 region 一半的對象。G1 收集器的運行大致可以分爲以下四個步驟:

  1. 初始標記 (Inital Marking):標記 GC Roots 能直接關聯到的對象,並且修改 TAMS(Top at Mark Start)指針的值,讓下一階段用戶線程併發運行時,能夠正確的在 Reigin 中分配新對象。G1 爲每一個 Reigin 都設計了兩個名爲 TAMS 的指針,新分配的對象必須位於這兩個指針位置以上,位於這兩個指針位置以上的對象默認被隱式標記爲存活的,不會納入回收範圍;
  2. 併發標記 (Concurrent Marking):從 GC Roots 能直接關聯到的對象開始遍歷整個對象圖。遍歷完成後,還需要處理 SATB 記錄中變動的對象。SATB(snapshot-at-the-beginning,開始階段快照)能夠有效的解決併發標記階段因爲用戶線程運行而導致的對象變動,其效率比 CMS 重新標記階段所使用的增量更新算法效率更高;
  3. 最終標記 (Final Marking):對用戶線程做一個短暫的暫停,用於處理併發階段結束後仍遺留下來的少量的 STAB 記錄。雖然併發標記階段會處理 SATB 記錄,但由於處理時用戶線程依然是運行中的,因此依然會有少量的變動,所以需要最終標記來處理;
  4. 篩選回收 (Live Data Counting and Evacuation):負責更新 Regin 統計數據,按照各個 Regin 的回收價值和成本進行排序,在根據用戶期望的停頓時間進行來指定回收計劃,可以選擇任意多個 Regin 構成回收集。然後將回收集中 Regin 的存活對象複製到空的 Regin 中,再清理掉整個舊的 Regin 。此時因爲涉及到存活對象的移動,所以需要暫停用戶線程,並由多個收集線程並行執行。

https://github.com/heibaiying

5.8 內存分配原則

1. 對象優先在 Eden 分配

大多數情況下,對象在新生代的 Eden 區中進行分配,當 Eden 區沒有足夠空間時,虛擬機將進行一次 Minor GC。

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

大對象就是指需要大量連續內存空間的 Java 對象,最典型的就是超長的字符串或者元素數量很多的數組,它們將直接進入老年代。主要是因爲如果在新生代分配,因爲其需要大量連續的內存空間,可能會導致提前觸發垃圾回收;並且由於新生代的垃圾回收本身就很頻繁,此時複製大對象也需要額外的性能開銷。

3. 長期存活的對象將進入老年代

虛擬機會給每個對象在其對象頭中定義一個年齡計數器。對象通常在 Eden 區中誕生,如果經歷第一次 Minor GC 後仍然存活,並且能夠被 Survivor 容納的話,該對象就會被移動到 Survivor 中,並將其年齡加 1。對象在 Survivor 中每經過一次 Minor GC,年齡就加 1,當年齡達到一定程度後(由 -XX:MaxTenuringThreshold 設置,默認值爲 15)就會進入老年代中。

4. 動態年齡判斷

如果在 Survivor 空間中相同年齡的所有對象大小的總和大於 Survivor 空間的一半,那麼年齡大於或等於該年齡的對象就可以直接進入老年代,而無需等待年齡到達 -XX:MaxTenuringThreshold 設置的值。

5. 空間擔保分配

在發生 Minor GC 之前,虛擬機必須先檢查老年代最大可用的連續空間是否大於新生代所有對象的總空間,如果條件成立,那麼這一次的 Minor GC 可以確認是安全的。如果不成立,虛擬機會查看 -XX:HandlePromotionFailure 的值是否允許擔保失敗,如果允許那麼就會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試着進行一次 Minor GC;如果小於或者 -XX:HandlePromotionFailure 的值設置不允許冒險,那麼就要改爲進行一次 Full GC 。

六、虛擬機類加載機制

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

6.1 類加載時機

一個類型從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期將會經歷加載、驗證、準備、卸載、解析、初始化、使用、卸載七個階段,其中驗證、準備、解析三個部分統稱爲連接:

https://github.com/heibaiying

《Java 虛擬機規範》嚴格規定了有且只有六種情況必須立即對類進行初始化:

  1. 遇到 newgetstaticputstaticinvokestatic 這四條字節碼指令,如果類型進行過初始化,則需要先觸發其進行初始化,能夠生成這四條指令碼的典型 Java 代碼場景有:
    • 使用 new 關鍵字實例化對象時;
    • 讀取或設置一個類型的靜態字段時(被 final 修飾,已在編譯期把結果放入常量池的靜態字段除外);
    • 調用一個類型的靜態方法時。
  2. 使用 java.lang.reflect 包的方法對類型進行反射調用時,如果類型沒有進行過初始化、則需要觸發其初始化;
  3. 當初始化類時,如發現其父類還沒有進行過初始化、則需要觸發其父類進行初始化;
  4. 當虛擬機啓動時,用戶需要指定一個要執行的主類(包含 main() 方法的那個類),虛擬機會先初始化這個主類;
  5. 當使用 JDK 7 新加入的動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最後解析的結果爲 REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial 四種類型的方法句柄,並且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化;
  6. 當一個接口中定義了 JDK 8 新加入的默認方法(被 default 關鍵字修飾的接口方法)時,如果有這個接口的實現類發生了初始化,那麼該接口要在其之前被初始化。

6.2 類加載過程

1. 加載

在加載階段,虛擬機需要完成以下三件事:

  • 通過一個類的全限定名來獲取定義此類的二進制字節流 ;
  • 將這個字節流所代表的靜態存儲結構轉換爲方法區的運行時數據結構;
  • 在內存中生成一個代表這個類的 java.lang.Class 對象,作爲方法區這個類的各種數據的訪問入口。

《Java 虛擬機規範》並沒有限制從何處獲取二進制流,因此可以從 JAR 包、WAR 包獲取,也可以從 JSP 生成的 Class 文件等處獲取。

2. 驗證

這一階段的目的是確保 Class 文件的字節流中包含的信息符合《Java 虛擬機規範》的全部約束要求,從而保證這些信息被當做代碼運行後不會危害虛擬機自身的安全。驗證階段大致會完成下面四項驗證:

  • 文件格式驗證:驗證字節流是否符合 Class 文件格式的規範;
  • 元數據驗證:對字節碼描述的信息進行語義分析,以保證其描述的信息符合《Java 語言規範》的要求(如除了 java.lang.Object 外,所有的類都應該有父類);
  • 字節碼驗證:通過數據流分析和控制流分析,確定程序語義是合法的,符合邏輯的(如允許把子類對象賦值給父類數據類型,但不能把父類對象賦值給子類數據類型);
  • 符號引用驗證:驗證類是否缺少或者被禁止訪問它依賴的某些外部類、方法、字段等資源。如果無法驗證通過,則會拋出一個java.lang.IncompatibleClassChangeError 的子類異常,如 java.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError 等。

3. 準備

準備階段是正式爲類中定義的變量(即靜態變量,被 static 修飾的變量)分配內存並設置類變量初始值的階段。

4. 解析

解析是 Java 虛擬機將常量池內的符號引用替換爲直接引用的過程:

  • 符號引用:符號引用用一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。
  • 直接引用:直接引用是指可以直接指向目標的指針、相對偏移量或者一個能間接定位到目標的句柄。

整個解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符這 7 類符號引用進行解析。

5. 初始化

初始化階段就是執行類構造器的 <clinit>() 方法的過程,該方法具有以下特點:

  • <clinit>() 方法由編譯器自動收集類中所有類變量的賦值動作和靜態語句塊中的語句合併產生,編譯器收集順序由語句在源文件中出現的順序決定。
  • <clinit>() 方法與類的構造器函數(即在虛擬機視角中的實例構造器 <init>()方法)不同,它不需要顯示的調用父類的構造器,Java 虛擬機會保證在子類的 <clinit>() 方法執行前,父類的 <clinit>() 方法已經執行完畢。
  • 由於父類的 <clinit>() 方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類變量的賦值操作。
  • <clinit>() 方法對於類或者接口不是必須的,如果一個類中沒有靜態語句塊,也沒有對變量進行賦值操作,那麼編譯器可以不爲這個類生成 <clinit>() 方法。
  • 接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成 <clinit>() 方法。
  • Java 虛擬機必須保證一個類的 <clinit>() 方法在多線程環境中被正確的加鎖同步,如果多個線程同時去初始化一個類,那麼只會有其中一個線程去執行這個類的 <clinit>() 方法,其他線程都需要阻塞等待。

6.3 類加載器

能夠通過一個類的全限定名來獲取描述該類的二進制字節流的工具稱爲類加載器。每一個類加載器都擁有一個獨立的類名空間,因此對於任意一個類,都必須由加載它的類加載器和這個類本身來共同確立其在 Java 虛擬機中的唯一性。這意味着要想比較兩個類是否相等,必須在同一類加載器加載的前提下;如果兩個類的類加載器不同,則它們一定不相等。

6.4 雙親委派模型

從 Java 虛擬機角度而言,類加載器可以分爲以下兩類:

  • 啓動類加載器:啓動類加載器(Bootstrap ClassLoader)由 C++ 語言實現(以 HotSpot 爲例),它是虛擬機自身的一部分;
  • 其他所有類的類加載器:由 Java 語言實現,獨立存在於虛擬機外部,並且全部繼承自 java.lang.ClassLoader

從開發人員角度而言,類加載器可以分爲以下三類:

  • 啓動類加載器 (Boostrap Class Loader):負責把存放在 <JAVA_HOME>\lib 目錄中,或被 -Xbootclasspath 參數所指定的路徑中存放的能被 Java 虛擬機識別的類庫加載到虛擬機的內存中;
  • 擴展類加載器 (Extension Class Loader):負責加載 <JAVA_HOME>\lib\ext 目錄中,或被 java.ext.dirs 系統變量所指定的路徑中的所有類庫。
  • 應用程序類加載器 (Application Class Loader):負責加載用戶類路徑(ClassPath)上的所有的類庫。

JDK 9 之前的 Java 應用都是由這三種類加載器相互配合來完成加載:

https://github.com/heibaiying

上圖所示的各種類加載器之間的層次關係被稱爲類加載器的 “雙親委派模型”,“雙親委派模型” 要求除了頂層的啓動類加載器外,其餘的類加載器都應該有自己的父類加載器,需要注意的是這裏的加載器之間的父子關係一般不是以繼承關係來實現的,而是使用組合關係來複用父類加載器的代碼。

雙親委派模型的工作過程如下:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到最頂層的啓動類加載器,只有當父加載器反饋自己無法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試自己去完成加載。基於雙親委派模型可以保證程序中的類在各種類加載器環境中都是同一個類,否則就有可能出現一個程序中存在兩個不同的 java.lang.Object 的情況。

6.5 模塊化下的類加載器

JDK 9 之後爲了適應模塊化的發展,類加載器做了如下變化:

  • 仍維持三層類加載器和雙親委派的架構,但擴展類加載器被平臺類加載器所取代;
  • 當平臺及應用程序類加載器收到類加載請求時,要首先判斷該類是否能夠歸屬到某一個系統模塊中,如果可以找到這樣的歸屬關係,就要優先委派給負責那個模塊的加載器完成加載;
  • 啓動類加載器、平臺類加載器、應用程序類加載器全部繼承自 java.internal.loader.BuiltinClassLoader ,BuiltinClassLoader 中實現了新的模塊化架構下類如何從模塊中加載的邏輯,以及模塊中資源可訪問性的處理。

https://github.com/heibaiying

七、程序編譯

7.1 編譯器分類

  • 前端編譯器:把 *.java 文件轉變成 .class 文件的過程;如 JDK 的 Javac,Eclipse JDT 中的增量式編譯器。
  • 即使編譯器:常稱爲 JIT 編譯器(Just In Time Complier),在運行期把字節碼轉變成本地機器碼的過程;如 HotSpot 虛擬機中的 C1、C2 編譯器,Graal 編譯器。
  • 提前編譯器:直接把程序編譯成目標機器指令集相關的二進制代碼的過程。如 JDK 的 jaotc,GUN Compiler for the Java(GCJ),Excelsior JET 。

7.2 解釋器與編譯器

在 HotSpot 虛擬機中,Java 程序最初都是通過解釋器(Interpreter)進行解釋執行的,其優點在於可以省去編譯時間,讓程序快速啓動。當程序啓動後,如果虛擬機發現某個方法或代碼塊的運行特別頻繁,就會使用編譯器將其編譯爲本地機器碼,並使用各種手段進行優化,從而提高執行效率,這就是即時編譯器。HotSpot 內置了兩個(或三個)即時編譯器:

  • 客戶端編譯器 (Client Complier):簡稱 C1;
  • 服務端編譯器 (Servier Complier):簡稱 C2,在有的資料和 JDK 源碼中也稱爲 Opto 編譯器;
  • Graal 編譯器:在 JDK 10 時纔出現,長期目標是替代 C2。

在分層編譯的工作模式出現前,採用客戶端編譯器還是服務端編譯器完全取決於虛擬機是運行在客戶端模式還是服務端模式下,可以在啓動時通過 -client-server 參數進行指定,也可以讓虛擬機根據自身版本和宿主機性能來自主選擇。

7.3 分層編譯

要編譯出優化程度越高的代碼通常都需要越長的編譯時間,爲了在程序啓動速度與運行效率之間達到最佳平衡,HotSpot 虛擬機在編譯子系統中加入了分層編譯(Tiered Compilation):

  • 第 0 層:程序純解釋執行,並且解釋器不開啓性能監控功能;
  • 第 1 層:使用客戶端編譯器將字節碼編譯爲本地代碼來運行,進行簡單可靠的穩定優化,不開啓性能監控功能;
  • 第 2 層:仍然使用客戶端編譯執行,僅開啓方法及回邊次數統計等有限的性能監控;
  • 第 3 層:仍然使用客戶端編譯執行,開啓全部性能監控;
  • 第 4 層:使用服務端編譯器將字節碼編譯爲本地代碼,其耗時更長,並且會根據性能監控信息進行一些不可靠的激進優化。

以上層次並不是固定不變的,根據不同的運行參數和版本,虛擬機可以調整分層的數量。各層次編譯之間的交互轉換關係如下圖所示:

https://github.com/heibaiying

實施分層編譯後,解釋器、客戶端編譯器和服務端編譯器就會同時工作,可以用客戶端編譯器獲取更高的編譯速度、用服務端編譯器來獲取更好的編譯質量。

7.4 熱點探測

即時編譯器編譯的目標是 “熱點代碼”,它主要分爲以下兩類:

  • 被多次調用的方法。
  • 被多次執行循環體。這裏指的是一個方法只被少量調用過,但方法體內部存在循環次數較多的循環體,此時也認爲是熱點代碼。但編譯器編譯的仍然是循環體所在的方法,而不會單獨編譯循環體。

判斷某段代碼是否是熱點代碼的行爲稱爲 “熱點探測” (Hot Spot Code Detection),主流的熱點探測方法有以下兩種:

  • 基於採樣的熱點探測 (Sample Based Hot Spot Code Detection):採用這種方法的虛擬機會週期性地檢查各個線程的調用棧頂,如果發現某個(或某些)方法經常出現在棧頂,那麼就認爲它是 “熱點方法”。
  • 基於計數的熱點探測 (Counter Based Hot Spot Code Detection):採用這種方法的虛擬機會爲每個方法(甚至是代碼塊)建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值就認爲它是 “熱點方法”。

八、代碼優化

即時編譯器除了將字節碼編譯爲本地機器碼外,還會對代碼進行一定程度的優化,它包含多達幾十種優化技術,這裏選取其中代表性的四種進行介紹:

8.1 方法內聯

最重要的優化手段,它會將目標方法中的代碼原封不動地 “複製” 到發起調用的方法之中,避免發生真實的方法調用,並採用名爲類型繼承關係分析(Class Hierarchy Analysis,CHA)的技術來解決虛方法(Java 語言中默認的實例方法都是虛方法)的內聯問題。

8.2 逃逸分析

逃逸行爲主要分爲以下兩類:

  • 方法逃逸:當一個對象在方法裏面被定義後,它可能被外部方法所引用,例如作爲調用參數傳遞到其他方法中,此時稱爲方法逃逸;
  • 線程逃逸:當一個對象在方法裏面被定義後,它可能被外部線程所訪問,例如賦值給可以在其他線程中訪問的實例變量,此時稱爲線程,其逃逸程度高於方法逃逸。
public static StringBuilder concat(String... strings) {
    StringBuilder sb = new StringBuilder();
    for (String string : strings) {
        sb.append(string);
    }
    return sb; // 發生了方法逃逸
}

public static String concat(String... strings) {
    StringBuilder sb = new StringBuilder();
    for (String string : strings) {
        sb.append(string);
    }
    return sb.toString(); // 沒有發生方法逃逸
}

如果能證明一個對象不會逃逸到方法或線程之外,或者逃逸程度比較低(只逃逸出方法而不會逃逸出線程),則可以爲這個對象實例採取不同程序的優化:

  • 棧上分配 (Stack Allocations):如果一個對象不會逃逸到線程外,那麼將會在棧上分配內存來創建這個對象,而不是 Java 堆上,此時對象所佔用的內存空間就會隨着棧幀的出棧而銷燬,從而可以減輕垃圾回收的壓力。
  • 標量替換 (Scalar Replacement):如果一個數據已經無法再分解成爲更小的數據類型,那麼這些數據就稱爲標量(如 int、long 等數值類型及 reference 類型等);反之,如果一個數據可以繼續分解,那它就被稱爲聚合量(如對象)。如果一個對象不會逃逸外方法外,那麼就可以將其改爲直接創建若干個被這個方法使用的成員變量來替代,從而減少內存佔用。
  • 同步消除 (Synchronization Elimination):如果一個變量不會逃逸出線程,那麼對這個變量實施的同步措施就可以消除掉。

8.3 公共子表達式消除

如果一個表達式 E 之前已經被計算過了,並且從先前的計算到現在 E 中所有變量的值都沒有發生過變化,那麼 E 這次的出現就稱爲公共子表達式。對於這種表達式,無需再重新進行計算,只需要直接使用前面的計算結果即可。

8.4 數組邊界檢查消除

對於虛擬機執行子系統來說,每次數組元素的讀寫都帶有一次隱含的上下文檢查以避免訪問越界。如果數組的訪問發生在循環之中,並且使用循環變量來訪問數據,即循環變量的取值永遠在 [0,list.length) 之間,那麼此時就可以消除整個循環的數據邊界檢查,從而避免多次無用的判斷。

參考資料

更多文章,歡迎訪問 [全棧工程師手冊] ,GitHub 地址:https://github.com/heibaiying/Full-Stack-Notes

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