1. 程式人生 > >JVM系列文章(一):Java記憶體區域分析

JVM系列文章(一):Java記憶體區域分析

所有內容均參考自《深入理解Java虛擬機器:JVM高階特性與最佳實踐》(第二版),感謝作者。 本文是系列文章第一篇,講述的是Java記憶體區域,即在虛擬機器上,資料是怎麼儲存的。

一、執行時資料區域

執行時資料區分為兩個部分,一部分由所有執行緒共享,一部分是各個執行緒私有的。 執行緒共享的資料區包括方法區和堆,執行緒私有的資料區包括虛擬機器棧、本地方法棧和程式計數器。如下圖所示:

(圖片來自網上圖片庫)

下面我們分別對這些區域進行介紹:

1.程式計數器

一塊較小的記憶體空間,可以看做是當前執行緒所執行的位元組碼的行號指示器。

如果執行緒正在執行的是一個JAVA方法,計數器值為當前執行的虛擬機器位元組碼指令的地址;如果正在執行的是Native方法,計數器值為空。

這個記憶體區域是唯一一塊絕對不會出現OutOfMemoryError的區域。

2.虛擬機器棧

執行緒私有,它的生命週期與執行緒相同。描述的是Java方法執行的記憶體模型: 每個方法在執行的時候都會建立一個棧幀,用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。每個方法從呼叫直至完成的過程,對應一個棧幀在虛擬機器棧中入棧到出棧的過程。

區域性變量表:

區域性變量表存放了編譯時可知的基本資料型別(8種,boolean等)、物件引用(指向物件起始地址的引用指標,或者是指向一個代表物件的控制代碼,或者是其他與此物件相關的位置)、returnAddress型別(指向位元組碼指令的地址)。區域性變量表所需要的記憶體空間在編譯期完成分配,在進入方法時,需要在幀中為這個方法分配多大的區域性變數空間是完全確定的,執行時不改變。 區域性變量表可能有兩種異常狀況: 如果執行緒請求的棧深度大於虛擬機器允許的深度,丟擲StackOverFlowError異常;如果虛擬機器棧可以動態擴充套件(大多數虛擬機器都可以),如果擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemory異常。

3. 本地方法棧

作用與虛擬機器棧類似,它們之間的區別是: 虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,本地方法棧為虛擬機器使用到的Native方法服務。也會丟擲StackOverFlowError和OutOfMemoryError異常。

4.Java堆

對於大多數應用來說,Java堆是Java虛擬機器所管理的記憶體中最大的一塊。Java堆被所有執行緒共享,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項(和陣列)都在這裡分配記憶體。 Java堆是垃圾收集器管理的主要區域,因此很多時候也被成為GC堆。 Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可。在實現時,既可以是固定大小的,也可以是可拓展的。如果在堆中沒有記憶體完成例項分配,並且堆也無法再拓展時,將會丟擲OutOfMemoryError異常。

5.方法區

所有執行緒共享,儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編輯器編譯後的程式碼等資料。雖然這個區域有“永久代”之稱,然而這個區域仍然存在記憶體回收,主要是針對常量池的回收和對型別的解除安裝。方法區也會丟擲OutOfMemoryError異常。

執行時常量池:

方法區的一部分。Class檔案除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用於存放編譯期生成的各種字面量和符號引用,這部分內容在類載入後進入方法區的執行時常量池。

6.直接記憶體

直接記憶體並不是虛擬機器執行時資料區的一部分,但是這部分記憶體也被頻繁使用,也可能導致OutOfMemoryError異常,所以放到這裡一起講。 JDK1.4中加入了NIO(New Input/Out )類,引入了一種基於通道和緩衝區的I/O方式,可以使用Native函式庫直接分配堆外記憶體,然後通過一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作。這樣能在一些場景中提高效能,避免了在Java堆和Native堆中來回複製資料。 受本機總記憶體和處理器定址空間(比如處理器是32位的,那你能夠通過地址訪問到的內容就是2^32,即4G,所以你能搭配的最大記憶體就是4G)的限制,也會丟擲OutOfMemoryError異常。

二、物件的建立、佈局與訪問

知道了記憶體中都存放了什麼之後,我們自然想進一步瞭解虛擬機器記憶體中的其他細節。比如是怎麼建立、佈局以及如何訪問的。 我們以最流行的HotSpot虛擬機器以及常用的記憶體區域Java堆為例,探討一下物件分配、佈局與訪問的全過程。

1.物件的建立

