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