1.8版本 最清晰理解Java內存區域劃分

寫在前面

在正式開始文章的閱讀之前,我們還要區分幾個概念,這很重要:(很多博客對Java內存模型,Java內存區域結構混一談,絲毫不加辨析,以至於很多時候看完之後還是雲裏霧裏)

  • Java內存模型:JMM與多線程相關,看起來和Java內存區域很相似,但JMM是一個抽象的概念,不是具體存在的。JMM描述了一組規則或規範,這個規範定義了一個線程對共享變量的操作對其他線程是可見的。
  • Java內存區域|Java內存結構:Java內存結構是JVM的運行時,虛擬機在執行Java程序時會內存區域劃分爲若干個不同的內存區域,這些區域各自都有自己的作用。只是不同的JVM虛擬機對區域的劃分稍有不同,但都遵循下面第二節所規定的規範。

一、各種基本數據類型的存儲

我們先來看一段小代碼:

public class{
	int a = 20;
	public static void main(String[] args){
		int b = 10;
		String str1 = ”abc“;
		String str2 = new String("abc");
	}
}

我們先初步分析一下着三個變量的存儲過程:
int 聲明的都是8中基本數據類型中的一種。

  • int a 是一個類的成員變量,成員變量的生命週期是和類在一起的,類下的每一個方法對於成員變量的值都是共享的,也就是成員變量需要多個方法都可以進行訪問。
  • int b 是main裏面的一個局部變量,他的生命週期隨着方法的結束而結束。
  • String str1和String str2的兩種聲明方式相同嗎? 答案是否定的,我們先闡述結果,然後帶着疑問繼續看後面的分析。
    首先str1和str2的內容是存儲在stack(棧)中的,他們是分別指向自己應當指向的內存區域(可能是棧中,也可能是堆中)
    str1是一個String類型的內容,"abc"在編譯時被放入靜態常量池(也就是class常量池)中,運行時被拿到運行時常量池中的字符串常量池,然後由str1指向"abc"的區域。
    str2的 new String(“abc”)是存儲在內存區域中的堆中的,這個new的過程是在運行期初始化階段才確定的,然後Stack上的str2指向heap(堆)上的new String(“abc”)。
    因爲他們是指向的不同的內存區域,System.out.println(str1 == str2); 的結果也自然就是false了。

二、Java內存區域的劃分(運行時數據區)

Java 虛擬機在執行 Java 程序的過程中會把它管理的內存劃分成若干個不同的數據區域。

1.8之前:
在這裏插入圖片描述
1.8之後:
在這裏插入圖片描述
我們可以把它們分爲兩個類型的區域,一種是線程私有的,另一種是線程共享的。

  • 線程共享的區域:
    • 堆(Heap)
    • 方法區(Method Area)
  • 線程私有:
    • 程序計數器(PC)
    • 虛擬機棧(VM Stack)
    • 本地方法棧(Native Method Stack)

2.1 程序計數器(Program Counter):

可以看作是當前線程所執行的字節碼的行號指示器,他標記着我們當前執行到了哪一條指令。類比我們計算機組成中的PC計數器,他實現着我們代碼的控制流程。
在多線程的情況下,程序計數器用於記錄當前線程執行的位置,從而當線程被切換回來的時候能夠知道該線程上次運行到哪兒了。

2.2 Java虛擬機棧(Stack)

與程序計數器一樣,Java虛擬機棧也是線程私有的,它的生命週期和線程相同,描述的是 Java 方法執行的內存模型,每次方法調用的數據都是通過棧傳遞的。
Java 內存可以粗糙的區分爲堆內存(Heap)和棧內存(Stack),其中棧就是現在說的虛擬機棧,或者說是虛擬機棧中局部變量表部分。 (實際上,Java虛擬機棧是由一個個棧幀組成,而每個棧幀中都擁有:局部變量表、操作數棧、動態鏈接、方法出口信息。)

局部變量表主要存放了編譯器可知的各種數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)。

Java 虛擬機棧會出現兩種異常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError :若 Java 虛擬機棧的內存大小不允許動態擴展,那麼當線程請求棧的深度超過當前 Java 虛擬機棧的最大深度的時候,就拋出 StackOverFlowError 異常。

程序實現SOF:

public class StackOverFlow {
    public int stackSize = 0;

    public void stackIncre() {
        stackSize++;
        stackIncre();
    }

    public static void main(String[] args) throws Throwable{
        StackOverFlow sof = new StackOverFlow();
        try {
            sof.stackIncre();
        } catch (Throwable e) {
            System.out.println(sof.stackSize);
            throw e;
        }
    }
}

  • OutOfMemery:若 Java 虛擬機棧的內存大小允許動態擴展,且當線程請求棧時內存用完了,無法再動態擴展了,此時拋出 OutOfMemoryError 異常。
    程序實現OOM:
public class OutOfMemory {
    public static void main(String[] args){
        List list=new ArrayList();
        for(;;){
            int[] tmp=new int[1000000];
            list.add(tmp);
        }
    }
}

Java 虛擬機棧也是線程私有的,每個線程都有各自的 Java 虛擬機棧,而且隨着線程的創建而創建,隨着線程的死亡而死亡。

拓展:方法/函數如何調用?

Java 棧可用類比數據結構中棧,Java 棧中保存的主要內容是棧幀,每一次函數調用都會有一個對應的棧幀被壓入 Java 棧,每一個函數調用結束後,都會有一個棧幀被彈出。

Java 方法有兩種返回方式:

  1. return 語句。
  2. 拋出異常。
    不管哪種返回方式都會導致棧幀被彈出。

2.3 Java本地方法棧(Native Method Stack)

和虛擬機棧所發揮的作用非常相似,區別是: 虛擬機棧爲虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的 Native 方法服務。 在 HotSpot 虛擬機中和 Java 虛擬機棧合二爲一。

本地方法被執行的時候,在本地方法棧也會創建一個棧幀,用於存放該本地方法的局部變量表、操作數棧、動態鏈接、出口信息。

方法執行完畢後相應的棧幀也會出棧並釋放內存空間,也會出現 StackOverFlowError 和 OutOfMemoryError 兩種異常。

2.4 堆(Heap)

Java 虛擬機所管理的內存中最大的一塊,Java 堆是所有線程共享的一塊內存區域,在虛擬機啓動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都在這裏分配內存。
Java 堆是垃圾收集器管理的主要區域,因此也被稱作GC堆(Garbage Collected Heap)。從垃圾回收的角度,由於現在收集器基本都採用分代垃圾收集算法,所以Java堆還可以細分爲:新生代和老年代:在細緻一點有:Eden空間、From Survivor、To Survivor空間等。進一步劃分的目的是更好地回收內存,或者更快地分配內存。
在這裏插入圖片描述
在 JDK 1.8中移除整個永久代,取而代之的是一個叫元空間(Metaspace)的區域(永久代使用的是JVM的堆內存空間,而元空間使用的是物理內存,直接受到本機的物理內存限制)。

2.5 方法區(Method Area/Non-Heap)

方法區與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。雖然Java虛擬機規範把方法區描述爲堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。
HotSpot 虛擬機中方法區也常被稱爲 “永久代”,本質上兩者並不等價。僅僅是因爲 HotSpot 虛擬機設計團隊用永久代來實現方法區而已,這樣 HotSpot 虛擬機的垃圾收集器就可以像管理 Java 堆一樣管理這部分內存了。但是這並不是一個好主意,因爲這樣更容易遇到內存溢出問題。(Java 8之前的實現

在Java 8版本之前,方法區僅是邏輯上的分區,物理上並沒有獨立於堆而存在,而是位於永久代中。所以這時候的方法區也是可以被回收的。在Java 8版本之後Hot Spot移除了永久代,使用本地內存來存儲類元數據信息,並命名爲元空間

2.5.1 方法區和永久代的關係

《Java 虛擬機規範》只是規定了有方法區這麼個概念和它的作用,並沒有規定如何去實現它。那麼,在不同的 JVM 上方法區的實現肯定是不同的了。 方法區和永久代的關係很像 Java 中接口和類的關係,類實現了接口,而永久代就是 HotSpot 虛擬機對虛擬機規範中方法區的一種實現方式。 也就是說,永久代是 HotSpot 的概念,方法區是 Java 虛擬機規範中的定義,是一種規範,而永久代是一種實現,一個是標準一個是實現,其他的虛擬機實現並沒有永久帶這一說法。

2.5.2 常用參數

JDK 1.8 之前永久代還沒被徹底移除的時候通常通過下面這些參數來調節方法區大小

-XX:PermSize=N //方法區 (永久代) 初始大小
-XX:MaxPermSize=N //方法區 (永久代) 最大大小,超過這個值將會拋出 OutOfMemoryError 異常:java.lang.OutOfMemoryError: PermGen

相對而言,垃圾收集行爲在這個區域是比較少出現的,但並非數據進入方法區後就“永久存在”了。

JDK 1.8 的時候,方法區(HotSpot 的永久代)被徹底移除了(JDK1.7 就已經開始了),取而代之是元空間,元空間使用的是直接內存。

下面是一些常用參數:

-XX:MetaspaceSize=N //設置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //設置 Metaspace 的最大大小

與永久代很大的不同就是,如果不指定大小的話,隨着更多類的創建,虛擬機會耗盡所有可用的系統內存。

2.5.3 爲什麼要將永久代 (PermGen) 替換爲元空間 (MetaSpace) 呢?

整個永久代有一個 JVM 本身設置固定大小上線,無法進行調整,而元空間使用的是直接內存,受本機可用內存的限制,並且永遠不會得到 java.lang.OutOfMemoryError。你可以使用 -XX:MaxMetaspaceSize 標誌設置最大元空間大小,默認值爲 unlimited,這意味着它只受系統內存的限制。-XX:MetaspaceSize 調整標誌定義元空間的初始大小如果未指定此標誌,則 Metaspace 將根據運行時的應用程序需求動態地重新調整大小。

2.6 運行時常量池

jdk1.6、1.7、1.8實際上運行時常量池的位置都發生了很大的變化,jdk1.6運行時常量池存在於方法區,jdk1.7移到了堆區,而jdk1.8運行時常量池其實是存在於與方法區和堆區相對獨立的元空間,而不是在堆區。
運行時常量池是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有常量池信息(用於存放編譯期生成的各種字面量和符號引用)
在這裏插入圖片描述
既然運行時常量池時方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。

拓展:字符串常量池和運行時常量池什麼關係?

  • 運行時常量池存在於內存中,也就是class常量池被加載到內存之後的版本,不同之處是:它的字面量可以動態的添加,符號引用可以被解析爲直接引用
  • JVM在執行某個類的時候,必須經過加載、連接、初始化,而連接又包括驗證、準備、解析三個階段。而當類加載到內存中後,jvm就會將class常量池中的內容存放到運行時常量池中,由此可知,運行時常量池也是每個類都有一個。在解析階段,會把符號引用替換爲直接引用,解析的過程會去查詢字符串常量池,也就是我們上面所說的StringTable,以保證運行時常量池所引用的字符串與字符串常量池中是一致的。

除此之外,常量池還有class constant pool
推薦閱讀:字符串常量池、class常量池和運行時常量池

2.7 直接內存

直接內存並不是虛擬機運行時數據區的一部分,也不是虛擬機規範中定義的內存區域,但是這部分內存也被頻繁地使用。而且也可能導致OutOfMemoryError異常出現。
JDK1.4中新加入的 NIO(New Input/Output) 類,引入了一種基於通道(Channel) 與緩存區(Buffer) 的 I/O 方式,它可以直接使用Native函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作爲這塊內存的引用進行操作。這樣就能在一些場景中顯著提高性能,因爲避免了在 Java 堆和 Native 堆之間來回複製數據。
本機直接內存的分配不會收到 Java 堆的限制,但是,既然是內存就會受到本機總內存大小以及處理器尋址空間的限制。

三、補充內容*

3.1 String 對象的兩種創建方式:

就是我們上面(一)中的str1和str2兩種創建string類型的方式。第一種方式是在常量池中拿對象,第二種方式是直接在堆內存空間創建一個新的對象
在這裏插入圖片描述

3.2 String 類型的常量池比較特殊。

它的主要使用方法有兩種:**

  • 用雙引號聲明出來的 String 對象會直接存儲在常量池中。
  • 如果不是用雙引號聲明的 String 對象,可以使用 String 提供的 intern 方String.intern() 是一個 Native 方法,它的作用是:如果運行時常量池中已經包含一個等於此 String 對象內容的字符串,則返回常量池中該字符串的引用;如果沒有,則在常量池中創建與此 String 內容相同的字符串,並返回常量池中創建的字符串的引用。
 String s1 = new String("計算機");
	      String s2 = s1.intern();
	      String s3 = "計算機";
	      System.out.println(s2);//計算機
	      System.out.println(s1 == s2);//false,因爲一個是堆內存中的String對象一個是常量池中的String對象,
	      System.out.println(s3 == s2);//true,因爲兩個都是常量池中的String對

3.3 String 字符串拼接

		  String str1 = "str";
		  String str2 = "ing";
		  
		  String str3 = "str" + "ing";//常量池中的對象
		  String str4 = str1 + str2; //在堆上創建的新的對象	  
		  String str5 = "string";//常量池中的對象
		  System.out.println(str3 == str4);//false
		  System.out.println(str3 == str5);//true
		  System.out.println(str4 == str5);//false


儘量避免多個字符串拼接,因爲這樣會重新創建對象。如果需要改變字符串的花,可以使用 StringBuilder 或者 StringBuffer。

3.4 String s1 = new String(“abc”);這句話創建了幾個字符串對象?

將創建 1 或 2 個字符串。如果池中已存在字符串文字“abc”,則池中只會創建一個字符串“s1”。如果池中沒有字符串文字“abc”,那麼它將首先在池中創建,然後在堆空間中創建,因此將創建總共 2 個字符串對象。

驗證:

String s1 = new String("abc");// 堆內存的地址值
		String s2 = "abc";
		System.out.println(s1 == s2);// 輸出 false,因爲一個是堆內存,一個是常量池的內存,故兩者是不同的。
		System.out.println(s1.equals(s2));// 輸出 true

結果:

false
true

3.5 八種基本數據類型,基本類型的包裝類和常量池

關於包裝類的知識推薦閱讀:[Java基礎] Java包裝類及自動裝箱、拆箱


基本數據類型(String不是基本數據類型)的數據保存在stack中,如目錄(一)中的b保存在棧中,10也保存在stack中,然後由b指向10
  • Java 基本類型的包裝類的大部分都實現了常量池技術,即Byte,Short,Integer,Long,Character,Boolean;這5種包裝類默認創建了數值[-128,127]的相應類型的緩存數據,但是超出此範圍仍然會去創建新的對象。
  • 兩種浮點數類型的包裝類 Float,Double 並沒有實現常量池技術
		Integer i1 = 33;
		Integer i2 = 33;
		System.out.println(i1 == i2);// 輸出true
		Integer i11 = 333;
		Integer i22 = 333;
		System.out.println(i11 == i22);// 輸出false
		Double i3 = 1.2;
		Double i4 = 1.2;
		System.out.println(i3 == i4);// 輸出false

Integer緩存源代碼

/**
*此方法將始終緩存-128到127(包括端點)範圍內的值,並可以緩存此範圍之外的其他值。
*/
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }
  1. Integer i1=40;Java 在編譯的時候會直接將代碼封裝成Integer i1=Integer.valueOf(40);,從而使用常量池中的對象。
  2. Integer i1 = new Integer(40);這種情況下會創建新的對象。

