運作時資料區域
JVM載執行Java程式的過程中會把它所管理的記憶體劃分為若幹個不同的資料區域。這些區域都有各自的用途,以及建立和銷毀的時間,有的區域随着虛拟機程序的啟動而存在,有些區域則是依賴使用者線程的啟動和結束而建立和銷毀。具體如下圖所示:

程式計數器(Program Counter Register)
程式計數器(Program Counter Register)是一塊較小的記憶體空間,可以看作是目前線程所執行的位元組碼的行号訓示器。在虛拟機概念模型中,位元組碼解釋器工作時就是通過改變計數器的值來選取下一條需要執行的位元組碼指令,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴這個計數器來完成。
程式計數器是一塊“線程私有”的記憶體,如上文的圖所示,每條線程都有一個獨立的程式計數器,各條線程之間的計數器互不影響,獨立存儲。這樣設計使得在多線程環境下,線程切換後能恢複到正确的執行位置。
如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛拟機位元組碼指令的位址;若執行的是Native方法,則計數器為空(Undefined)(因為對于Native方法而言,它的方法體并不是由Java位元組碼構成的,自然無法應用上述的“位元組碼指令的位址”的概念)。程式計數器也是唯一一個在Java虛拟機規範中沒有規定任何OutOfMemoryError情況的記憶體區域。
Java虛拟機棧(Java Virtual Machine Stacks)
Java虛拟機棧(Java Virtual Machine Stacks)描述的是Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame),棧幀中存儲着局部變量表、操作數棧、動态連結、方法出口等資訊。每一個方法從調用直至執行完成的過程,會對應一個棧幀在虛拟機棧中入棧到出棧的過程。與程式計數器一樣,Java虛拟機棧也是線程私有的。
函數的調用有完美的嵌套關系——調用者的生命期總是長于被調用者的生命期,并且後者在前者的之内。這樣,被調用者的局部資訊所占空間的配置設定總是後于調用者的(後入),而其釋放則總是先于調用者的(先出),是以正好可以滿足棧的LIFO順序,選用棧這種資料結構來實作調用棧是一種很自然的選擇。
局部變量表中存放了編譯期可知的各種:
- 基本資料類型(boolen、byte、char、short、int、 float、 long、double)
- 對象引用(reference類型,它不等于對象本身,可能是一個指向對象起始位址的指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)
- returnAddress類型(指向了一條位元組碼指令的位址)
其中64位長度的long和double類型的資料會占用2個局部變量空間(Slot),其餘資料類型隻占用1個。局部變量表所需的記憶體空間在編譯期間完成配置設定,當進入一個方法時,這個方法需要在幀中配置設定多大的局部變量空間是完全确定的,在方法運作期間不會改變局部變量表的大小。
Java虛拟機規範中對這個區域規定了兩種異常狀況:
- StackOverflowError:線程請求的棧深度大于虛拟機所允許的深度,将會抛出此異常。
- OutOfMemoryError:當可動态擴充的虛拟機棧在擴充時無法申請到足夠的記憶體,就會抛出該異常。
本地方法棧(Native Method Stack)
本地方法棧(Native Method Stack)與Java虛拟機棧作用很相似,它們的差別在于虛拟機棧為虛拟機執行Java方法(即位元組碼)服務,而本地方法棧則為虛拟機使用到的Native方法服務。
在虛拟機規範中對本地方法棧中使用的語言、方式和資料結構并無強制規定,是以具體的虛拟機可實作它。甚至有的虛拟機(Sun HotSpot虛拟機)直接把本地方法棧和虛拟機棧合二為一。與虛拟機一樣,本地方法棧會抛出StackOverflowError和OutOfMemoryError異常。
Java堆(Heap)
對于大多數應用而言,Java堆(Heap)是Java虛拟機所管理的記憶體中最大的一塊,它被所有線程共享的,在虛拟機啟動時建立。此記憶體區域唯一的目的是存放對象執行個體,幾乎所有的對象執行個體都在這裡配置設定記憶體,且每次配置設定的空間是不定長的。在Heap 中配置設定一定的記憶體來儲存對象執行個體,實際上隻是儲存對象執行個體的屬性值,屬性的類型和對象本身的類型标記等,并不儲存對象的方法(方法是指令,儲存在Stack中),在Heap 中配置設定一定的記憶體儲存對象執行個體和對象的序列化比較類似。對象執行個體在Heap 中配置設定好以後,需要在Stack中儲存一個4位元組的Heap 記憶體位址,用來定位該對象執行個體在Heap 中的位置,便于找到該對象執行個體。
Java虛拟機規範中描述道:所有的對象執行個體以及數組都要在堆上配置設定,但是随着JIT編譯器的發展和逃逸分析技術逐漸成熟,棧上配置設定、标量替換優化技術将會導緻一些微妙的變化發生,所有的對象都在堆上配置設定的定論也并不“絕對”了。
Java堆是垃圾收集器管理的主要區域,是以也被稱為“GC堆(Garbage Collected Heap)”。從記憶體回收的角度看記憶體空間可如下劃分:
- 新生代(Young): 新生成的對象優先存放在新生代中,新生代對象朝生夕死,存活率很低。在新生代中,正常應用進行一次垃圾收集一般可以回收70% ~ 95% 的空間,回收效率很高。新生代又可細分為Eden空間、From Survivor空間、To Survivor空間,預設比例為8:1:1。它們的具體作用将在下一篇文章講解GC時介紹。
- 老年代(Tenured/Old):在新生代中經曆了多次(具體看虛拟機配置的閥值)GC後仍然存活下來的對象會進入老年代中。老年代中的對象生命周期較長,存活率比較高,在老年代中進行GC的頻率相對而言較低,而且回收的速度也比較慢。
- 永久代(Perm):永久代存儲類資訊、常量、靜态變量、即時編譯器編譯後的代碼等資料,對這一區域而言,Java虛拟機規範指出可以不進行垃圾收集,一般而言不會進行垃圾回收。
其中新生代和老年代組成了Java堆的全部記憶體區域,而永久代不屬于堆空間,它在JDK 1.8以前被Sun HotSpot虛拟機用作方法區的實作,關于方法區的具體内容将在稍後介紹。
方法區(Method Area)
方法區(Method Area)與Java堆一樣,是各個線程共享的記憶體區域。Object Class Data(類定義資料)是存儲在方法區的,此外,常量、靜态變量、JIT編譯後的代碼也存儲在方法區。正因為方法區所存儲的資料與堆有一種類比關系,是以它還被稱為 Non-Heap。
JDK 1.8以前的永久代(PermGen)
Java虛拟機規範對方法區的限制非常寬松,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充外,還可以選擇不實作垃圾收集,也就是說,Java虛拟機規範隻是規定了方法區的概念和它的作用,并沒有規定如何去實作它。對于JDK 1.8之前的版本,HotSpot虛拟機設計團隊選擇把GC分代收集擴充至方法區,即用永久代來實作方法區,這樣HotSpot的垃圾收集器可以像管理Java堆一樣管理這部分記憶體,能夠省去專門為方法區編寫記憶體管理代碼的工作。對于其他的虛拟機(如Oracle JRockit、IBM J9等)來說是不存在永久代的概念的。
如果運作時有大量的類産生,可能會導緻方法區被填滿,直至溢出。常見的應用場景如:
- Spring和ORM架構使用CGLib操縱位元組碼對類進行增強,增強的類越多,就需要越大的方法區來保證動态生成的Class可以加載入記憶體。
- 大量JSP或動态産生JSP檔案的應用(JSP第一次運作時需要編譯為Java類)。
- 基于OSGi的應用(即使是同一個類檔案,被不同的類加載器加載也會視為不同的類)。 ……
這些都會導緻方法區溢出,報出
java.lang.OutOfMemoryError: PermGen space
。
JDK 1.8的元空間(Metaspace)
在JDK 1.8中,HotSpot虛拟機設計團隊為了促進HotSpot與 JRockit的融合,修改了方法區的實作,移除了永久代,選擇使用本地化的記憶體空間(而不是JVM的記憶體空間)存放類的中繼資料,這個空間叫做元空間(Metaspace)。
做了這個改動以後,
java.lang.OutOfMemoryError: PermGen
的空間問題将不複存在,并且不再需要調整和監控這個記憶體空間。且虛拟機需要為方法區設計額外的GC政策:如果類中繼資料的空間占用達到參數“MaxMetaspaceSize”設定的值,将會觸發對死亡對象和類加載器的垃圾回收。 為了限制垃圾回收的頻率和延遲,适當的監控和調優元空間是非常有必要的。元空間過多的垃圾收集可能表示類、類加載器記憶體洩漏或對你的應用程式來說空間太小了。
元空間的記憶體管理由元空間虛拟機來完成。先前,對于類的中繼資料我們需要不同的垃圾回收器進行處理,現在隻需要執行元空間虛拟機的C++代碼即可完成。在元空間中,類和其中繼資料的生命周期和其對應的類加載器是相同的。話句話說,隻要類加載器存活,其加載的類的中繼資料也是存活的,因而不會被回收掉。
我們從行文到現在提到的元空間稍微有點不嚴謹。準确的來說,每一個類加載器的存儲區域都稱作一個元空間,所有的元空間合在一起就是我們一直說的元空間。當一個類加載器被垃圾回收器标記為不再存活,其對應的元空間會被回收。在元空間的回收過程中沒有重定位和壓縮等操作。但是元空間内的中繼資料會進行掃描來确定Java引用。
元空間虛拟機負責元空間的配置設定,其采用的形式為組塊配置設定。組塊的大小因類加載器的類型而異。在元空間虛拟機中存在一個全局的空閑組塊清單。當一個類加載器需要組塊時,它就會從這個全局的組塊清單中擷取并維持一個自己的組塊清單。當一個類加載器不再存活,那麼其持有的組塊将會被釋放,并傳回給全局組塊清單。類加載器持有的組塊又會被分成多個塊,每一個塊存儲一個單元的元資訊。組塊中的塊是線性配置設定(指針碰撞配置設定形式)。組塊配置設定自記憶體映射區域。這些全局的虛拟記憶體映射區域以連結清單形式連接配接,一旦某個虛拟記憶體映射區域清空,這部分記憶體就會傳回給作業系統。
上圖展示的是虛拟記憶體映射區域如何進行元組塊的配置設定。類加載器1和3表明使用了反射或者為匿名類加載器,他們使用了特定大小組塊。 而類加載器2和4根據其内部條目的數量使用小型或者中型的組塊。
運作時常量池(Runtime Constant Pool)
運作時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、字段、方法、接口等描述資訊外,還有一項資訊是常量池(Constant Pool Table),用于存放編譯期生成的各種字面量和符号引用,這部分内容将在類加載後進入方法區的運作時常量池存放。
Java虛拟機對Class檔案每一部分(自然包括常量池)的格式有嚴格規定,每一個位元組用于存儲那種資料都必須符合規範上的要求才會被虛拟機認可、裝載和執行。但對于運作時常量池,Java虛拟機規範沒有做任何有關細節的要求,不同的提供商實作的虛拟機可以按照自己的需求來實作此記憶體區域。不過一般而言,除了儲存Class檔案中的描述符号引用外,還會把翻譯出的直接引用也存儲在運作時常量池中。
運作時常量池相對于Class檔案常量池的另外一個重要特征是具備動态性,Java語言并不要求常量一定隻有編譯器才能産生,也就是并非置入Class檔案中的常量池的内容才能進入方法區運作時常量池,運作期間也可能将新的常量放入池中,此特性被開發人員利用得比較多的便是String類的
intern()
方法。
直接記憶體
直接記憶體(Direct Memory)并不是虛拟機運作時資料區的一部分,也不是Java虛拟機規範中定義的記憶體區域。但這部分記憶體也被頻繁運用,而卻可能導緻OutOfMemoryError異常出現,是以這裡放到一起講解。
以NIO(New Input/Output)類為例,NIO引入了一種基于通道(Channel)與緩沖區(Buffer)的I/O方式,它可以使用Native函數庫直接配置設定堆外記憶體,然後通過一個存儲在Java堆中的DirectByteBuffer對象作為這塊記憶體的引用進行操作。這樣能避免在Java堆和Native堆中來回複制資料,在一些場景裡顯著提高性能。
本機直接記憶體的配置設定不會受到Java堆大小的限制,但是既然是記憶體,還是會受到本機總記憶體(包括RAM以及SWAP區或分頁檔案)大小以及處理器尋址空間的限制。伺服器管理者在配置虛拟機參數時,會根據實際記憶體設定-Xmx等參數資訊,但經常忽略直接記憶體,使得各個記憶體區域總和大于實體記憶體限制(包括實體的和作業系統的限制),進而導緻動态擴充時出現OutOfMemoryError異常。
HotSpot中的對象
對象的建立
Java的對象建立大緻有如下四種方式:
- new關鍵字 這應該是我們最常見和最常用最簡單的建立對象的方式。
- 使用
方法 這裡包括Class類的newInstance()
方法和Constructor類的newInstance()
方法(前者其實也是調用的後者)。newInstance()
-
方法 要使用clone()
方法我們必須實作實作Cloneable接口,用clone()
方法建立對象并不會調用任何構造函數。即我們所說的淺拷貝。clone()
- 反序列化 要實作反序列化我們需要讓我們的類實作Serializable接口。當我們序列化和反序列化一個對象,JVM會給我們建立一個單獨的對象,在反序列化時,JVM建立對象并不會調用任何構造函數。即我們所說的深拷貝。
上面的四種建立對象的方法除了第一種使用new指令之外,其他三種都是使用invokespecial(構造函數的直接調用)。這裡我們隻說new建立對象的方式,關于invokespecial的内容将在後續文章中介紹。下面我們來看看當虛拟機遇到new指令的時候對象是如何建立的。
1. 類加載檢查
虛拟機遇到一條new指令時,首先将去檢查這個指令的參數是否能在常量池中定位到一個類的符号引用,并且檢查這個符号引用代表的類是否已被加載、解析和初始化過的,如果沒有,則必須先執行相應的類加載過程,關于類加載機制和類加載器的詳細内容将在後續文章中介紹。
2. 配置設定記憶體
在類加載檢查通過後,虛拟機就将為新生對象配置設定記憶體。對象所需記憶體的大小在類加載完成後便可完全确定(如何确定在下一節對象記憶體布局時再詳細講解),為對象配置設定空間的任務具體便等同于從Java堆中劃出一塊大小确定的記憶體空間,可以分如下兩種情況讨論:
- Java堆中記憶體絕對規整 所有用過的記憶體都被放在一邊,空閑的記憶體被放在另一邊,中間放着一個指針作為分界點的訓示器,那所配置設定記憶體就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離,這種配置設定方式稱為“指針碰撞”(Bump The Pointer)。
- Java堆中的記憶體不規整 已被使用的記憶體和空閑的記憶體互相交錯,那就沒有辦法簡單的進行指針碰撞了,虛拟機就必須維護一個清單,記錄哪些記憶體塊是可用的,在配置設定的時候從清單中找到一塊足夠大的空間劃分給對象執行個體,并更新清單上的記錄,這種配置設定方式稱為“空閑清單”(Free List)。
選擇哪種配置設定方式由Java堆是否規整決定,而Java堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。是以在使用Serial、ParNew等帶Compact過程的收集器時,系統采用的配置設定算法是指針碰撞,而使用CMS這種基于Mark-Sweep算法的收集器時(說明一下,CMS收集器可以通過UseCMSCompactAtFullCollection或CMSFullGCsBeforeCompaction來整理記憶體),就通常采用空閑清單。關于垃圾收集器的具體内容将在下一篇文章中介紹。
除如何劃分可用空間之外,另外一個需要考慮的問題是對象建立在虛拟機中是非常頻繁的行為,即使是僅僅修改一個指針所指向的位置,在并發情況下也并非線程安全的,可能出現正在給對象A配置設定記憶體,指針還沒來得及修改,對象B又同時使用了原來的指針來配置設定記憶體。解決這個問題有如下兩個方案:
- 對配置設定記憶體空間的動作進行同步 實際上虛拟機是采用CAS配上失敗重試的方式保證更新操作的原子性。
- 把記憶體配置設定的動作按照線程劃分在不同的空間之中進行 即每個線程在Java堆中預先配置設定一小塊記憶體,稱為本地線程配置設定緩沖(TLAB ,Thread Local Allocation Buffer),哪個線程要配置設定記憶體,就在哪個線程的TLAB上配置設定,隻有TLAB用完,配置設定新的TLAB時才需要同步鎖定。虛拟機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定。
3. 初始化
記憶體配置設定完成之後,虛拟機需要将配置設定到的記憶體空間都初始化為零值(不包括對象頭),如果使用TLAB的話,這一個工作也可以提前至TLAB配置設定時進行。這步操作保證了對象的執行個體字段在Java代碼中可以不賦初始值就直接使用。
4. 設定對象頭
接下來,虛拟機要設定對象的資訊(如這個對象是哪個類的執行個體、如何才能找到類的中繼資料資訊、對象的哈希碼、對象的GC分代年齡等資訊)并存放在對象的對象頭(Object Header)中。根據虛拟機目前的運作狀态的不同,如是否啟用偏向鎖等,對象頭會有不同的設定方式。關于對象頭的具體内容,在下一節再詳細介紹。
5. 執行
<init>
方法
在上面工作都完成之後,在虛拟機的視角來看,一個新的對象已經産生了。但是在Java程式的視角看來,對象建立才剛剛開始——
<init>
方法還沒有執行,所有的字段都還為零值。是以一般來說(由位元組碼中是否跟随有invokespecial指令所決定),new指令之後會接着執行
<init>
方法,把對象按照程式員的意願進行初始化,這樣一個真正可用的對象才算完全産生出來。
對象的記憶體布局
HotSpot虛拟機中,對象在記憶體中存儲的布局可以分為三塊區域:對象頭(Header)、執行個體資料(Instance Data)和對齊填充(Padding)。
1. 對象頭
HotSpot虛拟機的對象頭包括兩部分資訊:
- 對象自身的運作時資料 “Mark Word” 如哈希碼(HashCode)、GC分代年齡、鎖狀态标志、線程持有的鎖、偏向線程ID、偏向時間戳等等,這部分資料的長度在32位和64位的虛拟機(暫不考慮開啟壓縮指針的場景)中分别為32個和64個Bits,官方稱它為“Mark Word”。對象需要存儲的運作時資料很多,其實已經超出了32、64位Bitmap結構所能記錄的限度,但是對象頭資訊是與對象自身定義的資料無關的額外存儲成本,考慮到虛拟機的空間效率,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體儲盡量多的資訊,它會根據對象的狀态複用自己的存儲空間。例如在32位的HotSpot虛拟機中對象未被鎖定的狀态下,Mark Word的32個Bits空間中的25Bits用于存儲對象哈希碼(HashCode),4Bits用于存儲對象分代年齡,2Bits用于存儲鎖标志位,1Bit固定為0,在其他狀态(輕量級鎖定、重量級鎖定、GC标記、可偏向)下對象的存儲内容如下圖所示:
- 類型指針 類型指針即對象指向它的類中繼資料的指針,虛拟機通過這個指針來确定這個對象是哪個類的執行個體。并不是所有的虛拟機實作都必須在對象資料上保留類型指針,換句話說查找對象的中繼資料資訊并不一定要經過對象本身,這點我們在下一節讨論。另外,如果對象是一個Java數組,那在對象頭中還必須有一塊用于記錄數組長度的資料,因為虛拟機可以通過普通Java對象的中繼資料資訊确定Java對象的大小,但是從數組的中繼資料中無法确定數組的大小。
2. 執行個體資料
執行個體資料是對象真正存儲的有效資訊,也既是我們在程式代碼裡面所定義的各種類型的字段内容,無論是從父類繼承下來的,還是在子類中定義的都需要記錄起來。這部分的存儲順序會受到虛拟機配置設定政策參數(FieldsAllocationStyle)和字段在Java源碼中定義順序的影響。HotSpot虛拟機預設的配置設定政策為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),從配置設定政策中可以看出,相同寬度的字段總是被配置設定到一起。在滿足這個前提條件的情況下,在父類中定義的變量會出現在子類之前。如果CompactFields參數值為true(預設為true),那子類之中較窄的變量也可能會插入到父類變量的空隙之中。
3. 對齊填充
對齊填充并不是必然存在的,也沒有特别的含義,它僅僅起着占位符的作用。由于HotSpot VM的自動記憶體管理系統要求對象起始位址必須是8位元組的整數倍,換句話說就是對象的大小必須是8位元組的整數倍。對象頭部分正好似8位元組的倍數(1倍或者2倍),是以當對象執行個體資料部分沒有對齊的話,就需要通過對齊填充來補全。
對象的通路定位
我們的Java程式需要通過棧上的對象引用(reference)資料(存儲在棧上的局部變量表中)來操作堆上的具體對象。由于reference類型在Java虛拟機規範裡面也隻規定了是一個指向對象的引用,并沒有定義這個引用的具體實作,對象通路方式也是取決于虛拟機實作而定的。主流的通路方式有使用句柄和直接指針兩種。
1. 使用句柄通路
如果使用句柄通路的話,Java堆中将會劃分出一塊記憶體來作為句柄池,reference中存儲的就是對象的句柄位址,而句柄中包含了對象執行個體資料與類型資料的各自的具體位址資訊。如下圖所示:
2. 使用直接指針通路
如果使用直接指針通路的話,Java堆對象的布局中就必須考慮如何放置通路類型資料的相關資訊,reference中存儲的直接就是對象位址,如下圖所示:
這兩種對象通路方式各有優勢,下面分别來談一談: