從零開始掌握 JVM
在當(dāng)今的軟件開發(fā)領(lǐng)域,Java 語言及其運行環(huán)境——Java 虛擬機(JVM)占據(jù)了舉足輕重的地位。無論是企業(yè)級應(yīng)用、Web 應(yīng)用還是移動應(yīng)用,JVM 都扮演著核心角色。然而,對于許多初學(xué)者來說,理解 JVM 的工作原理和內(nèi)部機制可能是一項挑戰(zhàn)。
本文將帶你從零開始,逐步了解 JVM 的基本概念、結(jié)構(gòu)與功能。無論你是剛剛接觸 Java 編程的新手,還是希望深入了解 JVM 內(nèi)部運作的技術(shù)愛好者,這篇文章都將為你提供全面而易懂的知識點介紹。
一、詳解JVM基礎(chǔ)概念
1.什么是JVM?JVM的作用是什么
JVM是Java設(shè)計者用于屏蔽多平臺差異,基于操作系統(tǒng)之上的一個"小型虛擬機",正是因為JVM的存在,使得Java應(yīng)用程序運行時不需要關(guān)注底層操作系統(tǒng)的差異。使得Java程序編譯只需編譯一次生成class字節(jié)碼,即可在任何操作系統(tǒng)都可以以相同的方式運行。
2.JVM運行時區(qū)域劃分
(1) JVM體系結(jié)概覽
因為JVM屏蔽了底層操作系統(tǒng)的差異,所以它自成了一套內(nèi)存結(jié)構(gòu)進行獨立的內(nèi)存管理,整體來說JVM可分為以下幾個部分:
- 方法區(qū)
- 堆區(qū)
- 虛擬機棧和本地方法棧
- 程序計數(shù)器
對應(yīng)的我們也給出一張宏觀的圖片:
(2) 方法區(qū)
我們先來說說方法區(qū),這里我們所說的方法區(qū)指的不是存放Java方法的區(qū)域,并且它也只是一個邏輯上的物理區(qū)域的概念,在不同的JDK版本中它的實現(xiàn)都會有所和不同,方法區(qū)主要存放的數(shù)據(jù)包括:
- 類信息:例如類名、父類名、接口列表、常量池、字段表、方法表等。
- 常量池:存儲編譯器生產(chǎn)的各種字面量和符號引用。
- 方法代碼:包括方法的字節(jié)碼指令和其他輔助信息,例如操作數(shù)棧和局部變量表等。
- 靜態(tài)變量:屬于類的各種靜態(tài)變量。
- 類的構(gòu)造器和初始化塊。
(3) 堆內(nèi)存
然后就是JVM的堆區(qū),對象實例和數(shù)組大部分都會存儲在這塊內(nèi)存空間中,注意筆者這里所說的一個強調(diào)——大部分,因為現(xiàn)代即時編譯技術(shù)的進步,在JVM進行逃逸分析時如果發(fā)現(xiàn)對象并未逃逸,則會直接進行棧上分配、標(biāo)量替換等手段將其分配在??臻g,并且java堆區(qū)是線程共享區(qū)域的,所以多線程情況下操作相同對象可能存在線程安全問題。
(4) 虛擬機棧
我們?nèi)粘ο髮嵗姆椒ㄕ{(diào)用都是在虛擬機棧上運行的,它是Java方法執(zhí)行的內(nèi)存模型,存儲著被執(zhí)行方法的局部變量表、動態(tài)鏈表、方法入口、棧的操作用(入棧和出棧)。
由于虛擬機棧是棧結(jié)構(gòu)所以方法調(diào)用按順序壓入棧中,就會倒序彈出虛擬機棧,例如我們的下面這段代碼:
public void a(){
b();
}
public void b(){
c();
}
public void c(){
}
當(dāng)線程調(diào)用a方法時,優(yōu)先為a產(chǎn)生一個棧幀A壓入棧中,發(fā)現(xiàn)a方法調(diào)用了b方法,再為b產(chǎn)生一個棧幀B壓入棧中,然后b再調(diào)用c方法,再為c產(chǎn)生一個棧幀C方法壓入棧中。
同理,執(zhí)行順序也是c先執(zhí)行結(jié)束,優(yōu)先彈出棧,然后是b,最后是a。由此我們可知Java中方法是可以嵌套調(diào)用的,但這并不意味方法可以無線層次的嵌套調(diào)用,當(dāng)方法嵌套調(diào)用深度超過了虛擬機棧規(guī)定的最大深度,就會拋出StackOverflowError,而這個錯誤也常常發(fā)生在我們編寫的無終止條件的遞歸代碼中。
虛擬機棧屬于線程獨享,所以也就沒有什么生命周期的概念,每個方法隨著調(diào)用的結(jié)束??臻g也隨之釋放,所以棧的生命周期也可以理解為和線程生命周期是一致的。
每個方法的調(diào)用都是往虛擬機棧中壓入一個棧幀,例如上述我們調(diào)用a方法,就是將a方法壓入棧幀,而每一個棧幀都有一個局部變量表,這個局部變量表用于記錄方法體內(nèi)的某些基本類型(byte、short、int、boolean、float、char、long、double)還有對象引用(不等同于對象本省,可能是一個指向?qū)ο笃鹗嫉刂返囊弥羔?和returnAddress(指向一條字節(jié)碼指令的地址)。
這些數(shù)據(jù)都會存儲在局部變量表的slot槽中,在某些情況下每個棧幀可能存在復(fù)用,我們不妨舉個例子,可以看到下面這段代碼就是在main方法上分配一個byte數(shù)組,我們添加一個-verbose:gc參數(shù)觀察gc回收情況:
public static void main(String[] args) {
byte[] placeHolder = new byte[1024 * 1024 * 64];
System.gc();
}
查看輸出結(jié)果可以看到byte數(shù)組空間沒有被回收,就是因為slot局部變量placeHolder 對應(yīng)的槽還沒有被其他變量所復(fù)用,這也就意味著此刻可達性算法分析認(rèn)為這塊placeHolder 不可被GC所以就不會被垃圾回收:
[GC (System.gc()) 86054K->68541K(243712K), 0.0023357 secs]
[Full GC (System.gc()) 68541K->68243K(243712K), 0.0203291 secs]
對此我們簡單調(diào)整一下代碼,將placeHolder 放在某個作用域里,只要執(zhí)行走出這個作用域,就意味著placeHolder 為無用的局部變量,后續(xù)新分配的a就會直接復(fù)用局部變量表的空間:
public static void main(String[] args) {
{
//placeHolder在代碼塊的作用域內(nèi)完成內(nèi)存分配
byte[] placeHolder = new byte[1024 * 1024 * 64];
}
//分配一個新的變量嘗試復(fù)用上述slot
int a = 0;
System.gc();
}
這也就是為什么本次gc可以回收64M的內(nèi)存空間的原因:
[GC (System.gc()) 86054K->68502K(243712K), 0.0023594 secs]
[Full GC (System.gc()) 68502K->2707K(243712K), 0.0221691 secs]
小結(jié)一下虛擬棧的特點:
- 是方法執(zhí)行時的內(nèi)存模型。
- 方法調(diào)用以棧幀形式壓入棧中。
- 方法嵌套調(diào)用并將棧幀壓入棧幀時,深度操作虛擬機棧最大深度會報StackOverflowError。
- 虛擬機棧的局部變量表隨著變量使用的完結(jié),之前的內(nèi)存區(qū)域可被復(fù)用。
- 棧的生命周期跟隨線程,線程調(diào)用結(jié)束棧即可被銷毀。
本地方法棧
下面這個帶有native關(guān)鍵字的方法就是在本地方法,它就是本地方法棧管理的方法,其工作機制和特點是虛擬機棧是差不多的,所以這里就不多做介紹了。
private native void start0();
(5) 程序計數(shù)器
程序計數(shù)器和我們操作系統(tǒng)學(xué)習(xí)的程序計數(shù)器概念差不多,是一塊比較小的內(nèi)存空間,我們可以將其看作當(dāng)前現(xiàn)場所執(zhí)行的字節(jié)碼行號的指示器,記錄著當(dāng)前線程下一條要執(zhí)行的指令的地址,對于程序中的分支、循環(huán)、跳轉(zhuǎn)、異常以及線程恢復(fù)和掛起都是基于這個計數(shù)器完成的。
我們以下面這段代碼為例展示一下程序計數(shù)器實質(zhì)記錄的信息:
public static void main(String[] args) {
int num = 1;
int num2 = 2;
int num3 = 3;
System.out.println("total: " + (num + num2 + num3));
}
可以看到實際上其編譯后的字節(jié)碼內(nèi)容如上,每一行指令前方所記錄的字節(jié)碼的偏移地址就是程序計數(shù)器所記錄的地址信息:
因為是每一個線程都有各自的計數(shù)器,所以我們可以認(rèn)為計數(shù)器是不會互相影響是線程安全的。需要注意的是程序計數(shù)器只有在記錄虛擬機棧的方法時才會有值,對于native方法,程序計數(shù)器是不工作的。
二、詳解JVM類加載器
1.什么是類加載器
類加載器實現(xiàn)將編譯后的class文件加載到內(nèi)存,并轉(zhuǎn)為為運行時區(qū)域劃分的運行時數(shù)據(jù)結(jié)構(gòu),注意類加載器只能決定類的加載,至于能不能運行則是由Execution Engine 來決定。
整體來說,類加載器對應(yīng)類的生命周期應(yīng)該是以下幾個階段:
- 加載
- 鏈接:分為驗證、準(zhǔn)備、解析
- 初始化
- 使用:此時用戶就可以基于這個類創(chuàng)建實例了
- 卸載
2.類的加載
加載的過程本質(zhì)上就是將class文件加載到JVM中,JVM根據(jù)類的全限定名獲取定義該類的二進制字節(jié)流。
- 將編譯后class文件加載到內(nèi)存。
- 將靜態(tài)數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換成方法區(qū)中運行時數(shù)據(jù)結(jié)構(gòu)。
- 在堆區(qū)創(chuàng)建一個java.lang.Class對象作為數(shù)據(jù)訪問的入口。
3.鏈接的過程
鏈接整體是分為上述所說的3個過程:
- 驗證:分為驗證階段主要是校驗類的文件格式、元數(shù)據(jù)、字節(jié)碼、二進制兼容性
- 準(zhǔn)備:在方法區(qū)為靜態(tài)變量常見空間,并對其進行初始化,例如private static int a=3;,在此階段就會在方法區(qū)完成創(chuàng)建,并初始默認(rèn)值0。
- 解析:即將類的符號引用直接轉(zhuǎn)換為直接引用,引用包括但不限于類、接口、字段、類方法、接口方法、方法類型、方法句柄、發(fā)文控制修飾符等,例如import java.util.ArrayList在此階段就會直接轉(zhuǎn)為指針或者對象地址。
4.初始化
將方法區(qū)中準(zhǔn)備好的值,通過調(diào)用<cinit>完成初始化工作。<cinit>會收集好所有的賦值動作,例如上文的private static int a=3就是這時候完成賦值的。
5.卸載
當(dāng)對象使用完成后,GC將無用對象從內(nèi)存中卸載。
6.類加載器的加載順序
其實類加載器并非只有一個,按照分類我們可以將其分為:
BootStrap ClassLoader:rt.jar
Extention ClassLoader: 加載擴展的jar包
App ClassLoader:指定的classpath下面的jar包
Custom ClassLoader:自定義的類加載器
所以,為了保證JDK自帶rt.jar的類能夠正常加載,就出現(xiàn)了一種名為雙親委派的類加載機制。
舉個例子,JDK自帶的包中有一個名為String的類,而我們自定義的代碼中也有一個String類,我們自己的類肯定是由App ClassLoader完成加載,如果我們的類加載器優(yōu)先執(zhí)行,那么JDK自帶的String類就無法被使用到。
所以雙親委派機制就規(guī)定了類加載優(yōu)先由BootStrap ClassLoader先加載,只有根加載器加載不到需要的類,才會交由下層類完成加載。 正是因為雙親委派機制的存在,jdk自帶的String類才能夠正常的使用,而我們也無法通過自定義String類進行重寫。
類加載器的工作流程為:
- 加載class文件到方法區(qū)并轉(zhuǎn)為運行時數(shù)據(jù)結(jié)構(gòu),并在堆區(qū)創(chuàng)建一個Class對象作為入口
- 驗證class的類方法是否由危害JVM的行為
- 準(zhǔn)備階段初始化靜態(tài)變量數(shù)據(jù)
- 解析階段將符號引用轉(zhuǎn)為可以可直接導(dǎo)向?qū)ο蟮刂返闹苯右?/li>
- 初始化階段通過cinit方法初始化對象實例變量等數(shù)據(jù)
- 使用完成后該類就會被卸載。
7.用一個線程的代碼執(zhí)行解釋Java文件是如何被運行的
如下所示,我們編寫一個Student 類,他有name這個成員屬性:
/**
* 學(xué)生類
*/
public class Student {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
然后我們編寫一個main方法,調(diào)用student類,完成屬性賦值。
public class Main {
public static void main(String[] args) throws InterruptedException {
Student student = new Student();
student.setName("小明");
}
}
首先編譯得到Main.class文件后,系統(tǒng)會啟動一個JVM進程,從classpath中找到這個class的二進制文件,將在到方法區(qū)的運行時數(shù)據(jù)區(qū)域:
再將當(dāng)前執(zhí)行的main方法壓入虛擬機棧中:
main方法中需要new Student();,JVM發(fā)現(xiàn)方法區(qū)中沒有Student類的信息,于是開始加載這個類,將這個類的信息存放到方法區(qū),并在堆區(qū)創(chuàng)建一個Class對象作為方法區(qū)信息的入口。
new Student();在此時就會根據(jù)類元信息獲取創(chuàng)建student對象所需要的空間大小,在堆區(qū)申請并開辟一個空間調(diào)用構(gòu)造函數(shù)創(chuàng)建Student實例。
main方法調(diào)用setName,student 的引用找到堆區(qū)的Student,通過其引用找到方法區(qū)中Student 類的方法表得到方法的字節(jié)碼地址,從而完成調(diào)用。
上述步驟完成后,方法按照入棧順序后進先出的彈出,虛擬機棧隨著線程一起銷毀。
三、詳解虛擬機堆
1.區(qū)域劃分
JVM將堆內(nèi)存分為年輕代和老年代。以及一個非堆內(nèi)存區(qū)域,我們稱為永久代,注意:這里所說的永久代只有在JDK8之前才會出現(xiàn),對此我們也給出JDK8之前版本的堆內(nèi)存區(qū)域劃分圖解:
在Java8之后考慮到與其他規(guī)范的虛擬機的兼容性以及GC效率,將方法區(qū)的實現(xiàn)交由元空間實現(xiàn),元空間所使用的內(nèi)存都是本地內(nèi)存,這里的本地內(nèi)存說的就是我們物理機上的內(nèi)存,所以理論上物理機內(nèi)存多大,元空間內(nèi)存就可以分配多大,元空間大小分配和JVM從物理機上分配的內(nèi)存大小沒有任何關(guān)系。
對應(yīng)的我們也給出元空間兩個設(shè)置參數(shù):
- MetaspaceSize:初始化元空間大小,控制發(fā)生GC
- MaxMetaspaceSize:限制元空間大小上限,防止占用過多物理內(nèi)存。
2.詳解新生代
我們再來聊聊年輕代,新生代又可以分為Eden和Survivor區(qū),Survivor區(qū)又被平均分為兩塊。所以年代整體比例為8:1:1。當(dāng)然這個值也可以通過-XX:+UsePSAdaptiveSurvivorSizePolicy來調(diào)整。
任何對象剛剛創(chuàng)建的時候都會放在Eden區(qū)。我們都知道堆區(qū)內(nèi)存是共享的,所以Eden區(qū)的空間也是多線程共享的,但是為了確保多線程彼此之間相對獨立(注意是線程之間彼此獨立而不是操作Eden區(qū)對象獨立),Eden區(qū)會專門劃出一塊連續(xù)的空間給每個線程分配一個獨立空間,這個空間叫做TLAB空間,每個線程都可以操作自己的TLAB空間和讀取其他線程的TLAB空間。
一旦Eden區(qū)滿了之后,就會觸發(fā)第一次Minor GC,就會將存活的對象從Eden區(qū)放到Survivor區(qū)。
需要注意的是,Survivor分為Survivor0和Survivor1區(qū)。JVM使用from和to兩個指針管理這兩塊區(qū)域,其中from指針指向有對象的區(qū)域空間,to指針指向空閑區(qū)域的Survivor空間。
從Eden區(qū)中存活下來首先會在Survivor0區(qū),一旦下一次Eden區(qū)空間滿了之后就再次觸發(fā)Minor GC 將Eden區(qū)和Survivor0區(qū)存活的對象復(fù)制到Survivor1區(qū),就這樣保存存活的對象在兩個Survivor區(qū)中來回游走,直到晉升到老年代:
經(jīng)過15次之后還活著的對象就會被存放到老年代,這里是15是由-XX:MaxTenuringThreshold指定的,-XX:MaxTenuringThreshold 占4位,默認(rèn)配置為15。 這里補充一下,同樣會將Survivor存放到老年代的第2個條件,當(dāng)Survivor區(qū)對象比例達到XX:TargetSurvivorRatio時,也會將存活的對象放到老年區(qū)。
3.詳解老年代
老年代存放的都是經(jīng)歷過無數(shù)次GC的老對象,一旦這個空間滿了之后就會出現(xiàn)一次Full GC,F(xiàn)ull GC期間所有線程都會停止手頭工作等待Full GC完成,所以在此期間,系統(tǒng)可能會出現(xiàn)卡頓現(xiàn)象。 這就意味著在高并發(fā)多對象創(chuàng)建場景的情況下,我們需要合理分配老年區(qū)的內(nèi)存。一旦Full GC后還是無法容納新對象,就會報OOM問題。
四、JVM如何判斷對象是否需要被銷毀
1.引用計數(shù)器法
一個對象被引用時+1,被解除引用時-1。我們根據(jù)引用計數(shù)結(jié)果決定是否GC,但是這種方式無法解決兩個對象互相引用的情況。例如我們棧區(qū)沒有一個引用指向當(dāng)前兩個對象,可堆區(qū)兩個對象卻互相引用對方。
2.可達性分析法
將一系列的GC ROOTS作為起始的存活對象集,查看是否有任意一個GC ROOTS可以到達這個對象,都不可達就說明這個對象要被回收了。
而以下幾種可以作為GC ROOTS:
- 虛擬機棧中的局部變量等,被該變量引用的對象不可回收。
- 方法區(qū)的靜態(tài)變量,被該變量引用的對象不可回收。
- 方法區(qū)的常量,被該變量引用的對象不可回收。
- 本地方法棧(即native修飾的方法),被該變量引用的對象不可回收。
- 未停止且正在使用該對象的線程,被該線程引用的對象不可回收。
通過可達性算法分析對象是否被回收需要經(jīng)過兩個階段:
- 可達性分析法發(fā)現(xiàn)不可達的對象后,就將其第一次標(biāo)記一下,然后判斷該對象的是否要執(zhí)行finalize()方法,若確定則將其存到F-Queue中。
- 將F-Queue中的對象調(diào)用finalize(),若此時還是沒有任何引用鏈引用,則說明這個對象要被回收了。
五、詳解幾種常見垃圾回收算法
1.標(biāo)記清除法
如下圖,這種算法很簡單,標(biāo)記出需要被回收的對象的空間,然后直接清除。同樣的缺點也很明顯,容易造成內(nèi)存碎片,內(nèi)存碎片也很好理解,回收的對象空間都是一小塊一小塊的,當(dāng)我們需要創(chuàng)建一個大對象時就沒有一塊連續(xù)大空間供其使用。
2.復(fù)制算法
這種算法和上文說的survivor一樣,將空間一分為二,from存放當(dāng)前活著的對象,to作為空閑空間。在進行回收時,將沒有被標(biāo)記回收的對象挪到另一個空間,然后from指向另一個空間。這種算法缺點也很明顯,可利用空間就一半。
3.標(biāo)記整理
這種算法算是復(fù)制算法的改良版,將存活對象全部挪動到一段,確??臻e和對象空間都是連續(xù)的,且空間利用率100%。
4.分代收集算法(綜合算法)
這種算法就是上面算法的組合,即年輕代存活率低,采用復(fù)制算法。老年代存活率高,采用標(biāo)記清除算法或者標(biāo)記整理算法。例如hotspot虛擬機的搭配就是新生代采用復(fù)制算法,每次觸發(fā)Minor gc就將Eden和survivor區(qū)存活的對象移動到to指針指向的survivor區(qū),而老年代而用標(biāo)記整理法將存活的對象都?xì)w整到同一個段中: