【整理】JVM知識點大梳理

友人421發表於2020-06-04
JVM是Java Virtual Machine(Java虛擬機器)的縮寫,JVM是一種用於計算裝置的規範,它是一個虛構出來的計算機,是通過在實際的計算機上模擬模擬各種計算機功能來實現的。引入Java語言虛擬機器後,Java語言在不同平臺上執行時不需要重新編譯。Java語言使用Java虛擬機器遮蔽了與具體平臺相關的資訊,使得Java語言編譯程式只需生成在Java虛擬機器上執行的目的碼(位元組碼),就可以在多種平臺上不加修改地執行。                                                                                     ----來源:百度百科


前言:
Java 虛擬機器,是一個可以執行 Java 位元組碼的虛擬機器程式。Java 原始檔被編譯成能被 Java 虛擬機器執行的位元組碼檔案( .class )。
跨平臺的是 Java 程式(包括位元組碼檔案),,而不是 JVM。JVM 是用 C/C++ 開發的,是編譯後的機器碼,不能跨平臺,不同平臺下需要安裝不同版本的 JVM 。

JVM 組成部分

 

  • 類載入器,在 JVM 啟動時或者類執行時將需要的 class 載入到 JVM 中。
  • 記憶體區,將記憶體劃分成若干個區以模擬實際機器上的儲存、記錄和排程功能模組,如實際機器上的各種功能的暫存器或者 PC 指標的記錄器等。
  • 執行引擎,執行引擎的任務是負責執行 class 檔案中包含的位元組碼指令,相當於實際機器上的 CPU 。
  • 本地方法呼叫,呼叫 C 或 C++ 實現的本地方法的程式碼返回結果。

1、類載入器

從類被載入到虛擬機器記憶體中開始,到卸御出記憶體為止,它的整個生命週期分為7個階段, 載入(Loading) 、 驗證(Verification) 、 準備(Preparation) 、 解析(Resolution) 、 初始化(Initialization) 、 使用(Using) 、 卸御(Unloading) 。其中驗證、準備、解析三個部分統稱為連線。7個階段發生的順序如下:載入(Loading)、驗證(Verification)、準備(Preparation)、初始化(Initialization)、解除安裝(Unloading) 這五個階段的過程是 固定 的,在類載入過程中必須按照這種順序按部就班地進行,而解析階段則不一定,他在某種情況下可以在初始化之後進行,這個是為了支援Java語言的執行時繫結(也稱為動態繫結或者晚期繫結)。

1.1、載入

載入階段,虛擬機器需要完成3件事:

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

載入階段完成後,虛擬機器外部的 二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,而且在Java堆中也建立一個java.lang.Class類的物件,這樣便可以通過該物件訪問方法區中的這些資料。

1.2、驗證

驗證階段主要,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合虛擬機器的要求,並且不會危害虛擬機器自身的安全。驗證階段主要完成下面4個階段的校驗動作:

  • 檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範;例如:是否以0xCAFEBABE開頭、主次版本號是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的型別。
  • 後設資料驗證:對位元組碼描述的資訊進行語義分析(注意:對比javac編譯階段的語義分析),以保證其描述的資訊符合Java語言規範的要求;例如:這個類是否有父類,除了java.lang.Object之外。
  • 位元組碼驗證:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。
  • 符號引用驗證:確保解析動作能正確執行。

 

1.3、準備

準備階段是正式為類變數分配記憶體並設定初始值的階段,這些變數所使用的記憶體都將在方法區分配。

進行記憶體分配的僅包括類變數(static),而不包括例項變數,例項變數會在物件例項化時隨著物件一塊分配在Java堆中。

初始值通常情況下是資料型別預設的零值(如0、0L、null、false等)

1.4、解析

解析階段是將虛擬機器常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號進行.

符號引用:簡單的理解就是字串,比如引用一個類,java.util.ArrayList 這就是一個符號引用,字串引用的物件不一定被載入。

直接引用:指標或者地址偏移量。引用物件一定在記憶體(已經載入)。

1.5、初始化

類初始化是類載入的最後一步,除了載入階段,使用者可以通過自定義的類載入器參與,其他階段都完全由虛擬機器主導和控制。到了初始化階段才真正執行Java程式碼。類的初始化的主要工作是為 靜態變數賦程式設定的初值 。如static int a = 100;在準備階段,a被賦預設值0,在初始化階段就會被賦值為100。Java虛擬機器規範中嚴格規定了有且只有五種情況必須對類進行初始化:

  • 1、使用new位元組碼指令建立類的例項,或者使用getstatic、putstatic讀取或設定一個靜態欄位的值(放入常量池中的常量除外),或者呼叫一個靜態方法的時候,對應類必須進行過初始化。
  • 2、通過java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則要首先進行初始化。
  • 3、當初始化一個類的時候,如果發現其父類沒有進行過初始化,則首先觸發父類初始化。
  • 4、當虛擬機器啟動時,使用者需要指定一個主類(包含main()方法的類),虛擬機器會首先初始化這個類。
  • 5、使用jdk1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、RE_invokeStatic的方法控制程式碼,並且這個方法控制程式碼對應的類沒有進行初始化,則需要先觸發其初始化。

2、物件的建立過程

Java 中物件的建立就是在堆上分配記憶體空間的過程,此處說的物件建立僅限於 new 關鍵字建立的普通 Java 物件,不包括陣列物件的建立。當虛擬機器遇到一條含有new的指令時,會進行一系列物件建立的操作:

2.1、檢查類是否被載入

1、檢查常量池中是否有即將要建立的這個物件所屬的類的符號引用;若常量池中沒有這個類的符號引用,說明這個類還沒有被定義!丟擲ClassNotFoundException;2、進而檢查這個符號引用所代表的類是否已經被JVM載入;若該類還沒有被載入,就找該類的class檔案,並載入進方法區;若該類已經被JVM載入,則準備為物件分配記憶體;

2.2、為物件分配記憶體

根據方法區中該類的資訊確定該類所需的記憶體大小;一個物件所需的記憶體大小是在這個物件所屬類被定義完就能確定的!且一個類所生產的所有物件的記憶體大小是一樣的!JVM在一個類被載入進方法區的時候就知道該類生產的每一個物件所需要的記憶體大小。4、從堆中劃分一塊對應大小的記憶體空間給新的物件;分配堆中記憶體有兩種方式:指標碰撞 如果JVM的垃圾收集器採用複製演算法或標記-整理演算法,那麼堆中空閒記憶體是完整的區域,並且空閒記憶體和已使用記憶體之間由一個指標標記。那麼當為一個物件分配記憶體時,只需移動指標即可。因此,這種在完整空閒區域上通過移動指標來分配記憶體的方式就叫做“指標碰撞”。空閒列表 如果JVM的垃圾收集器採用標記-清除演算法,那麼堆中空閒區域和已使用區域交錯,因此需要用一張“空閒列表”來記錄堆中哪些區域是空閒區域,從而在建立物件的時候根據這張“空閒列表”找到空閒區域,並分配記憶體。綜上所述:JVM究竟採用哪種記憶體分配方法,取決於它使用了何種垃圾收集器。

多執行緒併發時會出現正在給物件 A 分配記憶體,還沒來得及修改指標,物件 B 又用這個指標分配記憶體,這樣就出現問題了。

解決這種問題有兩種方案:

第一種,是採用同步的辦法,使用 CAS 來保證操作的原子性。

另一種,是每個執行緒分配記憶體都在自己的空間內進行,即是每個執行緒都在堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝(Thread Local Allocation Buffer, TLAB),分配記憶體的時候再TLAB上分配,互不干擾。可以通過 -XX:+/-UseTLAB 引數決定。

 

2.3、為分配的記憶體空間初始化零值

為物件中的成員變數賦上初始值(預設初始化);物件的記憶體分配完成後,還需要將物件的記憶體空間都初始化為零值,這樣能保證物件即使沒有賦初值,也可以直接使用

2.4、為物件進行其他設定

設定物件頭中的資訊;所屬的類,類的後設資料資訊,物件的 hashcode ,GC 分代年齡等資訊

2.5、執行 init 方法

呼叫物件的建構函式進行初始化執行完上面的步驟之後,在虛擬機器裡這個物件就算建立成功了,但是對於 Java 程式來說還需要執行 init 方法才算真正的建立完成,因為這個時候物件只是被初始化零值了,還沒有真正的去根據程式中的程式碼分配初始值,呼叫了 init 方法之後,這個物件才真正能使用。初始化順序:

  • 在new B一個例項時首先要進行類的裝載。(類只有在使用New呼叫建立的時候才會被java類裝載器裝入)

  • 在裝載類時,先裝載父類A,再裝載子類B

  • 裝載父類A後,完成靜態動作(包括靜態程式碼和變數,它們的級別是相同的,按照程式碼中出現的順序初始化)

  • 裝載子類B後,完成靜態動作

  • 類裝載完成,開始進行例項化

  • 在例項化子類B時,先要例項化父類A2,例項化父類A時,先成員例項化(非靜態程式碼)

  • 父類A的構造方法

  • 子類B的成員例項化(非靜態程式碼)

  • 子類B的構造方法

 

先初始化父類的靜態程式碼--->初始化子類的靜態程式碼-->初始化父類的非靜態程式碼--->初始化父類建構函式--->初始化子類非靜態程式碼--->初始化子類建構函式

 

 

3、物件的記憶體佈局

3.1、物件頭(markword)

  • 第一部分用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC 分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳、物件分代年齡,這部分資訊稱為“Mark Word”;Mark Word 被設計成一個非固定的資料結構以便在極小的空間記憶體儲儘量多的資訊,它會根據自己的狀態複用自己的儲存空間。
  • 第二部分是型別指標,即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項;
  • Klass Word  這裡其實是虛擬機器設計的一個oop-klass model模型,這裡的OOP是指Ordinary Object Pointer(普通物件指標),看起來像個指標實際上是藏在指標裡的物件。而 klass 則包含 後設資料和方法資訊,用來描述 Java 類。它在64位虛擬機器開啟壓縮指標的環境下佔用 32bits 空間。
  • 如果物件是一個 Java 陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料。因為虛擬機器可以通過普通 Java 物件的後設資料資訊確定 Java 物件的大小,但是從陣列的後設資料中無法確定陣列的大小。

在32位系統下,物件頭8位元組,64位則是16個位元組【未開啟壓縮指標,開啟後12位元組】。假設當前為32bit,在物件未被鎖定情況下。25bit為儲存物件的雜湊碼、4bit用於儲存分代年齡,2bit用於儲存鎖標誌位,1bit固定為0。不同狀態下存放資料:這其中鎖標識位需要特別關注下。鎖標誌位與是否為偏向鎖對應到唯一的鎖狀態。鎖的狀態分為四種無鎖狀態、偏向鎖、輕量級鎖和重量級鎖不同狀態時物件頭的區間含義,如圖所示。

3.2、例項資料(Instance Data)

例項資料部分是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。這部分的儲存順序會受到虛擬機器分配策略引數(FieldsAllocationStyle)和欄位在 Java 原始碼中定義順序的影響。分配策略:相同寬度的欄位總是放在一起,比如double和long

3.3、對其填充(Padding)

對齊填充不是必然存在的,沒有特別的含義,它僅起到佔位符的作用。

由於HotSpot規定物件的大小必須是8的整數倍,物件頭剛好是整數倍,如果例項資料不是的話,就需要佔位符對齊填充。

 

3.4、預估物件大小

32 位系統下,當使用 new Object() 時,JVM 將會分配 8(Mark Word+型別指標) 位元組的空間,128 個 Object 物件將佔用 1KB 的空間。如果是 new Integer(),那麼物件裡還有一個 int 值,其佔用 4 位元組,這個物件也就是 8+4=12 位元組,對齊後,該物件就是 16 位元組。以上只是一些簡單的物件,那麼物件的內部屬性是怎麼排布的?

Class A {    int i;    byte b;    String str;}

