JVM虛擬機器知識問答總結(簡單複習,快速回憶!)

BWH_Steven發表於2021-03-26

寫在最前面

這個專案是從20年末就立好的 flag,經過幾年的學習,回過頭再去看很多知識點又有新的理解。所以趁著找實習的準備,結合以前的學習儲備,建立一個主要針對應屆生和初學者的 Java 開源知識專案,專注 Java 後端面試題 + 解析 + 重點知識詳解 + 精選文章的開源專案,希望它能伴隨你我一直進步!

說明:此專案內容參考了諸多博主(已註明出處),資料,N本書籍,以及結合自己理解,重新繪圖,重新組織語言等等所制。個人之力綿薄,或有不足之處,在所難免,但更新/完善會一直進行。大家的每一個 Star 都是對我的鼓勵 !希望大家能喜歡。

注:所有涉及圖片未使用網路圖床,文章等均開源提供給大家。

專案名: Java-Ideal-Interview

Github 地址: Java-Ideal-Interview - Github

Gitee 地址:Java-Ideal-Interview - Gitee(碼雲)

持續更新中,線上閱讀將會在後期提供,若認為 Gitee 或 Github 閱讀不便,可克隆到本地配合 Typora 等編輯器舒適閱讀

若 Github 克隆速度過慢,可選擇使用國內 Gitee 倉庫

一 JVM 知識問答總結

1. JVM 基礎

1.1 請你談談你對 JVM 的認識和理解

注:此部分在 /docs/java/javase-basis/001-Java基礎知識.md 已經提到過。

JVM 又被稱作 Java 虛擬機器,用來執行 Java 位元組碼檔案(.class),因為 JVM 對於特定系統(Windows,Linux,macOS)有不同的具體實現,即它遮蔽了具體的作業系統和平臺等資訊,因此同一位元組碼檔案可以在各種平臺中任意執行,且得到同樣的結果。

1.1.1 什麼是位元組碼?

副檔名為 .class 的檔案叫做位元組碼,是程式的一種低階表示,它不面向任何特定的處理器,只面向虛擬機器(JVM),在經過虛擬機器的處理後,可以使得程式能在多個平臺上執行。

1.1.2 採用位元組碼的好處是什麼?

Java 語言通過位元組碼的方式,在一定程度上解決了傳統解釋型語言執行效率低的問題,同時又保留了解釋型語言可移植的特點。所以 Java 程式執行時比較高效,而且,由於位元組碼並不專對一種特定的機器,因此,Java程式無須重新編譯便可在多種不同的計算機上執行。

為什麼一定程度上解決了傳統解釋型語言執行效率低的問題(參考自思否-scherman ,僅供參考)

首先知道兩點,① 因為 Java 位元組碼是偽機器碼,所以會比解析型語言效率高 ② JVM不是解析型語言,是半編譯半解析型語言

解析型語言沒有編譯過程,是直接解析原始碼文字的,相當於在執行時進行了一次編譯,而 Java 的位元組碼雖然無法和本地機器碼完全一一對應,但可以簡單對映到本地機器碼,不需要做複雜的語法分析之類的編譯處理,當然比純解析語言快。

1.1.3 你能談一談 Java 程式從程式碼到執行的一個過程嗎?

過程:編寫 -> 編譯 -> 解釋(這也是 Java編譯與解釋共存的原因)

首先通過IDE/編輯器編寫原始碼然後經過 JDK 中的編譯器(javac)編譯成 Java 位元組碼檔案(.class檔案),位元組碼通過虛擬機器執行,虛擬機器將每一條要執行的位元組碼送給直譯器,直譯器會將其翻譯成特定機器上的機器碼(及其可執行的二進位制機器碼)。

1.2 你對類載入器有了解嗎?

定義:類載入器會根據指定class檔案的全限定名稱,將其載入到JVM記憶體,轉為Class物件。

1.2.1 類載入器的執行流程

1.2.1.1 載入

  1. 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
  2. 將這個二進位制位元組流所代表的靜態儲存結構匯入為方法區的執行時資料結構。
  3. 在java堆中生成一個java.lang.Class物件,來代表的這個類,作為方法區這些資料的入口。

1.2.1.2 連結

  1. 驗證:保證二進位制的位元組流所包含的資訊符號虛擬機器的要求,並且不會危害到虛擬機器自身的安全。
  2. 準備:為 static 靜態變數(類變數)分配記憶體,併為其設定初始值。
    • 注:這些記憶體都將在方法區內分配記憶體,例項變數在堆記憶體中,而且例項變數是在物件初始化時才賦值
  3. 解析:解析階段就是虛擬機器將常量池中的符號引用轉化為直接引用的過程。
    • 例如 import xxx.xxx.xxx 屬於符號引用,而通過指標或者物件地址引用就是直接引用

1.2.1.3 初始化

  1. 初始化會對變數進行賦值,即對最初的零值,進行顯式初始化,例如 static int num = 0 變成了 static int num = 3 ,這些工作都會在類構造器 <clinit>() 方法中執行。而且虛擬機器保證了會先去執行父類 <clinit>() 方法 。
    • 如果在靜態程式碼塊中修改了靜態變數的值,會對前面的顯示初始化的值進行覆蓋

1.2.1.4 解除安裝

GC 垃圾回收記憶體中的無用物件

1.2.2 類載入器有哪幾種,載入順序是什麼樣的?

JVM 中本身提供的類載入器(ClassLoader)主要有三種 ,除了 BootstrapClassLoader 是 C++ 實現以外,其他的類載入器均為 Java實現,而且都繼承了 java.lang.ClassLoader

  1. BootStrapClassLoader(啟動類載入器):C++ 實現,JDK目錄/lib 下面的 jar 和類,以及被 -Xbootclasspath 引數指定的路徑中的所有類,都歸其負責載入。
  2. ExtensionClassLoader: 載入擴充套件的jar包:負責載入 JRE目錄/lib 下面的 jar 和類,以及被 java.ext.dirs 系統變數所指定的路徑下的 jar 包。
  3. AppClassLoader:負責載入使用者當前應用下 classpath 下面的 jar 包和類

注:順序為最底層向上

1.2.3 雙親委派機制有了解嗎?

1.2.3.1 概念

雙親委派模型會要求除了頂層的啟動類載入器外,其餘的類載入器都應有自己的父類載入器,不過這裡的父子關係一般不是通過繼承來實現的,通常是使用組合關係來複用父載入器的程式碼

雙親委派模型的工作過程是:如果一個類載入器收到了類載入的請求,他首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入都是如此,因此所有的載入請求都最終應該傳送到最頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求(也就是它的範圍搜尋中,也沒有找到所需要的類),子載入器才會嘗試自己去完成載入。

1.2.3.2 優點

  • 載入位於rt.jar包中的類(例如 java.lang.Object)時不管是哪個載入器載入,最終都會委託最頂端的啟動類載入器 BootStrapClassLoader 進行載入,這樣保證它在各個類載入器環境下都是同一個結果。

  • 避免了自定義程式碼影響 JDK 的程式碼,如果我們自己也建立了一個 java.lang.Object 然後放在程式的 classpath 中,就會導致系統中出現不同的 Object 類,Java 型別體系中最基礎的行為也就無法保證。

public class Object(){
    public static void main(){
    	......
    }
}

1.2.3.3 如果不想使用雙親委派模型怎麼辦

自定義類載入器,然後重寫 loadClass() 方法

1.3 講一講 Java 記憶體區域(執行時資料區)

1.3.1 總體概述

Java 程式在被虛擬機器執行的時候,記憶體區域被劃分為多個區域,而且尤其在 JDK 1.6 和 JDK 1.8 的版本下,有一些明顯的變化,不過主題結構還是差不多的。

整體主要分為兩個部分:

  • 執行緒共享部分:
    • 程式計數器
    • 虛擬機器棧
    • 本地方法棧
  • 執行緒私有部分
    • 方法區(JDK 1.8 變為了元空間,元空間是位於直接記憶體中的)

注:我們配圖以 JDK 1.6 為例,至於發生的變化我們在下面有說明

1.3.2 程式計數器

概念:程式計數器是一塊較小的記憶體空間,可以看作是當前執行緒所執行的位元組碼的行號指示器。

  • 作用 1 (流程控制):位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等功能都需要依賴這個計數器來完成。

  • 作用 2(執行緒恢復):為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。

  • 執行緒切換的原因是:Java 虛擬機器的多執行緒是通過執行緒輪流切換,分配處理器時間片實現的,所以在任意時刻,一個處理器都(多核處理器來說是一個核心)只能執行一條指令。

1.3.2.1 為什麼程式計數器是執行緒私有的?

答:主要為了執行緒切換恢復後,能回到自己原先的位置。

1.3.3 Java 虛擬機器棧

Java 虛擬機器棧描述的是 Java 方法執行的記憶體模型,每次方法呼叫時,都會建立一個棧幀,每個棧幀中都擁有:區域性變數表、運算元棧、動態連結、方法出口資訊。

大部分情況下,很多人會將 Java 記憶體籠統的劃分為堆和棧(雖然這樣劃分有些粗糙,但是這也能說明這兩者是程式設計師們最關注的位置),這個棧,其實就是 Java 虛擬機器棧,或者說是其中的區域性變數表部分。

  • 區域性變數表主要存放了編譯期可知的各種資料型別(boolean、byte、char、short、int、float、long、double)、物件引用(reference 型別,它不同於物件本身,可能是一個指向物件起始地址的引用指標,也可能是指向一個代表物件的控制程式碼或其他與此物件相關的位置)和 returnAddress 型別(指向一條位元組碼指令的地址)

1.3.3.1 Java 虛擬機器棧會出現哪兩種錯誤?

  • StackOverFlowError: 如果 Java 虛擬機器棧容量不能動態擴充套件,而此時執行緒請求棧的深度超過當前 Java 虛擬機器棧的最大深度的時候,就丟擲 StackOverFlowError 錯誤。

  • OutOfMemoryError: 如果 Java 虛擬機器棧容量可以動態擴充套件,當棧擴充套件的時候,無法申請到足夠的記憶體(Java 虛擬機器堆中沒有空閒記憶體,垃圾回收器也沒辦法提供更多記憶體)

1.3.4 本地方法棧

和虛擬機器棧所發揮的作用非常相似,其區別是: 虛擬機器棧為虛擬機器執行 Java 方法 (也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的 Native 方法服務。

  • 因為本地方法棧中的方法,使用方式,資料結構,Java虛擬機器規範,未做強制要求,具體的虛擬機器可以自由的自己實現,例如:HotSpot 虛擬機器中和 Java 虛擬機器棧合二為一。

與虛擬機器棧相同,在棧深度溢位,以及棧擴充套件失敗的時候,也會出現 StackOverFlowErrorOutOfMemoryError 兩種錯誤。

1.3.4.1 虛擬機器棧和本地方法棧為什麼是私有的?

答:主要為了保證執行緒中的區域性變數不被別的執行緒訪問到

1.3.5 堆

Java 虛擬機器所管理的記憶體中最大的一塊,Java 堆是所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項以及陣列都在這裡分配記憶體。

  • 但是,隨著即時編譯技術的進步,尤其是逃逸分析技術日漸強大,棧上分配、標量替換優化技術將導致了一些微妙的變化,所以,所有的物件都在堆上分配也漸漸變得不那麼“絕對”了。
    • JDK 1.7 已經預設開啟逃逸分析,如果某些方法中的物件引用沒有被返回或者未被外面使用(即未逃逸出去),那麼物件可以直接在棧上分配記憶體。

補充:Java 堆是垃圾收集器管理的主要區域,因此也被稱作 GC 堆(Garbage Collected Heap)

1.3.6 方法區

方法區與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然 Java 虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

注意:JDK1.7 開始,到 JDK 8 版本之後方法區(HotSpot 的永久代)被徹底移除了,變成了元空間,元空間使用的是直接記憶體。

1.3.6.1 永久代是什麼

在JD K1.8之前,許多Java程式設計師都習慣在 hotspot 虛擬機器上開發,部署程式,很多人更願意把方法去稱呼為永久代,或者將兩者混為一談,本質上這兩者不是等價的,因為僅僅是當時 hotspot 虛擬機器設計團隊選擇把收集器的分代設計擴充套件至方法區,或者說使用永久代來實現方法區而已,這樣使得 hotspot 的垃圾收集器能夠像管理 Java 堆一樣管理這部分記憶體,省去專門為方法去編寫記憶體管理程式碼的工作,但是對於其他虛擬機器實現是不存在永久代的概念的。

1.3.6.2 永久代為什麼被替換成了元空間?

  • 永久代大小上限為固定的,無法調整修改。而元空間使用直接記憶體,與本機的可用記憶體有關,大大減少了溢位的機率
  • JRockit 想要移植到 HotSpot 虛擬機器的時候,因為兩者對方法區的實現存在差異面臨很多困難,所以 JDK 1.6 的時候 HotSpot 開發團隊就有了放棄永久代,逐漸改變為本地記憶體的計劃,到 JDK 1.7已經把原本放在永久代的字串常量池,靜態變數等移出,而到了JDK 1.8 完全放棄了永久代。

1.3.7 執行時常量池

執行時常量池是方法區的一部分。Class 檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有常量池表(用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中)

1.3.7.1 方法區的執行時常量池在 JDK 1.6 到 JDK 1.8 的版本中有什麼變化?

  • 根據上面的 Java 記憶體區域圖可知,JDK 1.6 方法區(HotSpot 的永久代)中的執行時常量池中包括了字串常量池,
  • JDK 1.7 版本下,字串常量池從方法區中被移到了堆中(注:只有這一個移動了)
  • JDK 1.8 版本下,HotSpot 的永久代變為了元空間,字串常量池還在堆中,執行時常量也還在方法區中,只不過方法區變成了元空間

1.3.7 直接記憶體

直接記憶體並不是虛擬機器執行時資料區的一部分,也不是虛擬機器規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用。而且也可能導致 OutOfMemoryError 錯誤出現。

1.4 Java 物件建立訪問到死亡

1.4.1 Java 物件的建立(JVM方向)

1.4.1.1 類載入檢查

  • 概念:JVM(此處指 HotSpot)遇到 new 指令時,先檢查指令引數是否能在常量池中定位到一個類的符號引用。
    • A:如果能定位到,就檢查這個符號引用代表的類是否已被載入、解析和初始化過。
    • B:如果不能定位到,或沒有檢查到,就先執行相應的類載入過程。

1.4.1.2 為物件分配記憶體

概念:載入檢查和載入後,就是分配記憶體,物件所需記憶體的大小在類載入完成後便完全確定(物件的大小 JVM 可以通過Java物件的類後設資料獲取)為物件分配記憶體相當於把一塊確定大小的記憶體從Java堆裡劃分出來。

  • ① 分配方式
    • A: 指標碰撞:中間有一個區分邊界的指標,兩邊分別是用過的記憶體區域,和沒用過的記憶體區域,分配記憶體的時候,就向著空閒的那邊移動指標。
      • 適用於:Java 堆是規整的情況下。
      • 應用:Serial 收集器、ParNew 收集器
    • B: 空閒列表:維護一個列表,其中記錄哪些記憶體可用,分配時會找到一塊足夠大的記憶體來劃分給物件例項,然後更新列表。
      • 適用於:堆記憶體不是很規整的情況下。
      • 應用:CMS 收集器

注:Java 堆是否規整,取決於 GC 收集器的演算法是什麼,如 “標記-清除” 就是不規整的,“標記-整理(壓縮)” 、 “複製演算法” 是規整的。這幾種演算法我們後面都會分別講解。

  • ② 執行緒安全問題

    • 併發情況下,上述兩種分配方式都不是執行緒安全的,JVM 虛擬機器提供了兩種解決方案

    • A:同步處理:CAS + 失敗重試

      • CAS的全稱是 Compare-and-Swap,也就是比較並交換。它包含了三個引數:V:記憶體值 、A:當前值(舊值)、B:要修改成的新值

        CAS 在執行時,只有 V 和 A 的值相等的情況下,才會將 V 的值設定為 B,如果 V 和 A 不同,這說明可能其他執行緒已經做了更新操作,那麼當前執行緒值就什麼也不做,最後 CAS 返回的是 V 的值。

        在多執行緒的的情況下,多個執行緒使用 CAS 操作同一個變數的時候,只有一個會成功,其他失敗的執行緒,就會繼續重試。

        正是這種機制,使得 CAS 在沒有鎖的情況下,也能實現安全,同時這種機制在很多情況下,也會顯得比較高效。

    • B:本地執行緒分配緩衝區:TLAB

      • 為每一個執行緒在 Java 堆的 Eden 區分配一小塊記憶體,哪個執行緒需要分配記憶體,就從哪個執行緒的 TLAB 上分配 ,只有 TLAB 的記憶體不夠用,或者用完的情況下,再採用 CAS 機制

1.4.1.3 物件初始化零值

記憶體分配結束後,執行初始化零值操作,即保證物件不顯式初始化零值的情況下,程式也能訪問到零值

1.4.1.4 設定物件頭

初始化零值後,顯式賦值前,需要先對物件頭進行一些必要的設定,即設定物件頭資訊,類後設資料的引用,物件的雜湊碼,物件的 GC 分代年齡等。

1.4.1.5 執行物件 init 方法

此處用來對物件進行顯式初始化,即根據程式者的意願進行初始化,會覆蓋掉前面的零值

1.4.2 物件的訪問定位方式哪兩種方式?

首先舉個例子: Student student = new Student();

假設我們建立了這樣一個學生類,Student student 就代表作為一個本地引用,被儲存在了 JVM 虛擬機器棧的區域性變數表中,此處代表一個 reference 型別的資料,而 new Student 作為例項資料儲存在了堆中。還儲存了物件型別資料(類資訊,常量,靜態變數)

而我們在使用物件的時候,就是通過棧上的這個 reference 型別的資料來操作物件,它有兩種方式訪問這個具體物件

  1. 控制程式碼:在堆中劃分出一塊記憶體作為控制程式碼池,reference 中儲存的就是物件的控制程式碼地址,而控制程式碼中儲存著物件例項資料和資料型別的地址。這種方式比較穩定,因為物件移動的時候,只改變控制程式碼中的例項資料的指標,reference 是不需要修改的。
  2. 直接指標:即 reference 中儲存的就是物件的地址。這種方式的優勢就是快速,因為少了一次指標定位的開銷

控制程式碼方式配圖:

直接指標方式配圖:

1.4.3 如何判斷物件死亡

堆中幾乎放著所有的物件例項,對堆垃圾回收前的第一步就是要判斷哪些物件已經死亡(即不能再被任何途徑使用的物件)。

  • 引用計數法:給物件中新增一個引用計數器,每當有一個地方引用它,計數器就加1;當引用失效,計數器就減1;任何時候計數器為0的物件就是不可能再被使用的。
    • 引用計數法原理簡單,判定效率也很高,在很多場景下是一個不錯的演算法,但是在主流的 Java 領域中,因為其需要配合大量額外處理才能保證正確地工作。
      • 例如它很難解決兩個物件之間迴圈引用的問題:物件 objA 和 objB 均含有 instance 欄位,賦值令 objA.instance = objB, objB.instance = objA,除此之外,這兩個物件已經再無引用,實際上這兩個已經不可能被再訪問了,因為雙方互相因喲紅著對方,它們的引用計數不為零,引用技術演算法也就無法回收它們。
  • 可達性分析演算法:這個演算法的基本思想就是通過一系列的稱為 “GC Roots” 的物件作為起點,從這些節點開始向下搜尋,節點所走過的路徑稱為引用鏈,當一個物件到 GC Roots 沒有任何引用鏈相連的話,則證明此物件是不可能再被使用的

1.4.3.1 四種引用型別的程度

無論是引用計數演算法,還是可達性分析演算法,判定物件的存活都與引用有關,但是 JDK 1.2 之間的版本,將物件的引用狀態分為 “被引用” 和 “未被引用” 實際上有些狹隘,描述一些食之無味,棄之可惜的物件就有一些無能為力,所以1.2 之後邊進行了更細緻的劃分。

JDK1.2之前,引用的概念就是,引用型別儲存的是一塊記憶體的起始地址,代表這是這塊記憶體的一個引用。

JDK1.2以後,細分為強引用、軟引用、弱引用、虛引用四種(逐漸變弱)

  • 強引用:垃圾回收器不會回收它,當記憶體不足的時候,JVM 寧願丟擲 OutOfMemoryError 錯誤,也不願意回收它。
  • 軟引用:只有在記憶體空間不足的情況下,才會考慮回收軟引用。
  • 弱引用:弱引用比軟引用宣告週期更短,在垃圾回收器執行緒掃描它管轄的記憶體區域的過程中,只要發現了弱引用物件,就會回收它,但是因為垃圾回收器執行緒的優先順序很低,所以,一般也不會很快發現並回收。
  • 虛引用:級別最低的引用型別,它任何時候都可能被垃圾回收器回收

1.5 講講幾種垃圾收集演算法

1.5.1 標記清除演算法

標記清除演算法首先標記出所有不需要回收的物件,在標記完成後統一回收掉所有沒有被標記的物件,也可以反過來。

它的主要缺點有兩個:

  • 第1個是執行效率不穩定,如果Java最終含有大量物件,而且其中大部分都是需要回收的,這是需要進行大量標記和清除動作,導致標記和清除兩個過程的執行效率隨著物件數量增長而降低。
  • 第2個是記憶體空間的碎片化問題,標記清除後會產生大量不存連續的記憶體碎片空間,碎片太多會導致以後程式執行時需要分配較大物件時無法找到足夠的連續記憶體,而不得不提前觸發一次垃圾收集工作。

它屬於基礎演算法,後續的大部分演算法,都是在其基礎上改進的。

1.5.2 標記複製演算法

標記複製演算法將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊,當這一塊的記憶體用完了就將還存活著的物件複製到另一塊上面,然後再把已經使用過的記憶體空間再次清理掉。

缺點:如果記憶體中多數物件都是存活的,這種演算法將會產生大量的記憶體間複製的開銷。****

優點:

  • 但是對於多數物件都是可回收的情況,演算法需要複製的就是佔有少數的存活物件
  • 每次都是針對整個半區進行記憶體回收,分配記憶體時,也不用考慮有空間碎片的複雜情況,只要移動堆頂指標按順序分配即可

1.5.3 標記整理演算法(標記壓縮演算法)

標記複製演算法在物件存活率較高的時候就要進行較多的複製,操作效率將會降低,更關鍵的是如果不想浪費50%的空間,就需要有額外的空間進行分配擔保以應對唄,使用記憶體所有物件都百分百存活的極端情況,所以在老年代一般是不採用這種演算法的。

標記整理演算法與標記清除演算法一致,但後續步驟不是直接對可回收物件進行清理,而是讓所有存貨的物件都向記憶體空間一端移動,然後直接清理掉邊界以外的記憶體

但移動存活物件也是有缺點的:尤其是在老年代這種每次回收都有大量物件存活的區域,移動存活物件並更新所有引用這些物件的地方,將會是一種極為負重的操作,而且這種物件移動操作必須全程暫停使用者應用程式才能進行,這種停頓被稱為 stop the world。

1.6 什麼是分代收集演算法

分代收集理論,首先它建立在兩個假說之上:

  1. 弱分代假說:絕大多數物件都是朝生夕滅的
  2. 強分帶假說:熬過越多次垃圾收集過程的物件就越難以消亡

所以多款常用垃圾收集器的一致設計原則即為:收集器應該將 Java 堆劃分出不同的區域,然後將回收物件依據其年齡(即熬過垃圾收集過程次數)分配到不同的區域之中儲存。

很明顯的,如果一個區域中大部分的物件都是朝生夕滅,難以熬過垃圾收集過程,那麼把它們集中放在一起,每次回收就只需要考慮如何保留少量存活的物件,而不是去標記那些大量要被回收的物件,這樣就能以一種比較低的代價回收大量空間,如果剩下的都是難以消亡的物件,就把它們集中到一塊,虛擬機器便可以使用較低的頻率來回收這個區域。

所以,分代收集演算法的思想就是根據物件存活週期的不同,將記憶體分為幾塊,例如分為新生代(Eden 空間、From Survivor 0、To Survivor 1 )和老年代,然後再各個年代選擇合適的垃圾收集演算法

  • 新生代 ( Young ) 與老年代 ( Old ) 的比例的值為 1:2
  • Edem : From Survivor 0 : To Survivor 1 = 8 : 1 : 1

新生代中每次都會有大量物件死去,所以選擇清除複製演算法,要比標記清除更高效,只需要複製移動少量存活下來的物件即可。

老年代中物件存活的機率比較高,所以要選擇標記清除或者標記整理演算法。

1.6.1 為什麼新生代要分為Eden區和Survivor區?

注:此處參考引用博文:為什麼新生代記憶體需要有兩個Survivor區 註明出處,請尊重原創

補充:

  • Minor GC / Young GC :新生代收集
  • Major GC / Old GC :老年代收集
  • Full GC 整堆收集

如果沒有Survivor,Eden區每進行一次Minor GC,存活的物件就會被送到老年代。老年代很快被填滿,觸發Major GC(因為Major GC一般伴隨著Minor GC,也可以看做觸發了Full GC)。老年代的記憶體空間遠大於新生代,進行一次Full GC消耗的時間比Minor GC長得多。你也許會問,執行時間長有什麼壞處?頻發的Full GC消耗的時間是非常可觀的,這一點會影響大型程式的執行和響應速度,更不要說某些連線會因為超時發生連線錯誤了。

  • Survivor的存在意義,就是減少被送到老年代的物件,進而減少Full GC的發生,Survivor的預篩選保證,只有經歷16次Minor GC還能在新生代中存活的物件,才會被送到老年代。

1.6.2 為什麼要設定兩個Survivor區?(有爭議,待修改)

引用博文的作者觀點:設定兩個Survivor區最大的好處就是解決了碎片化,剛剛新建的物件在Eden中,經歷一次Minor GC,Eden中的存活物件就會被移動到第一塊survivor space S0,Eden被清空;等Eden區再滿了,就再觸發一次Minor GC,Eden和S0中的存活物件又會被複制送入第二塊survivor space S1(這個過程非常重要,因為這種複製演算法保證了S1中來自S0和Eden兩部分的存活物件佔用連續的記憶體空間,避免了碎片化的發生)。S0和Eden被清空,然後下一輪S0與S1交換角色,如此迴圈往復。如果物件的複製次數達到16次,該物件就會被送到老年代中。

個人觀點,更本質是考慮了效率問題,如果是因為產生了碎片的問題,我完全可以使用標記整理方法解決,我更傾向於理解為整理空間帶來的效能消耗是遠大於使用兩塊 survivor 區進行復制移動的消耗的。

注:如果這一塊不清楚,可以參考一下引用文章的圖片。

1.6.3 哪些物件會直接進入老年代

  1. 大物件直接進入老年代

    • 在分配空間時它容易導致記憶體,明明還有不少空間時就提前觸發垃圾收集,以獲取足夠的連續空間才能好安置他們,而當複製物件時大物件就意味著高額的記憶體複製開銷,這樣做的目的就是避免在 Eden區 以及兩個 survivor 區之間來回複製產生大量的記憶體複製操作
  2. 長期存活的物件進入老年代

    • HotSpot 虛擬機器採用了分代收集的思想來管理記憶體,那麼記憶體回收時就必須能識別哪些物件應放在新生代,哪些物件應放在老年代中。為了做到這一點,虛擬機器給每個物件一個物件年齡(Age)計數器,儲存在物件頭中。

      如果物件在 Eden 出生並經過第一次 Minor GC 後仍然能夠存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並將物件年齡設為 1.物件在 Survivor 中每熬過一次 MinorGC,年齡就增加 1 歲,當它的年齡增加到一定程度(預設為 15 歲),就會被晉升到老年代中。物件晉升到老年代的年齡閾值,可以通過引數 -XX:MaxTenuringThreshold 來設定。

1.6.3 動態物件年齡判定

為了能更好的適應不同程式的記憶體狀況,HotSpot 虛擬機器並不是永遠要求物件年齡必須達到 -XX:MaxTenuringThreshold,才能晉升老年代,如果在 Survivor 空間中相同年齡所有物件大小的總和大於 Survivor 空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代。

1.7 介紹一下常見的垃圾回收器

1.7.1 Serial 收集器

Serial 收集器是最基本、歷史最悠久的垃圾收集器了。在 JDK 1.3.1 之前是 HotSpot 虛擬機器新生代收集器的唯一選擇,大家看名字就知道這個收集器是一個單執行緒收集器了。它的 “單執行緒” 的意義不僅僅意味著它只會使用一條垃圾收集執行緒去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作執行緒( "Stop The World" ),直到它收集結束。

  • 新生代採用複製演算法,老年代採用標記-整理演算法。

對於 "Stop The World" 帶給使用者的惡劣體驗早期 HotSpot 虛擬機器的設計者們表示完全理解,但也表示委屈:你媽媽在給你打掃房間的時候,肯定會讓你老老實實的在椅子上或者房間外等待,如果她一邊打掃你一邊亂扔紙屑,這房間還能打掃完嗎?這其實是一個合情合理的矛盾,雖然垃圾收集這項工作聽起來和打掃房間屬於一個工種,但實際上肯定要比打掃房間複雜很多。

雖然從現在看來,這個收集器已經老而無用,棄之可惜,但是它仍然是 HotSpot 虛擬機器在客戶端模式下預設的新生代收集器,因為其有著優秀的地方,就是簡單而又高效,記憶體消耗也是最小的。

1.7.2 ParNew 收集器

ParNew 收集器其實就是 Serial 收集器的多執行緒版本,除了使用多執行緒進行垃圾收集外,其餘行為(控制引數、收集演算法、Stop The World、物件分配規則、回收策略等)和 Serial 收集器完全一樣。

它除了支援多執行緒並行收集之外,與 Serial 收集器相比沒有太多的創新之處,但卻是不少執行在Server 服務端模式下的 HotSpot 虛擬機器的選擇。

  • 新生代採用複製演算法,老年代採用標記-整理演算法。

1.7.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器也是基於標記-複製演算法的多執行緒收集器,看起來和 ParNew 收集器很相似。

Parallel Scavenge 的目標是達到一個可控制的吞吐量(處理器用於執行這個程式的時間和處理器總消耗的時間之比),即高效利用 CPU,同時它也提供了很多引數供使用者找到最合適的停頓時間或最大吞吐量。

1.7.4 Serial Old 收集器

Serial 收集器的老年代版本,它同樣是一個單執行緒收集器。其主要意義還是提供客戶端模式下的 HotSpot 虛擬機器使用。

  • 如果實在服務端的模式下,也可能有兩種用途:
    • 一種用途是在 JDK1.5 以及以前的版本中與 Parallel Scavenge 收集器搭配使用,
    • 一種用途是作為 CMS 收集器的後備方案。

1.7.5 Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。也是一個基於 “標記-整理”演算法的多執行緒收集器。在注重吞吐量以及 CPU 資源的場合,都可以優先考慮 Parallel Scavenge 收集器和 Parallel Old 收集器。

1.7.6 CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是一種以獲得最短回收停頓時間為目標的收集器,能給使用者帶來比較好的互動體驗。基於標記清除演算法。

  • 初始標記: 初始標記僅僅是標記一下 GC Roots 能 直接關聯到的物件,速度很快
  • 併發標記:併發標記就是從 GC Roots 的直接關聯物件,開始遍歷整個物件圖的過程,這個過程耗時較長,但不需要停頓,使用者執行緒可以與垃圾收集執行緒一起併發執行
  • 重新標記: 重新標記階段就是為了修正併發標記期間,因為使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短。
  • 併發清除: 最後是併發清除階段,清理刪除掉標記階段判斷的已經死亡的物件,由於不需要移動存活物件,所以這個階段也是可以與使用者執行緒併發的。

1.7.7 G1 收集器

G1 (Garbage-First) 是一款面向伺服器的垃圾收集器,主要針對配備多顆處理器及大容量記憶體的機器. 以極高概率滿足 GC 停頓時間要求的同時,還具備高吞吐量效能特徵。同時不會產生碎片。

  • 初始標記:僅僅是標記一下 GC Roots 能直接關聯到的物件,並且修改 TAMS 指標的值,讓下一階段使用者執行緒併發執行時能正確的在可用的 Region中分配新物件,這個階段需要停頓執行緒,但耗時很短,而且是借用進行Minor GC 的時候同步完成的,所以 G1 收集器在這個階段其實沒有額外的停頓。

  • 併發標記:從GC Root 開始,對堆中物件進行可達性分析,遞迴掃描整個堆裡的物件圖找出要回收物件,這階段耗時較長,但可以與使用者程式併發執行,當物件掃描完成後,還要重新處理 SATB 記錄下的,在併發時有引用變動的物件。

  • 最終標記:對使用者現場做另一個短暫的暫停,用於處理併發階段結束後仍遺留下來最後那少量的 SATB 記錄。

  • 篩選回收:負責更新 Region 的統計資料,對各個 Region 的回收價值和成本進行排序,根據使用者所期望的停頓時間來制定回收計劃,可以自由選擇任意多個 Region 構成回收集,然後決定回收的那一部分 Region 的存貨物件複製到空的 Region 中,在清理到整個就 Region 的全部空間,這裡面涉及操作存活物件的移動是必須暫停使用者執行緒,由多條收集執行緒並行完成的

優點和特點:

  • G1 能在充分利用 CPU 的情況下,縮短 Stop-The-World 的時間,GC 時為併發狀態,不會暫停 Java 程式執行。
  • 保留了分代概念,但是它其實可以獨立管理整個 GC 堆。
  • G1 從整理上看是基於標記整理演算法實現的,從區域性上看是基於標記複製演算法的。
  • G1 除了追求停頓以外,還建立了可以預測的停頓時間模型,能讓使用者明確指定在一個長度為 M 毫秒的時間片段內。

相關文章