小白都能看得懂的java虛擬機器記憶體模型
目錄
一、虛擬機器
同樣的java程式碼在不同平臺生成的機器碼肯定是不一樣的,因為不同的作業系統底層的硬體指令集是不同的。
同一個java程式碼在windows上生成的機器碼可能是0101.......,在linux上生成的可能是1100......,那麼這是怎麼實現的呢?
不知道同學們還記不記得,在下載jdk的時候,我們在oracle官網,基於不同的作業系統或者位數版本要下載不同的jdk版本,也就是說針對不同的作業系統,jdk虛擬機器有不同的實現。
那麼虛擬機器又是什麼東西呢,如圖是從軟體層面遮蔽不同作業系統在底層硬體與指令上的區別,也就是跨平臺的由來。
說到這裡同學們可能還是有點不太明白,說的還是太巨集觀了,那我們來了解下java虛擬機器的組成。
二、虛擬機器組成
1.棧
我們先講一下其中的一塊記憶體區域棧,大家都知道棧是儲存區域性變數的,也是執行緒獨有的區域,也就是每一個執行緒都會有自己獨立的棧區域。
public class Math {
public static int initData = 666;
public static User user = new User();
public int compute() {
int a = 1;
int b = 2;
int c = (a+b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
System.out.println("test");
}
}
說起棧大家都不會陌生,資料結構中就有學,這裡執行緒棧中儲存資料的部分使用的就是棧,先進後出。
大家都知道每個方法都有自己的區域性變數,比如上圖中main方法中的math,compute方法中的a b c,那麼java虛擬機器為了區分不同方法中區域性變數作用域範圍的記憶體區域,每個方法在執行的時候都會分配一塊獨立的棧幀記憶體區域,我們試著按上圖中的程式來簡單畫一下程式碼執行的記憶體活動。
執行main方法中的第一行程式碼是,棧中會分配main()方法的棧幀,並儲存math區域性變數,,接著執行compute()方法,那麼棧又會分配compute()的棧幀區域。
這裡的棧儲存資料的方式和資料結構中學習的棧是一樣的,先進後出。當compute()方法執行完之後,就會出棧被釋放,也就符合先進後出的特點,後呼叫的方法先出棧。
棧幀
那麼棧幀內部其實不只是存放區域性變數的,它還有一些別的東西,主要由四個部分組成。
那麼要講這個就會涉及到更底層的原理--位元組碼。我們先看下我們上面程式碼的位元組碼檔案。
看著就是一個16位元組的檔案,看著像亂碼,其實每個都是有對應的含義的,oracle官方是有專門的jvm位元組碼指令手冊來查詢每組指令對應的含義的。那我們研究的,當然不是這個。
jdk有自帶一個javap的命令,可以將上述class檔案生成一種更可讀的位元組碼檔案。
我們使用javap -c命令將class檔案反編譯並輸出到TXT檔案中。
Compiled from "Math.java"
public class com.example.demo.test1.Math {
public static int initData;
public static com.example.demo.bean.User user;
public com.example.demo.test1.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/example/demo/test1/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: pop
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: ldc #6 // String test
18: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
21: return
static {};
Code:
0: sipush 666
3: putstatic #8 // Field initData:I
6: new #9 // class com/example/demo/bean/User
9: dup
10: invokespecial #10 // Method com/example/demo/bean/User."<init>":()V
13: putstatic #11 // Field user:Lcom/example/demo/bean/User;
16: return
}
此時的jvm指令碼就清晰很多了,大體結構是可以看懂的,類、靜態變數、構造方法、compute()方法、main()方法。
其中方法中的指令還是有點懵,我們舉compute()方法來看一下:
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
這幾行程式碼就是對應的我們程式碼中compute()方法中的四行程式碼。大家都知道越底層的程式碼,程式碼實現的行數越多,因為他會包含一些java程式碼在執行時底層隱藏的一些細節原理。
那麼一樣的,這個jvm指令官方也是有手冊可以查閱的,網上也有很多翻譯版本,大家如果想了解可自行百度。
這裡我只講解本博文設計程式碼中的部分指令含義:
0. 將int型別常量1壓入運算元棧
0: iconst_1
這一步很簡單,就是將1壓入運算元棧
1. 將int型別值存入區域性變數1
1: istore_1
區域性變數1,在我們程式碼中也就是第一個區域性變數a,先給a在區域性變數表中分配記憶體,然後將int型別的值,也就是目前唯一的一個1存入區域性變數a
2. 將int型別常量2壓入運算元棧
2: iconst_2
3. 將int型別值存入區域性變數2
3: istore_2
這兩行程式碼就和前兩行類似了。
4. 從區域性變數1中裝載int型別值
4: iload_1
5. 從區域性變數2中裝載int型別值
5: iload_2
這兩個程式碼是將區域性變數1和2,也就是a和b的值裝載到運算元棧中
6. 執行int型別的加法
6: iadd
iadd指令一執行,會將運算元棧中的1和2依次從棧底彈出並相加,然後把運算結果3在壓入運算元棧底。
7. 將一個8位帶符號整數壓入棧
7: bipush 10
這個指令就是將10壓入棧
8. 執行int型別的乘法
9: imul
這裡就類似上面的加法了,將3和10彈出棧,把結果30壓入棧
9. 將將int型別值存入區域性變數3
10: istore_3
這裡大家就不陌生了吧,和第二步第三步是一樣的,將30存入區域性變數3,也就是c
10. 從區域性變數3中裝載int型別值
11: iload_3
這個前面也說了
11. 返回int型別值
12: ireturn
這個就不用多說了,就是將運算元棧中的30返回
到這裡就把我們compute()方法講解完了,講完有沒有對區域性變數表和運算元棧的理解有所加深呢?說白了賦值號=後面的就是運算元,在這些運算元進行賦值,運算的時候需要記憶體存放,那就是存放在運算元棧中,作為臨時存放運算元的一小塊記憶體區域。
接下來我們再說說方法出口。
方法出口說白了不就是方法執行完了之後要出到哪裡,那麼我們知道上面compute()方法執行完之後應該回到main()方法第三行那麼當main()方法呼叫compute()的時候,compute()棧幀中的方法出口就儲存了當前要回到的位置,那麼當compute()方法執行完之後,會根據方法出口中儲存的相關資訊回到main()方法的相應位置。
那麼main()方同樣有自己的棧幀,在這裡有些不同的地方我們講一下。
我們上面已經知道區域性變數會存放在棧幀中的區域性變數表中,那麼main()方法中的math會存入其中,但是這裡的math是一個物件,我們知道new出來的物件是存放在堆中的
那麼這個math變數和堆中的物件有什麼聯絡呢?是同一個概念麼?
當然不是的,區域性變數表中的math儲存的是堆中那個math物件在堆中的記憶體地址
2.程式計數器
程式計數器也是執行緒私有的區域,每個執行緒都會分配程式計數器的記憶體,是用來存放當前執行緒正在執行或者即將要執行的jvm指令碼對應的地址,或者說行號位置。
上述程式碼中每個指令碼前面都有一個行號,你就可以把它看作當前執行緒執行到某一行程式碼位置的一個標識,這個值就是程式計數器的值。
那麼jvm虛擬機器為什麼要設定程式計數器這個結構呢?就是為了多執行緒的出現,多執行緒之間的切換,當一個程式被掛起的時候,總是要恢復的,那麼恢復到哪個位置呢,總不能又重新開始執行吧,那麼程式計數器就解決了這個問題。
3.方法區
public static int initData = 666;
這個initData就是靜態變數,毋庸置疑是存放在方法區的
public static User user = new User();
那麼這個user就有點不一樣了,user變數放在方法區,new的User是存放在堆中的
到這裡我們就能意識到棧,堆,方法區之間都是有聯絡的。
棧中的區域性變數,方法區中的靜態變數,如果是物件型別的話都會指向堆中new出來中的物件,那麼紅色的聯絡代表什麼呢?我們先來了解一下物件。
物件組成
你對物件的瞭解有多少呢,天天用物件,你是否知道物件在虛擬機器中的儲存結構呢?
物件在記憶體中儲存的佈局可以分為3塊區域:物件頭(Header)、例項資料(Instance Data)和對齊填充(Padding)。下圖是普通物件例項與陣列物件例項的資料結構:
物件頭
HotSpot虛擬機器的物件頭包括兩部分資訊:
Mark Word
第一部分markword,用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,這部分資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為32bit和64bit,官方稱它為“MarkWord”。
Klass Pointer
物件頭的另外一部分是klass型別指標,即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項.
陣列長度(只有陣列物件有)
如果物件是一個陣列, 那在物件頭中還必須有一塊資料用於記錄陣列長度.
例項資料
例項資料部分是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。
對齊填充
第三部分對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說,就是物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的倍數(1倍或者2倍),因此,當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。
其中的klass型別指標就是那條紅色的聯絡,那是怎麼聯絡的呢?
new Thread().start();
類載入其實最終是以類元資訊的形式儲存在方法區中的,math和math2都是由同一個類new出來的,當物件被new時,都會在物件頭中儲存一個指向類元資訊的指標,這就是Klass Pointer.
到這裡我們就講解了棧,程式計數器和方法區,下面我們簡單介紹一下本地方法區,最後再終點講解堆。
4.本地方法棧
實際上現在本地方法棧已經用的比較少了,大家應該都有聽過本地方法吧
如何經常用的執行緒類
new Thread().start();
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
}
}
}
其中底層呼叫了一個start0()的方法
private native void start0();
這個方法沒有實現,但又不是介面,是使用native修飾的,是屬於本地方法,底層通過C語言實現的,那java程式碼裡為什麼會有C語言實現的本地方法呢?
大家都知道JAVA是問世的,在那之前一個公司的系統百分之九十九都是使用C語言實現的,但是java出現後,很多專案都要轉為java開發,那麼新系統和舊系統就免不了要有互動,那麼就需要本地方法來實現了,底層是呼叫C語言中的dll庫檔案,就類似於java中的jar包,當然,如今跨語言的互動方式就很多了,比如thrift,http介面方式,webservice等,當時並沒有這些方式,就只能通過本地方法來實現了。
那麼本地方法始終也是方法,每個執行緒在執行的時候,如果有執行到本地方法,那麼必然也要產生區域性變數等,那麼就需要儲存在本地方法棧了。如果沒有本地方法,也就沒有本地方法棧了。
5.堆
最後我們講堆,堆是最重要的一塊記憶體區域,我相信大部分人對堆都不陌生。但是對於它的內部結構,運作細節想要搞清楚也沒那麼簡單。
對於這個基本組成大家應該都有所瞭解,對就是由年輕代和老年代組成,年輕代又分為伊甸園區和survivor區,survivor區中又有from區和to區.
我們new出來的物件大家都知道是放在堆中,那具體放在堆中的哪個位置呢?
其實new出來的物件一般都放在Eden區,那麼為什麼叫伊甸園區呢,伊甸園就是亞當夏娃住的地方,不就是造人的地方麼?所以我們new出來的物件就是放在這裡的,那當Eden區滿了之後呢?
假設我們給對分配600M記憶體,這個是可以通過引數調節的,我們後文再講。那麼老年代預設是佔2/3的,也就是差不多400M,那年輕代就是200M,Eden區160M,Survivor區40M。
GC
一個程式只要在執行,那麼就不會不停的new物件,那麼總有一刻Eden區會放滿,那麼一旦Eden區被放滿之後,虛擬機器會幹什麼呢?沒錯,就是gc,不過這裡的gc屬於minor gc,就是垃圾收集,來收集垃圾物件並清理的,那麼什麼是垃圾物件呢?
好比我們上面說的math物件,我們假設我們是一個web應用程式,main執行緒執行完之後程式不會結束,但是main方法結束了,那麼main()方法棧幀會被釋放,區域性變數會被釋放,但是區域性變數對應的堆中的物件還是依然存在的,但是又沒有指標指向它,那麼它就是一個垃圾物件,那就應該被回收掉了,之後如果還會new Math物件,也不會用這個之前的了,因為已經無法找到它了,如果留著這個物件只會佔用記憶體,顯然是不合適的。
這裡就涉及到了一個GC Root根以及可達性分析演算法的概念,也是面試偶爾會被問到的。
可達性分析演算法是將GC Roots物件作為起點,從這些起點開始向下搜尋引用的物件,找到的物件都標記為非垃圾物件,其餘未標記的都是垃圾物件。
那麼GC Roots根物件又是什麼呢,GC Roots根就是判斷一個物件是否可以回收的依據,只要能通過GC Roots根向下一直搜尋能搜尋到的物件,那麼這個物件就不算垃圾物件,而可以作為GC Roots根的有執行緒棧的本地變數,靜態變數,本地方法棧的變數等等,說白了就是找到和根節點有聯絡的物件就是有用的物件,其餘都認為是垃圾物件來回收。
經歷了第一次minor gc後,沒有被清理的物件就會被移到From區,如上圖。
上面在說物件組成的時候有寫到,在物件頭的Mark Word中有儲存GC分代年齡,一個物件每經歷一次gc,那麼它的gc分代年齡就會+1,如上圖。
那麼如果第二次新的物件又把Eden區放滿了,那麼又會執行minor gc,但是這次會連著From區一起gc,然後將Eden區和From區存活的物件都移到To區域,物件頭中分代年齡都+1,如上圖。
那麼當第三次Eden區又滿的時候,minor gc就是回收Eden區和To區域了,TEden區和To區域還活著的物件就會都移到From區,如上圖。說白了就是Survivor區中總有一塊區域是空著的,存活的物件存放是在From區和To區輪流存放,也就是互相複製拷貝,這也就是垃圾回收演算法中的複製-回收演算法。
如果一個物件經歷了一個限值15次gc的時候,就會移至老年代。那如果還沒有到限值,From區或者To區域也放不下了,就會直接挪到老年代,這只是舉例了兩種常規規則,還有其他規則也是會把物件存放至老年代的。
那麼隨著應用程式的不斷執行,老年代最終也是會滿的,那麼此時也會gc,此時的gc就是Full gc了。
GC案例
下面我們通過一個簡單的演示案例來更加清楚的瞭解GC。
public class HeapTest {
byte[] a = new byte[1024*100];
public static void main(String[] args) throws InterruptedException {
ArrayList<HeapTest> heapTest = new ArrayList<>();
while(true) {
heapTest.add(new HeapTest());
Thread.sleep(10);
}
}
}
這塊程式碼很明顯,就是一個死迴圈,不斷的往list中新增new出來的物件。
我們這裡使用jdk自帶的一個jvm調優工具jvisualvm來觀察一下這個程式碼執行的的記憶體結構。
執行程式碼開啟之後我們可以看到這樣的介面:
我們在左邊的應用程式中可以看到我們執行的這個程式碼,右邊是它的一些jvm,記憶體資訊,我們這裡不關注,我們需要用到的是最後一個Visual GC皮膚,這是一個外掛,如果你的開啟沒有這一欄的話,可以再工具欄的外掛中進行下載安裝。
開啟visual GC,我們先看一下介面大概的佈局,
其中老年代(Olc),伊甸園區(Eden),S0(From),S1(To)幾個區域的記憶體和動態分配圖都是清晰可見,以一對應的。
我們選擇中間一張圖給大家對應一下上面所講的內容:
1:物件放入Eden區
2:Eden區滿發生minor gc
3:第二步的存活物件移至From(Survivor 0)區
4:Eden區再滿發生minor gc
5:第四步存活的物件移至To(Survivor 1)區
這裡可以注意到From和To區域和我們上面所說移至,總有一個是空的。
大家還可以注意到老年代這裡,都是一段一段的直線,中間是突然的增加,這就是在minor gc中一批一批符合規則的物件被批量移入老年代。
那當我們老年代滿了會發生什麼呢?當然是我們上面說過的Full GC,但是你仔細看我們寫的這個程式,我們所有new出來的HeapTest物件都是存放在heapLists中的,那就會被這個區域性變數所引用,那麼Full GC就不會有什麼垃圾物件可以回收,可是記憶體又滿了,那怎麼辦?
沒錯,就是我們就算沒見過也總聽過的OOM。
到這裡jvm記憶體模型簡單介紹就結束了,看到這裡還不點個贊嘛!
歡迎訪問我的個人小站哦:我愛吃土豆
相關文章
- Java虛擬機器12:Java記憶體模型Java虛擬機記憶體模型
- Java虛擬機器記憶體模型學習筆記Java虛擬機記憶體模型筆記
- [Java虛擬機器]Java記憶體模型與執行緒Java虛擬機記憶體模型執行緒
- JAVA 虛擬機器可用記憶體Java虛擬機記憶體
- Java 虛擬機器之三:Java虛擬機器的記憶體結構Java虛擬機記憶體
- Java虛擬機器08——Java記憶體模型與執行緒Java虛擬機記憶體模型執行緒
- 淺析虛擬機器記憶體管理模型虛擬機記憶體模型
- jdk8:jvm虛擬機器記憶體模型JDKJVM虛擬機記憶體模型
- Java虛擬機器之記憶體區域Java虛擬機記憶體
- Java虛擬機器4:記憶體溢位Java虛擬機記憶體溢位
- [深入理解Java虛擬機器]第十二章 Java記憶體模型與執行緒-Java記憶體模型Java虛擬機記憶體模型執行緒
- 小試Java虛擬機器記憶體管理(Java 8)Java虛擬機記憶體
- JDK1.8-Java虛擬機器執行時資料區域和HotSpot虛擬機器的記憶體模型JDKJava虛擬機HotSpot記憶體模型
- java虛擬機器記憶體的各個區域Java虛擬機記憶體
- 《深入理解Java虛擬機器》個人讀書總結——JAVA虛擬機器記憶體Java虛擬機記憶體
- Java虛擬機器記憶體分配與回收策略Java虛擬機記憶體
- Java虛擬機器記憶體區域詳解Java虛擬機記憶體
- Java虛擬機器記憶體區域劃分Java虛擬機記憶體
- eclipse 設定java虛擬機器記憶體EclipseJava虛擬機記憶體
- Java虛擬機器:JVM記憶體分代策略Java虛擬機JVM記憶體
- Java虛擬機器7:記憶體分配原則Java虛擬機記憶體
- Java虛擬機器記憶體優化實踐Java虛擬機記憶體優化
- Java虛擬機器的記憶體是如何分佈的Java虛擬機記憶體
- Java虛擬機器2:Java記憶體區域及物件Java虛擬機記憶體物件
- Java虛擬機器的記憶體空間有幾種Java虛擬機記憶體
- Java虛擬機器的記憶體空間有幾種!Java虛擬機記憶體
- Java虛擬機器:記憶體管理與執行引擎Java虛擬機記憶體
- java虛擬機器執行時記憶體分割槽Java虛擬機記憶體
- 【深入Java虛擬機器】之一:Java記憶體區域與記憶體溢位Java虛擬機記憶體溢位
- Java虛擬機器系列之Java記憶體結構簡介Java虛擬機記憶體
- 【Java 虛擬機器筆記】記憶體分配策略相關整理Java虛擬機筆記記憶體
- Java虛擬機器(JVM)中的記憶體設定詳解Java虛擬機JVM記憶體
- 帶你清晰認識,Java虛擬機器記憶體管理!Java虛擬機記憶體
- 面試準備之java虛擬機器記憶體結構面試Java虛擬機記憶體
- 深入理解虛擬機器之Java記憶體區域虛擬機Java記憶體
- 《深入java虛擬機器》讀書筆記之Java記憶體區域Java虛擬機筆記記憶體
- Java虛擬機器01——Java記憶體資料區域和記憶體溢位異常Java虛擬機記憶體溢位
- 深入理解Java虛擬機器-Java記憶體區域與記憶體溢位異常Java虛擬機記憶體溢位