深入分析JVM中的物件及引用(十六)

nandao158發表於2020-09-28

今天分析JVM中的物件建立過程和使用流程:

、JVM 中物件的建立過程

 

1、物件的記憶體分配
虛擬機器遇到一條 new 指令時,首先檢查是否被類載入器載入,如果沒有,那必須先執行相應的類載入過程。
類載入就是把 class 載入到 JVM 的執行時資料區的過程(類載入後面有講解)。
1 )檢查載入
首先檢查這個指令的引數是否能在常量池中定位到一個類的符號引用( 符號引用 : 符號引用以一組符號來描述所引用的目標 ),並且檢查類是否已經被載入、
解析和初始化過。
2 )分配記憶體
接下來虛擬機器將為新生物件分配記憶體。為物件分配空間的任務等同於把一塊確定大小的記憶體從 Java 堆中劃分出來。
 
2、指標碰撞
如果 Java 堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅
是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離,這種分配方式稱為“ 指標碰撞 ”。
 
3、空閒列表
如果 Java 堆中的記憶體並不是規整的,已使用的記憶體和空閒的記憶體相互交錯,那就沒有辦法簡單地進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上 哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式稱為“空閒列表 ”。
 
 
選擇哪種分配方式由 Java 堆是否規整決定,而 Java 堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。
(這部分知識先了解,後續結合垃圾回收器一起去理解)
如果是 Serial ParNew 等帶有壓縮的整理的垃圾回收器的話,系統採用的是指標碰撞,既簡單又高效。
如果是使用 CMS 這種不帶壓縮(整理)的垃圾回收器的話,理論上只能採用較複雜的空閒列表。
 
4、併發安全
除如何劃分可用空間之外,還有另外一個需要考慮的問題是物件建立在虛擬機器中是非常頻繁的行為,即使是僅僅修改一個指標所指向的位置,在併發情 況下也並不是執行緒安全的,可能出現正在給物件 A 分配記憶體,指標還沒來得及修改,物件 B 又同時使用了原來的指標來分配記憶體的情況。
5、CAS 機制
解決這個問題有兩種方案,一種是對分配記憶體空間的動作進行同步處理——實際上虛擬機器採用 CAS 配上失敗重試的方式保證更新操作的原子性;
 
6、分配緩衝
另一種是把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在 Java 堆中預先分配一小塊私有記憶體,也就是本地執行緒分配緩衝( Thread Local Allocation Buffer,TLAB), JVM 線上程初始化時,同時也會申請一塊指定大小的記憶體,只給當前執行緒使用,這樣每個執行緒都單獨擁有一個 Buffer ,如果 需要分配記憶體,就在自己的 Buffer 上分配,這樣就不存在競爭的情況,可以大大提升分配效率,當 Buffer 容量不夠的時候,再重新從 Eden 區域申請一塊 繼續使用。 TLAB 的目的是在為新物件分配記憶體空間時,讓每個 Java 應用執行緒能在使用自己專屬的分配指標來分配空間,減少同步開銷。 TLAB 只是讓每個執行緒有私有的分配指標,但底下存物件的記憶體空間還是給所有執行緒訪問的,只是其它執行緒無法在這個區域分配而已。當一個 TLAB 用滿(分 配指標 top 撞上分配極限 end 了),就新申請一個 TLAB
引數:
 
-XX:+UseTLAB
允許在年輕代空間中使用執行緒本地分配塊( TLAB )。預設情況下啟用此選項。要禁用 TLAB ,請指定 -XX:-UseTLAB
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
 
 
 
3 )記憶體空間初始化
(注意不是構造方法)記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零值 ( int 值為 0 boolean 值為 false 等等 ) 。這一步操作保證了物件
的例項欄位在 Java 程式碼中可以不賦初始值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。
4 )設定
接下來,虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的後設資料資訊( Java classes Java hotspot VM 內部表示為類
後設資料)、物件的雜湊碼、物件的 GC 分代年齡等資訊。這些資訊存放在物件的物件頭之中。
 
5 )物件初始化
在上面工作都完成之後,從虛擬機器的視角來看,一個新的物件已經產生了,但從 Java 程式的視角來看,物件建立才剛剛開始,所有的欄位都還為零值。
所以,一般來說,執行 new 指令之後會接著把物件按照程式設計師的意願進行初始化 ( 構造方法 ) ,這樣一個真正可用的物件才算完全產生出來。
 
物件的記憶體佈局如圖:
 
HotSpot 虛擬機器中,物件在記憶體中儲存的佈局可以分為 3 塊區域:物件頭( Header )、例項資料( Instance Data )和對齊填充( Padding )。 物件頭包括兩部分資訊,第一部分用於儲存物件自身的執行時資料,如雜湊碼(HashCode )、 GC 分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等。 物件頭的另外一部分是型別指標,即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。 如果物件是一個 java 陣列,那麼在物件頭中還有一塊用於記錄陣列長度的資料。 第三部分對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於 HotSpot VM 的自動記憶體管理系統要求對物件的大小必須 是 8 位元組的整數倍。當物件其他資料部分沒有對齊時,就需要通過對齊填充來補全。
4、物件的訪問定位
建立物件是為了使用物件,我們的 Java 程式需要通過棧上的 reference 資料來操作堆上的具體物件。目前主流的訪問方式有使用控制程式碼和直接指標兩種。
1)控制程式碼
如果使用控制程式碼訪問的話,那麼 Java 堆中將會劃分出一塊記憶體來作為控制程式碼池, reference 中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與類 型資料各自的具體地址資訊。 使用控制程式碼來訪問的最大好處就是 reference 中儲存的是穩定的控制程式碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制程式碼中的實 例資料指標,而 reference 本身不需要修改 .
2)直接指標
如果使用直接指標訪問, reference 中儲存的直接就是物件地址。 這兩種物件訪問方式各有優勢,使用直接指標訪問方式的最大好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件的訪問在 Java 中非常頻 繁,因此這類開銷積少成多後也是一項非常可觀的執行成本。 對 Sun HotSpot 而言,它是使用直接指標訪問方式進行物件訪問的。
3)判斷物件的存活
在堆裡面存放著幾乎所有的物件例項,垃圾回收器在對對進行回收前,要做的事情就是確定這些物件中哪些還是“存活”著,哪些已經“死去”(死去 代表著不可能再被任何途徑使用得物件了)
5、什麼是垃圾? C 語言申請記憶體: malloc free
C++ new delete
C/C++ 手動回收記憶體
Java: new
Java 是自動記憶體回收,程式設計上簡單,系統不容易出錯。
手動釋放記憶體,容易出兩種型別的問題:
1 、忘記回收
2 、多次回收
沒有任何引用指向的一個物件或者多個物件(迴圈引用)
 
1)引用計數法
在物件中新增一個引用計數器,每當有一個地方引用它,計數器就加 1 ,當引用失效時,計數器減 1.
 
 
Python 在用,但主流虛擬機器沒有使用,因為存在物件相互引用的情況,這個時候需要引入額外的機制來處理,這樣做影響效率,
 
 
 
 
在程式碼中看到,只保留相互引用的物件還是被回收掉了,說明 JVM 中採用的不是引用計數法。
2)可達性分析
面試時重要的知識點,牢記
來判定物件是否存活的。這個演算法的基本思路就是通過一系列的稱為“ GC Roots ”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為 引用鏈(Reference Chain ),當一個物件到 GC Roots 沒有任何引用鏈相連時,則證明此物件是不可用的。 作為 GC Roots 的物件包括下面幾種 (重點是前面 4 種)
 
虛擬機器棧(棧幀中的本地變數表)中引用的物件;各個執行緒呼叫方法堆疊中使用到的引數、區域性變數、臨時變數等。
方法區中類靜態屬性引用的物件;java 類的引用型別靜態變數。
方法區中常量引用的物件;比如:字串常量池裡的引用。
本地方法棧中 JNI (即一般說的 Native 方法)引用的物件。
  JVM 的內部引用( class 物件、異常物件 NullPointException OutofMemoryError ,系統類載入器)。(非重點)
所有被同步鎖(synchronized 關鍵 ) 持有的物件。(非重點)
JVM 內部的 JMXBean JVMTI 中註冊的回撥、原生程式碼快取等(非重點)
JVM 實現中的“臨時性”物件,跨代引用的物件( 在使用分代模型回收只回收部分代的物件,這個後續會細講,先大致瞭解概念 )(非重點)
 
以上的回收都是物件,類的回收條件:
注意 Class 要被回收,條件比較苛刻,必須同時滿足以下的條件(僅僅是可以,不代表必然,因為還有一些引數可以進行控制):
1 、該類所有的例項都已經被回收,也就是堆中不存在該類的任何例項。
2 、 載入該類的 ClassLoader 已經被回收。
3、 該類對應的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
4 、 引數控制:
 
 
廢棄的常量和靜態變數的回收其實就和 Class 回收的條件差不多。
Finalize 方法
即使通過可達性分析判斷不可達的物件,也不是“非死不可”,它還會處於“緩刑”階段,真正要宣告一個物件死亡,需要經過兩次標記過程,一次是 沒有找到與 GCRoots 的引用鏈,它將被第一次標記。隨後進行一次篩選(如果物件覆蓋了 finalize ),我們可以在 finalize 中去拯救。
程式碼演示:
 
 
執行結果:
 
 
可以看到,物件可以被拯救一次 (finalize 執行第一次,但是不會執行第二次 )
程式碼改一下,再來一次。
 
執行結果:
 
 
物件沒有被拯救,這個就是 finalize 方法執行緩慢,還沒有完成拯救,垃圾回收器就已經回收掉了。 所以建議大家儘量不要使用 finalize ,因為這個方法太不可靠。在生產中你很難控制方法的執行或者物件的呼叫順序,建議大家忘了 finalize 方法!因為在 finalize 方法能做的工作, java 中有更好的,比如 try-finally 或者其他方式可以做得更好。下篇分析JVM中物件的引用和分配策略,敬請期待!
 
 
 
 

相關文章