學習分析 JVM 中的對象與垃圾回收機制(上)

上一章學習了 JVM 中的內存模型, 也就是運行時數據區的一些知識, 今天接着來繼續學習 JVM 中對象與垃圾回收機制, 本章內容將圍繞以下幾點進行學習.

  1. 虛擬機中對象的創建過程.
  2. 對象的內存佈局.
  3. 對象的訪問定位.
  4. 對象的存活以及各種引用.
  5. 對象的分配策略.
  6. 垃圾回收算法與垃圾收集器.

其中每個大的方向又包含了幾個別小的知識點. 那麼現在開始從第一點對象的創建過程開始學習.

1. 虛擬機中對象的創建過程

先上一張圖.


當我們在Java 程序中 new 一個普通對象時候, HotSpot 虛擬機是在經過了上面圖中的五個步驟後才創建出的對象.現在開始對其一一進行分析 (類加載會在別的章節單獨學習)

1.1 檢查加載

當 JVM 遇到 new 指令時, 會先檢查指令參數是否能在常量池中定位到一個類的符號引用.

  • 能定位到, 檢查這個符號引用代表的類是否已被加載, 解析和初始化過.
  • 不能定位, 或者沒有檢查到, 就先執行相應的類加載過程.
1.2 分配內存

對象所需內存的大小在類加載完成後就可以確定. (JVM 可以通過普通 Java 對象的元數據信息確定對象大小)

爲對象分配內存相當於把一塊確定大小的內存從 Java 堆裏劃分出來

內存的分配方式分爲兩種: 指針碰撞與空閒列表. 其實很容易區別, 下面來說一下.

1.2.1 內存的分配方式 - 指針碰撞

如果 Java 堆是規整的, 也就是用過的內存放在一邊, 空閒的內存放在另一邊, 中間放着一個指針作爲分界點的指示器, 那所分配內存就僅僅是把那個指針向空閒空間那邊挪動一段與對象大小相等的距離, 這種分配方式稱之爲 指針碰撞(Bump-the-Pointer). 類似下圖.


1.2.2 內存的分配方式 - 空閒列表

如果 java 堆不是規整的, 也就是說用過的空間和空閒的內存相互交錯, 那就沒有辦法簡單地進行指針碰撞了. 虛擬機就必須維護一個列表, 記錄上哪些內存塊是可用的, 在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例, 並更新列表上的記錄, 這種分配方式稱爲 空閒列表(Free Lis). 類似下圖


選擇哪種分配方式是由 Java 堆是否規整決定的, 而 Java 堆的是否規整又是由採用的垃圾收集器是否帶有壓縮功能決定的.
所以使用 Serial, ParNew 等待有 Compact 過程的收集器時, JVM 採用指針碰撞的方式分配內存既簡單又高效. 而使用 CMS 這種基於 標記-清除 (Mark - Sweep) 算法的收集器時, 採用的是空閒列表方式.
關於垃圾收集器會在後面進行學習.

在分配內存時, 還需要考慮到線程安全問題.

1.2.3 內存的分配 - 線程安全問題

除如何劃分可用空間之外, 還有另外一個需要考慮的問題就是對象創建在虛擬機中是非常頻繁的行爲, 即使是僅僅修改一個指針所指向的位置, 在併發情況下也並不是線程安全的, 可能出現正在給對象 A 分配內存, 指針還沒來得及修改, 對象 B 又同時使用了原來的指針來分配內存的情況. 那麼解決這個問題的方案有兩種.

  • 對分配內存空間的動作進行同步處理, JVM 採用 CAS 機制加上失敗重試的方式, 保證更新操作的原子性. CAS 前面有分析學習過. java 基礎回顧 - 基於 CAS 實現原子操作的基本理解
  • 另一種是把內存分配的動作按照線程劃分在不同的空間之中進行. 即每個線程在 Java 堆中預先分配一小塊私有內存, 這小塊私有內存被稱爲 本地線程分配緩衝 (Thread Local Allocation Buffer), 簡稱 TLAB.
    JVM 在線程初始化時, 同時也會申請一塊指定大小的內存, 只給當前線程使用, 這樣每個線程都單獨擁有一個 Buffer, 如果需要分配內存, 就在自己的 Buffer 上分配, 這樣就不存在競爭的情況, 可以大大提升分配效率. 當 Buffer 容量不夠的時候, 再重新從 Eded 區申請一塊繼續使用.
    TLAB 的目的是在爲新生對象分配內存空間時, 讓每個 Java 應用線程能在使用自己專屬的分配指針來分配空間, 減少同步開銷.

TLAB 只是讓每個線程有私有的分配指針, 但底下存對象的內存空間還是給所有線程訪問的, 只是其他線程無法在這個區域分配而已.
JVM中默認 開啓了 TLAB 方案. 可以使用命令: -XX: +UseTLAB 開啓, -XX: -UseTLAB 關閉.

1.2.4 內存的分配 - 棧上分配

在程序中, 其實有很多對象的作用域都不會逃逸出方法外, 也就是說該對象的生命週期會隨着方法的調用開始而開始, 方法的調用結束而結束, 對於這種對象, 就可以可以考慮不需要分配在堆中.

因爲一旦分配在堆中, 當方法調用結束, 沒有了引用指向該對象, 該對象就需要被 GC 回收, 而如果存在大量的這種情況, 其實對 GC 來說也是一種負擔. 因此, JVM 提供了一種叫棧上分配的概念, 針對那些作用域不會逃逸出方法的對象, 在分配內存時不再將對象實例分配到堆中, 而是將對象屬性打散後分配在棧中, 這樣就會隨着方法的調用結束而回收掉, 不再給 GC 增加額外的無用負擔, 從而提高整體的性能.

代碼示例

public static void main (String[] args) {
  User user = new User();
}

方法中 User 的引用, 就是方法的局部變量, 需要的就是將這個實例打散, 比如 User 實例中有兩個字段, 就把這個實例認作它內部的兩個字段以局部變量的形式分配在棧上.就是所謂的打散, 這個操作成爲標量替換.

棧上分配是通過逃逸分析技術實現的.

棧上分配需要有一定的前提,

  • 開啓逃逸分析 -XX:+DoEscapeAnalysis, JDK1.8 中默認開啓
  • 開啓標量替換 -XX:+EliminateAllocations.
    標量替換的作用是允許將對象根據屬性打散後分配在棧中, 默認也爲開啓.
1.3 內存空間初始化

對象內存分配完後, 虛擬機需要將分配到的內存空間都初始化零值.(如 int 值爲 0, boolean 值爲 false 等.), 但不包括對象頭.
這一步操作保證了對象的實例字段在 Java 代碼中可以不賦值就直接使用, 程序能訪問到這些字段的數據類型所對應的零值.

如果使用了 TLAB, 則這一步操作將提前至分配 TLAB 時.

1.4 設置

接下來, 虛擬機要對對象進行一些必要的設置, 例如這個對象是哪個類的實例, 對象頭信息, 包括類元數據引用, 對象的哈希碼, 對象的GC分代年齡等.

1.5 對象初始化

在上面的工作都完成之後, 從虛擬機的視角來看, 一個新的對象已經產生了, 但從 Java 程序的視角來看, 對象創建纔剛剛開始, 所有的字段都還是零值. 所以一般來說執行 new 指令之後會接着把對象按照程序員的意願進行初始化(執行構造方法), 這樣一個真正可用的對象纔算是完全產生出來.


 

2. 對象的內存佈局

在 HotSpot 虛擬機中, 對象在內存中存儲的佈局可分爲 3 塊區域, 對象頭, 實例數據, 對齊填充. 如下圖所示



在 HotSpot 虛擬機中, 對象在內存中存儲的佈局可分爲三塊區域: 對象頭, 實例數據, 和對齊填充.
對象頭包括兩部分信息:

  • 第一部分用於存儲對象自身運行時的數據, 如哈希碼,分代年齡, 鎖狀態標誌, 線程持有的鎖, 偏向線程 ID, 偏向時間戳等.
  • 另一部分是類型指針, 即對象指向它的類元數據的指針, 虛擬機通過這個指針來確定這個對象是哪個類的實例.

內存佈局中的第三部分也就是對齊填充並不是必然存在的, 也沒有什麼特別的含義, 僅起到佔位符的作用. 由於 HotSpot VM 自動內存管理系統要求對象的大小必須是 8 字節的整數倍. 當對象其他數據部分沒有對齊時, 就需要通過對齊填充來補全.

比如 對象頭 + 實例數據 是 30 個字節, 那麼就會再填充2 個字節.


 

3. 對象的訪問定位

建立對象是爲了使用對象, Java 程序需要通過找到棧上的引用 (reference) 來操作堆上的具體對象. reference 類型在 Java 虛擬機規範中只規定了一個指向對象的引用, 對於如何通過引用定位到對象沒有做出具體的規定. 對象的訪問方式取決於虛擬機的實現, 目前主流的訪問方式有兩種句柄和直接指針, 下來來看這兩種方式的區別.

3.1 句柄

如果使用句柄訪問的話, 那麼 Java 堆中將會劃分出一塊內存來作爲句柄池, reference 中存儲的就是對象的句柄地址, 而句柄中包含了對象實例數據與類型數據各自的具體地址信息. 如下圖所示

3.2 直接指針

如果使用直接指針訪問, 那麼 reference 中存儲的直接就是對象地址, 如下圖

3.3 兩者對比

使用句柄來訪問對象最大的好處就是 reference 中存儲的是穩定的句柄地址. 當對象移動時(垃圾回收時移動對象是非常普遍的行爲)也只會改變句柄的實例數據指針, reference 本身不需要修改.

使用指針來訪問對象的最大好處就是速度更快, 它相比句柄的方式節省了一次指針定位的時間開銷, 由於對象的訪問是非常頻繁的. HotSpot 虛擬機使用的就是直接指針的方式來進行訪問對象.


 

4. 對象的存活/死亡

在堆中幾乎存放了所有的對象實例, 而垃圾回收器在對其進行回收前, 要做的事情就是要確定這些對象中那些還存活着, 判斷對象的存活也有兩種方式.引用計數算法 與 可達性分析算法.

4.1 引用計數算法

在對象中添加一個引用計數器, 每當有一個地方引用它, 計數器就加一, 當引用失效時, 計數器減一 . 當計數器不爲 0 時, 判斷該對象存活. 否則判斷爲死亡(計數器 = 0).

  • 優點: 實現簡單, 判斷高效.
  • 缺點: 無法解決對象間相互循環引用的問題.
4.2 可達性分析算法

很多主流商用語言都採用引用鏈法判斷對象是否存活, 大致思路就是將一系列的 GC Roots 對象作爲起點, 從這些起點開始向下搜索, 搜索所走過的路徑成爲引用鏈. 當一個對象到 GC Roots 沒有任何引用鏈相連時, 則證明此對象是不可用的. 在 Java 語言中, 可作爲 GC Roots 的對象包含以下幾種.

  • 虛擬機棧中引用的對象
    在程序中正常創建一個對象, 對象會在堆上開闢一塊空間, 同時將這塊空間的地址作爲引用保存到虛擬機棧中, 如果對象聲明週期結束了, 那麼引用就會從虛擬機棧中出棧, 因此如果在虛擬機棧中有引用, 就說明這個對象還是有用的, 這是一種最常見的情況.

  • 在類中定義了全局的靜態對象
    也就是使用了 static 關鍵字, 由於虛擬機棧是線程私有的, 所以這種對象的引用會保存在共有的方法區中, 顯然將方法區中的靜態引用作爲 GC Roots 是必須的.

  • 常量引用
    使用了 static final 關鍵字, 由於這種引用初始化後不會修改, 所以方法區常量池裏的引用的對象也作爲了GC Roots

  • 本地方法棧中 JNI 引用的對象
    在使用 JNI 技術時, 有時候單純的 Java 代碼無法滿足需求, 可能需要在 Java 中調用 C/C++ 代碼, 因此會需要用到 Native 方法. JVM 內存中有專門有一塊本地方法棧, 來保存這些對象的引用, 所以本地方法棧中引用的對象也會被作爲 GC Roots.

判斷一個對象到 GC Roots 沒有任何引用鏈相連時, 則判斷該對象不可達.

上面說的回收的都是對象, 那麼類可以進行回收嗎?
可以的, 但是 Class 要被回收的話, 條件非常苛刻, 必須同時滿足以下的條件.

  • 該類所有的實例都已經被回收, 也就是堆中不存在該類的任何實例
  • 加載該類的 ClassLoader 已經被回收
  • 該類的 java.lang.class 對象沒有任何地方被引用. 無法通過反射訪問該類的方法.
  • 參數控制.-Xnoclassgs 禁用類的垃圾回收. 也不能開啓, 一旦使用這個, 會始終被認爲是活動的 .

注意: 可達性分析僅僅只是判斷對象是否可達, 但是還不足以判斷對象是否死亡.

4.3 Finalize

即使通過可達性分析判斷不可達的對象, 也不是 "非死不可", 還會處於一個 "緩刑" 的階段, 真正要宣告一個對象死亡, 還需要經過兩次標記的過程, 一次是沒有找到 GC Roots 引用鏈, 它將被第一次標記. 隨後經歷一次篩選 (篩選條件: 如果沒有實現 Finalize 方法 或者已經調用過 Finalize 方法), 則直接認定爲死亡, 等待被回收. 如果有實現這個方法, 就會先將這個對象放在一個隊列中, 並由虛擬機建立的一個低優先級的線程去執行它, 隨後就會進行第二次標記, 如果對象在這個方法中重新與 GC Roots 建立關係鏈, 那麼二次標記時就會將這個對象移出即將回收的集合, 如果二次標記時沒有重新建立關係鏈, 那麼也被認定爲死亡, 等待被回收.

Object 類中有個方法 Finalize 方法, 虛擬機只會觸發這個方法, 並不承諾等待它執行結束, 這樣做的原因就是如果一個對象在執行 Finalize方法時執行緩慢, 或者發生了死循環, 將可能導致 F-Queue 隊列中的其他對象永久處於等待狀態, 甚至導致整個內存回收系統崩潰. 儘量避免使用這個方法, 因爲無法掌控這個時間.

4.4 四大引用類型
  • StrongReference 強引用
    是使用最普遍的引用. 垃圾回收器絕對不會回收它, 內存不足時寧願拋出 OOM 導致程序異常. 平常的new 對象就是強引用. 例 Object obj = new Object()

  • SoftReference 軟引用
    垃圾回收器在內存充足時不會回收軟引用對象, 但是在將要發生 OOM 時, 這些對象就會被回收. 適合用於創建緩存.

  • WeakReference 弱引用
    用弱引用關聯的對象只能生存到下一次垃圾回收之前, 當 GC 發生時, 不管內存夠不夠, 都會被回收.

  • PhantomReference 虛引用
    也稱爲幽靈引用, 最弱, 隨時都有可能被回收. 唯一的作用就是監控垃圾回收器是否正常工作..


 

5. 對象的分配策略

其實在爲對象在堆中分配空間的時候, 需要遵從以下原則.

  • 對象優先在 Eden 區分配
  • 大對象直接進入老年代
  • 長期存活的對象進入到年代
  • 動態對象分代年齡判斷
  • 空間分配擔保
5.1 對象優先在 Eden 區分配

如果經過逃逸分析, 發現對象無法在棧中分配, 那麼就還是需要在堆中進行分配, 首先會優先將新生對象分配在堆中的 Eden 區. Eden 區內存不足就會觸發 MinorGC 清理 Eden 區. 在這個區域(新生代)的對象都是朝生夕死, 是對象最頻繁發生變動的區域

5.2 大對象直接進入老年代

什麼是大對象? 需要大量連續空間的對象, 如長字符串, 大數組等, 會直接在老年代分配內存, 這樣做的目的是避免在Eden區, from 和 to 區之間發生大量的內存拷貝.

新生代採用複製算法, 新生對象會被優先分配到 Eden 區, 當這些對象經歷過一次 Minor GC 後, 如果仍然存活就會移動到 from 區. 此時 to 區是空的.
下一次再發生 Minor GC 後, 會將 Eden 區與 from 區所有垃圾對象清除, 並將存活的對象複製至 to 區. 此時 from 區爲空.
每複製一次, 對象頭中的分代年齡都加一. 如此反覆在 from 與 to 區之間切換的次數超過了默認的最大分代年齡後, 依然存活的對象將會被移動到老年代中.

5.3 長期存活的對象進入到老年代

在 Eden 區,from 和 to 區的對象, 每移動一次, 對象的分代年齡就會加 1, 當達到閾值 15 時, 對象就會從年輕代移動到老年代.



上圖是對象頭中存儲的分代年齡, 存儲是按 byte 位存儲, 無論是 32 位還是 64 位虛擬機存放的都是4 位, 那麼分代年齡能夠記錄的最大值也就是 1111, 轉爲 10 進製爲 15. 也就是說分代年齡最大值爲 15, 在新生代中通過複製移動超過 15 次後, 就會認爲是需要長期存活的對象, 被移動到老年代. 移動到老年代後, 對象頭中的分代年齡失效.

也可以設置分代年齡的最大閾值: -XX:MaxTenuringThreshold= 閾值 (CMS 垃圾收集器的默認值爲 6).

5.4 動態對象分代年齡判斷

虛擬機並不是永遠的要求分代年齡必須達到閾值後才能晉升老年代, 如果 from 區和 to 區中相同年齡的所有對象大小的總和大於 from 區和 to 區空間的一半, 年齡大於或等於該年齡的對象可直接進入到老年代.

5.5 空間分配擔保

當垃圾收集器準備要在新生代發起一次 Minor GC 時, 首先會檢查老年代中最大的連續空間區域的大小是否 大於 新生代中所有對象的大小 或者 歷次晉升的平均大小. 如果大於就會進行 Minor GC, 否則進行 Major GC (Major GC 會回收老年代)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章