對於從事C、C++程式開發人員來說,在記憶體管理領域,他們既是擁有最高權力的“皇帝”又是從事最基礎工作的“勞動人民”--既擁有每一個物件的“所有權”,又負責每一個物件生命開始到終結的維護責任。
對於Java程式設計師來說,在虛擬機器自動記憶體管理機制的幫助下,不再需要為每一個new操作去寫配對的delete/free程式碼,不容易出現記憶體洩漏和記憶體溢位問題,由虛擬機器管理記憶體這一切看起來都很美好。不過,也正是因為Java程式設計師把記憶體控制的權力交給了Java虛擬機器,一旦出現記憶體洩漏和記憶體溢位方面的問題,如果不瞭解虛擬機器是怎樣使用記憶體的,那麼排查錯誤將會成為一項異常艱難的工作。
1 執行時資料區域
Java虛擬機器在執行Java程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域。這些區域都有各自的用途,以及建立和銷燬的時間,有的區域隨著虛擬機器程式的啟動而存在,有些區域則是依賴使用者執行緒的啟動和結束而建立和銷燬。
資料區域 | 程式計數器 | Java虛擬機器棧 | 本地方法棧 | Java堆 | 方法區 | 執行時常量池 | 直接記憶體 |
生命週期 | 與執行緒相同 | 與執行緒相同 | 與執行緒相同 | 與虛擬機器相同 | 與虛擬機器相同 | 與虛擬機器相同 | 與虛擬機器相同 |
所屬執行緒 | 執行緒私有 | 執行緒私有 | 執行緒私有 | 執行緒共享 | 執行緒共享 | 執行緒共享 | 執行緒共享 |
記憶體溢位 丟擲異常 |
無 |
OOM、SOF |
OOM、SOF |
OOM | OOM | OOM | OOM |
1.1 程式計數器(Program Counter Register)
程式計數器是一塊較小的記憶體空間,它的作用可以看做是當前執行緒所執行的位元組碼的行號指示器。由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)只會執行一條執行緒中的指令。因此,每條執行緒都需要一個獨立的程式計數器,各條執行緒之間的計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。
1.2 Java虛擬機器棧(Java Virtual Machine Stacks)
Java虛擬機器棧也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法被執行的時候都會同時建立一個棧幀(stack frame)用於儲存區域性變數表、操作棧、動態連結、方法出口等資訊。每一個方法被呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。
1.3 本地方法棧(Native Method Stacks)
與虛擬機器棧相似,其區別不過是虛擬機器棧執行Java方法(也就是位元組碼)服務,而本地方法棧則是為虛擬機器使用到的Native方法服務。
被native關鍵字修飾的方法叫做本地方法,本地方法和其它方法不一樣,本地方法意味著和平臺有關,因此使用了native的程式可移植性都不太高。另外native方法在JVM中執行時資料區也和其它方法不一樣,它有專門的本地方法棧。native方法主要用於載入檔案和動態連結庫,由於Java語言無法訪問作業系統底層資訊(比如:底層硬體裝置等),這時候就需要藉助C語言來完成了。被native修飾的方法可以被C語言重寫。
1.4 Java堆(Java Heap)
對於大多數應用來說,Java堆是Java虛擬機器所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。
1.5 方法區(Method Area)
方法區與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。
1.6 執行時常量池(Runtime Constant Pool)
執行時常量池是方法區的一部分。Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。
1.7 直接記憶體
直接記憶體並不是虛擬機器執行時資料區的一部分,但是這部分記憶體也被頻繁地使用,也可能導致OOM異常出現。在JDK1.4中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O方式,它可以使用Native函式庫直接分配堆外記憶體,然後通過一個儲存在Java堆裡面的DirectByteBuffer物件作為這塊記憶體的引用進行操作。在一些場景中可以避免在Java堆和Native堆中來回複製資料,從而提高效能。
2 HotSpot虛擬機器物件探祕
學習了Java虛擬機器的執行時資料區之後,我們大致知道了虛擬機器記憶體的概況,接下來我們進一步瞭解虛擬機器記憶體中的資料的其他細節:物件是如何建立、如何佈局及如何訪問的。本文總結了HotSpot虛擬機器在Java堆中物件分配、佈局和訪問的全過程。
2.1 物件的建立
Java是一門物件導向的程式語言,在Java程式執行過程中無時無刻都有物件被建立。在語言層面上,建立物件(如克隆、反序列化)通常僅僅是一個new關鍵字而已,而在虛擬機器中,普通Java物件的建立又是怎樣一個過程呢?
虛擬機器遇到一條new指令時,首先將去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過。如果沒有,則須先執行相應的類載入過程。
類載入檢查通過後,接下來虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來。
記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭),如果使用TLAB(Thread Local Allocation Buffer),這一工作過程也可以提前至TLAB分配時進行。這一步操作保證了物件的例項欄位在Java程式碼中可以不賦初值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。
接下來,虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的後設資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊存放在物件的物件頭(Object Header)之中。根據虛擬機器當前的執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式。
在上面的工作都完成之後,從虛擬機器的視角來看,一個新的物件已經產生了,但從Java程式的視角來看,物件建立才剛剛開始----<init>方法還沒執行,所有的欄位都還為零。所以,一般來說,執行new指令之後會接著執行<init>方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全產生出來了。
2.2 物件的記憶體佈局
在HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以分為3塊區域:物件頭(Header)、例項資料(Instance Data)、和對齊填充(Padding)
物件頭
物件頭包括兩部分資訊,第一部分用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。
物件頭的另外一部分是型別指標,即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項,但是並不是所有虛擬機器實現都必須在物件資料上保留型別指標,也就是查詢物件的後設資料資訊並不一定要經過物件本身。另外,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料。
例項資料
接下來的例項資料部分是物件真正儲存的有效資訊,及各種型別的欄位內容,無論是從父類繼承還是在子類中定義的,都需要記錄。
對齊填充
第三部分對齊填充並不是必然存在的,也沒有特別含義,僅僅起著佔位符的作用。這是由於HotSpot虛擬機器的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,也就是說物件的大小必須是8位元組的整數倍,而物件頭部分正好是8位元組的倍數(1或2倍),因此當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。
2.3 物件的訪問定位
建立物件是為了使用物件,我們的Java程式需要通過棧上的reference資料來操作堆上的具體物件。對於應該通過何種方式去定位、訪問堆中的物件的具體位置,目前主流的訪問方式有使用控制程式碼和直接指標兩種方式。
控制程式碼
如果使用控制程式碼訪問的話,那麼Java堆中將會劃分出一塊記憶體來作為控制程式碼池,reference中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與型別資料各自的具體地址資訊。
直接指標
如果使用直接指標訪問方式,Java堆物件的佈局中就必須考慮如何放置訪問型別資料的相關資訊,reference中直接儲存的就是物件地址。
兩種方式比較
使用控制程式碼訪問方式的最大好處就是reference中儲存的是穩定的控制程式碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制程式碼中的例項資料指標,而reference本身不需要被修改。
使用直接指標訪問的方式的最大好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件的訪問在Java中非常頻繁,因此這列開銷積少成多後也是一項非常可觀的執行成本。
對於HotSpot而言,它是使用第二種方式進行物件訪問的,但從整個軟體開發的範圍來看,各種語言和框架使用控制程式碼來訪問的情況也十分常見。