本文從概念上介紹 Java 虛擬機記憶體的各個區域,講解這些區域的作用、服務對象以及其中可能產生的問題。 Java 虛擬機在執行 Java 程式的過程中會把它所管理的記憶體劃分為若幹個不同的數據區域。這些區域有各自的用途,以及創建和銷毀的時間,有些區域隨著虛擬機進程的啟動而一直存在,有些區域則是依賴用戶 ...
本文從概念上介紹 Java 虛擬機記憶體的各個區域,講解這些區域的作用、服務對象以及其中可能產生的問題。
Java 虛擬機在執行 Java 程式的過程中會把它所管理的記憶體劃分為若幹個不同的數據區域。這些區域有各自的用途,以及創建和銷毀的時間,有些區域隨著虛擬機進程的啟動而一直存在,有些區域則是依賴用戶線程的啟動和結束而建立和銷毀。
根據《Java 虛擬機規範》的規定, Java 虛擬機所管理的記憶體將會包括以下幾個運行時數據區域:程式計數器、Java 虛擬機棧、本地方法棧、Java 堆、方法區。
程式計數器
程式計數器(Program Counter Register)是一塊較小的記憶體空間,程式計數器可以看作是當前線程所執行的位元組碼的行號指示器。在 Java 虛擬機的概念模型里,位元組碼解釋器工作時就是通過改變程式計數器的值來選取下一條需要執行的位元組碼指令,程式計數器是程式控制流的指示器,分支、迴圈、跳轉、異常處理、線程恢復等基礎功能都需要依賴程式計數器來完成。
“概念模型”這個詞會經常被提及,它代表了所有虛擬機的統一外觀,但各款具體的 Java 虛擬機並不一定要完全照著概念模型的定義來進行設計,具體的 Java 虛擬機可能會通過一些更高效率的等價方式去實現它。
由於 Java 虛擬機的多線程是通過線程輪流切換、分配處理器執行時間的方式來實現的,在任何一個確定的時刻, 一個處理器(對於多核處理器來說是一個內核)都只會執行一個線程中的指令。因此,為了線程切換後能恢復到正確的執行位置,每個線程都需要有一個獨立的程式計數器,各個線程之間的程式計數器互不影響,獨立存儲,我們稱這類記憶體區域為 “線程私有” 的記憶體。
如果線程正在執行的是一個 Java 方法, 程式計數器記錄的是正在執行的虛擬機位元組碼指令的地址; 如果線程正在執行的是本地(Native) 方法,程式計數器值則應為空(Undefined)。
程式計數器記憶體區域是唯一一個在《Java 虛擬機規範》中沒有規定任何 OutOfMemoryError 情況的區域。
Java 虛擬機棧
Java 虛擬機棧(Java Virtual Machine Stack)與程式計數器一樣,也是線程私有的記憶體區域,Java 虛擬機棧的生命周期與線程相同。
Java 虛擬機棧描述的是 Java 方法執行的線程記憶體模型:每個方法被執行的時候,Java 虛擬機都會同步創建一個棧幀(Stack Frame)用於存儲局部變數表、操作數棧、動態連接、方法出口等信息。每個方法被調用直至執行完畢的過程,就對應著一個棧幀在 Java 虛擬機棧中從入棧到出棧的過程。
每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在運行期會由即時編譯器進行一些優化, 但在基於概念模型的討論里,大體上可以認為是編譯期可知的)
局部變數表
局部變數表存放了編譯期可知的各種 Java 虛擬機基本數據類型(boolean、 byte、 char、 short、 int、float、 long、 double) 、對象引用(reference 類型,對象引用並不等同於對象本身,對象引用可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或者其他與此對象相關的位置)和 returnAddress 類型(指向了一條位元組碼指令的地址)。
這些數據類型在局部變數表中的存儲空間以局部變數槽(Slot)來表示, 其中 64 位長度的 long 和 double 類型的數據會占用兩個變數槽,其餘的數據類型只占用一個變數槽。局部變數表所需的記憶體空間在編譯期間完成分配,當進入一個方法時, 這個方法需要在棧幀中分配多大的局部變數空間是完全確定的,在方法運行期間局部變數表的大小不會改變。
請讀者註意,這裡說的 “大小” 指的是變數槽的數量,虛擬機真正使用多大的記憶體空間(譬如按照 1 個變數槽占用 32 個比特、 64 個比特, 或者更多)來實現一個變數槽,這是完全由具體的虛擬機實現自行決定的事情。
在《Java 虛擬機規範》中, 對 Java 虛擬機棧記憶體區域規定了兩類異常狀況:StackOverflowError、OutOfMemoryError
- 如果線程請求的棧深度大於虛擬機所允許的深度, 將拋出 StackOverflowError 異常(棧深度溢出異常);
- 如果 Java 虛擬機棧容量可以動態擴展,當棧擴展時無法申請到足夠的記憶體會拋出 OutOfMemoryError 異常。
通過參數 -Xss 來設定單個線程棧的大小,棧的大小直接決定了函數調用的最大深度。
HotSpot 虛擬機的棧容量是不可以動態擴展的,以前的 Classic 虛擬機倒是可以。所以在 HotSpot 虛擬機上是不會由於虛擬機棧無法擴展而導致 OutOfMemoryError 異常。只要線程申請棧空間成功了就不會有 OOM,但是如果線程申請棧空間失敗了,仍然是會出現 OOM 異常的。
本地方法棧
本地方法棧(Native Method Stacks)與虛擬機棧所發揮的作用非常相似,它們兩個的區別是:虛擬機棧為虛擬機執行 Java 方法(也就是位元組碼) 服務,而本地方法棧則是為虛擬機使用到的本地(Native)方法服務。
《Java 虛擬機規範》對本地方法棧中方法使用的語言、使用方式與數據結構並沒有任何強制規定,因此具體的虛擬機可以根據需要自由實現它,甚至有的 Java 虛擬機(譬如 HotSpot 虛擬機) 直接就將本地方法棧和虛擬機棧合二為一。
與虛擬機棧一樣,本地方法棧也會在棧深度溢出或者棧擴展失敗時分別拋出 StackOverflowError 和 OutOfMemoryError 異常。
Java 堆
Java 堆是一塊被所有線程共用的記憶體區域,Java 堆在虛擬機啟動時被創建。
Java 堆記憶體區域的唯一目的就是存放對象實例,Java 世界里 “幾乎” 所有的對象實例都在 Java 堆分配記憶體。
在《Java 虛擬機規範》中對 Java 堆的描述是:“所有的對象實例以及數組都應當在堆上分配”,而這裡筆者寫的“幾乎”是指從實現角度來看,隨著 Java 語言的發展,現在已經能看到些許跡象表明日後可能出現值類型的支持,即使只考慮現在,由於即時編譯技術的進步,尤其是逃逸分析技術的日漸強大,棧上分配、標量替換優化手段已經導致一些微妙的變化悄然發生,所以說 Java 對象實例都分配在堆上也漸漸變得不是那麼絕對了。
根據《Java 虛擬機規範》的規定,Java 堆可以處於物理上不連續的記憶體空間中,但在邏輯上它應該被視為連續的,這點就像我們用磁碟空間去存儲文件一樣,並不要求每個文件都連續存放。但對於大對象(典型的如數組對象),多數虛擬機實現出於實現簡單、存儲高效的考慮,很可能會要求連續的記憶體空間。
Java 堆既可以被實現成固定大小的,也可以是可擴展的,不過當前主流的 Java 虛擬機都是按照可擴展來實現的(通過參數 -Xmx 和 -Xms 設定)。 如果 Java 堆無法滿足新的記憶體分配需求,並且堆也無法再擴展時,Java 虛擬機將會拋出 OutOfMemoryError 異常。
固定大小的 Java 堆指的是:只在虛擬機啟動時,向操作系統申請固定大小的堆記憶體空間。
可擴展的 Java 堆指的是:在虛擬機啟動時,向操作系統申請固定大小的初始堆記憶體空間。在空閑的 Java 堆記憶體空間無法滿足新的記憶體分配需求時,再向操作系統申請堆記憶體空間。
方法區
方法區(Method Area)與 Java 堆一樣, 也是被所有線程共用的記憶體區域。
方法區用於存儲已被虛擬機載入的類型信息(如類名、訪問修飾符、欄位描述、方法描述等)、常量、靜態變數、即時編譯器編譯後的代碼緩存等數據。
雖然《Java 虛擬機規範》中把方法區描述為堆的一個邏輯部分,但是方法區它卻有一個別名叫作 “非堆”(Non-Heap) ,目的是與 Java 堆區分開來。
根據《Java 虛擬機規範》的規定,如果方法區無法滿足新的記憶體分配需求時,Java 虛擬機將會拋出 OutOfMemoryError 異常。
《Java 虛擬機規範》對方法區的約束是非常寬鬆的,除了和 Java 堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴展外,甚至還可以選擇不實現垃圾收集。相對而言,垃圾收集行為在方法區這個區域的確是比較少出現的,但並非數據進入了方法區就如永久代的名字一樣 “永久” 存在了。方法區這個區域的記憶體回收目標主要是針對常量池的回收和對類型的卸載, 一般來說方法區這個區域的回收效果比較難令人滿意,尤其是類型的卸載,條件相當苛刻,但是方法區這個區域的回收有時又確實是必要的。 以前 Sun 公司的 Bug 列表中,曾出現過的若幹個嚴重的 Bug 就是由於低版本的 HotSpot 虛擬機對方法區這個區域未完全回收而導致記憶體泄漏。
永久代
說到方法區,不得不提一下 “永久代” 這個概念,尤其是在 JDK8 以前,許多 Java 程式員都習慣在 HotSpot 虛擬機上開發、部署程式,很多人都更願意把方法區稱為 “永久代”(Permanent Generation),或者將這兩者(方法區、永久代)混為一談。本質上這兩者(方法區、永久代)並不是等價的,因為僅僅是當時的 HotSpot 虛擬機設計團隊選擇把垃圾收集器的分代設計擴展至方法區,或者說使用永久代來實現方法區而已, 這樣使得 HotSpot 的垃圾收集器能夠像管理 Java 堆一樣管理方法區這部分記憶體,省去專門為方法區編寫記憶體管理代碼的工作。但是對於其他的虛擬機實現, 譬如 BEA JRockit、IBM J9 等來說,是不存在永久代這個概念的。
原則上如何實現方法區屬於虛擬機的實現細節,不受《Java 虛擬機規範》管束, 並不要求統一。但現在回頭來看,當年使用永久代來實現方法區的決定並不是一個好主意,這種設計導致了 Java 應用更容易遇到記憶體溢出的問題(永久代有 -XX:MaxPermSize 的上限,即使不設置也有預設大小,而 J9 和 JRockit 只要沒有觸碰到進程可用記憶體的上限, 例如32位系統中的4GB限制, 就不會出問題) ,而且有極少數方法(例如String::intern()) 會因永久代的原因而導致不同虛擬機下有不同的表現。
當 Oracle 收購 BEA 獲得了JRockit 的所有權後, 準備把 JRockit 中的優秀功能,譬如 Java Mission Control 管理工具, 移植到 HotSpot 虛擬機時,但因為兩者對方法區實現的差異而面臨諸多困難。
考慮到 HotSpot 未來的發展,在 JDK6 的時候 HotSpot 開發團隊就有放棄永久代,逐步改為採用本地記憶體(Native Memory)來實現方法區的計划了,到了 JDK7 的 HotSpot,已經把原本放在永久代的字元串常量池、靜態變數等移出, 而到了 JDK8 , 終於完全廢棄了永久代的概念, 改用與 JRockit、J9 一樣在本地記憶體中實現的元空間(Metaspace)來代替,把 JDK7 中永久代還剩餘的內容(主要是類型信息) 全部移到元空間中。
運行時常量池
運行時常量池(Runtime Constant Pool)是方法區的一部分。
Class 文件中除了有類的版本、欄位、方法、介面等描述信息外,還有一項信息是常量池表(Constant Pool Table),常量池表用於存放編譯期生成的各種字面量與符號引用,這部分內容將在類載入後存放到方法區的運行時常量池中。
Java 虛擬機對於 Class 文件每一部分(自然也包括常量池)的格式都有嚴格規定,如每一個位元組用於存儲哪種數據都必須符合規範上的要求才會被虛擬機認可、載入和執行,但對於運行時常量池,《Java 虛擬機規範》並沒有做任何細節的要求,不同提供商實現的虛擬機可以按照自己的需要來實現這個記憶體區域,不過一般來說,除了保存 Class 文件中描述的符號引用外,還會把由符號引用翻譯出來的直接引用也存儲在運行時常量池中。
運行時常量池相對於 Class 文件常量池的另外一個重要特征是具備動態性,Java 語言並不要求常量一定只有編譯期才能產生,也就是說,並非預置入 Class 文件中常量池的內容才能進入方法區的運行時常量池, 運行期間也可以將新的常量放入池中,這種特性被開發人員利用得比較多的便是 String 類的 intern() 方法。
直接記憶體
直接記憶體(Direct Memory)並不是虛擬機運行時數據區域的一部分,也不是《Java 虛擬機規範》中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導致 OutOfMemoryError 異常出現,所以我們放到這裡一起講解。
在 JDK1.4 中新加入了 NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O方式, NIO 它可以使用 Native 函數庫直接分配堆外記憶體,然後通過一個存儲在 Java 堆裡面的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆中來回覆制數據。
顯然,本機直接記憶體的分配不會受到 Java 堆大小的限制,但是,既然是記憶體,則肯定還是會受到本機總記憶體(包括物理記憶體、SWAP 分區或者分頁文件)大小以及處理器定址空間的限制,一般伺服器管理員配置虛擬機參數時,會根據實際記憶體去設置 -Xmx 等參數信息,但經常忽略掉直接記憶體,使得各個記憶體區域總和大於物理記憶體限制(包括物理的和操作系統級的限制),從而導致動態擴展時出現 OutOfMemoryError 異常。
總結
運行時數據區域
程式計數器
程式計數器是一塊較小的記憶體空間。程式計數器是“線程私有”的數據區域。
如果一個線程正在執行的是一個 Java 方法, 程式計數器記錄的是正在執行的虛擬機位元組碼指令的地址。
在 Java 虛擬機的概念模型里,位元組碼解釋器工作時就是通過改變程式計數器的值來選取下一條需要執行的位元組碼指令,程式計數器是程式控制流的指示器,分支、迴圈、跳轉、異常處理、線程恢復等基礎功能都需要依賴程式計數器來完成。
Java 虛擬機棧、本地方法棧
HotSpot 虛擬機將本地方法棧和虛擬機棧合二為一。
- Java 虛擬機棧描述的是 Java 方法執行的線程記憶體模型:每個方法被執行的時候,Java 虛擬機都會同步創建一個棧幀(Stack Frame)用於存儲局部變數表、操作數棧、動態連接、方法出口等信息。每個方法被調用直至執行完畢的過程,就對應著一個棧幀在 Java 虛擬機棧中從入棧到出棧的過程。
- 本地方法棧(Native Method Stacks) 與虛擬機棧所發揮的作用非常相似,它們兩個的區別是:虛擬機棧為虛擬機執行 Java 方法(也就是位元組碼) 服務,而本地方法棧則是為虛擬機使用到的本地(Native)方法服務。
【Java 堆】記憶體區域的唯一目的就是存放對象實例,Java 世界里 “幾乎” 所有的對象實例都在【Java 堆】區域分配記憶體。
【方法區】記憶體區域用於存儲已被虛擬機載入的類型信息(如類名、訪問修飾符、欄位描述、方法描述等)、常量、靜態變數、即時編譯器編譯後的代碼緩存等數據。
“線程私有” 的區域
“線程私有” 的記憶體區域:每個線程都有一個獨立的記憶體區域,各個線程之間的記憶體區域互不影響, 獨立存儲,我們稱這類記憶體區域為 “線程私有” 的記憶體區域。
- “線程私有” 的記憶體區域有:程式計數器、Java 虛擬機棧、本地方法棧;
- 被所有線程共用的記憶體區域有:Java 堆、方法區。
垃圾收集的區域
程式計數器、Java 虛擬機棧、本地方法棧這三個運行時數據區域隨線程而生,隨線程而滅,棧中的棧幀隨著方法的進入和退出而有條不紊地執行著入棧和出棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在運行期會由即時編譯器進行一些優化,但在基於概念模型的討論里,大體上可以認為是編譯期可知的),因此這三個運行時數據區域的記憶體分配和回收都具備確定性,在這三個運行時數據區域內就不需要過多考慮如何回收的問題,當方法結束或者線程結束時,記憶體自然就跟隨著回收了。
而 Java 堆和方法區這兩個運行時數據區域則有著很顯著的不確定性:一個介面的多個實現類需要的記憶體可能會不一樣, 一個方法所執行的不同條件分支所需要的記憶體也可能不一樣,只有處於運行期間,我們才能知道程式究竟會創建哪些對象,創建多少個對象,這部分(Java 堆、方法區)記憶體的分配和回收是動態的。垃圾收集器所關註的正是這部分(Java 堆、方法區)記憶體該如何管理。
記憶體區域的異常狀況
【程式計數器】記憶體區域是唯一一個在《Java 虛擬機規範》中沒有規定任何 OutOfMemoryError 情況的區域。
【Java 虛擬機棧】、【本地方法棧】記憶體區域:在【Java 虛擬機棧】、【本地方法棧】記憶體區域中,可能出現的異常狀況有:OutOfMemoryError、StackOverflowError:
- 創建線程時,需要申請棧空間。如果線程申請棧空間失敗了,那麼 Java 虛擬機就會拋出 OutOfMemoryError 異常。
- 線程申請棧空間成功後,如果線程請求的棧深度大於虛擬機所允許的深度,那麼 Java 虛擬機就會拋出 StackOverflowError 異常。
【Java 堆】記憶體區域:如果 Java 堆無法滿足新的記憶體分配需求,並且堆也無法再擴展時,Java 虛擬機將會拋出 OutOfMemoryError 異常。
【方法區】記憶體區域:如果方法區無法滿足新的記憶體分配需求時,Java 虛擬機將會拋出 OutOfMemoryError 異常。
【直接記憶體】:如果各個記憶體區域的總和大於物理記憶體限制(包括物理的和操作系統級的限制),Java 虛擬機將會拋出 OutOfMemoryError 異常。
參考資料
《深入理解 Java 虛擬機》第 2 章:Java 記憶體區域與記憶體溢出異常 2.2 運行時數據區域
本文來自博客園,作者:真正的飛魚,轉載請註明原文鏈接:https://www.cnblogs.com/feiyu2/p/17279905.html