標籤:java jvm 記憶體 虛擬機器 記憶體溢出
作為一個程式員,僅僅知道怎麼用是遠遠不夠的。起碼,你需要知道為什麼可以這麼用,即我們所謂底層的東西。
那到底什麼是底層呢?我覺得這不能一概而論。以我現在的知識水平而言:對於Web開發人員,TCP/IP、HTTP等等協議可能就是底層;對於C、C++程式員,記憶體、指標等等可能就是底層的東西。那對於Java開發人員,你的Java代碼運行所在的JVM可能就是你所需要去瞭解、理解的東西。
我會在接下來的一段時間,和讀者您一起去學習JVM,所有內容均參考自《深入理解Java虛擬機器:JVM進階特性與最佳實務》(第二版),感謝作者。
本文是系列文章第一篇,講述的是
Java記憶體地區,即在虛擬機器上,資料是怎麼儲存的。
一、運行時資料區域
運行時資料區分為兩個部分,一部分由所有線程共用,一部分是各個線程私人的。線程共用的資料區包括方法區和堆,線程私人的資料區包括虛擬機器棧、本地方法棧和程式計數器。如所示:
(圖片來自網片庫)
下面我們分別對這些地區進行介紹:
1.程式計數器
一塊較小的記憶體空間,可以看做是當前線程所執行的位元組碼的行號指標。
如果線程正在執行的是一個JAVA方法,計數器值為當前執行的虛擬機器位元組碼指令的地址;如果正在執行的是Native方法,計數器值為空白。
這個記憶體地區是唯一一塊絕對不會出現OutOfMemoryError的地區。
2.虛擬機器棧
線程私人,它的生命週期與線程相同。描述的是Java方法執行的記憶體模型:每個方法在執行的時候都會建立一個棧幀,用於儲存局部變數表、運算元棧、動態連結、方法出口等資訊。每個方法從調用直至完成的過程,對應一個棧幀在虛擬機器棧中入棧到出棧的過程。局部變數表:局部變數表存放了編譯時間可知的基礎資料型別 (Elementary Data Type)(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我沒什麼研究,以後有時間再學習吧)。
JVM系列文章(一):Java記憶體地區分析