1. 程式人生 > 其它 >Java-執行時資料區

Java-執行時資料區

-摘自《深入理解Java虛擬機器》

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

程式計數器

程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的 位元組碼的行號指示器。在Java虛擬機器的概念模型裡,位元組碼直譯器工作時就是通過改變這個計數器 的值來選取下一條需要執行的位元組碼指令,它是程式控制流的指示器,分支、迴圈、跳轉、異常處 理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。 由於Java虛擬機器的多執行緒是通過執行緒輪流切換、分配處理器執行時間的方式來實現的,在任何一 個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。因 此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒 之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。 如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是本地(Native)方法,這個計數器值則應為空(Undefined)。此記憶體區域是唯一一個在《Java虛擬機器規範》中沒有規定任何OutOfMemoryError情況的區域。

Java虛擬機器棧

Java虛擬機器棧(Java Virtual Machine Stack)也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是Java方法執行的執行緒記憶體模型:每個方法被執行的時候,Java虛擬機器都 會同步建立一個棧幀[1](Stack Frame)用於儲存區域性變量表、運算元棧、動態連線、方法出口等信 息。每一個方法被呼叫直至執行完畢的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。 經常有人把Java記憶體區域籠統地劃分為堆記憶體(Heap)和棧記憶體(Stack),這種劃分方式直接繼 承自傳統的C、C++程式的記憶體佈局結構,在Java語言裡就顯得有些粗糙了,實際的記憶體區域劃分要比 這更復雜。不過這種劃分方式的流行也間接說明了程式設計師最關注的、與物件記憶體分配關係最密切的區 域是“堆”和“棧”兩塊。“棧”通常就是指這裡講的虛擬機器棧,或者更多的情況下只是指虛擬機器棧中區域性變量表部分。區域性變量表存放了編譯期可知的各種Java虛擬機器基本資料型別(boolean、byte、char、short、int、 float、long、double)、物件引用(reference型別,它並不等同於物件本身,可能是一個指向物件起始 地址的引用指標,也可能是指向一個代表物件的控制代碼或者其他與此物件相關的位置)和returnAddress 型別(指向了一條位元組碼指令的地址)。 這些資料型別在區域性變量表中的儲存空間以區域性變數槽(Slot)來表示,其中64位長度的long和 double型別的資料會佔用兩個變數槽,其餘的資料型別只佔用一個。區域性變量表所需的記憶體空間在編 譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的區域性變數空間是完全確定 的,在方法執行期間不會改變區域性變量表的大小。這裡說的“大小”是指變數槽的數量,虛擬機器真正使用多大的記憶體空間(譬如按照1個變數槽佔用32個位元、64個位元,或者更多)來實現一個變數槽,這是完全由具體的虛擬機器實現自行決定的事情。 在《Java虛擬機器規範》中,對這個記憶體區域規定了兩類異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;如果Java虛擬機器棧容量可以動態擴充套件,當棧擴充套件時無法申請到足夠的記憶體會丟擲OutOfMemoryError異常。

本地方法棧

本地方法棧(Native Method Stacks)與虛擬機器棧所發揮的作用是非常相似的,其區別只是虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,而本地方法棧則是為虛擬機器使用到的本地(Native) 方法服務。 《Java虛擬機器規範》對本地方法棧中方法使用的語言、使用方式與資料結構並沒有任何強制規 定,因此具體的虛擬機器可以根據需要自由實現它,甚至有的Java虛擬機器(譬如Hot-Spot虛擬機器)直接 就把本地方法棧和虛擬機器棧合二為一。與虛擬機器棧一樣,本地方法棧也會在棧深度溢位或者棧擴充套件失 敗時分別丟擲StackOverflowError和OutOfMemoryError異常。

Java堆

對於Java應用程式來說,Java堆(Java Heap)是虛擬機器所管理的記憶體中最大的一塊。Java堆是被所 有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,Java 世界裡“幾乎”所有的物件例項都在這裡分配記憶體。在《Java虛擬機器規範》中對Java堆的描述是:“所有 的物件例項以及陣列都應當在堆上分配[1]”,而這裡筆者寫的“幾乎”是指從實現角度來看,隨著Java語 言的發展,現在已經能看到些許跡象表明日後可能出現值型別的支援,即使只考慮現在,由於即時編 譯技術的進步,尤其是逃逸分析技術的日漸強大,棧上分配、標量替換[2]優化手段已經導致一些微妙 的變化悄然發生,所以說Java物件例項都分配在堆上也漸漸變得不是那麼絕對了。 Java堆是垃圾收集器管理的記憶體區域,因此一些資料中它也被稱作“GC堆”(Garbage Collected Heap,幸好國內沒翻譯成“垃圾堆”)。從回收記憶體的角度看,由於現代垃圾收集器大部分都是基於分 代收集理論設計的,所以Java堆中經常會出現“新生代”“老年代”“永久代”“Eden空間”“From Survivor空 間”“To Survivor空間”等名詞,這些概念在本書後續章節中還會反覆登場亮相,在這裡筆者想先說明的 是這些區域劃分僅僅是一部分垃圾收集器的共同特性或者說設計風格而已,而非某個Java虛擬機器具體 實現的固有記憶體佈局,更不是《Java虛擬機器規範》裡對Java堆的進一步細緻劃分。不少資料上經常寫著 類似於“Java虛擬機器的堆記憶體分為新生代、老年代、永久代、Eden、Survivor……”這樣的內容。在十年 之前(以G1收集器的出現為分界),作為業界絕對主流的HotSpot虛擬機器,它內部的垃圾收集器全部 都基於“經典分代”[3]來設計,需要新生代、老年代收集器搭配才能工作,在這種背景下,上述說法還 算是不會產生太大歧義。但是到了今天,垃圾收集器技術與十年前已不可同日而語,HotSpot裡面也出 現了不採用分代設計的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了。 如果從分配記憶體的角度看,所有執行緒共享的Java堆中可以劃分出多個執行緒私有的分配緩衝區 (Thread Local Allocation Buffer,TLAB),以提升物件分配時的效率。不過無論從什麼角度,無論如 何劃分,都不會改變Java堆中儲存內容的共性,無論是哪個區域,儲存的都只能是物件的例項,將Java 堆細分的目的只是為了更好地回收記憶體,或者更快地分配記憶體。在本章中,我們僅僅針對記憶體區域的 作用進行討論,Java堆中的上述各個區域的分配、回收等細節將會是下一章的主題。 根據《Java虛擬機器規範》的規定,Java堆可以處於物理上不連續的記憶體空間中,但在邏輯上它應該 被視為連續的,這點就像我們用磁碟空間去儲存檔案一樣,並不要求每個檔案都連續存放。但對於大 物件(典型的如陣列物件),多數虛擬機器實現出於實現簡單、儲存高效的考慮,很可能會要求連續的 記憶體空間。 Java堆既可以被實現成固定大小的,也可以是可擴充套件的,不過當前主流的Java虛擬機器都是按照可擴 展來實現的(通過引數-Xmx和-Xms設定)。如果在Java堆中沒有記憶體完成例項分配,並且堆也無法再 擴充套件時,Java虛擬機器將會丟擲OutOfMemoryError異常。

方法區

方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入 的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等資料。雖然《Java虛擬機器規範》中把 方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫作“非堆”(Non-Heap),目的是與Java堆區 分開來。說到方法區,不得不提一下“永久代”這個概念,尤其是在JDK 8以前,許多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未來的發展,在JDK 6的 時候HotSpot開發團隊就有放棄永久代,逐步改為採用本地記憶體(Native Memory)來實現方法區的計 劃了[1],到了JDK 7的HotSpot,已經把原本放在永久代的字串常量池、靜態變數等移出,而到了 JDK 8,終於完全廢棄了永久代的概念,改用與JRockit、J9一樣在本地記憶體中實現的元空間(Meta- space)來代替,把JDK 7中永久代還剩餘的內容(主要是型別資訊)全部移到元空間中。 《Java虛擬機器規範》對方法區的約束是非常寬鬆的,除了和Java堆一樣不需要連續的記憶體和可以選 擇固定大小或者可擴充套件外,甚至還可以選擇不實現垃圾收集。相對而言,垃圾收集行為在這個區域的 確是比較少出現的,但並非資料進入了方法區就如永久代的名字一樣“永久”存在了。這區域的記憶體回 收目標主要是針對常量池的回收和對型別的解除安裝,一般來說這個區域的回收效果比較難令人滿意,尤 其是型別的解除安裝,條件相當苛刻,但是這部分割槽域的回收有時又確實是必要的。以前Sun公司的Bug列 表中,曾出現過的若干個嚴重的Bug就是由於低版本的HotSpot虛擬機器對此區域未完全回收而導致記憶體 洩漏。根據《Java虛擬機器規範》的規定,如果方法區無法滿足新的記憶體分配需求時,將丟擲 OutOfMemoryError異常。

執行時常量池

執行時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、字 段、方法、介面等描述資訊外,還有一項資訊是常量池表(Constant Pool Table),用於存放編譯期生 成的各種字面量與符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。 Java虛擬機器對於Class檔案每一部分(自然也包括常量池)的格式都有嚴格規定,如每一個位元組用 於儲存哪種資料都必須符合規範上的要求才會被虛擬機器認可、載入和執行,但對於執行時常量池, 《Java虛擬機器規範》並沒有做任何細節的要求,不同提供商實現的虛擬機器可以按照自己的需要來實現這個記憶體區域,不過一般來說,除了儲存Class檔案中描述的符號引用外,還會把由符號引用翻譯出來 的直接引用也儲存在執行時常量池中。 執行時常量池相對於Class檔案常量池的另外一個重要特徵是具備動態性,Java語言並不要求常量一定只有編譯期才能產生,也就是說,並非預置入Class檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可以將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的 intern()方法。 既然執行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體 時會丟擲OutOfMemoryError異常。

直接記憶體

直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是《Java虛擬機器規範》中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導致OutOfMemoryError異常出現,在JDK 1.4中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區 (Buffer)的I/O方式,它可以使用Native函式庫直接分配堆外記憶體,然後通過一個儲存在Java堆裡面的 DirectByteBuffer物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了 在Java堆和Native堆中來回複製資料。 顯然,本機直接記憶體的分配不會受到Java堆大小的限制,但是,既然是記憶體,則肯定還是會受到 本機總記憶體(包括實體記憶體、SWAP分割槽或者分頁檔案)大小以及處理器定址空間的限制,一般服務 器管理員配置虛擬機器引數時,會根據實際記憶體去設定-Xmx等引數資訊,但經常忽略掉直接記憶體,使得 各個記憶體區域總和大於實體記憶體限制(包括物理的和作業系統級的限制),從而導致動態擴充套件時出現 OutOfMemoryError異常。