阿里雲:JDK1.8 JVM內存模型
最近在看《深入理解Java虛擬機》,書中給了幾個例子,比較好的說明了幾種OOM(OutOfMemory)產生的過程,大部分的程序員在寫程序時不會太關注Java運行時數據區域的結構:
感覺有必要通過幾個實在的例子來加深對這幾個區域的了解。
Java程序運行時,數據會分區存放,JavaStack(Java棧)、 heap(堆)、method(方法區)。
1、Java棧
Java棧的區域很小,只有1M,特點是存取速度很快,所以在stack中存放的都是快速執行的任務,基本數據類型的數據,和對象的引用(reference)。
駐留於常規RAM(隨機訪問存儲器)區域。但可通過它的“棧指針”獲取處理的直接支持。棧指針若向下移,會創建新的內存;若向上移,則會釋放那些內存。這是一種特別快、特別有效的數據保存方式,僅次於寄存器。創建程序時,Java編譯器必須准確地知道堆棧內保存的所有數據的“長度”以及“存在時間”。這是由於它必須生成相應的代碼,以便向上和向下移動指針。這一限制無疑影響了程序的靈活性,所以盡管有些Java數據要保存在棧里——特別是對象句柄,但Java對象並不放到其中。
JVM只會直接對JavaStack(Java棧)執行兩種操作:①以幀為單位的壓棧或出棧;②通過-Xss來設置, 若不夠會拋出StackOverflowError異常。
1.每個線程包含一個棧區,棧中只保存基本數據類型的數據和自定義對象的引用(不是對象),對象都存放在堆區中
2.每個棧中的數據(原始類型和對象引用)都是私有的,其他棧不能訪問。
3.棧分為3個部分:基本數據類型的變量區、執行環境上下文、操作指令區(存放操作指令)。
棧是存放線程調用方法時存儲局部變量表,操作,方法出口等與方法執行相關的信息,Java棧所占內存的大小由Xss來調節,方法調用層次太多會撐爆這個區域。
2、程序計數器(ProgramCounter)寄存器
PC寄存器( PC register ):每個線程啟動的時候,都會創建一個PC(Program Counter,程序計數器)寄存器。PC寄存器里保存有當前正在執行的JVM指令的地址。 每一個線程都有它自己的PC寄存器,也是該線程啟動時創建的。保存下一條將要執行的指令地址的寄存器是 :PC寄存器。PC寄存器的內容總是指向下一條將被執行指令的地址,這里的地址可以是一個本地指針,也可以是在方法區中相對應於該方法起始指令的偏移量。
3、本地方法棧
Nativemethodstack(本地方法棧):保存native方法進入區域的地址。
4、堆
類的對象放在heap(堆)中,所有的類對象都是通過new方法創建,創建后,在stack(棧)會創建類對象的引用(內存地址)。
一種常規用途的內存池(也在RAM(隨機存取存儲器 )區域),其中保存了Java對象。和棧不同:“內存堆”或“堆”最吸引人的地方在於編譯器不必知道要從堆里分配多少存儲空間,也不必知道存儲的數據要在堆里停留多長的時間。因此,用堆保存數據時會得到更大的靈活性。要求創建一個對象時,只需用new命令編輯相應的代碼即可。執行這些代碼時,會在堆里自動進行數據的保存。當然,為達到這種靈活性,必然會付出一定的代價:在堆里分配存儲空間時會花掉更長的時間。
JVM將所有對象的實例(即用new創建的對象)(對應於對象的引用(引用就是內存地址))的內存都分配在堆上,堆所占內存的大小由-Xmx指令和-Xms指令來調節,sample如下所示:
public class HeapOOM { static class OOMObject{} /** * @param args */ public static void main(String[] args) { List list = new ArrayList();// List類和ArrayList類都是集合類, // 但是ArrayList可以理解為順序表, // 屬於線性表。 while (true) { list.add(new OOMObject()); } } }
加上JVM參數-verbose:gc -Xms10M -Xmx10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+HeapDumpOnOutOfMemoryError,就能很快報出OOM異常(內存溢出異常):
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
並且能自動生成Dump。
5、方法區
method(方法區)又叫靜態區,存放所有的①類(class),②靜態變量(static變量),③靜態方法,④常量和⑤成員方法。
1.又叫靜態區,跟堆一樣,被所有的線程共享。
2.方法區中存放的都是在整個程序中永遠唯一的元素。這也是方法區被所有的線程共享的原因。
(順便展開靜態變量和常量的區別: 靜態變量本質是變量,是整個類所有對象共享的一個變量,其值一旦改變對這個類的所有對象都有影響;常量一旦賦值后不能修改其引用,其中基本數據類型的常量不能修改其值。)
Java里面是沒有靜態變量這個概念的,不信你自己在某個成員方法里面定義一個static int i = 0;Java里只有靜態成員變量。它屬於類的屬性。至於他放哪里?樓上說的是靜態區。我不知道到底有沒有這個翻譯。但是深入JVM里是翻譯為方法區的。虛擬機的體系結構:①Java棧,② 堆,③PC寄存器,④方法區,⑤本地方法棧,⑥運行常量池。而方法區保存的就是一個類的模板,堆是放類的實例(即對象)的。棧是一般來用來函數計算的。隨便找本計算機底層的書都知道了。棧里的數據,函數執行完就不會存儲了。這就是為什么局部變量每一次都是一樣的。就算給他加一后,下次執行函數的時候還是原來的樣子。
方法區的大小由-XX:PermSize和-XX:MaxPermSize來調節,類太多有可能撐爆永久代。靜態變量或常量也有可能撐爆方法區。
6、運行常量池(也屬於方法區的)
這兒的“靜態”是指“位於固定位置”。程序運行期間,靜態存儲的數據將隨時等候調用。可用static關鍵字指出一個對象的特定元素是靜態的。但Java對象本身永遠都不會置入靜態存儲空間。
這個區域屬於方法區。該區域存放類和接口的常量,除此之外,它還存放成員變量和成員方法的所有引用。當一個成員變量或者成員方法被引用的時候,JVM就通過運行常量池中的這些引用來查找成員變量和成員方法在內存中的的實際地址。
7、舉例分析
例子如下:
為了更清楚地搞明白程序運行時,數據區里的情況,我們來准備2個小道具(2個非常簡單的小程序)。
// AppMain.java public class AppMain { //運行時,JVM把AppMain的信息都放入方法區 public static void main(String[] args) { //main成員方法本身放入方法區。 Sample test1 = new Sample( " 測試1 " ); //test1是引用,所以放到棧區里,Sample是自定義對象應該放到堆里面 Sample test2 = new Sample( " 測試2 " ); test1.printName(); test2.printName(); } } // Sample.java public class Sample { //運行時,JVM把appmain的信息都放入方法區。 private name; //new Sample實例后,name引用放入棧區里,name對象放入堆里。 public Sample(String name) { this .name = name; } public void printName() {// printName()成員方法本身放入方法區里。 System.out.println(name); } }
OK,讓我們開始行動吧,出發指令就是:“java AppMain”,包包里帶好我們的行動向導圖。
系統收到了我們發出的指令,啟動了一個Java虛擬機進程,這個進程首先從classpath中找到AppMain.class文件,讀取這個文件中的二進制數據,然后把Appmain類的類信息存放到運行時數據區的方法區中。這一過程稱為AppMain類的加載過程。
接着,JVM定位到方法區中AppMain類的Main()方法的字節碼,開始執行它的指令。這個main()方法的第一條語句就是:
Sample test1 = new Sample("測試1");
語句很簡單啦,就是讓JVM創建一個Sample實例,並且呢,使引用變量test1引用這個實例。貌似小case一樁哦,就讓我們來跟蹤一下JVM,看看它究竟是怎么來執行這個任務的:
1、Java虛擬機一看,不就是建立一個Sample類的實例嗎,簡單,於是就直奔方法區(方法區存放已經加載的類的相關信息,如類、靜態變量和常量)而去,先找到Sample類的類型信息再說。結果呢,嘿嘿,沒找到@@,這會兒的方法區里還沒有Sample類呢(即Sample類的類信息還沒有進入方法區中)。可JVM也不是一根筋的笨蛋,於是,它發揚“自己動手,豐衣足食”的作風,立馬加載了Sample類, 把Sample類的相關信息存放在了方法區中。
2、Sample類的相關信息加載完成后。Java虛擬機做的第一件事情就是在堆中為一個新的Sample類的實例分配內存,這個Sample類的實例持有着指向方法區的Sample類的類型信息的引用(Java中引用就是內存地址)。這里所說的引用,實際上指的是Sample類的類型信息在方法區中的內存地址,其實,就是有點類似於C語言里的指針啦~~,而這個地址呢,就存放了在Sample類的實例的數據區中。
3、在JVM中的一個進程中,每個線程都會擁有一個方法調用棧,用來跟蹤線程運行中一系列的方法調用過程,棧中的每一個元素被稱為棧幀,每當線程調用一個方法的時候就會向方法棧中壓入一個新棧幀。這里的幀用來存儲方法的參數、局部變量和運算過程中的臨時數據。OK,原理講完了,就讓我們來繼續我們的跟蹤行動!位於“=”前的test1是一個在main()方法中定義的變量,可見,它是一個局部變量,因此,test1這個局部變量會被JVM添加到執行main()方法的主線程的Java方法調用棧中。而“=”將把這個test1變量指向堆區中的Sample實例,也就是說,test1這個局部變量持有指向Sample類的實例的引用(即內存地址)。
OK,到這里為止呢,JVM就完成了這個簡單語句的執行任務。參考我們的行動向導圖,我們終於初步摸清了JVM的一點點底細了,COOL!
接下來,JVM將繼續執行后續指令,在堆區里繼續創建另一個Sample類的實例,然后依次執行它們的printName()方法。當JVM執行test1.printName()方法時,JVM根據局部變量test1持有的引用,定位到堆中的Sample類的實例,再根據Sample類的實例持有的引用,定位到方法區中Sample類的類型信息(包括①類,②靜態變量,③靜態方法,④常量和⑤成員方法),從而獲取printName()成員方法的字節碼,接着執行printName()成員方法包含的指令。