(一)JAVA記憶體區域與記憶體溢位異常
目錄
0、前沿
借用JVM書中一句話:JAVA和C++之間有一堵由記憶體動態分配和垃圾收集技術所圍成的高牆,牆外面的人想進來,牆裡面的人想出去。
1、概述
對於C++程式設計師而言,對於記憶體管理,他們既是擁有最高權力的“皇帝”,也是從事最基礎工作的“勞動人民”。他擁有對每一個物件的所有權,同時也必須對每一個物件的生命週期負責到底。因此在獲得對記憶體極大的使用權的同時,也會帶來一些問題,容易出現記憶體洩漏和記憶體溢位等問題。
對於JAVA程式設計師而言,在藉助虛擬機器JVM的自動記憶體管理機制的幫助下,記憶體管理不再需要JAVA程式設計師的親力親為,釋放記憶體的權利都交給了JVM,因此極大的提供了JAVA程式設計師的生產力,同時也有效的減少了人為造成的記憶體洩漏等問題。
但是事情都是有兩面性的,JVM的記憶體自動管理機制帶來的後果是弱化了JAVA程式設計師對記憶體的控制能力,它對於程式設計師而言,相當於一個黑盒,另外JVM的記憶體管理機制並不是完美無缺,也會有記憶體洩漏的可能,因此如果不瞭解JVM的記憶體管理機制,我們是沒法定位分析JAVA中出現的記憶體洩漏等問題的。
本文也是基於這個目的,將平時積累的知識儘可能全面的展示出來,以供分享。
2、執行時資料區域
JVM在執行JAVA程式的過程中會把它管理的記憶體劃分為若干個不同的資料區域。這些區域都有各自的用途以及建立和銷燬的時間,有的區域是隨著JVM程序的啟動而存在,有的則是依賴於使用者執行緒的啟動和結束而建立和銷燬。
經常有人將JAVA記憶體區域簡單的劃分為堆記憶體和棧記憶體,這種分割槽方式很粗糙,它只關注了大多數程式設計師最關注的,與物件記憶體分配關係最密切的記憶體區域。另外一種更加精細的記憶體區域劃分,如下圖所示:
下圖是記憶體管理中5大區域。
如圖所示:
(1)所有執行緒共享的區域:方法區、堆
(2)執行緒隔離的區域(屬於單個執行緒所有):虛擬機器棧、本地方法棧、程式計數器
2.1、程式計數器
程式計數器是一塊較小的記憶體空間,他可以看做是當前執行緒所執行的位元組碼的行號指示器。
問題1:為什麼需要程式計數器?
由於java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各執行緒之間的程式計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。
問題2:工作原理
在虛擬機器的概念模型,位元組碼直譯器工作時就是通過改變計數器的值來選擇下一條需要執行的位元組碼指令,分支、迴圈、跳轉等基礎功能都需要依賴這個計數器完成。
2.2、JAVA虛擬機器棧
與程式計數器一樣,JAVA虛擬機器棧也是執行緒私有的,它的生命週期和執行緒相同,虛擬機器棧描述的是Java方法執行的記憶體模型,每一個方法(非本地方法)在執行時都會建立一個棧幀,用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊,每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。
區域性變量表存放了編譯期可知的各種基本資料型別(8種),物件的引用和returnAddress型別(指向了一條位元組碼指令的地址)。區域性變量表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變量表的大小。
2.3、本地方法棧
本地方法棧與JAVA虛擬機器棧發揮的作用很類似,它們之間的區別在於JAVA虛擬機器棧為虛擬機器執行JAVA方法(也就是位元組碼)服務,而本地方法棧則是為虛擬機器使用到的Native方法服務。
2.4、JAVA堆
JAVA堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立,此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。換句話說,所有new出來的物件都在JAVA堆中存放。
JAVA堆是垃圾收集器管理的主要區域。從記憶體回收的角度來看,由於現在收集器基本都採用分代收集演算法,所以JAVA堆中還可以細分為:新生代和老生代。
JAVA堆可以是物理上不連續的記憶體空間,只要邏輯連續就可以,就如同我們的磁碟空間一樣,在實現時,既可以實現固定大小的,也可以擴充套件的,不過當前主流的虛擬機器都是按照可擴充套件來實現的(通過-Xmx和-Xms控制)。
2.5、方法區
方法區與JAVA堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊,常量,靜態變數,即時編譯器編譯後的程式碼等資料。
對於習慣上在HotSpot虛擬機器上開發、部署程式的開發者來說,很多人更加願意把方法區稱作為“永久代”,本質上兩者不等價,僅僅是因為HotSpot虛擬機器的設計團隊選擇把GC分代收集擴充套件到方法區,或者說使用永久代來實現方法區而已,這樣HotSpot的垃圾收集器就可以像管理JAVA堆一樣來管理方法區這部分記憶體了,能夠省去專門為方法區編寫記憶體管理程式碼的工作。但是對於其他虛擬機器而言(如BEA IBM等)來說是不存在永久代的概念的。
原則上,如何實現方法區屬於虛擬機器實現細節,不受虛擬機器規範約束,但是使用永久代來實現方法區,現在看來不是一個好主意,因為這樣更容易遇到記憶體溢位問題(永久代有-XX:MaxPermSize的上限)。
垃圾回收行為在方法區區域是比較少出現的,但並非資料進入到了方法區就不會被回收,這部分割槽域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝。
2.5.1、執行時常量池
執行時常量池是方法區的一部分。class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊就是常量池,用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池存放。
3、HotSpot虛擬機器
介紹完JAVA虛擬機器執行時資料區後,我們大致瞭解了JAVA記憶體管理的情況,如果想進一步瞭解細節,我們還需要拿具體的虛擬機器來講解,因為每種虛擬機器的實現方式並不一樣,基於實用優先的原則,本章打算拿HotSpot虛擬機器和JAVA堆進行講解。深入探討HotSpot虛擬機器在JAVA堆中物件分配、佈局和訪問的全過程。
3.1、物件的建立
JAVA是一門面向物件的程式語言,在JAVA程式中無時無刻都有物件被建立,建立物件僅僅是new就可以了,哪在執行new的指令時是怎麼一個過程呢?
1、虛擬機器遇到一條new指令時,首先會檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用所代表的類是否被載入、解析和初始化、如果沒有,則必須先執行相應的類載入過程。
2、在類載入檢查通過後,接下來虛擬機器將為新生物件分配記憶體。
物件所需記憶體的大小是在類載入完成後便可以完全確定了,為物件分配空間的任務等同於把一塊確定大小的記憶體從JAVA堆中處分出來。則會出現如下兩種方法:
(1)指標碰撞
如果JAVA堆中記憶體是規整的,所有用過的記憶體都放在一邊,空閒的記憶體放到另一邊,中間放置一個指標作為分界點的指示器,那麼分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離,這種分配方式就是“指標碰撞”,Serial、ParNew採用此方式。
(2)空閒列表
如果JAVA堆中記憶體不規整,已使用的記憶體和未使用的記憶體相互交錯,那就沒辦法採用簡單的進行指標碰撞了,虛擬機器必須維護一個列表,記錄上哪些記憶體是可用的,在分配的時候,從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式稱為“空閒列表”,CMS垃圾收集器就是採用的這種方式。
除了如何劃分可用空間之外,另外一個需要考慮的問題是物件建立過程,面對併發建立物件時的安全問題,例如利用移動指標建立物件,面對多個物件的建立時,共用指標,會出現併發問題。解決方案有兩種:
(1)對分配記憶體空間的動作進行同步處理
實際上虛擬機器採用CAS配上失敗重試的方法保證更新操作的原子性
(2)把記憶體分配的動作按照執行緒劃分到不同的空間之中進行
每個執行緒在JAVA堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝(TLAB),哪個執行緒要分配記憶體,就在哪個執行緒的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步確定。
3、記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零隻(不包括物件頭)
4、接下來,虛擬機器要對物件進行必要的設定。主要是對物件頭,根據虛擬機器當前的執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式。
5、以上步驟完成後,一個新的物件就產生了,但是這只是物件建立的開始,<init>方法還沒有執行,所有欄位都為空,所以執行new之後,緊接著會執行<init>方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全產生出來。
3.2、物件的記憶體佈局
在HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以分為3塊區域:物件頭、例項資料、對齊填充。
1、物件頭
物件頭可以分為Mark Word和型別指標。
(1)Mark Word
用於儲存物件自身的執行時資料,如雜湊碼、GC分代年齡、鎖狀態標誌,執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。Mark Word被設計成一種非固定的資料結構以便在極小的空間記憶體儲存儘量多的資訊,它會根據物件的狀態複用自己的儲存空間,例如物件的狀態處於鎖定、輕量級鎖定、重量級鎖定、GC標記等等
(2)型別指標
物件指向它的元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
3.3、物件的訪問定位
JAVA程式需要通過棧上的reference資料來操作堆上的具體物件,目前主流的訪問方式有:1、控制代碼;2、直接指標
(1)控制代碼
JAVA堆中將會劃分出一塊記憶體來作為控制代碼池,reference中儲存的就是物件的控制代碼地址,而控制代碼中包含惡劣物件例項資料與型別資料各自的具體地址
(2)直接指標訪問
JAVA堆物件的佈局中就必須考慮如何放置訪問型別資料的相關資訊,而reference中儲存的直接就是物件地址。
至此本篇部落格講解完畢。