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