JVM 的主要作用是什么?
JVM 就是 Java Virtual Machine(Java虛擬機(jī))的縮寫,JVM 屏蔽了與具體操作系統(tǒng)平臺相關(guān)的信息,使 Java 程序只需生成在 Java 虛擬機(jī)上運行的目標(biāo)代碼 (字節(jié)碼),就可以在不同的平臺上運行。
請你描述一下 Java 的內(nèi)存區(qū)域?
JVM 在執(zhí)行 Java 程序的過程中會把它管理的內(nèi)存分為若干個不同的區(qū)域,這些組成部分有些是線程私有的,有些則是線程共享的,Java 內(nèi)存區(qū)域也叫做運行時數(shù)據(jù)區(qū),它的具體劃分如下:
虛擬機(jī)棧
: Java 虛擬機(jī)棧是線程私有的數(shù)據(jù)區(qū),Java 虛擬機(jī)棧的生命周期與線程相同,虛擬機(jī)棧也是局部變量的存儲位置。方法在執(zhí)行過程中,會在虛擬機(jī)棧中創(chuàng)建一個棧幀(stack frame)
。每個方法執(zhí)行的過程就對應(yīng)了一個入棧和出棧的過程。
本地方法棧
: 本地方法棧也是線程私有的數(shù)據(jù)區(qū),本地方法棧存儲的區(qū)域主要是 Java 中使用 native
關(guān)鍵字修飾的方法所存儲的區(qū)域。
程序計數(shù)器
:程序計數(shù)器也是線程私有的數(shù)據(jù)區(qū),這部分區(qū)域用于存儲線程的指令地址,用于判斷線程的分支、循環(huán)、跳轉(zhuǎn)、異常、線程切換和恢復(fù)等功能,這些都通過程序計數(shù)器來完成。
方法區(qū)
:方法區(qū)是各個線程共享的內(nèi)存區(qū)域,它用于存儲虛擬機(jī)加載的 類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼等數(shù)據(jù)。
堆
:堆是線程共享的數(shù)據(jù)區(qū),堆是 JVM 中最大的一塊存儲區(qū)域,所有的對象實例都會分配在堆上。JDK 1.7后,字符串常量池從永久代中剝離出來,存放在堆中。
堆空間的內(nèi)存分配(默認(rèn)情況下):
命令行上執(zhí)行如下命令,會查看默認(rèn)的 JVM 參數(shù)。
java -XX:+PrintFlagsFinal -version
輸出的內(nèi)容非常多,但是只有兩行能夠反映出上面的內(nèi)存分配結(jié)果
運行時常量池
:運行時常量池又被稱為 Runtime Constant Pool
,這塊區(qū)域是方法區(qū)的一部分,它的名字非常有意思,通常被稱為 非堆
。它并不要求常量一定只有在編譯期才能產(chǎn)生,也就是并非編譯期間將常量放在常量池中,運行期間也可以將新的常量放入常量池中,String 的 intern 方法就是一個典型的例子。
- eden 區(qū):8/10 的年輕代空間survivor 0 : 1/10 的年輕代空間survivor 1 : 1/10 的年輕代空間老年代 :三分之二的堆空間年輕代 :三分之一的堆空間
請你描述一下 Java 中的類加載機(jī)制?
Java 虛擬機(jī)負(fù)責(zé)把描述類的數(shù)據(jù)從 Class 文件加載到系統(tǒng)內(nèi)存中,并對類的數(shù)據(jù)進(jìn)行校驗、轉(zhuǎn)換解析和初始化,最終形成可以被虛擬機(jī)直接使用的 Java 類型,這個過程被稱之為 Java 的類加載機(jī)制
。
一個類從被加載到虛擬機(jī)內(nèi)存開始,到卸載出內(nèi)存為止,一共會經(jīng)歷下面這些過程。
類加載機(jī)制一共有五個步驟,分別是加載、鏈接、初始化、使用和卸載階段,這五個階段的順序是確定的。
其中鏈接階段會細(xì)分成三個階段,分別是驗證、準(zhǔn)備、解析階段,這三個階段的順序是不確定的,這三個階段通常交互進(jìn)行。解析階段通常會在初始化之后再開始,這是為了支持 Java 語言的運行時綁定特性(也被稱為動態(tài)綁定
)。
下面我們就來聊一下這幾個過程。
加載
關(guān)于什么時候開始加載這個過程,《Java 虛擬機(jī)規(guī)范》并沒有強(qiáng)制約束,所以這一點我們可以自由實現(xiàn)。加載是整個類加載過程的第一個階段,在這個階段,Java 虛擬機(jī)需要完成三件事情:
- 通過一個類的全限定名來獲取定義此類的二進(jìn)制字節(jié)流。將這個字節(jié)流表示的一種存儲結(jié)構(gòu)轉(zhuǎn)換為運行時數(shù)據(jù)區(qū)中方法區(qū)的數(shù)據(jù)結(jié)構(gòu)。在內(nèi)存中生成一個 Class 對象,這個對象就代表了這個數(shù)據(jù)結(jié)構(gòu)的訪問入口。
《Java 虛擬機(jī)規(guī)范》并未規(guī)定全限定名是如何獲取的,所以現(xiàn)在業(yè)界有很多獲取全限定名的方式:
- 從 ZIP 包中讀取,最終會改變?yōu)?JAR、EAR、WAR 格式。從網(wǎng)絡(luò)中獲取,最常見的應(yīng)用就是 Web Applet。運行時動態(tài)生成,使用最多的就是動態(tài)代理技術(shù)。由其他文件生成,比如 JSP 應(yīng)用場景,由 JSP 文件生成對應(yīng)的 Class 文件。從數(shù)據(jù)庫中讀取,這種場景就比較小了??梢詮募用芪募蝎@取,這是典型的防止 Class 文件被反編譯的保護(hù)措施。
加載階段既可以使用虛擬機(jī)內(nèi)置的引導(dǎo)類加載器來完成,也可以使用用戶自定義的類加載器來完成。程序員可以通過自己定義類加載器來控制字節(jié)流的訪問方式。
數(shù)組的加載不需要通過類加載器來創(chuàng)建,它是直接在內(nèi)存中分配,但是數(shù)組的元素類型(數(shù)組去掉所有維度的類型)最終還是要靠類加載器來完成加載。
驗證
加載過后的下一個階段就是驗證,因為我們上一步講到在內(nèi)存中生成了一個 Class 對象,這個對象是訪問其代表數(shù)據(jù)結(jié)構(gòu)的入口,所以這一步驗證的工作就是確保 Class 文件的字節(jié)流中的內(nèi)容符合《Java 虛擬機(jī)規(guī)范》中的要求,保證這些信息被當(dāng)作代碼運行后,它不會威脅到虛擬機(jī)的安全。
驗證階段主要分為四個階段的檢驗:
- 文件格式驗證。元數(shù)據(jù)驗證。字節(jié)碼驗證。符號引用驗證。
文件格式驗證
這一階段可能會包含下面這些驗證點:
- 魔數(shù)是否以
0xCAFEBABE
開頭。主、次版本號是否在當(dāng)前 Java 虛擬機(jī)接受范圍之內(nèi)。常量池的常量中是否有不支持的常量類型。指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 編碼的數(shù)據(jù)。Class 文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
實際上驗證點遠(yuǎn)遠(yuǎn)不止有這些,上面這些只是從 HotSpot 源碼中摘抄的一小段內(nèi)容。
元數(shù)據(jù)驗證
這一階段主要是對字節(jié)碼描述的信息進(jìn)行語義分析,以確保描述的信息符合《Java 語言規(guī)范》,驗證點包括
- 驗證的類是否有父類(除了 Object 類之外,所有的類都應(yīng)該有父類)。要驗證類的父類是否繼承了不允許繼承的類。如果這個類不是抽象類,那么這個類是否實現(xiàn)了父類或者接口中要求的所有方法。是否覆蓋了 final 字段,是否出現(xiàn)了不符合規(guī)定的重載等。
需要記住這一階段只是對《Java 語言規(guī)范》的驗證。
字節(jié)碼驗證
字節(jié)碼驗證階段是最復(fù)雜的一個階段,這個階段主要是確定程序語意是否合法、是否是符合邏輯的。這個階段主要是對類的方法體(Class 文件中的 Code 屬性)進(jìn)行校驗分析。這部分驗證包括
- 確保操作數(shù)棧的數(shù)據(jù)類型和實際執(zhí)行時的數(shù)據(jù)類型是否一致。保證任何跳轉(zhuǎn)指令不會跳出到方法體外的字節(jié)碼指令上。保證方法體中的類型轉(zhuǎn)換是有效的,例如可以把一個子類對象賦值給父類數(shù)據(jù)類型,但是不能把父類數(shù)據(jù)類型賦值給子類等諸如此不安全的類型轉(zhuǎn)換。其他驗證。
如果沒有通過字節(jié)碼驗證,就說明驗證出問題。但是不一定通過了字節(jié)碼驗證,就能保證程序是安全的。
符號引用驗證
最后一個階段的校驗行為發(fā)生在虛擬機(jī)將符號引用轉(zhuǎn)換為直接引用的時候,這個轉(zhuǎn)化將在連接的第三個階段,即解析階段中發(fā)生。符號引用驗證可以看作是對類自身以外的各類信息進(jìn)行匹配性校驗,這個驗證主要包括
- 符號引用中的字符串全限定名是否能找到對應(yīng)的類。指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。符號引用的類、字段方法的可訪問性是否可被當(dāng)前類所訪問。其他驗證。
這一階段主要是確保解析行為能否正常執(zhí)行,如果無法通過符號引用驗證,就會出現(xiàn)類似 IllegalAccessError
、NoSuchFieldError
、NoSuchMethodError
等錯誤。
驗證階段對于虛擬機(jī)來說非常重要,如果能通過驗證,就說明你的程序在運行時不會產(chǎn)生任何影響。
準(zhǔn)備
準(zhǔn)備階段是為類中的變量分配內(nèi)存并設(shè)置其初始值的階段,這些變量所使用的內(nèi)存都應(yīng)當(dāng)在方法區(qū)中進(jìn)行分配,在 JDK 7 之前,HotSpot 使用永久代來實現(xiàn)方法區(qū),是符合這種邏輯概念的。而在 JDK 8 之后,變量則會隨著 Class 對象一起存放在 Java 堆中。
下面通常情況下的基本類型和引用類型的初始值
除了"通常情況"下,還有一些"例外情況",如果類字段屬性中存在 ConstantValue
屬性,那就這個變量值在初始階段就會初始化為 ConstantValue 屬性所指定的初始值,比如
public static final int value = "666";
編譯時就會把 value 的值設(shè)置為 666。
解析
解析階段是 Java 虛擬機(jī)將常量池內(nèi)的符號引用替換為直接引用的過程。
符號引用
:符號引用以一組符號來描述所引用的目標(biāo)。符號引用可以是任何形式的字面量,只要使用時能無歧義地定位到目標(biāo)即可,符號引用和虛擬機(jī)的布局無關(guān)。直接引用
:直接引用可以直接指向目標(biāo)的指針、相對便宜量或者一個能間接定位到目標(biāo)的句柄。直接引用和虛擬機(jī)的布局是相關(guān)的,不同的虛擬機(jī)對于相同的符號引用所翻譯出來的直接引用一般是不同的。如果有了直接引用,那么直接引用的目標(biāo)一定被加載到了內(nèi)存中。
這樣說你可能還有點不明白,我再換一種說法:
在編譯的時候一個每個 Java 類都會被編譯成一個 class 文件,但在編譯的時候虛擬機(jī)并不知道所引用類的地址,所以就用符號引用來代替,而在這個解析階段就是為了把這個符號引用轉(zhuǎn)化成為真正的地址的階段。
《Java 虛擬機(jī)規(guī)范》并未規(guī)定解析階段發(fā)生的時間,只要求了在 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 這 17 個用于操作符號引用的字節(jié)碼指令之前,先對所使用的符號引用進(jìn)行解析。
解析也分為四個步驟
- 類或接口的解析字段解析方法解析接口方法解析
初始化
初始化是類加載過程的最后一個步驟,在之前的階段中,都是由 Java 虛擬機(jī)占主導(dǎo)作用,但是到了這一步,卻把主動權(quán)移交給應(yīng)用程序。
對于初始化階段,《Java 虛擬機(jī)規(guī)范》嚴(yán)格規(guī)定了只有下面這六種情況下才會觸發(fā)類的初始化。
- 在遇到 new、getstatic、putstatic 或者 invokestatic 這四條字節(jié)碼指令時,如果沒有進(jìn)行過初始化,那么首先觸發(fā)初始化。通過這四個字節(jié)碼的名稱可以判斷,這四條字節(jié)碼其實就兩個場景,調(diào)用 new 關(guān)鍵字的時候進(jìn)行初始化、讀取或者設(shè)置一個靜態(tài)字段的時候、調(diào)用靜態(tài)方法的時候。在初始化類的時候,如果父類還沒有初始化,那么就需要先對父類進(jìn)行初始化。在使用 java.lang.reflect 包的方法進(jìn)行反射調(diào)用的時候。當(dāng)虛擬機(jī)啟動時,用戶需要指定執(zhí)行主類的時候,說白了就是虛擬機(jī)會先初始化 main 方法這個類。在使用 JDK 7 新加入的動態(tài)語言支持時,如果一個 jafva.lang.invoke.MethodHandle 實例最后的解析結(jié)果為 REF_getstatic、REF_putstatic、REF_invokeStatic、REF_newInvokeSpecial 四種類型的方法句柄,并且這個方法句柄對應(yīng)的類沒有進(jìn)行過初始化,需要先對其進(jìn)行初始化。當(dāng)一個接口中定義了 JDK 8 新加入的默認(rèn)方法(被 default 關(guān)鍵字修飾的接口方法)時,如果有這個接口的實現(xiàn)類發(fā)生了初始化,那該接口要在其之前被初始化。
其實上面只有前四個大家需要知道就好了,后面兩個比較冷門。
如果說要回答類加載的話,其實聊到這里已經(jīng)可以了,但是為了完整性,我們索性把后面兩個過程也來聊一聊。
使用
這個階段沒什么可說的,就是初始化之后的代碼由 JVM 來動態(tài)調(diào)用執(zhí)行。
卸載
當(dāng)代表一個類的 Class 對象不再被引用,那么 Class 對象的生命周期就結(jié)束了,對應(yīng)的在方法區(qū)中的數(shù)據(jù)也會被卸載。
??但是需要注意一點:JVM 自帶的類加載器裝載的類,是不會卸載的,由用戶自定義的類加載器加載的類是可以卸載的。
在 JVM 中,對象是如何創(chuàng)建的?
如果要回答對象是怎么創(chuàng)建的,我們一般想到的回答是直接 new
出來就行了,這個回答不僅局限于編程中,也融入在我們生活中的方方面面。
但是遇到面試的時候你只回答一個"new 出來就行了"顯然是不行的,因為面試更趨向于讓你解釋當(dāng)程序執(zhí)行到 new 這條指令時,它的背后發(fā)生了什么。
所以你需要從 JVM 的角度來解釋這件事情。
當(dāng)虛擬機(jī)遇到一個 new 指令時(其實就是字節(jié)碼),首先會去檢查這個指令的參數(shù)是否能在常量池中定位到一個類的符號引用,并且檢查這個符號引用所代表的類是否已經(jīng)被加載、解析和初始化。
因為此時很可能不知道具體的類是什么,所以這里使用的是符號引用。
如果發(fā)現(xiàn)這個類沒有經(jīng)過上面類加載的過程,那么就執(zhí)行相應(yīng)的類加載過程。
類檢查完成后,接下來虛擬機(jī)將會為新生對象分配內(nèi)存,對象所需的大小在類加載完成后便可確定(我會在下面的面試題中介紹)。
分配內(nèi)存相當(dāng)于是把一塊固定的內(nèi)存塊從堆中劃分出來。劃分出來之后,虛擬機(jī)會將分配到的內(nèi)存空間都初始化為零值,如果使用了 TLAB
(本地線程分配緩沖),這一項初始化工作可以提前在 TLAB 分配時進(jìn)行。這一步操作保證了對象實例字段在 Java 代碼中可以不賦值就能直接使用。
接下來,Java 虛擬機(jī)還會對對象進(jìn)行必要的設(shè)置,比如確定對象是哪個類的實例、對象的 hashcode、對象的 gc 分代年齡信息。這些信息存放在對象的對象頭(Object Header)中。
如果上面的工作都做完后,從虛擬機(jī)的角度來說,一個新的對象就創(chuàng)建完畢了;但是對于程序員來說,對象創(chuàng)建才剛剛開始,因為構(gòu)造函數(shù),即 Class 文件中的 <init>()
方法還沒有執(zhí)行,所有字段都為默認(rèn)的零值。new 指令之后才會執(zhí)行 <init>()
方法,然后按照程序員的意愿對對象進(jìn)行初始化,這樣一個對象才可能被完整的構(gòu)造出來。
內(nèi)存分配方式有哪些呢?
在類加載完成后,虛擬機(jī)需要為新生對象分配內(nèi)存,為對象分配內(nèi)存相當(dāng)于是把一塊確定的區(qū)域從堆中劃分出來,這就涉及到一個問題,要劃分的堆區(qū)是否規(guī)整。
假設(shè) Java 堆中內(nèi)存是規(guī)整的,所有使用過的內(nèi)存放在一邊,未使用的內(nèi)存放在一邊,中間放著一個指針,這個指針為分界指示器。那么為新對象分配內(nèi)存空間就相當(dāng)于是把指針向空閑的空間挪動對象大小相等的距離,這種內(nèi)存分配方式叫做指針碰撞(Bump The Pointer)
。
如果 Java 堆中的內(nèi)存并不是規(guī)整的,已經(jīng)被使用的內(nèi)存和未被使用的內(nèi)存相互交錯在一起,這種情況下就沒有辦法使用指針碰撞,這里就要使用另外一種記錄內(nèi)存使用的方式:空閑列表(Free List)
,空閑列表維護(hù)了一個列表,這個列表記錄了哪些內(nèi)存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,并更新列表上的記錄。
所以,上述兩種分配方式選擇哪個,取決于 Java 堆是否規(guī)整來決定。在一些垃圾收集器的實現(xiàn)中,Serial、ParNew 等帶壓縮整理過程的收集器,使用的是指針碰撞;而使用 CMS 這種基于清除算法的收集器時,使用的是空閑列表,具體的垃圾收集器我們后面會聊到。
請你說一下對象的內(nèi)存布局?
在 hotspot
虛擬機(jī)中,對象在內(nèi)存中的布局分為三塊區(qū)域:
對象頭(Header)
實例數(shù)據(jù)(Instance Data)
對齊填充(Padding)
這三塊區(qū)域的內(nèi)存分布如下圖所示
我們來詳細(xì)介紹一下上面對象中的內(nèi)容。
對象頭 Header
對象頭 Header 主要包含 MarkWord 和對象指針 Klass Pointer,如果是數(shù)組的話,還要包含數(shù)組的長度。
在 32 位的虛擬機(jī)中 MarkWord ,Klass Pointer 和數(shù)組長度分別占用 32 位,也就是 4 字節(jié)。
如果是 64 位虛擬機(jī)的話,MarkWord ,Klass Pointer 和數(shù)組長度分別占用 64 位,也就是 8 字節(jié)。
在 32 位虛擬機(jī)和 64 位虛擬機(jī)的 Mark Word 所占用的字節(jié)大小不一樣,32 位虛擬機(jī)的 Mark Word 和 Klass Pointer 分別占用 32 bits 的字節(jié),而 64 位虛擬機(jī)的 Mark Word 和 Klass Pointer 占用了64 bits 的字節(jié),下面我們以 32 位虛擬機(jī)為例,來看一下其 Mark Word 的字節(jié)具體是如何分配的。
用中文翻譯過來就是
- 無狀態(tài)也就是
無鎖
的時候,對象頭開辟 25 bit 的空間用來存儲對象的 hashcode ,4 bit 用于存放分代年齡,1 bit 用來存放是否偏向鎖的標(biāo)識位,2 bit 用來存放鎖標(biāo)識位為 01。偏向鎖
中劃分更細(xì),還是開辟 25 bit 的空間,其中 23 bit 用來存放線程ID,2bit 用來存放 epoch,4bit 存放分代年齡,1 bit 存放是否偏向鎖標(biāo)識, 0 表示無鎖,1 表示偏向鎖,鎖的標(biāo)識位還是 01。輕量級鎖
中直接開辟 30 bit 的空間存放指向棧中鎖記錄的指針,2bit 存放鎖的標(biāo)志位,其標(biāo)志位為 00。重量級鎖
中和輕量級鎖一樣,30 bit 的空間用來存放指向重量級鎖的指針,2 bit 存放鎖的標(biāo)識位,為 11GC標(biāo)記
開辟 30 bit 的內(nèi)存空間卻沒有占用,2 bit 空間存放鎖標(biāo)志位為 11。
其中無鎖和偏向鎖的鎖標(biāo)志位都是 01,只是在前面的 1 bit 區(qū)分了這是無鎖狀態(tài)還是偏向鎖狀態(tài)。
關(guān)于為什么這么分配的內(nèi)存,我們可以從 OpenJDK
中的markOop.hpp類中的枚舉窺出端倪
來解釋一下
- age_bits 就是我們說的分代回收的標(biāo)識,占用4字節(jié)lock_bits 是鎖的標(biāo)志位,占用2個字節(jié)biased_lock_bits 是是否偏向鎖的標(biāo)識,占用1個字節(jié)。max_hash_bits 是針對無鎖計算的 hashcode 占用字節(jié)數(shù)量,如果是 32 位虛擬機(jī),就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虛擬機(jī),64 - 4 - 2 - 1 = 57 byte,但是會有 25 字節(jié)未使用,所以 64 位的 hashcode 占用 31 byte。hash_bits 是針對 64 位虛擬機(jī)來說,如果最大字節(jié)數(shù)大于 31,則取 31,否則取真實的字節(jié)數(shù)cms_bits 我覺得應(yīng)該是不是 64 位虛擬機(jī)就占用 0 byte,是 64 位就占用 1byteepoch_bits 就是 epoch 所占用的字節(jié)大小,2 字節(jié)。
在上面的虛擬機(jī)對象頭分配表中,我們可以看到有幾種鎖的狀態(tài):無鎖(無狀態(tài)),偏向鎖,輕量級鎖,重量級鎖,其中輕量級鎖和偏向鎖是 JDK1.6 中對 synchronized 鎖進(jìn)行優(yōu)化后新增加的,其目的就是為了大大優(yōu)化鎖的性能,所以在 JDK 1.6 中,使用 synchronized 的開銷也沒那么大了。其實從鎖有無鎖定來講,還是只有無鎖和重量級鎖,偏向鎖和輕量級鎖的出現(xiàn)就是增加了鎖的獲取性能而已,并沒有出現(xiàn)新的鎖。
所以我們的重點放在對 synchronized 重量級鎖的研究上,當(dāng) monitor 被某個線程持有后,它就會處于鎖定狀態(tài)。在 HotSpot 虛擬機(jī)中,monitor 的底層代碼是由 ObjectMonitor
實現(xiàn)的,其主要數(shù)據(jù)結(jié)構(gòu)如下(位于 HotSpot 虛擬機(jī)源碼 ObjectMonitor.hpp 文件,C++ 實現(xiàn)的)
這段 C++ 中需要注意幾個屬性:_WaitSet 、 _EntryList 和 _Owner,每個等待獲取鎖的線程都會被封裝稱為 ObjectWaiter
對象。
_Owner 是指向了 ObjectMonitor 對象的線程,而 _WaitSet 和 _EntryList 就是用來保存每個線程的列表。
那么這兩個列表有什么區(qū)別呢?這個問題我和你聊一下鎖的獲取流程你就清楚了。
鎖的兩個列表
當(dāng)多個線程同時訪問某段同步代碼時,首先會進(jìn)入 _EntryList 集合,當(dāng)線程獲取到對象的 monitor 之后,就會進(jìn)入 _Owner 區(qū)域,并把 ObjectMonitor 對象的 _Owner 指向為當(dāng)前線程,并使 _count + 1,如果調(diào)用了釋放鎖(比如 wait)的操作,就會釋放當(dāng)前持有的 monitor ,owner = null, _count - 1,同時這個線程會進(jìn)入到 _WaitSet 列表中等待被喚醒。如果當(dāng)前線程執(zhí)行完畢后也會釋放 monitor 鎖,只不過此時不會進(jìn)入 _WaitSet 列表了,而是直接復(fù)位 _count 的值。
Klass Pointer 表示的是類型指針,也就是對象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)通過這個指針來確定這個對象是哪個類的實例。
你可能不是很理解指針是個什么概念,你可以簡單理解為指針就是指向某個數(shù)據(jù)的地址。
實例數(shù)據(jù) Instance Data
實例數(shù)據(jù)部分是對象真正存儲的有效信息,也是代碼中定義的各個字段的字節(jié)大小,比如一個 byte 占 1 個字節(jié),一個 int 占用 4 個字節(jié)。
對齊 Padding
對齊不是必須存在的,它只起到了占位符(%d, %c 等)的作用。這就是 JVM 的要求了,因為 HotSpot JVM 要求對象的起始地址必須是 8 字節(jié)的整數(shù)倍,也就是說對象的字節(jié)大小是 8 的整數(shù)倍,不夠的需要使用 Padding 補(bǔ)全。
對象訪問定位的方式有哪些?
我們創(chuàng)建一個對象的目的當(dāng)然就是為了使用它,但是,一個對象被創(chuàng)建出來之后,在 JVM 中是如何訪問這個對象的呢?一般有兩種方式:通過句柄訪問和 通過直接指針訪問。
如果使用句柄訪問方式的話,Java 堆中可能會劃分出一塊內(nèi)存作為句柄池,引用(reference)中存儲的是對象的句柄地址,而句柄中包含了對象的實例數(shù)據(jù)與類型數(shù)據(jù)各自具體的地址信息。如下圖所示。
如果使用直接指針訪問的話,Java 堆中對象的內(nèi)存布局就會有所區(qū)別,棧區(qū)引用指示的是堆中的實例數(shù)據(jù)的地址,如果只是訪問對象本身的話,就不會多一次直接訪問的開銷,而對象類型數(shù)據(jù)的指針是存在于方法區(qū)中,如果定位的話,需要多一次直接定位開銷。如下圖所示
這兩種對象訪問方式各有各的優(yōu)勢,使用句柄最大的好處就是引用中存儲的是句柄地址,對象移動時只需改變句柄的地址就可以,而無需改變對象本身。
使用直接指針來訪問速度更快,它節(jié)省了一次指針定位的時間開銷,由于對象訪問在 Java 中非常頻繁,因為這類的開銷也是值得優(yōu)化的地方。
上面聊到了對象的兩種數(shù)據(jù),一種是對象的實例數(shù)據(jù),這沒什么好說的,就是對象實例字段的數(shù)據(jù),一種是對象的類型數(shù)據(jù),這個數(shù)據(jù)說的是對象的類型、父類、實現(xiàn)的接口和方法等。
如何判斷對象已經(jīng)死亡?
我們大家知道,基本上所有的對象都在堆中分布,當(dāng)我們不再使用對象的時候,垃圾收集器會對無用對象進(jìn)行回收??,那么 JVM 是如何判斷哪些對象已經(jīng)是"無用對象"的呢?
這里有兩種判斷方式,首先我們先來說第一種:引用計數(shù)法。
引用計數(shù)法的判斷標(biāo)準(zhǔn)是這樣的:在對象中添加一個引用計數(shù)器,每當(dāng)有一個地方引用它時,計數(shù)器的值就會加一;當(dāng)引用失效時,計數(shù)器的值就會減一;只要任何時刻計數(shù)器為零的對象就是不會再被使用的對象。雖然這種判斷方式非常簡單粗暴,但是往往很有用,不過,在 Java 領(lǐng)域,主流的 Hotspot 虛擬機(jī)實現(xiàn)并沒有采用這種方式,因為引用計數(shù)法不能解決對象之間的循環(huán)引用問題。
循環(huán)引用問題簡單來講就是兩個對象之間互相依賴著對方,除此之外,再無其他引用,這樣虛擬機(jī)無法判斷引用是否為零從而進(jìn)行垃圾回收操作。
還有一種判斷對象無用的方法就是可達(dá)性分析算法。
當(dāng)前主流的 JVM 都采用了可達(dá)性分析算法來進(jìn)行判斷,這個算法的基本思路就是通過一系列被稱為GC Roots
的根對象作為起始節(jié)點集,從這些節(jié)點開始,根據(jù)引用關(guān)系向下搜索,搜索過程走過的路徑被稱為引用鏈
(Reference Chain),如果某個對象到 GC Roots 之間沒有任何引用鏈相連接,或者說從 GC Roots 到這個對象不可達(dá)時,則證明此這個對象是無用對象,需要被垃圾回收。
這種引用方式如下
如上圖所示,從枚舉根節(jié)點 GC Roots 開始進(jìn)行遍歷,object 1 、2、3、4 是存在引用關(guān)系的對象,而 object 5、6、7 之間雖然有關(guān)聯(lián),但是它們到 GC Roots 之間是不可達(dá)的,所以被認(rèn)為是可以回收的對象。
在 Java 技術(shù)體系中,可以作為 GC Roots 進(jìn)行檢索的對象主要有
在虛擬機(jī)棧(棧幀中的本地變量表)中引用的對象。
方法區(qū)中類靜態(tài)屬性引用的對象,比如 Java 類的引用類型靜態(tài)變量。
方法區(qū)中常量引用的對象,比如字符串常量池中的引用。
在本地方法棧中 JNI 引用的對象。
JVM 內(nèi)部的引用,比如基本數(shù)據(jù)類型對應(yīng)的 Class 對象,一些異常對象比如 NullPointerException、OutOfMemoryError 等,還有系統(tǒng)類加載器。
所有被 synchronized 持有的對象。
還有一些 JVM 內(nèi)部的比如 JMXBean、JVMTI 中注冊的回調(diào),本地代碼緩存等。
根據(jù)用戶所選的垃圾收集器以及當(dāng)前回收的內(nèi)存區(qū)域的不同,還可能會有一些對象臨時加入,共同構(gòu)成 GC Roots 集合。
雖然我們上面提到了兩種判斷對象回收的方法,但無論是引用計數(shù)法還是判斷 GC Roots 都離不開引用
這一層關(guān)系。
這里涉及到到強(qiáng)引用、軟引用、弱引用、虛引用的引用關(guān)系,你可以閱讀作者的這一篇文章
小心點,別被當(dāng)成垃圾回收了。
如何判斷一個不再使用的類?
判斷一個類型屬于"不再使用的類"需要滿足下面這三個條件
- 這個類所有的實例已經(jīng)被回收,也就是 Java 堆中不存在該類及其任何這個類字類的實例加載這個類的類加載器已經(jīng)被回收,但是類加載器一般很難會被回收,除非這個類加載器是為了這個目的設(shè)計的,比如 OSGI、JSP 的重加載等,否則通常很難達(dá)成。這個類對應(yīng)的 Class 對象沒有任何地方被引用,無法在任何時刻通過反射訪問這個類的屬性和方法。
虛擬機(jī)允許對滿足上面這三個條件的無用類進(jìn)行回收操作。
JVM 分代收集理論有哪些?
一般商業(yè)的虛擬機(jī),大多數(shù)都遵循了分代收集的設(shè)計思想,分代收集理論主要有兩條假說。
第一個是強(qiáng)分代假說,強(qiáng)分代假說指的是 JVM 認(rèn)為絕大多數(shù)對象的生存周期都是朝生夕滅的;
第二個是弱分代假說,弱分代假說指的是只要熬過越多次垃圾收集過程的對象就越難以回收(看來對象也會長心眼)。
就是基于這兩個假說理論,JVM 將堆
區(qū)劃分為不同的區(qū)域,再將需要回收的對象根據(jù)其熬過垃圾回收的次數(shù)分配到不同的區(qū)域中存儲。
JVM 根據(jù)這兩條分代收集理論,把堆區(qū)劃分為新生代(Young Generation)和老年代(Old Generation)這兩個區(qū)域。在新生代中,每次垃圾收集時都發(fā)現(xiàn)有大批對象死去,剩下沒有死去的對象會直接晉升到老年代中。
上面這兩個假說沒有考慮對象的引用關(guān)系,而事實情況是,對象之間會存在引用關(guān)系,基于此又誕生了第三個假說,即跨代引用假說(Intergeneration Reference Hypothesis),跨代引用相比較同代引用來說僅占少數(shù)。
正常來說存在相互引用的兩個對象應(yīng)該是同生共死的,不過也會存在特例,如果一個新生代對象跨代引用了一個老年代的對象,那么垃圾回收的時候就不會回收這個新生代對象,更不會回收老年代對象,然后這個新生代對象熬過一次垃圾回收進(jìn)入到老年代中,這時候跨代引用才會消除。
根據(jù)跨代引用假說,我們不需要因為老年代中存在少量跨代引用就去直接掃描整個老年代,也不用在老年代中維護(hù)一個列表記錄有哪些跨代引用,實際上,可以直接在新生代中維護(hù)一個記憶集(Remembered Set),由這個記憶集把老年代劃分稱為若干小塊,標(biāo)識出老年代的哪一塊會存在跨代引用。
記憶集的圖示如下
從圖中我們可以看到,記憶集中的每個元素分別對應(yīng)內(nèi)存中的一塊連續(xù)區(qū)域是否有跨代引用對象,如果有,該區(qū)域會被標(biāo)記為“臟的”(dirty),否則就是“干凈的”(clean)。這樣在垃圾回收時,只需要掃描記憶集就可以簡單地確定跨代引用的位置,是個典型的空間換時間的思路。
聊一聊 JVM 中的垃圾回收算法?
在聊具體的垃圾回收算法之前,需要明確一點,哪些對象需要被垃圾收集器進(jìn)行回收?也就是說需要先判斷哪些對象是"垃圾"?
判斷的標(biāo)準(zhǔn)我在上面如何判斷對象已經(jīng)死亡的問題中描述了,有兩種方式,一種是引用計數(shù)法,這種判斷標(biāo)準(zhǔn)就是給對象添加一個引用計數(shù)器,引用這個對象會使計數(shù)器的值 + 1,引用失效后,計數(shù)器的值就會 -1。但是這種技術(shù)無法解決對象之間的循環(huán)引用問題。
還有一種方式是 GC Roots,GC Roots 這種方式是以 Root 根節(jié)點為核心,逐步向下搜索每個對象的引用,搜索走過的路徑被稱為引用鏈,如果搜索過后這個對象不存在引用鏈,那么這個對象就是無用對象,可以被回收。GC Roots 可以解決循環(huán)引用問題,所以一般 JVM 都采用的是這種方式。
解決循環(huán)引用代碼描述:
public class test{
public static void main(String[]args){
A a = new A();
B b = new B();
a=null;
b=null;
}
}
class A {
public B b;
}
class B {
public A a;
}
基于 GC Roots 的這種思想,發(fā)展出了很多垃圾回收算法,下面我們就來聊一聊這些算法。
標(biāo)記-清除算法
標(biāo)記-清除(Mark-Sweep)這個算法可以說是最早最基礎(chǔ)的算法了,標(biāo)記-清除顧名思義分為兩個階段,即標(biāo)記和清除階段:首先標(biāo)記出所有需要回收的對象,在標(biāo)記完成后,統(tǒng)一回收掉所有被標(biāo)記的對象。當(dāng)然也可以標(biāo)記存活的對象,回收未被標(biāo)記的對象。這個標(biāo)記的過程就是垃圾判定的過程。
后續(xù)大部分垃圾回收算法都是基于標(biāo)記-算法思想衍生的,只不過后續(xù)的算法彌補(bǔ)了標(biāo)記-清除算法的缺點,那么它有什么缺點呢?主要有兩個
- 執(zhí)行效率不穩(wěn)定,因為假如說堆中存在大量無用對象,而且大部分需要回收的情況下,這時必須進(jìn)行大量的標(biāo)記和清除,導(dǎo)致標(biāo)記和清除這兩個過程的執(zhí)行效率隨對象的數(shù)量增長而降低。內(nèi)存碎片化,標(biāo)記-清除算法會在堆區(qū)產(chǎn)生大量不連續(xù)的內(nèi)存碎片。碎片太多會導(dǎo)致在分配大對象時沒有足夠的空間,不得不進(jìn)行一次垃圾回收操作。
標(biāo)記算法的示意圖如下
標(biāo)記-復(fù)制算法
由于標(biāo)記-清除算法極易產(chǎn)生內(nèi)存碎片,研究人員提出了標(biāo)記-復(fù)制算法,標(biāo)記-復(fù)制算法也可以簡稱為復(fù)制算法,復(fù)制算法是一種半?yún)^(qū)復(fù)制,它會將內(nèi)存大小劃分為相等的兩塊,每次只使用其中的一塊,用完一塊再用另外一塊,然后再把用過的一塊進(jìn)行清除。雖然解決了部分內(nèi)存碎片的問題,但是復(fù)制算法也帶來了新的問題,即復(fù)制開銷,不過這種開銷是可以降低的,如果內(nèi)存中大多數(shù)對象是無用對象,那么就可以把少數(shù)的存活對象進(jìn)行復(fù)制,再回收無用的對象。
不過復(fù)制算法的缺陷也是顯而易見的,那就是內(nèi)存空間縮小為原來的一半,空間浪費太明顯。標(biāo)記-復(fù)制算法示意圖如下
現(xiàn)在 Java 虛擬機(jī)大多數(shù)都是用了這種算法來回收新生代,因為經(jīng)過研究表明,新生代對象 98% 都熬不過第一輪收集,因此不需要按照 1 :1 的比例來劃分新生代的內(nèi)存空間。
基于此,研究人員提出了一種 Appel 式回收,Appel 式回收的具體做法是把新生代分為一塊較大的 Eden 空間和兩塊 Survivor 空間,每次分配內(nèi)存都只使用 Eden 和其中的一塊 Survivor 空間,發(fā)生垃圾收集時,將 Eden 和 Survivor 中仍然存活的對象一次性復(fù)制到另外一塊 Survivor 空間上,然后直接清理掉 Eden 和已使用過的 Survivor 空間。
在主流的 HotSpot 虛擬機(jī)中,默認(rèn)的 Eden 和 Survivor 大小比例是 8:1,也就是每次新生代中可用內(nèi)存空間為整個新生代容量的 90%,只有一個 Survivor 空間,所以會浪費掉 10% 的空間。這個 8:1 只是一個理論值,也就是說,不能保證每次都有不超過 10% 的對象存活,所以,當(dāng)進(jìn)行垃圾回收后如果 Survivor 容納不了可存活的對象后,就需要其他內(nèi)存空間來進(jìn)行幫助,這種方式就叫做內(nèi)存擔(dān)保(Handle Promotion) ,通常情況下,作為擔(dān)保的是老年代。
標(biāo)記-整理算法
標(biāo)記-復(fù)制算法雖然解決了內(nèi)存碎片問題,但是沒有解決復(fù)制對象存在大量開銷的問題。為了解決復(fù)制算法的缺陷,充分利用內(nèi)存空間,提出了標(biāo)記-整理算法。該算法標(biāo)記階段和標(biāo)記-清除一樣,但是在完成標(biāo)記之后,它不是直接清理可回收對象,而是將存活對象都向一端移動,然后清理掉端邊界以外的內(nèi)存。具體過程如下圖所示:
什么是記憶集,什么是卡表?記憶集和卡表有什么關(guān)系?
為了解決跨代引用問題,提出了記憶集這個概念,記憶集是一個在新生代中使用的數(shù)據(jù)結(jié)構(gòu),它相當(dāng)于是記錄了一些指針的集合,指向了老年代中哪些對象存在跨代引用。
記憶集的實現(xiàn)有不同的粒度
- 字長精度:每個記錄精確到一個字長,機(jī)器字長就是處理器的尋址位數(shù),比如常見的 32 位或者 64 位處理器,這個精度決定了機(jī)器訪問物理內(nèi)存地址的指針長度,字中包含跨代指針。對象精度:每個記錄精確到一個對象,該對象里含有跨代指針。卡精度:每個記錄精確到一塊內(nèi)存區(qū)域,區(qū)域內(nèi)含有跨代指針。
其中卡精度是使用了卡表作為記憶集的實現(xiàn),關(guān)于記憶集和卡表的關(guān)系,大家可以想象成是 HashMap 和 Map 的關(guān)系。
什么是卡頁?
卡表其實就是一個字節(jié)數(shù)組
CARD_TABLE[this address >> 9] = 0;
字節(jié)數(shù)組 CARD_TABLE 的每一個元素都對應(yīng)著內(nèi)存區(qū)域中一塊特定大小的內(nèi)存塊,這個內(nèi)存塊就是卡頁,一般來說,卡頁都是 2 的 N 次冪字節(jié)數(shù),通過上面的代碼我們可以知道,卡頁一般是 2 的 9 次冪,這也是 HotSpot 中使用的卡頁,即 512 字節(jié)。
一個卡頁的內(nèi)存通常包含不止一個對象,只要卡頁中有一個對象的字段存在跨代指針,那就將對應(yīng)卡表的數(shù)組元素的值設(shè)置為 1,稱之為這個元素變臟
了,沒有標(biāo)示則為 0 。在垃圾收集時,只要篩選出卡表中變臟的元素,就能輕易得出哪些卡頁內(nèi)存塊中包含跨代指針,然后把他們加入 GC Roots 進(jìn)行掃描。
所以,卡頁和卡表主要用來解決跨代引用問題的。
什么是寫屏障?寫屏障帶來的問題?
如果有其他分代區(qū)域中對象引用了本區(qū)域的對象,那么其對應(yīng)的卡表元素就會變臟,這個引用說的就是對象賦值,也就是說卡表元素會變臟發(fā)生在對象賦值的時候,那么如何在對象賦值的時候更新維護(hù)卡表呢?
在 HotSpot 虛擬機(jī)中使用的是寫屏障(Write Barrier) 來維護(hù)卡表狀態(tài)的,這個寫屏障和我們內(nèi)存屏障完全不同,希望讀者不要搞混了。
這個寫屏障其實就是一個 Aop 切面,在引用對象進(jìn)行賦值時會產(chǎn)生一個環(huán)形通知(Around),環(huán)形通知就是切面前后分別產(chǎn)生一個通知,因為這個又是寫屏障,所以在賦值前的部分寫屏障叫做寫前屏障,在賦值后的則叫做寫后屏障。
寫屏障會帶來兩個問題
無條件寫屏障帶來的性能開銷
每次對引用的更新,無論是否更新了老年代對新生代對象的引用,都會進(jìn)行一次寫屏障操作。顯然,這會增加一些額外的開銷。但是,掃描整個老年代相比較,這個開銷就低得多了。
不過,在高并發(fā)環(huán)境下,寫屏障又帶來了偽共享(false sharing)問題。
高并發(fā)下偽共享帶來的性能開銷
在高并發(fā)情況下,頻繁的寫屏障很容易發(fā)生偽共享(false sharing),從而帶來性能開銷。
假設(shè) CPU 緩存行大小為 64 字節(jié),由于一個卡表項占 1 個字節(jié),這意味著,64 個卡表項將共享同一個緩存行。
HotSpot 每個卡頁為 512 字節(jié),那么一個緩存行將對應(yīng) 64 個卡頁一共 64*512 = 32K B。
如果不同線程對對象引用的更新操作,恰好位于同一個 32 KB 區(qū)域內(nèi),這將導(dǎo)致同時更新卡表的同一個緩存行,從而造成緩存行的寫回、無效化或者同步操作,間接影響程序性能。
一個簡單的解決方案,就是不采用無條件的寫屏障,而是先檢查卡表標(biāo)記,只有當(dāng)該卡表項未被標(biāo)記過才將其標(biāo)記為臟的。
這就是 JDK 7 中引入的解決方法,引入了一個新的 JVM 參數(shù) -XX:+UseCondCardMark,在執(zhí)行寫屏障之前,先簡單的做一下判斷。如果卡頁已被標(biāo)識過,則不再進(jìn)行標(biāo)識。
簡單理解如下:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
與原來的實現(xiàn)相比,只是簡單的增加了一個判斷操作。
雖然開啟 -XX:+UseCondCardMark 之后多了一些判斷開銷,但是卻可以避免在高并發(fā)情況下可能發(fā)生的并發(fā)寫卡表問題。通過減少并發(fā)寫操作,進(jìn)而避免出現(xiàn)偽共享問題(false sharing)。
什么是三色標(biāo)記法?三色標(biāo)記法會造成哪些問題?
根據(jù)可達(dá)性算法的分析可知,如果要找出存活對象,需要從 GC Roots 開始遍歷,然后搜索每個對象是否可達(dá),如果對象可達(dá)則為存活對象,在 GC Roots 的搜索過程中,按照對象和其引用是否被訪問過這個條件會分成下面三種顏色:
- 白色:白色表示 GC Roots 的遍歷過程中沒有被訪問過的對象,出現(xiàn)白色顯然在可達(dá)性分析剛剛開始的階段,這個時候所有對象都是白色的,如果在分析結(jié)束的階段,仍然是白色的對象,那么代表不可達(dá),可以進(jìn)行回收?;疑夯疑硎緦ο笠呀?jīng)被訪問過,但是這個對象的引用還沒有訪問完畢。黑色:黑色表示此對象已經(jīng)被訪問過了,而且這個對象的引用也已經(jīng)被訪問了。
注:如果標(biāo)記結(jié)束后對象仍為白色,意味著已經(jīng)“找不到”該對象在哪了,不可能會再被重新引用。
現(xiàn)代的垃圾回收器幾乎都借鑒了三色標(biāo)記的算法思想,盡管實現(xiàn)的方式不盡相同:比如白色/黑色集合一般都不會出現(xiàn)(但是有其他體現(xiàn)顏色的地方)、灰色集合可以通過棧/隊列/緩存日志等方式進(jìn)行實現(xiàn)、遍歷方式可以是廣度/深度遍歷等等。
三色標(biāo)記法會造成兩種問題,這兩種問題所出現(xiàn)的環(huán)境都是由于用戶環(huán)境和收集器并行工作造成的 。當(dāng)用戶線程正在修改引用關(guān)系,此時收集器在回收引用關(guān)系,此時就會造成把原本已經(jīng)消亡的對象標(biāo)記為存活,如果出現(xiàn)這種狀況的話,問題不大,下次再讓收集器重新收集一波就完了,但是還有一種情況是把存活的對象標(biāo)記為死亡,這種狀況就會造成不可預(yù)知的后果。
針對上面這兩種對象消失問題,業(yè)界有兩種處理方式,一種是增量更新(Incremental Update) ,一種是原是快照(Snapshot At The Beginning, SATB)。
請你介紹一波垃圾收集器
垃圾收集器是面試的常考,也是必考點,只要涉及到 JVM 的相關(guān)問題,都會圍繞著垃圾收集器來做一波展開,所以,有必要了解一下這些垃圾收集器。
垃圾收集器有很多,不同商家、不同版本的 JVM 所提供的垃圾收集器可能會有很大差別,我們主要介紹 HotSpot 虛擬機(jī)中的垃圾收集器。
垃圾收集器是垃圾回收算法的具體實現(xiàn),我們上面提到過,垃圾回收算法有標(biāo)記-清除算法、標(biāo)記-整理、標(biāo)記-復(fù)制,所以對應(yīng)的垃圾收集器也有不同的實現(xiàn)方式。
我們知道,HotSpot 虛擬機(jī)中的垃圾收集都是分代回收的,所以根據(jù)不同的分代,可以把垃圾收集器分為
新生代收集器:Serial、ParNew、Parallel Scavenge;
老年代收集器:Serial Old、Parallel Old、CMS;
整堆收集器:G1;
Serial 收集器
Serial 收集器是一種新生代的垃圾收集器,它是一個單線程工作的收集器,使用復(fù)制算法來進(jìn)行回收,單線程工作不是說這個垃圾收集器只有一個,而是說這個收集器在工作時,必須暫停其他所有工作線程,這種暴力的暫停方式就是 Stop The World,Serial 就好像是寡頭壟斷一樣,只要它一發(fā)話,其他所有的小弟(線程)都得給它讓路。Serial 收集器的示意圖如下:
SefePoint 全局安全點:它就是代碼中的一段特殊的位置,在所有用戶線程到達(dá) SafePoint 之后,用戶線程掛起,GC 線程會進(jìn)行清理工作。
雖然 Serial 有 STW 這種顯而易見的缺點,不過,從其他角度來看,Serial 還是很討喜的,它還有著優(yōu)于其他收集器的地方,那就是簡單而高效,對于內(nèi)存資源首先的環(huán)境,它是所有收集器中額外內(nèi)存消耗最小的,對于單核處理器或者處理器核心較少的環(huán)境來說,Serial 收集器由于沒有線程交互開銷,所以 Serial 專心做垃圾回收效率比較高。
ParNew 收集器
ParNew 是 Serial 的多線程版本,除了同時使用多條線程外,其他參數(shù)和機(jī)制(STW、回收策略、對象分配規(guī)則)都和 Serial 完全一致,ParNew 收集器的示意圖如下:
雖然 ParNew 使用了多條線程進(jìn)行垃圾回收,但是在單線程環(huán)境下它絕對不會比 Serial 收集效率更高,因為多線程存在線程交互的開銷,但是隨著可用 CPU 核數(shù)的增加,ParNew 的處理效率會比 Serial 更高效。
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一款新生代收集器,它同樣是基于標(biāo)記-復(fù)制算法實現(xiàn)的,而且它也能夠并行收集,這么看來,表面上 Parallel Scavenge 與 ParNew 非常相似,那么它們之間有什么區(qū)別呢?
Parallel Scavenge 的關(guān)注點主要在達(dá)到一個可控制的吞吐量上面。吞吐量就是處理器用于運行用戶代碼的時間與處理器總消耗時間的比。也就是
這里給大家舉一個吞吐量的例子,如果執(zhí)行用戶代碼的時間 + 運行垃圾收集的時間總共耗費了 100 分鐘,其中垃圾收集耗費掉了 1 分鐘,那么吞吐量就是 99%。停頓時間越短就越適合需要與用戶交互或需要保證服務(wù)響應(yīng)質(zhì)量,良好的響應(yīng)速度可以提升用戶體驗,而高吞吐量可以最高效率利用處理器資源。
Serial Old 收集器
前面介紹了一下 Serial,我們知道它是一個新生代的垃圾收集,使用了標(biāo)記-復(fù)制算法。而這個 Serial Old 收集器卻是 Serial 的老年版本,它同樣也是一個單線程收集器,使用的是標(biāo)記-整理算法,Serial Old 收集器有兩種用途:一種是在 JDK 5 和之前的版本與 Parallel Scavenge 收集器搭配使用,另外一種用法就是作為 CMS
收集器的備選,CMS 垃圾收集器我們下面說,Serial Old 的收集流程如下
Parallel Old 收集器
前面我們介紹了 Parallel Scavenge 收集器,現(xiàn)在來介紹一下 Parallel Old 收集器,它是 Parallel Scavenge 的老年版本,支持多線程并發(fā)收集,基于標(biāo)記 - 整理算法實現(xiàn),JDK 6 之后出現(xiàn),吞吐量優(yōu)先可以考慮 Parallel Scavenge + Parallel Old 的搭配
CMS 收集器
CMS
收集器的主要目標(biāo)是獲取最短的回收停頓時間,它的全稱是 Concurrent Mark Sweep,從這個名字就可以知道,這個收集器是基于標(biāo)記 - 清除算法實現(xiàn)的,而且支持并發(fā)收集,它的運行過程要比上面我們提到的收集器復(fù)雜一些,它的工作流程如下:
- 初始標(biāo)記(CMS initial mark)并發(fā)標(biāo)記(CMS concurrent mark)重新標(biāo)記(CMS remark)并發(fā)清除(CMS concurrent sweep)
對于上面這四個步驟,初始標(biāo)記和并發(fā)標(biāo)記都需要 Stop The World,初始標(biāo)記只是標(biāo)記一下和 GC Roots 直接關(guān)聯(lián)到的對象,速度較快;并發(fā)標(biāo)記階段就是從 GC Roots 的直接關(guān)聯(lián)對象開始遍歷整個對象圖的過程。這個過程時間比較長但是不需要停頓用戶線程,也就是說與垃圾收集線程一起并發(fā)運行。并發(fā)標(biāo)記的過程中,可能會有錯標(biāo)或者漏標(biāo)的情況,此時就需要在重新標(biāo)記一下,最后是并發(fā)清除階段,清理掉標(biāo)記階段中判斷已經(jīng)死亡的對象。
CMS 的收集過程如下
CMS 是一款非常優(yōu)秀的垃圾收集器,但是沒有任何收集器能夠做到完美的程度,CMS 也是一樣,CMS 至少有三個缺點:
CMS 對處理器資源非常敏感,在并發(fā)階段,雖然不會造成用戶線程停頓,但是卻會因為占用一部分線程而導(dǎo)致應(yīng)用程序變慢,降低總吞吐量。
CMS 無法處理浮動垃圾,有可能出現(xiàn)Concurrent Mode Failure失敗進(jìn)而導(dǎo)致另一次完全 Stop The World的 Full GC 產(chǎn)生。
什么是浮動垃圾呢?由于并發(fā)標(biāo)記和并發(fā)清理階段,用戶線程仍在繼續(xù)運行,所以程序自然而然就會伴隨著新的垃圾不斷出現(xiàn),而且這一部分垃圾出現(xiàn)在標(biāo)記結(jié)束之后,CMS 無法處理這些垃圾,所以只能等到下一次垃圾回收時在進(jìn)行清理。這一部分垃圾就被稱為浮動垃圾。
CMS 最后一個缺點是并發(fā)-清除的通病,也就是會有大量的空間碎片出現(xiàn),這將會給分配大對象帶來困難。
Garbage First 收集器
Garbage First 又被稱為 G1 收集器,它的出現(xiàn)意味著垃圾收集器走過了一個里程碑,為什么說它是里程碑呢?因為 G1 這個收集器是一種面向局部的垃圾收集器,HotSpot 團(tuán)隊開發(fā)這個垃圾收集器為了讓它替換掉 CMS 收集器,所以到后來,JDK 9 發(fā)布后,G1 取代了 Parallel Scavenge + Parallel Old 組合,成為服務(wù)端默認(rèn)的垃圾收集器,而 CMS 則不再推薦使用。
之前的垃圾收集器存在回收區(qū)域的局限性,因為之前這些垃圾收集器的目標(biāo)范圍要么是整個新生代、要么是整個老年代,要么是整個 Java 堆(Full GC),而 G1 跳出了這個框架,它可以面向堆內(nèi)存的任何部分來組成回收集(Collection Set,CSet),衡量垃圾收集的不再是哪個分代,這就是 G1 的 Mixed GC 模式。
G1 是基于 Region 來進(jìn)行回收的,Region 就是堆內(nèi)存中任意的布局,每一塊 Region 都可以根據(jù)需要扮演 Eden 空間、Survivor 空間或者老年代空間,收集器能夠?qū)Σ煌?Region 角色采用不同的策略來進(jìn)行處理。Region 中還有一塊特殊的區(qū)域,這塊區(qū)域就是 Humongous 區(qū)域,它是專門用來存儲大對象的,G1 認(rèn)為只要大小超過了 Region 容量一半的對象即可判定為大對象。如果超過了 Region 容量的大對象,將會存儲在連續(xù)的 Humongous Region 中,G1 大多數(shù)行為都會把 Humongous Region 作為老年代來看待。
G1 保留了新生代(Eden Suvivor)和老年代的概念,但是新生代和老年代不再是固定的了。它們都是一系列區(qū)域的動態(tài)集合。
G1 收集器的運作過程可以分為以下四步:
- 初始標(biāo)記:這個步驟也僅僅是標(biāo)記一下 GC Roots 能夠直接關(guān)聯(lián)到的對象;并修改 TAMS 指針的值(每一個 Region 都有兩個 RAMS 指針),使得下一階段用戶并發(fā)運行時,能夠在可用的 Region 中分配對象,這個階段需要暫停用戶線程,但是時間很短。這個停頓是借用 Minor GC 的時候完成的,所以可以忽略不計。并發(fā)標(biāo)記:從 GC Root 開始對堆中對象進(jìn)行可達(dá)性分析,遞歸掃描整個堆中的對象圖,找出要回收的對象。當(dāng)對象圖掃描完成后,重新處理 SATB 記錄下的在并發(fā)時有引用的對象;最終標(biāo)記:對用戶線程做一個短暫的暫停,用于處理并發(fā)階段結(jié)束后遺留下來的少量 SATB 記錄(一種原始快照,用來記錄并發(fā)標(biāo)記中某些對象)篩選回收:負(fù)責(zé)更新 Region 的統(tǒng)計數(shù)據(jù),對各個 Region 的回收價值和成本進(jìn)行排序,根據(jù)用戶所期望的停頓時間來制定回收計劃,可以自由選擇多個 Region 構(gòu)成回收集,然后把決定要回收的那一部分 Region 存活對象復(fù)制到空的 Region 中,再清理掉整個舊 Region 的全部空間。這里的操作設(shè)計對象的移動,所以必須要暫停用戶線程,由多條收集器線程并行收集
從上面這幾個步驟可以看出,除了并發(fā)標(biāo)記外,其余三個階段都需要暫停用戶線程,所以,這個 G1 收集器并非追求低延遲,官方給出的設(shè)計目標(biāo)是在延遲可控的情況下盡可能的提高吞吐量,擔(dān)任全功能收集器的重任。
下面是 G1 回收的示意圖
G1 收集器同樣也有缺點和問題:
- 第一個問題就是 Region 中存在跨代引用的問題,我們之前知道可以用記憶集來解決跨代引用問題,不過 Region 中的跨代引用要復(fù)雜很多;第二個問題就是如何保證收集線程與用戶線程互不干擾的運行?CMS 使用的是增量更新算法,G1 使用的是原始快照(SATB),G1 為 Region 分配了兩塊 TAMS 指針,把 Region 中的一部分空間劃分出來用于并發(fā)回收過程中的新對象分配,并發(fā)回收時新分配的對象地址都必須在這兩個指針位置以上。如果內(nèi)存回收速度趕不上內(nèi)存分配速度,G1 收集器也要凍結(jié)用戶線程執(zhí)行,導(dǎo)致 Full GC 而產(chǎn)生長時間的 STW。第三個問題是無法建立可預(yù)測的停頓模型。
JVM 常用命令介紹
下面介紹一下 JVM 中常用的調(diào)優(yōu)、故障處理等工具。
- jps :虛擬機(jī)進(jìn)程工具,全稱是 JVM Process Status Tool,它的功能和 Linux 中的 ps 類似,可以列出正在運行的虛擬機(jī)進(jìn)程,并顯示虛擬機(jī)執(zhí)行主類 Main Class 所在的本地虛擬機(jī)唯一 ID,雖然功能比較單一,但是這個命令絕對是使用最高頻的一個命令。jstat:虛擬機(jī)統(tǒng)計信息工具,用于監(jiān)視虛擬機(jī)各種運行狀態(tài)的信息的命令行工具,它可以顯示本地或者遠(yuǎn)程虛擬機(jī)進(jìn)程中的類加載、內(nèi)存、垃圾收集、即時編譯等運行時數(shù)據(jù)。jinfo:Java 配置信息工具,全稱是 Configuration Info for Java,它的作用是可以實時調(diào)整虛擬機(jī)各項參數(shù)。jmap:Java 內(nèi)存映像工具,全稱是 Memory Map For Java,它用于生成轉(zhuǎn)儲快照,用來排查內(nèi)存占用情況jhat:虛擬機(jī)堆轉(zhuǎn)儲快照分析工具,全稱是 JVM Heap Analysis Tool,這個指令通常和 jmap 一起搭配使用,jhat 內(nèi)置了一個 HTTP/Web 服務(wù)器,生成轉(zhuǎn)儲快照后可以在瀏覽器中查看。不過,一般還是 jmap 命令使用的頻率比較高。jstack:Java 堆棧跟蹤工具,全稱是 Stack Trace for Java ,顧名思義,這個命令用來追蹤堆棧的使用情況,用于虛擬機(jī)當(dāng)前時刻的線程快照,線程快照就是當(dāng)前虛擬機(jī)內(nèi)每一條正在執(zhí)行的方法堆棧的集合。
什么是雙親委派模型?
JVM 類加載默認(rèn)使用的是雙親委派模型,那么什么是雙親委派模型呢?
這里我們需要先介紹一下三種類加載器:
- 啟動類加載器,Bootstrap Class Loader,這個類加載器是 C++ 實現(xiàn)的,它是 JVM 的一部分,這個類加載器負(fù)責(zé)加載存放在 <JAVA_HOME>lib 目錄,啟動類加載器無法被 Java 程序直接引用。這也就是說,JDK 中的常用類的加載都是由啟動類加載器來完成的。擴(kuò)展類加載器,Extension Class Loader,這個類加載器是 Java 實現(xiàn)的,它負(fù)責(zé)加載 <JAVA_HOME>libext 目錄。應(yīng)用程序類加載器,Application Class Loader,這個類加載器是由 sum.misc.Launcher$AppClassLoader 來實現(xiàn),它負(fù)責(zé)加載 ClassPath 上所有的類庫,如果應(yīng)用程序中沒有定義自己的類加載器,默認(rèn)使用就是這個類加載器。
所以,我們的 Java 應(yīng)用程序都是由這三種類加載器來相互配合完成的,當(dāng)然,用戶也可以自己定義類加載器,即 User Class Loader,這幾個類加載器的模型如下
上面這幾類類加載器構(gòu)成了不同的層次結(jié)構(gòu),當(dāng)我們需要加載一個類時,子類加載器并不會馬上去加載,而是依次去請求父類加載器加載,一直往上請求到最高類加載器:啟動類加載器。當(dāng)啟動類加載器加載不了的時候,依次往下讓子類加載器進(jìn)行加載。這就是雙親委派模型。
雙親委派模型的缺陷?
在雙親委派模型中,子類加載器可以使用父類加載器已經(jīng)加載的類,而父類加載器無法使用子類加載器已經(jīng)加載的。這就導(dǎo)致了雙親委派模型并不能解決所有的類加載器問題。
Java 提供了很多外部接口,這些接口統(tǒng)稱為 Service Provider Interface, SPI,允許第三方實現(xiàn)這些接口,而這些接口卻是 Java 核心類提供的,由 Bootstrap Class Loader 加載,而一般的擴(kuò)展接口是由 Application Class Loader 加載的,Bootstrap Class Loader 是無法找到 SPI 的實現(xiàn)類的,因為它只加載 Java 的核心庫。它也不能代理給 Application Class Loader,因為它是最頂層的類加載器。
雙親委派機(jī)制的三次破壞
雖然雙親委派機(jī)制是 Java 強(qiáng)烈推薦給開發(fā)者們的類加載器的實現(xiàn)方式,但是并沒有強(qiáng)制規(guī)定你必須就要這么實現(xiàn),所以,它一樣也存在被破壞的情況,實際上,歷史上一共出現(xiàn)三次雙親委派機(jī)制被破壞的情況:
- 雙親委派機(jī)制第一次被破壞發(fā)生在雙親委派機(jī)制出現(xiàn)之前,由于雙親委派機(jī)制 JDK 1.2 之后才引用的,但類加載的概念在 Java 剛出現(xiàn)的時候就有了,所以引用雙親委派機(jī)制之前,設(shè)計者們必須兼顧開發(fā)者們自定義的一些類加載器的代碼,所以在 JDK 1.2 之后的 java.lang.ClassLoader 中添加了一個新的 findClass 方法,引導(dǎo)用戶編寫類加載器邏輯的時候重寫這個 findClass 方法,而不是基于 loadClass編寫。雙親委派機(jī)制第二次被破壞是由于它自己模型導(dǎo)致的,由于它只能向上(基礎(chǔ))加載,越基礎(chǔ)的類越由上層加載器加載,所以如果基礎(chǔ)類型又想要調(diào)用用戶的代碼,該怎么辦?這也就是我們上面那個問題所說的 SPI 機(jī)制。那么 JDK 團(tuán)隊是如何做的呢?它們引用了一個 線程上下文類加載器(Thread Context ClassLoader),這個類加載器可以通過 java.lang.Thread 類的 setContextClassLoader 進(jìn)行設(shè)置,如果創(chuàng)建時線程還未設(shè)置,它將會從父線程中繼承,如果全局沒有設(shè)置類加載器的話,這個 ClassLoader 就是默認(rèn)的類加載器。這種行為雖然是一種犯規(guī)行為,但是 Java 代碼中的 JNDI、JDBC 等都是使用這種方式來完成的。直到 JDK 6 ,引用了 java.util.ServiceLoader,使用 META-INF/services + 責(zé)任鏈的設(shè)計模式,才解決了 SPI 的這種加載機(jī)制。雙親委派機(jī)制第三次被破壞是由于用戶對程序的動態(tài)需求使熱加載、熱部署的引入所致。由于時代的變化,我們希望 Java 能像鼠標(biāo)鍵盤一樣實現(xiàn)熱部署,即時加載(load class),引入了 OSGI,OSGI 實現(xiàn)熱部署的關(guān)鍵在于它自定義類加載器機(jī)制的實現(xiàn),OSGI 中的每一個 Bundle 也就是模塊都有一個自己的類加載器。當(dāng)需要更換 Bundle 時,就直接把 Bundle 連同類加載器一起替換掉就能夠?qū)崿F(xiàn)熱加載。在 OSGI 環(huán)境下,類加載器不再遵從雙親委派機(jī)制,而是使用了一種更復(fù)雜的加載機(jī)制。
常見的 JVM 調(diào)優(yōu)參數(shù)有哪些?
- -Xms256m:初始化堆大小為 256m;-Xmx2g:最大內(nèi)存為 2g;-Xmn50m:新生代的大小50m;-XX:+PrintGCDetails 打印 gc 詳細(xì)信息;-XX:+HeapDumpOnOutOfMemoryError 在發(fā)生OutOfMemoryError錯誤時,來 dump 出堆快照;-XX:NewRatio=4 設(shè)置年輕的和老年代的內(nèi)存比例為 1:4;-XX:SurvivorRatio=8 設(shè)置新生代 Eden 和 Survivor 比例為 8:2;-XX:+UseSerialGC 新生代和老年代都用串行收集器 Serial + Serial Old-XX:+UseParNewGC 指定使用 ParNew + Serial Old 垃圾回收器組合;-XX:+UseParallelGC 新生代使用 Parallel Scavenge,老年代使用 Serial Old-XX:+UseParallelOldGC:新生代 ParallelScavenge + 老年代 ParallelOld 組合;-XX:+UseConcMarkSweepGC:新生代使用 ParNew,老年代使用 CMS;-XX:NewSize:新生代最小值;-XX:MaxNewSize:新生代最大值-XX:MetaspaceSize 元空間初始化大小-XX:MaxMetaspaceSize 元空間最大值
后記
這篇文章是 JVM 面試題的第二版,新增了很多內(nèi)容,寫的時間也比較長了,如果你覺得文章還不錯的話,大家三連走起!另外,分享到朋友圈是對我莫大的支持,感謝!不騙你,看完真的需要一小時。