1. 程式人生 > >一夜搞懂 | JVM GC&記憶體分配

一夜搞懂 | JVM GC&記憶體分配

## 前言 >本文已經收錄到我的Github個人部落格,歡迎大佬們光臨寒舍: > >[我的GIthub部落格](https://lovelifeeveryday.github.io/) ## 學習導圖 ![學習導圖](https://cdn.jsdelivr.net/gh/LoveLifeEveryday/FigureBed@master/typora202003/29/220825-78346.png) ## 一.為什麼要學習`GC`&記憶體分配? 時代發展到現在,如今的記憶體動態分配與記憶體回收技術已經相當成熟,一切看似進入了“自動化”時代,不免發出疑問:"為啥我們還要了解垃圾收集和記憶體分配?" 答案很簡單,當需要排查各種記憶體溢位/洩漏問題的時候,當垃圾收整合為系統達到更高併發量的瓶頸的時候,我們必須對"自動化"技術進行必要的監控和調節。 所以,我們要了解下`GC`&記憶體分配,為工作中或者是面試中實際的需要打好基礎。 ## 二.核心知識點歸納 ### 2.1 物件存活判定演算法 > 在瞭解物件存活的判定之前,我們先來了解下四種引用型別 - 強引用`StrongReference` > - 具有強引用的物件不會被`GC` > - 即便記憶體空間不足,`JVM`寧願丟擲`OutOfMemoryError`使程式異常終止,也不會隨意回收具有強引用的物件 - 軟引用`SoftReference` > - 只具有軟引用的物件,會在**記憶體空間不足**的時候被`GC`,如果**回收之後記憶體仍不足,才會丟擲`OOM`異常** > - 軟引用常用於描述**有用但並非必需**的物件,比如實現記憶體敏感的高速**快取** - 弱引用`WeakReference` > - 只被弱引用關聯的物件,**無論當前記憶體是否足夠都會被`GC`** > - 強度比軟引用更弱,常用於描述**非必需**物件 - 虛引用`PhantomReference` > - 僅持有虛引用的物件,在任何時候都可能被`GC`(和弱引用一樣) > > - 主要作用是為了垃圾收集器回收時收到一個系統通知(`PhantomRefernece`類實現虛引用) > > - 與弱引用的區別:不同之處在於弱引用的`get`方法,虛引用的`get`方法始終返回`null`,**弱引用可以**使用`ReferenceQueue`,**虛引用必須**配合`ReferenceQueue`使用 > > - 必須和**引用佇列** (`ReferenceQueue`)聯合使用,當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用**加入到與之關聯的引用佇列**中 > > (想要了解**虛引用詳細用法**的讀者,可以看下這篇文章:[強軟弱虛引用,只有體會過了,才能記住](https://juejin.im/post/5e65b8096fb9a07cbb6e4a43#heading-5)) #### 2.1.1 引用計數演算法 定義:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的 > 然而在主流的Java虛擬機器裡未選用引用計數演算法來管理記憶體,主要原因是它難以解決物件之間**相互迴圈引用**的問題,所以出現了另一種物件存活判定演算法 ```java //相互迴圈引用的DEMO public class ReferenceCountingGC { public Object instance = null; private static final int _1MB = 1024 * 1024; /** * 這個成員屬性的意義是佔點記憶體,以便在GC日誌中看清楚是否有回收過 */ private byte[] bigSize =new byte[2 * _1MB]; public static void testGC() { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; System.gc(); } } ``` #### 2.1.2 可達性分析法 定義:通過一系列被稱為『`GC Roots`』的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為**引用鏈**,當一個物件到`GC Roots`沒有任何引用鏈相連時,則證明此物件是不可用的 >可作為GC Roots的物件: > >- 虛擬機器棧中引用的物件,主要是指棧幀中的**本地變量表** >- 本地方法棧中`Native`方法引用的物件 >- 方法區中**類靜態屬性**引用的物件 >- 方法區中**常量**引用的物件 >- `JVM`內部的引用(基本資料型別對應的`Class`物件) >- 所有被同步鎖(`synchronized`關鍵字)持有的物件 >- 反映`JVM`內部情況的`JMXBean`、`JVMTI`中的註冊的回撥、原生代碼快取等 ![可達性分析法](https://s1.ax1x.com/2020/03/29/GVU32q.png) Q:可達性分析演算法中被判定不可達的物件真的被判『死刑』了嗎? A:在可達性分析演算法中被判定不可達的物件還未真的判『死刑』,一共至少要經歷兩次標記過程: - 第一次標記:當第一次可達性分析後沒有與`GC Roots`相連線的引用鏈,被第一次標記 - 第二次標記: 判斷物件是否有必要執行`finalize()`方法;若被判定為有必要執行`finalize()`方法,之後還會對物件再進行一次篩選,如果物件能在`finalize()`中重新與引用鏈上的任何一個物件建立關聯,將被移除出“即將回收”的集合。 ![是否要回收的判斷](https://cdn.jsdelivr.net/gh/LoveLifeEveryday/FigureBed@master/typora202003/29/162614-891750.png) >引申:有關**方法區**的`GC`,可分成兩部分 > >- **廢棄常量**與回收`Java`堆中的物件的`GC`很類似,即在任何地方都未被引用的常量會被`GC`。 > >- **無用的類** > > 需滿足以下三個條件才會被`GC`: > > A.該類所有的例項都已被回收,即Java堆中不存在該類的任何例項; > > B.載入該類的`ClassLoader`已經被回收; > > C.該類對應的`java.lang.Class`物件沒在任何地方被引用,即無法在任何地方通過**反射**訪問該類的方法。 ### 2.2 垃圾收集演算法 > 前文講了`JVM`會回收哪些物件,下文筆者將探究`JVM`如何回收這些物件 #### 2.2.1 分代收集理論 Q1:**三個假說是什麼?** - 弱分代假說:絕大多數物件都是**朝生夕滅**的 - 強分代假說:熬過越多次垃圾收集過程的物件就**難以消亡** - 跨代引用假說:跨代引用相對於同代來說**僅佔極少數**(存在引用關係的物件應該傾向於同時生存或者同時消亡的,例如某個新生代被老年代所引用,該引用會使新生代物件在收集時同樣存活,進而進入老年代) > 在新生代上建立一個全域性的資料結構(**記憶集**),將老年代劃**分成若干小塊**,標識出老年代哪一塊記憶體存在跨代引用,`Minor GC`時,在跨代引用的記憶體裡的物件才會加入到`GC Roots`進行掃描 Q2:**垃圾收集器一致的設計原則** - 收集器應將`Java`堆劃分出不同的區域,然後將回收物件依據其年齡(年齡是物件熬過垃圾收集過程的次數)分配到不同的區域之中儲存 - 如果一個區域中大多數物件都是朝生夕滅,將他們集中到一起,每次回收時只關注少量存活,能以較低代價回收到大量的空間 - 如果是難以消亡的物件,把他們集中放在一起,虛擬機器用較低頻率來回收這個區域,同時兼顧垃圾收集的時間開銷和記憶體的空間 Q3:如何根據各個年代的特點選擇演算法呢? - 新生代:大批物件死去,只有少量存活。使用『複製演算法』,只需複製少量存活物件即可 - 老年代:物件存活率高。使用『標記—清理演算法』或者『標記—整理演算法』,只需標記較少的回收物件即可 > 這三種演算法,筆者將在下文為您詳細解析 #### 2.2.2 複製演算法 - 定義:把可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用盡後,把還存活著的物件『複製』到另外一塊上面,再將這一塊記憶體空間一次清理掉 - 優點:每次都是對整個半區進行記憶體回收,**無需考慮記憶體碎片**等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,**實現簡單,執行高效** - 缺點:每次可使用的記憶體縮小為原來的一半,**記憶體使用率低** ![複製演算法](https://cdn.jsdelivr.net/gh/LoveLifeEveryday/FigureBed@master/typora202003/29/164419-336620.png) - `Appel`式回收 分為一塊較大的`Eden`空間和兩塊較小的`Survivor`空間,在`HotSpot`虛擬機器中預設比例為8:1:1。每次使用`Eden`和一塊`Survivor`,回收時將這兩塊中存活著的物件一次性地複製到另外一塊`Survivor`上,再做清理。可見只有`10%`的記憶體會被“浪費”,倘若`Survivor`空間不足還需要依賴其他記憶體(老年代)進行分配擔保 #### 2.2.3 標記-清除演算法 - 首先『標記』出所有需要回收的物件,然後統一『清除』所有被標記的物件 - 是最基礎的收集演算法 - 缺點: > - 『標記』和『清除』過程的效率不高 > - 空間碎片太多。『標記』『清除』之後會產生大量**不連續的記憶體碎片**,可能會導致後續需要分配較大物件時,因無法找到足夠的連續記憶體而提前觸發另一次`GC`,影響系統性能 ![標記-清除演算法](https://cdn.jsdelivr.net/gh/LoveLifeEveryday/FigureBed@master/typora202003/29/165211-25230.png) #### 2.2.4 標記-整理演算法 - 首先『標記』出所有需要回收的物件,然後進行『整理』,使得存活的物件都向一端移動,最後直接清理掉端邊界以外的記憶體 - 優點:即沒有浪費50%的空間,又不存在空間碎片問題,價效比較高 - 缺點:**移動**在老年代每次回收都存在大量物件存活區域,必須**暫停使用者應用程式才能進行**(`Stop The World`) - 一般情況下,老年代會選擇標記-整理演算法。 ![標記-整理演算法](https://cdn.jsdelivr.net/gh/LoveLifeEveryday/FigureBed@master/typora202003/29/165651-315190.png) #### 2.2.5 和稀泥式 解決方法:大部分時間使用標記-清除演算法,當記憶體空間的碎片程度影響到記憶體分配,再使用標記-整理演算法進行收集 ### 2.3 `HotSpot`演算法實現&垃圾回收器 > 接下來介紹如何在`HotSpot`虛擬機器上實現物件存活判定演算法和垃圾收集演算法,並保證虛擬機器高效執行 #### 2.3.1 列舉根節點 主流`JVM`使用的都是**準確式`GC`**,在執行系統停頓之後無需檢查所有執行上下文和全域性的引用位置,而是通過一些辦法直接**獲取到存放物件引用的地方**,在`HotSpot`中是通過一組稱為`OopMap`的資料結構來實現的,完成類載入後會計算出物件某偏移量上某型別資料、`JIT`編譯時會在**特定的位置**記錄棧和暫存器中是引用的位置。這樣`GC`在掃描時就可直接得知這些資訊,並快速準確地完成`GC Roots`的列舉 #### 2.3.2 安全點 上述“特定的位置”被稱為安全點,即程式執行時並非在所有地方都停頓執行`GC`,只在到達安全點時才暫停,降低`GC`的空間成本 - 安全點的選定標準:可讓程式**長時間執行**的地方,如方法呼叫、迴圈跳轉、異常跳轉等具有指令序列複用的特徵 - 使所有執行緒在**最近的安全點上再停頓**的方案: > - 搶先式中斷:無需程式碼主動配合,在`GC`發生時把所有執行緒全部中斷,若執行緒中斷處不在安全點上就恢復執行緒,讓它“跑”到安全點上。現在**幾乎沒有虛擬機器實現採用搶先式中斷**來暫停執行緒從而響應`GC`事件 > - 主動式中斷:在`GC`要中斷執行緒時不直接對執行緒操作,而是設定一箇中斷標誌,讓各個執行緒在執行時主動輪詢它,當中斷標誌為真時就自己中斷掛起 #### 2.3.3 安全區域 > 安全點機制只能保證程式執行時,在不太長的時間內遇到可進入`GC`的安全點,但在程式不執行時(如執行緒處於`Sleep`或`Blocked`狀態)執行緒無法響應`JVM`的中斷請求,此時就需要安全區域來解決 - 安全區域:**引用關係不會發生變化的一段程式碼片段**,在安全區域中的任意地方開始`GC`都是安全的(因為引用關係不變),可看做是擴充套件的安全點 - 執行過程: 當執行緒執行到安全區域中的程式碼時就標識一下,如果這時`JVM`要發起`GC`就不用管被標識的執行緒; 線上程要離開安全區域時檢查系統是否已經完成了根節點列舉,若完成則執行緒可以繼續執行,否則等待直到收到可以安全離開安全區域的訊號為止 #### 2.3.4 `JVM`中七種回收器 ![垃圾回收器](https://s1.ax1x.com/2020/03/29/GVbHC4.png) | 序號 | 收集器 | 收集範圍 | 演算法 | 執行型別 | | ---- | -------------- | -------- | ------------------- | ---------- | | 1 | `Serial` | 新生代 | 複製 | 單執行緒 | | 2 | `ParNew` | 新生代 | 複製 | 多執行緒並行 | | 3 | `Parallel` | 新生代 | 複製 | 多執行緒並行 | | 4 | `Serial Old` | 老年代 | 標記整理 | 單執行緒 | | 5 | `CMS` | 老年代 | 標記清除 | 多執行緒併發 | | 6 | `Parallel Old` | 老年代 | 標記整理 | 多執行緒 | | 7 | `G1` | 全部 | 複製演算法,標記-整理 | 多執行緒 | > 注意併發和並行的概念: > > 在`GC`中: > > - **並行:**多條**垃圾收集執行緒並行工作**,而使用者執行緒仍處於等待狀態 > - **併發**:**垃圾收集執行緒與使用者執行緒**一段時間內同時工作(交替執行) > > 在普通情景中: > > - 並行:**多個程式在多個`CPU`**上同時執行,任意一個時刻可以有很多個程式同時執行,互不干擾 > - 併發:**多個程式在一個`CPU`**上執行,`CPU`在多個程式之間快速切換,微觀上不是同時執行,任意一個時刻只有一個程式在執行,但巨集觀上看起來就像多個程式同時執行一樣,因為`CPU`切換速度非常快,時間片是`64ms`(每`64ms`切換一次,不同的作業系統有不同的時間),人類的反應速度是`100ms`,你還沒反應過來,`CPU`已經切換了好幾個程式了 ### 2.4 記憶體分配和回收策略 >物件的記憶體分配廣義上是指在堆上分配,主要是在**新生代**的`Eden`區上,如果啟動了`TLAB`,將按執行緒優先在`TLAB`上分配,少數情況下也可能會分配在老年代中。分配細節還是取決於所使用的`GC`收集器組合以及虛擬機器中與記憶體相關的引數的設定。以下介紹幾條普遍的記憶體分配規則 - **物件優先在`Eden`分配**:大多數情況下物件在新生代`Eden`區中分配,當`Eden`區沒有足夠空間進行分配時虛擬機器將發起一次`Minor GC` >- 新生代`GC`:發生在新生代的垃圾收集動作。較頻繁、回收速度也較快 - 老年代`GC`(`Major GC/Full GC`):發生在老年代的垃圾收集動作。出現`Major GC`經常會伴隨至少一次的`Minor GC`。速度一般比`Minor GC`慢10倍以上 - **大物件直接進入老年代**:對於需要大量連續記憶體空間的`Java`物件(如很長的字串以及陣列),如果大於虛擬機器設定的`-XX:PretenureSizeThreshold`引數值將直接在老年代分配。這樣做的目的是避免在`Eden`區及兩個`Survivor`區之間發生大量的記憶體複製 - **長期存活的物件將進入老年代**:虛擬機器會給每個物件定義一個年齡計數器,當物件在`Eden`出生並經過第一次`Minor` `GC`後仍存活且能被`Survivor`容納的話,將被移動到`Survivor`空間中並將物件年齡設為1;當物件在`Survivor`區中每“熬過”一次`Minor GC`年齡就+1,直至增加到一定程度(預設為`15歲`,可通過`-XX: MaxTenuringThreshold`設定)就會被晉升到老年代中 - **動態物件年齡判定**:為了能更好地適應不同程式的記憶體狀況,虛擬機器並不要求一定要達到`-XX: MaxTenuringThreshold`設定值才能晉升到老年代,當`Survivor`空間中相同年齡所有物件大小的總和大於`Survivor`空間的一半,那麼年齡大於或等於該年齡的物件可以直接進入老年代 - **空間分配擔保**:在發生`Minor GC`之前虛擬機器會先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,若是,說明可確保`Minor GC`是安全的,反之虛擬機器會檢視`-XX:HandlePromotionFailure`設定值是否允許擔保失敗;若允許,會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小;若大於,將嘗試進行一次`Minor GC`,若小於或者不允許擔保失敗,將改為進行一次`Full GC` >解釋:當大量物件在`MinorGC`後仍然存活的情況時,需要藉助老年代進行分配擔保,把`Survivor`無法容納的物件直接進入老年代,但前提是老年代本身還有容納這些物件的剩餘空間,由於在完成記憶體回收之前無法預知實際存活物件,只好取之前每次回收晉升到老年代物件容量的平均大小值作為經驗值,與老年代的剩餘空間進行比較,從而決定是否進行`Full GC`來讓老年代騰出更多空間 ## 三.課堂小測試 > 恭喜你!已經看完了前面的文章,相信你對`JVM GC`&記憶體分配已經有一定深度的瞭解,下面,進行一下課堂小測試,驗證一下自己的學習成果吧! Q1:垃圾回收演算法你瞭解幾種?請你簡要分析一下,並說明其優缺點? Q2:`Java`的引用機制有幾種?請簡要分析下,並說明其在`Android`中的應用場景有哪些? Q3:安全點你瞭解過嗎?安全區呢?請你介紹下安全區相對安全點的優勢在哪裡? Q4:怎麼判斷物件是否存活呢?有幾種方法? > 上面問題的答案,在前文都提到過,如果還不能回答出來的話,建議回顧下前文 ------ 如果文章對您有一點幫助的話,希望您能點一下贊,您的點贊,是我前進的動力 本文參考連結: - 《深入理解Java虛擬機器》第3版 - [JVM面試知識點梳理](https://juejin.im/post/5bf94ae9e51d4523ec265619#heading-5) - [強軟弱虛引用,只有體會過了,才能記住](https://juejin.im/post/5e65b8096fb9a07cbb6e4a43#heading-5) - [深入JVM物件引用](https://blog.csdn.net/dd864140130/article/details/49885811) - [要點提煉| 理解JVM之GC&記憶體分配](https://www.jianshu.com/p/a62697f00b85) - [JVM(HotSpot) 垃圾收集器](https://www.jianshu.com/p/b4a03b5de0d9) - [垃圾收集器與記憶體分配策略](https://juejin.im/post/5e6cb3a051882549016083d1#heading-0) - [深入理解併發/並行,阻塞/非阻塞,同步/非同步](https://www.jianshu.com/p/2116fff869b6) - [軟引用、弱引用、虛引用-他們的特點及應用場景](https://www.jianshu.com/p/825cc