天天看點

【深入Java虛拟機】之一:Java記憶體區域與記憶體溢出

記憶體區域

Java虛拟機在執行Java程式的過程中會把他所管理的記憶體劃分為若幹個不同的資料區域。Java虛拟機規範将JVM所管理的記憶體分為以下幾個運作時資料區:程式計數器、Java虛拟機棧、本地方法棧、Java堆、方法區。下面詳細闡述各資料區所存儲的資料類型。

【深入Java虛拟機】之一:Java記憶體區域與記憶體溢出

程式計數器(Program Counter Register)

一塊較小的記憶體空間,它是目前線程所執行的位元組碼的行号訓示器,位元組碼解釋器工作時通過改變該計數器的值來選擇下一條需要執行的位元組碼指令,分支、跳轉、循環等基礎功能都要依賴它來實作。每條線程都有一個獨立的的程式計數器,各線程間的計數器互不影響,是以該區域是線程私有的。

當線程在執行一個Java方法時,該計數器記錄的是正在執行的虛拟機位元組碼指令的位址,當線程在執行的是Native方法(調用本地作業系統方法)時,該計數器的值為空。另外,該記憶體區域是唯一一個在Java虛拟機規範中沒有規定任何OOM(記憶體溢出:OutOfMemoryError)情況的區域。

Java虛拟機棧(Java Virtual Machine Stacks)

該區域是線程私有的,它的生命周期與線程相同。虛拟機棧描述的是Java方法執行的記憶體模型:每個方法被執行的時候都會同時建立一個棧幀,棧它是用于支援續虛拟機進行方法調用和方法執行的資料結構。對于執行引擎來講,活動線程中,隻有棧頂的棧幀是有效的,稱為目前棧幀,這個棧幀所關聯的方法稱為目前方法,執行引擎所運作的所有位元組碼指令都隻針對目前棧幀進行操作。棧幀用于存儲局部變量表、操作數棧、動态連結、方法傳回位址和一些額外的附加資訊。在編譯程式代碼時,棧幀中需要多大的局部變量表、多深的操作數棧都已經完全确定了,并且寫入了方法表的Code屬性之中。是以,一個棧幀需要配置設定多少記憶體,不會受到程式運作期變量資料的影響,而僅僅取決于具體的虛拟機實作。

在Java虛拟機規範中,對這個區域規定了兩種異常情況:

  1. 如果線程請求的棧深度大于虛拟機所允許的深度,将抛出StackOverflowError異常。
  2. 如果虛拟機在動态擴充棧時無法申請到足夠的記憶體空間,則抛出OutOfMemoryError異常。

這兩種情況存在着一些互相重疊的地方:當棧空間無法繼續配置設定時,到底是記憶體太小,還是已使用的棧空間太大,其本質上隻是對同一件事情的兩種描述而已。在單線程的操作中,無論是由于棧幀太大,還是虛拟機棧空間太小,當棧空間無法配置設定時,虛拟機抛出的都是StackOverflowError異常,而不會得到OutOfMemoryError異常。而在多線程環境下,則會抛出OutOfMemoryError異常。

下面詳細說明棧幀中所存放的各部分資訊的作用和資料結構。

局部變量表

局部變量表是一組變量值存儲空間,用于存放方法參數和方法内部定義的局部變量,其中存放的資料的類型是編譯期可知的各種基本資料類型、對象引用(reference)和returnAddress類型(它指向了一條位元組碼指令的位址)。局部變量表所需的記憶體空間在編譯期間完成配置設定,即在Java程式被編譯成Class檔案時,就确定了所需配置設定的最大局部變量表的容量。當進入一個方法時,這個方法需要在棧中配置設定多大的局部變量空間是完全确定的,在方法運作期間不會改變局部變量表的大小。

局部變量表的容量以變量槽(Slot)為最小機關。在虛拟機規範中并沒有明确指明一個Slot應占用的記憶體空間大小(允許其随着處理器、作業系統或虛拟機的不同而發生變化),一個Slot可以存放一個32位以内的資料類型:boolean、byte、char、short、int、float、reference和returnAddress。reference是對象的引用類型,returnAddress是為位元組指令服務的,它執行了一條位元組碼指令的位址。對于64位的資料類型(long和double),虛拟機會以高位在前的方式為其配置設定兩個連續的Slot空間。

