Java虛擬機內存模型

作者:xcbeyond
瘋狂源自夢想,技術成就輝煌!微信公眾號:《程序猿技術大咖》號主,專注后端開發多年,擁有豐富的研發經驗,樂于技術輸出、分享,現階段從事微服務架構項目的研發工作,涉及架構設計、技術選型、業務研發等工作。對于Java、微服務、數據庫、Docker有深入了解,并有大量的調優經驗。

一、前言
Java虛擬機,簡稱JVM(Java Virtual Machine),是Java語言中最為核心的一個東西,Java程序運行離不開它,因為它的存在,使得Java擁有“一次編譯,多次運行”的特點。任何平臺只要裝有針對于該平臺的Java虛擬機,字節碼文件(.class)就可以在該平臺上運行。

JVM是Java中最難以理解、而且非常重要的知識點,也常常用來衡量一個人Java基本功是否牢靠,更是在面試中被問及最多、最頻繁的知識點之一。本文將從Java虛擬機內存模型開始入手,一步步來了解它。

Java虛擬機內存模型是Java程序運行的基礎,為了使Java應用程序正常運行,JVM將其內存數據分為程序計數器、虛擬機棧、本地方法棧、堆和方法區,如下圖所示:

(在JDK1.8開始,已經去掉了方法區的概念,用元空間(Metaspace)進行了代替.)

程序計數器用于存放下一條運行的指令;虛擬機棧和本地方法棧用于存放函數方法調用堆棧信息;Java堆用于存放Java程序運行時所需的對象等數據;方法區用于存放程序的元數據信息。

其中,一部分是線程私有的,而另一部分卻是線程共享的。

線程私有:程序計數器、虛擬機棧、本地方法棧
線程共享:堆、方法區
二、程序計數器
程序計數器是一塊很小的內存空間,用于存放下一條運行的指令,它是線程私有的,可以認作為當前線程的行號指示器。

由于Java是支持線程的語言,當線程數量超過CPU數量時,線程之間根據時間片輪詢搶奪CPU資源。對于單核CPU而言,每一時刻,只能有一個線程在運行,而其他線程必須被切換出去。為此,每一個線程都必須用一個獨立的程序計數器,來記錄下一條要運行的指令。各個線程之間的計數器互不影響,獨立工作。

如果當線程正在執行一個Java方法,則程序計數器記錄正在執行的Java字節碼地址,如果當前線程正在執行一個Native方法,則程序計數器為空。

三、虛擬機棧(棧)
棧保存的是方法的局部變量、部分結果,并參與方法的調用和返回,即:棧幀數據。

1.棧幀
每個方法被執行的時候都會創建一個棧幀用于存儲局部變量表、操作數棧、動態鏈接方法、返回地址等信息。每一個方法被調用的過程就對應一個棧幀在虛擬機棧中從入棧(方法調用)到出棧(方法返回)的過程。

棧幀結構如下圖所示:
 

如果方法調用時,方法的參數和局部變量相對較多,那么棧幀中局部變量表就會比較大,棧幀就很很大,因此,單個方法調用所需的??臻g大小也會很大。(在程序開發時,盡量避免這種情況,尤其是遞歸方法中要避免遞歸調用的深度)

以下代碼片段中,通過逐步設置遞歸方法調用的深度,將會拋出棧溢出異常(StackOverflowError)。






public class StackTest {
    // 遞歸次數
    private final int count = 100000;

    /**
     * 遞歸方法
     * @param num
     */
    public void recursionMethod(int num) {
        num++;
        if (num < count) {
            recursionMethod(num);
        }
    }

    @Test
    public void stackDepthTest() {
        recursionMethod(0);
    }
}

2.棧溢出、內存溢出
Java虛擬機規范中允許棧的大小是動態的或者是固定的,定義了兩種異常與??臻g相關:StackOverflowError和OutOfMemoryError。如果線程在計算過程中,請求的棧深度大于最大可用的棧深度,則會拋出StackOverflowError異常,如果棧能夠動態擴展,而在擴展過程中,沒有足夠的內存空間來支持棧的擴展,則會拋出OutOfMemoryError異常。

其中,可以使用JVM參數-Xss來調整設置棧的大小,從而決定了方法調用可以達到的深度。

針對上述代碼StackTest中,在遞歸次數為100000時,將-Xss參數調整為-Xss512M后,未拋出異常。
 

3.jclasslib工具
篇外話,但覺得還是有必要提出來,在研究JVM時,總是會去研究一些字節碼指令、Class類文件結構、大小等數據,而jclasslib工具恰恰滿足這些,有了它更有助于我們對Java、JVM有更深入的了解。

大家可根據自己的喜好,選擇安裝,有單機軟件版、IDE插件可供使用,在此,我選擇的是在idea中安裝了jclasslib插件,方便使用。此工具將伴隨著你在JVM的世界里翱翔,一探JVM究竟。

以上述代碼為例進行說明,如下圖所示,在idea中通過jclasslib插件查看StackTest.class文件,展開方法recursionMethod后,查看Code屬性的Misc頁簽中,當前方法的最大局部變量表的容量為2。因為在該方法中只有一個int類型的參數,所以共占2字。
 

關于jclasslib工具的更多使用技巧,在不斷的使用中去摸索吧。

四、本地方法棧
本地方法棧和虛擬機棧的功能很相似,虛擬機棧用于管理Java方法的調用,而本地方法棧用于管理本地方法的調用。

本地方法并不是用Java實現的,而是使用C實現的。本地方法棧保存的是native方法的信息,當一個JVM創建的線程調用native方法后,JVM不再為其在虛擬機棧中創建棧幀,JVM只是簡單地動態鏈接并直接調用native方法。

在Hot Spot虛擬機中,是不區分本地方法棧和虛擬機棧的。因此,本地方法棧一樣也會拋出異常StackOverflowError和OutOfMemoryError。

五、堆
堆可以說是Java運行時內存中最為重要的部分,幾乎所有的對象和數組都是在堆中分配空間的。堆分為新生代和老年代兩部分,新生代用于存放剛剛產生的對象和年輕的對象,如果對象一直沒有被收回,生存得足夠長,老年對象就會被移入老年代。

新生代又可以進一步細分為eden、survivor space0(s0或者from space)和survivor space1(s1或者to space)。eden稱之為伊甸園,即對象的出生地,大部分對象剛剛創建時,通常會存放在這里。s0和s1為survivor空間,直譯為幸存者,就是指存放其中的對象至少經歷了一次垃圾回收,并得以幸存。如果在幸存區的對象到了指定年齡仍未被回收,則有機會進入老年代。

換言之,堆空間簡單分為新生代和老年代,新生代用于存放剛產生的新對象,老年代則存放年長的對象(存放時間較長,經過垃圾回收次數較多的對象)。

堆空間結構如下圖所示:
 

六、方法區
方法區同堆一樣,是所有線程共享的內存區域,為了區分堆,又被稱為非堆。主要保存的信息是類的元數據,即類的類型信息、常量池、域信息、方法信息,如static修飾的變量加載類的時候就被加載到方法區中。

類型信息包括類的完整名稱、父類的完整名稱、類型修飾符(public/protected/private)和類型的直接接口類表;常量池包括這個類方法、域等信息所引用的常量信息;域信息包括域名稱、域類型和域修飾符;方法信息包括方法名稱、返回類型、方法參數、方法修飾符、方法字節碼、操作數棧和方法幀棧的局部變量區大小以及異常表??傊?,方法區內保存的信息,大部分都來自于class文件。

在Hot Spot虛擬機中,方法區也成為永久區,是一塊獨立于Java堆的內存空間。雖然叫做永久區,但是永久區中的對象,同樣也是可以被GC回收的。只是對于GC的表現和Java堆空間略不相同。對永久區GC的回收,通常主要從兩個方面分析:一是GC對永久區常量池的回收,二是永久區對類元數據的回收。

方法區也成為永久區,主要存放常量和類的定義信息。

(在JDK1.8的HotSpot虛擬機中,已經去掉了方法區的概念,用 Metaspace代替,并且將其移到了本地內存來規劃了。)