其中物件頭部佔用 ‘Mark Word’4 + ‘型別指標’4 = 8 位元組;byte 8 位長,佔用 1 位元組;int 32 位長,佔用 4 位元組;String 只有引用,佔用 4 位元組;那麼物件 A 一共佔用了 8+1+4+4=17 位元組,按照 8 位元組對齊原則,物件大小也就是 24 位元組。
這個計算看起來是沒有問題的,物件的大小也確實是 24 位元組,但是對齊(padding)的位置並不對:在 HotSpot VM 中,物件排布時,間隙是在 4 位元組基礎上的(在 32 位和 64 位壓縮模式下),上述例子中,int 後面的 byte,空隙只剩下 3 位元組,接下來的 String 物件引用需要 4 位元組來存放,因此 byte 和物件引用之間就會有 3 位元組對齊,物件引用排布後,最後會有 4 位元組對齊,因此結果上依然是 7 位元組對齊。此時物件的結構示意圖,如下圖所示:

4、物件訪問

物件的訪問方式由虛擬機器決定,java虛擬機器提供兩種主流的方式

1.控制程式碼訪問物件

2.直接指標訪問物件。(Sun HotSpot使用這種方式)

 

4.1、控制程式碼訪問

簡單來說就是java堆劃出一塊記憶體作為控制程式碼池,引用中儲存物件的控制程式碼地址,控制程式碼中包含物件例項資料、型別資料的地址資訊。優點:引用中儲存的是穩定的控制程式碼地址,在物件被移動【垃圾收集時移動物件是常態】只需改變控制程式碼中例項資料的指標,不需要改動引用【ref】本身。

4.2、直接指標

與控制程式碼訪問不同的是,ref中直接儲存的就是物件的例項資料,但是型別資料跟控制程式碼訪問方式一樣。優點:優勢很明顯,就是速度快,相比於控制程式碼訪問少了一次指標定位的開銷時間。【可能是出於Java中物件的訪問時十分頻繁的,平時我們常用的JVM HotSpot採用此種方式】

5、JVM 記憶體區域

5.1、虛擬機器棧

描述的是方法執行時的 記憶體模型 ,是 執行緒私有 的,生命週期與執行緒相同,每個方法被執行的同時會建立棧楨,主要儲存執行方法時的 區域性變數表、運算元棧、動態連線和方法返回地址 等資訊,方法執行時入棧,方法執行完出棧,出棧就相當於清空了資料,入棧出棧的時機很明確,所以這塊區域不需要進行 GC。Java虛擬機器棧可能出現兩種型別的異常:

  • 執行緒請求的棧深度大於虛擬機器允許的棧深度,將丟擲 StackOverflowError
  • 虛擬機器棧空間可以動態擴充套件,當動態擴充套件是無法申請到足夠的空間時,丟擲 OutOfMemory異常
  • 擴充link: 棧幀

5.2、本地方法棧

與虛擬機器棧功能非常類似,主要區別在於虛擬機器棧為虛擬機器執行 Java 方法時服務,而本地方法棧為虛擬機器執行 本地方法 時服務的。這塊區域也不需要進行 GC。

5.3、程式計數器

  • 程式計數器是一塊很小的記憶體空間,它是執行緒私有的,可以認作為當前執行緒的行號指示器。
  • 程式計數器的主要作用是記錄執行緒執行時的狀態,方便執行緒被喚醒時能從上一次被掛起時的狀態繼續執行
  • 程式計數器是唯一一個在 Java 虛擬機器規範中沒有規定任何 OOM 情況的區域 ,所以這塊區域也不需要進行 GC

5.4、本地記憶體

  • 執行緒共享區域,Java 8 中,本地記憶體,也是我們通常說的堆外記憶體,包括元空間和方法區
  • 主要儲存類的資訊,常量,靜態變數,即時編譯器編譯後程式碼等,這部分由於是在堆中實現的,受 GC 的管理,不過由於永久代有 -XX:MaxPermSize 的上限
  • 所以如果動態生成類(將類資訊放入永久代)或大量地執行 String.intern (將欄位串放入永久代中的常量區),很容易造成 OOM,有人說可以把永久代設定得足夠大,但很難確定一個合適的大小,受類數量,常量數量的多少影響很大。
  • 所以在 Java 8 中就把方法區的實現移到了本地記憶體中的元空間中,這樣方法區就不受 JVM 的控制了,也就不會進行 GC,也因此提升了效能(發生 GC 會發生 Stop The Word,造成效能受到一定影響,後文會提到),也就不存在由於永久代限制大小而導致的 OOM 異常了(假設總記憶體2G,JVM 被分配記憶體 100M, 理論上元空間可以分配 2G-100M = 1.9G,空間大小足夠),也方便在元空間中統一管理。
  • 綜上所述,在 Java 8 以後這一區域也不需要進行 GC
  • 擴充link: 堆外記憶體回收

5.5、堆

  • 物件例項和陣列都是在堆上分配的,GC 也主要對這兩類資料進行回收。
  • java虛擬機器規範對這塊的描述是:所有物件例項及陣列都要在堆上分配記憶體,但隨著JIT編譯器的發展和 逃逸分析技術 的成熟,這個說法也不是那麼絕對,但是大多數情況都是這樣的。
  • 堆細分:新生代(Eden,survior)和老年代

6、物件存活判斷

  • 引用計數
  • 可達性分析

6.1、引用計數

每個物件有一個引用計數屬性,新增一個引用時計數加 1 ,引用釋放時計數減 1 ,計數為 0 時可以回收。此方法簡單,無法解決物件相互迴圈引用的問題。目前在用的有 Python、ActionScript3 等語言。

6.2、可達性分析

從 GC Roots 開始向下搜尋,搜尋所走過的路徑稱為引用鏈。當一個物件到 GC Roots 沒有任何引用鏈相連時,則證明此物件是不可用的。不可達物件。目前在用的有 Java、C# 等語言。

GC Roots 物件:

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

  • 方法區中的類靜態屬性引用的物件。

  • 方法區中常量引用的物件。

  • 本地方法棧中 JNI(即一般說的 Native 方法)中引用的物件。

如何判斷無用的類:

  • 該類所有例項都被回收(Java 堆中沒有該類的物件)。

  • 載入該類的 ClassLoader 已經被回收。

  • 該類對應的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方利用反射訪問該類。

 

6.3、finalize

finallize()方法,是在釋放該物件記憶體前由 GC (垃圾回收器)呼叫。通常建議在這個方法中釋放該物件持有的資源,例如持有的堆外記憶體、和遠端服務的長連線。一般情況下,不建議重寫該方法。對於一個物件,該方法有且僅會被呼叫一次。

6.4、物件引用型別

  • 強引用
  • 軟引用(SoftReference)
  • 弱引用(WeakReference)
  • 虛引用(PhantomReference)

6.4.1、強引用

如果一個物件具有強引用,那就類似於必不可少的生活用品,垃圾回收器絕不會回收它。當記憶體空間不足,Java 虛擬機器寧願丟擲 OutOfMemoryError 錯誤,使程式異常終止,也不會靠隨意回收具有強引用的物件來解決記憶體不足問題

6.4.2、軟引用

如果一個物件只具有軟引用,那就類似於可有可無的生活用品。如果記憶體空間足夠,垃圾回收器就不會回收它,如果記憶體空間不足了,就會回收這些物件的記憶體。只要垃圾回收器沒有回收它,該物件就可以被程式使用。軟引用可用來實現記憶體敏感的快取記憶體。

6.4.3、弱引用

弱引用與軟引用的區別在於:只具有弱引用的物件擁有更短暫的生命週期。在垃圾回收器執行緒掃描它 所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體。

6.4.4、虛引用

“虛引用”顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定物件的生命週期。如果一個物件僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。。當垃 圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。程式可以通過判斷引用佇列中是否已經加入了虛引用,來了解被引用的物件是否將要被垃圾回收。程式如果發現某個虛引用已經被加入到引用佇列,那麼就可以在所引用的物件的記憶體被回收之前採取必要的行動。

擴充

利用軟引用和弱引用解決 OOM 問題。用一個 HashMap 來儲存圖片的路徑和相應圖片物件關聯的軟引用之間的對映關係,在記憶體不足時,JVM 會自動回收這些快取圖片物件所佔用的空間,從而有效地避免了 OOM 的問題. 通過軟引用實現 Java 物件的快取記憶體。比如我們建立了一 Person 的類,如果每次需要查詢一個人的資訊,哪怕是幾秒中之前剛剛查詢過的,都要重新構建一個例項,這將引起大量 Person 物件的消耗,並且由於這些物件的生命週期相對較短,會引起多次 GC 影響效能。此時,通過軟引用和 HashMap 的結合可以構建快取記憶體,提供效能。