我們建立物件,當然是用new指令。 虛擬機器遇到new指令時,首先去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個引用代表的類是否已經被載入、解析和初始化過。即第一步,先去檢查虛擬機器載入了你要new的這個類沒,如果沒載入,必須先執行相應的類載入過程。(在以後的文章中會詳細介紹) 然後是為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定。 分配記憶體有兩種方式: 指標碰撞:如果Java堆中記憶體絕對規整,在使用的記憶體放在一邊,空閒記憶體放在另一邊,中間一個指標作為分界點的指示器,那分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相同的距離。 空閒列表:如果並不是規整的,虛擬機器就需要維護一個列表,記錄哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄。 除了如何劃分可用空間之外,還需要考慮修改指標時的執行緒安全問題。可能出現正在給物件A分配記憶體,指標還未修改,物件B又同時使用原來的指標分配記憶體的情況。 解決這個問題有兩種方案: 對分配記憶體空間的動作進行同步處理:採用CAS+失敗重試的方式保證更新操作的原子性(什麼是CAS:  compare-and-wwap。它的原理:我認為位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。可以參考這篇文章:CAS原理分析把記憶體分配的動作按照執行緒劃分的不同的空間中:每個執行緒在Java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝(TLAB),哪個執行緒要分配記憶體,就在自己的TLAB上分配,如果TLAB用完並分配新的TLAB時,再加同步鎖定。 記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零值。如果使用TLAB,也可以提前到TLAB分配時進行。這一步操作保證了物件的例項欄位在Java程式碼中可以不賦初值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。 接下來,要對物件進行必要的設定。例如這個物件是哪個類的例項、如何才能找到類的元資料資訊、物件的雜湊嗎、物件的GC分代年齡等資訊,這些資訊存放在物件頭之中。 最後執行根據程式設計師的意願進行初始化。

2.物件的記憶體佈局


分為3塊區域:物件頭、例項資料、對齊填充。 物件頭包括兩部分資訊: 第一部分用於儲存物件自身的執行時資料,如雜湊嗎、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。這部分資料長度在32位和64位的虛擬機器中分別為32bit和64bit,稱為Mark Word。這部分資料很多,超出了這麼多位可以記錄的限制,所以被設計成一個非固定的資料結構以便在極小的空間記憶體儲儘量多的資訊。(根據不同的標誌位,它的資料代表不同的含義。) 物件頭的另一部分是型別指標,指向它的類元資料(元資料即關於資料的資料),虛擬機器通過這個指標確定這個物件是哪個類的例項。 例項資料部分是物件真正儲存的有效資訊,也是在程式碼中所定義的各種型別的欄位內容。無論是從父類繼承的還是子類中定義的,都需要記錄起來。 對齊填充並不是必然存在的,僅僅起著佔位符的作用。HotSpot的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,因此當物件例項資料部分沒有對齊時,需要對齊填充來補全。

3.物件的訪問定位

目前主流的訪問方式有使用控制代碼和直接指標兩種。(控制代碼可理解成一個間接訪問物件的渠道。) 使用控制代碼的情況:Java堆中會劃分出一塊記憶體作為控制代碼池,棧中的reference指向物件的控制代碼地址,控制代碼中包含了物件例項資料和型別資料各自的具體地址資訊。 (圖片來自網上圖片庫)
使用直接指標的情況:reference中儲存的就是物件地址。 (圖片來自網上圖片庫)
這兩種方式各自的優點: 使用控制代碼訪問的最大好處就是reference中儲存的是穩定的控制代碼地址,物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制代碼中的例項資料指標,reference本身不需要修改。 使用直接指標訪問的最大好處就是速度快,節省了一次指標定位的時間開銷。

三、異常產生情況分析

1.Java堆溢位

只要不斷地建立物件,並且保證GC roots到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼在物件數量到達最大堆的容量限制後就會產生記憶體溢位異常。 要解決這個異常,一般先通過記憶體映像分析工具對堆轉儲快照分析,確定記憶體的物件是否是必要的(即判斷是記憶體洩露還是記憶體溢位)。 如果是記憶體洩露,可以進一步通過工具檢視洩露物件到GC Roots的引用鏈,比較準確地定位出洩露程式碼的位置。 如果是記憶體溢位,可以調大虛擬機器堆引數,或者從程式碼上檢查是否存在某些物件生命週期過長的情況。

2.虛擬機器棧和本地方法棧溢位

如果執行緒請求的棧深度大於虛擬機器棧允許的最大深度,將丟擲StackOverflowError異常。如果虛擬機器在拓展棧時無法申請到足夠的記憶體空間,則丟擲OutOfMemoryError異常。 定義大量的本地變數,增大此方法幀中本地變量表的長度,達到棧允許的最大深度後,就會丟擲StackOverflowError。 單執行緒情況下,很難丟擲OutOfMemoryError異常。因為你在達到棧最大深度時,一般都還沒有用完記憶體空間。
如果是多執行緒情況下,不斷建立新的執行緒,新的執行緒中又不斷建立新變數,可能會丟擲OutOfMemoryError。

3.方法區和執行時常量池溢位

String.intern()是一個native方法,它的作用是:如果字串常量池中已經包含一個等於此String物件的字串,則返回代表池中這個字串的String物件,否則將此String物件包含的字串新增到常量池中,並且返回此String物件的引用。 在JDK1.6及之前的版本中,由於常量池分配在永久代中,如果不斷地intern,會丟擲OutOfMemoryError異常。使用JDK1.7就不會丟擲。 方法區溢位的情況:一個類要被垃圾回收器回收掉,判斷條件是比較苛刻的。在經常動態產生大量Class的應用中,需要特別注意類的回收狀況。比如動態語言、大量JSP或者動態產生JSP檔案的應用(JSP第一次執行時需要編譯為Java類)、基於OSGi的應用(即使是同一個類檔案,被不同的載入器載入也會視為不同的類。不過對於OSGi我沒什麼研究,以後有時間再學習吧)。