關于 JVM,都有哪些面試最常考的點需要重點掌握?

個人 GitHub:https://github.com/yanglbme

這里僅僅記錄了一些筆者認為需要重點掌握的 JVM 知識點,如果你想更加全面地了解 JVM 底層原理,可以閱讀周志明老師《深入理解Java虛擬機——JVM高級特性與最佳實踐(第2版)》全書。

一、JVM 內存結構

Java 虛擬機的內存空間分為 5 個部分:

  • 程序計數器
  • Java 虛擬機棧
  • 本地方法棧
  • 方法區
image

JDK 1.8 同 JDK 1.7 比,最大的差別就是:元數據區取代了永久代。元空間的本質和永久代類似,都是對 JVM 規范中方法區的實現。不過元空間與永久代之間最大的區別在于:元數據空間并不在虛擬機中,而是使用本地內存。

1. 程序計數器(PC 寄存器)

程序計數器的定義

程序計數器是一塊較小的內存空間,是當前線程正在執行的那條字節碼指令的地址。若當前線程正在執行的是一個本地方法,那么此時程序計數器為Undefined

程序計數器的作用

  • 字節碼解釋器通過改變程序計數器來依次讀取指令,從而實現代碼的流程控制。
  • 在多線程情況下,程序計數器記錄的是當前線程執行的位置,從而當線程切換回來時,就知道上次線程執行到哪了。

程序計數器的特點

  • 是一塊較小的內存空間。
  • 線程私有,每條線程都有自己的程序計數器。
  • 生命周期:隨著線程的創建而創建,隨著線程的結束而銷毀。
  • 是唯一一個不會出現OutOfMemoryError的內存區域。

2. Java 虛擬機棧(Java 棧)

Java 虛擬機棧的定義

Java 虛擬機棧是描述 Java 方法運行過程的內存模型。

Java 虛擬機棧會為每一個即將運行的 Java 方法創建一塊叫做“棧幀”的區域,用于存放該方法運行過程中的一些信息,如:

  • 局部變量表
  • 操作數棧
  • 動態鏈接
  • 方法出口信息
  • ......
image

壓棧出棧過程

當方法運行過程中需要創建局部變量時,就將局部變量的值存入棧幀中的局部變量表中。

Java 虛擬機棧的棧頂的棧幀是當前正在執行的活動棧,也就是當前正在執行的方法,PC 寄存器也會指向這個地址。只有這個活動的棧幀的本地變量可以被操作數棧使用,當在這個棧幀中調用另一個方法,與之對應的棧幀又會被創建,新創建的棧幀壓入棧頂,變為當前的活動棧幀。

方法結束后,當前棧幀被移出,棧幀的返回值變成新的活動棧幀中操作數棧的一個操作數。如果沒有返回值,那么新的活動棧幀中操作數棧的操作數沒有變化。

由于Java 虛擬機棧是與線程對應的,數據不是線程共享的,因此不用關心數據一致性問題,也不會存在同步鎖的問題。

Java 虛擬機棧的特點

  • 局部變量表隨著棧幀的創建而創建,它的大小在編譯時確定,創建時只需分配事先規定的大小即可。在方法運行過程中,局部變量表的大小不會發生改變。
  • Java 虛擬機棧會出現兩種異常:StackOverFlowError 和 OutOfMemoryError。
    • StackOverFlowError 若 Java 虛擬機棧的大小不允許動態擴展,那么當線程請求棧的深度超過當前 Java 虛擬機棧的最大深度時,拋出 StackOverFlowError 異常。
    • OutOfMemoryError 若允許動態擴展,那么當線程請求棧時內存用完了,無法再動態擴展時,拋出 OutOfMemoryError 異常。
  • Java 虛擬機棧也是線程私有,隨著線程創建而創建,隨著線程的結束而銷毀。

出現 StackOverFlowError 時,內存空間可能還有很多。

3. 本地方法棧(C 棧)

本地方法棧的定義

本地方法棧是為 JVM 運行 Native 方法準備的空間,由于很多 Native 方法都是用 C 語言實現的,所以它通常又叫 C 棧。它與 Java 虛擬機棧實現的功能類似,只不過本地方法棧是描述本地方法運行過程的內存模型。

棧幀變化過程

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

方法執行結束后,相應的棧幀也會出棧,并釋放內存空間。也會拋出 StackOverFlowError 和 OutOfMemoryError 異常。

如果 Java 虛擬機本身不支持 Native 方法,或是本身不依賴于傳統棧,那么可以不提供本地方法棧。如果支持本地方法棧,那么這個棧一般會在線程創建的時候按線程分配。

4. 堆

堆的定義

堆是用來存放對象的內存空間,幾乎所有的對象都存儲在堆中。

堆的特點

  • 線程共享,整個 Java 虛擬機只有一個堆,所有的線程都訪問同一個堆。而程序計數器、Java 虛擬機棧、本地方法棧都是一個線程對應一個。
  • 在虛擬機啟動時創建。
  • 是垃圾回收的主要場所。
  • 進一步可分為:新生代(Eden區 From Survior To Survivor)、老年代。

不同的區域存放不同生命周期的對象,這樣可以根據不同的區域使用不同的垃圾回收算法,更具有針對性。

堆的大小既可以固定也可以擴展,但對于主流的虛擬機,堆的大小是可擴展的,因此當線程請求分配內存,但堆已滿,且內存已無法再擴展時,就拋出 OutOfMemoryError 異常。

Java 堆所使用的內存不需要保證是連續的。而由于堆是被所有線程共享的,所以對它的訪問需要注意同步問題,方法和對應的屬性都需要保證一致性。

5. 方法區

方法區的定義

Java 虛擬機規范中定義方法區是堆的一個邏輯部分。方法區存放以下信息:

  • 已經被虛擬機加載的類信息
  • 常量
  • 靜態變量
  • 即時編譯器編譯后的代碼

方法區的特點

  • 線程共享。 方法區是堆的一個邏輯部分,因此和堆一樣,都是線程共享的。整個虛擬機中只有一個方法區。
  • 永久代。 方法區中的信息一般需要長期存在,而且它又是堆的邏輯分區,因此用堆的劃分方法,把方法區稱為“永久代”。
  • 內存回收效率低。 方法區中的信息一般需要長期存在,回收一遍之后可能只有少量信息無效。主要回收目標是:對常量池的回收;對類型的卸載。
  • Java 虛擬機規范對方法區的要求比較寬松。 和堆一樣,允許固定大小,也允許動態擴展,還允許不實現垃圾回收。

運行時常量池

方法區中存放:類信息、常量、靜態變量、即時編譯器編譯后的代碼。常量就存放在運行時常量池中。

當類被 Java 虛擬機加載后, .class 文件中的常量就存放在方法區的運行時常量池中。而且在運行期間,可以向常量池中添加新的常量。如 String 類的 intern() 方法就能在運行期間向常量池中添加字符串常量。

6. 直接內存(堆外內存)

直接內存是除 Java 虛擬機之外的內存,但也可能被 Java 使用。

操作直接內存

在 NIO 中引入了一種基于通道和緩沖的 IO 方式。它可以通過調用本地方法直接分配 Java 虛擬機之外的內存,然后通過一個存儲在堆中的DirectByteBuffer對象直接操作該內存,而無須先將外部內存中的數據復制到堆中再進行操作,從而提高了數據操作的效率。

直接內存的大小不受 Java 虛擬機控制,但既然是內存,當內存不足時就會拋出 OutOfMemoryError 異常。

直接內存與堆內存比較

  • 直接內存申請空間耗費更高的性能
  • 直接內存讀取 IO 的性能要優于普通的堆內存。
  • 直接內存作用鏈: 本地 IO -> 直接內存 -> 本地 IO
  • 堆內存作用鏈:本地 IO -> 直接內存 -> 非直接內存 -> 直接內存 -> 本地 IO

服務器管理員在配置虛擬機參數時,會根據實際內存設置-Xmx等參數信息,但經常忽略直接內存,使得各個內存區域總和大于物理內存限制,從而導致動態擴展時出現OutOfMemoryError異常。

二、HotSpot 虛擬機對象探秘

1. 對象的內存布局

在 HotSpot 虛擬機中,對象的內存布局分為以下 3 塊區域:

  • 對象頭(Header)
  • 實例數據(Instance Data)
  • 對齊填充(Padding)
image

對象頭

對象頭記錄了對象在運行過程中所需要使用的一些數據:

  • 哈希碼
  • GC 分代年齡
  • 鎖狀態標志
  • 線程持有的鎖
  • 偏向線程 ID
  • 偏向時間戳

對象頭可能包含類型指針,通過該指針能確定對象屬于哪個類。如果對象是一個數組,那么對象頭還會包括數組長度。

實例數據

實例數據部分就是成員變量的值,其中包括父類成員變量和本類成員變量。

對齊填充

用于確保對象的總長度為 8 字節的整數倍。

HotSpot VM 的自動內存管理系統要求對象的大小必須是 8 字節的整數倍。而對象頭部分正好是 8 字節的倍數(1 倍或 2 倍),因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。

對齊填充并不是必然存在,也沒有特別的含義,它僅僅起著占位符的作用。

2. 對象的創建過程

類加載檢查

虛擬機在解析.class文件時,若遇到一條 new 指令,首先它會去檢查常量池中是否有這個類的符號引用,并且檢查這個符號引用所代表的類是否已被加載、解析和初始化過。如果沒有,那么必須先執行相應的類加載過程。

為新生對象分配內存

對象所需內存的大小在類加載完成后便可完全確定,接下來從堆中劃分一塊對應大小的內存空間給新的對象。分配堆中內存有兩種方式:

  • 指針碰撞
    如果 Java 堆中內存絕對規整(說明采用的是“復制算法”或“標記整理法”),空閑內存和已使用內存中間放著一個指針作為分界點指示器,那么分配內存時只需要把指針向空閑內存挪動一段與對象大小一樣的距離,這種分配方式稱為“指針碰撞”。

  • 空閑列表
    如果 Java 堆中內存并不規整,已使用的內存和空閑內存交錯(說明采用的是標記-清除法,有碎片),此時沒法簡單進行指針碰撞, VM 必須維護一個列表,記錄其中哪些內存塊空閑可用。分配之時從空閑列表中找到一塊足夠大的內存空間劃分給對象實例。這種方式稱為“空閑列表”。

初始化

分配完內存后,為對象中的成員變量賦上初始值,設置對象頭信息,調用對象的構造函數方法進行初始化。

至此,整個對象的創建過程就完成了。

3. 對象的訪問方式

所有對象的存儲空間都是在堆中分配的,但是這個對象的引用卻是在堆棧中分配的。也就是說在建立一個對象時兩個地方都分配內存,在堆中分配的內存實際建立這個對象,而在堆棧中分配的內存只是一個指向這個堆對象的指針(引用)而已。 那么根據引用存放的地址類型的不同,對象有不同的訪問方式。

句柄訪問方式

堆中需要有一塊叫做“句柄池”的內存空間,句柄中包含了對象實例數據與類型數據各自的具體地址信息。

引用類型的變量存放的是該對象的句柄地址(reference)。訪問對象時,首先需要通過引用類型的變量找到該對象的句柄,然后根據句柄中對象的地址找到對象。

image

直接指針訪問方式

引用類型的變量直接存放對象的地址,從而不需要句柄池,通過引用能夠直接訪問對象。但對象所在的內存空間需要額外的策略存儲對象所屬的類信息的地址。

image

需要說明的是,HotSpot 采用第二種方式,即直接指針方式來訪問對象,只需要一次尋址操作,所以在性能上比句柄訪問方式快一倍。但像上面所說,它需要額外的策略來存儲對象在方法區中類信息的地址。

三、垃圾收集策略與算法

程序計數器、虛擬機棧、本地方法棧隨線程而生,也隨線程而滅;棧幀隨著方法的開始而入棧,隨著方法的結束而出棧。這幾個區域的內存分配和回收都具有確定性,在這幾個區域內不需要過多考慮回收的問題,因為方法結束或者線程結束時,內存自然就跟隨著回收了。

而對于 Java 堆和方法區,我們只有在程序運行期間才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,垃圾收集器所關注的正是這部分內存。

1. 判定對象是否存活

若一個對象不被任何對象或變量引用,那么它就是無效對象,需要被回收。

引用計數法

在對象頭維護著一個 counter 計數器,對象被引用一次則計數器 +1;若引用失效則計數器 -1。當計數器為 0 時,就認為該對象無效了。

引用計數算法的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的算法。但是主流的 Java 虛擬機里沒有選用引用計數算法來管理內存,主要是因為它很難解決對象之間循環引用的問題。

舉個栗子??對象 objA 和 objB 都有字段 instance,令 objA.instance = objB 并且 objB.instance = objA,由于它們互相引用著對方,導致它們的引用計數都不為 0,于是引用計數算法無法通知 GC 收集器回收它們。

可達性分析法

所有和 GC Roots 直接或間接關聯的對象都是有效對象,和 GC Roots 沒有關聯的對象就是無效對象。

GC Roots 是指:

  • Java 虛擬機棧(棧幀中的本地變量表)中引用的對象
  • 本地方法棧中引用的對象
  • 方法區中常量引用的對象
  • 方法區中類靜態屬性引用的對象

GC Roots 并不包括堆中對象所引用的對象,這樣就不會有循環引用的問題。

2. 引用的種類

判定對象是否存活與“引用”有關。在 JDK 1.2 以前,Java 中的引用定義很傳統,一個對象只有被引用或者沒有被引用兩種狀態,我們希望能描述這一類對象:當內存空間還足夠時,則保留在內存中;如果內存空間在進行垃圾手收集后還是非常緊張,則可以拋棄這些對象。很多系統的緩存功能都符合這樣的應用場景。

在 JDK 1.2 之后,Java 對引用的概念進行了擴充,將引用分為了以下四種。不同的引用類型,主要體現的是對象不同的可達性狀態reachable和垃圾收集的影響。

強引用(Strong Reference)

類似 "Object obj = new Object()" 這類的引用,就是強引用,只要強引用存在,垃圾收集器永遠不會回收被引用的對象。但是,如果我們錯誤地保持了強引用,比如:賦值給了 static 變量,那么對象在很長一段時間內不會被回收,會產生內存泄漏。

軟引用(Soft Reference)

軟引用是一種相對強引用弱化一些的引用,可以讓對象豁免一些垃圾收集,只有當 JVM 認為內存不足時,才會去試圖回收軟引用指向的對象。JVM 會確保在拋出 OutOfMemoryError 之前,清理軟引用指向的對象。軟引用通常用來實現內存敏感的緩存,如果還有空閑內存,就可以暫時保留緩存,當內存不足時清理掉,這樣就保證了使用緩存的同時,不會耗盡內存。

弱引用(Weak Reference)

弱引用的強度比軟引用更弱一些。當 JVM 進行垃圾回收時,無論內存是否充足,都會回收只被弱引用關聯的對象。

虛引用(Phantom Reference)

虛引用也稱幽靈引用或者幻影引用,它是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響。它僅僅是提供了一種確保對象被 finalize 以后,做某些事情的機制,比如,通常用來做所謂的 Post-Mortem 清理機制。

3. 回收堆中無效對象

對于可達性分析中不可達的對象,也并不是沒有存活的可能。

判定 finalize() 是否有必要執行

JVM 會判斷此對象是否有必要執行 finalize() 方法,如果對象沒有覆蓋 finalize() 方法,或者 finalize() 方法已經被虛擬機調用過,那么視為“沒有必要執行”。那么對象基本上就真的被回收了。

如果對象被判定為有必要執行 finalize() 方法,那么對象會被放入一個 F-Queue 隊列中,虛擬機會以較低的優先級執行這些 finalize()方法,但不會確保所有的 finalize() 方法都會執行結束。如果 finalize() 方法出現耗時操作,虛擬機就直接停止指向該方法,將對象清除。

對象重生或死亡

如果在執行 finalize() 方法時,將 this 賦給了某一個引用,那么該對象就重生了。如果沒有,那么就會被垃圾收集器清除。

任何一個對象的 finalize() 方法只會被系統自動調用一次,如果對象面臨下一次回收,它的 finalize() 方法不會被再次執行,想繼續在 finalize() 中自救就失效了。

4. 回收方法區內存

方法區中存放生命周期較長的類信息、常量、靜態變量,每次垃圾收集只有少量的垃圾被清除。方法區中主要清除兩種垃圾:

  • 廢棄常量
  • 無用的類

判定廢棄常量

只要常量池中的常量不被任何變量或對象引用,那么這些常量就會被清除掉。比如,一個字符串 "bingo" 進入了常量池,但是當前系統沒有任何一個 String 對象引用常量池中的 "bingo" 常量,也沒有其它地方引用這個字面量,必要的話,"bingo"常量會被清理出常量池。

判定無用的類

判定一個類是否是“無用的類”,條件較為苛刻。

  • 該類的所有對象都已經被清除
  • 加載該類的 ClassLoader 已經被回收
  • 該類的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

一個類被虛擬機加載進方法區,那么在堆中就會有一個代表該類的對象:java.lang.Class。這個對象在類被加載進方法區時創建,在方法區該類被刪除時清除。

5. 垃圾收集算法

學會了如何判定無效對象、無用類、廢棄常量之后,剩余工作就是回收這些垃圾。常見的垃圾收集算法有以下幾個:

標記-清除算法

標記的過程是:遍歷所有的 GC Roots,然后將所有 GC Roots 可達的對象標記為存活的對象

清除的過程將遍歷堆中所有的對象,將沒有標記的對象全部清除掉。與此同時,清除那些被標記過的對象的標記,以便下次的垃圾回收。

這種方法有兩個不足

  • 效率問題:標記和清除兩個過程的效率都不高。
  • 空間問題:標記清除之后會產生大量不連續的內存碎片,碎片太多可能導致以后需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。

復制算法(新生代)

為了解決效率問題,“復制”收集算法出現了。它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊內存用完,需要進行垃圾收集時,就將存活者的對象復制到另一塊上面,然后將第一塊內存全部清除。這種算法有優有劣:

  • 優點:不會有內存碎片的問題。
  • 缺點:內存縮小為原來的一半,浪費空間。

為了解決空間利用率問題,可以將內存分為三塊: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一塊 Survivor。回收時,將 Eden 和 Survivor 中還存活的對象一次性復制到另外一塊 Survivor 空間上,最后清理掉 Eden 和剛才使用的 Survivor 空間。這樣只有 10% 的內存被浪費。

但是我們無法保證每次回收都只有不多于 10% 的對象存活,當 Survivor 空間不夠,需要依賴其他內存(指老年代)進行分配擔保。

分配擔保

為對象分配內存空間時,如果 Eden+Survivor 中空閑區域無法裝下該對象,會觸發 MinorGC 進行垃圾收集。但如果 Minor GC 過后依然有超過 10% 的對象存活,這樣存活的對象直接通過分配擔保機制進入老年代,然后再將新對象存入 Eden 區。

標記-整理算法(老年代)

標記:它的第一個階段與標記/清除算法是一模一樣的,均是遍歷 GC Roots,然后將存活的對象標記。

整理:移動所有存活的對象,且按照內存地址次序依次排列,然后將末端內存地址以后的內存全部回收。因此,第二階段才稱為整理階段。

這是一種老年代的垃圾收集算法。老年代的對象一般壽命比較長,因此每次垃圾回收會有大量對象存活,如果采用復制算法,每次需要復制大量存活的對象,效率很低。

分代收集算法

根據對象存活周期的不同,將內存劃分為幾塊。一般是把 Java 堆分為新生代和老年代,針對各個年代的特點采用最適當的收集算法。

  • 新生代:復制算法
  • 老年代:標記-清除算法、標記-整理算法

四、HotSpot 垃圾收集器

HotSpot 虛擬機提供了多種垃圾收集器,每種收集器都有各自的特點,雖然我們要對各個收集器進行比較,但并非為了挑選出一個最好的收集器。我們選擇的只是對具體應用最合適的收集器。

1. 新生代垃圾收集器

Serial 垃圾收集器(單線程)

只開啟一條 GC 線程進行垃圾回收,并且在垃圾收集過程中停止一切用戶線程(Stop The World)。

一般客戶端應用所需內存較小,不會創建太多對象,而且堆內存不大,因此垃圾收集器回收時間短,即使在這段時間停止一切用戶線程,也不會感覺明顯卡頓。因此 Serial 垃圾收集器適合客戶端使用。

由于 Serial 收集器只使用一條 GC 線程,避免了線程切換的開銷,從而簡單高效。

image

ParNew 垃圾收集器(多線程)

ParNew 是 Serial 的多線程版本。由多條 GC 線程并行地進行垃圾清理。但清理過程依然需要 Stop The World。

ParNew 追求“低停頓時間”,與 Serial 唯一區別就是使用了多線程進行垃圾收集,在多 CPU 環境下性能比 Serial 會有一定程度的提升;但線程切換需要額外的開銷,因此在單 CPU 環境中表現不如 Serial。

image

Parallel Scavenge 垃圾收集器(多線程)

Parallel Scavenge 和 ParNew 一樣,都是多線程、新生代垃圾收集器。但是兩者有巨大的不同點:

  • Parallel Scavenge:追求 CPU 吞吐量,能夠在較短時間內完成指定任務,因此適合沒有交互的后臺計算。
  • ParNew:追求降低用戶停頓時間,適合交互式應用。

吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)

追求高吞吐量,可以通過減少 GC 執行實際工作的時間,然而,僅僅偶爾運行 GC 意味著每當 GC 運行時將有許多工作要做,因為在此期間積累在堆中的對象數量很高。單個 GC 需要花更多的時間來完成,從而導致更高的暫停時間。而考慮到低暫停時間,最好頻繁運行 GC 以便更快速完成,反過來又導致吞吐量下降。

  • 通過參數 -XX:GCTimeRadio 設置垃圾回收時間占總 CPU 時間的百分比。
  • 通過參數 -XX:MaxGCPauseMillis 設置垃圾處理過程最久停頓時間。
  • 通過命令 -XX:+UseAdaptiveSizePolicy 開啟自適應策略。我們只要設置好堆的大小和 MaxGCPauseMillis 或 GCTimeRadio,收集器會自動調整新生代的大小、Eden 和 Survivor 的比例、對象進入老年代的年齡,以最大程度上接近我們設置的 MaxGCPauseMillis 或 GCTimeRadio。

2. 老年代垃圾收集器

Serial Old 垃圾收集器(單線程)

Serial Old 收集器是 Serial 的老年代版本,都是單線程收集器,只啟用一條 GC 線程,都適合客戶端應用。它們唯一的區別就是:Serial Old 工作在老年代,使用“標記-整理”算法;Serial 工作在新生代,使用“復制”算法。

Parallel Old 垃圾收集器(多線程)

Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。

CMS 垃圾收集器

CMS(Concurrent Mark Sweep,并發標記清除)收集器是以獲取最短回收停頓時間為目標的收集器(追求低停頓),它在垃圾收集時使得用戶線程和 GC 線程并發執行,因此在垃圾收集過程中用戶也不會感到明顯的卡頓。

  • 初始標記:Stop The World,僅使用一條初始標記線程對所有與 GC Roots 直接關聯的對象進行標記。
  • 并發標記:使用多條標記線程,與用戶線程并發執行。此過程進行可達性分析,標記出所有廢棄對象。速度很慢。
  • 重新標記:Stop The World,使用多條標記線程并發執行,將剛才并發標記過程中新出現的廢棄對象標記出來。
  • 并發清除:只使用一條 GC 線程,與用戶線程并發執行,清除剛才標記的對象。這個過程非常耗時。

并發標記與并發清除過程耗時最長,且可以與用戶線程一起工作,因此,總體上說,CMS 收集器的內存回收過程是與用戶線程一起并發執行的。

image

CMS 的缺點:

  • 吞吐量低
  • 無法處理浮動垃圾,導致頻繁 Full GC
  • 使用“標記-清除”算法產生碎片空間

對于產生碎片空間的問題,可以通過開啟 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都會進行一次內存壓縮整理,將零散在各處的對象整理到一塊。設置參數 -XX:CMSFullGCsBeforeCompaction告訴 CMS,經過了 N 次 Full GC 之后再進行一次內存整理。

3. G1 通用垃圾收集器

G1 是一款面向服務端應用的垃圾收集器,它沒有新生代和老年代的概念,而是將堆劃分為一塊塊獨立的 Region。當要進行垃圾收集時,首先估計每個 Region 中垃圾的數量,每次都從垃圾回收價值最大的 Region 開始回收,因此可以獲得最大的回收效率。

從整體上看, G1 是基于“標記-整理”算法實現的收集器,從局部(兩個 Region 之間)上看是基于“復制”算法實現的,這意味著運行期間不會產生內存空間碎片。

這里拋個問題??:

一個對象和它內部所引用的對象可能不在同一個 Region 中,那么當垃圾回收時,是否需要掃描整個堆內存才能完整地進行一次可達性分析?

并不!每個 Region 都有一個 Remembered Set,用于記錄本區域中所有對象引用的對象所在的區域,進行可達性分析時,只要在 GC Roots 中再加上 Remembered Set 即可防止對整個堆內存進行遍歷。

如果不計算維護 Remembered Set 的操作,G1 收集器的工作過程分為以下幾個步驟:

  • 初始標記:Stop The World,僅使用一條初始標記線程對所有與 GC Roots 直接關聯的對象進行標記。
  • 并發標記:使用一條標記線程與用戶線程并發執行。此過程進行可達性分析,速度很慢。
  • 最終標記:Stop The World,使用多條標記線程并發執行。
  • 篩選回收:回收廢棄對象,此時也要 Stop The World,并使用多條篩選回收線程并發執行。

五、內存分配與回收策略

對象的內存分配,就是在堆上分配(也可能經過 JIT 編譯后被拆散為標量類型并間接在棧上分配),對象主要分配在新生代的 Eden 區上,少數情況下可能直接分配在老年代,分配規則不固定,取決于當前使用的垃圾收集器組合以及相關的參數配置。

以下列舉幾條最普遍的內存分配規則,供大家學習。

1. 對象優先在 Eden 分配

大多數情況下,對象在新生代 Eden 區中分配。當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC。

??Minor GC vs Major GC/Full GC

  • Minor GC:回收新生代(包括 Eden 和 Survivor 區域),因為 Java 對象大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
  • Major GC / Full GC: 回收老年代,出現了 Major GC,經常會伴隨至少一次的 Minor GC,但這并非絕對。Major GC 的速度一般會比 Minor GC 慢 10 倍 以上。

在 JVM 規范中,Major GC 和 Full GC 都沒有一個正式的定義,所以有人也簡單地認為 Major GC 清理老年代,而 Full GC 清理整個內存堆。

2. 大對象直接進入老年代

大對象是指需要大量連續內存空間的 Java 對象,如很長的字符串或數據。

一個大對象能夠存入 Eden 區的概率比較小,發生分配擔保的概率比較大,而分配擔保需要涉及大量的復制,就會造成效率低下。

虛擬機提供了一個 -XX:PretenureSizeThreshold 參數,令大于這個設置值的對象直接在老年代分配,這樣做的目的是避免在 Eden 區及兩個 Survivor 區之間發生大量的內存復制。(還記得嗎,新生代采用復制算法回收垃圾)

3. 長期存活的對象將進入老年代

JVM 給每個對象定義了一個對象年齡計數器。當新生代發生一次 Minor GC 后,存活下來的對象年齡 +1,當年齡超過一定值時,就將超過該值的所有對象轉移到老年代中去。

使用 -XXMaxTenuringThreshold 設置新生代的最大年齡,只要超過該參數的新生代對象都會被轉移到老年代中去。

4. 動態對象年齡判定

如果當前新生代的 Survivor 中,相同年齡所有對象大小的總和大于 Survivor 空間的一半,年齡 >= 該年齡的對象就可以直接進入老年代,無須等到 MaxTenuringThreshold 中要求的年齡。

5. 空間分配擔保

JDK 6 Update 24 之前的規則是這樣的:
在發生 Minor GC 之前,虛擬機會先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間, 如果這個條件成立,Minor GC 可以確保是安全的; 如果不成立,則虛擬機會查看 HandlePromotionFailure 值是否設置為允許擔保失敗, 如果是,那么會繼續檢查老年代最大可用的連續空間是否大于歷次晉升到老年代對象的平均大小, 如果大于,將嘗試進行一次 Minor GC,盡管這次 Minor GC 是有風險的; 如果小于,或者 HandlePromotionFailure 設置不允許冒險,那此時也要改為進行一次 Full GC。

JDK 6 Update 24 之后的規則變為:
只要老年代的連續空間大于新生代對象總大小或者歷次晉升的平均大小,就會進行 Minor GC,否則將進行 Full GC。

通過清除老年代中廢棄數據來擴大老年代空閑空間,以便給新生代作擔保。

這個過程就是分配擔保。

??總結一下有哪些情況可能會觸發 JVM 進行 Full GC:

  1. System.gc() 方法的調用
    此方法的調用是建議 JVM 進行 Full GC,注意這只是建議而非一定,但在很多情況下它會觸發 Full GC,從而增加 Full GC 的頻率。通常情況下我們只需要讓虛擬機自己去管理內存即可,我們可以通過 -XX:+ DisableExplicitGC 來禁止調用 System.gc()。

  2. 老年代空間不足
    老年代空間不足會觸發 Full GC操作,若進行該操作后空間依然不足,則會拋出錯誤: java.lang.OutOfMemoryError: Java heap space

  3. 永久代空間不足
    JVM 規范中運行時數據區域中的方法區,在 HotSpot 虛擬機中也稱為永久代(Permanet Generation),存放一些類信息、常量、靜態變量等數據,當系統要加載的類、反射的類和調用的方法較多時,永久代可能會被占滿,會觸發 Full GC。如果經過 Full GC 仍然回收不了,那么 JVM 會拋出錯誤信息:java.lang.OutOfMemoryError: PermGen space

  4. CMS GC 時出現 promotion failed 和 concurrent mode failure
    promotion failed,就是上文所說的擔保失敗,而 concurrent mode failure 是在執行 CMS GC 的過程中同時有對象要放入老年代,而此時老年代空間不足造成的。

  5. 統計得到的 Minor GC 晉升到舊生代的平均大小大于老年代的剩余空間

六、JVM 性能調優

在高性能硬件上部署程序,目前主要有兩種方式:

  • 通過 64 位 JDK 來使用大內存;
  • 使用若干個 32 位虛擬機建立邏輯集群來利用硬件資源。

1. 使用 64 位 JDK 管理大內存

堆內存變大后,雖然垃圾收集的頻率減少了,但每次垃圾回收的時間變長。 如果堆內存為14 G,那么每次 Full GC 將長達數十秒。如果 Full GC 頻繁發生,那么對于一個網站來說是無法忍受的。

對于用戶交互性強、對停頓時間敏感的系統,可以給 Java 虛擬機分配超大堆的前提是有把握把應用程序的 Full GC 頻率控制得足夠低,至少要低到不會影響用戶使用。

可能面臨的問題:

  • 內存回收導致的長時間停頓;
  • 現階段,64位 JDK 的性能普遍比 32 位 JDK 低;
  • 需要保證程序足夠穩定,因為這種應用要是產生堆溢出幾乎就無法產生堆轉儲快照(因為要產生超過 10GB 的 Dump 文件),哪怕產生了快照也幾乎無法進行分析;
  • 相同程序在 64 位 JDK 消耗的內存一般比 32 位 JDK 大,這是由于指針膨脹,以及數據類型對齊補白等因素導致的。

2. 使用 32 位 JVM 建立邏輯集群

在一臺物理機器上啟動多個應用服務器進程,每個服務器進程分配不同端口, 然后在前端搭建一個負載均衡器,以反向代理的方式來分配訪問請求。

考慮到在一臺物理機器上建立邏輯集群的目的僅僅是為了盡可能利用硬件資源,并不需要關心狀態保留、熱轉移之類的高可用性能需求, 也不需要保證每個虛擬機進程有絕對的均衡負載,因此使用無 Session 復制的親合式集群是一個不錯的選擇。 我們僅僅需要保障集群具備親合性,也就是均衡器按一定的規則算法(一般根據 SessionID 分配) 將一個固定的用戶請求永遠分配到固定的一個集群節點進行處理即可。

可能遇到的問題:

  • 盡量避免節點競爭全局資源,如磁盤競爭,各個節點如果同時訪問某個磁盤文件的話,很可能導致 IO 異常;
  • 很難高效利用資源池,如連接池,一般都是在節點建立自己獨立的連接池,這樣有可能導致一些節點池滿了而另外一些節點仍有較多空余;
  • 各個節點受到 32 位的內存限制;
  • 大量使用本地緩存的應用,在邏輯集群中會造成較大的內存浪費,因為每個邏輯節點都有一份緩存,這時候可以考慮把本地緩存改成集中式緩存。

3. 調優案例分析與實戰

場景描述

一個小型系統,使用 32 位 JDK,4G 內存,測試期間發現服務端不定時拋出內存溢出異常。 加入 -XX:+HeapDumpOnOutOfMemoryError(添加這個參數后,堆內存溢出時就會輸出異常日志), 但再次發生內存溢出時,沒有生成相關異常日志。

分析

在 32 位 JDK 上,1.6G 分配給堆,還有一部分分配給 JVM 的其他內存,直接內存最大也只能在剩余的 0.4G 空間中分出一部分, 如果使用了 NIO,JVM 會在 JVM 內存之外分配內存空間,那么就要小心“直接內存”不足時發生內存溢出異常了。

直接內存的回收過程

直接內存雖然不是 JVM 內存空間,但它的垃圾回收也由 JVM 負責。

垃圾收集進行時,虛擬機雖然會對直接內存進行回收, 但是直接內存卻不能像新生代、老年代那樣,發現空間不足了就通知收集器進行垃圾回收, 它只能等老年代滿了后 Full GC,然后“順便”幫它清理掉內存的廢棄對象。 否則只能一直等到拋出內存溢出異常時,先 catch 掉,再在 catch 塊里大喊 “System.gc()”。 要是虛擬機還是不聽,那就只能眼睜睜看著堆中還有許多空閑內存,自己卻不得不拋出內存溢出異常了。

七、類文件結構

1. JVM 的“無關性”

談論 JVM 的無關性,主要有以下兩個:

  • 平臺無關性:任何操作系統都能運行 Java 代碼
  • 語言無關性: JVM 能運行除 Java 以外的其他代碼

Java 源代碼首先需要使用 Javac 編譯器編譯成 .class 文件,然后由 JVM 執行 .class 文件,從而程序開始運行。

JVM 只認識 .class 文件,它不關心是何種語言生成了 .class 文件,只要 .class 文件符合 JVM 的規范就能運行。 目前已經有 JRuby、Jython、Scala 等語言能夠在 JVM 上運行。它們有各自的語法規則,不過它們的編譯器 都能將各自的源碼編譯成符合 JVM 規范的 .class 文件,從而能夠借助 JVM 運行它們。

Java 語言中的各種變量、關鍵字和運算符號的語義最終都是由多條字節碼命令組合而成的, 因此字節碼命令所能提供的語義描述能力肯定會比 Java 語言本身更加強大。 因此,有一些 Java 語言本身無法有效支持的語言特性,不代表字節碼本身無法有效支持。

2. Class 文件結構

Class 文件是二進制文件,它的內容具有嚴格的規范,文件中沒有任何空格,全都是連續的 0/1。Class 文件 中的所有內容被分為兩種類型:無符號數、表。

  • 無符號數 無符號數表示 Class 文件中的值,這些值沒有任何類型,但有不同的長度。u1、u2、u4、u8 分別代表 1/2/4/8 字節的無符號數。
  • 表 由多個無符號數或者其他表作為數據項構成的符合數據類型。

Class 文件具體由以下幾個構成:

  • 魔數
  • 版本信息
  • 常量池
  • 訪問標志
  • 類索引、父類索引、接口索引集合
  • 字段表集合
  • 方法表集合
  • 屬性表集合

魔數

Class 文件的頭 4 個字節稱為魔數,用來表示這個 Class 文件的類型。

Class 文件的魔數是用 16 進制表示的“CAFE BABE”,是不是很具有浪漫色彩?

魔數相當于文件后綴名,只不過后綴名容易被修改,不安全,因此在 Class 文件中標識文件類型比較合適。

版本信息

緊接著魔數的 4 個字節是版本信息,5-6 字節表示次版本號,7-8 字節表示主版本號,它們表示當前 Class 文件中使用的是哪個版本的 JDK。

高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能運行以后版本的 Class 文件,即使文件格式并未發生任何變化,虛擬機也必需拒絕執行超過其版本號的 Class 文件。

常量池

版本信息之后就是常量池,常量池中存放兩種類型的常量:

  • 字面值常量
    字面值常量就是我們在程序中定義的字符串、被 final 修飾的值。

  • 符號引用
    符號引用就是我們定義的各種名字:類和接口的全限定名、字段的名字和描述符、方法的名字和描述符。

常量池的特點

  • 常量池中常量數量不固定,因此常量池開頭放置一個 u2 類型的無符號數,用來存儲當前常量池的容量。
  • 常量池的每一項常量都是一個表,表開始的第一位是一個 u1 類型的標志位(tag),代表當前這個常量屬于哪種常量類型。

常量池中常量類型

類型 tag 描述
CONSTANT_utf8_info 1 UTF-8編碼的字符串
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮點型字面量
CONSTANT_Long_info 5 長整型字面量
CONSTANT_Double_info 6 雙精度浮點型字面量
CONSTANT_Class_info 7 類或接口的符號引用
CONSTANT_String_info 8 字符串類型字面量
CONSTANT_Fieldref_info 9 字段的符號引用
CONSTANT_Methodref_info 10 類中方法的符號引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符號引用
CONSTANT_NameAndType_info 12 字段或方法的符號引用
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_MethodType_info 16 標識方法類型
CONSTANT_InvokeDynamic_info 18 表示一個動態方法調用點

對于 CONSTANT_Class_info(此類型的常量代表一個類或者接口的符號引用),它的二維表結構如下:

類型 名稱 數量
u1 tag 1
u2 name_index 1

tag 是標志位,用于區分常量類型;name_index 是一個索引值,它指向常量池中一個 CONSTANT_Utf8_info 類型常量,此常量代表這個類(或接口)的全限定名,這里 name_index 值若為 0x0002,也即是指向了常量池中的第二項常量。

CONSTANT_Utf8_info 型常量的結構如下:

類型 名稱 數量
u1 tag 1
u2 length 1
u1 bytes length

tag 是當前常量的類型;length 表示這個字符串的長度;bytes 是這個字符串的內容(采用縮略的 UTF8 編碼)

