JVM內存模型和面試題解析


一、JVM運行時區域

  

  其中,

    線程私有的:程序計數器,虛擬機棧,本地方法棧

    線程共享的:堆,方法區,直接內存

1 程序計數器

  程序計數器是一塊較小的內存空間,可以看作是當前線程所執行的字節碼的行號指示器。字節碼解釋器工作時通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等功能都需要依賴這個計數器來完。

  java虛擬機的多線程是通過線程輪流切換並分配CPU的時間片的方式實現的,因此在任何時刻一個處理器(如果是多核處理器,則只是一個核)都只會處理一個線程,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各線程之間計數器互不影響,獨立存儲,因此這類內存區域為“線程私有”的內存。

  從上面的介紹中我們知道程序計數器主要有兩個作用:

    1. 字節碼解釋器通過改變程序計數器來依次讀取指令,從而實現代碼的流程控制,如:順序執行、選擇、循環、異常處理。
    2. 在多線程的情況下,程序計數器用於記錄當前線程執行的位置,從而當線程被切換回來的時候能夠知道該線程上次運行到哪兒了。

  注意:程序計數器是唯不會出現 OutOfMemoryError 的內存區域,它的生命周期隨着線程的創建而創建,隨着線程的結束而死亡。

2 Java 虛擬機棧

  Java虛擬機棧也是線程私有的,它的生命周期和線程相同,描述的是 Java 方法執行的內存模型。Java虛擬機棧是由一個個棧幀組成,線程在執行一個方法時,便會向棧中放入一個棧幀,每個棧幀中都擁有局部變量表、操作數棧、動態鏈接、方法出口信息。局部變量表主要存放了編譯器可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)和對象引用(reference類型,它不同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)。

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

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

  OutOfMemoryError:若 Java 虛擬機棧的內存大小允許動態擴展,且當線程請求棧時內存用完了,無法再動態擴展了,此時拋出OutOfMemoryError異常。

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

3 本地方法棧

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

  本地方法被執行的時候,在本地方法棧也會創建一個棧幀,用於存放該本地方法的局部變量表、操作數棧、動態鏈接、出口信息。方法執行完畢后相應的棧幀也會出棧並釋放內存空間,也會出現 StackOverFlowError 和 OutOfMemoryError 兩種異常。

4 堆

  堆是Java 虛擬機所管理的內存中最大的一塊,Java 堆是所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都在這里分配內存(目前由於編譯器的優化,對象在堆上分配已經沒有那么絕對了,參見:https://www.cnblogs.com/aiqiqi/p/10650394.html)。

  Java 堆是垃圾收集器管理的主要區域,因此也被稱作GC堆(Garbage Collected Heap)。從垃圾回收的角度,由於現在收集器基本都采用分代垃圾收集算法,所以Java堆還可以細分為:新生代和老年代:其中新生代又分為:Eden空間、From Survivor、To Survivor空間。進一步划分的目的是更好地回收內存,或者更快地分配內存。“分代回收”是基於這樣一個事實:對象的生命周期不同,所以針對不同生命周期的對象可以采取不同的回收方式,以便提高回收效率。從內存分配的角度來看,線程共享的java堆中可能會划分出多個線程私有的分配緩沖區(Thread Local Allocation Buffer,TLAB)。

   

  如圖所示,JVM內存主要由新生代、老年代、永久代構成。

  ① 新生代(Young Generation):大多數對象在新生代中被創建,其中很多對象的生命周期很短。每次新生代的垃圾回收(又稱Minor GC)后只有少量對象存活,所以選用復制算法,只需要少量的復制成本就可以完成回收。

  新生代內又分三個區:一個Eden區,兩個Survivor區(一般而言),大部分對象在Eden區中生成。當Eden區滿時,還存活的對象將被復制到兩個Survivor區(中的一個)。當這個Survivor區滿時,此區的存活且不滿足“晉升”條件的對象將被復制到另外一個Survivor區。對象每經歷一次Minor GC,年齡加1,達到“晉升年齡閾值”后,被放到老年代,這個過程也稱為“晉升”。顯然,“晉升年齡閾值”的大小直接影響着對象在新生代中的停留時間,在Serial和ParNew GC兩種回收器中,“晉升年齡閾值”通過參數MaxTenuringThreshold設定,默認值為15。

  ② 老年代(Old Generation):在新生代中經歷了N次垃圾回收后仍然存活的對象,就會被放到年老代,該區域中對象存活率高。老年代的垃圾回收(又稱Major GC)通常使用“標記-清理”或“標記-整理”算法。整堆包括新生代和老年代的垃圾回收稱為Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都會同時收集整個GC堆,包括新生代)。

  ③ 永久代(Perm Generation):主要存放元數據,例如Class、Method的元信息,與垃圾回收要回收的Java對象關系不大。相對於新生代和年老代來說,該區域的划分對垃圾回收影響比較小。

  在 JDK 1.8中移除整個永久代,取而代之的是一個叫元空間(Metaspace)的區域(永久代使用的是JVM的堆內存空間,而元空間使用的是物理內存,直接受到本機的物理內存限制)。

5 方法區

  方法區與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據。雖然Java虛擬機規范把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

  HotSpot 虛擬機中方法區也常被稱為 “永久代”,本質上兩者並不等價。僅僅是因為 HotSpot 虛擬機設計團隊用永久代來實現方法區而已,這樣 HotSpot 虛擬機的垃圾收集器就可以像管理 Java 堆一樣管理這部分內存了。但是這並不是一個好主意,因為這樣更容易遇到內存溢出問題。

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

6 運行時常量池

  運行時常量池是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有常量池信息(用於存放編譯期生成的各種字面量和符號引用)

  既然運行時常量池時方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。

  JDK1.7及之后版本的 JVM 已經將運行時常量池從方法區中移了出來,在 Java 堆(Heap)中開辟了一塊區域存放運行時常量池。

   

7 直接內存

  直接內存並不是虛擬機運行時數據區的一部分,也不是虛擬機規范中定義的內存區域,但是這部分內存也被頻繁地使用。而且也可能導致OutOfMemoryError異常出現。

  JDK1.4中新加入的 NIO(New Input/Output) 類,引入了一種基於通道(Channel) 與緩存區(Buffer) 的 I/O 方式,它可以直接使用Native函數庫直接分配堆外內存,然后通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作為這塊內存的引用進行操作。這樣就能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆之間來回復制數據。

  本機直接內存的分配不會收到 Java 堆的限制,但是,既然是內存就會受到本機總內存大小以及處理器尋址空間的限制。

二、對象創建過程

 

1、類加載檢查

  虛擬機遇到一條new指令時,首先將去檢查這個指令的參數能否在常量池中定位到一個類的符號引用,並檢查這個符號引用代表的類是否已經被加載、解析和初始化過,如果沒有,那么必須先執行相應的類加載過程。

2、分配內存

  在類加載檢查通過后,接下來虛擬機將會為新生的對象分配內存。對象所需要的內存大小在類加載完成后便可完全確定,為對象分配空間等同於把一塊確定大小的內存從java堆中划分出來。分配方式有 “指針碰撞” 和 “空閑列表” 兩種,選擇那種分配方式由 Java 堆是否規整決定,而Java堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。

  (1)指針碰撞法

  假設Java堆中內存是完整的,已分配的內存和空閑內存分別在不同的一側,通過一個指針作為分界點,需要分配內存時,僅僅需要把指針往空閑的一端移動與對象大小相等的距離。使用的GC收集器:Serial、ParNew,適用堆內存規整(即沒有內存碎片)的情況下。

  (2)空閑列表法

  事實上,Java堆的內存並不是完整的,已分配的內存和空閑內存相互交錯,JVM通過維護一個空閑列表,記錄可用的內存塊信息,當分配操作發生時,從列表中找到一個足夠大的內存塊分配給對象實例,並更新列表上的記錄。使用的GC收集器:CMS,適用堆內存不規整的情況下。

   Java 堆內存是否規整,取決於 GC 收集器的算法是”標記-清除”,還是”標記-整理”(也稱作”標記-壓縮”),值得注意的是,復制算法內存也是規整的。在使用Serial、ParNew等待整理過程的收集器時,采用的是指針碰撞,在使用CMS這種mark-sweep算法的收集器時,使用的是空閑列表。

內存分配並發問題

  在創建對象的時候有一個很重要的問題,就是線程安全,因為在實際開發過程中,創建對象是很頻繁的事情,例如正在給A對象分配內存,但是指針還沒修改,這時候對象B可能使用原來的指針來分配內存的情況。作為虛擬機來說,必須要保證線程是安全的,通常來講,虛擬機采用兩種方式來保證線程安全:

  • CAS+失敗重試: CAS 是樂觀鎖的一種實現方式。所謂樂觀鎖就是,每次不加鎖而是假設沒有沖突而去完成某項操作,如果因為沖突失敗就重試,直到成功為止。虛擬機采用 CAS 配上失敗重試的方式保證更新操作的原子性。
  • TLAB: 為每一個線程預先在 Eden 區分配一塊內存。JVM 在給線程中的對象分配內存時,首先在各個線程的TLAB 分配,當對象大於TLAB 中的剩余內存或 TLAB 的內存已用盡時,再采用上述的 CAS 進行內存分配。虛擬機是否啟用TLAB,可以通過-XX:+/-UseTLAB參數來設定。

3、初始零值

  內存分配完成后,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭),這一步操作保證了對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。如果使用TLAB,這一工作過程也可以提前到TLAB分配時進行。

 4、設置對象頭

  接下來,虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例,如何才能找到類的元數據信息,對象的哈希嗎,對象的GC分代年齡等信息,這些信息存放在對象的對象頭中。根據虛擬機當前的運行狀態的不同,對象頭會有不同的設置方式。

5、執行init方法

  在上面工作都完成之后,從虛擬機的視角來看,一個新的對象已經產生了,但從 Java 程序的視角來看,對象創建才剛開始,<init> 方法還沒有執行,所有的字段都還為零。所以一般來說,執行 new 指令之后會接着執行 <init> 方法,把對象按照程序員的意願進行初始化,這樣一個真正可用的對象才算完全產生出來。

三、對象的內存布局

  在HotSpot虛擬機中,對象在內存中存儲的布局可以分為三個區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。

  (1)HotSpot虛擬機的對象頭包括兩部分信息:

    第一部分用來存儲對象自身的運行時數據,例如哈希碼、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分數據在32位和64的虛擬機中分別為32bit和64bit,成為Mark Word。

    另一部分是類型指針,即對象指向他的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。 

  (2)實例數據部分存儲的是對象真正有效的信息,也是在程序代碼中定義的各種類型的字段內容。無論從父類中繼承下來的,還是在子類中定義的都需要記錄下來。

  (3)對齊填充並不是必須的部分,沒有特別的含義,僅僅起着占位符的作用,因為 Hotspot 虛擬機的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說就是對象的大小必須是8字節的整數倍。而對象頭部分正好是8字節的倍數(1倍或2倍),因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。

四、對象的訪問定位

  建立對象就是為了使用對象,Java程序中需要通過棧上的reference引用數據來操作堆上的具體對象。對象的訪問方式取決於虛擬機的實現,主流的方式有句柄池和直接指針兩種。

  (1)句柄池。如果使用句柄池的話,java堆中將會划分出一塊內存來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。

    

  (2)直接指針。如果使用直接指針,那么Java堆中的對象的布局就必須考慮如何放置訪問類型數據的相關信息,而reference中存儲的就是對象的直接地址。

     

  使用句柄訪問最大的好處就是reference中存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針,而reference本身不需要修改。

  使用直接指針最大的好處就是速度更快,節省了一次指針定位的時間開銷。

五、String類和常量池常見面試題

1、String的兩種創建方式的區別

  String str1 = "abc";
  String str2 = new String("abc");
  sout(str1 == str2);//false

   其中,第一種方式是從常量池中獲取對象,第二種方式是直接在堆內存中創建一個新的對象。

   

  只要是使用new方法,邊需要在堆中創建新的對象。

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 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

   

問題:

  String s1 = new String("abc"); // 這句話創建了幾個對象?

解答:

  創建了兩個對象。

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

解釋:

  先有字符串 “abc” 放入常量池,然后 new 了一份字符串 “abc” 放入 Java 堆(字符串常量 “abc” 在編譯期就已經確定放入常量池,而 Java 堆上的 “abc” 是在運行期初始化階段才確定),然后 Java 棧的 str1 指向 Java 堆上的 “abc”。

六、八種基本類型的包裝類和常量池

  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緩存源碼:

private static class IntegerCache {
        static final int low = -128;
        static final int high;
        static final Integer cache[];

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    int i = parseInt(integerCacheHighPropValue);
                    i = Math.max(i, 127);
                    // Maximum array size is Integer.MAX_VALUE
                    h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // If the property cannot be parsed into an int, ignore it.
                }
            }
            high = h;

            cache = new Integer[(high - low) + 1];
            int j = low;
            for(int k = 0; k < cache.length; k++)
                cache[k] = new Integer(j++);

            // range [-128, 127] must be interned (JLS7 5.1.7)
            assert IntegerCache.high >= 127;
        }

        private IntegerCache() {}
    }

  這里將緩存-128到127的數據,因此Integer i1=40;Java 在編譯的時候會直接將代碼封裝成 Integer i1=Integer.valueOf(40); 從而使用常量池中的對象。否則返回新的對象。

  public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

 另外:

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));//true
System.out.println("i1=i2+i3   " + (i1 == i2 + i3));//true
System.out.println("i1=i4   " + (i1 == i4));//false
System.out.println("i4=i5   " + (i4 == i5));//false
System.out.println("i4=i5+i6   " + (i4 == i5 + i6));//true   
System.out.println("40=i5+i6   " + (40 == i5 + i6));//true

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

 

 參考:

  http://www.importnew.com/31126.html https://blog.csdn.net/keyandi/article/details/89203476 JVM基礎面試題及原理講解

   https://blog.csdn.net/qq_26222859/article/details/73135660  字符串常量池、class常量池和運行時常量池


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM