全面解析JVM,超詳細!

語言: CN / TW / HK

寫在前面:

小夥伴兒們,大家好!今天來學習Java虛擬機相關內容,作為面試必問的知識點,來深入瞭解一波!

思維導圖:

image-20201123102602304
image-20201123102602304

1,JVM是什麼?

1.1,概述

JVM是Java Virtual Machine(Java虛擬機)的縮寫,JVM是一種用於計算設備的規範。引入Java虛擬機後,Java語言在不同平台上運行時不需要重新編譯。Java語言編譯程序只需生成在Java虛擬機上運行的目標代碼(字節碼),就可以在多種平台上不加修改地運行。任何平台只要裝有針對於該平台的Java虛擬機,字節碼文件(.class)就可以在該平台上運行。這就是“一次編譯,多次運行”。

所謂java能實現跨平台,是由在不同平台上運行不同的虛擬機決定的,因此java文件的執行不直接在操作系統上執行,而是通過jvm虛擬機執行,我們可以從這張圖看到,JVM並沒有直接與硬件打交道,而是與操作系統交互用以執行java程序。

image-20201117155559970
image-20201117155559970

1.2,JVM運行流程

image-20201117165944476
image-20201117165944476

這個是JVM的組成圖,由四個部分組成:

  • 類加載器

    ​ 類加載器的作用是加載類文件到內存。比如我們執行一個.java程序的文件,首先使用javac命令進行編譯,生成.class文件。然後我們需要用類加載器將字節碼文件加載到內存中去,通過jvm後續的模塊進行加載執行程序。至於是否能夠執行,則由執行引擎負責。

  • 執行引擎

    ​ 執行引擎也叫解釋器,負責解釋命令,提交操作系統執行。

  • 本地接口

    ​ 它的作用是融合不同的編程語言為Java所用,目前該方法使用的是越來越少了,除非是與硬件有關的應用,比如通過Java程序驅動打印機,或者Java系統管理生產設備。

  • 運行時數據區

    ​ 運行數據區是整個JVM的重點。我們所有寫的程序都被加載到這裏,之後才開始運行,Java生態系統如此的繁榮,得益於該區域的優良自治。整個JVM框架由加載器加載文件,然後執行器在內存中處理數據,需要與異構系統交互是可以通過本地接口進行!

2,JVM的內存區域

​ 內存區域也就是上面的運行時數據區。對於從事C或者C++的程序員來説,必須對每個對象的整個生命週期負責。但是對java程序員來説,在jvm的自動內存管理機制下,不需要為每一個對象去寫delete或者free代碼,不容易出現內存泄漏或內存溢出的問題。但正因為java程序員將內存管理權力交給了內存管理機制,所以一旦出現內存泄漏或者內存溢出的問題,在對jvm內存結構不清楚的情況下,排查錯誤將會成為一項非常複雜且困難的工作。

運行時數據區

image-20201118101357320
image-20201118101357320

2.1,程序計算器

程序計數器是一小塊的內存區域,可以看做當前線程執行字節碼的行號指示器,在虛擬機的概念模型裏,字節碼解釋工作就是通過改變這個程序計數器的值來選取下一個要執行的字節碼指令。比如分支控制,循環控制,跳轉,異常等操作,線程恢復等功能都是通過這個計數器來完成。

由於jvm的多線程是通過線程的輪流切換並分配處理器執行時間來實現的。因此,在任何一個確定的時刻,一個處理器(對於多核處理器來説是一個內核)都只會執行一條線程中的指令。因此,為了線程切換後能回到正確的執行位置,每條線程都需要自己獨有的程序計數器,多條線程計數器之間互不影響,獨立存儲。我們稱這類內存區域為線程私有的內存區域。

如果線程執行的是Java方法時,程序計數器記錄的是 Java 虛擬機正在執行的字節碼指令的地址,而在線程執行 Native 方法時,程序計數器為空,因為此時 Java 虛擬機調用是和操作系統相關的接口,接口的實現不是 Java 語言,而是 C語言和 C++。

程序計數器是唯一一個在Java虛擬機中不會出現 OutOfMemoryError 的內存區域,它的生命週期隨着線程的創建而創建,隨着線程的結束而結束。

2.2,Java虛擬機棧

與程序計數器一致,Java虛擬機棧也是線程私有的,生命週期與線程相同。虛擬機棧描述的是Java方法的執行內存模型,每個方法在執行的時候都會創建一個棧幀(用於存儲局部變量表、操作數棧、動態鏈棧、方法出口等信息)。每一個方法從執行到結束的過程,就對應一個棧幀從入棧到出棧的過程。

Java內存可以粗糙地分為堆內存(Heap)棧內存(Stack),當然Java內存區域的劃分實際上遠比這複雜,我們現在所説的Java虛擬機棧就是這裏的棧內存,或者説是虛擬機棧中局部變量表部分

局部變量表存放了編譯器可知的四類八種基本數據類型(boolean、byte、char、short、int、float、long、double),對象引用(reference類型,它不同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)。

Java虛擬機會出現兩種異常狀況:

如果線程在棧中申請的深度大於虛擬機所允許的深度,將出現StackOverFlowError異常; 如果虛擬機棧可以動態擴展,且擴展無法申請到足夠的內存,就會拋出OutOfMemoryError異常。

2.3,本地方法棧

本地方法棧與虛擬機棧的作用非常類似,區別是:虛擬機棧為虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native 方法服務,在 HotSpot 虛擬機中直接將虛擬機棧和本地方法棧合二為一。

與Java虛擬機棧一樣,本地方法棧在執行的時候也會創建一個棧幀(用於存儲局部變量表、操作數棧、動態鏈棧、方法出口等信息)。也會拋出StackOverFlowError異常和OutOfMemoryError異常。

2.4,Java堆

Java堆是JVM所管理的內存中最大的一塊區域,Java堆是被所有線程所共享的一片區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都在這裏分配內存。

Java堆是垃圾收集器管理的主要區域,因此也被稱作GC堆(Garbage Collected Heap)。從內存回收的角度看,由於現在收集器基本都採用分代垃圾收集算法,所以Java堆還可以細分為:新生代和老年代。**進一步劃分的目的是更好地回收內存,或者更快地分配內存。**根據JVM的規範規定,Java堆可以處於物理上不連續的內存空間,只要邏輯上是連續的即可。如果在堆中沒有完成內存分配,且堆也沒有可擴展的內存空間,則會拋出OutOfMemoryError異常。

2.5,方法區

方法區與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。雖然Java虛擬機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

Java虛擬機相對而言對方法區的限制非常寬鬆,除了和堆一樣不需要連續的空間可以選擇固定大小或者可擴展之外,還可以選擇不實現垃圾回收。相對而言,垃圾回收在這個區域算比較少見了,但並非數據進入方法區以後就可以實現永久存活了,這個區域的回收目標主要是常量池的回收和對類型的卸載,一般來説,這個區域的回收成績是比較難以讓人滿意的。尤其是類型的卸載,條件相當苛刻。根據Java虛擬機規範規定,當方法區無法滿足內存分配時,將拋出OutOfMemoryError異常。

我們在這裏舉一個簡單例子來看看,看看上述的哪些信息會存放上方法區中;

靜態變量和常量,在編譯期間就放在方法區中;

//靜態變量,在編譯期間存放在方法區
private static int num=10;
//常量,在編譯期間存放在方法區
private final String name="boy";
複製代碼

我們先來看看new String時堆中的變化;

String s1="hello";
String s2=new String("hello");
String s3=new String("hello");
System.out.println(s1==s3);  // false
System.out.println(s2==s3);  // false
複製代碼

這個輸出的結果肯定是false,採用new的時候會在堆內存開闢一塊空間存放hello對象,雖然s2和s3指向的內容相同,但是棧種存放的地址不同,所以是不相等的。

image-20201121145752820
image-20201121145752820

對於引用類型來説,"=="指的是地址值的比較。

雙引號直接寫的字符串是在常量池之中,而new的對象則不在池之中。

再來看看運行期間添加進常量池的;

String s2=new String("hello");
String s3=new String("hello");
//在運行過程中添加進常量池中
System.out.println(s2.intern()==s3.intern());
複製代碼
image-20201121162239889
image-20201121162239889

如果常量池中存在當前字符串,那麼直接返回常量池中該對象的引用

如果常量池中沒有此字符串, 會將此字符串引用保存到常量池中後, 再直接返回該字符串的引用

2.6,運行時常量池

運行時常量池是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有常量池信息(用於存放編譯期生成的各種字面量和符號引用)。既然運行時常量池時方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。

2.7,直接內存

直接內存並不屬於Jvm運行時數據區的一部分,但是這部分內存區域被頻繁的調用,也可能發生OutOfMemoryError異常。顯然本機的直接內存不會受到Java堆分配內存的影響,但是既然是內存,肯定要受到本機總內存大小以及處理器尋址空間的限制。服務器管理員在配置虛擬機參數時,會根據實際內存設置-Xmx等參數信息,但經常忽略直接內存。使得各個區域的內存總和大於物理內存限制,從而導致動態擴展時出現OutOfMemoryError異常。

3,Java對象的創建過程

下面這張圖就是Java對象創建的過程,總共來説分為五部分;

image-20201122105639783
image-20201122105639783

3.1,類加載過程

虛擬機遇到一條 new 指令時,首先將去檢查這個指令的參數是否能在常量池中定位到這個類的符號引用,並且檢查這個符號引用代表的類是否已被加載過、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。

3.2,分配內存

在類加載檢查通過後,接下來虛擬機將為新生對象分配內存。對象所需的內存大小在類加載完成後便可確定,為對象分配空間的任務等同於把一塊確定大小的內存從 Java 堆中劃分出來。分配方式“指針碰撞”“空閒列表” 兩種,選擇哪種分配方式由 Java 堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定

指針碰撞:

  • 場景:Java堆中內存是絕對規整的;
  • 原理:所有用過的內存都放在一邊,空閒的內存放在另外一邊,中間放一個指針作為分界點的指示器,分配內存時只需要把那個指針向空閒空間那邊挪動一段與對象大小相等的距離就可以了;
  • GC收集器:Serial、ParNew等帶Compact過程的收集器。

空閒列表:

  • 場景:Java堆中內存不是規整的;
  • 原理:虛擬機會維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄;
  • GC收集器:CMS基於Mark-Sweep算法的收集器。

內存分配併發的問題

在創建對象的時候還需要考慮的一個問題就是在併發情況下,線程是否安全的問題。因為創建對象在虛擬機中是非常頻繁的行為,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況。因此必須要保證線程安全,解決這個問題有兩種方案:

  • **CAS以及失敗重試(比較和交換機制):**對分配內存空間的操作進行同步處理——實際上虛擬機採用CAS配上失敗重試的方式保證更新操作的原子性。CAS操作需要輸入兩個數值,一箇舊值(操作前期望的值)和一個新值,在操作期間先比較舊值有沒有發送變化,如果沒有變化,才交換成新值,否則不進行交換。
  • **TLAB(分配緩衝):**把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊私有內存,也就是本地線程分配緩衝。TLAB的目的是在為新對象分配內存空間時,讓每個Java應用線程能在使用自己專屬的分配指針來分配空間,減少同步開銷。

3.3,初始化零值

內存分配完成後,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭),這一步操作保證了對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。

3.4,設置對象頭

初始化零值完成之後,虛擬機要對對象進行必要的設置,例如這個對象是那個類的實例、如何才能找到類的元數據信息、對象的哈希嗎、對象的 GC 分代年齡等信息。 這些信息存放在對象頭中。 另外,根據虛擬機當前運行狀態的不同,如是否啟用偏向鎖等,對象頭會有不同的設置方式。

3.5,執行Init方法

在上面工作都完成之後,從虛擬機的視角來看,一個新的對象已經產生了,但從 Java 程序的視角來看,對象創建才剛開始,<init> 方法還沒有執行,所有的字段都還為零。所以一般來説,執行 new 指令之後會接着執行 <init> 方法,把對象按照程序員的意願進行初始化,這樣一個真正可用的對象才算完全產生出來。

4,對象的訪問定位

建立對象就是為了使用對象,我們的Java程序通過棧上的 reference 數據來操作堆上的具體對象。對象的訪問方式由虛擬機實現而定,目前主流的訪問方式有使用句柄直接指針兩種。

4.1,使用句柄

如果使用句柄的話,那麼Java堆中將會劃分出一塊內存來作為句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。如圖所示:

通過句柄訪問對象
通過句柄訪問對象

4.2,直接指針

如果使用直接指針訪問,那麼 Java 堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息,而reference 中存儲的直接就是對象的地址。如圖所示:

通過直接指針訪問對象
通過直接指針訪問對象

這兩種對象訪問方式各有優勢,**使用句柄來訪問的最大好處就是reference 中存儲的是穩定的句柄地址,**在對象被移動時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。**使用直接指針訪問方式最大的好處就是速度更快,**它節省了一次指針定位的時間開銷。由於對象的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常樂觀的執行成本。

5,OutOfMemoryError(內存溢出)異常

在Java虛擬機規範的描述中,除了程序計算器之外,**虛擬機內存的其他幾個運行時區域都有發生OutOfMemoryError異常的可能。**現在我們通過兩個實例來驗證異常發生的場景,也會初步介紹幾個與內存相關的最基本的虛擬機參數。

5.1,堆內存異常

我們來演示一下堆內存的異常:

/**
 * @author 公眾號:程序員的時光
 * @create 2020-11-23 08:54
 * @description
 */

public class HeapOOM {
    public static void main(String[] args) {
        //測試堆內存異常
        List<HeapOOM> heapOOMList=new ArrayList<>();
        //這裏只添加一個對象,不會發生異常
        heapOOMList.add(new HeapOOM());
        //添加進死循環,不斷地new對象,堆內存已經耗盡
        while (true) {
            heapOOMList.add(new HeapOOM());
        }
    }
}

複製代碼

在運行這個程序之前,我們先要設置Java虛擬機的參數。由於IDEA默認設置的堆內存很大,所以我們需要單個配置;點擊Run >> Edit Configurations,然後就開始配置,如下,初始化堆內存和最大堆內存都設置為10m,看看上面的死循環能否在10m內存中完成;

image-20201123091045402
image-20201123091045402

我們來看運行結果:

image-20201123091246230
image-20201123091246230

可以看到堆內存發生異常,上面的死循環中我們不斷地new對象,導致堆內存已經耗盡,無法為新生的對象分配內存,從而發生異常

5.2,棧內存異常

再來看看棧內存異常:

/**
 * @author 公眾號:程序員的時光
 * @create 2020-11-23 09:14
 * @description
 */

public class StackOOM {
    public static void main(String[] args) {
        test();
    }

    //我們設置一個簡單的遞歸方法,沒有跳出遞歸條件的話,就會發生棧內存異常
    public static void test(){
        test();
    }
}

複製代碼

我們設置一個簡單的遞歸方法,但是不給出跳出遞歸條件,這樣的話就會異。

運行結果如下:

image-20201123092455732
image-20201123092455732

這種是線程請求的棧深度超過虛擬機所允許的最大深度,拋出StackOverflowError異常,原因就是使用不合理的遞歸造成的。

我們再來看看第二種異常情況:

/**
 * @author 公眾號:程序員的時光
 * @create 2020-11-23 10:05
 * @description
 */

public class StackOOM1 {

    //線程任務,每個線程任務一直在執行
    private void WinStop(){
        while(true){
            System.out.println(System.currentTimeMillis());
        }
    }

    //不斷創建線程
    public void StackByThread(){
        while(true){
            Thread thread=new Thread(new Runnable() {
                @Override
                public void run() {
                    WinStop();
                }
            });
        }
    }

    public static void main(String[] args) {
        StackOOM1 stackOOM1=new StackOOM1();
        stackOOM1.StackByThread();
    }
}
複製代碼

上述代碼的理論上運行結果是:Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread,但是運行這段代碼可能會導致操作系統卡頓,運行須謹慎。

這種是虛擬機在擴展棧時無法申請到足夠的內存空間,拋出OutOfMemoryError異常,原因是不斷創建活躍的線程造成的。


微信搜索公眾號《程序員的時光》 好了,今天就先分享到這裏了,下期繼續給大家帶來JVM垃圾回收面試內容! 更多幹貨、優質文章,歡迎關注我的原創技術公眾號~

參考文獻:

深入理解Java虛擬機(第2版).周志明