java的執行時資料區域

真正的飛魚發表於2023-04-02

本文從概念上介紹 Java 虛擬機器記憶體的各個區域,講解這些區域的作用、服務物件以及其中可能產生的問題。

Java 虛擬機器在執行 Java 程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域。這些區域有各自的用途,以及建立和銷燬的時間,有些區域隨著虛擬機器程式的啟動而一直存在,有些區域則是依賴使用者執行緒的啟動和結束而建立和銷燬。

根據《Java 虛擬機器規範》的規定, Java 虛擬機器所管理的記憶體將會包括以下幾個執行時資料區域:程式計數器、Java 虛擬機器棧、本地方法棧、Java 堆、方法區。

image-20230221174739144.png

程式計數器

程式計數器(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 執行時資料區域

相關文章