JVM 面試題,安排上了!!!

程式設計師cxuan發表於2021-10-08

肝了一篇非常硬核的 JVM 基礎總結,寫作不易,小夥伴們趕緊點贊、轉發安排起來!

原文連結 據說看完這篇 JVM 要一小時

JVM 的主要作用是什麼?

JVM 就是 Java Virtual Machine(Java虛擬機器)的縮寫,JVM 遮蔽了與具體作業系統平臺相關的資訊,使 Java 程式只需生成在 Java 虛擬機器上執行的目的碼 (位元組碼),就可以在不同的平臺上執行。

請你描述一下 Java 的記憶體區域?

JVM 在執行 Java 程式的過程中會把它管理的記憶體分為若干個不同的區域,這些組成部分有些是執行緒私有的,有些則是執行緒共享的,Java 記憶體區域也叫做執行時資料區,它的具體劃分如下:

image-20210909232300925

  • 虛擬機器棧 : Java 虛擬機器棧是執行緒私有的資料區,Java 虛擬機器棧的生命週期與執行緒相同,虛擬機器棧也是區域性變數的儲存位置。方法在執行過程中,會在虛擬機器棧中建立一個 棧幀(stack frame)。每個方法執行的過程就對應了一個入棧和出棧的過程。

image-20210817204550728

  • 本地方法棧: 本地方法棧也是執行緒私有的資料區,本地方法棧儲存的區域主要是 Java 中使用 native 關鍵字修飾的方法所儲存的區域。

  • 程式計數器:程式計數器也是執行緒私有的資料區,這部分割槽域用於儲存執行緒的指令地址,用於判斷執行緒的分支、迴圈、跳轉、異常、執行緒切換和恢復等功能,這些都通過程式計數器來完成。

  • 方法區:方法區是各個執行緒共享的記憶體區域,它用於儲存虛擬機器載入的 類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

  • :堆是執行緒共享的資料區,堆是 JVM 中最大的一塊儲存區域,所有的物件例項都會分配在堆上。JDK 1.7後,字串常量池從永久代中剝離出來,存放在堆中。

    堆空間的記憶體分配(預設情況下):

    • 老年代 : 三分之二的堆空間
    • 年輕代 : 三分之一的堆空間
      • eden 區: 8/10 的年輕代空間
      • survivor 0 : 1/10 的年輕代空間
      • survivor 1 : 1/10 的年輕代空間

    命令列上執行如下命令,會檢視預設的 JVM 引數。

    java -XX:+PrintFlagsFinal -version
    

    輸出的內容非常多,但是隻有兩行能夠反映出上面的記憶體分配結果

    image-20210817184720097

    image-20210817184754351

    image-20210817184629515

  • 執行時常量池:執行時常量池又被稱為 Runtime Constant Pool,這塊區域是方法區的一部分,它的名字非常有意思,通常被稱為 非堆。它並不要求常量一定只有在編譯期才能產生,也就是並非編譯期間將常量放在常量池中,執行期間也可以將新的常量放入常量池中,String 的 intern 方法就是一個典型的例子。

請你描述一下 Java 中的類載入機制?

Java 虛擬機器負責把描述類的資料從 Class 檔案載入到系統記憶體中,並對類的資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的 Java 型別,這個過程被稱之為 Java 的類載入機制

一個類從被載入到虛擬機器記憶體開始,到解除安裝出記憶體為止,一共會經歷下面這些過程。

image-20210823222909485

類載入機制一共有五個步驟,分別是載入、連結、初始化、使用和解除安裝階段,這五個階段的順序是確定的。

其中連結階段會細分成三個階段,分別是驗證、準備、解析階段,這三個階段的順序是不確定的,這三個階段通常互動進行。解析階段通常會在初始化之後再開始,這是為了支援 Java 語言的執行時繫結特性(也被稱為動態繫結)。

下面我們就來聊一下這幾個過程。

載入

關於什麼時候開始載入這個過程,《Java 虛擬機器規範》並沒有強制約束,所以這一點我們可以自由實現。載入是整個類載入過程的第一個階段,在這個階段,Java 虛擬機器需要完成三件事情:

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
  • 將這個位元組流表示的一種儲存結構轉換為執行時資料區中方法區的資料結構。
  • 在記憶體中生成一個 Class 物件,這個物件就代表了這個資料結構的訪問入口。

《Java 虛擬機器規範》並未規定全限定名是如何獲取的,所以現在業界有很多獲取全限定名的方式:

  • 從 ZIP 包中讀取,最終會改變為 JAR、EAR、WAR 格式。
  • 從網路中獲取,最常見的應用就是 Web Applet。
  • 執行時動態生成,使用最多的就是動態代理技術。
  • 由其他檔案生成,比如 JSP 應用場景,由 JSP 檔案生成對應的 Class 檔案。
  • 從資料庫中讀取,這種場景就比較小了。
  • 可以從加密檔案中獲取,這是典型的防止 Class 檔案被反編譯的保護措施。

載入階段既可以使用虛擬機器內建的引導類載入器來完成,也可以使用使用者自定義的類載入器來完成。程式設計師可以通過自己定義類載入器來控制位元組流的訪問方式。

陣列的載入不需要通過類載入器來建立,它是直接在記憶體中分配,但是陣列的元素型別(陣列去掉所有維度的型別)最終還是要靠類載入器來完成載入。

驗證

載入過後的下一個階段就是驗證,因為我們上一步講到在記憶體中生成了一個 Class 物件,這個物件是訪問其代表資料結構的入口,所以這一步驗證的工作就是確保 Class 檔案的位元組流中的內容符合《Java 虛擬機器規範》中的要求,保證這些資訊被當作程式碼執行後,它不會威脅到虛擬機器的安全。

驗證階段主要分為四個階段的檢驗:

  • 檔案格式驗證。
  • 後設資料驗證。
  • 位元組碼驗證。
  • 符號引用驗證。

檔案格式驗證

這一階段可能會包含下面這些驗證點:

  • 魔數是否以 0xCAFEBABE 開頭。
  • 主、次版本號是否在當前 Java 虛擬機器接受範圍之內。
  • 常亮池的常量中是否有不支援的常量型別。
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量。
  • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 編碼的資料。
  • Class 檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊。

實際上驗證點遠遠不止有這些,上面這些只是從 HotSpot 原始碼中摘抄的一小段內容。

後設資料驗證

這一階段主要是對位元組碼描述的資訊進行語義分析,以確保描述的資訊符合《Java 語言規範》,驗證點包括

  • 驗證的類是否有父類(除了 Object 類之外,所有的類都應該有父類)。
  • 要驗證類的父類是否繼承了不允許繼承的類。
  • 如果這個類不是抽象類,那麼這個類是否實現了父類或者介面中要求的所有方法。
  • 是否覆蓋了 final 欄位,是否出現了不符合規定的過載等。

需要記住這一階段只是對《Java 語言規範》的驗證。

位元組碼驗證

位元組碼驗證階段是最複雜的一個階段,這個階段主要是確定程式語意是否合法、是否是符合邏輯的。這個階段主要是對類的方法體(Class 檔案中的 Code 屬性)進行校驗分析。這部分驗證包括

  • 確保運算元棧的資料型別和實際執行時的資料型別是否一致。
  • 保證任何跳轉指令不會跳出到方法體外的位元組碼指令上。
  • 保證方法體中的型別轉換是有效的,例如可以把一個子類物件賦值給父類資料型別,但是不能把父類資料型別賦值給子類等諸如此不安全的型別轉換。
  • 其他驗證。

如果沒有通過位元組碼驗證,就說明驗證出問題。但是不一定通過了位元組碼驗證,就能保證程式是安全的。

符號引用驗證

最後一個階段的校驗行為發生在虛擬機器將符號引用轉換為直接引用的時候,這個轉化將在連線的第三個階段,即解析階段中發生。符號引用驗證可以看作是對類自身以外的各類資訊進行匹配性校驗,這個驗證主要包括

  • 符號引用中的字串全限定名是否能找到對應的類。
  • 指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位。
  • 符號引用的類、欄位方法的可訪問性是否可被當前類所訪問。
  • 其他驗證。

這一階段主要是確保解析行為能否正常執行,如果無法通過符號引用驗證,就會出現類似 IllegalAccessErrorNoSuchFieldErrorNoSuchMethodError 等錯誤。

驗證階段對於虛擬機器來說非常重要,如果能通過驗證,就說明你的程式在執行時不會產生任何影響。

準備

準備階段是為類中的變數分配記憶體並設定其初始值的階段,這些變數所使用的記憶體都應當在方法區中進行分配,在 JDK 7 之前,HotSpot 使用永久代來實現方法區,是符合這種邏輯概念的。而在 JDK 8 之後,變數則會隨著 Class 物件一起存放在 Java 堆中。

下面通常情況下的基本型別和引用型別的初始值

image-20210823223020677

除了"通常情況"下,還有一些"例外情況",如果類欄位屬性中存在 ConstantValue 屬性,那就這個變數值在初始階段就會初始化為 ConstantValue 屬性所指定的初始值,比如

public static final int value = "666";

編譯時就會把 value 的值設定為 666。

解析

解析階段是 Java 虛擬機器將常量池內的符號引用替換為直接引用的過程。

  • 符號引用:符號引用以一組符號來描述所引用的目標。符號引用可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可,符號引用和虛擬機器的佈局無關。
  • 直接引用:直接引用可以直接指向目標的指標、相對便宜量或者一個能間接定位到目標的控制程式碼。直接引用和虛擬機器的佈局是相關的,不同的虛擬機器對於相同的符號引用所翻譯出來的直接引用一般是不同的。如果有了直接引用,那麼直接引用的目標一定被載入到了記憶體中。

這樣說你可能還有點不明白,我再換一種說法:

在編譯的時候一個每個 Java 類都會被編譯成一個 class 檔案,但在編譯的時候虛擬機器並不知道所引用類的地址,所以就用符號引用來代替,而在這個解析階段就是為了把這個符號引用轉化成為真正的地址的階段。

《Java 虛擬機器規範》並未規定解析階段發生的時間,只要求了在 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 這 17 個用於操作符號引用的位元組碼指令之前,先對所使用的符號引用進行解析。

解析也分為四個步驟

  • 類或介面的解析
  • 欄位解析
  • 方法解析
  • 介面方法解析

初始化

初始化是類載入過程的最後一個步驟,在之前的階段中,都是由 Java 虛擬機器佔主導作用,但是到了這一步,卻把主動權移交給應用程式。

對於初始化階段,《Java 虛擬機器規範》嚴格規定了只有下面這六種情況下才會觸發類的初始化。

  • 在遇到 new、getstatic、putstatic 或者 invokestatic 這四條位元組碼指令時,如果沒有進行過初始化,那麼首先觸發初始化。通過這四個位元組碼的名稱可以判斷,這四條位元組碼其實就兩個場景,呼叫 new 關鍵字的時候進行初始化、讀取或者設定一個靜態欄位的時候、呼叫靜態方法的時候。
  • 在初始化類的時候,如果父類還沒有初始化,那麼就需要先對父類進行初始化。
  • 在使用 java.lang.reflect 包的方法進行反射呼叫的時候。
  • 當虛擬機器啟動時,使用者需要指定執行主類的時候,說白了就是虛擬機器會先初始化 main 方法這個類。
  • 在使用 JDK 7 新加入的動態語言支援時,如果一個 jafva.lang.invoke.MethodHandle 例項最後的解析結果為 REF_getstatic、REF_putstatic、REF_invokeStatic、REF_newInvokeSpecial 四種型別的方法控制程式碼,並且這個方法控制程式碼對應的類沒有進行過初始化,需要先對其進行初始化。
  • 當一個介面中定義了 JDK 8 新加入的預設方法(被 default 關鍵字修飾的介面方法)時,如果有這個藉口的實現類發生了初始化,那該介面要在其之前被初始化。

其實上面只有前四個大家需要知道就好了,後面兩個比較冷門。

如果說要答類載入的話,其實聊到這裡已經可以了,但是為了完整性,我們索性把後面兩個過程也來聊一聊。

使用

這個階段沒什麼可說的,就是初始化之後的程式碼由 JVM 來動態呼叫執行。

解除安裝

當代表一個類的 Class 物件不再被引用,那麼 Class 物件的生命週期就結束了,對應的在方法區中的資料也會被解除安裝。

⚠️但是需要注意一點:JVM 自帶的類載入器裝載的類,是不會解除安裝的,由使用者自定義的類載入器載入的類是可以解除安裝的。

在 JVM 中,物件是如何建立的?

如果要回答物件是怎麼建立的,我們一般想到的回答是直接 new 出來就行了,這個回答不僅侷限於程式設計中,也融入在我們生活中的方方面面。

但是遇到面試的時候你只回答一個"new 出來就行了"顯然是不行的,因為面試更趨向於讓你解釋當程式執行到 new 這條指令時,它的背後發生了什麼。

所以你需要從 JVM 的角度來解釋這件事情。

當虛擬機器遇到一個 new 指令時(其實就是位元組碼),首先會去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用所代表的類是否已經被載入、解析和初始化。

因為此時很可能不知道具體的類是什麼,所以這裡使用的是符號引用。

如果發現這個類沒有經過上面類載入的過程,那麼就執行相應的類載入過程。

類檢查完成後,接下來虛擬機器將會為新生物件分配記憶體,物件所需的大小在類載入完成後便可確定(我會在下面的面試題中介紹)。

分配記憶體相當於是把一塊固定的記憶體塊從堆中劃分出來。劃分出來之後,虛擬機器會將分配到的記憶體空間都初始化為零值,如果使用了 TLAB(本地執行緒分配緩衝),這一項初始化工作可以提前在 TLAB 分配時進行。這一步操作保證了物件例項欄位在 Java 程式碼中可以不賦值就能直接使用。

接下來,Java 虛擬機器還會對物件進行必要的設定,比如確定物件是哪個類的例項、物件的 hashcode、物件的 gc 分代年齡資訊。這些資訊存放在物件的物件頭(Object Header)中。

如果上面的工作都做完後,從虛擬機器的角度來說,一個新的物件就建立完畢了;但是對於程式設計師來說,物件建立才剛剛開始,因為建構函式,即 Class 檔案中的 <init>() 方法還沒有執行,所有欄位都為預設的零值。new 指令之後才會執行 <init>() 方法,然後按照程式設計師的意願對物件進行初始化,這樣一個物件才可能被完整的構造出來。

記憶體分配方式有哪些呢?

在類載入完成後,虛擬機器需要為新生物件分配記憶體,為物件分配記憶體相當於是把一塊確定的區域從堆中劃分出來,這就涉及到一個問題,要劃分的堆區是否規整

假設 Java 堆中記憶體是規整的,所有使用過的記憶體放在一邊,未使用的記憶體放在一邊,中間放著一個指標,這個指標為分界指示器。那麼為新物件分配記憶體空間就相當於是把指標向空閒的空間挪動物件大小相等的距離,這種記憶體分配方式叫做指標碰撞(Bump The Pointer)

如果 Java 堆中的記憶體並不是規整的,已經被使用的記憶體和未被使用的記憶體相互交錯在一起,這種情況下就沒有辦法使用指標碰撞,這裡就要使用另外一種記錄記憶體使用的方式:空閒列表(Free List),空閒列表維護了一個列表,這個列表記錄了哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄。

所以,上述兩種分配方式選擇哪個,取決於 Java 堆是否規整來決定。在一些垃圾收集器的實現中,Serial、ParNew 等帶壓縮整理過程的收集器,使用的是指標碰撞;而使用 CMS 這種基於清除演算法的收集器時,使用的是空閒列表,具體的垃圾收集器我們後面會聊到。

請你說一下物件的記憶體佈局?

hotspot 虛擬機器中,物件在記憶體中的佈局分為三塊區域:

  • 物件頭(Header)
  • 例項資料(Instance Data)
  • 對齊填充(Padding)

這三塊區域的記憶體分佈如下圖所示

image-20210823223037637

我們來詳細介紹一下上面物件中的內容。

物件頭 Header

物件頭 Header 主要包含 MarkWord 和物件指標 Klass Pointer,如果是陣列的話,還要包含陣列的長度。

image-20210823223045677

在 32 位的虛擬機器中 MarkWord ,Klass Pointer 和陣列長度分別佔用 32 位,也就是 4 位元組。

如果是 64 位虛擬機器的話,MarkWord ,Klass Pointer 和陣列長度分別佔用 64 位,也就是 8 位元組。

在 32 位虛擬機器和 64 位虛擬機器的 Mark Word 所佔用的位元組大小不一樣,32 位虛擬機器的 Mark Word 和 Klass Pointer 分別佔用 32 bits 的位元組,而 64 位虛擬機器的 Mark Word 和 Klass Pointer 佔用了64 bits 的位元組,下面我們以 32 位虛擬機器為例,來看一下其 Mark Word 的位元組具體是如何分配的。

image-20210823223455786

用中文翻譯過來就是

image-20210823223519871

  • 無狀態也就是無鎖的時候,物件頭開闢 25 bit 的空間用來儲存物件的 hashcode ,4 bit 用於存放分代年齡,1 bit 用來存放是否偏向鎖的標識位,2 bit 用來存放鎖標識位為 01。
  • 偏向鎖 中劃分更細,還是開闢 25 bit 的空間,其中 23 bit 用來存放執行緒ID,2bit 用來存放 epoch,4bit 存放分代年齡,1 bit 存放是否偏向鎖標識, 0 表示無鎖,1 表示偏向鎖,鎖的標識位還是 01。
  • 輕量級鎖中直接開闢 30 bit 的空間存放指向棧中鎖記錄的指標,2bit 存放鎖的標誌位,其標誌位為 00。
  • 重量級鎖中和輕量級鎖一樣,30 bit 的空間用來存放指向重量級鎖的指標,2 bit 存放鎖的標識位,為 11
  • GC標記開闢 30 bit 的記憶體空間卻沒有佔用,2 bit 空間存放鎖標誌位為 11。

其中無鎖和偏向鎖的鎖標誌位都是 01,只是在前面的 1 bit 區分了這是無鎖狀態還是偏向鎖狀態。

關於為什麼這麼分配的記憶體,我們可以從 OpenJDK 中的markOop.hpp類中的列舉窺出端倪

image-20210823223531938

來解釋一下

  • age_bits 就是我們說的分代回收的標識,佔用4位元組
  • lock_bits 是鎖的標誌位,佔用2個位元組
  • biased_lock_bits 是是否偏向鎖的標識,佔用1個位元組。
  • max_hash_bits 是針對無鎖計算的 hashcode 佔用位元組數量,如果是 32 位虛擬機器,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虛擬機器,64 - 4 - 2 - 1 = 57 byte,但是會有 25 位元組未使用,所以 64 位的 hashcode 佔用 31 byte。
  • hash_bits 是針對 64 位虛擬機器來說,如果最大位元組數大於 31,則取 31,否則取真實的位元組數
  • cms_bits 我覺得應該是不是 64 位虛擬機器就佔用 0 byte,是 64 位就佔用 1byte
  • epoch_bits 就是 epoch 所佔用的位元組大小,2 位元組。

在上面的虛擬機器物件頭分配表中,我們可以看到有幾種鎖的狀態:無鎖(無狀態),偏向鎖,輕量級鎖,重量級鎖,其中輕量級鎖和偏向鎖是 JDK1.6 中對 synchronized 鎖進行優化後新增加的,其目的就是為了大大優化鎖的效能,所以在 JDK 1.6 中,使用 synchronized 的開銷也沒那麼大了。其實從鎖有無鎖定來講,還是隻有無鎖和重量級鎖,偏向鎖和輕量級鎖的出現就是增加了鎖的獲取效能而已,並沒有出現新的鎖。

所以我們的重點放在對 synchronized 重量級鎖的研究上,當 monitor 被某個執行緒持有後,它就會處於鎖定狀態。在 HotSpot 虛擬機器中,monitor 的底層程式碼是由 ObjectMonitor 實現的,其主要資料結構如下(位於 HotSpot 虛擬機器原始碼 ObjectMonitor.hpp 檔案,C++ 實現的)

image-20210823223547587

這段 C++ 中需要注意幾個屬性:_WaitSet 、 _EntryList 和 _Owner,每個等待獲取鎖的執行緒都會被封裝稱為 ObjectWaiter 物件。

image-20210823223558339

_Owner 是指向了 ObjectMonitor 物件的執行緒,而 _WaitSet 和 _EntryList 就是用來儲存每個執行緒的列表。

那麼這兩個列表有什麼區別呢?這個問題我和你聊一下鎖的獲取流程你就清楚了。

鎖的兩個列表

當多個執行緒同時訪問某段同步程式碼時,首先會進入 _EntryList 集合,當執行緒獲取到物件的 monitor 之後,就會進入 _Owner 區域,並把 ObjectMonitor 物件的 _Owner 指向為當前執行緒,並使 _count + 1,如果呼叫了釋放鎖(比如 wait)的操作,就會釋放當前持有的 monitor ,owner = null, _count - 1,同時這個執行緒會進入到 _WaitSet 列表中等待被喚醒。如果當前執行緒執行完畢後也會釋放 monitor 鎖,只不過此時不會進入 _WaitSet 列表了,而是直接復位 _count 的值。

image-20210823223605628

Klass Pointer 表示的是型別指標,也就是物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。

你可能不是很理解指標是個什麼概念,你可以簡單理解為指標就是指向某個資料的地址。

image-20210823223616085

例項資料 Instance Data

例項資料部分是物件真正儲存的有效資訊,也是程式碼中定義的各個欄位的位元組大小,比如一個 byte 佔 1 個位元組,一個 int 佔用 4 個位元組。

對齊 Padding

對齊不是必須存在的,它只起到了佔位符(%d, %c 等)的作用。這就是 JVM 的要求了,因為 HotSpot JVM 要求物件的起始地址必須是 8 位元組的整數倍,也就是說物件的位元組大小是 8 的整數倍,不夠的需要使用 Padding 補全。

物件訪問定位的方式有哪些?

我們建立一個物件的目的當然就是為了使用它,但是,一個物件被建立出來之後,在 JVM 中是如何訪問這個物件的呢?一般有兩種方式:通過控制程式碼訪問通過直接指標訪問

  • 如果使用控制程式碼訪問方式的話,Java 堆中可能會劃分出一塊記憶體作為控制程式碼池,引用(reference)中儲存的是物件的控制程式碼地址,而控制程式碼中包含了物件的例項資料與型別資料各自具體的地址資訊。如下圖所示。

    image-20210821225508905

  • 如果使用直接指標訪問的話,Java 堆中物件的記憶體佈局就會有所區別,棧區引用指示的是堆中的例項資料的地址,如果只是訪問物件本身的話,就不會多一次直接訪問的開銷,而物件型別資料的指標是存在於方法區中,如果定位的話,需要多一次直接定位開銷。如下圖所示

    image-20210821225705281

這兩種物件訪問方式各有各的優勢,使用控制程式碼最大的好處就是引用中儲存的是控制程式碼地址,物件移動時只需改變控制程式碼的地址就可以,而無需改變物件本身。

使用直接指標來訪問速度更快,它節省了一次指標定位的時間開銷,由於物件訪問在 Java 中非常頻繁,因為這類的開銷也是值得優化的地方。

上面聊到了物件的兩種資料,一種是物件的例項資料,這沒什麼好說的,就是物件例項欄位的資料,一種是物件的型別資料,這個資料說的是物件的型別、父類、實現的介面和方法等。

如何判斷物件已經死亡?

我們大家知道,基本上所有的物件都在堆中分佈,當我們不再使用物件的時候,垃圾收集器會對無用物件進行回收♻️,那麼 JVM 是如何判斷哪些物件已經是"無用物件"的呢?

這裡有兩種判斷方式,首先我們先來說第一種:引用計數法

引用計數法的判斷標準是這樣的:在物件中新增一個引用計數器,每當有一個地方引用它時,計數器的值就會加一;當引用失效時,計數器的值就會減一;只要任何時刻計數器為零的物件就是不會再被使用的物件。雖然這種判斷方式非常簡單粗暴,但是往往很有用,不過,在 Java 領域,主流的 Hotspot 虛擬機器實現並沒有採用這種方式,因為引用計數法不能解決物件之間的迴圈引用問題。

迴圈引用問題簡單來講就是兩個物件之間互相依賴著對方,除此之外,再無其他引用,這樣虛擬機器無法判斷引用是否為零從而進行垃圾回收操作。

還有一種判斷物件無用的方法就是可達性分析演算法

當前主流的 JVM 都採用了可達性分析演算法來進行判斷,這個演算法的基本思路就是通過一系列被稱為GC Roots的根物件作為起始節點集,從這些節點開始,根據引用關係向下搜尋,搜尋過程走過的路徑被稱為引用鏈(Reference Chain),如果某個物件到 GC Roots 之間沒有任何引用鏈相連線,或者說從 GC Roots 到這個物件不可達時,則證明此這個物件是無用物件,需要被垃圾回收。

這種引用方式如下

image-20210822230043691

如上圖所示,從列舉根節點 GC Roots 開始進行遍歷,object 1 、2、3、4 是存在引用關係的物件,而 object 5、6、7 之間雖然有關聯,但是它們到 GC Roots 之間是不可大的,所以被認為是可以回收的物件。

在 Java 技術體系中,可以作為 GC Roots 進行檢索的物件主要有

  • 在虛擬機器棧(棧幀中的本地變數表)中引用的物件。

  • 方法區中類靜態屬性引用的物件,比如 Java 類的引用型別靜態變數。

  • 方法區中常量引用的物件,比如字串常量池中的引用。

  • 在本地方法棧中 JNI 引用的物件。

  • JVM 內部的引用,比如基本資料型別對應的 Class 物件,一些異常物件比如 NullPointerException、OutOfMemoryError 等,還有系統類載入器。

  • 所有被 synchronized 持有的物件。

  • 還有一些 JVM 內部的比如 JMXBean、JVMTI 中註冊的回撥,原生程式碼快取等。

  • 根據使用者所選的垃圾收集器以及當前回收的記憶體區域的不同,還可能會有一些物件臨時加入,共同構成 GC Roots 集合。

雖然我們上面提到了兩種判斷物件回收的方法,但無論是引用計數法還是判斷 GC Roots 都離不開引用這一層關係。

這裡涉及到到強引用、軟引用、弱引用、虛引用的引用關係,你可以閱讀作者的這一篇文章

小心點,別被當成垃圾回收了。

如何判斷一個不再使用的類?

判斷一個型別屬於"不再使用的類"需要滿足下面這三個條件

  • 這個類所有的例項已經被回收,也就是 Java 堆中不存在該類及其任何這個類字累的例項
  • 載入這個類的類載入器已經被回收,但是類載入器一般很難會被回收,除非這個類載入器是為了這個目的設計的,比如 OSGI、JSP 的重載入等,否則通常很難達成。
  • 這個類對應的 Class 物件沒有任何地方被引用,無法在任何時刻通過反射訪問這個類的屬性和方法。

虛擬機器允許對滿足上面這三個條件的無用類進行回收操作。

JVM 分代收集理論有哪些?

一般商業的虛擬機器,大多數都遵循了分代收集的設計思想,分代收集理論主要有兩條假說。

第一個是強分代假說,強分代假說指的是 JVM 認為絕大多數物件的生存週期都是朝生夕滅的;

第二個是弱分代假說,弱分代假說指的是隻要熬過越多次垃圾收集過程的物件就越難以回收(看來物件也會長心眼)。

就是基於這兩個假說理論,JVM 將區劃分為不同的區域,再將需要回收的物件根據其熬過垃圾回收的次數分配到不同的區域中儲存。

JVM 根據這兩條分代收集理論,把堆區劃分為新生代(Young Generation)老年代(Old Generation)這兩個區域。在新生代中,每次垃圾收集時都發現有大批物件死去,剩下沒有死去的物件會直接晉升到老年代中。

上面這兩個假說沒有考慮物件的引用關係,而事實情況是,物件之間會存在引用關係,基於此又誕生了第三個假說,即跨代引用假說(Intergeneration Reference Hypothesis),跨代引用相比較同代引用來說僅佔少數。

正常來說存在相互引用的兩個物件應該是同生共死的,不過也會存在特例,如果一個新生代物件跨代引用了一個老年代的物件,那麼垃圾回收的時候就不會回收這個新生代物件,更不會回收老年代物件,然後這個新生代物件熬過一次垃圾回收進入到老年代中,這時候跨代引用才會消除。

根據跨代引用假說,我們不需要因為老年代中存在少量跨代引用就去直接掃描整個老年代,也不用在老年代中維護一個列表記錄有哪些跨代引用,實際上,可以直接在新生代中維護一個記憶集(Remembered Set),由這個記憶集把老年代劃分稱為若干小塊,標識出老年代的哪一塊會存在跨代引用。

記憶集的圖示如下

image-20210903223603191

從圖中我們可以看到,記憶集中的每個元素分別對應記憶體中的一塊連續區域是否有跨代引用物件,如果有,該區域會被標記為“髒的”(dirty),否則就是“乾淨的”(clean)。這樣在垃圾回收時,只需要掃描記憶集就可以簡單地確定跨代引用的位置,是個典型的空間換時間的思路。

聊一聊 JVM 中的垃圾回收演算法?

在聊具體的垃圾回收演算法之前,需要明確一點,哪些物件需要被垃圾收集器進行回收?也就是說需要先判斷哪些物件是"垃圾"?

判斷的標準我在上面如何判斷物件已經死亡的問題中描述了,有兩種方式,一種是引用計數法,這種判斷標準就是給物件新增一個引用計數器,引用這個物件會使計數器的值 + 1,引用失效後,計數器的值就會 -1。但是這種技術無法解決物件之間的迴圈引用問題。

還有一種方式是 GC Roots,GC Roots 這種方式是以 Root 根節點為核心,逐步向下搜尋每個物件的引用,搜尋走過的路徑被稱為引用鏈,如果搜尋過後這個物件不存在引用鏈,那麼這個物件就是無用物件,可以被回收。GC Roots 可以解決迴圈引用問題,所以一般 JVM 都採用的是這種方式。

解決迴圈引用程式碼描述:

public class test{
    public static void main(String[]args){
        A a = new A();
        B b = new B();
        a=null;
        b=null;
    }
}
class A {
 
    public B b;
}
class B {
    public A a;
}

基於 GC Roots 的這種思想,發展出了很多垃圾回收演算法,下面我們就來聊一聊這些演算法。

標記-清除演算法

標記-清除(Mark-Sweep)這個演算法可以說是最早最基礎的演算法了,標記-清除顧名思義分為兩個階段,即標記和清除階段:首先標記處所有需要回收的物件,在標記完成後,統一回收掉所有被標記的物件。當然也可以標記存活的物件,回收未被標記的物件。這個標記的過程就是垃圾判定的過程。

後續大部分垃圾回收演算法都是基於標記-演算法思想衍生的,只不過後續的演算法彌補了標記-清除演算法的缺點,那麼它由什麼缺點呢?主要有兩個

  • 執行效率不穩定,因為假如說堆中存在大量無用物件,而且大部分需要回收的情況下,這時必須進行大量的標記和清除,導致標記和清除這兩個過程的執行效率隨物件的數量增長而降低。
  • 記憶體碎片化,標記-清除演算法會在堆區產生大量不連續的記憶體碎片。碎片太多會導致在分配大物件時沒有足夠的空間,不得不進行一次垃圾回收操作。

標記演算法的示意圖如下

image-20210904182457721

標記-複製演算法

由於標記-清除演算法極易產生記憶體碎片,研究人員提出了標記-複製演算法,標記-複製演算法也可以簡稱為複製演算法,複製演算法是一種半區複製,它會將記憶體大小劃分為相等的兩塊,每次只使用其中的一塊,用完一塊再用另外一塊,然後再把用過的一塊進行清除。雖然解決了部分記憶體碎片的問題,但是複製演算法也帶來了新的問題,即複製開銷,不過這種開銷是可以降低的,如果記憶體中大多數物件是無用物件,那麼就可以把少數的存活物件進行復制,再回收無用的物件。

不過複製演算法的缺陷也是顯而易見的,那就是記憶體空間縮小為原來的一半,空間浪費太明顯。標記-複製演算法示意圖如下

image-20210904182444311

現在 Java 虛擬機器大多數都是用了這種演算法來回收新生代,因為經過研究表明,新生代物件由 98% 都熬不過第一輪收集,因此不需要按照 1 : 1 的比例來劃分新生代的記憶體空間。

基於此,研究人員提出了一種 Appel 式回收,Appel 式回收的具體做法是把新生代分為一塊較大的 Eden 空間和兩塊 Survivor 空間,每次分配記憶體都只使用 Eden 和其中的一塊 Survivor 空間,發生垃圾收集時,將 Eden 和 Survivor 中仍然存活的物件一次性複製到另外一塊 Survivor 空間上,然後直接清理掉 Eden 和已使用過的 Survivor 空間。

在主流的 HotSpot 虛擬機器中,預設的 Eden 和 Survivor 大小比例是 8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的 90%,只有一個 Survivor 空間,所以會浪費掉 10% 的空間。這個 8:1 只是一個理論值,也就是說,不能保證每次都有不超過 10% 的物件存活,所以,當進行垃圾回收後如果 Survivor 容納不了可存活的物件後,就需要其他記憶體空間來進行幫助,這種方式就叫做記憶體擔保(Handle Promotion) ,通常情況下,作為擔保的是老年代。

標記-整理演算法

標記-複製演算法雖然解決了記憶體碎片問題,但是沒有解決複製物件存在大量開銷的問題。為了解決複製演算法的缺陷,充分利用記憶體空間,提出了標記-整理演算法。該演算法標記階段和標記-清除一樣,但是在完成標記之後,它不是直接清理可回收物件,而是將存活物件都向一端移動,然後清理掉端邊界以外的記憶體。具體過程如下圖所示:

image-20210904232102284

什麼是記憶集,什麼是卡表?記憶集和卡表有什麼關係?

為了解決跨代引用問題,提出了記憶集這個概念,記憶集是一個在新生代中使用的資料結構,它相當於是記錄了一些指標的集合,指向了老年代中哪些物件存在跨代引用。

記憶集的實現有不同的粒度

  • 字長精度:每個記錄精確到一個字長,機器字長就是處理器的定址位數,比如常見的 32 位或者 64 位處理器,這個精度決定了機器訪問實體記憶體地址的指標長度,字中包含跨代指標。
  • 物件精度:每個記錄精確到一個物件,該物件裡含有跨代指標。
  • 卡精度:每個記錄精確到一塊記憶體區域,區域內含有跨代指標。

其中卡精度是使用了卡表作為記憶集的實現,關於記憶集和卡表的關係,大家可以想象成是 HashMap 和 Map 的關係。

什麼是卡頁?

卡表其實就是一個位元組陣列

CARD_TABLE[this address >> 9] = 0;

位元組陣列 CARD_TABLE 的每一個元素都對應著記憶體區域中一塊特定大小的記憶體塊,這個記憶體塊就是卡頁,一般來說,卡頁都是 2 的 N 次冪位元組數,通過上面的程式碼我們可以知道,卡頁一般是 2 的 9 次冪,這也是 HotSpot 中使用的卡頁,即 512 位元組。

一個卡頁的記憶體通常包含不止一個物件,只要卡頁中有一個物件的欄位存在跨代指標,那就將對應卡表的陣列元素的值設定為 1,稱之為這個元素變了,沒有標示則為 0 。在垃圾收集時,只要篩選出卡表中變髒的元素,就能輕易得出哪些卡頁記憶體塊中包含跨代指標,然後把他們加入 GC Roots 進行掃描。

所以,卡頁和卡表主要用來解決跨代引用問題的。

什麼是寫屏障?寫屏障帶來的問題?

如果有其他分代區域中物件引用了本區域的物件,那麼其對應的卡表元素就會變髒,這個引用說的就是物件賦值,也就是說卡表元素會變髒發生在物件賦值的時候,那麼如何在物件賦值的時候更新維護卡表呢?

在 HotSpot 虛擬機器中使用的是寫屏障(Write Barrier) 來維護卡表狀態的,這個寫屏障和我們記憶體屏障完全不同,希望讀者不要搞混了。

這個寫屏障其實就是一個 Aop 切面,在引用物件進行賦值時會產生一個環形通知(Around),環形通知就是切面前後分別產生一個通知,因為這個又是寫屏障,所以在賦值前的部分寫屏障叫做寫前屏障,在賦值後的則叫做寫後屏障。