訪問標志

在常量池結束之后,緊接著的兩個字節代表訪問標志,這個標志用于識別一些類或者接口層次的訪問信息,包括:這個 Class 是類還是接口;是否定義為 public 類型;是否被 abstract/final 修飾。

類索引、父類索引、接口索引集合

類索引和父類索引都是一個 u2 類型的數據,而接口索引集合是一組 u2 類型的數據的集合,Class 文件中由這三項數據來確定類的繼承關系。類索引用于確定這個類的全限定名,父類索引用于確定這個類的父類的全限定名。

由于 Java 不允許多重繼承,所以父類索引只有一個,除了 java.lang.Object 之外,所有的 Java 類都有父類,因此除了 java.lang.Object 外,所有 Java 類的父類索引都不為 0。一個類可能實現了多個接口,因此用接口索引集合來描述。這個集合第一項為 u2 類型的數據,表示索引表的容量,接下來就是接口的名字索引。

類索引和父類索引用兩個 u2 類型的索引值表示,它們各自指向一個類型為 CONSTANT_Class_info 的類描述符常量,通過該常量總的索引值可以找到定義在 CONSTANT_Utf8_info 類型的常量中的全限定名字符串。

字段表集合

字段表集合存儲本類涉及到的成員變量,包括實例變量和類變量,但不包括方法中的局部變量。

每一個字段表只表示一個成員變量,本類中的所有成員變量構成了字段表集合。字段表結構如下:

類型 名稱 數量 說明
u2 access_flags 1 字段的訪問標志,與類稍有不同
u2 name_index 1 字段名字的索引
u2 descriptor_index 1 描述符,用于描述字段的數據類型。 基本數據類型用大寫字母表示; 對象類型用“L 對象類型的全限定名”表示。
u2 attributes_count 1 屬性表集合的長度
u2 attributes attributes_count 屬性表集合,用于存放屬性的額外信息,如屬性的值。

字段表集合中不會出現從父類(或接口)中繼承而來的字段,但有可能出現原本 Java 代碼中不存在的字段,譬如在內部類中為了保持對外部類的訪問性,會自動添加指向外部類實例的字段。

方法表集合

方法表結構與屬性表類似。

volatile 關鍵字 和 transient 關鍵字不能修飾方法,所以方法表的訪問標志中沒有 ACC_VOLATILE 和 ACC_TRANSIENT 標志。

方法表的屬性表集合中有一張 Code 屬性表,用于存儲當前方法經編譯器編譯后的字節碼指令。

屬性表集合

每個屬性對應一張屬性表,屬性表的結構如下:

類型 名稱 數量
u2 attribute_name_index 1
u4 attribute_length 1
u1 info attribute_length

八、類加載的時機

1. 類的生命周期

類從被加載到虛擬機內存開始,到卸載出內存為止,它的整個生命周期包括以下 7 個階段:

  • 加載
  • 驗證
  • 準備
  • 解析
  • 初始化
  • 使用
  • 卸載

驗證、準備、解析 3 個階段統稱為連接。

image

加載、驗證、準備、初始化和卸載這 5 個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始(注意是“開始”,而不是“進行”或“完成”),而解析階段則不一定:它在某些情況下可以在初始化后再開始,這是為了支持 Java 語言的運行時綁定。

2. 類加載過程中“初始化”開始的時機

Java 虛擬機規范沒有強制約束類加載過程的第一階段(即:加載)什么時候開始,但對于“初始化”階段,有著嚴格的規定。有且僅有 5 種情況必須立即對類進行“初始化”:

  • 在遇到 new、putstatic、getstatic、invokestatic 字節碼指令時,如果類尚未初始化,則需要先觸發其初始化。
  • 對類進行反射調用時,如果類還沒有初始化,則需要先觸發其初始化。
  • 初始化一個類時,如果其父類還沒有初始化,則需要先初始化父類。
  • 虛擬機啟動時,用于需要指定一個包含 main() 方法的主類,虛擬機會先初始化這個主類。
  • 當使用 JDK 1.7 的動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最后的解析結果為 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且這個方法句柄所對應的類還沒初始化,則需要先觸發其初始化。

這 5 種場景中的行為稱為對一個類進行主動引用,除此之外,其它所有引用類的方式都不會觸發初始化,稱為被動引用

3. 被動引用演示 Demo

Demo1

/**
 * 被動引用 Demo1:
 * 通過子類引用父類的靜態字段,不會導致子類初始化。
 * 
 * @author ylb
 *
 */
class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

public class NotInitialization {

    public static void main(String[] args) {
        System.out.println(SubClass.value);
        // SuperClass init!
    }

}

對于靜態字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。

Demo2

/**
 * 被動引用 Demo2:
 * 通過數組定義來引用類,不會觸發此類的初始化。
 * 
 * @author ylb
 *
 */

public class NotInitialization {

    public static void main(String[] args) {
        SuperClass[] superClasses = new SuperClass[10];
    }

}

這段代碼不會觸發父類的初始化,但會觸發“[L 全類名”這個類的初始化,它由虛擬機自動生成,直接繼承自 java.lang.Object,創建動作由字節碼指令 newarray 觸發。

Demo3

/**
 * 被動引用 Demo3:
 * 常量在編譯階段會存入調用類的常量池中,本質上并沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
 * 
 * @author ylb
 *
 */
class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLO_BINGO = "Hello Bingo";

}

public class NotInitialization {

    public static void main(String[] args) {
        System.out.println(ConstClass.HELLO_BINGO);
    }

}

編譯通過之后,常量存儲到 NotInitialization 類的常量池中,NotInitialization 的 Class 文件中并沒有 ConstClass 類的符號引用入口,這兩個類在編譯成 Class 之后就沒有任何聯系了。

4. 接口的加載過程

接口加載過程與類加載過程稍有不同。

當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,當真正用到父接口的時候才會初始化。

九、類加載的過程

類加載過程包括 5 個階段:加載、驗證、準備、解析和初始化。

1. 加載

加載的過程

“加載”是“類加載”過程的一個階段,不能混淆這兩個名詞。在加載階段,虛擬機需要完成 3 件事:

  • 通過類的全限定名獲取該類的二進制字節流。
  • 將二進制字節流所代表的靜態結構轉化為方法區的運行時數據結構。
  • 在內存中創建一個代表該類的 java.lang.Class 對象,作為方法區這個類的各種數據的訪問入口。

獲取二進制字節流