7、垃圾回收演算法

  • 標記-清除演算法
  • 標記-整理演算法
  • 複製演算法
  • 分代收集演算法

7.1、標記-清除

在標記階段,首先通過根節點,標記所有從根節點開始的可達物件。因此,未被標記的物件就是未被引用的垃圾物件(好多資料說標記出要回收的物件,其實明白大概意思就可以了)。然後,在清除階段,清除所有未被標記的物件。缺點:

  • 1、效率問題,標記和清除兩個過程的效率都不高。
  • 2、空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大的物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

7.2、標記-整理

標記整理演算法,類似與標記清除演算法,不過它標記完物件後,不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體。優點:

  • 1、相對標記清除演算法,解決了記憶體碎片問題。
  • 2、沒有記憶體碎片後,物件建立記憶體分配也更快速了(可以使用TLAB進行分配)。

缺點:

  • 1、效率問題,(同標記清除演算法)標記和整理兩個過程的效率都不高。

7.3、複製演算法

複製演算法,可以解決效率問題,它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊,當這一塊記憶體用完了,就將還存活著的物件複製到另一塊上面,然後再把已經使用過的記憶體空間一次清理掉,這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可(還可使用TLAB進行高效分配記憶體)優點:

  • 1、效率高,沒有記憶體碎片。

缺點:

  • 1、浪費一半的記憶體空間。
  • 2、複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。

7.4、分代演算法

當前商業虛擬機器都是採用分代收集演算法,它根據物件存活週期的不同將記憶體劃分為幾塊,一般是把 Java 堆分為新生代和老年代,然後根據各個年代的特點採用最適當的收集演算法。在新生代中,每次垃圾收集都發現有大批物件死去,只有少量存活,就選用複製演算法。而老年代中,因為物件存活率高,沒有額外空間對它進行分配擔保,就必須使用“標記清理”或者“標記整理”演算法來進行回收。圖的左半部分是未回收前的記憶體區域,右半部分是回收後的記憶體區域。物件分配策略:物件優先在 Eden 區域分配,如果物件過大直接分配到 Old 區域。長時間存活的物件進入到 Old 區域。改進自複製演算法現在的商業虛擬機器都採用這種收集演算法來回收新生代,IBM 公司的專門研究表明,新生代中的物件 98% 是“朝生夕死”的,所以並不需要按照 1:1 的比例來劃分記憶體空間,而是將記憶體分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor 。當回收時,將 Eden 和 Survivor 中還存活著的物件一次性地複製到另外一塊 Survivor 空間上,最後清理掉 Eden 和剛才用過的 Survivor 空間。HotSpot 虛擬機器預設 Eden 和 2 塊 Survivor 的大小比例是 8:1:1,也就是每次新生代中可用記憶體空間為整個新生代容量的 90%(80%+10%),只有 10% 的記憶體會被“浪費”。當然,98% 的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於 10% 的物件存活,當 Survivor 空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保(Handle Promotion)。

8、安全點

8.1、安全點

SafePoint 安全點,顧名思義是指一些特定的位置,當執行緒執行到這些位置時,執行緒的一些狀態可以被確定(the thread’s representation of it’s Java machine state is well described),比如記錄OopMap 的狀態,從而確定 GC Root 的資訊,使 JVM 可以安全的進行一些操作,比如開始 GC 。SafePoint 指的特定位置主要有:

  • 迴圈的末尾 (防止大迴圈的時候一直不進入 Safepoint ,而其他執行緒在等待它進入 Safepoint )。
  • 方法返回前。
  • 呼叫方法的 Call 之後。
  • 丟擲異常的位置。

8.2、安全區域

