--- 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` > ![](https://i.imgur.com/1lJ4kxb.png) ### 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()** **--實做--** > ![](https://i.imgur.com/NqvjSpx.png) ## 物件生死判斷 對於自動回收的關鍵問題其中一個就是,如何判斷物件是否還被需要(物件生死);以下舉幾個常見的方案 ### 方案 - 引用計數 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 觸發時搜尋這些根結點,並往下搜尋,如果對象是不可達的代表該對象是可回收的,**分析完後就會給可回收的==對象標記==** > ![](https://i.imgur.com/h9SSQ3Z.png) 作為 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. **私有區**:線程私有區塊(**程序計數器、本地方法棧、虛擬機棧**),這個區塊隨著線程生亡,所以每一個棧楨區的分配的內存大小是已知的 > **棧楨大小基本是編譯期間就可知的**,因此這個 **私有區塊不需要特別進行回收**! > ![](https://hackmd.io/_uploads/r1qLJvU3h.png) ### 方法區回收 * 方法區是載入 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. 由於零碎的記憶體區塊,**導致創建物件效率太低**(創建物件要連續的記憶體區塊) > ![](https://i.imgur.com/S6wRvQf.png) ### 分代收集理論 - 標記壓縮算法 (Mark-Compact) * 常用於**堆區**(也就是強分代),標記清除算法的進階,在清除後重新排列對象的記憶體位子,**大多使用在 ==強分代==**;其特點如下 1. 對象的複製移動 2. **引用更新**(相對來講是個重量級操作) :::info * **Stop the World**! 在 GC 發生時 (演算法發生時),只有 GC 線程在運行,其他線程全部暫停(包括用戶線程),**等待 GC 線程執行完引用更新後才能再次運行** > **針對不同的 GC 回收器,Stop the World 會發生在不同的時機** **大量的暫停程序勢必會影響到用戶應用的吞吐量** > ![](https://hackmd.io/_uploads/BkkZXhWR3.png) ::: 3. 沒有內存碎片 (但較耗時) > ![](https://i.imgur.com/d7C4Dfu.png) ### 分代收集理論 - 複製算法(Copying) * 常用於**堆區 - 新生代(也就是弱分代)** 把記憶體切成兩半,當 GC 被觸發時會遍歷一半區塊,把存活的對象複製到另一半(**大多使用在生命週期不長的 ==弱分代==**);並有以下特點 * **優點** 1. 實現簡單,速度快 2. 內存複製,沒有內存碎片 * **缺點** 1. 如果存活對象過多時就會導致大量複製,效率太低 2. 對於空間的利用率只有一半(只剩下一半的空間可以儲存物件) > ![](https://i.imgur.com/Sh8WpP4.png) * 由於上述的空間浪費,所以有另外針對複製算法規劃出不同的分區(不使用 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` **空間到達上限**,將存活對象移動到老年代 > ![](https://i.imgur.com/giBTrmD.png) ::: ## GC 回收器 針對不同的堆區會有不同的收集器,而其特色又不一樣,以下主要分為新生代老年代,這樣的分法是依照 JVM 舊的劃分方式,**新的 G1 就++沒有在堆中區分++** 其配對關係如下圖,並 **不是隨意可以配對使用** (有連線才可配合使用) > ![](https://i.imgur.com/J2pr3bl.png) ### 弱分代回收器 > 也就是 Hotspot 的新生代回收器 | 回收器名稱 | 算法 | 類型 | | -------- | -------- | - | | Serial | 複製算法 | 單線程 | | ParNew | 複製算法 | 並行的多線程收集 (利用 CPU) | | Parallel Scavenge | 複製算法 | 並行的多線程收集 (利用 CPU) | * 單線程收集(`Serial`) > ![](https://i.imgur.com/WoNhIwB.png) * 多線程收集 (`ParNew`、`Parallel Scavenge`**同一進程,產生多線程**) > ![](https://i.imgur.com/aHnAW9i.png) :::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 使用的**標記清除**產生的內存碎片 概念圖 > ![reference link](https://i.imgur.com/NXdWpMZ.png) :::success * **浮動垃圾** ? 它的概念是 **在並發過程中所產生的 GC 標記**,所以會產生在 2~3 和 4階段 1. 第 2~3 階段經由 Stop the World 來停止所有線程,把浮動垃圾都標記 2. 第 4 階段產生浮動垃圾就不算在 CMS 清除之內 * 第 4 階段的浮動垃圾 其實可從 概念圖發現 CMS 在老年代還可以串接 Serial Old,它的作用就是在於處理浮動垃圾,**但由於 Serial Old 是單線程效率較低,有的伺服器會選擇重啟 (這也就是為何遊戲商在半夜維修的原因, 其實就是重新啟動)** > ![](https://i.imgur.com/8O0myGU.png) ::: ### G1 回收器 * G1 並沒有把堆分代,而是**透過等分劃分**,自由度更高 1. Eden: 新生代,使用複製算法 2. Survior: 新生代,使用複製算法 3. Old: 老年代,標記壓縮 4. Humong: 判斷當前對象是否超過 Eden 區的一半,如果超過就放入 Humong 區塊 | 回收器名稱 | 算法 | 類型 | | -------- | -------- | - | | G1 | ==標記清除 + 初始化為 0== | **並行 & 並發收集器** | > ![](https://i.imgur.com/TY5bNRn.png) * G1 的回收方法有接過 4 個階段 1. 初始標記 Stop the World 2. 並發標記 3. 最終標記 : 類似於重新標記 Stop the World 4. **篩選回收** : 對於回收時間較高的先不回收 Stop the World > ![](https://i.imgur.com/dsmScEZ.png) :::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` > ![](https://i.imgur.com/RIanjbI.png) 2. **關閉逃逸分析** (必須手動關閉)`-XX:-DoEscapeAnalysis` > ![](https://i.imgur.com/20AZUf3.png) ### GC Roots 細節 - 根節點枚舉 * GC Roots 主要在全局性的引用(常量、靜態屬性... 等等),但是如果在大應用上,要全部掃描並分析會耗費許多時間,**並且在收集實惠暫停用戶線程** :::info * **這類似於 `Stop The World`** * 這 **不同於可達性分析**,可達性分析可以與用戶線程併發,但 **根節點枚舉仍需要暫停** 並取得快照才行 ::: * 當用戶線程暫停後,並不需要一個一個的檢查所有上下文、引用位置,**虛擬機應該有辦法 ++直接的取得存放物件的引用++** **在 HotSpot 中,會使用一組 OopMap 的數據結構來達到該目的!** 當類加載完成後,HotSpot 會把物件內的成員類型都計算出來… 在即時編譯時,也會在特定的位置紀錄下棧、暫存器的引用 > 藉此,GC 在回收時就不用從頭掃描所有物件的上下文 ## Appendix & FAQ :::info ::: ###### tags: `JVM`