前言
在真實的世界裡,每個人都是一個物件,從出生到長大再到死亡是一個完整的生命週期。而在計算機的世界裡,物件也會有它的生命週期,包括物件的建立、物件的記憶體佈局、物件的訪問和物件的銷燬。C++ 中物件是這樣,Java 中物件也是這樣。只是在 C++ 裡物件的生命週期完全由程式設計師掌控,包括建立、使用和回收。而在 Java 中程式設計師只需要負責建立和使用物件即可,回收全權交給 Java 虛擬機器。建立物件易,銷燬物件難。作為 Java 程式設計師,雖然虛擬機器給我們做了物件回收,但不代表我們不用去理解一個完整的物件生命週期。只有理解了物件從生到死的過程,才能對物件愛恨情深。
物件的建立
1、建立物件的流程
在程式設計師看來建立物件大部分情況下就是一個 new 關鍵字,而在虛擬機器中遠遠不止這麼簡單。它至少包括如下幾個階段:
- 載入該物件所在的類檔案(就是編譯後的.class 檔案)進入記憶體
- 在堆上分配一塊跟類物件大小一樣的記憶體
- 將物件所在的記憶體值初始化為 0
- 初始化物件頭,包括、物件的雜湊碼、物件的GC分代年齡等資訊
- 呼叫類的 init 函式進行 Java 層面的物件初始化
流程圖如下:
2、分配物件記憶體的方式
都知道 Java 生成物件需要分配記憶體,虛擬機器又是怎樣從記憶體池中分配一塊記憶體給新建立的物件呢?虛擬機器的實現中主要有以下兩種方法:
- 指標碰撞法
如果 Java 堆中的記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離,這種分配方式稱為“指標碰撞”(Bump the Pointer)。
- 空閒列表法:
以上兩種方法只考慮了物件記憶體的分配,其實還有記憶體的回收,也是虛擬機器需要做的。記憶體的分配與回收在C/C++ 中討論的比較多,對於 Java 物件建立來說這裡不是重點,只需要知道物件記憶體分配有這兩種方式就行,它們各有利弊。多說一句,物件記憶體分配和回收的設計,我只服 nginx,nginx 將記憶體資源管理這塊玩的淋漓盡致,有興趣的可以看看相關原始碼。如果 Java 堆中的記憶體並不是規整的,已使用的記憶體和空閒的記憶體相互交錯,那就沒有辦法簡單地進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式稱為“空閒列表”(Free List)。
物件的佈局
Java 物件並不只是包含我們在 class 中所定義那部分例項資料,還需要包含虛擬機器所需要的一些額外資訊以及空填充,即物件頭(Header)、實體資料(Instance Data)和對齊填充(Padding)三個部分,如下圖所示:
- 物件頭中的 Mark Word 在 32 和 64 位的虛擬機器中大小為 32bit 和 64bit,型別指標同理,在物件頭中欄位特性如下:
- Mark Word 被設計成一個非固定的資料結構以便在極小的空間記憶體儲儘量多的資訊,它會根據物件的狀態複用自己的儲存空間。具體不同狀態不同複用效果見下圖
- 型別指標指向該物件所屬的類的 Class 物件,但該欄位並不是在所有虛擬機器中都是必須的。要看虛擬機器如何實現物件的訪問方式,這裡下一節討論。
- 另外如果物件是一個陣列,物件頭中還需要儲存陣列長度的資訊。這樣虛擬機器才能從物件後設資料中確定物件的大小。
- 物件的例項資料才是使用者可見的、可控制的,包括從所有父類(直到Object)中繼承的,以及子類中定義的。需要注意的是所有的靜態變數、區域性變數和函式並不包含在物件記憶體的例項資料中:
靜態變數和函式是屬於類的,一個類只有一份,它在類載入的時候存進方法區。 函式作為類的後設資料也存在方法區(同時會受到即時編譯的影響)。 區域性變數在方法執行時在棧中進行動態分配的,主要位置是區域性變數表。
- 對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說,就是物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的倍數(1倍或者2倍),因此,當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。
C++ 中有一本廣為流傳的書《深入理解 C++ 物件模型》專門講 C++ 物件模型的,整整寫成了一本書。可見相對於 C++ 物件,Java 要容易的多。C++ 中要計算一個物件的大小,直接使用 sizeof 即可。而在 Java 中卻不能那麼直接,如果想更具體的瞭解 Java 物件在記憶體中的大小,請看 一個java物件佔多大記憶體 這篇文章,自己實踐下。其實,只要知道了物件在記憶體中的組成(頭、例項資料和填充)、例項資料中各個欄位型別的大小,就能輕易算出物件的整體大小。
物件的訪問
就像建立自然世界的人主要目的就是活著,建立計算機世界的物件主要是為了使用它。Java 物件儲存在堆上的,而它的引用主要儲存在棧上,引用中存放的是物件在堆中的地址。這句話也對也不對,不對在後半句,引用中存放的是啥要看虛擬機器訪問物件方式的具體實現,主流的有以下兩種:
- 控制程式碼:reference 中儲存的是物件例項資料指標的指標,要通過兩次定址才能訪問到物件例項,具體流程見下圖:
- 直接指標:reference 中儲存的是物件例項資料,與大部分人理解的一致。此時,物件的型別資料指標放到 Mark Word 中去(不理解的可以回看物件頭的介紹)。具體的訪問流程圖如下:
這下別人再問你 reference 中儲存的是什麼的時候,別急著回答說是物件的地址哦,也有可能是物件地址的地址。
物件的銷燬
這是物件生命週期中最複雜的部分,也是 Java 的精髓所在。在 C++ 中一個 new 出來的物件如果不再需要使用的時候,需要手動 DELETE 才行。而 Java 中靠虛擬機器來完成回收,虛擬機器 GC(Gargbage Collection)物件記憶體要解決如下兩個問題:
何時回收?
當一個物件還在被使用的時候顯然是不能被回收的,只有死亡了的(不再使用的)物件才能進行記憶體回收,那如何判斷物件是否不再使用呢?
- 引用計數器法
- 原理:給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減 1;任何時刻計數器為 0 的物件就是不可能再被使用的,如上圖中的 Object A。
- 優點:原理簡單,使用簡單,判定效率高。
- 缺點:很難解決物件之間相互迴圈應用的額問題,除非禁止迴圈引用。
- 實踐:微軟公司的 COM 技術,Python 語言等。
- 可達性判定法
- 原理:通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是從GC Roots到這個物件不可達)時,則證明此物件是不可用的,如上圖中的 Object5、Object6 和 Object7。
- 優點:可以解決迴圈引用的問題,滿足各種情況下的需求。
- 缺點:實現複雜,遍歷的效率低。
- 實踐:Java 的主流虛擬機器。
如何回收
找到了需要被回收的物件,如何進行回收也是個技術活。一方面它影響到虛擬機器的回收效能,另一方面也會影響到物件記憶體的分配。所以回收演算法很重要,主要有以下幾種演算法:
- 標記-清除(Mark-Sweep)演算法:分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。
- 複製(Copying)演算法:它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。
- 標記-整理(Mark-Compact)演算法:標記過程與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。
- 分代收集(Generation-Collection)演算法:Collection)演算法,這種演算法並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。。
由於這些演算法涉及的內容比較多,這裡只丟擲概念,後面還會整理一篇文章專門講虛擬機器的垃圾回收,這也是 Java 虛擬機器中的重中之重,不想三言兩語的帶過,也不想在本篇文章大篇幅的介紹。 說起來讀者可能不信,在上家公司的一款上線產品(C++實現)中,我們的專案中幾乎不允許使用 new(程式導向程式設計),就是怕一不小心導致記憶體洩露。真的是如履薄冰,不過我覺得這樣做有點因噎廢食了,完全沒有體現 C++ 物件導向的優勢。
總結
C++ 下多執行緒網路庫 muduo 的作者陳碩在它的《Linux多執行緒服務端程式設計》中提到的“建立物件很簡單,銷燬太難”,看了 Java 的物件生命週期管理才終於明白此言不虛。感謝偉大的 Java 發明者,把物件生命週期管理中最容易的部分給了使用者,把最難的部分留給了自己。