安全點完美的解決了如何進入GC問題,實際情況可能比這個更復雜,但是如果程式長時間不執行,比如執行緒呼叫的sleep方法,這時候程式無法響應JVM中斷請求這時候執行緒無法到達安全點,顯然JVM也不可能等待程式喚醒,這時候就需要安全區域了。安全區域是指一段程式碼片中,引用關係不會發生變化,在這個區域任何地方GC都是安全的,安全區域可以看做是安全點的一個擴充套件。執行緒執行到安全區域的程式碼時,首先標識自己進入了安全區域,這樣GC時就不用管進入安全區域的線層了,線層要離開安全區域時就檢查JVM是否完成了GC Roots列舉,如果完成就繼續執行,如果沒有完成就等待直到收到可以安全離開的訊號。

9、JVM 垃圾回收器

9.1、Serial (新生代)

  • 最基本的單執行緒垃圾收集器。使用一個CPU或一條收集執行緒去執行垃圾收集工作。

  • 工作時會Stop The World,暫停所有使用者執行緒,造成卡頓。適合執行在Client模式下的虛擬機器。

  • 用作新生代收集器,複製演算法。

9.2、ParNew(新生代)

  • Serial收集器的多執行緒版本,和Serial的唯一區別就是使用了多條執行緒去垃圾收集。

  • 除了Serial,只有它可以和CMS搭配使用的收集器。

  • 用作新生代收集器,複製演算法。

9.3、Parallel Scavenge(新生代)

用作新生代收集器,複製演算法。關注高吞吐量,可以高效率地利用CPU時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。Parallel Scavenge收集器提供了兩個引數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數。

9.4、Serial Old(老年代)

  • Serial收集器的老年代版本,單執行緒,標記-整理 演算法。

  • 一般用於Client模式的虛擬機器。

  • 當虛擬機器是Server模式時,有2個用途:一種用途是在JDK 1.5以及之前的版本中與Parallel Scavenge收集器搭配使用 ,另一種用途就是作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用。

9.5、Parallel Old(老年代)

  • Parallel Scavenge收集器的老年代版本,使用多執行緒和 標記-整理 演算法。在JDK 1.6中開始提供。在注重吞吐量的場合,配合Parallel Scavenge收集器使用。

9.6、CMS(Concurrent Mark Sweep)(老年代)

  • 一種以獲取最短回收停頓時間為目標的收集器。適合需要與使用者互動的程式,良好的響應速度能提升使用者體驗。

  • 基於 標記—清除 演算法。適合作為老年代收集器。

  • 收集過程分4步:

  1. 初始標記(CMS initial mark):只是標記一下GC Roots能直接關聯到的物件,速度很快,會Stop The World。

  2. 併發標記(CMS concurrent mark):進行GC Roots Tracing(可達性分析)的過程。

  3. 重新標記(CMS remark):會Stop The -World。為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般比初始標記階段稍長些,但遠比並發標記的時間短。

  4. 併發清除(CMS concurrent sweep):回收記憶體。

耗時最長的併發標記和併發清除過程收集器執行緒都可以與使用者執行緒一起工作,所以時併發執行的。

缺點:

  • 併發階段,雖然不會導致使用者執行緒暫停,但會佔用一部分執行緒(CPU資源),導致應用變慢,吞吐量降低。預設啟動收集執行緒數是(CPU數量+3)/4。即當CPU在4個以上時,併發回收時垃圾收集執行緒不少於25%的CPU資源,並且隨著CPU數量的增加而下降。但是當CPU不足4個(譬如2個)時,CMS對使用者程式的影響就可能變得很大。

  • 無法清除浮動垃圾。併發清除階段,使用者執行緒還在執行,還會產生新垃圾。這些垃圾不會在此次GC中被標記,只能等到下次GC被回收。

  • 標記-清除 演算法會產生大量不連續記憶體,導致分配大物件時記憶體不夠,提前觸發Full GC。

9.7、G1

-XX:G1HeapRegionSize

 

  • E:eden區,新生代

  • S:survivor區,新生代

  • O:old區,老年代

  • H:humongous區,用來放大物件。當新建物件大小超過region大小一半時,直接在新的一個或多個連續region中分配,並標記為H

可預測的停頓時間:估算每個region內的垃圾可回收的空間以及回收需要的時間(經驗值),記錄在一個優先列表中。收集時,優先回收價值最大的region,而不是在整個堆進行全區域回收。這樣提高了回收效率,得名:Garbage-First。G1中有2種GC:

