--- title: 'Java 自動管理內存(HotSpot)、創建物件' disqus: kyleAlien --- Java 自動管理內存(HotSpot)、創建物件 === ## OverView of Content 高級語言 Java 跟 C、C++ 的一大差異就在於對於記憶體的管理方式,Java 透過 JVM 自動幫你管理記憶體,而 C、C++ 則需要手動申請 (new)、釋放 (delete) [TOC] ## Java 運行時數據區域 JVM 在執行 Java 應用時會將內存分為多個區塊,每個區域都各有用途 (請注意顏色,綠色為 thread 私有) > ![](https://i.imgur.com/6mQrXyB.png) ### 程式計數器 - 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 方法服務 > ![](https://i.imgur.com/ux60Sgb.png) :::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`)**,使用指標的移動,指針移動一個對象的大小 > ![](https://i.imgur.com/h8JN3pk.png) > > 綠色: 已用 / > > 橘色: 新對象 / > > 白色: 目前無使用(可用) 2. **內存不規整**:JVM 會維護一個 **空閒列表 (`Free List`)**,並在 **需要時搜尋到足夠空間讓其使用**(JVM 就像是停車場管理員一樣) > ![](https://i.imgur.com/aPdQz3S.png) > > 綠色: 已用 / > > 橘色: 新對象 / > > 白色: 目前無使用(可用) :::info * JVM 記憶體到底採用哪個呢?**內存是否規整與 GC 策略有關**! 1. 如果使用的 GC 包含 `空間壓縮 Compact` 的能力,那 JVM 就會使用 `指針碰撞` > Eg. Serial、ParNew 2. 如果 GC 使用 CMS `標記清除 Sweep` 算法,就必須使用 `空閒列表` > ![](https://i.imgur.com/V0yADMU.png) ::: ### 內存空間初始化、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`** > ![](https://i.imgur.com/zuc8p7H.png) ### 對象頭 Header * Header 又分兩個部份 > ![](https://i.imgur.com/asmlJm1.png) 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` 的註解如下 > ![](https://i.imgur.com/8KEkb4w.png) ### 實例數據 Instance * Instance 則是儲存該對象的真正實例數據 > ![](https://i.imgur.com/3OxKvqQ.png) 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 > ![](https://i.imgur.com/RwU8nAe.png) ::: ## 物件的訪問定位 通過 **在 JVM Heap 上搜尋對象 `reference`**,透過 `reference` 來操作 Heap 上的數據,目前主流的方式是 `句柄`、`直接指針` > Java 虛擬機規範只有定義 `reference` 是指向對象的引用,實際實現由 JVM 自己決定 | 訪問方式 | 好處 | | -------- | -------- | | 句柄 | 對象被移動時,只會改變句柄池的指向,不會影響 Stack 上的指針 | | 直接指針 | 速度快 | ### 類引用 - 句柄 * [**句柄**](https://baike.baidu.com/item/%E5%8F%A5%E6%9F%84): 在 Heap 中分配一個區塊作為 **句柄池**,而 Stack 中的 `reference` 就是 **指向句柄池**。句柄一般來說是指向一處資源的指針,是一種 **間接指向** > ![](https://i.imgur.com/ygc66Kk.png) ### 類引用 - 直接指針 * **直接指針**: **Stack 中直接存取 JVM Heap 中 `reference` 指針**,可以直接操作對象,其特色是速度較快 > ![](https://i.imgur.com/14wVC1M.png) :::success * HotSpot 大部分選用這種方式訪問對象 ::: ## Appendix & FAQ :::info ::: ###### tags: `JVM`