天天看點

面試幹貨5——請詳細說說JVM記憶體結構(堆、棧、常量池)一、概述二、運作時資料區

JVM記憶體結構

  • 一、概述
  • 二、運作時資料區
    • 1、程式計數器
    • 2、堆
    • 3、棧
    • 4、方法區/中繼資料區
      • 4.1 常量池

一、概述

        JVM是中、進階開發人員必學的,雖然這玩意對平時的開發沒有卵用,但是有助于你了解項目從加載到運作的整個流程,有助于你處理生産上出現的問題,比如我們常見的OOM,如果你對JVM一無所知,你會知道為什麼會OOM嗎?你知道如何監控嗎?你懂得怎麼處理嗎?

        前面的文章講解了類加載過程,類加載器,垃圾回收機制。而類加載就是将.class檔案加載到JVM記憶體中,垃圾回收也是在替我們管理JVM記憶體,那麼.class檔案到底加載到JVM記憶體的什麼區域呢?垃圾回收管理的又是哪些呢?差別于靜态連結的動态連結到底發生在哪裡呢?又或者是方法到底是怎樣執行的,對象建立又是怎樣的?這篇文章就帶你深入了解JVM記憶體結構。

        翻臉警告:本篇文章介紹的是JVM記憶體結構,千萬不要與JVM記憶體模型混為一談,二者是完全不同的兩個概念,如果你當着面試官的面将二者混為一談,那麼你可以直接滾蛋了(開玩笑啦,誇張一點而已~)

二、運作時資料區

面試幹貨5——請詳細說說JVM記憶體結構(堆、棧、常量池)一、概述二、運作時資料區

        本篇文章基于JDK1.8。JVM内部設計如上圖所示,其中執行引擎包含了解釋器、即時編譯器、垃圾回收器,另外還有本地庫接口,主要是為了調用本地方法的。我們所研究的JVM記憶體結構主要是運作時資料區。

        JVM将運作時資料區劃分成幾個部分,每個部分都有各自的作用,主要包括:方法區、堆、虛拟機棧、本地方法棧、程式計數器。當然,方法區在JDK1.7已經取消了,後續文章會詳細介紹。

1、程式計數器

        程式計數器(Program Counter Register) 是一塊較小的記憶體空間,可以看作是目前線程所要執行指令的行号訓示器,指向下一條将要執行的指令的位址。即線程執行流程是由 位元組碼解釋器 來改變程式計數器的值來維持的。此外程式計數器是不存在記憶體溢出的。

        位元組碼解釋器: 我們都知道Java代碼需要進行編譯,成為位元組碼檔案,但是位元組碼檔案并不是機器能夠識别的語言,是以在執行的時候需要将位元組碼解釋成機器語言去執行,那麼位元組碼解釋器就是将位元組碼檔案解釋成機器語言的,即解釋執行。注: 同一份位元組碼檔案在不同的JDK下能夠被解釋成不同的機器指令,此處也展現了Java語言平台無關性

程式計數器的作用:

        1、解釋器解釋執行目前指令,并将下一條指令的位址放入程式計數器

        2、執行完成以後再到程式技術器取到目前要執行的指令,并将下一條指令位址存入計數器

        3、循環步驟

        為了確定線程切換能恢複到正确的執行位置,是以每一個線程都有獨立的程式計數器,各個線程的計數器互不影響,獨立存儲,是以說程式計數器與線程共存亡,線程結束,釋放記憶體,無需垃圾回收管理。

        如果目前執行的是Java方法,那麼程式計數器的值為位元組碼指令位址,如果執行的是native方法,那麼計數器的值為undefined。

2、堆

        堆是JVM所管理的記憶體中最大的一塊區域,由各個線程共享,所有的對象以及數組所用記憶體都要在堆中開辟。學過垃圾回收的同學,肯定多少都了解堆的内部結構。

        堆主要分為年輕代,老年代;年輕代分為伊甸區(Eden)、幸存區(Survivor),幸存區又分為From Survivor和To Survivor兩塊大小相等的區域,如下圖所示(圖檔來源于網絡)

面試幹貨5——請詳細說說JVM記憶體結構(堆、棧、常量池)一、概述二、運作時資料區

        新生對象,也就是剛new出來的對象,會放到Eden區(翻譯過來為伊甸園,是傳說中亞當夏娃造人的地方,是以新生對象放這裡),Eden滿了之後會觸發Minor GC,進行複制算法垃圾回收,熬過一次GC的對象會進入到From/To區,熬過n次(預設15次)Minor GC的對象,會進入到Old老年代,老年代存儲長期存活的對象和大對象,老年代滿了會觸發Full GC,Full GC清理老年代和年輕代,這些東西在GC垃圾回收這篇文章都講過,不再贅述。

一些JVM常用的堆記憶體配置參數如下表

參數 含義
-Xms 設定JVM啟動時申請的初始堆記憶體,也為最小堆記憶體,預設是實體記憶體的1/64。預設堆記憶體空閑量大于70%則堆記憶體縮減為最小堆記憶體
-Xmx 設定JVM可申請的最大堆記憶體,預設是實體記憶體的1/4。預設堆記憶體空閑量小于40%則記憶體自動擴容到可申請的最大堆記憶體
-Xmn 設定年輕代的大小
-XXSurvivorRatio Eden區與Survivor區的比例,預設值為8,即Eden與Survivor記憶體大小的比值為8 : 1 : 1

        表格中-XX:PermSize實際為非堆記憶體,可以了解為方法區(永久代),在JDK1.7及以前JVM運作時資料區是存在方法區的,該參數是用于設定它的,在JDK1.8及以後,取消了方法區,取而代之的是中繼資料區,位于電腦實體記憶體,作為非堆記憶體。

3、棧

        在JVM中,有虛拟機棧與本地方法棧,虛拟機棧是為我們的Java方法服務的,而本地方法棧則是為native方法服務的,二者功能基本相似,而我們平時所說的棧就是虛拟機棧。

        虛拟機棧為Java方法服務,是一個方法執行的記憶體空間,棧是線程獨享的,與線程生命周期一樣,不需要GC管理,每個方法執行的同時都會建立一個棧幀,用于存儲局部變量表、操作數棧、動态連結、傳回位址等資訊,每個方法從調用直至執行完成的過程都對應着一個棧幀的入棧與出棧,下圖為棧的結構:

面試幹貨5——請詳細說說JVM記憶體結構(堆、棧、常量池)一、概述二、運作時資料區

舉例: 此時要執行a方法,a方法又調用了b方法,b方法又調用了c方法,那麼執行過程為:建立棧幀a,入棧,到a調用b方法的代碼時,建立棧幀b,入棧,到b調用c方法的代碼時,建立棧幀c,入棧,c方法執行結束,棧幀c出棧,繼續執行b方法,b方法執行結束,棧幀b出棧,繼續調用a方法,a執行結束,棧幀a出棧,流程結束。

        局部變量表: 用于存儲方法的局部變量,隻存儲引用,不存儲内容,其記憶體空間在編譯期确定,在運作期間大小不會發生改變。

        動态連結: 動态連結即将符号引用替換為直接引用(直接為記憶體中的引用位址),每一個棧幀都會有一個符号引用,這個符号引用指向運作時常量池的直接引用,為了支援方法的調用,需要将這些符号引用替換為直接引用,有一部分符号引用替換為直接引用是在類加載的解析階段完成的,譬如一些編譯期可知,運作期不可變的類方法或私有方法,這屬于靜态連結,而在棧幀中完成的符号引用替換為直接引用稱之為動态連結。靜态連結在Java類加載過程一文中有詳細介紹。

        操作數棧: 操作數棧也是一個後進先出的資料結構,一個指令往操作數棧壓入資料,另一個指令就可以從操作數棧彈出資料。虛拟機将操作數棧作為工作區,我個人認為,程式的執行是由操作數棧運作的,比如做一個運算,需要兩個變量,首先将第一個變量壓入操作數棧,然後壓入第二個,然後操作數棧将兩個變量推出,給到cpu的寄存器去運算,得到結果入操作數棧,然後再把結果推出,賦到局部變量表,完成運算過程,然後程式繼續向下執行,如此反複。

        傳回位址: 實際上就是方法的出口,本質上就是指向一個位址,該位址就是調用該方法的對象,在程式執行完方法後,需要知道接下來該繼續執行哪裡,在執行過程中,程式計數器也一直在記錄着指令運作到哪裡

4、方法區/中繼資料區

        在HotSpot JVM中,設計者将方法區歸到了GC的分代收集,是以方法區就有了永久代這麼一個說法。

        方法區是各個線程共享的記憶體區域,主要存儲的是類的元資訊(字段、方法、接口)、靜态變量、常量、運作時常量池,可以了解為類被編譯的資料加載到記憶體後的存儲位置。需要注意的是,在JDK1.8取消了方法區,用元空間取代了方法區,元空間位于實體記憶體,其JVM配置參數有如下變化:

參數 含義
-XX:PermSize 設定永久代初始大小,預設為實體記憶體的1/64,為JDK1.8之前的參數
-XX:MaxPermSize 設定永久代最大容量,預設為實體記憶體1/4,為JDK1.8之前的參數
-XX:MetaspaceSize 設定中繼資料區的初始容量,預設值是20.74MB,中繼資料區位于實體記憶體,取代了永久代,為JDK1.8及之後的參數
-XX:MaxMetaspaceSize 設定中繼資料區的最大容量,預設為無窮大,取決于實體記憶體大小,為JDK1.8及以後的參數

4.1 常量池

常量池有三類,分别是類常量池、字元串常量池、運作時常量池:

        類常量池: 用于存儲類的符号引用與常量,JDK1.8位于堆中

        字元串常量池: 用于存儲字元串對象的符号引用與字元串常量,在JDK1.7時,将字元串常量池從方法區移除,在堆中開辟了一塊空間作為字元串常量池。

        運作時常量池: 用于存儲類的元資訊,編譯後的代碼資料以及類常量池中符号引用相對應的直接引用。JDK1.8常量池位于元空間,即實體記憶體。

常量池的作用

        常量池不僅避免了頻繁的建立和銷毀對象而影響系統性能,而且還實作了對象的共享,說到這個就不得不提到基本資料類型的自動拆箱與自動裝箱了,

Integer緩存/Integer常量池

Integer i1 = 1;
Integer i2 = 1;
System.out.println(i1==i2)
           

請問i1與i2相等嗎?答案是相等的。

        我們都知道Java數值整型預設是int類型,那麼int類型如何指派給Integer呢?實際上i1=1有一步裝箱操作,即将int類型的1封裝成Integer類型。Integer設計是這樣的,如果所給的值在-128到127,那麼會直接傳回常量池中的值,即此處的1為常量池中的Integer類型的1,否則重新new一個對象,是以i1與i2指向的實際上是一個引用,是以傳回true。

再看下面代碼:

Integer i1 = new Integer(2);
Integer i2 = new Integer(3);
Integer i3 = new Integer(5);
System.out.print(i3 == i1+i2);
           

        結果也為true,其實Integer類型是不能直接相加的,需要先進行拆箱,拆成int類型進行計算,所得的值如果在[-128,127],也會直接傳回常量池的内容,那上述代碼就不難了解,首先Integer類型的2、3先拆箱成int類型,然後進行加法,所得結果在常量池存在,那麼直接傳回Integer類型的5,i3也是常量池的引用,是以i3==i1+i2

String常量池

        在編譯期就能确定的字元串,則被認為是直接量/常量,會存放到常量池,如果第二次再用到直接從字元串常量池取即可,舉例說明

        首先String有兩種建立方式,一個是new對象,直接在堆中開辟空間,另一種是直接指派,這種方式會先去字元串常量池檢查有無,如果有,直接拿來用,如果沒有,則把值放進去

String s1 = "a";
String s2 = "a";
           

        在建立s1時,會去常量池檢查有沒有a字元串,此時沒有,是以将a放進去,在建立s2時,去常量池檢查,發現有a,則直接指向a,是以s1與s2指向同一個引用,它們相等。

拓展: String是比較常用的類,Java對字元串是有優化的

        優化1:

String s = "ab"
String f = "a" + "b"
           

        在虛拟機中會将上述字元串相加優化,虛拟機認為你這種相加無意義,f直接等于"ab",且s==f。

        優化2:

final String str1 = "a";
final String str2 = "b";
String stri = "ab";
String str3 = str1 + str2;
System.out.println(str3 == stri); // true
           

        str1與str2都由final修飾,而且在定義的時候也沒有什麼可變因素,隻是單純的指派,也可以單純的直接字元串相加(“a”+“b”),這種被叫做直接量,也就是常量,而str3,有兩個直接量相加,那麼str3就能在編譯期确定值,且str3與stri指向同一個引用。

好了,這篇文章就介紹到這裡了,最後,給大家分享一個面試經曆,面試官問我,String為什麼會被設計成final的呢?大家可以結合字元串常量考慮考慮O(∩_∩)O

繼續閱讀