---
title: 'Java GC、內存分配'
disqus: kyleAlien
---
Java GC、內存分配
===
## OverView of Content
如有引用參考請詳註出處,感謝 :smile:
> GC 也就是 [**Garbage Collection**](https://zh.wikipedia.org/wiki/%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6_(%E8%A8%88%E7%AE%97%E6%A9%9F%E7%A7%91%E5%AD%B8)) 它出現的時間比 Java 還早,是一種**自動管理記憶體機制**
至於有 GC、內存動態分配後謂何還需要特別了解?
> 當我們需要排查、優化內存時,我們就需要對這些自動化技術進行監控、調節
[TOC]
## GC - 概述
Lisp 是第一門開始使用內存動態分配、內存回收的語言;初期主要思考 3 件事情
1. 哪些物件的內存需要回收
2. 何時需要觸發內存回收
3. 如何回收
## JVM 選項設置
### JVM 日誌設定
* [Android 設定](https://developer.android.com/studio/intro/studio-config#customize_vm) : `Help` > `Edit Custom VM Options`
* Eclipse 設定 : `Run` -> `Run configurations` -> `類名` -> `arguments` -> `VM arguments`
> 
### JVM 相關指令
* JVM 除了可以設定日誌選項之外,還可以有多個不同選項可以設定給 JVM
| 指令 | 功能 |
| ---------------------- | ------------------------------------- |
| -XX:+PrintGCDetails | 印出 GC 細節 |
| -XX:+PrintGCTimeStamps | 印出 GC 時間戳記 |
| -Xloggc:d:/gc.log | GC 日誌存放位子,在 D:/ |
| -verbose:gc | 開啟 GC 日誌 |
| -Xms20m | JVM 堆的最小值 |
| -Xmx20m | JVM 堆的最大值 |
| -Xmn10M | 堆內新生代的大小 |
| -XX:SurvivorRatio=8 | 新生代中 Eden : Survivor 的比例,8: 2 |
| -XX:HeapDumpOnOutOfMemoryError | 當棧溢出時輸出堆疊錯誤訊息 |
| -XX:MaxTenuringThreshold | **分代年齡控制** |
### JVM 日誌 & 測試
* JVM 日誌測試碼
```java=
public class TestFlagGC {
public static void main(String[] args) {
TestGC g1 = new TestGC();
TestGC g2 = new TestGC();
g1.instance = g2;
g2.instance = g1;
// 移除 GC ROOT
g1 = null; // 促進 GC
g2 = null; // 促進 GC
System.gc();
}
}
class TestGC {
Object instance = null;
private byte[] data = new byte[2 * 1024 * 1024];
}
```
* Eclipse 日誌
```groovy=
[0.060s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.
[0.091s][info ][gc,heap] Heap region size: 1M
[0.097s][info ][gc ] Using G1
[0.097s][info ][gc,heap,coops] Heap address: 0x0000000081c00000, size: 2020 MB, Compressed Oops mode: 32-bit
[0.342s][info ][gc,start ] GC(0) Pause Full (System.gc())
[0.342s][info ][gc,phases,start] GC(0) Phase 1: Mark live objects
[0.366s][info ][gc,stringtable ] GC(0) Cleaned string and symbol table, strings: 3081 processed, 15 removed, symbols: 20031 processed, 0 removed
[0.366s][info ][gc,phases ] GC(0) Phase 1: Mark live objects 23.512ms
[0.366s][info ][gc,phases,start] GC(0) Phase 2: Compute new object addresses
[0.366s][info ][gc,phases ] GC(0) Phase 2: Compute new object addresses 0.500ms
[0.366s][info ][gc,phases,start] GC(0) Phase 3: Adjust pointers
[0.367s][info ][gc,phases ] GC(0) Phase 3: Adjust pointers 0.685ms
[0.367s][info ][gc,phases,start] GC(0) Phase 4: Move objects
[0.368s][info ][gc,phases ] GC(0) Phase 4: Move objects 0.419ms
[0.374s][info ][gc,task ] GC(0) Using 4 workers of 4 to rebuild remembered set
[0.376s][info ][gc,heap ] GC(0) Eden regions: 2->0(3)
[0.376s][info ][gc,heap ] GC(0) Survivor regions: 0->0(0)
[0.376s][info ][gc,heap ] GC(0) Old regions: 0->1
[0.376s][info ][gc,heap ] GC(0) Humongous regions: 6->0
[0.376s][info ][gc,metaspace ] GC(0) Metaspace: 4172K->4172K(1056768K)
[0.376s][info ][gc ] GC(0) Pause Full (System.gc()) 7M->0M(8M) 33.401ms
[0.376s][info ][gc,cpu ] GC(0) User=0.00s Sys=0.02s Real=0.03s
[0.382s][info ][gc,heap,exit ] Heap
[0.382s][info ][gc,heap,exit ] garbage-first heap total 8192K, used 973K [0x0000000081c00000, 0x0000000081d00040, 0x0000000100000000)
[0.382s][info ][gc,heap,exit ] region size 1024K, 1 young (1024K), 0 survivors (0K)
[0.382s][info ][gc,heap,exit ] Metaspace used 4180K, capacity 4486K, committed 4864K, reserved 1056768K
[0.382s][info ][gc,heap,exit ] class space used 375K, capacity 386K, committed 512K, reserved 1048576K
```
> [0.342s][info ][gc,start ] GC(0) Pause Full (System.gc())
>
> 前面的 0.xxxs 是 GC 發生的時間,**發生暫停的 GC 類型是 System.gc()**
**--實做--**
> 
## 物件生死判斷
對於自動回收的關鍵問題其中一個就是,如何判斷物件是否還被需要(物件生死);以下舉幾個常見的方案
### 方案 - 引用計數 Reference Counting
* **引用計數**
原理簡單、判定效率也高,但需要耗費額外空間(小);但是最麻煩的是無法判斷 **相互引用的問題**;當對象被引用時該計數 `+1`,引用失效時計數 `-1`,當引用數計為 0 就任務該物件不會再被使用,GC 可回收
```java=
// 引用計數概念程式
public static void main(String[] args) {
A insA = new A();
B insB = new B();
// 互相引用
insA.b = insB; // insB += 1
insB.a = insA; // insA += 1
insA = null; // insB -= 1
insB = null; // insA -= 1
System.gc();
}
class A {
B b = null; // 內部持有 insB += 1
}
class B {
A a = null; // 內部持有 insA += 1
}
```
:::danger
* **引數計算法不能解決相互引用的問題**:
當對象被相互引用時,就無法確認該物件是否是可以回收的目標
> JVM Hotspot 不採用該方式判斷物件是否可回收
:::
### 方案 - 可達性分析 Reachability Analysis
> 又稱為 **`Tracing GC`**
* 選定一些對項作為 GC Roots,並組成根對象集合,當 GC 觸發時搜尋這些根結點,並往下搜尋,如果對象是不可達的代表該對象是可回收的,**分析完後就會給可回收的==對象標記==**
> 
作為 GC Roots 根對象主要有
* Java **棧中引用的物件**:也就是方法中所使用到的參數、局部變量、臨時變量
```java=
void functionObject() {
String message = new String("Hello");
System.out.println("Message: " + message);
}
Object obj = new Object();
void showObjectClz() {
Class<?> clz = obj.getClass();
System.out.println("Member class: " + clz);
}
```
* 方法區中 **靜態屬性引用的物件**
```java=
public class MyClz {
public static final Object object = new Object();
}
class EntryClz {
public static void main(String[] args) {
System.out.println(MyClz.object.getClass());
}
}
```
* 方法區中運行時 **常量池引用** 的物件
```java=
public class MyClz {
public static final String TAG = "MyTag";
}
class EntryClz {
public static void main(String[] args) {
System.out.println(MyClz.TAG);
}
}
```
* 本地方法中 JNI 引用的物件
* 運行中的線程所持有的引用
* 包括同步鎖(`synchronized`)持有的物件
* 由引導類加載器加載的對象(通常就是 JavaSE 的核心類)
* GC 控制的對象
:::warning
* **物件標記後,是否立刻被回收呢 ?**
**不會立刻回收**,首先 GC 並非運行在 MainThread,再者要看物件的引用類型、是否被拯救... 等等狀況
:::
:::success
* **完整的 GC Roots**
除了 GC Roots 以外,JVM 還會 **根據用戶所選用的 GC 回收器、當前回收區域、臨時性... 構成完整的 GC Roots**
:::
### Java 引用差異
* JDK 1.2 後就有出現不同的引用方式,**強引用、軟引用、弱引用、虛引用**,以下會使用 `System.gc()` 強制會收並觀察其引用狀況
:::info
* 請參考另外一篇 [**Java 引用差別**](https://hackmd.io/Y8kxlR5XSsiQ3uFQMcI_Og?view#Java-%E5%BC%95%E7%94%A8%E5%B7%AE%E7%95%B0)
:::
## GC 目標區塊
* 以 Java 內存分配,運行時的區域分配有兩大特點
1. **共有區**:所有線程共有區塊(**堆、方法區**);一個界面可能有多個實現,一個方法內也有可能創建多個物件
> 由於共享區 堆、棧 的不確定性,所以 **共享區 是主要回收的點**!
:::info
* 至於是否要全部回收、部份回收,則須看你選用的 GC 回收器的實做是甚麽
> eg. Hotspot 就有分代回收機制
:::
2. **私有區**:線程私有區塊(**程序計數器、本地方法棧、虛擬機棧**),這個區塊隨著線程生亡,所以每一個棧楨區的分配的內存大小是已知的
> **棧楨大小基本是編譯期間就可知的**,因此這個 **私有區塊不需要特別進行回收**!
> 
### 方法區回收
* 方法區是載入 Class 二進制檔案、常數資料的區塊,該區塊較少被回收(代表仍可能會回收);
:::info
* JVM 規範中,並沒有強制規定要回收方法區的物件
* 方法區塊的回收相較於堆區回收 CP 值較低
:::
* 如果方法區被回收的話,主要會回收兩個部份的資料
* **無用的常量**
* 在程式中沒有再被使用的常量
* **無用的類、界面**(有關到類的卸載)
* 目標類 **無任何實例(instance)或是所有的實例都已經被回收**(當然也包括類的子類)
* 加載該類的 **ClassLoader 已經被回收**(通常是自訂的 ClassLoader)
> ClassLoader 被回收後,就不會有這個類的緩存,GC Roots 就不可達
* 該類在 **堆區中的 `java.lang.Class` 物件不被任何的地方引用**
> 反射也無法訪問到
:::warning
* 並非所有的 GC 都支援類卸載;像是 **`ZGC` 不支援類卸載**
:::
### 堆區回收
* 堆區是 GC 主力作用的區域,而該區的回收機制又與使用的 GC 類型不同而有不同的管理(可稱為 **GC 收集算法**)
> 以下章節會詳細介紹
## GC 收集算法
這裡主要說明幾種假說、理論還有發展過程
### 分代收集理論 - 劃分堆區、回收方案
* 目前大多數的 GC 都遵循了 **分代收集(`Generational Collection`)** 的理論、設計;在該理論之上有兩個假說
1. **弱分代假說(`Weak Generational Hypothesis`)**:
**朝生夕滅的物件**,像是區域物件(方法內創建的物件)
> Hotspot 又將弱分代區塊稱為 **新生代**
2. **強分代假說(`Strong Generational Hypothesis`)**:
**熬過多次回收的物件**
> Hotspot 又將強分代區塊稱為 **老年代**
:::info
* 這兩個假說共同奠定了多個 GC 的設置方向;也就是 **將 Java 堆區劃分出多個不同區塊** 來回收
> **主要掃描回收弱分代,關注長期存活的物件,這樣效率更高**
:::
* 將堆區劃分出不同區塊後,GC 就可以區域性的回收,藉此產生了 **幾種不同的回收方案**:**^1.^ `Minor GC`、^2.^`Major GC`、^3.^`Mixed GC`、^4.^`Full GC`**
> 而回收方案又會影響到回收區域
``` mermaid
graph LR;
Minor_GC-->堆區_弱分代;
Major_GC-->堆區_強分代;
Mixed_GC-->堆區_弱分代;
Mixed_GC-->堆區_強分代;
Full_GC-->堆區_弱分代;
Full_GC-->堆區_強分代;
Full_GC-->方法區;
```
總結 Java 對於物件記憶體區域劃分如下,而區塊何時被回收又如上述 3 種
| 區域 | 細劃分 | Hotspot 稱呼 | 回收方案 |
| - | - | - | - |
| 堆 | 弱分代 | 新生代 | `Minor GC`、`Major GC`、`Full GC` |
| | 強分代 | 老年代 | `Mixed GC`、`Major GC` |
| 方法區 | - | 永久代 | `Full GC`|
:::info
* 而每個劃分區域使用的回收方案又不同(每種虛擬機實現的回收方案都不同),**沒有規定哪個區塊只能用指定算法**
> 之後再介紹回收算法,**這些算法都基於 ++分代收集理論++**
:::
3. **跨代引用假說(`Intergenerational Reference Hypothesis`)**:
跨代引用(eg. 弱分代使用強分代的引用)較少,而同代引用較多;也就是說 **我們不該為了少量的跨代引用去掃描整個強分代(老年代)**
因此我們在弱分代之上建立一個全局的數據結構(用來紀錄),它將強分代劃分回多個小區,標示強分代的哪個區塊會存在跨代引用
```mermaid
graph TD
subgraph 弱分代
引用
區域跨代_1
end
subgraph 強分代
區域_1
區域_2
區域_3
end
區域跨代_1 --> 區域_1
```
此後當發生 `Minor GC` 時,就只有包含掛待引用的小塊內存里的物件才會被加入到 GC Roots 掃描(也就不會整強分代都掃描)
> 這樣就不會整個強分代都掃描到
```mermaid
graph TD
subgraph 弱分代
引用
區域跨代_1
end
subgraph 強分代
區域_1
區域_2
區域_3
end
區域跨代_1 --> 區域_1
GC_Roots --> 引用;
GC_Roots --> 區域跨代_1;
```
### 分代收集理論 - 標記清除算法 (Mark-Sweep)
* 常用於**堆區**(也就是強分代),遍歷所有區塊,再清除所有的要回收的對象;這會導致兩個問題
1. **內存區塊不完整**,零碎不堪
2. 由於零碎的記憶體區塊,**導致創建物件效率太低**(創建物件要連續的記憶體區塊)
> 
### 分代收集理論 - 標記壓縮算法 (Mark-Compact)
* 常用於**堆區**(也就是強分代),標記清除算法的進階,在清除後重新排列對象的記憶體位子,**大多使用在 ==強分代==**;其特點如下
1. 對象的複製移動
2. **引用更新**(相對來講是個重量級操作)
:::info
* **Stop the World**!
在 GC 發生時 (演算法發生時),只有 GC 線程在運行,其他線程全部暫停(包括用戶線程),**等待 GC 線程執行完引用更新後才能再次運行**
> **針對不同的 GC 回收器,Stop the World 會發生在不同的時機**
**大量的暫停程序勢必會影響到用戶應用的吞吐量**
> 
:::
3. 沒有內存碎片 (但較耗時)
> 
### 分代收集理論 - 複製算法(Copying)
* 常用於**堆區 - 新生代(也就是弱分代)**
把記憶體切成兩半,當 GC 被觸發時會遍歷一半區塊,把存活的對象複製到另一半(**大多使用在生命週期不長的 ==弱分代==**);並有以下特點
* **優點**
1. 實現簡單,速度快
2. 內存複製,沒有內存碎片
* **缺點**
1. 如果存活對象過多時就會導致大量複製,效率太低
2. 對於空間的利用率只有一半(只剩下一半的空間可以儲存物件)
> 
* 由於上述的空間浪費,所以有另外針對複製算法規劃出不同的分區(不使用 1:1 的空間分配),而是 **採用別種分配方案**
以下 **以 HotSpot 的 Serial、ParNew 為例**,它把新生代(弱分代)再區分為如下空間:
1. **`Eden` 區**:比例為 8;所有的新物件都放置這個區塊
```mermaid
graph TD
subgraph 弱分代
Eden_8
end
Eden_8 --> 物件_A
Eden_8 --> 物件_B
Eden_8 --> 物件_C
```
2. **`Survior` 區**:比例為 2,其中 **又分為 `From Survior`、`To Survior`**;
* 每次物件回收後其除 `Eden` 區
```mermaid
graph TD
subgraph 弱分代
Eden_8
Survior_2
end
```
* 遺留的物件放到 `To Survior` 區塊
```mermaid
graph TD
subgraph 弱分代
Eden_8
Survior_2
end
subgraph Survior
From_Survior
To_Survior
end
To_Survior --> 物件_A
To_Survior --> 物件_B
Survior_2 --> From_Survior
Survior_2 --> To_Survior
```
:::danger
* 存活物件超出空間、或活很久?
**再待下一次回收時,如果存留下來的物件超過 10% 的空間(`To Survior` 區塊),則直接移動到老年代(強分代區塊)**
1. 存活對象超過分代年齡,也就是 **多次 Minor Collection**,移動到老年代 (`-XX:MaxTenuringThreshold`)
2. `To Survivor` **空間到達上限**,將存活對象移動到老年代
> 
:::
## GC 回收器
針對不同的堆區會有不同的收集器,而其特色又不一樣,以下主要分為新生代老年代,這樣的分法是依照 JVM 舊的劃分方式,**新的 G1 就++沒有在堆中區分++**
其配對關係如下圖,並 **不是隨意可以配對使用** (有連線才可配合使用)
> 
### 弱分代回收器
> 也就是 Hotspot 的新生代回收器
| 回收器名稱 | 算法 | 類型 |
| -------- | -------- | - |
| Serial | 複製算法 | 單線程 |
| ParNew | 複製算法 | 並行的多線程收集 (利用 CPU) |
| Parallel Scavenge | 複製算法 | 並行的多線程收集 (利用 CPU) |
* 單線程收集(`Serial`)
> 
* 多線程收集 (`ParNew`、`Parallel Scavenge`**同一進程,產生多線程**)
> 
:::info
* **What is "Safe Point"** ?
> JVM 可以快速地完成 GC Root 的初始標記,但如果 Thread 正在執行方法 (棧楨上),就需等到它完全出棧後才能清理,而出棧的點就稱為 SafePoint
以下假設 GC 發生時 Thread 正在 Function 內執行 (以下程式是假設的程式)
```java=
import java.lang.ref.WeakReference;
public class safePoint {
public int work() {
int x = 1;
int y = 2;
int z = (x+y)*10; // JVM 發生GC,但這裡並非 saftPoint !
return z;
}
public static void main(String[] args) {
safePoint s = new safePoint();
WeakReference<safePoint> ws = new WeakReference<>(s);
s = null;
int a = ws.get().work(); // 在執行完方法後才執行 GC,這裡才是 saftPoint !!
if(ws.get() == null) {
System.out.println("已回收");
} else {
System.out.println("未回收");
}
System.out.println("value of a is : " + a);
}
}
```
:::
### 強分代 - CMS
> 也就是 Hotspot 的老年代
| 回收器名稱 | 算法 | 類型 |
| -------- | -------- | - |
| Serial Old | 標記壓縮 | 單線程 |
| Parallel Old | 標記壓縮 | 並行多線程 |
| CMS | ==標記清除== | **並行 & 並發收集器** |
* 比較特殊的是 **CMS (Concurrent Mark Sweep)** 使用**標記清除 (無壓縮)**,其注重在反應時間,使用並發的特性產生多的 GC 線程,並**經歷 4 個過程**
1. 初始標記 Stop the world 停止其他線程
2. 並發標記
3. 重新標記 Stop the world 停止其他線程 (時間長一些)
4. 並發清除
* CMS 會造成的一些問題我們也要關注到
1. CPU 敏感: 因為多個了許多的 GC 線程
2. **浮動垃圾** : 在第四個階段的並發清理中產生了要 GC 的對象
3. 內存碎片 : CMS 使用的**標記清除**產生的內存碎片
概念圖
> 
:::success
* **浮動垃圾** ?
它的概念是 **在並發過程中所產生的 GC 標記**,所以會產生在 2~3 和 4階段
1. 第 2~3 階段經由 Stop the World 來停止所有線程,把浮動垃圾都標記
2. 第 4 階段產生浮動垃圾就不算在 CMS 清除之內
* 第 4 階段的浮動垃圾
其實可從 概念圖發現 CMS 在老年代還可以串接 Serial Old,它的作用就是在於處理浮動垃圾,**但由於 Serial Old 是單線程效率較低,有的伺服器會選擇重啟 (這也就是為何遊戲商在半夜維修的原因, 其實就是重新啟動)**
> 
:::
### G1 回收器
* G1 並沒有把堆分代,而是**透過等分劃分**,自由度更高
1. Eden: 新生代,使用複製算法
2. Survior: 新生代,使用複製算法
3. Old: 老年代,標記壓縮
4. Humong: 判斷當前對象是否超過 Eden 區的一半,如果超過就放入 Humong 區塊
| 回收器名稱 | 算法 | 類型 |
| -------- | -------- | - |
| G1 | ==標記清除 + 初始化為 0== | **並行 & 並發收集器** |
> 
* G1 的回收方法有接過 4 個階段
1. 初始標記 Stop the World
2. 並發標記
3. 最終標記 : 類似於重新標記 Stop the World
4. **篩選回收** : 對於回收時間較高的先不回收 Stop the World
> 
:::success
* G1 v.s CMS
> 在內存大小 6~8M 之內使用 CMS 效率較高,而超出該範圍則是 G1 效率較高
:::
## 其他
### 物件在 JVM 中的生命週期
:::success
**標記完成後並不代表該對象會立刻回收**,必須要依照對象的生命週期來判斷
:::
* **創建階段 (`Created`)**
1. 為對象分配內存記憶體空間
2. 建構對象
3. 將超類 (父類) 到子類 static 成員初始化
4. 遞歸調用構造方法 Construct (父類 > 子類)
* **應用階段 (`In Use`)**
> 對象被創建,並分配給變量時,**這時對象就持有一個引用 (強、軟、弱、虛引用)**
* **不可見階段 (`InVisible`)**
> 代表當程序找不到對象的強引用,或是已超出作用域時,對象可能作為特殊的 GC Roots 持有,Ex: 對象被本地方法棧中 JNI 引用,或是被其他線程引用
* **不可達階段 (`Unreachable`)**
> 並無任何強引用,並且 GC roots 以不可達
* **收集階段 (`Collected`)**
> GC 已準備好將該對象內存回收,並重新分配空間,**如果這時 Override finalize 方法則會被調用**
* **終結階段 (`Finalized`)**
> 對象執行完 finalize 方法後,則該對象進入最後階段
* **對象空間重新分配階段**
> 回收該對象,重新排列記憶體位置
### 逃逸分析 - 棧上分配物件
* 一般來說新物件都是分配在堆上,但是如果經過逃逸分析,並 **沒有逃出該方法就不會在堆上分配,會改為在棧上分配**(這就大大減少了 GC 回收耗費的時間)
```java=
// -XX:-DoEscapeAnalysis
// -XX:+PrintGC
// - 除去
// + 加上
public class testEscape {
public static void main(String[] args) {
System.out.println("Start");
for(int i = 0; i < 7E7; i++) {
Escape();
}
System.out.println("End");
}
public static void Escape() {
String str = new String("123456789");
}
}
```
**--實做結果--**
1. **開啟逃逸分析** (預設就開啟)`-XX:+DoEscapeAnalysis`
> 
2. **關閉逃逸分析** (必須手動關閉)`-XX:-DoEscapeAnalysis`
> 
### GC Roots 細節 - 根節點枚舉
* GC Roots 主要在全局性的引用(常量、靜態屬性... 等等),但是如果在大應用上,要全部掃描並分析會耗費許多時間,**並且在收集實惠暫停用戶線程**
:::info
* **這類似於 `Stop The World`**
* 這 **不同於可達性分析**,可達性分析可以與用戶線程併發,但 **根節點枚舉仍需要暫停** 並取得快照才行
:::
* 當用戶線程暫停後,並不需要一個一個的檢查所有上下文、引用位置,**虛擬機應該有辦法 ++直接的取得存放物件的引用++**
**在 HotSpot 中,會使用一組 OopMap 的數據結構來達到該目的!**
當類加載完成後,HotSpot 會把物件內的成員類型都計算出來… 在即時編譯時,也會在特定的位置紀錄下棧、暫存器的引用
> 藉此,GC 在回收時就不用從頭掃描所有物件的上下文
## Appendix & FAQ
:::info
:::
###### tags: `JVM`