JVM 自動記憶體管理機制及 GC 演算法

Snake_sss發表於2018-12-30

讀書筆記,如需轉載,請註明作者:Yuloran (t.cn/EGU6c76)

前言(Preface)

《Java 虛擬機器規範》讀書筆記,部分內容摘自 The Java® Virtual Machine Specification Java SE 11 EditionJava Garbage Collection Basics ,部分內容摘自《深入理解Java虛擬機器_JVM高階特性與最佳實踐 第2版》(周志朋著)。

首先,我們們要明確一個概念:《Java 虛擬機器規範》是獨立於具體的程式語言以及具體的虛擬機器實現的,Java 只是大家最為熟悉的一種 JVM 程式語言而已,目前比較火的其它 JVM 語言 還有:

實現這種語言無關性的基石就是平臺無關的程式儲存格式 - 位元組碼(ByteCode),即二進位制檔案 *.Class:

jvm language

另外,《Java 虛擬機器規範》也沒有說明 GC 該如何實現,所以在闡述 Java 虛擬機器的 GC 演算法、GC 收集器時,應當指明具體的虛擬機器。原文:

THIS document specifies an abstract machine. It does not describe any particular implementation of the Java Virtual Machine. To implement the Java Virtual Machine correctly, you need only be able to read the class file format and correctly perform the operations specified therein. Implementation details that are not part of the Java Virtual Machine's specification would unnecessarily constrain the creativity of implementors. For example, the memory layout of run-time data areas, the garbage-collection algorithm used, and any internal optimization of the Java Virtual Machine instructions (for example, translating them into machine code) are left to the discretion of the implementor.

譯文:

本文闡述了一個抽象機器。並未闡述任何 Java 虛擬機器的特定實現。為了正確實現虛擬機器,你只需要能夠讀取 Class 檔案格式並執行其中的指定操作即可。實現細節並不是 Java 虛擬機器規範的一部分,因為這可能會約束實現者的創造力。比如,執行時資料區的記憶體佈局、GC 演算法以及任何 Java 虛擬機器指令集的內部優化(比如,翻譯成機器碼),都由實現者自行決定。

收購 Sun 使 Oracle 有了兩種主要的 Java 虛擬機器 (JVM) 實現,即 Java HotSpot VM 和 Oracle JRockit JVM。未作特殊說明,本文皆以 HotSpot JVM 為例來闡述《Java 虛擬機器規範》的具體實現。

HotSpot JVM Architecture

摘自 Java SE HotSpot 概覽Java Garbage Collection Basics

HotSpot 虛擬機器是 Java SE 平臺的一個核心元件,是 Java 虛擬機器規範的實現之一,並作為 JRE 的一個共享庫來提供。作為 Java 位元組碼執行引擎,它在多種作業系統和架構上提供 Java 執行時設施,如執行緒和物件同步。它包括自適應將 Java 位元組碼編譯成優化機器指令的動態編譯器,並使用為降低暫停時間和吞吐量而優化的垃圾收集器來高效管理 Java 堆。

HotSpot 虛擬機器可根據平臺配置,選擇合適的編譯器、Java 堆配置和垃圾收集器,以保證為大多數應用程式提供優良效能。下圖為 HotSpot 虛擬機器的架構:

HotSpot JVM Architecture

其主要元件包括:Class Loader, Runtime Data Areas 和 Execution Engine.

注:上圖 Run-Time Data AreasJava Threads 指的是 Java Virtual Machine StacksNative Internal Threads 指的是 Native Method Stacks

Runtime Data Areas

為了便於理解,本人結合 JVM 規範,將上圖的 Runtime Data Areas 重新繪製如下:

Java Virtual Machine Runtime Data Areas

上圖為 JVM 規範的闡述,無關具體的虛擬機器。比如,Native Method Stacks 可能不存在,HotSopt JVM 就將 JVM Stacks 與 Native Method Stacks 合二為一了。

The PC Register

Java 虛擬機器支援多執行緒併發執行,每個執行緒都有自己的程式計數器(Program Counter Register)。任何確定的時刻,JVM 執行緒只能執行一個方法,稱為當前方法。如果當前方法是 Java 方法,那麼程式計數器裡儲存的就是 JVM 當前正在執行的指令的地址。如果當前方法是本地方法,程式計數器的值則為空(Undefined)。程式計數器是一塊較小的記憶體空間,可以看作是當前執行緒所執行的位元組碼的行號指示器,也是唯一一個不會產生 OutOfMemoryError 的區域。

Java Virtual Machine Stacks

每個 JVM 執行緒都用於一個私有的 Java 虛擬機器棧,它隨著程式的建立而分配,隨著程式的退出而銷燬。Java 虛擬機器棧,描述的是 Java 方法執行的記憶體模型,所以也可以稱之為 Java 方法棧。每次 Java 方法執行時都會建立一個棧幀,棧幀是描述虛擬機器進行方法呼叫和方法執行的資料結構,用於儲存方法的區域性變數表、運算元棧、動態連線、方法返回地址和一些附加資訊。方法從呼叫至完成的過程,對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

在編譯程式程式碼的時候,棧幀中需要多大的區域性變數表,多深的運算元棧都已經完全確定,並寫入到方法表的 Code 屬性之中。因此一個棧幀需要分配多少記憶體,不會受到程式執行期變數資料的影響,僅取決於具體的虛擬機器實現。

棧幀的概念結構

區域性變數表

用於儲存方法引數和方法內定義的區域性變數,以 Variable Slot 為最小單位,可以存放一個 boolean、byte、char、short、int、float、reference 或 returnAddress 型別(可按照 Java 語言對應的型別理解,但本質上是不一樣的)的資料。

  • reference 型別:虛擬機器規範既沒有說明它的長度,也沒有明確指出這種引用應有怎樣的結構。但是虛擬機器的實現至少要做到兩點:
    • 可以從此引用中直接或間接查詢到物件在 Java 堆中的資料存放地址的起始地址索引;
    • 可以直接或間接從此引用查到物件所屬資料型別在方法區中儲存的型別資訊。
  • returnAddress 型別:指向一條位元組碼指令的地址,目前已經很少用了,很古老的虛擬機器曾用來實現異常處理。

在 Java 程式編譯為 Class 檔案時,就在方法的 Code 屬性的 max_locals 資料項中確定了該方法所需要分配的區域性變數表的最大容量。

運算元棧

也常稱為操作棧,其最大深度在編譯期寫入 Code 屬性的 max_stacks 中。棧元素可以是 Java 語言的任意資料型別。在方法剛執行時,該棧是空的。在方法執行過程中,會有各種位元組碼指令對操作棧進行讀寫操作,比如算術運算是通過操作棧來完成的,或者呼叫其它方法時,使用操作棧來傳遞引數。

動態連線

位元組碼中的方法呼叫指令以常量池中指向該方法的符號引用作為引數,這些符號引用有一部分會在類載入階段或第一次使用時轉為直接引用,還有一部分在執行期才轉為直接引用,這稱為動態連線。每個棧幀中都包含一個指向執行時常量池中該棧幀所屬方法的引用,以實現動態連線。

方法返回地址

方法退出後,需要返回方法被呼叫的位置。方法正常退出時,呼叫者的 PC 值可作為返回地址,棧幀中很可能儲存了這個值。方法異常退出時,返回地址需要通過異常處理器表來確定,棧幀中一般不會儲存這部分資訊。

附加資訊

虛擬機器規範裡沒有描述的資訊,比如除錯資訊等。

可能產生的異常:

  • StackOverflowError:執行緒請求的棧深度大於虛擬機器棧深度,就會丟擲該異常
  • OutOfMemoryError:如果虛擬機器棧支援動態擴充套件,但是擴充套件時申請不到足夠記憶體,或者建立執行緒時沒有足夠記憶體初始化虛擬機器棧,就會丟擲該異常

Native Method Stacks

與 Java 虛擬機器棧非常相似,只不過是描述 Java 本地方法執行的記憶體模型,所以稱之為本地方法棧。雖然虛擬機器規範沒有規定本地方法棧中方法使用的語言、使用方式和與資料結構,不過一般指 C 語言。說到這兒,就不得不說,其實從程式角度來看,無論什麼程式語言編寫的程式,其記憶體模型或者說記憶體佈局都差不多。下圖為 Linux 程式中,C 程式的記憶體佈局

