從零開始JVM(一):初探JVM執行時資料區域

raledong發表於2021-10-26

前言

最近重新開始閱讀《深入瞭解Java虛擬機器》這本書,就想著用一個系列文章來記錄和分享自己的心得。為什麼要說”重新“呢?是因為這本書我在多年前就買了,中間也曾翻來覆去的看過。這個”翻來覆去“可以說是非常的生動形象,因為我不僅從前往後看,也從後往前看了這本書。但是,這並不是一個值得驕傲的過程,因為我之前看的時候經常被卡住(俗稱看不懂),導致我中途放棄。再次拾起的時候為了多一些新鮮感,就嘗試從後往前看,事實證明效果依舊不佳。今年我又拿起這本書(生活所迫),這次閱讀下來,相比之前要流暢許多,可能是因為有了一些工作經驗吧(社會的毒打)。感覺這本書難以堅持閱讀主要有幾個幾個原因:

  1. 對計算機基本功有一些要求
    這本書其實對於初級開發者來說,是不建議閱讀的,因為它預設讀者已經瞭解計算機領域的很多基礎知識,包括作業系統、資料結構,編譯原理等,並要求有一些原始碼閱讀能力(這裡的原始碼還不是JAVA,而是C或者組合語言)。如果對這些沒有初步的認識,就很容易被滿書的專業術語帶跑(傳說中的認識每一個字,卻不知道這句話在說啥,這種感覺,我懂~),並最終從入門走向放棄。
  2. 真正開發過程中遇到的機會不多
    JVM對於JAVA工程師就像是灶臺之於廚師(我們對JVM的瞭解可能還不如廚師)。誰都知道去用它,它也很少出問題,但是一旦出問題了,我們就開始傻眼了。而這也導致我最初閱讀這本書的時候對很多例子難以感同身受,再加上實用機會不多,也無法活學活用。但是一旦走到工作中,JVM出問題的概率就增加了(雖然依然不多)。當系統頻繁的報警記憶體使用率過高或OOM異常時,我們也許就需要掏出這本書,給忙碌的系統降降溫。
  3. 不夠追求極致
    其實書中讓我觸動最深的是作者記錄了對Eclipse虛擬機器優化的實戰。正如上文所說,使用JVM優化的場景並不多,但是反過來想,這是否是因為我們不夠追求極致。程式碼的編譯速度是否還能提升?系統的啟動耗時是否還能縮短?Full GC頻率是否還能降低?作者當時是用的Eclipse啟動耗時並不差,但是他依然找到了這個優化場景,並靈活的運用JVM知識達到了預期效果。既然系統可以使用,那不妨讓它更好用,追求極致是推動程式設計師成長的最佳品質~

那麼既然這本書已經很好了,這一系列文章想要達到什麼目的呢?主要有兩個:

  1. 降低閱讀門檻
    上文提到的閱讀本書前需要提前瞭解的一些關聯知識,這個系列文章中都會進行介紹。不會那麼深入,但可以讓大家的閱讀更加連貫。
  2. 分享閱讀心得
    我在很多論壇上發過如何學習JVM,但是反饋寥寥。之前也在內網看到大神分享自己學習JVM的坎坷經歷,但是我的功力顯然不允許我直接手撕程式碼。因此希望這裡對閱讀的內容進行延伸,通過分享鞏固自己的認識。也希望大家閱讀文章後多給一些反饋,無論是文章中的誤區,還是工作中遇到的優化的例子,都來者不拒。

多執行緒基本模型

在開始介紹JVM之前,我們先來簡單瞭解一下現代計算機主要包含哪些部分,以及多執行緒執行的概念。
現代計算機的模型主要來源於最初的馮諾依曼模型,它主要由以下幾個部分組成:CPU,記憶體,磁碟和IO裝置(這裡僅給出最基礎的組成結構)。

現代計算機組成.png
其中,CPU負責計算,記憶體和磁碟負責儲存,二者的區別在於斷電後資料是否能夠持久化,IO裝置則是指所有獲得輸入輸出的裝置,如鍵盤,顯示器等。隨著計算機的發展,各個硬體的效能都得到顯著的提升,尤其是CPU的計算能力。從而導致其它操作,如磁碟的讀寫能力成為了瓶頸(可以理解為一次讀寫的耗時可以計算成千上萬條CPU指令)。因此作業系統引入了多程式模型,並隨後又引入了多執行緒模型,即在其中一個程式/執行緒在執行耗時較長且無需CPU參與的操作時,如讀取檔案,將CPU釋放出來交給另一個程式/執行緒使用。至於究竟是多程式併發還是多執行緒併發,則要看具體的作業系統設計。有的作業系統只能按照執行緒分配CPU時間,需要程式內部將時間繼續切片分給執行緒。程式、執行緒和CPU的總體關係如下,其中綠色的代表當前獲得CPU時鐘並執行的執行緒,

CPU分配執行緒_程式級別.png

Java從程式碼到執行的過程

接著我們來看程式碼是如何從我們看到的高階開發語言(如Java,C++等)變成可以執行的計算機指令。眾所周知,計算機不可能去理解每一種不同的高階開發語言的語義,它只能理解機器語言,如將記憶體位置A中的值+1,或者是讀取記憶體位置B的值並放入累加器。因此需要通過某種工具將高階開發語言轉義為機器可以理解的指令。而這個轉換的過程又可以分為編譯型和解釋型。

解釋型語言是在執行的時候才會編譯成機器可執行的指令,常見的解釋型語言有python、perl等。而編譯型語言則會先將高階語言編譯成可執行指令產物,再去執行,因此相對而言會先增加一個編譯的耗時,但是編譯產物可反覆執行。JAVA就是一種編譯型語言。
Java編譯執行過程.png
但是,JAVA和傳統的編譯型語言如C相比還多瞭解釋的步驟,它的編譯產物並非可執行檔案,而是位元組碼檔案(.class檔案),再通過JVM將位元組碼檔案解釋為可執行的機器指令進行執行。正是這一步使得Java成為一個支援跨平臺執行的語言,因為只需要編譯一次,其編譯產物就可以在各個平臺上執行。當然,這也意味著JVM是需要針對不同的平臺進行定製開發的。

JVM執行時資料區域

在介紹完Java從程式碼到執行所經歷的過程,我們瞭解了JVM在整個生命週期中負責將.class檔案解釋成機器指令並執行。既然它作為中間商承載了程式的執行,同樣的它就需要和計算機的各個元件進行互動並管理。而本文就將介紹JVM是如何進行記憶體區域的劃分和管理的。

如下圖所示,JVM將劃分得到的記憶體按照儲存的資料型別進一步區分,並劃分出如下幾個區域:程式計數器,Java虛擬機器棧,本地方法棧,Java堆和方法區。

JVM記憶體管理架構圖.png

這裡的每一個區域儲存著不同型別的資料,並且根據資料的特性會採取不同的記憶體回收機制。(記憶體回收不是本節的內容,但是瞭解區域的特性將有效的幫助理解為何採取相應的記憶體回收策略)

程式計數器

程式計數器並不是JVM特有的屬性,事實上作業系統中也存在程式計數器的概念,二者在程式執行中起到的功能其實是類似的。

正如上文提到,當今的作業系統是多執行緒並行的,每個執行緒都將在獲得CPU時鐘的時候執行當前執行緒需要完成的工作,並且在時鐘週期結束後進行新一輪的搶佔和分配。這也意味著沒有獲得時鐘週期的執行緒需要中斷並等待下一次分得時間片。因此每個執行緒需要記錄當前執行的進度,從而在重新獲得CPU時鐘時可以恢復執行。而JVM程式計數器就是用來記錄下一條需要執行的位元組碼指令(注意,這裡是位元組碼指令,作業系統的程式計數中記錄的就是機器指令了)

既然每個執行緒有各自獨立的程式計數器(這裡肯定不能共享啦,否則就會變成A執行緒獲得CPU時鐘時執行B執行緒指令),所以這一塊記憶體是執行緒私有的記憶體。

Java虛擬機器棧

Java虛擬機器棧描述的是Java方法執行的記憶體模型。這裡可以簡單介紹一下方法執行的記憶體模型。先讓我們回顧一下Java中的method。

public class Dog {
    private int weight;

    public void eat(Food f) {
        f.consume();
        Poop poop = new Poop();
        weight++;
    }
}

public class Food {
    private int bones;
    public void consume() {
        bones--;
    }
}

每一個Java方法包含方法名稱、入參和返回值(也可能是void),接著方法中可能會訪問別的方法,區域性變數或者是全域性變數等。以上文中的程式碼為例,如果我們呼叫new Dog().eat(new Food()),eat方法首先會呼叫物件f中的方法consume,這個方法中會訪問成員變數bones並將其值減一,接著eat方法會訪問自己的成員變數weight並將其值加一。如果瞭解過資料結構的同學就可以將整個過程想象為一個入棧出棧。

每訪問一個Java方法,本地方法棧中就會建立一個棧幀,棧幀中會儲存區域性變數表,運算元棧,動態連結,方法出口等資訊。而方法執行完成後,棧幀的生命週期也隨之結束。這也可以解釋為什麼方法內部建立的例項是執行緒安全的(前提是這個例項不會通方法返回或者其引用是方法區域外的)。

這裡再解釋一下上面提到的幾個概念:區域性變數表,運算元棧和動態連結

區域性變數表會儲存函式的引數,區域性變數和returnAddress型別,以上面一段虛擬碼為例,eat方法被呼叫時,它的區域性變數表中會包含方法的引數f和在方法中建立的物件poop,當然因為Food和Poop是物件,所以這裡儲存的其實是對這兩個物件的引用。因為這個方法沒有返回值,因此returnAddress型別為void。區域性變數表的大小在程式碼編譯期間就可以確定下來(不熟悉編譯的參考上文的Java程式碼執行流程圖)

運算元棧,顧名思義,是一個棧的資料結構,它用來儲存計算過程的中間結果,同時作為計算過程中變數臨時的儲存空間。不知道大家是否寫過用棧來實現複雜的四則運算的題目(非常有趣的題目,完美的利用了棧後進先出的特性),這裡運算元棧的功能與之類似,只不過完成的操作不僅四則運算,還有其它的指令,如對其它方法的呼叫並儲存返回值。同樣,運算元棧所需的記憶體大小也是在編譯時可以確定下來。

動態連結指向當前方法所在類的 執行時常量池, 這樣如果當前方法中如果需要呼叫其他方法的時候, 能夠從執行時常量池中找到對應的符號引用, 然後將符號引用轉換為直接引用,然後就能直接呼叫對應方法。換句話說,就是如果當前方法需要呼叫別的物件或者方法,就需要知道他們所處的記憶體位置。動態連結會記錄這些資訊,並在需要的時候將其轉化為記憶體位置並訪問。

Java虛擬機器棧中可能存在兩種異常,StackOverflowError和OutOfMemoryError,前者是執行緒請求的棧深度大於虛擬機器所允許的深度,常見於在迴圈中呼叫方法導致的死迴圈。而後者則可能出現於執行緒數過多的情況,導致記憶體分配不足以滿足需求。

正如其功能所示,Java虛擬機器棧是執行緒私有的記憶體,A執行緒不能訪問B執行緒虛擬機器棧中的內容。

本地方法棧

本地方法棧和Java虛擬機器棧的功能類似,區別在於呼叫的不是Java方法,而是Native方法。Native方法通常不是Java語言實現的,通常是C/C++實現的,JVM規範並沒有要求使用特定語言來實現Native方法。

但是並不是所有的虛擬機器都會將方法棧區分為Java虛擬機器棧和本地方法棧,比如Sun的Hotspot的虛擬機器就將兩個棧合二為一統一管理。

Java堆

Java堆存放的是物件的例項和陣列,這也是記憶體管理最大的一塊區域,並且這塊區域是執行緒共享的(也是需要我們在程式設計時注意做併發控制的區域)。當方法建立物件或者傳遞某個物件時,它實際上傳遞的是物件的引用,這個引用會指向物件的起始地址或者是和物件相關的位置。

Java堆還可以系分為新生代和老年代,這是以物件的存活期限進行區分的。同時新生代中還可以劃分出Eden空間,From Survivor空間,To Survivor空間,這主要是為了更好的完成垃圾回收。當物件從建立出來之後,會隨著被回收的次數逐漸移到相應的區域。具體多少次回收後會進入對應的區域則由JVM的配置決定。

Java區域劃分.png

圖中還有一個之前沒有提到的區域:永久代。這個區域中通常存放一些很少會變動的資訊比如後文講到的方法區的內容,因此它的特性並不適用於Java堆。記憶體回收管理時同樣會對這個區域進行記憶體回收。

方法區

方法區同樣是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯後的程式碼等資料。可以看到這一型別的資料通常很少變動,因此有些虛擬機器會將其視為JVM永久代進行管理。而這一塊的記憶體回收就意味著對常量池的回收和對型別的解除安裝(實時上型別解除安裝的條件時非常高的,因此大多數類不會被解除安裝,這對於那些喜歡使用動態代理的專案來說這一塊記憶體很可能出現記憶體溢位)

這裡再解釋一下上文提到的即時編譯後的程式碼這個概念。正如上文所說,Java的執行過程是通過JVM解釋位元組碼來實現的。但是,每執行一行程式碼都需要先解釋後執行,難免對效能產生影響。於是JVM內部做了一些優化,對於頻繁執行的程式碼塊會將其轉換為機器指令並儲存,這樣下次執行時就不需要再進行解釋,極大的提高了效能。這個過程被稱為及時編譯(Just In Time Compiler),而JIT編譯後的機器指令就會被儲存在方法區。

總結

這裡對JVM記憶體管理時各個區域的功能和可能出現的異常進行了總結。
總結.png

相關文章