虛拟機通過索引定位的方式使用局部變量表,索引值的範圍是從0開始到局部變量表最大的Slot數量,對于32位資料類型的變量,索引n代表第n個Slot,對于64位的,索引n代表第n和第n+1兩個Slot。

在方法執行時,虛拟機是使用局部變量表來完成參數值到參數變量清單的傳遞過程的,如果是執行個體方法(非static),則局部變量表中的第0位索引的Slot預設是用于傳遞方法所屬對象執行個體的引用,在方法中可以通過關鍵字“this”來通路這個隐含的參數。其餘參數則按照參數表的順序來排列,占用從1開始的局部變量Slot,參數表配置設定完畢後,再根據方法體内部定義的變量順序和作用域配置設定其餘的Slot。

局部變量表中的Slot是可重用的,方法體中定義的變量,作用域并不一定會覆寫整個方法體,如果目前位元組碼PC計數器的值已經超過了某個變量的作用域,那麼這個變量對應的Slot就可以交給其他變量使用。這樣的設計不僅僅是為了節省空間,在某些情況下Slot的複用會直接影響到系統的而垃圾收集行為。

操作數棧

操作數棧又常被稱為操作棧,操作數棧的最大深度也是在編譯的時候就确定了。32位資料類型所占的棧容量為1,64為資料類型所占的棧容量為2。當一個方法開始執行時,它的操作棧是空的,在方法的執行過程中,會有各種位元組碼指令(比如:加操作、指派元算等)向操作棧中寫入和提取内容,也就是入棧和出棧操作。

Java虛拟機的解釋執行引擎稱為“基于棧的執行引擎”,其中所指的“棧”就是操作數棧。是以我們也稱Java虛拟機是基于棧的,這點不同于Android虛拟機,Android虛拟機是基于寄存器的。

基于棧的指令集最主要的優點是可移植性強,主要的缺點是執行速度相對會慢些;而由于寄存器由硬體直接提供,是以基于寄存器指令集最主要的優點是執行速度快,主要的缺點是可移植性差。

動态連接配接

每個棧幀都包含一個指向運作時常量池(在方法區中,後面介紹)中該棧幀所屬方法的引用,持有這個引用是為了支援方法調用過程中的動态連接配接。Class檔案的常量池中存在有大量的符号引用,位元組碼中的方法調用指令就以常量池中指向方法的符号引用為參數。這些符号引用,一部分會在類加載階段或第一次使用的時候轉化為直接引用(如final、static域等),稱為靜态解析,另一部分将在每一次的運作期間轉化為直接引用,這部分稱為動态連接配接。

方法傳回位址

當一個方法被執行後,有兩種方式退出該方法:執行引擎遇到了任意一個方法傳回的位元組碼指令或遇到異常,并且該異常沒有在方法體内得到處理。無論采用何種退出方式,在方法退出之後,都需要傳回到方法被調用的位置,程式才能繼續執行。方法傳回時可能需要在棧幀中儲存一些資訊,用來幫助恢複它的上層方法的執行狀态。一般來說,方法正常退出時,調用者的PC計數器的值就可以作為傳回位址,棧幀中很可能儲存了這個計數器值,而方法異常退出時,傳回位址是要通過異常處理器來确定的,棧幀中一般不會儲存這部分資訊。

方法退出的過程實際上等同于把目前棧幀出棧,是以退出時可能執行的操作有:恢複上層方法的局部變量表和操作數棧,如果有傳回值,則把它壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令後面的一條指令。

本地方法棧(Native Method Stacks)

該區域與虛拟機棧所發揮的作用非常相似,隻是虛拟機棧為虛拟機執行Java方法服務,而本地方法棧則為使用到的本地作業系統(Native)方法服務。

Java堆(Java Heap)

Java Heap是Java虛拟機所管理的記憶體中最大的一塊,它是所有線程共享的一塊記憶體區域。幾乎所有的對象執行個體和數組都在這類配置設定記憶體。Java Heap是垃圾收集器管理的主要區域,是以很多時候也被稱為“GC堆”。

根據Java虛拟機規範的規定,Java堆可以處在實體上不連續的記憶體空間中,隻要邏輯上是連續的即可。如果在堆中沒有記憶體可配置設定時,并且堆也無法擴充時,将會抛出OutOfMemoryError異常。

方法區(Method Area)

方法區也是各個線程共享的記憶體區域,它用于存儲已經被虛拟機加載的類資訊、常量、靜态變量、即時編譯器編譯後的代碼等資料。方法區域又被稱為“永久代”,但這僅僅對于Sun HotSpot來講,JRockit和IBM J9虛拟機中并不存在永久代的概念。Java虛拟機規範把方法區描述為Java堆的一個邏輯部分,而且它和Java Heap一樣不需要連續的記憶體,可以選擇固定大小或可擴充,另外,虛拟機規範允許該區域可以選擇不實作垃圾回收。相對而言,垃圾收集行為在這個區域比較少出現。該區域的記憶體回收目标主要針是對廢棄常量的和無用類的回收。運作時常量池是方法區的一部分,Class檔案中除了有類的版本、字段、方法、接口等描述資訊外,還有一項資訊是常量池(Class檔案常量池),用于存放編譯器生成的各種字面量和符号引用,這部分内容将在類加載後存放到方法區的運作時常量池中。運作時常量池相對于Class檔案常量池的另一個重要特征是具備動态性,Java語言并不要求常量一定隻能在編譯期産生,也就是并非預置入Class檔案中的常量池的内容才能進入方法區的運作時常量池,運作期間也可能将新的常量放入池中,這種特性被開發人員利用比較多的是String類的intern()方法。

根據Java虛拟機規範的規定,當方法區無法滿足記憶體配置設定需求時,将抛出OutOfMemoryError異常。

記憶體洩漏和記憶體溢出的差别

記憶體洩露是指配置設定出去的記憶體沒有被回收回來,由于失去了對該記憶體區域的控制,因而造成了資源的浪費。Java中一般不會産生記憶體洩露,因為有垃圾回收器自動回收垃圾,但這也不絕對,當我們new了對象,并儲存了其引用,但是後面一直沒用它,而垃圾回收器又不會去回收它,這邊會造成記憶體洩露,

記憶體溢出是指程式所需要的記憶體超出了系統所能配置設定的記憶體(包括動态擴充)的上限。

類型擦除

Java語言在JDK1.5之後引入的泛型實際上隻在程式源碼中存在,在編譯後的位元組碼檔案中,就已經被替換為了原來的原生類型,并且在相應的地方插入了強制轉型代碼,是以對于運作期的Java語言來說,​

​ArrayList<String>​

​​和​

​ArrayList<Integer>​

​就是同一個類。是以泛型技術實際上是Java語言的一顆文法糖,Java語言中的泛型實作方法稱為類型擦除,基于這種方法實作的泛型被稱為僞泛型。

下面是一段簡單的Java泛型代碼:

Map<Integer,String> map = new HashMap<Integer,String>();  
map.put(1,"No.1");  
map.put(2,"No.2");  
System.out.println(map.get(1));  
System.out.println(map.get(2));      

将這段Java代碼編譯成Class檔案,然後再用位元組碼反編譯工具進行反編譯後,将會發現泛型都變回了原生類型,如下面的代碼所示:

Map map = new HashMap();  
map.put(1,"No.1");  
map.put(2,"No.2");  
System.out.println((String)map.get(1));  
System.out.println((String)map.get(2));      

為了更詳細地說明類型擦除,再看如下代碼:

import java.util.List;  
public class FanxingTest{  
    public void method(List<String> list){  
        System.out.println("List String");  
    }  
    public void method(List<Integer> list){  
        System.out.println("List Int");  
    }  
}      

當我用Javac編譯器編譯這段代碼時,報出了如下錯誤:

FanxingTest.java:3: 名稱沖突:method(java.util.List<java.lang.String>) 和 method

(java.util.List<java.lang.Integer>) 具有相同疑符

public void method(List<String> list){

^

FanxingTest.java:6: 名稱沖突:method(java.util.List<java.lang.Integer>) 和 metho

d(java.util.List<java.lang.String>) 具有相同疑符

public void method(List<Integer> list){

^      

2 錯誤

這是因為泛型List和List編譯後都被擦除了,變成了一樣的原生類型List,擦除動作導緻這兩個方法的特征簽名變得一模一樣,在Class類檔案結構一文中講過,Class檔案中不能存在特征簽名相同的方法。

把以上代碼修改如下:

import java.util.List;  
public class FanxingTest{  
    public int method(List<String> list){  
        System.out.println("List String");  
        return 1;  
    }  
    public boolean method(List<Integer> list){  
        System.out.println("List Int");  
        return true;  
    }  
}      

發現這時編譯可以通過了(注意:Java語言中true和1沒有關聯,二者屬于不同的類型,不能互相轉換,不存在C語言中整數值非零即真的情況)。兩個不同類型的傳回值的加入,使得方法的重載成功了。這是為什麼呢?

我們知道,Java代碼中的方法特征簽名隻包括了方法名稱、參數順序和參數類型,并不包括方法的傳回值,是以方法的傳回值并不參與重載方法的選擇,這樣看來為重載方法加入傳回值貌似是多餘的。對于重載方法的選擇來說,這确實是多餘的,但我們現在要解決的問題是讓上述代碼能通過編譯,讓兩個重載方法能夠合理地共存于同一個Class檔案之中,這就要看位元組碼的方法特征簽名,它不僅包括了Java代碼中方法特征簽名中所包含的那些資訊,還包括方法傳回值及受查異常表。為兩個重載方法加入不同的傳回值後,因為有了不同的位元組碼特征簽名,它們便可以共存于一個Class檔案之中。

直接記憶體(Direct Memory)

直接記憶體并不是虛拟機運作時資料區的一部分,也不是Java虛拟機規範中定義的記憶體區域,它直接從作業系統中配置設定,是以不受Java堆大小的限制,但是會受到本機總記憶體的大小及處理器尋址空間的限制,是以它也可能導緻OutOfMemoryError異常出現。在JDK1.4中新引入了NIO機制,它是一種基于通道與緩沖區的新I/O方式,可以直接從作業系統中配置設定直接記憶體,即在堆外配置設定記憶體,這樣能在一些場景中提高性能,因為避免了在Java堆和Native堆中來回複制資料。

記憶體溢出

下面給出個記憶體區域記憶體溢出的簡單測試方法

【深入Java虛拟機】之一:Java記憶體區域與記憶體溢出

這裡有一點要重點說明,在多線程情況下,給每個線程的棧配置設定的記憶體越大,反而越容易産生記憶體溢出異常。作業系統為每個程序配置設定的記憶體是有限制的,虛拟機提供了參數來控制Java堆和方法區這兩部分記憶體的最大值,忽略掉程式計數器消耗的記憶體(很小),以及程序本身消耗的記憶體,剩下的記憶體便給了虛拟機棧和本地方法棧,每個線程配置設定到的棧容量越大,可以建立的線程數量自然就越少。是以,如果是建立過多的線程導緻的記憶體溢出,在不能減少線程數的情況下,就隻能通過減少最大堆和每個線程的棧容量來換取更多的線程。

另外,由于Java堆内也可能發生記憶體洩露(Memory Leak),這裡簡要說明一下記憶體洩露和記憶體溢出的差別:

記憶體洩露是指配置設定出去的記憶體沒有被回收回來,由于失去了對該記憶體區域的控制,因而造成了資源的浪費。Java中一般不會産生記憶體洩露,因為有垃圾回收器自動回收垃圾,但這也不絕對,當我們new了對象,并儲存了其引用,但是後面一直沒用它,而垃圾回收器又不會去回收它,這邊會造成記憶體洩露,記憶體溢出是指程式所需要的記憶體超出了系統所能配置設定的記憶體(包括動态擴充)的上限。

對象執行個體化分析

對記憶體配置設定情況分析最常見的示例便是對象執行個體化 : Object obj = new Object();

這段代碼的執行會涉及Java棧、Java堆、方法區三個最重要的記憶體區域。假設該語句出現在方法體中,即使對JVM虛拟機不了解的Java使用這,應該也知道obj會作為引用類型(reference)的資料儲存在Java棧的本地變量表中,而會在Java堆中儲存該引用的執行個體化對象,但可能并不知道,Java堆中還必須包含能查找到此對象類型資料的位址資訊(如對象類型、父類、實作的接口、方法等),這些類型資料則儲存在方法區中。

另外,由于reference類型在Java虛拟機規範裡面隻規定了一個指向對象的引用,并沒有定義這個引用應該通過哪種方式去定位,以及通路到Java堆中的對象的具體位置,是以不同虛拟機實作的對象通路方式會有所不同,主流的通路方式有兩種:使用句柄池和直接使用指針。

通過句柄池通路的方式如下:

【深入Java虛拟機】之一:Java記憶體區域與記憶體溢出

通過直接指針通路的方式如下:

【深入Java虛拟機】之一:Java記憶體區域與記憶體溢出