Java的記憶體模型

糖拌蕃茄發表於2021-05-09

寫在前面:

該系列文章,主要是為了深入學習Java完成的一條鏈,推薦閱讀的整體順序為:Java的記憶體模型(根源),一個java檔案被執行的歷程一個Java類的載入及JVM記憶體的分配Java的垃圾回收機制及記憶體的劃分Linux(六):系統運維常用命令(基本問題定位),Jvm內建命令的使用

其實本篇的題目叫做《Java的記憶體模型》有些不準確,更準確的說法應該是JVM的記憶體模型,但是這裡又牽扯了一些其他的前置知識,主要是想從Java入手,從源頭上梳理一遍整個Java底層執行的機制,中間會額外補充一些和題目無關的前置基礎,導致主講記憶體模型的篇幅所佔的比例就不是那麼絕對, 關於這點只能請小夥伴們多擔待些了。

JVM的記憶體模型

前戲 1:Java “一次執行,到處編譯” 的真面目

說JVM記憶體模型之前,先聊一個老生常談的問題,為什麼Java可以 “一次編譯,到處執行”,這個話題最直接的答案就是,因為Java有JVM啊,解釋這個答案之前,我想先回顧一下一個語言被編譯的過程:

一般程式語言的編譯過程大抵就是,編譯——連線——執行,這裡的編譯就是,把我們寫的原始碼,根據語義語法進行翻譯,形成目的碼,即彙編碼。再由彙編程式翻譯成機器語言(可以理解為直接執行於硬體上的01語言);然後進行連線,所謂連線就是將目的碼與函式庫相連線,並將源程式所用的庫程式碼與目的碼合併,並形成最終可執行的二進位制機器程式碼(程式)。

編譯執行的整個流程,有一個前提,那就是到彙編的層面,指令編碼就和處理器的架構強關聯了,說白點就是和硬體關聯了,可以粗暴的理解為,一類硬體機器只認識一種彙編,一種機器只認一種機器碼。在這個基礎下,很容易就會發現一個問題,一個程式語言經過編譯、連線形成的可執行的機器碼X,可以在硬體環境1的情況下執行,當機器碼X到硬體環境2,就未必可以執行了,或者說執行結果就不是硬體環境1的結果了,所以,同一個程式,換臺PC,我們就可能需要重新編譯、打包成可執行在當前硬體環境的程式。這樣在工程化運用中真的是災難。

 

現在我們回到開篇問題的答案,之所以Java可以“一次編譯,到處執行”,是因為有JVM,為了便於理解,我們可以這樣認為:JVM就是一個完備的中間環境,它提供編譯執行Java位元組碼的全套環境,換句話說,它就像一個小隔離空間,我的Java程式只要編譯一次,只要滿足可以跑在JVM中,那它就可以隨便移植在任何硬體環境中,所以Java的“一次編譯,到處執行”的本質就是,它處處都要依賴JVM,它其實就是一個執行在JVM中的寄生蟲,這也是為什麼想要執行環境,你就必須要裝JDK的原因。

前戲二:JVM的本質和位置

上面的理解只不過是為了更快的入戲,但是上面的理解過於粗暴,下面細膩一下JVM的性質以及它所處的位置:

 

通常工作中所接觸的基本是Java庫和應用以及Java核心類庫,知道如何使用就可以了,但是歸根結底程式碼都是要編譯成class檔案由Java虛擬機器裝載執行,所產生的結果或者現象都可以通過Java虛擬機器的執行機制來解釋。一些相同的程式碼會由於虛擬機器的實現不同而產生不同結果。

然後是我們要介紹的JVM,首先我們要明確一個概念,JVM它並不是某一個具體的產品,也不是一個成品的軟體,更準確地說JVM是一種理論規範,對JVM的具體實現要麼是軟體,要麼是軟體和硬體的組合,JVM可以由不同的廠商來實現成不同的產品。由於廠商的不同必然導致JVM在實現上的一些不同,像國內就有著名的TaobaoVM;

在Java平臺的結構中,可以看出,Java虛擬機器(JVM)處在核心的位置,是程式與底層作業系統和硬體無關的關鍵。它的下方是移植介面,移植介面由兩部分組成:介面卡和Java作業系統,其中依賴於平臺的部分稱為介面卡;JVM通過移植介面在具體的平臺和作業系統上實現;在JVM的上方是Java的基本類庫和擴充套件類庫以及它們的API, 利用Java API編寫的應用程式(application)和小程式(Java applet)可以在任何Java平臺上執行而無需考慮底層平臺,就是因為有Java虛擬機器(JVM)實現了程式與作業系統的分離,從而實現了Java的平臺無關性。

JVM在它的生存週期中有一個明確的任務,那就是裝載位元組碼檔案,一旦位元組碼進入虛擬機器,它就會被直譯器解釋執行,或者是被即時程式碼發生器有選擇的轉換成機器碼執行,即Java程式被執行。因此當Java程式啟動的時候,就產生JVM的一個例項;當程式執行結束的時候,該例項也跟著消失了。

JVM的記憶體模型總覽

 “博主你前戲真多,你是不是不行鴨……” 

“啊…這…前戲多了,才能更好享受……”

 額…前面的前戲確實有些多了,但是主要是為了更好的接洽後面的內容,不然直接上五大記憶體部分,說什麼執行緒私有、公有,個人感覺很突兀。

好了,不廢話了,下面開始上主菜

 

總體來講,JVM會將Java程式所管理的記憶體劃分為若干不同的資料區域. 這些區域有各自的用途、建立/銷燬時間。以上這張圖,就是Java的編譯執行過程,上半部分(執行時區域)其實就是JVM的記憶體分配,它把從作業系統獲取來的記憶體空間進行了獨立的劃分,分別為方法區、堆、虛擬機器棧、本地方法棧、程式計數器。下半部分就是連線——執行階段的,JVM將Java語言處理完畢,變成適配與當前機器的機器碼,然後與本地庫進行連線,執行。

 

執行緒私有區域

執行緒私有資料區域生命週期與執行緒相同, 依賴使用者執行緒的啟動/結束而建立/銷燬(在Hotspot VM內, 每個執行緒都與作業系統的本地執行緒直接對映, 因此這部分記憶體區域的存/否跟隨本地執行緒的生/死)。

程式計數器

一塊較小的記憶體空間, 作用是當前執行緒所執行位元組碼的行號指示器(類似於傳統CPU模型中的PC), PC在每次指令執行後自增, 維護下一個將要執行指令的地址. 在JVM模型中, 位元組碼直譯器就是通過改變PC值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴PC完成(僅限於Java方法, Native方法該計數器值為undefined).
不同於OS以程式為單位排程, JVM中的併發是通過執行緒切換並分配時間片執行來實現的. 在任何一個時刻, 一個處理器核心只會執行一條執行緒中的指令. 因此, 為了執行緒切換後能恢復到正確的執行位置, 每條執行緒都需要有一個獨立的程式計數器, 這類記憶體被稱為“執行緒私有”記憶體。

JAVA程式碼編譯後的位元組碼在未經過JIT(實時編譯器)編譯前,其執行方式是通過“位元組碼直譯器”進行解釋執行。簡單的工作原理為直譯器讀取裝載入記憶體的位元組碼,按照順序讀取位元組碼指令。讀取一個指令後,將該指令“翻譯”成固定的操作,並根據這些操作進行分支、迴圈、跳轉等流程。

  從上面的描述中,可能會產生程式計數器是否是多餘的疑問。因為沿著指令的順序執行下去,即使是分支跳轉這樣的流程,跳轉到指定的指令處按順序繼續執行是完全能夠保證程式的執行順序的。假設程式永遠只有一個執行緒,這個疑問沒有任何問題,也就是說並不需要程式計數器。但實際上程式是通過多個執行緒協同合作執行的。

  首先我們要搞清楚JVM的多執行緒實現方式。JVM的多執行緒是通過CPU時間片輪轉(即執行緒輪流切換並分配處理器執行時間)演算法來實現的。也就是說,某個執行緒在執行過程中可能會因為時間片耗盡而被掛起,而另一個執行緒獲取到時間片開始執行。當被掛起的執行緒重新獲取到時間片的時候,它要想從被掛起的地方繼續執行,就必須知道它上次執行到哪個位置,在JVM中,通過程式計數器來記錄某個執行緒的位元組碼執行位置。因此,程式計數器是具備執行緒隔離的特性,也就是說,每個執行緒工作時都有屬於自己的獨立計數器。

程式計數器的特點

  1.執行緒隔離性,每個執行緒工作時都有屬於自己的獨立計數器。
  2.執行java方法時,程式計數器是有值的,且記錄的是正在執行的位元組碼指令的地址(參考上一小節的描述)。
  3.執行native本地方法時,程式計數器的值為空(Undefined)。因為native方法是java通過JNI直接呼叫本地C/C++庫,可以近似的認為native方法相當於C/C++暴露給java的一個介面,java通過呼叫這個介面從而呼叫到C/C++方法。由於該方法是通過C/C++而不是java進行實現。那麼自然無法產生相應的位元組碼,並且C/C++執行時的記憶體分配是由自己語言決定的,而不是由JVM決定的。

​       4.程式計數器佔用記憶體很小,在進行JVM記憶體計算時,可以忽略不計。

  5.程式計數器,是唯一一個在java虛擬機器規範中沒有規定任何OutOfMemoryError的區域。

虛擬機器棧

這裡的虛擬機器棧主要是針對Java的方法執行,我們都知道方法在程式設計中使用的是棧的資料結構;每個方法被執行時會建立一個棧幀(Stack Frame)用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊. 每個方法被呼叫至返回的過程, 就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程(VM提供了-Xss來指定執行緒的最大棧空間, 該引數也直接決定了函式呼叫的最大深度)。這裡特別說明一下區域性變數表,這裡的區域性變數表,其實就是我們定義的方法內部的變數,它基本的範圍包括基本資料型別(如boolean、int、double等) 、物件引用(reference : 不等同於物件本身, 可能是一個指向物件起始地址的指標, 也可能指向一個代表物件的控制程式碼或其他與此物件相關的位置),也就是我們私下常說的‘堆疊’中的‘棧’。

  Java虛擬機器使用區域性變數表來完成方法呼叫時的引數傳遞。區域性變數表的長度在編譯期已經決定了並儲存於類和介面的二進位制表示中,一個區域性變數可以儲存一個型別為boolean、byte、char、short、float、reference和returnAddress的資料,兩個區域性變數可以儲存一個型別為long和double的資料。
  Java虛擬機器提供一些位元組碼指令來從區域性變數表或者物件例項的欄位中複製常量或變數值到運算元棧中,也提供了一些指令用於從運算元棧取走資料、運算元據和把操作結果重新入棧。在方法呼叫的時候,運算元棧也用來準備呼叫方法的引數以及接收方法返回結果。
  每個棧幀中都包含一個指向執行時常量區的引用支援當前方法的動態連結。在Class檔案中,方法呼叫和訪問成員變數都是通過符號引用來表示的,動態連結的作用就是將符號引用轉化為實際方法的直接引用或者訪問變數的執行是記憶體位置的正確偏移量。
總的來說,Java虛擬機器棧是用來存放區域性變數和過程結果的地方。
Java虛擬機器棧可能發生如下異常情況: 如果Java虛擬機器棧被實現為固定大小記憶體,執行緒請求分配的棧容量超過Java虛擬機器棧允許的最大容量時,Java虛擬機器將會丟擲一個StackOverflowError異常。
如果Java虛擬機器棧被實現為動態擴充套件記憶體大小,並且擴充套件的動作已經嘗試過,但是目前無法申請到足夠的記憶體去完成擴充套件,或者在建立新的執行緒時沒有足夠的記憶體去建立對應的虛擬機器棧,那Java虛擬機器將會丟擲一個OutOfMemoryError異常。

1.符號引用(Symbolic References):

  符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能夠無歧義的定位到目標即可。例如,在Class檔案中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等型別的常量出現。符號引用與虛擬機器的記憶體佈局無關,引用的目標並不一定載入到記憶體中。在Java中,一個java類將會編譯成一個class檔案。在編譯時,java類並不知道所引用的類的實際地址,因此只能使用符號引用來代替。比如org.simple.People類引用了org.simple.Language類,在編譯時People類並不知道Language類的實際記憶體地址,因此只能使用符號org.simple.Language(假設是這個,當然實際中是由類似於CONSTANT_Class_info的常量來表示的)來表示Language類的地址。各種虛擬機器實現的記憶體佈局可能有所不同,但是它們能接受的符號引用都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。

2.直接引用:

直接引用可以是

(1)直接指向目標的指標(比如,指向“型別”【Class物件】、類變數、類方法的直接引用可能是指向方法區的指標)

(2)相對偏移量(比如,指向例項變數、例項方法的直接引用都是偏移量)

(3)一個能間接定位到目標的控制程式碼

直接引用是和虛擬機器的佈局相關的,同一個符號引用在不同的虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經被載入入記憶體中了。

本地方法棧

本地方法棧其實作用和虛擬機器棧的作用一樣,不同的是,虛擬機器棧是為虛擬機器解析執行Java方法,而本地方法棧是為虛擬機器呼叫Native方法服務(Native方法簡單點來說就是一個java呼叫非java程式碼的介面。一個Native 方法是這樣一個java的方法:該方法的實現由非java語言實現)

執行緒共享區域

這一區域的生命週期,同虛擬機器一致,也就是虛擬機器內部的公共記憶體區域,隨虛擬機器的啟動/關閉而建立/銷燬

 

堆區

這裡的堆,是虛擬機器從作業系統那裡申請來的的記憶體空間,這塊空間是Java虛擬機器所管理的記憶體中最大的一塊,並且是所有執行緒共享的一塊記憶體區域,Java堆在虛擬機器啟動的時候被建立,主要用來為類例項物件和陣列分配記憶體。這塊區域可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像我們的磁碟空間一樣,在實現時,既可以實現成固定大小的,也可以是擴充套件的,如果是可擴充套件的,則通過(-Xmx和-Xms控制),如果在隊中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError異常。

Java堆是垃圾回收器管理的主要區域,很多時候也被稱為“GC”堆,在現在的實現上,堆被劃分成兩個不同的區域:新生代( Young )、老年代( Old );這也就是JVM採用的“分代收集演算法”,簡單說,就是針對不同特徵的java物件採用不同的 策略實施存放和回收,自然所用分配機制和回收演算法就不一樣。新生代( Young ) 又被劃分為三個區域:Eden、From Survivor、To Survivor。

方法區

方法區和Java堆一樣,是各個執行緒共享的記憶體區域,用於儲存已被虛擬機器載入的類資訊,常量、靜態變數,還包括在類、例項、介面初始化時用到的特殊方法。虛擬機器規範上把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做“非堆”,目的就是與Java的堆區分開來。

直接記憶體 

直接記憶體並不是JVM執行時資料區的一部分, 但也會被頻繁的使用: 在JDK 1.4引入的NIO提供了基於Channel與Buffer的IO方式, 它可以使用Native函式庫直接分配堆外記憶體, 然後使用DirectByteBuffer物件作為這塊記憶體的引用進行操作(詳見: Java I/O 擴充套件), 這樣就避免了在Java堆和Native堆中來回複製資料, 因此在一些場景中可以顯著提高效能。
顯然, 本機直接記憶體的分配不會受到Java堆大小的限制(即不會遵守-Xms、-Xmx等設定), 但既然是記憶體, 則肯定還是會受到本機總記憶體大小及處理器定址空間的限制, 因此動態擴充套件時也會出現OutOfMemoryError異常。

從栗子來理解記憶體模型

這裡我們引入一個比較簡單的程式樣例,從具體的程式碼角度去理解Jvm的記憶體

一個person類

public class Persion {
    private String name;
    public static String aninmal = "dog" 
    
     public Persion(String name){
        this.name = name
    }

    public String getName() {
        return name;
    }
   
    public void setName(String name) {
        this.name = name;
    }

   
}

一個App類

public class App 
{
    public static void main( String[] args )
    {
        Persion persion1 = new Persion("張三"); 
        Persion persion2 = new Persion("李四");
        persoion1.setName("王五");
     } 
}

 

程式開始執行,系統啟動了一個Java虛擬機器程式,Java虛擬機器定位到方法區中App類的main()方法的位元組碼,開始執行它的指令。分別去建立Persion1和Persion2(這裡我們以persion物件為跟蹤點)

1、程式從main方法開始執行,既然提到了方法,根據上面的知識,我們知道,它首先會在棧區動工。在JAVA虛擬機器程式中,每個執行緒都會擁有一個方法呼叫棧,用來跟蹤執行緒執行中一系列的方法呼叫過程,棧中的每一個元素就被稱為棧幀,每當執行緒呼叫一個方法的時候就會向方法棧壓入一個新幀。這裡的幀用來儲存方法的引數、區域性變數和運算過程中的臨時資料。這時候執行main方法的主執行緒會在棧區申請一片區域。根據原始碼,它會識別出persion1和persion2分別為兩個變數,並且給它們定性是方法內區域性變數,因此,它被會新增到了執行main()方法的主執行緒的JAVA方法呼叫棧中。

2、 接下來就是 “=” 賦值操作了,Java虛擬機器接受執行指令,發現右側是個物件例項,於是就直奔方法區而去,試圖找到Persion類的型別資訊。首次執行,發現並沒有找到Persion的資訊,這時候Java虛擬機器根據預設的規則,在無法找到類資訊的情況下,自行去載入Persion類,把Persion類的型別資訊存放在方法區裡。

3、 現在Persion類的資訊已經被載入到了方法區,這裡Persion類中的靜態變數animal也會被填充上值“dog”存放於方法區,此時Java虛擬機器根據我們程式碼中的兩句new指令,分別去堆中劃出兩塊記憶體區域,分別用於存放persion例項1和persion例項2,這兩個例項物件分別擁有自己獨立的記憶體空間, 同時這倆例項持有著指向方法區的Persion類的型別資訊的引用。這裡所說的引用,實際上指的是Persion類的型別資訊在方法區中的記憶體地址,其實,就是有點類似於C語言裡的指標,而這個地址呢,就存放了在persion例項1、persopn例項2的資料區裡。我們也能發現persion例項1和persion例項2共享animal這個變數,也就是說,無論使用哪一個引用(persion1和persion2)去修改這個animal變數,任何一個Persion物件使用這個變數的時候,都會發生改變。

4、到此為止已經將main方法中的兩個成員變數persion1和persion2分別關聯到了堆中的物件。當Java虛擬機器執行到persion1.setName()的時候,Java虛擬機器根據main方法棧區中的persion1變數,定位到堆中的Persion名字為張三的例項(persion例項1),再根據這個例項所持有的類資訊引用(或者說指標),定位到方法區的Persion類資訊,從中獲得setName(String name)方法,然後棧區再壓入一個新幀,並在其中完成引數(String name)的複製,然後根據指令,將堆中的Persion例項1 空間中的Name變成“王五”,然後結束。

 

相關文章