JVM 基礎面試題總結

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

hey guys, 各位小夥伴們大家好,這裡是程式設計師cxuan,歡迎你收看我新一期的文章,這篇文章我花了幾天時間給你彙總了一波 JVM 的基礎知識和麵試題,內容還不是很全,我還在連載中,這篇文章相當於是第一彈,廢話不多說,走起!

JVM 的主要作用是什麼?

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

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

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

  • 虛擬機器棧 : 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 物件沒有任何地方被引用,無法在任何時刻通過反射訪問這個類的屬性和方法。

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

相關文章