寫屏障會帶來兩個問題

無條件寫屏障帶來的效能開銷

每次對引用的更新,無論是否更新了老年代對新生代物件的引用,都會進行一次寫屏障操作。顯然,這會增加一些額外的開銷。但是,掃描整個老年代相比較,這個開銷就低得多了。

不過,在高併發環境下,寫屏障又帶來了偽共享(false sharing)問題。

高併發下偽共享帶來的效能開銷

在高併發情況下,頻繁的寫屏障很容易發生偽共享(false sharing),從而帶來效能開銷。

假設 CPU 快取行大小為 64 位元組,由於一個卡表項佔 1 個位元組,這意味著,64 個卡表項將共享同一個快取行。

HotSpot 每個卡頁為 512 位元組,那麼一個快取行將對應 64 個卡頁一共 64*512 = 32K B。

如果不同執行緒對物件引用的更新操作,恰好位於同一個 32 KB 區域內,這將導致同時更新卡表的同一個快取行,從而造成快取行的寫回、無效化或者同步操作,間接影響程式效能。

一個簡單的解決方案,就是不採用無條件的寫屏障,而是先檢查卡表標記,只有當該卡表項未被標記過才將其標記為髒的。

這就是 JDK 7 中引入的解決方法,引入了一個新的 JVM 引數 -XX:+UseCondCardMark,在執行寫屏障之前,先簡單的做一下判斷。如果卡頁已被標識過,則不再進行標識。

簡單理解如下:

if (CARD_TABLE [this address >> 9] != 0)
  CARD_TABLE [this address >> 9] = 0;

與原來的實現相比,只是簡單的增加了一個判斷操作。

雖然開啟 -XX:+UseCondCardMark 之後多了一些判斷開銷,但是卻可以避免在高併發情況下可能發生的併發寫卡表問題。通過減少併發寫操作,進而避免出現偽共享問題(false sharing)。

什麼是三色標記法?三色標記法會造成哪些問題?

根據可達性演算法的分析可知,如果要找出存活物件,需要從 GC Roots 開始遍歷,然後搜尋每個物件是否可達,如果物件可達則為存活物件,在 GC Roots 的搜尋過程中,按照物件和其引用是否被訪問過這個條件會分成下面三種顏色:

  • 白色:白色表示 GC Roots 的遍歷過程中沒有被訪問過的物件,出現白色顯然在可達性分析剛剛開始的階段,這個時候所有物件都是白色的,如果在分析結束的階段,仍然是白色的物件,那麼代表不可達,可以進行回收。
  • 灰色:灰色表示物件已經被訪問過,但是這個物件的引用還沒有訪問完畢。
  • 黑色:黑色表示此物件已經被訪問過了,而且這個物件的引用也已經唄訪問了。

注:如果標記結束後物件仍為白色,意味著已經“找不到”該物件在哪了,不可能會再被重新引用。

現代的垃圾回收器幾乎都借鑑了三色標記的演算法思想,儘管實現的方式不盡相同:比如白色/黑色集合一般都不會出現(但是有其他體現顏色的地方)、灰色集合可以通過棧/佇列/快取日誌等方式進行實現、遍歷方式可以是廣度/深度遍歷等等。

三色標記法會造成兩種問題,這兩種問題所出現的環境都是由於使用者環境和收集器並行工作造成的 。當使用者執行緒正在修改引用關係,此時收集器在回收引用關係,此時就會造成把原本已經消亡的物件標記為存活,如果出現這種狀況的話,問題不大,下次再讓收集器重新收集一波就完了,但是還有一種情況是把存活的物件標記為死亡,這種狀況就會造成不可預知的後果。

針對上面這兩種物件消失問題,業界有兩種處理方式,一種是增量更新(Incremental Update) ,一種是原是快照(Snapshot At The Beginning, SATB)

請你介紹一波垃圾收集器

垃圾收集器是面試的常考,也是必考點,只要涉及到 JVM 的相關問題,都會圍繞著垃圾收集器來做一波展開,所以,有必要了解一下這些垃圾收集器。

垃圾收集器有很多,不同商家、不同版本的J VM 所提供的垃圾收集器可能會有很在差別,我們主要介紹 HotSpot 虛擬機器中的垃圾收集器。

垃圾收集器是垃圾回收演算法的具體實現,我們上面提到過,垃圾回收演算法有標記-清除演算法、標記-整理、標記-複製,所以對應的垃圾收集器也有不同的實現方式。

我們知道,HotSpot 虛擬機器中的垃圾收集都是分代回收的,所以根據不同的分代,可以把垃圾收集器分為

新生代收集器:Serial、ParNew、Parallel Scavenge;

老年代收集器:Serial Old、Parallel Old、CMS;

整堆收集器:G1;

Serial 收集器

Serial 收集器是一種新生代的垃圾收集器,它是一個單執行緒工作的收集器,使用複製演算法來進行回收,單執行緒工作不是說這個垃圾收集器只有一個,而是說這個收集器在工作時,必須暫停其他所有工作執行緒,這種暴力的暫停方式就是 Stop The World,Serial 就好像是寡頭壟斷一樣,只要它一發話,其他所有的小弟(執行緒)都得給它讓路。Serial 收集器的示意圖如下:

image-20210921224244386

SefePoint 全域性安全點:它就是程式碼中的一段特殊的位置,在所有使用者執行緒到達 SafePoint 之後,使用者執行緒掛起,GC 執行緒會進行清理工作。

雖然 Serial 有 STW 這種顯而易見的缺點,不過,從其他角度來看,Serial 還是很討喜的,它還有著優於其他收集器的地方,那就是簡單而高效,對於記憶體資源首先的環境,它是所有收集器中額外記憶體消耗最小的,對於單核處理器或者處理器核心較少的環境來說,Serial 收集器由於沒有執行緒互動開銷,所以 Serial 專心做垃圾回收效率比較高。

ParNew 收集器

ParNew 是 Serial 的多執行緒版本,除了同時使用多條執行緒外,其他引數和機制(STW、回收策略、物件分配規則)都和 Serial 完全一致,ParNew 收集器的示意圖如下:

image-20210921234313336

雖然 ParNew 使用了多條執行緒進行垃圾回收,但是在單執行緒環境下它絕對不會比 Serial 收集效率更高,因為多執行緒存線上程互動的開銷,但是隨著可用 CPU 核數的增加,ParNew 的處理效率會比 Serial 更高效。

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一款新生代收集器,它同樣是基於標記-複製演算法實現的,而且它也能夠並行收集,這麼看來,表面上 Parallel Scavenge 於 ParNew 非常相似,那麼它們之間有什麼區別呢?

Parallel Scavenge 的關注點主要在達到一個可控制的吞吐量上面。吞吐量就是處理器用於執行使用者程式碼的時間與處理器總消耗時間的比。也就是

image-20210922205128446

這裡給大家舉一個吞吐量的例子,如果執行使用者程式碼的時間 + 執行垃圾收集的時間總共耗費了 100 分鐘,其中垃圾收集耗費掉了 1 分鐘,那麼吞吐量就是 99%。停頓時間越短就越適合需要與使用者互動或需要保證服務響應質量,良好的響應速度可以提升使用者體驗,而高吞吐量可以最高效率利用處理器資源。

Serial Old 收集器

前面介紹了一下 Serial,我們知道它是一個新生代的垃圾收集,使用了標記-複製演算法。而這個 Serial Old 收集器卻是 Serial 的老年版本,它同樣也是一個單執行緒收集器,使用的是標記-整理演算法,Serial Old 收集器有兩種用途:一種是在 JDK 5 和之前的版本與 Parallel Scavenge 收集器搭配使用,另外一種用法就是作為 CMS 收集器的備選,CMS 垃圾收集器我們下面說,Serial Old 的收集流程如下

image-20210922212732454

Parallel Old 收集器

前面我們介紹了 Parallel Scavenge 收集器,現在來介紹一下 Parallel Old 收集器,它是 Parallel Scavenge 的老年版本,支援多執行緒併發收集,基於標記 - 整理演算法實現,JDK 6 之後出現,吞吐量優先可以考慮 Parallel Scavenge + Parallel Old 的搭配

image-20210922213221449

CMS 收集器

CMS收集器的主要目標是獲取最短的回收停頓時間,它的全稱是 Concurrent Mark Sweep,從這個名字就可以知道,這個收集器是基於標記 - 清除演算法實現的,而且支援併發收集,它的執行過程要比上面我們提到的收集器複雜一些,它的工作流程如下:

  • 初始標記(CMS initial mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS concurrent sweep)

對於上面這四個步驟,初始標記和併發標記都需要 Stop The World,初始標記只是標記一下和 GC Roots 直接關聯到的物件,速度較快;併發標記階段就是從 GC Roots 的直接關聯物件開始遍歷整個物件圖的過程。這個過程時間比較長但是不需要停頓使用者執行緒,也就是說與垃圾收集執行緒一起併發執行。併發標記的過程中,可能會有錯標或者漏標的情況,此時就需要在重新標記一下,最後是併發清除階段,清理掉標記階段中判斷已經死亡的物件。

CMS 的收集過程如下

image-20210922223723196

CMS 是一款非常優秀的垃圾收集器,但是沒有任何收集器能夠做到完美的程度,CMS 也是一樣,CMS 至少有三個缺點:

  • CMS 對處理器資源非常敏感,在併發階段,雖然不會造成使用者執行緒停頓,但是卻會因為佔用一部分執行緒而導致應用程式變慢,降低總吞吐量。

  • CMS 無法處理浮動垃圾,有可能出現Concurrent Mode Failure失敗進而導致另一次完全 Stop The WorldFull GC 產生。

    什麼是浮動垃圾呢?由於併發標記和併發清理階段,使用者執行緒仍在繼續執行,所以程式自然而然就會伴隨著新的垃圾不斷出現,而且這一部分垃圾出現在標記結束之後,CMS 無法處理這些垃圾,所以只能等到下一次垃圾回收時在進行清理。這一部分垃圾就被稱為浮動垃圾。

  • CMS 最後一個缺點是併發-清除的通病,也就是會有大量的空間碎片出現,這將會給分配大物件帶來困難。

Garbage First 收集器

Garbage First 又被稱為 G1 收集器,它的出現意味著垃圾收集器走過了一個里程碑,為什麼說它是里程碑呢?因為 G1 這個收集器是一種面向區域性的垃圾收集器,HotSpot 團隊開發這個垃圾收集器為了讓它替換掉 CMS 收集器,所以到後來,JDK 9 釋出後,G1 取代了 Parallel Scavenge + Parallel Old 組合,成為服務端預設的垃圾收集器,而 CMS 則不再推薦使用。

之前的垃圾收集器存在回收區域的侷限性,因為之前這些垃圾收集器的目標範圍要麼是整個新生代、要麼是整個老年代,要麼是整個 Java 堆(Full GC),而 G1 跳出了這個框架,它可以面向堆記憶體的任何部分來組成回收集(Collection Set,CSet),衡量垃圾收集的不再是哪個分代,這就是 G1 的 Mixed GC 模式。

G1 是基於 Region 來進行回收的,Region 就是堆記憶體中任意的佈局,每一塊 Region 都可以根據需要扮演 Eden 空間、Survivor 空間或者老年代空間,收集器能夠對不同的 Region 角色採用不同的策略來進行處理。Region 中還有一塊特殊的區域,這塊區域就是 Humongous 區域,它是專門用來儲存大物件的,G1 認為只要大小超過了 Region 容量一半的物件即可判定為大物件。如果超過了 Region 容量的大物件,將會儲存在連續的 Humongous Region 中,G1 大多數行為都會吧 Humongous Region 作為老年代來看待。

G1 保留了新生代(Eden Suvivor)和老年代的概念,但是新生代和老年代不再是固定的了。它們都是一系列區域的動態集合。

G1 收集器的運作過程可以分為以下四步:

  • 初始標記:這個步驟也僅僅是標記一下 GC Roots 能夠直接關聯到的物件;並修改 TAMS 指標的值(每一個 Region 都有兩個 RAMS 指標),似的下一階段使用者併發執行時,能夠在可用的 Region 中分配物件,這個階段需要暫停使用者執行緒,但是時間很短。這個停頓是借用 Minor GC 的時候完成的,所以可以忽略不計。
  • 併發標記:從 GC Root 開始對堆中物件進行可達性分析,遞迴掃描整個堆中的物件圖,找出要回收的物件。當物件圖掃描完成後,重新處理 SATB 記錄下的在併發時有引用的物件;
  • 最終標記:對使用者執行緒做一個短暫的暫停,用於處理併發階段結束後遺留下來的少量 SATB 記錄(一種原始快照,用來記錄併發標記中某些物件)
  • 篩選回收:負責更新 Region 的統計資料,對各個 Region 的回收價值和成本進行排序,根據使用者所期望的停頓時間來制定回收計劃,可以自由選擇多個 Region 構成回收集,然後把決定要回收的那一部分 Region 存活物件複製到空的 Region 中,再清理掉整個舊 Region 的全部空間。這裡的操作設計物件的移動,所以必須要暫停使用者執行緒,由多條收集器執行緒並行收集

從上面這幾個步驟可以看出,除了併發標記外,其餘三個階段都需要暫停使用者執行緒,所以,這個 G1 收集器並非追求低延遲,官方給出的設計目標是在延遲可控的情況下儘可能的提高吞吐量,擔任全功能收集器的重任。

下面是 G1 回收的示意圖

image-20210923221512041

G1 收集器同樣也有缺點和問題:

  • 第一個問題就是 Region 中存在跨代引用的問題,我們之前知道可以用記憶集來解決跨代引用問題,不過 Region 中的跨代引用要複雜很多;
  • 第二個問題就是如何保證收集執行緒與使用者執行緒互不干擾的執行?CMS 使用的是增量更新演算法,G1 使用的是原始快照(SATB),G1 為 Region 分配了兩塊 TAMS 指標,把 Region 中的一部分空間劃分出來用於併發回收過程中的新物件分配,併發回收時心分配的物件地址都必須在這兩個指標位置以上。如果記憶體回收速度趕不上記憶體分配速度,G1 收集器也要凍結使用者執行緒執行,導致 Full GC 而產生長時間的 STW。
  • 第三個問題是無法建立可預測的停頓模型。

JVM 常用命令介紹

下面介紹一下 JVM 中常用的調優、故障處理等工具。

  1. jps :虛擬機器程式工具,全稱是 JVM Process Status Tool,它的功能和 Linux 中的 ps 類似,可以列出正在執行的虛擬機器程式,並顯示虛擬機器執行主類 Main Class 所在的本地虛擬機器唯一 ID,雖然功能比較單一,但是這個命令絕對是使用最高頻的一個命令。
  2. jstat:虛擬機器統計資訊工具,用於監視虛擬機器各種執行狀態的資訊的命令列工具,它可以顯示本地或者遠端虛擬機器程式中的類載入、記憶體、垃圾收集、即時編譯等執行時資料。
  3. jinfo:Java 配置資訊工具,全稱是 Configuration Info for Java,它的作用是可以事實調整虛擬機器各項引數。
  4. jmap:Java 記憶體映像工具,全稱是 Memory Map For Java,它用於生成轉儲快照,用來排查記憶體佔用情況
  5. jhat:虛擬機器堆轉儲快照分析工具,全稱是 JVM Heap Analysis Tool,這個指令通常和 jmap 一起搭配使用,jhat 內建了一個 HTTP/Web 伺服器,生成轉儲快照後可以在瀏覽器中檢視。不過,一般還是 jmap 命令使用的頻率比較高。
  6. jstack:Java 堆疊跟蹤工具,全稱是 Stack Trace for Java ,顧名思義,這個命令用來追蹤堆疊的使用情況,用於虛擬機器當前時刻的執行緒快照,執行緒快照就是當前虛擬機器內每一條正在執行的方法堆疊的集合。

什麼是雙親委派模型?

JVM 類載入預設使用的是雙親委派模型,那麼什麼是雙親委派模型呢?

這裡我們需要先介紹一下三種類載入器:

  • 啟動類載入器,Bootstrap Class Loader,這個類載入器是 C++ 實現的,它是 JVM 的一部分,這個類載入器負責載入存放在 <JAVA_HOME>\lib 目錄,啟動類載入器無法被 Java 程式直接引用。這也就是說,JDK 中的常用類的載入都是由啟動類載入器來完成的。
  • 擴充套件類載入器,Extension Class Loader,這個類載入器是 Java 實現的,它負責載入 <JAVA_HOME>\lib\ext 目錄。
  • 應用程式類載入器,Application Class Loader,這個類載入器是由 sum.misc.Launcher$AppClassLoader 來實現,它負責載入 ClassPath 上所有的類庫,如果應用程式中沒有定義自己的類載入器,預設使用就是這個類載入器。

所以,我們的 Java 應用程式都是由這三種類載入器來相互配合完成的,當然,使用者也可以自己定義類載入器,即 User Class Loader,這幾個類載入器的模型如下

image-20210924231418026

上面這幾類類載入器構成了不同的層次結構,當我們需要載入一個類時,子類載入器並不會馬上去載入,而是依次去請求父類載入器載入,一直往上請求到最高類載入器:啟動類載入器。當啟動類載入器載入不了的時候,依次往下讓子類載入器進行載入。這就是雙親委派模型。

雙親委派模型的缺陷?

在雙親委派模型中,子類載入器可以使用父類載入器已經載入的類,而父類載入器無法使用子類載入器已經載入的。這就導致了雙親委派模型並不能解決所有的類載入器問題。

Java 提供了很多外部介面,這些介面統稱為 Service Provider Interface, SPI,允許第三方實現這些介面,而這些介面卻是 Java 核心類提供的,由 Bootstrap Class Loader 載入,而一般的擴充套件介面是由 Application Class Loader 載入的,Bootstrap Class Loader 是無法找到 SPI 的實現類的,因為它只載入 Java 的核心庫。它也不能代理給 Application Class Loader,因為它是最頂層的類載入器。

雙親委派機制的三次破壞

雖然雙親委派機制是 Java 強烈推薦給開發者們的類載入器的實現方式,但是並沒有強制規定你必須就要這麼實現,所以,它一樣也存在被破壞的情況,實際上,歷史上一共出現三次雙親委派機制被破壞的情況:

  • 雙親委派機制第一次被破壞發生在雙親委派機制出現之前,由於雙親委派機制 JDK 1.2 之後才引用的,但類載入的概念在 Java 剛出現的時候就有了,所以引用雙親委派機制之前,設計者們必須兼顧開發者們自定義的一些類載入器的程式碼,所以在 JDK 1.2 之後的 java.lang.ClassLoader 中新增了一個新的 findClass 方法,引導使用者編寫類載入器邏輯的時候重寫這個 findClass 方法,而不是基於 loadClass編寫。
  • 雙親委派機制第二次被破壞是由於它自己模型導致的,由於它只能向上(基礎)載入,越基礎的類越由上層載入器載入,所以如果基礎型別又想要呼叫使用者的程式碼,該怎麼辦?這也就是我們上面那個問題所說的 SPI 機制。那麼 JDK 團隊是如何做的呢?它們引用了一個 執行緒上下文類載入器(Thread Context ClassLoader),這個類載入器可以通過 java.lang.Thread 類的 setContextClassLoader 進行設定,如果建立時執行緒還未設定,它將會從父執行緒中繼承,如果全域性沒有設定類載入器的話,這個 ClassLoader 就是預設的類載入器。這種行為雖然是一種犯規行為,但是 Java 程式碼中的 JNDI、JDBC 等都是使用這種方式來完成的。直到 JDK 6 ,引用了 java.util.ServiceLoader,使用 META-INF/services + 責任鏈的設計模式,才解決了 SPI 的這種載入機制。
  • 雙親委派機制第三次被破壞是由於使用者對程式的動態需求使熱載入、熱部署的引入所致。由於時代的變化,我們希望 Java 能像滑鼠鍵盤一樣實現熱部署,即時載入(load class),引入了 OSGI,OSGI 實現熱部署的關鍵在於它自定義類載入器機制的實現,OSGI 中的每一個 Bundle 也就是模組都有一個自己的類載入器。當需要更換 Bundle 時,就直接把 Bundle 連同類載入器一起替換掉就能夠實現熱載入。在 OSGI 環境下,類載入器不再遵從雙親委派機制,而是使用了一種更復雜的載入機制。

常見的 JVM 調優引數有哪些?

  • -Xms256m:初始化堆大小為 256m;
  • -Xmx2g:堆最大記憶體為 2g;
  • -Xmn50m:新生代的大小50m;
  • -XX:+PrintGCDetails 列印 gc 詳細資訊;
  • -XX:+HeapDumpOnOutOfMemoryError 在發生OutOfMemoryError錯誤時,來 dump 出堆快照;
  • -XX:NewRatio=4 設定年輕的和老年代的記憶體比例為 1:4;
  • -XX:SurvivorRatio=8 設定新生代 Eden 和 Survivor 比例為 8:2;
  • -XX:+UseSerialGC 新生代和老年代都用序列收集器 Serial + Serial Old
  • -XX:+UseParNewGC 指定使用 ParNew + Serial Old 垃圾回收器組合;
  • -XX:+UseParallelGC 新生代使用 Parallel Scavenge,老年代使用 Serial Old
  • -XX:+UseParallelOldGC:新生代 ParallelScavenge + 老年代 ParallelOld 組合;
  • -XX:+UseConcMarkSweepGC:新生代使用 ParNew,老年代的用 CMS;
  • -XX:NewSize:新生代最小值;
  • -XX:MaxNewSize:新生代最大值
  • -XX:MetaspaceSize 元空間初始化大小
  • -XX:MaxMetaspaceSize 元空間最大值

如果對你有幫助,可以關注一下 公眾號:程式設計師cxuan, 有更多的硬核文章等著你。

相關文章