---
title: 'Java 自動管理內存(HotSpot)、創建物件'
disqus: kyleAlien
---
Java 自動管理內存(HotSpot)、創建物件
===
## OverView of Content
高級語言 Java 跟 C、C++ 的一大差異就在於對於記憶體的管理方式,Java 透過 JVM 自動幫你管理記憶體,而 C、C++ 則需要手動申請 (new)、釋放 (delete)
[TOC]
## Java 運行時數據區域
JVM 在執行 Java 應用時會將內存分為多個區塊,每個區域都各有用途 (請注意顏色,綠色為 thread 私有)
> 
### 程式計數器 - Program Counter Register
* **每個線程都`私有`**,Java 天生為多線程程序所以必須紀錄每個 Thread 當前運行到哪裡,並且只需要較小的記憶體空間(記憶當前 thread 的行號 `Line`)
:::info
* **何為指令**?
在 JVM 模型概念 (類似協定) 中,字解碼解釋器改變計數器,來決定要讀取的字解碼指令
> 字解碼指令像是:分支、循環、異常處理... 等等
>
> 該指令類似於 CPU 指令,不過該指令只針對 JVM Runtime 給虛擬機使用!
:::
* 此區塊是 **唯一不受 Java 虛擬機規範所以 ++不會有 OutOfMemoryError 錯誤產生++**
> Thread 正在執行 Java 方法,計數器記錄的是 ++正在執行的虛擬機器位元組碼指令地址++
:::success
* **Native method 是否紀錄?**
如果 **執行的是Native方法,計數器值則為空(`Undefined`)**,因為其不是由 Java 呼叫,而是本地呼叫(所有 JVM 管不著)
:::
### 虛擬機棧 - VM Stack
* **每個線程都有各自 `私有` 的 Stack**,其 **空間遠小於堆**
> 這種規劃分式類似於 C、C++ 對於內存規劃的方式(有堆區、棧區)
* 每呼叫一個 Java 方法,JVM 就會產生一個 **棧禎 (Stack Frame)** 放入 Stack 中,棧禎 (Stack Frame) 方法如下
| 棧禎 紀錄類型 | 說明 | 補充 |
| -------- | -------- | - |
| 區域性變量表 | Java 基礎八大數據、Object 引用 | 儲存局部變量的一張表 |
| 數據操作棧 | 存放分法執行的操作數據,**做出入棧的動作** | 操作的元素可以是任意的 Java 數據 |
| 動態連結 | **Java 的多態**,動態特性等等(這樣 Java 這種靜態語言擁有動態語言的特性) | 在編譯期間不確定的程式跳轉(多型) |
| 返回出口 | 調用計數器的地址,返回其結果 | 調用程序計數器中的地址作為返回 |
:::info
* **調整 Stack 大小**
> 可用 `-Xss` 調整大小,Ex: -Xss512k (預設取決於系統)
:::
:::success
* **區域性變量槽 (Slot)**
1. 在儲存局部量量時,是使用 **局部變量槽 (Slot)**,這個槽的大小是 JVM 決定(有可能是 32、64 bit),像是 `double`、`long`... 等等 就是使用兩個槽來儲存
2. 局部變量表所需要的內存空間是在 **++編譯完成後就決定++**,也就是說 **局部變量表的大小是不能動態調整的**
> 局部變量表在編譯期間就決定
:::
* JVM 規範中,VM Stack 操作失敗如下:
| 產生異常 | 原因 | 發生點 |
| -------- | -------- | - |
| **StackOverflowError** | **Stack 深度** 大於 JVM 規範的深度 | 容易發生在 **遞迴** |
| **OutOfMemoryError** | JVM 容量是可以動態拓展的,但動態拓展失敗 | Java 應用申請記憶超出規範大小 |
### 本地方法棧 - Native Method Stack
* **每個 Thread 都有各自`私有`**,與 VM Stack 類似,不過一個是為 Java(`Byte Code`) 服務,一個是為 Native 方法服務
> 
:::info
* **Native Method Stack 儲存的訊息**
Java 對於本地方法棧的 `語言`、`數據結構` 並沒有硬性規定,因此具體由 Java 虛擬機自己實現它
:::
* 本地方法棧與虛擬機器棧所發揮的作用非常類似,最大的不同在於 **虛擬機器棧用於 Java方法** 的呼叫,而 **本地方法棧則用於本地方法** 的呼叫,也就是由不同的方式呼叫
```java=
// Java 方法
public void showJavaVersion();
// Java 呼叫 Native 方法 (Native 關鍵字)
public native void showNativeVersion();
```
* JVM 規範中,**Native Method Stack 操作失敗如下**:(同 VM Stack)
| 產生異常 | 原因 | 發生點 |
| -------- | -------- | - |
| **StackOverflowError** | **Stack 深度** 大於 JVM 規範的深度 | 容易發生在 **遞迴** |
| **OutOfMemoryError** | JVM 容量是可以動態拓展的,但動態拓展失敗 | Java 應用申請記憶超出規範大小 |
### 堆 - Heap
* **各執行緒 `共享` 其內容**,在 **虛擬機起動時建立**
**此區塊的目的是==存放物件實例==**,此區塊使用的記憶體容量也是最大,**==GC 的主要區塊==**
:::info
* **JVM Heap 分配可通過以下 Options 調整 JVM 堆區的設置**
| Optioins 指令 | 調整目標 |
| -------- | -------- |
| -Xmx | 堆的最大值 |
| -Xms | 堆的最小值 |
| -Xmn | 新生代的大小 |
| -XX:NewSize | 新生代最小值 |
| -XX:MaxNewSize | 新生代最大值 |
:::
:::warning
* Java 對象 **全部都放置在堆區 ?**
**No!** 只能說 **大多數的對象都放置在堆區**,由於編譯技術的進步,有部分對象可以放置到 Stack 區塊 (逃逸分析)
:::
* JVM 規範中,**Heap 操作失敗如下**:
| 產生異常 | 原因 | 發生點 |
| -------- | -------- | - |
| **OutOfMemoryError** | JVM 容量是可以動態拓展的,但動態拓展失敗 | Java 應用申請記憶超出規範大小 |
:::success
* **堆區是一整塊大記憶體**?
Java 為了要更有效率的回收對象,會 **將 Heap 劃分為多個區塊**,但不論怎麼劃分,Heap 仍是所有 thread 共用區域
> 對於堆區的規劃以 JVM 實現為主;像是 HopSpot VM 來說就使用了經典分代 (其他小節會說)
重點是 **堆區是共用區、可以不連續(但邏輯上是連續的)的特性**!
:::
### 方法區 - Method Area
* **所有 thread `共享` 內容**,也稱為 **非堆區 (`Non-Heap`)**,方法區儲存的目標有如下
| 儲存目標 |
| - |
| 加載的類資訊 class (二進制) |
| 常量 final |
| 靜態變數 static |
| 即時編譯程式 |
* 在 JVM 的規範中,是 **++可選擇++ 不回收 `方法區` 的記憶體,如果要回收,方法區的回收條件也是相當嚴苛的**,像是對於類的卸載
> 對方法區的規範相對式寬鬆的
:::info
* Class 檔案訊息
Class 檔案中除了有類的 `版本`、`欄位`、`方法`、`介面` ... 等描述資訊外,還有一項資訊是 `常量池`,用於**存放編譯期生產的各種字面量和符號引用**,這部分內容將在類載入後進入方法區的執行時常量池
:::
==**運行時常量區**==:**它用來存放編譯期間生成的 ++字面量、符號引用++,並非在編譯時才能產生,也 ++可以在運行時產生++**
> 類加載時編譯時的鏈結
:::warning
* **方法區 & 永久代 一樣**?
**有許多人會習慣把分法區稱為永久代,但其實兩者並不相等**,**永久代是 HotSpot 團隊對於方法區的一種實現**
> 像是 JRockit 就不存在永久代。
1. JVM 類的加載過程是,**`加載`->`驗證`->`準備`->`解析`->`初始化`->`使用`**
2. 上面可以分析出類的版本、字段、方法、接口等等的描述,還包括 **常量池 (`constant pool table`),用於存放編譯期間生成的各種字面常量、符號引用**
```java=
// 以下是編譯期間生成的常量
String a = "b";
final String c = "d";
// 符號引用 (類、方法的全限定名)
Java/lang/String;
```
3. 在解析接段,JVM 會把符號引用轉為直接引用,多個類共用運行時常池,在常量池中只會存在一份
4. 方法區 & 堆 都是共享空間
:::
* JVM 規範中,**Method Area 操作失敗如下**:
| 產生異常 | 原因 | 發生點 |
| -------- | -------- | - |
| **OutOfMemoryError** | JVM 容量是可以動態拓展的,但動態拓展失敗 | 方法區無法分配新的記憶體空間時 |
:::info
* **元空間**:方法區永久代的更新
Java7 版本:中已經 **將永久代的靜態變量 & 運行時常量轉移到堆中**
Java8 版本:已經將方法區中的永生代去除,並改用 **元空間 (class matadata) 儲存並且位置在本地**
:::
### 直接記憶體 (直接內存) - Direct Memory
* 直接記憶體並**不是虛擬機器執行時資料區**的一部分,**也不是 Java 虛擬機器規範中定義的記憶體區域,==NDK 就屬於這一區塊==**
* 此區塊記憶體的分配 **++不會受到 Java 堆 大小的限制++**,但是 **會受到本機總記憶體**以及處理器定址空間的**限制**,**超出時導致 OutOfMemoryError 異常**
* 直接記憶體的 **速度會優於 Java堆**
> 可以避免 Java 堆、Native 堆中來回複製數據
* 雖然該區塊不受 JVM 堆大小限制,但 **會受到裝置本身內存的限制**;**當直接記憶體操作失敗**:
| 產生異常 | 原因 | 發生點 |
| -------- | -------- | - |
| **OutOfMemoryError** | 由系統產生 | 系統的記憶體區無法分配新的記憶體空間時 |
### 類加載子系統
* 類載入子系統 **負責從檔案系統中載入 Class 資訊**,載入的類資訊**存放於`方法區`的記憶體空間**
### 回收 GC
* 垃圾回收器可以對方法區、Java堆和直接記憶體進行回收
* Java 堆是垃圾收集器的工作重點
### 執行引擎
* 不同於類加載系統,它 **負責執行虛擬機器的位元組碼**
## HotSpot 創建對象
以下使用最常用(經典)的 HotSpot 虛擬機、內存 Java 堆為例,討論 Java 對象創建在 JVM 運作
:::warning
這裡限於 Java 普通對象 (new 關鍵字),不包括 Array、Class 對象
:::
### JVM 檢察加載
> 也就是 **ClassLoader 階段**,在這個階段會完成類的載入、連結、初始化
* **檢查常量池**:當 ClassLoader 加載類時,JVM 會收到字節碼,首先先去檢查 **常量池**,**定位類的符號引用**,檢查的過程包括
1. 是否被加載過
2. 是否被解析過
3. 是否初始化過(這會以 ClassLoader 為基準檢查)
:::success
經過檢查後,就 **可以確定類的大小**
:::
### JVM 分配內存 - 策略
> 這個階段 ClassLoader 已經加載完類,並 **使用 `new` 關鍵字創建物件**
* 內存分配基本有兩種狀態 ^1.^ **內存規整**、^2.^ **內存不規整**
1. **內存規整**:**使用 `指針碰撞` (`Bump The Pointer`)**,使用指標的移動,指針移動一個對象的大小
> 
>
> 綠色: 已用 /
>
> 橘色: 新對象 /
>
> 白色: 目前無使用(可用)
2. **內存不規整**:JVM 會維護一個 **空閒列表 (`Free List`)**,並在 **需要時搜尋到足夠空間讓其使用**(JVM 就像是停車場管理員一樣)
> 
>
> 綠色: 已用 /
>
> 橘色: 新對象 /
>
> 白色: 目前無使用(可用)
:::info
* JVM 記憶體到底採用哪個呢?**內存是否規整與 GC 策略有關**!
1. 如果使用的 GC 包含 `空間壓縮 Compact` 的能力,那 JVM 就會使用 `指針碰撞`
> Eg. Serial、ParNew
2. 如果 GC 使用 CMS `標記清除 Sweep` 算法,就必須使用 `空閒列表`
> 
:::
### 內存空間初始化、JVM 設定對象
* 把 **規劃完的記憶體空間全部賦值為 `0`、`false`**... 等等預設值,這也就是為何 Class member 不初始化的參數也會為 0,但是 ==**對象頭尚未初始化**==
> 這個階段是記憶體的初始化,**尚未與類相關訊息連接**
:::warning
* 如果使用 TLAB 機制,那初始化行為就會移動到分配內存的階段
:::
* 在分配完內存、初始化後,JVM 就會 **對該對象設定必要的數據**
1. 該對象(記憶體)是 **哪個類的實例**
2. **類的元數據類型**、對象 HashCode (會延後設定)、對象的 GC 分代
3. 是否有使用偏向鎖... 等等
:::info
* 到該這個步驟後,對象在 JVM 中就算創建完成 (對於 Java 來說尚未完成)
:::
* 當上述的行為都結束後,**執行該對象的 Constructor 建構方法** (也就是 Class 文件中的 `<init>()` 方法),並且且所有 Field 莫認為 0
### JVM 分配內存 - 併發競爭
> 這裡細節提及一下內存是如何分配的,而在多線程併發中又會有哪些問題、JVM 如何解決
* 由於 Java 有多線程特性,所以在分配內存時需要特別注意 **==併發問題==**,JVM 也有多線程的內存分配方案
1. **CAS (`Compare and swap`) 同步機制**:
操作時會輸入兩個值 (`Old_A` & `New_B`),**在操作其間會比較 A & B 值有沒有變化**,直到 A == B 才會交換成 B 值
> JVM 採用 CAS + 失敗重試的方式保證更新操作的 **原子性**
```java=
// 概念程式
int cas(int oldVal, int newVal) {
int result = oldVal;
while(true) {
if(oldVal == newVal) { // 比較真正設定完數據才跳出 while
result = newVal;
break;
}
}
return result;
}
```
2. **TLAB (`Thread Local Allocation Buffer`)** 本地線程分配緩衝:
在 **JVM Heap** 中先分配一塊內存給 Thread,只有當 Thread 使用完堆空間時才需要同步
:::info
* JVM 是否使用 TLAB 機制可以使用 `-XX:+UseTLAB` 參數來控制
:::
* **HotSpot 解釋器對於創建新物件的指令的解釋**:
一般來說使用 `new` 關鍵字會產生兩條指令字節碼(`new` & 呼叫建構函數的 `<init>()`);接下來我們看看 JDK12 中的字節碼解釋器(`bytecodeInterpreter.cpp`)
```c=
// bytecodeInterpreter.cpp
CASE(_new): {
u2 index = Bytes::get_Java_u2(pc+1);
ConstantPool* constants = istate->method()->constants();
// 判斷 Class 已經被加載
if (!constants->tag_at(index).is_unresolved_klass()) {
// Make sure klass is initialized and doesn't have a finalizer
Klass* entry = constants->resolved_klass_at(index);
InstanceKlass* ik = InstanceKlass::cast(entry);
// 判斷類是否已經初始化
if (ik->is_initialized() && ik->can_be_fastpath_allocated() ) {
// 已初始化
size_t obj_size = ik->size_helper();
oop result = NULL;
// 是否需要將物件所有字段至為 0
// If the TLAB isn't pre-zeroed then we'll have to do it
bool need_zero = !ZeroTLAB;
if (UseTLAB) { // 是否在 TLAB 分配物件記憶體
result = (oop) THREAD->tlab().allocate(obj_size);
}
#ifndef CC_INTERP_PROFILE
if (result == NULL) {
// 不使用 TLAB
need_zero = true;
// Try allocate in shared eden
retry:
HeapWord* compare_to = *Universe::heap()->top_addr();
HeapWord* new_top = compare_to + obj_size;
// 尚未分配完成
if (new_top <= *Universe::heap()->end_addr()) {
// 使用 CAS 機制
if (Atomic::cmpxchg(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) {
// 如果尚未分配記憶體,跳回 retry
goto retry;
}
result = (oop) compare_to;
}
}
#endif
// 物件記憶體分配成功!
if (result != NULL) {
// 判斷是否需要初始化
if (need_zero ) {
HeapWord* to_zero = (HeapWord*) result + sizeof(oopDesc) / oopSize;
obj_size -= sizeof(oopDesc) / oopSize;
if (obj_size > 0 ) {
// 出使化物件記憶體空間為 0
memset(to_zero, 0, obj_size * HeapWordSize);
}
}
// 判斷是否有偏向鎖
if (UseBiasedLocking) {
result->set_mark(ik->prototype_header());
} else {
result->set_mark(markOopDesc::prototype());
}
result->set_klass_gap(0);
result->set_klass(ik);
// Must prevent reordering of stores for object initialization
// with stores that publish the new object.
OrderAccess::storestore();
// 儲存物件到 Stack
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
}
}
// Slow case allocation
CALL_VM(InterpreterRuntime::_new(THREAD, METHOD->constants(), index),
handle_exception);
// Must prevent reordering of stores for object initialization
// with stores that publish the new object.
OrderAccess::storestore();
SET_STACK_OBJECT(THREAD->vm_result(), 0);
THREAD->set_vm_result(NULL);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
```
## 物件 - 內存設計
**物件在 JVM 堆中持有的內存分配策略**:在 HotSpot 實現的虛擬機中分為 **3 個部分**
1. **對象頭 `Header`**
2. **實例數據 `Instance Data`**
3. **對齊填充 `Padding`**
> 
### 對象頭 Header
* Header 又分兩個部份
> 
1. **對象運行時數據**:又稱 **Mark World** (`HashCode`, `GC`, `Lock Flag`, `thead ID`, `time Stamp` ... 等等數據)
大小為 32、64 bit
:::success
* 物件運行時數據大小
1. **Header 中的運行時數據,與物件真實儲存的數據無關**
2. 對象運行時數據其實很容易大於 32、64 bit,所以 Mark Word 被設定為與 **動態定義的數據結構**,每個狀態儲存的數據都不同
> 假設 32 bit 空間中:25 bit 為儲存物件的 hashcode、4 bit 儲存分代年齡、2 bit 儲存鎖訊息
| Header 內容 (動態) | 標誌位 (2 bit) | 鎖的類型 |
| -------- | -------- | -------- |
| HashCode、對象分代年齡 | 01 | 未鎖定 |
| 指向所記錄的指針 | 00 | 輕量級 |
| 指向重量級鎖的指針 | 10 | 重量級鎖定 |
| 空、不須記錄訊息 | 11 | GC 標記 |
| 偏向線程 ID、偏向時間戳、分代年齡 | 01 | 可偏向鎖 |
:::
2. **類行指針**:**指向類數據的 ++元數據++ 指針**,**JVM 透過這個指針來確定該對象來自哪個類**,如果對象是數組則會儲存該數組的大小 (如果長度不確定則長度則無法確定)
> 指向方法區(物件的 Metadata)
:::danger
* 類行指針 是一個物件必須 ?
這並不一定,這要看 **JVM 的實現而定**,**查找對象的元數據類型,並不一定需要透過對象本身 !**
:::
在 `markOop.hpp` 的註解如下
> 
### 實例數據 Instance
* Instance 則是儲存該對象的真正實例數據
> 
1. **父類**:儲存 Parent Class 的所有訊息 (這其中也包括私有數據,這也就是繼承 `extends` 為何在設計中,代價較為大的原因)
2. **自身**:該類自身的數據
* **儲存數據的順序**:順序會 **依照 Java source code 中的順序而定**,HotSpot 預設儲存順序為
1. `long`/`doubles`
2. `ints`
3. `shorts`/`chars`
4. `bytes`/`boolean`
5. `oop` (Ordinary Object Pointer)
從上面的策略來看,**相同長度的類型會放置在一起** (在 Parent Class 中的數據會在 Child Class 之前出現)
:::info
* 順序可以透過 `-XX:FieldAllocationStyle` 參數設置
> 此設置會將相同寬度的字段放置在一起
:::
### 對齊填充 Padding
> Padding 並非必須
* **Padding 主要是在填充 `Instance` 數據**,對齊數據 (8 Bit 倍數) 對於 CPU 來說相當重要,因為這有關於 CPU 讀取數據的效率
:::success
* 由於 Header 自身設計就是 32、64 bit 所以不需要 Padding
> 
:::
## 物件的訪問定位
通過 **在 JVM Heap 上搜尋對象 `reference`**,透過 `reference` 來操作 Heap 上的數據,目前主流的方式是 `句柄`、`直接指針`
> Java 虛擬機規範只有定義 `reference` 是指向對象的引用,實際實現由 JVM 自己決定
| 訪問方式 | 好處 |
| -------- | -------- |
| 句柄 | 對象被移動時,只會改變句柄池的指向,不會影響 Stack 上的指針 |
| 直接指針 | 速度快 |
### 類引用 - 句柄
* [**句柄**](https://baike.baidu.com/item/%E5%8F%A5%E6%9F%84):
在 Heap 中分配一個區塊作為 **句柄池**,而 Stack 中的 `reference` 就是 **指向句柄池**。句柄一般來說是指向一處資源的指針,是一種 **間接指向**
> 
### 類引用 - 直接指針
* **直接指針**:
**Stack 中直接存取 JVM Heap 中 `reference` 指針**,可以直接操作對象,其特色是速度較快
> 
:::success
* HotSpot 大部分選用這種方式訪問對象
:::
## Appendix & FAQ
:::info
:::
###### tags: `JVM`