JVM内存结构图解
一 真實系統中的概念
| JVM(Java Virtual Machine),顧名思義是對真實計算機系統的模擬,正因如此才能屏蔽物理機器的變化,從而實現“一次編譯,到處運行”。
相信很多Java程序員經常聽到堆、棧等概念,也會進行設置調優以讓Java應用能夠更好地運行,但對于JVM與真實計算機系統之間的關系并沒有特別清晰的認識。因此,這里先簡單介紹下真實計算機系統中的一些概念。 現代計算機系統中,也有寄存器、棧、堆等概念,這些與JVM中的概念相似,但有本質的不同。 現代計算機系統中,內存是由操作系統配合CPU的段寄存器來管理的,主要分為內核空間(內核代碼段,內核數據區)、代碼段(.text)、數據段(.data 和 .bss)、棧、堆、共享內存區等。 JVM作為進程運行在操作系統之上,那么操作系統也需要為JVM分配棧空間。 所以,JVM中的堆并非操作系統管理的堆,JVM的棧也不是操作系統管理的棧。 聊了聊真實計算機系統,再接著談談JVM。 |
二 JVM運行時數據區
㈠ PC寄存器(Program counter register)
PC寄存器又稱作程序計數器,其作用類似于cpu中的代碼段寄存器:指針寄存器(匯編中CS:EIP總是指向下一條要運行的指令地址)。
線程中正在運行的方法被稱為當前方法(current method)。如果當前方法是非native的,PC寄存器保存的是當前方法的字節碼指令的地址;否則,值為undefined。
㈢ 堆(Heap)
⑴ 系統堆 與Java堆
這里的堆指的是Java堆,與操作系統管理的堆是兩個不同的概念,但作用類似。
C語言中,可以使用malloc()向操作系統申請堆內存,使用完畢后一般需要顯式調用free()來釋放內存,如果未釋放則可能導致內存耗盡。同時,這種內存申請、釋放的方式容易產生內存碎片(C/C++程序員有些會使用第三方庫來管理內存,有些則自己實現內存池來管理內存)。
但在Java中,這些由JVM來處理,因此避免了復雜繁瑣的內存管理。
JVM運行過程中,可以動態地向操作系統申請內存作為Java堆或歸還未使用的內存,堆內存可以是非連續的內存空間。當觸發預設條件時,JVM會調用垃圾收集器來回收未被使用的對象。
Java堆是垃圾收集器最重要的工作區域,另一個區域是非堆(永久代)。
以下內容中,除非特別說明,堆均指的就是JVM堆。
⑵ 內存分配與垃圾回收
堆保存類實例對象和數組對象,堆是共享數據區,各線程均可使用此區域。
堆內存空間分配和垃圾收集機制會因垃圾收集器不同而不同,這里以Parallel new + CMS垃圾收集器為例。
堆分為新生代(young generation)和老年代(old generation);新生代又可分為Eden, From Survivor, To Survivor。
當Eden空間足夠時,大部分新創建對象會被分配在Eden區(部分大對象會被直接分配到老年代)。
當Eden空間不足時,會發生一次Minor GC,未被引用的對象會被回收,Eden中仍然存活的對象會被移動到From Survivor。
Survivor中的對象每熬過1次MinorGC增加1歲,默認超過15歲依然存活的對象會被移入Tenured。
當發生Minor GC時:如果Survivor的空間不足以保存Eden區仍然存活的對象,那么該對象會被直接移入 Tenured;如果Survivor 中同年對象的占用空間的總和達到或超過其中一個Survivor的一半,那么所有同年對象都會被移入Tenured。
通常情況下,只有其中一個Survivor持有對象,另一個在下次GC之前總是為空。當再次發生GC時,Eden中的對象被復制到標記為To的空的Surivivor中,原來From中依然存活的未到達年齡的對象也會復制到To,此時To被標記為From,原來的From置空并被標記為To,輪換是為了避免Surivivor中因沒有連續空間而導致對象被直接移入老年代。
當Tenured空間使用達到一定比例時會觸發Full GC,并且可能伴隨著進行Minor GC。
除了CMS和新的G1垃圾收集器以外,其它的垃圾收集器都會觸發Stop The World,所有其它線程暫停。
⑶ 線程本地分配緩沖區(Thread-Local Allocation Buffer, TLAB)
為保證線程安全和避免內存爭用,JVM會為每一個線程在Eden中設置一小塊私有的緩沖區,稱為TLAB。每一個TLAB都只有一個線程可以分配對象,因此可以避免采用全局鎖來控制內存分配,而只需要在最后一個分配對象的末端順序寫入即可(指針碰撞),可以快速分配內存。
當一個線程的TLAB的空間不足需擴充內存時,那么就需要多線程方式來保證不會出現數據覆寫。
⑷ 注意事項
1.為了減少短期存活的大對象進入老年代,應盡可能縮短其生命周期,一種比較好的方式是在最后使用的地方手動置為null。
幸運的話它會在Eden區被回收,即使進入Survivor也很難熬過15次Minor GC。
2. 數據庫查詢只獲取必要數據,而不是全表查詢。
3. 嚴格限定對象作用域,避免作用域溢出,導致對象總是被引用而無法回收。
4. 多用單例,少用new。
㈣ 非堆(Non-Heap Memory)
非堆也稱作永久代(permanent generation),邏輯上屬于堆的一部分,但老年代的對象并不會移入永久代。
永久代只用于存儲元數據(Metadata),譬如類的數據結構、字符串常量池等數據。
運行時常量池與字符串常量池是完全不同的概念,運行時常量池歸屬于具體的類,是類數據結構的一部分,是私有的;而字符串常量池保存的是字符串對象的引用,字符串對象本身保存在堆中,是共享的。
永久代也會發生GC,但此區域通常回收效率不高。
㈤ Java虛擬機棧(Java Virtual Machine Stack)
Java虛擬機棧是每一個線程私有的,隨線程開始而創建,隨線程結束而銷毀。
⑴ 棧幀(Frams)
線程在執行每個方法時都會創建一個棧幀,棧幀隨方法調用而創建,隨方法結束而銷毀,無論方法是否正常結束。
棧幀中保存局部變量表、操作數棧和一個指向當前方法所屬類的運行時常量池的引用。棧幀同樣是線程私有的,一個線程不能訪問另一個線程的棧幀。
⑵ 局部變量表(Local Variables)
局部變量表保存的是方法運行期間所需要的數據。數據類型可以分為基本數據類型、對象引用類型和returnAddress類型。long和double會占用兩個局部變量空間(slot),其余的數據類型占用一個,局部變量表所需的內存空間在編譯期間確定,方法執行期間不會改變。
⑶ 操作數棧(Operand Stack)
操作數棧的長度由編譯期間確定,操作數棧初始時為空,每一個操作數棧的成員(Entry)可以保存JVM定義的任意數據類型的值。long和double占用2個棧深單位,其它數據類型占用一個棧深單位。
㈥ 本地方法棧(Native Method Stack)
本地方法棧保存的是native方法的信息,當一個JVM創建的線程調用native方法后,JVM不再為其在虛擬機棧中創建棧幀,JVM只是簡單地動態鏈接并直接調用native方法。
關于本地方法棧的信息內容非常少,HotSpot的說明書也沒有找到相關信息,為避免誤導,這里就先略過吧。
三 代碼說明
這些概念都比較抽象,舉個例子說明更直觀明了。
㈠ 示例代碼
public class HelloWorld {
public static final int a = 10; //聲明全局變量a并賦值
public static void main(String[] args){
HelloWorld hw = new HelloWorld(); //實例化對象hw
int b = 15; //聲明局部變量b并賦值
int c = hw.add(b); //調用add方法并賦值給c
}
public int add(int b){
b = change(b); //調用change方法并賦值給b
return a + b + 3; //返回計算結果
}
public int change(int b){
return b + 5; //返回計算結果
}
}
㈡ 字節碼
/**
* JVM啟動時會將類信息保存到永久代(方法區)
*/
public class HelloWorld
minor version: 0 //編譯副版本號
major version: 52 //編譯主版本號,JVM校驗class文件時使用,低版本JVM不能運行高版本編譯器編譯的class文件
flags: ACC_PUBLIC, ACC_SUPER //ACC_PUBLIC表示可以被包外class訪問,ACC_SUPER表示需特殊處理的父類方法
/**
* 常量池:類似于《編譯原理》中介紹的符號表;如希望進一步了解,請閱讀附錄書單的⑴⑵⑶
*/
Constant pool:
#1 = Methodref #6.#22 // java/lang/Object."<init>":()V
#2 = Class #23 // HelloWorld
#3 = Methodref #2.#22 // HelloWorld."<init>":()V
#4 = Methodref #2.#24 // HelloWorld.add:(I)I
#5 = Methodref #2.#25 // HelloWorld.change:(I)I
#6 = Class #26 // java/lang/Object
#7 = Utf8 a
#8 = Utf8 I
#9 = Utf8 ConstantValue
#10 = Integer 10
#11 = Utf8 <init>
#12 = Utf8 ()V
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 add
#18 = Utf8 (I)I
#19 = Utf8 change
#20 = Utf8 SourceFile
#21 = Utf8 HelloWorld.java
#22 = NameAndType #11:#12 // "<init>":()V
#23 = Utf8 HelloWorld
#24 = NameAndType #17:#18 // add:(I)I
#25 = NameAndType #19:#18 // change:(I)I
#26 = Utf8 java/lang/Object
{
/** 靜態final變量 a */
public static final int a;
descriptor: I //字段類型描述符,表明是一個int整形數
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL //字段屬性描述符
ConstantValue: int 10 //常量值
/** 構造方法 */
public HelloWorld();
descriptor: ()V //方法描述符,V表明返回值為空
flags: ACC_PUBLIC //方法屬性標簽
Code:
stack=1, locals=1, args_size=1 // 操作數棧深=1,本地變量數量=1,參數數量=1,0索引總是保存當前方法所屬的對象引用(ObjectReference),所以無參構造方法卻顯示有1個參數
0: aload_0 // 從局部變量表索引為0的地方獲取對象引用類型,并壓入到操作數棧,新建但未初始化
1: invokespecial #1 // Method java/lang/Object."<init>":()V 調用父類的初始化方法
4: return // 方法返回,返回值為空,棧幀銷毀
LineNumberTable: // LineNumberTable是一個數組,記錄源代碼所在的行。
line 1: 0 // line_number(源文件行號) : start_pc(code[]數組索引)
/** main方法 */
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1 // 操作數棧深=2,本地變量數量=4,參數數量=1
0: new #2 // class HelloWorld 創建HelloWorld對象,堆中分配內存,引用值壓入棧頂
3: dup // 復制棧頂保存的對象引用,并將HelloWorld引用值再次壓入棧頂
4: invokespecial #3 // Method "<init>":()V 彈出棧頂的一個元素HelloWorld引用,調用HelloWorld對象的初始化方法
7: astore_1 // 彈出棧頂的一個元素HelloWorld引用,將棧頂的HelloWorld引用存入局部變量表的索引1位置
8: bipush 15 // 將byte類型常數15壓入棧頂
10: istore_2 // 彈出棧頂的一個元素15,并將其存入局部變量表索引2位置
11: aload_1 // 將局部變量表的索引1位置的HelloWorld引用壓入棧頂
12: iload_2 // 將局部變量表的索引2位置的int類型的15壓入棧頂
13: invokevirtual #4 // Method add:(I)I 彈出棧頂的兩個元素并調用add方法,返回值33壓入棧頂
16: istore_3 // 彈出棧頂的一個元素33,并將其存入局部變量表索引3位置
17: return // 方法返回,返回值為空,棧幀銷毀,線程結束
LineNumberTable:
line 5: 0
line 6: 8
line 7: 11
line 8: 17
/** add方法 */
public int add(int);
descriptor: (I)I
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2 // 操作數棧深=2,本地變量數量=2,參數數量=2(非靜態方法的參數0位置總是為當前方法所屬對象的引用,所以只傳入了參數b,卻顯示有2個參數)
0: aload_0 // 將局部變量表的索引0位置的HelloWorld引用壓入棧頂
1: iload_1 // 將局部變量表的索引1位置(參數b)的15壓入棧頂
2: invokevirtual #5 // Method change:(I)I 彈出棧頂的兩個元素并調用change方法,返回值20壓入棧頂
5: istore_1 // 彈出棧頂元素20,并將其存入局部變量表的索引1位置
6: bipush 10 // 將byte類型常數10壓入棧頂
8: iload_1 // 將局部變量表的索引1位置的20壓入棧頂
9: iadd // 彈出棧頂的兩個元素并相加:20 + 10,將結果30存入棧頂
10: iconst_3 // 將int類型常數3壓入棧頂
11: iadd // 彈出棧頂的兩個元素并相加:30 + 3,將結果33存入棧頂
12: ireturn // 返回int類型的數值 33,棧幀銷毀
LineNumberTable:
line 11: 0
line 12: 6
/** change方法 */
public int change(int);
descriptor: (I)I
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: iload_1 // 將局部變量表的索引1位置的15壓入棧頂
1: iconst_5 // 將int類型常數5壓入棧頂
2: iadd // 將棧頂的兩個元素彈出并相加:5 + 15,并將結果存入棧頂
3: ireturn // 返回int類型的數值 20,棧幀銷毀
LineNumberTable:
line 16: 0
}
㈢ 構造方法圖解
1.操作數棧初始為空,執行0:aload_0指令,局部變量表的當前方法所屬對象的引用(this) 復制到操作數棧的棧頂。實例對象保存在java堆,方法引用指向非堆方法區。
2.執行1:invokespecial #1指令,調用父類的初始化方法。父類初始化方法會在當前棧幀上添加一層新的棧幀。父類初始化方法執行完畢后,其對應棧幀銷毀。
3.執行4:return指令返回,當前對象實例化完成,當前棧幀銷毀。
㈣ main方法圖解
為更清楚地看到操作數棧、局部變量表及棧幀的變化,以main方法為例進行描述。
| 棧幀內容變化 |
棧幀創建銷毀變化 |
描述 |
| 創建main方法棧幀
指令 0:new #2 |
||
| 指令 3:dup
復制棧頂的當前對象引用(this),并將其再次壓入棧頂 |
||
| 指令 4:invokespecial #3
彈出棧頂的一個元素this作為參數并調用HelloWorld.init方法,創建一層HelloWorld.init方法的棧幀 |
||
| HelloWorld.init方法執行期間: HelloWorld.init方法中再調用Object.init方法,創建一層Object.init方法的棧幀 (見上一小節的構造方法圖解) |
||
| HelloWorld.init方法執行期間: Object.init方法執行完畢,其對應的棧幀銷毀。 |
||
| HelloWorld.init方法執行完畢,其對應棧幀銷毀。
指令 7:astore_1 |
||
| 指令 8:bipush 15 將byte類型常數15壓入棧頂 |
||
| 指令 10:istore_2 將棧頂的int類型常數 15保存到局部變量表索引2位置 |
||
| 指令 11:aload_1 將局部變量表的索引1位置的HelloWorld引用壓入棧頂 |
||
| 指令12: iload_2 將局部變量表的索引2位置的int類型的15壓入棧頂 |
||
| 指令 13:invokevirtual #4 彈出棧頂的兩個元素作為參數并調用add方法,,創建一層Object.init方法的棧幀 add方法執行完畢后返回值33壓入棧頂 |
||
| add方法執行期間: add方法調用change方法,創建一層change方法棧幀 |
||
| add方法執行期間: change方法執行完畢,其對應的棧幀銷毀 |
||
| add方法執行完畢,其對應的棧幀銷毀
指令 16:istore_3 |
||
| 指令 17:return main方法執行完畢,棧幀銷毀,線程結束 |
四 數據類型占用空間分析
操作數棧:long和double需要占用2個棧深單位(unit of depth),其它類型占用1個棧深單位。
局部變量表:long和double需要占用2個局部變量空間(slot),其它類型占用1個局部變量空間。
運行時常量池:byte、short和int被存儲為CONSTANT_Integer_info 結構;float被存儲為CONSTANT_Float_info 結構;long被存儲為CONSTANT_Long_info 結構;double被存儲為 CONSTANT_Double_info 結構。其中,long 和 double占用8個字節,byte、short、int和float占用4個字節。
雖然運行時常量池中占用空間并沒有進一步細分,但保存的數據結構中會標記數據類型,byte被標記為B,int 被標記為I……
Java堆:雖然《Java虛擬機規范》中并沒有明確說明基本數據類型的空間占用,但根據我對JIT編譯生成的匯編代碼分析,byte占用一個字節,short占用2個字節,float和int占用4個字節,long 和 double占用8個字節。
測試方法:聲明byte[],順序寫入索引0、索引1、索引2、索引3的元素。運行時開啟JIT編譯,查看得到的匯編代碼中你會發現內存地址變化正如上面所說。
示例Java代碼:
byte[] array = new byte[4];
array[0] = 0;
array[1] = 1;
array[2] = 2;
array[3] = 3;</span>
關鍵匯編代碼:
0xa726a086: jne 0xa726a07d ;*newarray檢測zf標志位:1順序執行下一條指令;0跳轉到0xa726a07d處指令
;eax寄存器中保存的是數組的起始內存地址。0xc(%eax):基址eax + 偏移12。
;32位JVM中,數組對象使用12個字節記錄兩項信息:數組長度4字節 + 數組對象頭8字節 = 12字節(0x0 至 0xb),所以保存數據的起始地址是0xc。
0xa726a088: movb $0x0,0xc(%eax) ;*bastore將0寫入0xc偏移位置
0xa726a08c: movb $0x1,0xd(%eax) ;*bastore將1寫入0xd偏移位置
0xa726a090: movb $0x2,0xe(%eax) ;*bastore將2寫入0xd偏移位置
0xa726a094: movb $0x3,0xf(%eax) ;*bastore將3寫入0xd偏移位置
五 遞歸優化
㈠ 棧溢出
根據第三節圖例,JVM每執行每一個方法都會創建一層新的棧幀,當方法結束,那么棧幀就會銷毀。
方法1調用方法2,方法2調用方法3……方法i-1調用方法i,因為每一個方法都沒結束,那么最后會創建i層棧幀。
JVM中的虛擬機棧的空間大小可以通過參數配置,但如果方法嵌套調用鏈過長導致棧空間耗盡,那么就會發生棧溢出(StackOverflowError)。
㈡ 遞歸注意事項
正常程序一般不會導致棧溢出,但遞歸方法需要特別注意。
因為遞歸方法本身既是調用者又是被調用者,每一次方法執行時被調用者又會成為調用者而沒有結束,所以棧幀不會被銷毀,而是會一層一層累加。
雖然如此,很多時候依然會傾向于使用遞歸,但使用遞歸方法應注意以下幾點:
1、一定要設定退出條件(無需遞歸即可直接求解的基準情況)。
2、避免在遞歸中反復求解。
3、避免在遞歸方法中嵌套遞歸方法。
4、避免在遞歸中創建大對象。
㈢ 錯誤示例及優化
錯誤示例1(無退出條件):
public static void getAndSet(){
Object obj = get();
set(obj);
getAndSet();
}
正確方式:
public static void getAndSet(){
Object obj = get();
if(null != obj){
set(obj);
getAndSet();
}
}
如果實在沒辦法判斷退出條件,可以這樣:
public static void getAndSet(){
for( ; ; ){
Object obj = get();
set(obj);
}
}
錯誤示例2(反復求解):
/**計算斐波那契數列
* 0,1,1,2,3,5,8,13
* 為了計算第7個數,必須先計算第6個;為了計算第6個,先得計算第5個……因為每一步計算的結果都沒有存儲,所以相同的計算結果反復計算。
* 每一次方法調用都是兩個f(n)的計算,所以第3個數開始,每次的計算都是前面兩個數的計算次數之和。這是一個非常非常非常緩慢的算法!!!
* 相當于每增加1,計算次數就要乘以1.618。
* 當計算第30個數字的值時,方法調用達到1664079次,棧幀數量等同。
*/
public static int f(int n){
if(n == 0){
return 0;
}
if(n <= 2){
return 1;
}
return f(n-1) + f(n-2);
}
正確方式:
public static int f(int n){
int lastlast = 0;
int last = 1;
int sum = 1;
for(int i=2; i<=n; i++){
sum = last + lastlast;
lastlast = last;
last = sum;
}
return sum;
}
錯誤示例3(遞歸中嵌套遞歸):
public static void getAndSet(){
Object obj = get();
if(null != obj){
set(obj);
getAndSet();
}
}
public static void set(Object obj){
obj.value = 10;
obj = obj.next;
if(null != obj){
set(obj);
}
}
正確方式:
public static void getAndSet(){
Object obj = get();
while(null != obj){
set(obj);
obj = get();
}
}
public static void set(Object obj){
while(null != obj){
obj.value = 10;
obj = obj.next;
}
}
錯誤方式4(遞歸方法中創建大數據對象):
public static void build(){
int[] array = new int[1024 * 1024 * 1024];
build();
}
㈣ 總結
從以上示例可知,簡單的尾遞歸都可以轉化成循環。
從匯編語言的角度來看,比較、賦值和跳轉構成了所有的語法結構,并沒有遞歸,也沒有循環。因此其實所有的遞歸,無論多復雜都可以轉化成循環語句。
大部分情況下,遞歸并不需要轉化成循環。譬如樹搜索等使用遞歸會使得程序結構簡單明了,且因其特殊的數據結構也使得遞歸層次并不會太深。
現代JVM會對大部分的尾遞歸方法進行優化,也就是轉化成循環結構。但JVM并不保證對所有的尾遞歸都會進行轉換。因此當存在遞歸深度過深的風險、遞歸方法中包含大對象等可能導致棧溢出的情況,手動轉化成循環結構應該是更好的選擇。
六 后記
JVM的知識結構體系龐大而復雜,牽涉到很多其它學科的知識,譬如計算機體系結構、操作系統、編譯原理、離散數學、匯編語言、C、C++……
而且JVM中的每一個知識點幾乎都可以寫幾本厚厚的書,譬如垃圾回收算法、性能調優……
本文目的只是讓java coder對JVM有一個直觀的認識,因此盡量用簡單明了的語言和圖例來描述比較抽象的概念,如果能幫助大伙在進一步學習時建立一點基本常識則非常歡喜了。
另,如有錯誤之處歡迎指正。謝謝!
七 參考資料
這也是我的推薦書單。
⑴是我買的關于JVM的第一本書,也是我后來最常翻閱的一本書,強烈推薦。周志明大大既是⑴的作者,也是⑵的譯者之一。⑵的翻譯非常流暢準確,是我閱讀過的翻譯得最好的資料之一。
⑷是計算機體系結構、操作系統和編譯原理的綜合書籍,對于希望進一步理解計算機科學底層原理的讀者來說是一本非常好的教材。
⑸是操作系統方面的書,對進程、線程、cpu、內存、文件系統……等等都有很好的介紹。
遞歸優化主要參考⑻,這也是學習數據結構和算法的很好的書籍,某些部分比《算法導論》講得更深入,學完這個再看《算法導論》幾乎無壓力。
⑺介紹了JVM性能調優的大量方法、系統監控和JVM監控的大量工具,并且有很多測試和優化場景案例,推薦閱讀。
⑴ 《深入理解Java虛擬機:JVM高級特性與最佳實踐 第2版》 作者:周志明
⑵ 《Java虛擬機規范:Java SE 7 Edition》 作者:Tim Lindholm、Frank Yellin、Gilad Bracha、Alex Buckley 譯者:周志明、吳璞淵、冶秀剛
⑶ 《The Java? Virtual Machine Specification :Java SE 8 Edition》 作者:Tim Lindholm、Frank Yellin、Gilad Bracha、Alex Buckley
⑷ 《深入理解計算機系統 第2版》 作者:Randal E.Bryant、David R.O’Hallaron
⑸ 《操作系統概念 第7版》 作者:Abraham Silberschatz、Peter Baer Galvin、Greg Gagne
⑹ 《Garbage Collection in the Java HotSpot Virtual Machine》 作者:Tony Printezis
⑺ 《Java 性能優化權威指南》 作者:charlie Hunt、Binu John
⑻ 《數據結構與算法分析.Java語言描述》 作者:Mark Allen Weiss
總結
- 上一篇: webService学习记录
- 下一篇: Can't create handler