A typical memory layout of a running process

Java 本地方法棧在功能上就類似於 C 程式的 C Stack。同樣,Java 本地方法棧也會丟擲 StackOverflowError 和 OutOfMemoryError。

Method Area

Java 方法區是所有執行緒共享的,在功能上類似於 C 程式的 Text Segment。該區域儲存了執行時常量池、已載入的 Class、靜態變數的引用、成員變數和成員方法的引用,以及即時編譯器(JIT Compiler)編譯後的程式碼等資料。

方法區在虛擬機器啟動時建立。雖然邏輯上屬於 Java Heap(GC重點區域),但是可以不實現垃圾回收或者壓縮整理。方法區的大小可以是固定的、動態擴充套件的和可壓縮的(無需這麼大的方法區時),而且記憶體不要求連續。

HotSpot 虛擬機器將 GC 分代收集擴充套件到了方法區,或者說他們用永久(Permanent Generation)代實現了方法區。這會出現一些問題,一是方法區的 GC 沒什麼效果,二是永久代記憶體有上限,容易出現 OOM,三是極少數方法會因為這個原因在不同虛擬機器下有不同表現,比如String.intern():

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        String str1 = new StringBuilder("計算機").append("軟體").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}
複製程式碼

這段程式碼在 JDK 1.6 中執行會得到兩個 false,在 JDK 1.7 中執行,會得到一個 true,一個 false。原因是:JDK 1.6 中會將首次遇到的字串例項複製到永久代,返回的也是永久代中這個字串例項的引用,而 StringBuilder 建立的字串例項位於 Java 堆,所以必然不是同一個引用。而 JDK 1.7(已經將字串常量池移出了永久代) 的 intern() 則不會再複製例項,只是在常量池中記錄首次出現的例項引用,所以 intern() 返回的引用和 StringBuilder 建立的字串例項的引用是同一個。而 ”java“ 這個字串虛擬機器啟動時,已經由 JDK 的 Version 類載入到常量池了,所以不是同一個引用。

方法區無法滿足記憶體分配需求時,將會丟擲 OutOfMemoryError。

Run-Time Constant Pool

執行時常量池是方法區的一部分。Class 檔案除了有類的版本、欄位、方法、介面等描述資訊,還有一項資訊是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量(整型字面量、字串字面量等)和符號引用,類似於 C 程式的 Symbol Table,不過資料型別要比 C 語言豐富的多,這部分內容將在類載入後,進入方法區的常量池。

Java 虛擬機器對 Class 檔案的每一部分都有嚴格規定,每一個位元組用於存放哪種資料都必須符合規範才能被虛擬機器認可、裝載和執行。但是對於執行時常量池卻沒有任何細節要求, 所以執行時常量池被實現成了具備動態性,即常量也可以在執行時生成,比如 String 的 intern() 方法。

當執行時常量池無法再申請到記憶體時,會丟擲 OutOfMemoryError。

Heap

Java 堆是所有 JVM 執行緒共享的區域。所有類的例項以及陣列都在堆上分配。

Java 堆在虛擬機器啟動時建立。堆上的物件由垃圾收集器(Garbage Collector)自動管理。

從記憶體回收角度看,由於現在垃圾收集器大多采用分代收集演算法,所以 Java 堆還可以細分為年輕代(Young Generation)和老年代(Old Generation),其中年輕代還可以按 8:1:1 的比例再分為 Eden、From Survivor、To Survivor。

從記憶體分配角度看,Java 堆可能劃分出多個執行緒私有的記憶體分配緩衝區(Thread Local Allocation Buffer,TLAB)。

Java 堆記憶體不要求物理上連續,只要邏輯上連續即可。如果堆中沒有記憶體完成例項分配,且堆也無法繼續擴充套件時,將丟擲 OutOfMemoryError。

Automatic Garbage Collection

不像 C 或 C++,記憶體由開發人員手動分配和釋放,JVM 中的記憶體由垃圾收集器自動管理,基本步驟如下:

Step1:標記(Mark)

標記哪些物件是被使用的,哪些是不再使用的:

JVM 自動記憶體管理機制及 GC 演算法

Step2:清除(Sweep)

刪除不再引用的物件,保留存活的物件,並維護一個空閒記憶體的引用列表 :

JVM 自動記憶體管理機制及 GC 演算法

Step2a:清除並整理(Sweep-Compact)

為了提高效能,有些收集器使用 "標記-清除-整理" 演算法來回收記憶體。將仍然存活的物件移至一端,以便下次更容易找到連續可用記憶體:

JVM 自動記憶體管理機制及 GC 演算法

Generational Garbage Collection

使用 "標記-清除" 法回收物件的效率是比較低的,尤其是在物件越來越多的時候,將需要更長的時間來執行垃圾回收。這是很恐怖的,因為 GC 觸發時,Java 程式需要被凍結,否則物件的引用關係將無法追蹤。研究表明,大部分物件的存活時間都很短。所以現在的 JVM 大多采用分代收集演算法來提高效能:

JVM 自動記憶體管理機制及 GC 演算法

年輕代(Young Generation)

所有新物件分配和變老的地方。年輕代用完時,將會觸發一次 Minor Garbage Collection。GC 後,仍然存活的物件會變老並最終進入老年代。年輕代使用 "標記-複製" 法進行 GC。

Stop the World Event:所有的 Minor Garbage Collection 都是 "Stop the World Event",這意味著所有的執行緒都要暫停直至 GC 完成。

老年代(Old Generation)

用來存放長時間存活的物件。通常,年輕代會設定一個年齡閾值,當物件年齡超過這個閾值時,就會被移至老年代。最終老年代的物件也會被回收,稱之為 Major Garbage Collection,它也是 "Stop the World Event" 。通常,Major GC 是比較慢的,因為涉及到所有的存活物件。所以,HotSpot 虛擬機器同時使用多種垃圾收集器,來降低 GC 時間。老年代使用 "標記-整理" 法進行 GC。

永久代(Permanent generation)

對於 HotSpot 虛擬機器來說,就是方法區。該區域具備動態性,可以在執行時新增常量,也可以對不再使用的常量進行丟棄,對不再使用的類進行解除安裝,但是類的解除安裝條件非常苛刻:

  1. 該類所有的例項都被回收;
  2. 載入該類的 ClassLoader 已經被回收;
  3. 該類對應的 Class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

分代收集的步驟

Step1. 新分配的物件進入 Eden 空間,兩個 Survivor 開始時都是空的:

JVM 自動記憶體管理機制及 GC 演算法

Step2. 當 Eden 空間滿時,觸發一次 Minor GC:

JVM 自動記憶體管理機制及 GC 演算法

Step3. 仍然存活的物件移至 Survivor 空間,年齡為1,不再引用的刪除,清理 Eden 空間:

JVM 自動記憶體管理機制及 GC 演算法

Step4. 下一次 Minor GC 觸發時,重複以上操作。不過這次需要將仍然存活的物件移至另一個 Survivor 空間,並且在上一次 Minor GC 中存活下來的物件的年齡要 +1。然後清理原來的 Survivor 和 Eden 空間:

JVM 自動記憶體管理機制及 GC 演算法

Step5. 下一次 Minor GC 觸發時,重複以上操作,即切換 Survivor 空間,年齡自增等:

JVM 自動記憶體管理機制及 GC 演算法

Step6. 多次 Minor GC 觸發後,部分存活物件的年齡超過了年輕代的年齡閾值(這裡假設為8),晉升為老年代:

JVM 自動記憶體管理機制及 GC 演算法

Step7. 隨著 Minor GC 不斷觸發,年輕代的存活物件也不斷的晉升為老年代:

JVM 自動記憶體管理機制及 GC 演算法

Step8. 如上過程幾乎涵蓋了年輕代的整個過程。最終,老年代也會觸發 Major GC 來進行垃圾回收:

JVM 自動記憶體管理機制及 GC 演算法

物件分析

物件訪問定位

Java 程式通過棧上的 reference 來操作堆上的具體物件。由於 JVM 規範只規定了 Reference 型別是一個指向物件的引用,並沒有定義這個引用該通過何種方式去定位、訪問堆中的物件的具體位置。目前主流的訪問方式有使用控制程式碼和直接指標兩種:

  • 使用控制程式碼:

JVM 自動記憶體管理機制及 GC 演算法

  • 直接指標:

JVM 自動記憶體管理機制及 GC 演算法

HotSpot 虛擬機器使用的是直接指標方式,最大好處就是速度更快,節省了一次指標定位的開銷。

物件引用分析

  • 引用計數法:兩個物件相互引用時,無法判斷其中物件是否不再使用
  • 可達性分析法:引入 GC Roots 概念,如果一個物件到 GC Roots 沒有任何引用鏈,這個物件就是可以被回收的。在 Java 中,可作為 GC Roots 的物件有:
    • Java 方法棧中引用的物件
    • 方法區中類靜態屬性引用的物件
    • 方法區常量引用的物件
    • 本地方法棧中引用的物件

引用細分

  • 強引用:類似於 "Object obj = new Object();" 都是強引用,GC 永遠不會回收;
  • 軟引用:有用但不必要的物件,在發生 OOM 之前,會對這些物件進行第二次回收,如果回收後,記憶體仍然不足,才丟擲 OOM。實現類為 SoftReference;
  • 弱引用:比軟引用弱,只能活到下一次 GC 前。下次 GC 發生時,無論記憶體是否緊張,都會回收掉只被弱引用關聯的物件。實現類為 WeakReference
  • 虛引用:最弱的一種引用關係,無法通過它獲取被引用物件。唯一作用就是被回收時收到一個系統通知。實現類為 PhantomReference

finalize()

Java 程式設計規範中明確指出,不要重寫 finalize() 方法,除非你知道自己在幹什麼。finalize() 方法只會被 JVM 執行一次。一個物件被第一次被標記為死亡時,會進行一次篩選,篩選條件是是否需要執行 finalize() 方法,如果需要(物件重寫了 finalize() 方法),這個物件就會被扔到一個叫做 F-Queue 的佇列中,等待有 JVM 自動建立的、低優先順序的 Finalizer 執行緒去執行。稍後,GC 將對 F-Queue 中的物件進行第二次小規模的標記,如果此時還沒有逃脫(在 finalize() 中 將 this 賦給其他類的成員變數),基本上就真的被回收了。

Garbage Collectors

Java 垃圾收集器有很多,JDK 1.7 Update 14 之後的 HotSpot JVM 就同時有 7 個垃圾收集器,而且年輕代和老年代用的收集器還不一樣。為什麼要用這麼多的垃圾收集器呢?就是為了提高虛擬機器的效能。不過無論怎麼優化, "Stop The World" 都是無法避免的,時間長短而已。Android 的 Dalvik 或者 ART 虛擬機器也是如此。所以一是要減少 GC 的時間,二是避免頻繁觸發 GC。

HotSpot 虛擬機器在 JDK 1.7 Update 14 之後使用的垃圾收集器:

JVM 自動記憶體管理機制及 GC 演算法

連線表示可以搭配使用。

Serial

發展歷史最悠久的單執行緒收集器,使用 "標記-複製" 演算法。GC 時,必須暫停其它所有工作執行緒,直至 GC 結束。

ParNew

是 Serial 收集器的多執行緒版本,使用 "標記-複製" 演算法。

Parallel Scavenge

類似於 ParNew 收集器,不過關注點是達到一個可控制的吞吐量,使用 "標記-複製" 演算法。

Serial Old

Serial 收集器的老年代版本,使用 "標記-整理" 演算法。

Parallel Old

Parallel Scavenge 的老年代版本,使用 "標記-整理" 演算法。

CMS

Concurrent Mark Sweep,是一種以獲取最短回收停頓時間為目標的收集器,使用 "標記-清除" 演算法。

G1

Garbage First,是當今收集器技術發展的最前沿成果之一,是一款面向服務端應用的收集器。具有並行與併發、分代收集、空間整合和可預測的停頓等特點。

如何獲取 JVM 規範?

  1. 進入 Oracle 官網,按圖所示:

JVM 自動記憶體管理機制及 GC 演算法

  1. 點選 Java SE documentation

JVM 自動記憶體管理機制及 GC 演算法

  1. 點選 Language and VM

JVM 自動記憶體管理機制及 GC 演算法

  1. 選擇版本

JVM 自動記憶體管理機制及 GC 演算法

相關文章