Java程式而言,Java虛擬機有自動記憶體管理機制,不需要開發人員去手動釋放內空間,也不容易出現記憶體泄漏和溢出的問題,一切看起來都很完美。一旦出現記憶體泄漏和溢出方面的問題,如果不瞭解Java虛擬機是怎麼樣使用記憶體的,那麼排查起來將困難。以往對記憶體的理解僅僅停留在棧、堆這兩個部分,其實Java虛擬機的 ...
Java程式而言,Java虛擬機有自動記憶體管理機制,不需要開發人員去手動釋放內空間,也不容易出現記憶體泄漏和溢出的問題,一切看起來都很完美。一旦出現記憶體泄漏和溢出方面的問題,如果不瞭解Java虛擬機是怎麼樣使用記憶體的,那麼排查起來將困難。以往對記憶體的理解僅僅停留在棧、堆這兩個部分,其實Java虛擬機的還有其他分區遠比這複雜。接下來將介紹Java虛擬機主要的幾個區域及其作用、記憶體溢出。
java虛擬機在執行Java程式時會把其管理的記憶體劃分為若幹個不同的數據區域,這些區域都有各自的用途、創建和銷毀時間。線程共用區域的數據區域隨著虛擬機啟動而存在,線程隔離的數據區域依賴線程的啟動而創建、線程結束而銷毀。
Java虛擬機運行時數據區
程式計數器
程式計數器是一塊較小的記憶體空間,它可以看成是當前線程執行的位元組碼的行號指示器。其實程式計數器就是一個寄存器用來存放當前正在被執行的指令,也可以存放下一個要被執行的指令。
在虛擬機的概念模型中,位元組碼解釋器工作時就是通過改變這個計數器的值來選擇下一條需要執行的位元組碼指令,由於Java虛擬機的多線程是通過線程輪流切換並分配處理執行時間的方式實現的,在任何一個確定的時刻,一個處理器(一個內核)都只會執行一條線程中的指令。因此,為了線程切換後還能恢復到正確的執行位置,每條線程都需要擁有一個獨立的程式計數器,各條線程之間計數器互不影響,獨立存儲,所以這部份記憶體區域我們稱之為線程私有記憶體,即線程隔離。
Java虛擬機棧
和程式計數器一樣,Java虛擬機棧也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是Java方法執行的記憶體模型:每個方法在執行時都會創建一個棧幀用來存儲局部變數表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程。
局部變數表存放了編譯期可知的各種基本數據類型(boolean-1、byte-1、char-2、short-2、int-4、float-4、long-8、double-8),對象引用(reference類型,可能是一個對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)和returnAddress類型(指向一條位元組碼指令的地址)。局部變數表所需要的記憶體空間在編譯時期完成分配。進入一個方法時,這個方法需要在幀中分配多大的局部變數空間是完全確定的,在方法運行期間不會改變局部變數表的大小。
本地方法棧
本地方法棧是與虛擬機棧所發揮的作用是非常相似的,他們之間的區別就是虛擬機棧為虛擬機執行Java方法(也就是位元組碼)服務,而本地方法棧則為虛擬機使用到的Native方法服務。虛擬機規範中對本地方法棧中方法所使用的語音、使用方式以及數據結構 都沒有強制規定,因此具體的虛擬機可以自由地實現它。甚至在有的虛擬機中直接將虛擬機棧和本地方法棧合併為一個。和虛擬機棧一樣,本地方法棧區也會拋出StackOverflowError和OutOfMemory異常。
Java 堆
對應大多數應用來說,Java堆是Java虛擬機所管理的記憶體中最大的一塊。Java堆是被所有線程共用的一塊記憶體區域,在虛擬機啟動時候創建。此記憶體區域的唯一目的就是存放對象實例,幾乎所有的記憶體實例都在這裡分配記憶體。Java虛擬機規範中的描述是:所有的對象實例以及數組都要在堆上分配,但隨著JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化 ,所喲的對象都分配在堆上也漸漸變得不是那麼“絕對”了。
Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱為GC堆,現在收集器基本採用分代收集演算法,所以Java堆中還可以細分為:新生代和老年代。根據Java虛擬機規範的規定,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像我們的磁碟空間一樣。在實現時,可以固定大小,也可是可拓展的,主流的虛擬機都是按照可拓展來實現的(通過-Xmx和-Xms來控制)。如果在堆中沒有記憶體完成實例分配,並且堆也無法繼續拓展時,將會拋出OutOfMemortError異常。
方法區
方法區與Java堆一樣,是各個線程共用的記憶體區域,它用於存儲已被虛擬機載入的類信息、常量、靜態變數、即時編譯後的代碼等數據。雖然Java虛擬機將其描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆)。目的是與Java堆區分開來。
Java虛擬機規範對方法區的限制非常松,除了和Java堆一樣,不需要連續的記憶體和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。相對而言,垃圾收集在這個區域是比較少出現的,這個區域記憶體回收的主要目標是針對常量池的回收和對類型的卸載。根據Java規範的規定,當方法區無法滿足記憶體分配需要時,將拋出OutOfMemoryError異常。
運行時常量池
運行常量池是方法區的一部分。class文件中除了有類的版本、欄位、方法、介面等描述信息外,還有一項信息是常量池,用於存放編譯時期生成的各種字面量和符號引用,這部份內容將在類載入後進入方法區的運行時常量池中存放。
運行時常量池具備動態性,Java語音並不要求常量一定只有編譯期才能產生,也就是並非預置入class文件中常量池的內容才能進入方法區運行時常量池,運行時期也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。既然運行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會拋出OutOfMemoryError異常。
直接記憶體
由於直接記憶體(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規範中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導致記憶體溢出異常(OutOfMemoryError)出現,所以也放到這部分進行介紹。顯然,本機直接記憶體的分配不會受到Java堆大小的限制。但是肯定還是會受到本機總記憶體大小以及處理器定址空間的限制。管理員在配置虛擬機參數時,會根據實際記憶體設置-Xmx等參數信息,但經常忽略直接記憶體,使得各個記憶體區域總和大於物理記憶體限制(包括物理的和操作系統級的限制),從而導致動態拓展時出現OutOfMemoryError異常。
對象的創建方式
虛擬機遇到一條new指令時,首先去檢查這個指令的參數能否在常量池中定位到一個類的符合引用。並且檢查這個符號引用代表的類是否已經被載入、解析和初始化過。如果沒有,那就先執行載入過程。在類載入完成後,虛擬機將為新生對象分配記憶體。對象所需要的記憶體大小在類載入完成之後便可完全確定,為對象分配空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來。
為對象分配記憶體空間有兩種方式:
指針碰撞:假設Java堆中記憶體是規整的,所有用過的記憶體都放在一邊,空閑的記憶體放在另一邊,中間放著一個指針作為分界點的指示器,那分配記憶體就是將指針往空間空間挪動一段與對象大小相等的距離,這種分配記憶體的方式就被稱為指針碰撞;
空閑列表:如果Java堆中的記憶體並不是規整的,已經使用的記憶體和空閑記憶體相互交錯,那就沒有辦法簡單地使用指針碰撞的方法進行記憶體分配了。虛擬機此時必須維護一個列表用來記錄哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間為分配給對象實例,並且更新列表上的記錄,這種分配方式就被稱為空閑列表。 選擇哪一種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。對象的記憶體佈局
HotSpot虛擬機中,對象在記憶體中存儲的佈局分為3塊區域:對象頭、實例數據、對齊填充。
對象頭由兩部分信息組成,第一部分用於存儲對象自身運行時的數據,如哈希碼、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等。第二部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例,並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據信息並不一定要經過對象本身。另外,如果對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因為虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中卻無法確定數組的大小。
實例數據是對象真正存儲的有效信息,也是在程式代碼中所定義的各種類型的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。這部分的存儲順序會受到虛擬機分配策略參數和欄位在Java源碼中定義順序的影響。
對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用。由於HotSpot VM的自動記憶體管理系統要求對象起始地址必須是8個位元組的整數倍,換句話說,就是對象對象的大小必須是8位元組的整數倍。而對象頭部分正好是8位元組的整數倍,因此,當對象實例數據部分沒有對齊時,就需要通過補齊填充來補全。
對象的訪問定位
建立對象是為了使用對象,我們的Java程式需要通過棧上的reference數據來操作堆上的具體對象。由於reference類型在Java虛擬機規範中只規定了一個指向對象的引用,並沒有定義這個引用應該通過何種方式去定位、訪問堆中的對象的具體位置,所以對象訪問方法也是取決於虛擬機的實現而決定的。目前主流的訪問方式有使用句柄和直接指針兩種。
如果使用句柄的話,那麼Java堆中將會劃分一塊記憶體來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中存儲的就是對象實例數據與類型數據具體地址信息。優點:reference存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針,而reference本身不需要修改。缺點:增加了一次指針定位的時間開銷。
通過句柄訪問對象
如果使用直接指針訪問方式最大的好處就是速度更快,它節省了一次指針定位的時間開銷,由於對象的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成本。缺點:在對象被移動時reference本身需要被修改。
通過直接指針訪問對象
記憶體溢出
堆溢出
Java堆唯一的作用就是存儲對象實例,只要保證不斷創建對象並且對象不被回收,那麼對象數量達到最大堆容量限制後就會產生記憶體溢出異常了。
虛擬機棧和本地方法棧溢出
Java虛擬機規範中描述瞭如果線程請求的棧深度太深(換句話說方法調用的深度太深),就會產生棧溢出了。那麼,我們只要寫一個無限調用自己的方法,自然就會出現方法調用的深度太深的場景了。
如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError異常;
如果虛擬機在擴展棧時無法申請到足夠的記憶體空間,則拋出OutOfMemoryError異常。
這裡把異常分為兩種情況,看似較為嚴謹,但卻存在著一些互相重疊的地方:當棧空間無法繼續分配時,到底是已使用的棧空間太大,還是記憶體太小,其本質上都只是對同一件事情的兩種描述而已。