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


肝了一篇非常硬核的 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, 有更多的硬核文章等着你。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM