天天看點

JVM基礎面試題及原理講解

本文從 JVM 結構入手,介紹了 Java 記憶體管理、對象建立、常量池等基礎知識,對面試中 JVM 相關的基礎題目進行了講解。

寫在前面(常見面試題)

基本問題

  • 介紹下 Java 記憶體區域(運作時資料區)
  • Java 對象的建立過程(五步,建議能默寫出來并且要知道每一步虛拟機做了什麼)
  • 對象的通路定位的兩種方式(句柄和直接指針兩種方式)

拓展問題

  • String類和常量池
  • 8種基本類型的包裝類和常量池

1 概述

對于 Java 程式員來說,在虛拟機自動記憶體管理機制下,不再需要像C/C++程式開發程式員這樣為内一個 new 操作去寫對應的 delete/free 操作,不容易出現記憶體洩漏和記憶體溢出問題。正是因為 Java 程式員把記憶體控制權利交給 Java 虛拟機,一旦出現記憶體洩漏和溢出方面的問題,如果不了解虛拟機是怎樣使用記憶體的,那麼排查錯誤将會是一個非常艱巨的任務。

2 運作時資料區域

Java 虛拟機在執行 Java 程式的過程中會把它管理的記憶體劃分成若幹個不同的資料區域。

http://www.importnew.com/31126.html/jvm-1

這些組成部分一些是線程私有的,其他的則是線程共享的。

線程私有的:

  • 程式計數器
  • 虛拟機棧
  • 本地方法棧

線程共享的:

  • 方法區
  • 直接記憶體

2.1 程式計數器

程式計數器是一塊較小的記憶體空間,可以看作是目前線程所執行的位元組碼的行号訓示器。位元組碼解釋器工作時通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、循環、跳轉、異常處理、線程恢複等功能都需要依賴這個計數器來完。

另外,為了線程切換後能恢複到正确的執行位置,每條線程都需要有一個獨立的程式計數器,各線程之間計數器互不影響,獨立存儲,我們稱這類記憶體區域為“線程私有”的記憶體。

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

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

注意:程式計數器是唯不會出現 OutOfMemoryError 的記憶體區域,它的生命周期随着線程的建立而建立,随着線程的結束而死亡。

2.2 Java 虛拟機棧

與程式計數器一樣,Java虛拟機棧也是線程私有的,它的生命周期和線程相同,描述的是 Java 方法執行的記憶體模型。

Java 記憶體可以粗糙的區分為堆記憶體(Heap)和棧記憶體(Stack)其中棧就是現在說的虛拟機棧,或者說是虛拟機棧中局部變量表部分。 (實際上,Java虛拟機棧是由一個個棧幀組成,而每個棧幀中都擁有局部變量表、操作數棧、動态連結、方法出口資訊)

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

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

  • StackOverFlowError: 若Java虛拟機棧的記憶體大小不允許動态擴充,那麼當線程請求棧的深度超過目前Java虛拟機棧的最大深度的時候,就抛出StackOverFlowError異常。
  • OutOfMemoryError: 若 Java 虛拟機棧的記憶體大小允許動态擴充,且當線程請求棧時記憶體用完了,無法再動态擴充了,此時抛出OutOfMemoryError異常。

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

2.3 本地方法棧

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

本地方法被執行的時候,在本地方法棧也會建立一個棧幀,用于存放該本地方法的局部變量表、操作數棧、動态連結、出口資訊。

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

2.4 堆

Java 虛拟機所管理的記憶體中最大的一塊,Java 堆是所有線程共享的一塊記憶體區域,在虛拟機啟動時建立。此記憶體區域的唯一目的就是存放對象執行個體,幾乎所有的對象執行個體以及數組都在這裡配置設定記憶體。

Java 堆是垃圾收集器管理的主要區域,是以也被稱作GC堆(Garbage Collected Heap).從垃圾回收的角度,由于現在收集器基本都采用分代垃圾收集算法,是以Java堆還可以細分為:新生代和老年代:再細緻一點有:Eden空間、From Survivor、To Survivor空間等。進一步劃分的目的是更好地回收記憶體,或者更快地配置設定記憶體。

http://www.importnew.com/31126.html/jvm-2-2

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

推薦閱讀:

Java8記憶體模型——永久代(PermGen)和元空間(Metaspace)

2.5 方法區

方法區與 Java 堆一樣,是各個線程共享的記憶體區域,它用于存儲已被虛拟機加載的類資訊、常量、靜态變量、即時編譯器編譯後的代碼等資料。雖然Java虛拟機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個别名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

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

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

2.6 運作時常量池

運作時常量池是方法區的一部分。Class 檔案中除了有類的版本、字段、方法、接口等描述資訊外,還有常量池資訊(用于存放編譯期生成的各種字面量和符号引用)

既然運作時常量池時方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會抛出 OutOfMemoryError 異常。

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

http://www.importnew.com/31126.html/jvm-3-2 Java 中幾種常量池的區分

2.7 直接記憶體

直接記憶體并不是虛拟機運作時資料區的一部分,也不是虛拟機規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用。而且也可能導緻OutOfMemoryError異常出現。

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

本機直接記憶體的配置設定不會收到 Java 堆的限制,但是,既然是記憶體就會受到本機總記憶體大小以及處理器尋址空間的限制。

3 HotSpot 虛拟機對象探秘

通過上面的介紹我們大概知道了虛拟機的記憶體情況,下面我們來詳細的了解一下 HotSpot 虛拟機在 Java 堆中對象配置設定、布局和通路的全過程。

3.1 對象的建立

下圖便是 Java 對象的建立過程,我建議最好是能默寫出來,并且要掌握每一步在做什麼。

http://www.importnew.com/31126.html/jvm-4

Java建立對象過程

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

2. 配置設定記憶體: 在類加載檢查通過後,接下來虛拟機将為新生對象配置設定記憶體。對象所需的記憶體大小在類加載完成後便可确定,為對象配置設定空間的任務等同于把一塊确定大小的記憶體從 Java 堆中劃分出來。配置設定方式有 “指針碰撞” 和 “空閑清單” 兩種,選擇那種配置設定方式由 Java 堆是否規整決定,而Java堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。

記憶體配置設定的兩種方式:(補充内容,需要掌握)

選擇以上兩種方式中的哪一種,取決于 Java 堆記憶體是否規整。而 Java 堆記憶體是否規整,取決于 GC 收集器的算法是”标記-清除”,還是”标記-整理”(也稱作”标記-壓縮”),值得注意的是,複制算法記憶體也是規整的。

http://www.importnew.com/31126.html/jvm-5

記憶體配置設定并發問題(補充内容,需要掌握)

在建立對象的時候有一個很重要的問題,就是線程安全,因為在實際開發過程中,建立對象是很頻繁的事情,作為虛拟機來說,必須要保證線程是安全的,通常來講,虛拟機采用兩種方式來保證線程安全:

  • CAS+失敗重試: CAS 是樂觀鎖的一種實作方式。所謂樂觀鎖就是,每次不加鎖而是假設沒有沖突而去完成某項操作,如果因為沖突失敗就重試,直到成功為止。虛拟機采用 CAS 配上失敗重試的方式保證更新操作的原子性。
  • TLAB: 為每一個線程預先在 Eden 區配置設定一塊記憶體。JVM 在給線程中的對象配置設定記憶體時,首先在 TLAB 配置設定,當對象大于TLAB 中的剩餘記憶體或 TLAB 的記憶體已用盡時,再采用上述的 CAS 進行記憶體配置設定。

3. 初始化零值: 記憶體配置設定完成後,虛拟機需要将配置設定到的記憶體空間都初始化為零值(不包括對象頭),這一步操作保證了對象的執行個體字段在 Java 代碼中可以不賦初始值就直接使用,程式能通路到這些字段的資料類型所對應的零值。

4. 設定對象頭: 初始化零值完成之後,虛拟機要對對象進行必要的設定,例如這個對象是那個類的執行個體、如何才能找到類的中繼資料資訊、對象的哈希嗎、對象的 GC 分代年齡等資訊。 這些資訊存放在對象頭中。 另外,根據虛拟機目前運作狀态的不同,如是否啟用偏向鎖等,對象頭會有不同的設定方式。

5. 執行 init 方法: 在上面工作都完成之後,從虛拟機的視角來看,一個新的對象已經産生了,但從 Java 程式的視角來看,對象建立才剛開始,

<init>

 方法還沒有執行,所有的字段都還為零。是以一般來說,執行 new 指令之後會接着執行 

<init>

 方法,把對象按照程式員的意願進行初始化,這樣一個真正可用的對象才算完全産生出來。

3.2 對象的記憶體布局

在 Hotspot 虛拟機中,對象在記憶體中的布局可以分為3塊區域:對象頭、執行個體資料和對齊填充。

Hotspot虛拟機的對象頭包括兩部分資訊,第一部分用于存儲對象自身的自身運作時資料(哈希碼、GC分代年齡、鎖狀态标志等等),另一部分是類型指針,即對象指向它的類中繼資料的指針,虛拟機通過這個指針來确定這個對象是那個類的執行個體。

執行個體資料部分是對象真正存儲的有效資訊,也是在程式中所定義的各種類型的字段内容。

對齊填充部分不是必然存在的,也沒有什麼特别的含義,僅僅起占位作用。 因為 Hotspot 虛拟機的自動記憶體管理系統要求對象起始位址必須是8位元組的整數倍,換句話說就是對象的大小必須是8位元組的整數倍。而對象頭部分正好是8位元組的倍數(1倍或2倍),是以,當對象執行個體資料部分沒有對齊時,就需要通過對齊填充來補全。

3.3 對象的通路定位

建立對象就是為了使用對象,我們的Java程式通過棧上的 reference 資料來操作堆上的具體對象。對象的通路方式有虛拟機實作而定,目前主流的通路方式有使用句柄和直接指針兩種:

1. 句柄: 如果使用句柄的話,那麼 Java 堆中将會劃分出一塊記憶體來作為句柄池,reference 中存儲的就是對象的句柄位址,而句柄中包含了對象執行個體資料與類型資料各自的具體位址資訊。

http://www.importnew.com/31126.html/jvm-6

通過句柄通路對象

2. 直接指針: 如果使用直接指針通路,那麼 Java 堆對象的布局中就必須考慮如何放置通路類型資料的相關資訊,而 reference 中存儲的直接就是對象的位址。

http://www.importnew.com/31126.html/jvm-7

通過直接指針通路對象

這兩種對象通路方式各有優勢。使用句柄來通路的最大好處是 reference 中存儲的是穩定的句柄位址,在對象被移動時隻會改變句柄中的執行個體資料指針,而 reference 本身不需要修改。使用直接指針通路方式最大的好處就是速度快,它節省了一次指針定位的時間開銷。

4 重點補充内容

4.1 String 類和常量池

1 String 對象的兩種建立方式

String str1 =

"abcd"

;

String str2 =

new

String(

"abcd"

);

System.out.println(str1==str2);

//false

這兩種不同的建立方法是有差别的,第一種方式是在常量池中拿對象,第二種方式是直接在堆記憶體空間建立一個新的對象。

http://www.importnew.com/31126.html/jvm-8

記住:隻要使用 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

http://www.importnew.com/31126.html/jvm-9

盡量避免多個字元串拼接,因為這樣會重新建立對象。如果需要改變字元串的話,可以使用 StringBuilder 或者 StringBuffer。

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

結果:

false

true

解釋:

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

4.2 8種基本類型的包裝類和常量池

  • 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 i1 =

40

;

Integer i2 =

new

Integer(

40

);

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

//輸出false

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

Integer i1 =

40

;

Integer i2 =

40

;

Integer i3 =

;

Integer i4 =

new

Integer(

40

);

Integer i5 =

new

Integer(

40

);

Integer i6 =

new

Integer(

);

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進行數值比較。