JVM 記憶體管理與 GC

Vivi Wang
14 min readApr 3, 2024

對於 Java 工程師來說,Java 記憶體交由虛擬機自動管理。出現問題時,若不了解虛擬機器如何運作會很難排查錯誤。

目錄
JVM 記憶體管理
JVM 建立物件過程
JVM 的垃圾回收機制 (GC)

JVM 記憶體管理

JVM Memory

在程式執行過程中,可以將 JVM 劃分為以下區域:

JVM Memory,綠色標示為每個 Thread 私有
  • Method Area: 儲存所有與類別相關的資訊,如類別名稱、方法和變數資訊等,包括靜態 (static) 變數。共享資源
  • Heap: 所有物件的資訊都存儲在堆 (Heap) 中,在程式中的 instances objects、instance variables 都會被儲存在 heap 直到不被使用為止。Java heap 是垃圾收集器管理的主要區域,因此也被稱作 GC 堆 (Garbage Collected Heap)。共享資源
  • Stack area: 對於每個執行緒 (thread),JVM 會創建一個 run-time stack 用來儲存函數路徑及區域變數,每個 thread 之間的 stack 相互獨立。Thread 私有
JVM Stack 運作示意圖
每個執行緒被分配的虛擬機堆疊大小可以透過參數 -Xss 來指定。
如果堆疊大小已經指定,但執行緒在該方法中使用的堆疊空間超出允許範圍,
將引發 StackOverflowError。

大多數情況下,虛擬機堆疊大小可以動態擴展。如果擴展至過大,
超出允許的內存範圍,則會引發 OutOfMemoryError。
  • PC Registers: 每個執行緒都有自己的 PC Registers,它儲存著該執行緒目前所執行的指令位址。Thread 私有
PC Registers 主要用途有:
1. 追蹤執行位置:PC 寄存器使得 JVM 能夠跟蹤每個執行緒正在執行的指令位置,這對於確保指令的正確執行至關重要。
2. 執行指令:PC 寄存器存儲著下一個將要被執行的指令的位址,從而使得 JVM 能夠繼續執行程式碼。
3. 分支和迴圈:PC 寄存器在控制流程中起著關鍵作用,例如當程序遇到分支或迴圈時,PC 寄存器將指向正確的指令位置。

程式計數器是唯一一個不會出現 OutOfMemoryError 的記憶體區域,
它的生命週期隨著執行緒的建立而建立,隨著執行緒的結束而死亡。
  • Native method stacks: 當 Java 程式呼叫本地方法時,JVM 會將執行緒的狀態保存在 Native method stack 中,包括方法呼叫、參數和本地方法的執行狀態。這些資訊允許 JVM 與本地程式碼進行交互,並將控制權轉移給本地程式碼執行相應的操作。Thread 私有

JVM stack/heap

簡單用圖說明 Java 初心者需要搞懂的 JVM stack/heap 概念(簡略版):

複習時間

字串操作

String str1 = "hey";
String str2 = new String("hey");
System.out.println(str1==str2); //false

String str1 = "haha";
String str2 = "hoho";
String str3 = "haha" + "hoho"; //String Pool 中的物件
String str4 = str1 + str2; //在堆上建立的新的物件
String str5 = "hahahoho"; //String Pool 中的物件
System.out.println(str3 == str4); //false
System.out.println(str3 == str5); //true
System.out.println(str4 == str5); //false

基本型別

Integer i1 = 66;
Integer i2 = 66;
System.out.println(i1 == i2); //輸出true

Integer i11 = 666;
Integer i22 = 666;
System.out.println(i11 == i22); //輸出false,預設建立數值[-128,127]的相應型別的快取資料,但是超出此範圍仍然會去建立新的物件

Double i3 = 1.5;
Double i4 = 1.5;
System.out.println(i3 == i4); //輸出false,兩種浮點數型別的包裝類 Float, Double 並沒有實現常量池技術

建議延伸閱讀:Java 創建物件分析

JVM 建立物件過程

1. 類別加載檢查

虛擬機器遇到一條 new 指令時,首先將去檢查這個指令的引數是否能在常量池中定位到這個類的符號取用,並且檢查這個符號取用代表的類是否已被載入過、解析和初始化過。如果沒有,那必須先執行相應的類載入過程。

loading of java class
  1. 加載 (Loading):從硬碟讀取類檔案並將相應的二進制資料存儲在方法區 (method area),然後在 memory 中實例化一個 java.lang.Class 類的物件。對於每個類別檔案,JVM 將在方法區中存儲相應的資訊,例如:方法資訊、變數資訊、構造函數資訊和 .class 檔案代表的是類別或介面等。
  2. 驗證 (Verification):驗證階段主要確保被加載的類的正確性。這一階段的目的是為了確保 Class 檔案的位元流中包含的資訊符合當前虛擬機的要求。
  3. 準備 (Preparation):在這個階段,JVM將為類級或介面級的靜態變數分配記憶體並指定預設值。在準備階段,靜態變數 (static variables) 只會分配預設值(非開發者預設的值)。
  4. 解析 (Resolution):簡單來說,它是將我們程式中的符號名稱替換為方法區域中的原始記憶體參考的過程。
  5. 初始化 (Initialization):在初始化階段,所有靜態變數都將被分配原始值,並且靜態區塊 (static blocks) 將從父類到子類以及從頂部到底部執行。

在加載、連結和初始化過程中,如果發生任何錯誤,我們將收到運行時異常 java.lang.LinkageError 或其子類 java.lang.VerifyError

Q:How many Class class object will be created in JVM?
A: For every loaded type, only one class Object will be created,
even though we are using class multiple times in our program.
Q: Why Java is Secured Language?
A: Bytecode verifier is one of the feature which makes java a secured language.
If attackers changes the class file manually to create some kind of virus,
Bytecode verifier will detect that class file as it is not generated by valid compiler.
Verfication fails, we will get runtime exception saying java.lang.VerifyError

更詳細可參考:JVM 類加載的五個過程

2. 新物件分配記憶體

在類別加載檢查通過後,虛擬機將為新的物件分配記憶體。所需要的記憶體大小在類別加載完成後便可以完全確定 → 等同於從 Java Heap 中劃分出一塊確定大小的記憶體區塊。

分配方式有 指針碰撞 (Bump the Pointer) 以及 空閒列表(Free List) ,採哪種分配方式取決於 java heap 是否是規整的,而是否規整則取決於所使用的垃圾收集器 (GC) 是否具有空間壓縮整理的能力。

  • 指針碰撞 (Bump the Pointer):在 heap memory 中,當採用的是連續記憶體分配方式時,指針碰撞是一種常見的分配方法。它利用一個指針不斷地向高地址方向移動,以表示已經分配記憶體的部分,而未分配的部分則留給下一個記憶體分配請求。這種方式需要 heap 記憶體是連續的,並且能夠使用地址指針進行操作。
Bump the Pointer 示意圖
  • 空閒列表 (Free List):在 heap memory 不是連續的情況下,空閒列表是一種常見的記憶體分配方式。空閒列表維護了一份記憶體的列表,其中記錄了所有可用的記憶體塊的位置和大小。當有記憶體分配請求時,會遍歷這個列表,找到足夠大小的空閒記憶體塊,並將其分配給請求。分配完成後再更新空閒列表。
Free List 示意圖
Q: JVM new 對象時會發生併發競爭嗎?
A: 會,針對多線程的併發方案,可以使用:

1. CAS (Compare and swap):用分配重試的方式來保證更新操作的原子性。
操作時會輸入兩個值 (Old_A & New_B),在操作其間會比較 A & B 值有沒有變化,
直到 A == B 才會交換成 B 值。
2. TLAB (Thread Local Allocation Buffer) 本地線程分配緩衝:每個線程在 Java Heap 中
先預先分配一小塊記憶體,只有本地的 TLAB 使用完要分配新的緩衝區時才需要鎖定。

正常情況下不需要手動配置 CAS 或 TLAB。(預設通常是 TLAB)
JVM 會根據運行時的狀況和內部策略來自動管理這些機制,以提供最佳的性能和效率。

JVM 的垃圾回收機制 (GC)

前言

Java 中的 GC(Garbage Collection)是一種自動記憶體管理機制,用於自動回收不再被程式使用的記憶體,以避免記憶體洩漏 (memory leak) 和記憶體碎片化 (memory fragmentation)。

當 Java 程式運行時創建了大量的對象,但這些對象不再被程式碼所引用時,它們就會成為垃圾(garbage)。GC 負責定期尋找和回收這些垃圾對象,並將其所占用的記憶體釋放出來,以便後續的使用。

JVM 的 GC 演算法

  • 標記清除算法 (Mark-sweep):主要分為兩步驟,標記需要回收的對象以及清除標記的對象。缺點:如果 heap 中有大量需要回收的,這時必須進行大量的標記&清除,執行效率會隨數量增長而降低;另一方面是較容易產生空間碎片化的問題。
Mark-sweep 示意圖
  • 標記複製算法 (Copying collectors):將可用記憶體按照容量大小分割為同等的兩塊,每次只使用其中的一塊,當其中一塊使用完,就將還存活的對象複製到另一塊上,再清除可回收區,缺點:空間浪費。(主要用於新生代)
Copying collectors 示意圖
  • 標記壓縮算法 (Mark-compact collectors):步驟和標記清除算法類似,然而在清除時不是直接清理可回收對象,會是讓所有還存活的對象往記憶體的同一端移動,再清理掉邊界外的記憶體。此種操作需要 STW (Stop The World),是極為負重的操作。(主要用於老生代)
Mark-compact collectors 示意圖
快速筆記:
- 標記算法(標記要清除的,會產生大量碎片)
- 複製算法(將內存區域分成大小相等兩份,每次只使用一半,問題為浪費空間)
- 標記壓縮算法(標記階段會標記要清除的,標記後會先把存活對象移動到邊界內,邊界外的清除)

JVM GC 運行時 Memory

因為 JVM 是採用分代回收的算法,即根據對象的生命周期進行區分,並進行分代存儲和回收,其主要分為 新生代 (Young Generation)老生代 (Old Generation)終生代(JDK 1.8 後已經移除)

新生代 (Young Generation)

用來存放新生的對象。一般占據堆的 1/3 空間。由於頻繁創建對象,所以新生代會頻繁觸發 MinorGC 進行垃圾回收。

  • Eden 區
    Java 新對象的出生地(如果新創建的對象占用記憶體空間很大,則直接分配到老年代)。當 Eden 區記憶體不夠的時候就會觸發 MinorGC,對新生代區進行一次 GC。(MinorGC 採用複製算法)
  • From Survivor
    保留了一次 MinorGC 過程中的倖存者,作為這一次 GC 的被掃描者。
  • To Survivor
    保留了一次 MinorGC 過程中的倖存者。

老年代 (Old Generation)

當無法找到足夠大的連續空間分配給新創建的較大對象時也會提前觸發一次 MajorGC 進行垃圾回收騰出空間。(採用標記清理或標記壓縮演算法),當老生代也滿了裝不下的時候,就會拋出 OOM(Out of Memory)異常。

快速筆記:
- Java Heap 從 GC 的角度可以分為新生代跟老生代
- 新生代約佔 1/3,老生代佔 2/3 Heap(堆)空間
- 新生代又分為 Eden, From, To Survivor 三個區,預設是 15 次存活移至老生代

JVM 垃圾收集器

先來簡單回憶一下 JVM 架構

jvm

Java Heap 是在虛擬機啟動時自動創建的記憶體空間。在運行過程中,所有的對象和陣列都被放置在這個空間裡,若未及時清理可能導致 OOM。因此需要在一個物件不再被使用時,將其從 Heap 中清除。

在進行垃圾回收時,通常會強制暫停所有應用程式,造成 STW (Stop The Word),這將影響系統的整體運行。

哪些東西需要被回收

如何去判斷哪些對象需要被回收呢?有兩種比較經典的判斷策略:引用計數器法以及可達性分析法。

  • 引用計數器法 (Reference Counting Collector)

當一個物件被建立後,系統會為這個物件初始化一個引用計數器。當這個物件被引用時,計數器就會加 1;當該引用失效後,計數器則減 1。直到計數器為 0,表示該物件不再被使用,此時就可以將其回收。此方法有一個致命的缺點:無法避免循環依賴的問題,當程式中發生循環依賴,便無法釋放記憶體。

引用計數器法
優點:簡單快速
缺點:無法避免循環依賴的問題
  • 根搜索算法/可達性分析算法 (GCRoot)

GCRoot 回收算法的基本思想是從 GCRoots 出發,通过可達性分析(Reachability Analysis)來確定哪些對象可以被訪問到,從而判斷哪些對象是有被使用的,哪些對象是可以被回收的。

GCRoot 示意圖

誰可以當 GCRoot?

  1. 虛擬機堆疊中(堆疊框架中的本地變數表)引用的物件
  2. 方法區中類的靜態屬性引用的物件
  3. 方法區中常數引用的物件
  4. 本地方法堆疊中 JNI(即一般所說的本機方法)的引用物件
  5. 被同步鎖持有的物件:被 synchronized 鎖住的物件也是絕對不能回收的,當前有線程持有物件鎖,如果GC回收了物件,鎖就失效了。

常見收集器

G1 (Garbage-First Garbage Collector): 在 JDK 1.7 時引入,在 JDK 9 時取代 CMS 成為了預設的垃圾收集器,G1通過將堆劃分為多個區域(Region)來實現垃圾收集,它以低延遲和高吞吐量為目標,它採用了全局並行和局部並行的方式進行垃圾收集,以減少停頓時間。

ZGC (The Z Garbage Collector): 是 JDK11 推出的一款低延遲垃圾收集器,以極低停頓時間為目標的垃圾收集器,它適用於大內存和高性能的應用。

詳細說明可以參考:深入理解 JVM 的垃圾收集器:CMS, G1, ZGC

小結

先補上基礎 JVM 的知識,覺得寫的內容比較廣,有些地方不夠深入,之後有時間再慢慢補!

Sign up to discover human stories that deepen your understanding of the world.

Vivi Wang
Vivi Wang

Written by Vivi Wang

一些簡單基礎小筆記

No responses yet

Write a response