young GC:新生代eden區沒有足夠可用空間時觸發。存活的物件移到survivor區或晉升old區。mixed GC:當old區物件很多時,老年代物件空間佔堆總空間的比值達到閾值(-XX:InitiatingHeapOccupancyPercent預設45%)會觸發,它除了回收年輕代,也回收 部分 老年代(回收價值高的部分region)。

mixed GC回收步驟:

  1. 初始標記(Initial Marking):只是標記一下GC Roots能直接關聯到的物件,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段使用者程式併發執行時,能在正確可用的Region中建立新物件。這階段需要停頓執行緒(STW),但耗時很短,共用YGC的停頓,所以一般伴隨著YGC發生。

  2. 併發標記(Concurrent Marking):進行可達性分析,找出存活物件,耗時長,但可與使用者執行緒併發執行。

  3. 最終標記(Final Marking):修正併發標記階段使用者執行緒執行導致的變動記錄。會STW,但可以並行執行,時間不會很長。

  4. 篩選回收(Live Data Counting and Evacuation):根據每個region的回收價值和回收成本排序,根據使用者配置的GC停頓時間開始回收。

 

當物件分配過快,mixed GC來不及回收,G1會退化,觸發Full GC,它使用單執行緒的Serial收集器來回收,整個過程STW,要儘量避免這種情況。

當記憶體很少的時候(存活物件佔用大量空間),沒有足夠空間來複制物件,會導致回收失敗。這時會保留被移動過的物件和沒移動的物件,只調整引用。失敗發生後,收集器認為存活物件被移動了,有足夠空間讓應用程式使用,於是使用者執行緒繼續工作,等待下一次觸發GC。如果記憶體不夠,就會觸發Full GC。

9.8、ZGC

在JDK 11當中,加入了實驗性質的ZGC。它的回收耗時平均不到2毫秒。它是一款低停頓高併發的收集器。ZGC幾乎在所有地方併發執行的,除了初始標記的是STW的。所以停頓時間幾乎就耗費在初始標記上,這部分的實際是非常少的。那麼其他階段是怎麼做到可以併發執行的呢?

ZGC主要新增了兩項技術,

  • 著色指標Colored Pointer,

  • 讀屏障Load Barrier。

ZGC 是一個 併發、基於區域(region)、增量式壓縮 的收集器。Stop-The-World 階段只會在根物件掃描(root scanning)階段發生,這樣的話 GC 暫停時間並不會隨著堆和存活物件的數量而增加。

處理階段:

  • 標記(Marking);

  • 重定位(Relocation)/壓縮(Compaction);

  • 重新分配集的選擇(Relocation set selection);

  • 引用處理(Reference processing);

  • 弱引用的清理(WeakRefs Cleaning);

  • 字串常量池(String Table)和符號表(Symbol Table)的清理;

  • 類解除安裝(Class unloading)

著色指標Colored Pointer

ZGC利用指標的64位中的幾位表示Finalizable、Remapped、Marked1、Marked0(ZGC僅支援64位平臺),以標記該指向記憶體的儲存狀態。

相當於在物件的指標上標註了物件的資訊。注意,這裡的指標相當於Java術語當中的引用。

在這個被指向的記憶體發生變化的時候(記憶體在Compact被移動時),顏色就會發生變化。

由於著色指標的存在,在程式執行時訪問物件的時候,可以輕易知道物件在記憶體的儲存狀態(通過指標訪問物件),

讀屏障Load Barrier

若請求讀的記憶體在被著色了,那麼則會觸發讀屏障。讀屏障會更新指標再返回結果,此過程有一定的耗費,從而達到與使用者執行緒併發的效果。

與標記物件的傳統演算法相比,ZGC在指標上做標記,在訪問指標時加入Load Barrier(讀屏障),比如當物件正被GC移動,指標上的顏色就會不對,這個屏障就會先把指標更新為有效地址再返回,也就是,永遠只有單個物件讀取時有概率被減速,而不存在為了保持應用與GC一致而粗暴整體的Stop The World。

 

 

關注公眾號:Java架構師聯盟,每日更新技術好文

相關文章