詳解對象的創建,布局,定位,對象存活判斷
我們在創建普通對象的時候只需要new關鍵字就解決了,但是在new的背後到底經歷了什麼呢?我們創建一個對象的過程到底是什麼樣子呢?
1 對象的創建
我們的Java虛擬機在遇到一條位元組碼new指令時,首先經歷以下的步驟:
我們先不介紹類載入過程,後面如果出了相關博文會在這裡給一個超鏈接(點擊跳轉)。
在我們的類檢查通過後,也就是到了我們的虛擬機為我們的新生對象分配記憶體。我們的記憶體分配方式有兩種
1.1 指針碰撞
假設Java堆中記憶體是絕對規整的,所有被使用過的記憶體都放在一邊,空閑的記憶體被放在另一邊,中間放著一個指針作為分界點的指示器,那所分配的記憶體就僅僅是把那個指針向空閑方向挪動一段與對象大小相等的舉例,這種分配方式稱為「指針碰撞」(Bump ThePointer)。一般使用Serial、ParNew等帶壓縮整理過程的收集器。
1.2 空閑列表
假設Java堆中的記憶體並不是規整的,已被使用的記憶體和空閑的記憶體相互交錯在一起,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式稱為「空閑列表」(Free List)。一般使用CMS這種基於清除(Sweep)演算法的收集器。
注意,因為我們的虛擬機創建對象是非常頻繁的,所以僅僅只是修改一個指針的位置,在並發里也是不安全的。比如給A對象分配記憶體,指針還沒來得及修改,對象B又同時使用了原來的指針來分配記憶體的情況。我們針對這種並發安全的問題也提出了兩種解決方案:
- 同步處理。對我們的分配記憶體空間的操作進行同步處理,採用CAS配上自旋的方式保證更新操作的原子性。
- 本地執行緒分配緩衝。把我們分配記憶體的動作按照執行緒劃分在不同的空間之中進行,也就是每個執行緒在Java堆中都預先分配一小塊記憶體,稱為本地執行緒分配緩衝區。哪個執行緒要分配記憶體,就在哪個執行緒的本地緩衝區分配,只有本地緩衝區用完了,分配新的緩衝區時才需要同步鎖定。虛擬機是否使用緩衝區,可以通過參數-XX:+/-UseTLAB參數來設定。
分配完記憶體就需要將我們的記憶體空間都初始化為零值了。然後開始往我們的對象的對象頭裡填充一些資訊,比如該對象是哪個類的實例、如何才能找到類的元數據資訊、對象的哈希碼、對象的GC分代年齡等資訊。至此,我們從虛擬機的角度來看,一個對象已經產生了。但是從Java程式來看,我們還需要進行構造函數(
(補充:為了能在多數情況下能夠更快的分配記憶體,設計了一個叫作LinearAllocation Buffer的分配緩衝區,通過空閑列表拿到一大塊分配緩衝區之後,在它裡面仍然可以使用指針碰撞方式來分配。)
2 對象的布局
我們上面介紹了到了在虛擬機中一個對象的創建,我們接下來介紹的就是對象在堆記憶體中的存儲布局。可以劃分為三個部分:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。
2.1對象頭
我們的對象頭主要包括了兩類資訊。
- 第一類是用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,簡稱「Mark Word」。
- 第二類便是類型指針,即對象指向它的類型元數據的指針,Java虛擬機通過這個指針來確定該對象是哪個類的實例。(但是並不是所有對象都會保留的,在下面定位的時候會具體談到!)
關於我們的Mark Word會根據對象的狀態來複用空間,也就是處於什麼狀態,就會如何分配我們的比特存儲空間。比如處於對象未被同步鎖鎖定的狀態下(無鎖態),Mark Word的32個比特存儲空間中的25個比特用於存儲對象哈希碼,4個比特用於存儲對象分代年齡,2個比特用於存儲鎖標誌位,1個比特固定為0。下面給上其他狀態的空間分布:
2.2實例數據
實例數據是我們對象真正存儲的有效資訊,也就是我們在程式程式碼裡面所定義的各種類型的欄位內容,無論是從父類繼承下來的,還是在子類中定義的欄位都必須記錄起來。具體的存儲順序可以受到虛擬機分配策略參數(-XX:FieldsAllocationStyle參數)和欄位在Java源碼中定義順序的影響。
2.3 對齊填充
沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot虛擬機的自動記憶體管理系統要求對象起始地址必須是8位元組的整數倍,換句話說就是任何對象的大小都必須是8位元組的整數倍。對象頭部分已經被精心設計成正好是8位元組的倍數(1倍或者2倍),因此,如果對象實例數據部分沒有對齊的話,就需要通過對齊填充來補全。
3 對象的定位
我們創建對象是為了後續使用對象,Java程式會通過棧上的reference數據(指向對象的引用)來操作堆上的具體對象。具體的主流對象訪問方式主要使用句柄和直接指針兩種。
3.1 句柄訪問
如果使用句柄訪問的話,Java堆中將可能會劃分出一塊記憶體來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自具體的地址資訊,結構如下:
使用句柄來訪問的最大好處就是reference中存儲的是穩定句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而reference本身不需要被修改。
3.2 直接指針
如果使用直接指針的話,那麼我們的Java堆中對象的記憶體布局就必須考慮如何放置訪問類型數據的相關資訊,reference中存儲的直接就是對象實例數據,如果只是訪問對象本身的話,就會了少了一次間接訪問的開銷,結構如下:
使用直接指針訪問的優點是速度快,少了一次指針定位的時間開銷。如果對象訪問十分頻繁的話,那麼便是極為可觀的執行成本!
4 對象存活判斷
我們的對象在被垃圾回收器回收的時候會進行判斷對象是否存活,然後才選擇是否回收。而我們進行判斷的兩種方式如下:
4.1 引用計數演算法
在對象中添加一個引用計數器,如果被引用計數器加 1,引用失效時計數器減 1,如果計數器為 0 則被標記為垃圾。原理簡單,效率高,但是在 Java 中很少使用,因為存在對象間循環引用的問題,導致計數器無法清零。
4.2 可達性分析演算法
主流語言的記憶體管理都使用可達性分析判斷對象是否存活。基本思路是通過一系列稱為 GC Roots 的根對象作為起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程走過的路徑稱為引用鏈,如果某個對象到 GC Roots 沒有任何引用鏈相連,則會被標記為垃圾。可作為 GC Roots 的對象包括虛擬機棧和本地方法棧中引用的對象、類靜態屬性引用的對象、常量引用的對象。
4.3 談談四種引用
在上面對象定位說到了reference是傳統的某塊記憶體、對象的引用。但是在JDK1.2之後,我們的引用被細分成了四種引用,通過強弱依次遞減分別是,強引用,軟引用,弱引用,虛引用四種。
- 強引用。最常見的引用,例如
Object obj = new Object()
就屬於強引用。只要對象有強引用指向且 GC Roots 可達,在記憶體回收時即使瀕臨記憶體耗盡也不會被回收。 - 軟引用。弱於強引用,描述非必需對象。在系統將發生記憶體溢出前,會把軟引用關聯的對象加入回收範圍以獲得更多記憶體空間。用來快取伺服器中間計算結果及不需要實時保存的用戶行為等。
- 弱引用。弱於軟引用,描述非必需對象。弱引用關聯的對象只能生存到下次 YGC (Young GC)前,當垃圾收集器開始工作時無論當前記憶體是否足夠都會回收只被弱引用關聯的對象。由於 YGC 具有不確定性,因此弱引用何時被回收也不確定。
- 虛引用。最弱的引用,定義完成後無法通過該引用獲取對象。唯一目的就是為了能在對象被回收時收到一個系統通知。虛引用必須與引用隊列聯合使用,垃圾回收時如果出現虛引用,就會在回收對象前把這個虛引用加入引用隊列。
5 參考資料
深入理解Java虛擬機:JVM高級特性(第三版)