Integer比較更豐富的一個例子:

Integer i1 = 40;
  Integer i2 = 40;
  Integer i3 = 0;
  Integer i4 = new Integer(40);
  Integer i5 = new Integer(40);
  Integer i6 = new Integer(0);
  
  System.out.println("i1=i2   " + (i1 == i2));
  System.out.println("i1=i2+i3   " + (i1 == i2 + i3));
  System.out.println("i1=i4   " + (i1 == i4));
  System.out.println("i4=i5   " + (i4 == i5));
  System.out.println("i4=i5+i6   " + (i4 == i5 + i6));   
  System.out.println("40=i5+i6   " + (40 == i5 + i6));     

運行結果:

i1=i2   true
i1=i2+i3   true
i1=i4   false
i4=i5   false
i4=i5+i6   true
40=i5+i6   true

語句i4 == i5 + i6,因爲+這個操作符不適用於Integer對象,首先i5和i6進行自動拆箱操作,進行數值相加,即i4 == 40。然後Integer對象無法與數值進行直接比較,所以i4自動拆箱轉爲int值40,最終這條語句轉爲40 == 40進行數值比較。

四、總結

我們平時討論最多的是stack棧內存和heap堆內存,再次對數據類型在內存中的存儲問題來解釋一下:

  1. 方法中聲明的變量,即該變量是局部變量,每當程序調用方法時,系統都會爲該方法建立一個方法棧,其所在方法中聲明的變量就放在方法棧中,當方法結束系統會釋放方法棧,其對應在該方法中聲明的變量隨着棧的銷燬而結束,這就局部變量只能在方法中有效的原因

    在方法中聲明的變量可以是基本類型的變量,也可以是引用類型的變量。

  • 當聲明是基本類型的變量的時,其變量名及值(變量名及值是兩個概念)是放在JAVA虛擬機棧中
  • 當聲明的是引用變量時,所聲明的變量的reference(該變量實際上是在方法中存儲的是內存地址值)是放在JAVA虛擬機的棧中,該變量所指向的對象是放在堆類存中的。
  1. 類中聲明 的變量是成員變量,也叫全局變量,放在堆中的(因爲全局變量不會隨着某個方法執行結束而銷燬)。
    同樣在類中聲明的變量即可是基本類型的變量 也可是引用類型的變量
  • 當聲明的是基本類型的變量其變量名及其值放在堆內存中的
  • 引用類型時,其聲明的變量仍然會存儲一個內存地址值,該內存地址值指向所引用的對象。引用變量名和對應的對象仍然存儲在相應的堆中

參考:https://juejin.im/post/5b7d69e4e51d4538ca5730cb#heading-18
http://tangxman.github.io/2015/07/27/the-difference-of-java-string-pool/
https://blog.csdn.net/zm13007310400/article/details/77534349
https://blog.csdn.net/jingjbuer/article/details/46348667

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章