對于 Class 文件,虛擬機沒有指明要從哪里獲取、怎樣獲取。除了直接從編譯好的 .class 文件中讀取,還有以下幾種方式:

  • 從 zip 包中讀取,如 jar、war等
  • 從網絡中獲取,如 Applect
  • 通過動態代理計數生成代理類的二進制字節流
  • 由 JSP 文件生成對應的 Class 類
  • 從數據庫中讀取,如 有些中間件服務器可以選擇把程序安裝到數據庫中來完成程序代碼在集群間的分發。

“非數組類”與“數組類”加載比較

  • 非數組類加載階段可以使用系統提供的引導類加載器,也可以由用戶自定義的類加載器完成,開發人員可以通過定義自己的類加載器控制字節流的獲取方式(如重寫一個類加載器的 loadClass() 方法)
  • 數組類本身不通過類加載器創建,它是由 Java 虛擬機直接創建的,再由類加載器創建數組中的元素類。

注意事項

  • 虛擬機規范未規定 Class 對象的存儲位置,對于 HotSpot 虛擬機而言,Class 對象比較特殊,它雖然是對象,但存放在方法區中。
  • 加載階段與連接階段的部分內容交叉進行,加載階段尚未完成,連接階段可能已經開始了。但這兩個階段的開始實踐仍然保持著固定的先后順序。

2. 驗證

驗證的重要性

驗證階段確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。

驗證的過程

  • 文件格式驗證 驗證字節流是否符合 Class 文件格式的規范,并且能被當前版本的虛擬機處理,驗證點如下:
    • 是否以魔數 0XCAFEBABE 開頭
    • 主次版本號是否在當前虛擬機處理范圍內
    • 常量池是否有不被支持的常量類型
    • 指向常量的索引值是否指向了不存在的常量
    • CONSTANT_Utf8_info 型的常量是否有不符合 UTF8 編碼的數據
    • ......
  • 元數據驗證 對字節碼描述信息進行語義分析,確保其符合 Java 語法規范。
  • 字節碼驗證 本階段是驗證過程中最復雜的一個階段,是對方法體進行語義分析,保證方法在運行時不會出現危害虛擬機的事件。
  • 符號引用驗證 本階段發生在解析階段,確保解析正常執行。

3. 準備

準備階段是正式為類變量(或稱“靜態成員變量”)分配內存并設置初始值的階段。這些變量(不包括實例變量)所使用的內存都在方法區中進行分配。

初始值“通常情況下”是數據類型的零值(0, null...),假設一個類變量的定義為:

public static int value = 123;

那么變量 value 在準備階段過后的初始值為 0 而不是 123,因為這時候尚未開始執行任何 Java 方法。

存在“特殊情況”:如果類字段的字段屬性表中存在 ConstantValue 屬性,那么在準備階段 value 就會被初始化為 ConstantValue 屬性所指定的值,假設上面類變量 value 的定義變為:

public static final int value = 123;

那么在準備階段虛擬機會根據 ConstantValue 的設置將 value 賦值為 123。

4. 解析

解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。

5. 初始化

類初始化階段是類加載過程的最后一步,是執行類構造器 <clinit>() 方法的過程。

<clinit>() 方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static {} 塊)中的語句合并產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的。

靜態語句塊中只能訪問定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊中可以賦值,但不能訪問。如下方代碼所示:

public class Test {
    static {
        i = 0;  // 給變量賦值可以正常編譯通過
        System.out.println(i);  // 這句編譯器會提示“非法向前引用”
    }
    static int i = 1;
}

<clinit>() 方法不需要顯式調用父類構造器,虛擬機會保證在子類的 <clinit>() 方法執行之前,父類的 <clinit>() 方法已經執行完畢。

由于父類的 <clinit>() 方法先執行,意味著父類中定義的靜態語句塊要優先于子類的變量賦值操作。如下方代碼所示:

static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}

static class Sub extends Parent {
    public static int B = A;
}

public static void main(String[] args) {
    System.out.println(Sub.B); // 輸出 2
}

<clinit>() 方法不是必需的,如果一個類沒有靜態語句塊,也沒有對類變量的賦值操作,那么編譯器可以不為這個類生成 <clinit>() 方法。

接口中不能使用靜態代碼塊,但接口也需要通過 <clinit>() 方法為接口中定義的靜態成員變量顯式初始化。但接口與類不同,接口的 <clinit>() 方法不需要先執行父類的 <clinit>() 方法,只有當父接口中定義的變量使用時,父接口才會初始化。

虛擬機會保證一個類的 <clinit>() 方法在多線程環境中被正確加鎖、同步。如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的 <clinit>() 方法。

十、類加載器

1. 類與類加載器

判斷類是否“相等”

任意一個類,都由加載它的類加載器和這個類本身一同確立其在 Java 虛擬機中的唯一性,每一個類加載器,都有一個獨立的類名稱空間。

因此,比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個 Class 文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那么這兩個類就必定不相等。

這里的“相等”,包括代表類的 Class 對象的 equals() 方法、isInstance() 方法的返回結果,也包括使用 instanceof 關鍵字做對象所屬關系判定等情況。

加載器種類

系統提供了 3 種類加載器:

  • 啟動類加載器(Bootstrap ClassLoader): 負責將存放在 <JAVA_HOME>\lib 目錄中的,并且能被虛擬機識別的(僅按照文件名識別,如 rt.jar,名字不符合的類庫即使放在 lib 目錄中也不會被加載)類庫加載到虛擬機內存中。
  • 擴展類加載器(Extension ClassLoader): 負責加載 <JAVA_HOME>\lib\ext 目錄中的所有類庫,開發者可以直接使用擴展類加載器。
  • 應用程序類加載器(Application ClassLoader): 由于這個類加載器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也稱它為“系統類加載器”。它負責加載用戶類路徑(classpath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
image

當然,如果有必要,還可以加入自己定義的類加載器。

2. 雙親委派模型

什么是雙親委派模型

雙親委派模型是描述類加載器之間的層次關系。它要求除了頂層的啟動類加載器外,其余的類加載器都應當有自己的父類加載器。(父子關系一般不會以繼承的關系實現,而是以組合關系來復用父加載器的代碼)

工作過程

如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(找不到所需的類)時,子加載器才會嘗試自己去加載。

在 java.lang.ClassLoader 中的 loadClass() 方法中實現該過程。

為什么使用雙親委派模型

像 java.lang.Object 這些存放在 rt.jar 中的類,無論使用哪個類加載器加載,最終都會委派給最頂端的啟動類加載器加載,從而使得不同加載器加載的 Object 類都是同一個。

相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個稱為 java.lang.Object 的類,并放在 classpath 下,那么系統將會出現多個不同的 Object 類,Java 類型體系中最基礎的行為也就無法保證。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。