JVM 結構剖析
1.程式計數器
程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。
由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立記憶體
如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是Native方法,這個計數器的值為空(Undefined)。此記憶體區域是唯一一個在Java虛擬機器規範中沒有任何OutOfMemoryError情況的區域。
2.Java虛擬機器棧
與程式計數器一樣,Java虛擬機器棧(Java Virtual Machine Stacks)也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame
在JVM規範中對於該區域規定了兩種異常情況:如果執行緒請求的棧深度大於虛擬機器所允許的深度(可以將棧理解為一種陣列,棧深度可以理解為陣列長度,當然棧和陣列還是很不同的,這裡是為了理解),將丟擲StackOverflowError異常;如果虛擬機器可以動態擴充套件(當前大部分的Java虛擬機器都可以動態擴充套件,只不過Java虛擬機器規範中也允許固定長度的虛擬機器棧),如果擴充套件時無法申請到足夠的記憶體空間,就會丟擲OutOfMemoryError異常。
對於執行引擎來說,活動執行緒中,只有棧頂的棧幀是有效的,稱為當前棧幀,這個棧幀所關聯的方法稱為當前方法。執行引擎所執行的所有位元組碼指令都只針對當前棧幀進行操作。
2.1.執行時棧幀結構
棧幀(Stack Frame)是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中的虛擬機器棧(Virtual Machine Stack)的棧元素。棧幀儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址等資訊。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。
每一個棧幀都包括了局部變量表、運算元棧、動態連線、方法返回地址和一些額外的附加資訊。在編譯程式程式碼的時候,棧幀中需要多大的區域性變量表,多深的運算元棧都已經完全確定了,並且寫入到方法表的 Code 屬性之中,因此一個棧幀需要分配多少記憶體,不會受到程式執行期變數資料的影響,而僅僅取決於具體的虛擬機器實現。
一個執行緒中的方法呼叫鏈可能會很長,很多方法都同時處於執行狀態。對於執行引擎來說,在活動執行緒中,只有位於棧頂的棧幀才是有效的,稱為當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法稱為當前方法(CurrentMethod)。執行引擎執行的所有位元組碼指令都只針對當前棧幀進行操作,在概念模型上,典型的棧幀結構如上圖所示。
2.2.區域性變量表
區域性變量表(Local Variable Table)是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。在 Java 程式編譯為 Class 檔案時,就在方法的 Code 屬性的 max_locals 資料項中確定了該方法所需要分配的區域性變量表的最大容量。
區域性變量表的容量以變數槽(Variable Slot,下稱 Slot)為最小單位,虛擬機器規範中並沒有明確指明一個 Slot 應占用的記憶體空間大小,只是很有導向性地說到每個 Slot 都應該能存放一個 boolean、byte、char、short、int、float、reference (注:Java 虛擬機器規範中沒有明確規定 reference 型別的長度,它的長度與實際使用 32 還是 64 位虛擬機器有關,如果是64 位虛擬機器,還與是否開啟某些物件指標壓縮的優化有關,這裡暫且只取 32 位虛擬機器的 reference 長度)或 returnAddress 型別的資料,這 8 種資料型別,都可以使用 32 位或更小的實體記憶體來存放,但這種描述與明確指出“每個 Slot 佔用 32 位長度的記憶體空間” 是有一些差別的,它允許 Slot 的長度可以隨著處理器、作業系統或虛擬機器的不同而傳送變化。只要保證即使在 64位虛擬機器中使用了 64 位的實體記憶體空間去實現一個 Slot,虛擬機器仍要使用對齊和補白的手段讓 Slot在外觀上看起來與 32 位虛擬機器中的一致。
對於 64 位的資料型別,虛擬機器會以高位對齊的方式為其分配兩個連續的 Slot 空間。Java 語言中明確的(reference 型別則可能是 32 位也可能是 64 位)64 位的資料型別只有 long 和 double 兩種。值得一提的是,這裡把 long 和 double 資料型別分割儲存的做法與 “虛擬機器通過索引定位的方式使用區域性變量表,索引值的範圍是從 0 開始至區域性變量表最大的 Slot 數量。如果訪問的是 32 位資料型別的變數,索引 n 就代表了使用第 n 個 Slot,如果是 64 位資料型別的變數,則說明會同時使用 n 和 n+1 兩個 Slot。對於兩個相鄰的共同存放一個 64 位資料的兩個 Slot,不允許採用任何方式單獨訪問其中的某一個,Java 虛擬機器規範中明確要求瞭如果遇到進行這種操作的位元組碼序列,虛擬機器應該在類載入的校驗階段丟擲異常。
2.3運算元棧
運算元棧(Operand Stack)也常稱為操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同區域性變量表一樣,運算元棧的最大深度也在編譯的時候寫入到 Code 屬性的 max_stacks 資料項中。運算元棧的每一個元素可以是任意的Java 資料型別,包括long 和double。32 位資料型別所佔的棧容量為1,64位資料型別所佔的棧容量為2。在方法執行的任何時候,運算元棧的深度都不會超過在 max_stacks 資料項中設定的最大值。
當一個方法剛剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是出棧/入棧操作。例如,在做算術運算的時候是通過運算元棧來進行的,又或者再呼叫其他方法的時候是通過運算元棧來進行引數傳遞的。
2.4動態連線
每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線(Dynamic Linking)。我們知道 Class 檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用作為引數。這些符號引用一部分會在類載入階段或者第一次使用的時候就轉化為直接引用,這種轉化成為靜態解析。另外一部分將在每一次執行期間轉化為直接引用,這部分成為動態連線。
2.5方法返回地址
當一個方法開始執行後,只有兩種方式可以退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者(呼叫當前方法的方法稱為呼叫者),是否有返回值和返回值的型別將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocatino Completion)。
另外一種退出方式是,在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是 Java虛擬機器內部產生的異常,還是程式碼中使用 athrow 位元組碼指令產生的異常,只要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者產生任何返回值的。
無論採用何種退出方式,在方法退出之後,都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,呼叫者的 PC 計數器的值可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會儲存這部分資訊。
方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變量表和運算元棧,把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中,調整 PC 計數器的值以指向方法呼叫指令後面的一條指令等。
3.本地方法棧
本地方法棧(Native Method Stack)與虛擬機器棧所發揮的作用十分相似的,它們之間的區別不過是虛擬機器棧為虛擬機器執行Java方法(也是位元組碼)服務,而本地方法棧則為虛擬機器使用到的Native方法服務。在虛擬機器規範中對本地方法棧中使用的語言、使用方式和資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。甚至有的虛擬機器(如HotSpot)直接就把本地方法棧和虛擬機器棧合二為一。與虛擬機器棧一樣,本地方法棧也會丟擲OutOfMemoryError異常。
我們知道,當一個類第一次被使用到時,這個類的位元組碼會被載入到記憶體,並且只會回載一次。在這個被載入的位元組碼的入口維持著一個該類所有方法描述符的list,這些方法描述符包含這樣一些資訊:方法程式碼存於何處,它有哪些引數,方法的描述符(public之類)等等。
如果一個方法描述符內有native,這個描述符塊將有一個指向該方法的實現的指標。這些實現在一些DLL檔案內,但是它們會被作業系統載入到java程式的地址空間。當一個帶有本地方法的類被載入時,其相關的DLL並未被載入,因此指向方法實現的指標並不會被設定。當本地方法被呼叫之前,這些DLL才會被載入,這是通過呼叫java.system.loadLibrary()實現的。
最後需要提示的是,使用本地方法是有開銷的,它喪失了java的很多好處。如果別無選擇,我們可以選擇使用本地方法。
4.Java堆
對於大多數應用來說,Java堆(JavaHeap)是Java虛擬機器所管理記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。這一點在JVM規範中的描述是:所有物件例項以及陣列都要在堆上分配,但是隨著JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的物件都分配在堆上也逐漸變得不是那麼“絕對”了。
Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱為“GC堆”(Garbage Collected Head,注意這不是垃圾堆)。從記憶體回收的角度來看,由於現在收集器基本都採用分代收集演算法,所以Java堆中可以細分為:新生代和老年代;再細緻一點的有Eden空間、From Survivor空間、To Survivor空間等。從記憶體分配的角度來看,執行緒共享的Java堆中可能劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。不過無論如何劃分,都與存放內容無關,無論哪個區域,儲存的都仍然是物件例項,進一步劃分的目的是為了更好地記憶體回收,或者更快的分配記憶體。
5. 方法區
方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆),目的應該是與Java堆區分開來。
很多時候我們看到一些文獻和資料稱呼HotSpot的方法區為“永久代”(Permanent Generation),本質上兩者是不等價的,僅僅是因為HotSpot虛擬機器的設計團隊把GC分代收集擴充套件至方法區,或者說使用永久代來實現方法區而已,這樣HotSpot的垃圾收集器可以像管理Java堆一樣來管理這部分記憶體,能夠省去專門為方法區編寫記憶體管理程式碼的工作。對於其它虛擬機器(如BEA JRockit、IBM J9等)來說是不存在永久代的概念的。原則上,如何實現方法區屬於虛擬機器的實現細節,不受虛擬機器規範約束,但使用永久代來實現方法區,現在看來不是一個好的主意,因為這樣很容易造成記憶體洩漏問題(永久代有-XX:MaxPermSize的上限,J9和JRockit只要沒有觸碰到程序可用記憶體的上限,例如32位系統中的4GB,就不會出現問題),而且有極少數方法(例如String.intern())會因這個原因導致不同虛擬機器下有不同的表現。因此,對於HotSpot虛擬機器,根據官方釋出的路線圖資訊,現在也有放棄永久代並逐步改為採用Native Memory來實現方法區的規劃了(實際上JDK1.8已經實現了該計劃,永久代消除,以元空間Metaspace替代),在JDK1.7中,已經將原本放在方法區中字串常量池移出到堆中。
JVM規範對於方法區的限制非常寬鬆,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。相對而言,垃圾收集行為在這個區域是比較少出現的,但並非資料進入了方法區就如永久代的名字一樣“永久”存在了。這區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,一般來說,這個區域的回收成績比較令人難以滿意,尤其是型別的解除安裝,條件相當苛刻,但是這部分割槽域的回收確實是很必要的。在Sun公司的BUG列表中,曾出現過的若干個嚴重的BUG就是由於低版本的HotSpot虛擬機器對此區域未完全回收而導致記憶體洩漏。
根據Java虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError異常。
5.1執行時常量池
執行時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池(ConstantPool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。
JVM對於Class檔案每一部分(自然包括常量池)的格式都有嚴格規定,每一個位元組用於儲存哪種資料都必須符合規範上的要求才會被虛擬機器認可、裝載和執行,但對於執行時常量池,Java虛擬機器規範沒有做任何細節的要求,不同的提供商實現的虛擬機器可以按照自己的需求來實現這個記憶體區域。不過,一般來說,除了儲存Class檔案中描述的符號引用外,還會把翻譯出來的直接引用也儲存在執行時常量池中。
執行時常量池相對於Class檔案常量池的另外一個重要的特徵是具備動態性,Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入Class檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。
既然執行時常量池是方法區的一部分,自然受到方法區的限制,當常量池無法再申請到記憶體時會丟擲OutOfMemoryError異常。
6元空間
上面我們知道移除永久代的工作從JDK1.7就開始了。JDK1.7中,儲存在永久代的部分資料就已經轉移到了JavaHeap或者是 Native Heap。但永久代仍存在於JDK1.7中,並沒完全移除,譬如符號引用(Symbols)轉移到了native heap;字面量(interned strings)轉移到了java heap;類的靜態變數(class statics)轉移到了java heap。我們可以通過一段程式來比較 JDK 1.6 與 JDK 1.7及 JDK 1.8 的區別,以字串常量為例:
package cn.metaspace.error; import java.util.ArrayList; import java.util.List; publicclass MethodAreaTest { static String base = "String"; publicstaticvoid main(String[] args) { List<String> list = new ArrayList<String>(); for (int i = 0; i < Integer.MAX_VALUE; i++) { String str = base + base; base = str; list.add(str.intern()); } } } |
這段程式以2的指數級不斷的生成新的字串,這樣可以比較快速的消耗記憶體。我們通過 JDK 1.6、JDK 1.7 和 JDK 1.8 分別執行:
JDK 1.6 的執行結果:
JDK 1.7的執行結果:
JDK 1.8的執行結果:
取消配置命令
-XX:PermSize=8m-XX:MaxPermSize=8m -Xmx16m
從上述結果可以看出,JDK 1.6下,會出現“PermGen Space”的記憶體溢位,而在 JDK 1.7和 JDK 1.8 中,會出現堆記憶體溢位,並且 JDK 1.8中 PermSize 和 MaxPermGen 已經無效。因此,可以大致驗證 JDK 1.7 和 1.8 將字串常量由永久代轉移到堆中,並且 JDK 1.8 中已經不存在永久代的結論。現在我們看看元空間到底是一個什麼東西?
元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體。因此,預設情況下,元空間的大小僅受本地記憶體限制,但可以通過以下引數來指定元空間的大小:
-XX:MetaspaceSize,初始空間大小,達到該值就會觸發垃圾收集進行型別解除安裝,同時GC會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那麼在不超過MaxMetaspaceSize時,適當提高該值。
-XX:MaxMetaspaceSize,最大空間,預設是沒有限制的。
除了上面兩個指定大小的選項以外,還有兩個與 GC 相關的屬性:
-XX:MinMetaspaceFreeRatio,在GC之後,最小的Metaspace剩餘空間容量的百分比,減少為分配空間所導致的垃圾收集
-XX:MaxMetaspaceFreeRatio,在GC之後,最大的Metaspace剩餘空間容量的百分比,減少為釋放空間所導致的垃圾收集
現在我們在JDK8下重新執行一下下面程式碼段,不過這次不再指定PermSize和MaxPermSize。而是指定MetaSpaceSize 和 MaxMetaSpaceSize的大小。輸出結果如下:
package cn.metaspace.error; import java.io.File; import java.lang.management.ClassLoadingMXBean; import java.lang.management.ManagementFactory; import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.List; publicclass OOMTest { publicstaticvoid main(String[] args) { URL url = null; List classLoaderList = new ArrayList(); try { url = newFile("/tmp").toURI().toURL(); URL[] urls = {url}; while (true){ ClassLoader loader = new URLClassLoader(urls); classLoaderList.add(loader); loader.loadClass("cn.metaspace.error.ClassA"); } } catch(Exception e) { e.printStackTrace(); } } } |
-XX:MetaspaceSize=8m-XX:MaxMetaspaceSize=8m
從輸出結果,我們可以看出,這次不再出現永久代溢位,而是出現了元空間的溢位。
7 總結
通過上面分析,大致瞭解了 JVM 的記憶體劃分,也清楚了 JDK 8 中永久代向元空間的轉換。不過大家應該都有一個疑問,就是為什麼要做這個轉換?所以,最後給大家總結以下幾點原因:
1、字串存在永久代中,容易出現效能問題和記憶體溢位。所以JDK1.7實現了將字串常量池轉移到堆中的操作,利用堆的大空間和垃圾回收幫助解決這個問題。
2、類及方法的資訊等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢位,太大則容易導致老年代溢位。故1.8實現了去除永久代的操作。
3、永久代會為 GC 帶來不必要的複雜度,並且回收效率偏低。
4、Oracle 可能會將HotSpot 與 JRockit 合二為一。
總之,元資料的出現大大減少了OutOfMemoryError的出現概率.
參考:https://www.cnblogs.com/lin-jing/p/8308272.html#_label5