JVM之記憶體管理(一)

  • 2019 年 10 月 8 日
  • 筆記

現如今的企業級Java應用開發已經日漸成熟,而越來越多的系統應用變得更加龐大而難以發現問題,JVM就是一個必須去重視和關注的難點,今天我們就開始對此進行認識、了解並深入其中。

本文介紹關於JVM的概念、組成和記憶體模型的相關內容。

首先,在理解JVM之前,我們先了解一下Java當中,人們常常提起的「跨平台」。

那,什麼又是跨平台,Java又是如何實現跨平台的呢?

跨平台,指的就是Java編寫的程式,能夠在多種機器平台環境里運行,實現了一次編譯好的程式,在不同的機器上運行。Java實現的跨平台機制,其實指的是Java程式的跨平台。通過JVM(C/C++所開發)的,將Java程式編譯生成 .class 文件,稱為位元組碼文件。Java 虛擬機(JVM)就是負責將位元組碼文件翻譯成特定平台下的機器碼然後運行,也就是說,只要在不同平台上安裝對應的 JVM,就可以運行位元組碼文件,運行我們編寫的 Java 程式。

而這,就是傳說中的「一次編譯,到處運行」。現在,就來了解一下,什麼是JVM?

1、什麼是JVM?

簡單來說,JVM (即 Java Virtual Machine,Java 虛擬機)就是 編譯後的 Java 程式(.class文件)和硬體系統之間的介面或者說聯繫。它通過模擬一個電腦來達到一個電腦所具有的的計算功能。JVM 能夠跨電腦體系結構來執行 Java 位元組碼,主要是由於 JVM 屏蔽了與各個電腦平台相關的軟體或者硬體之間的差異,使得與平台相關的耦合統一由 JVM 提供者來實現。

而它又是怎麼做到跨平台,並且能做到「與機器無關,與平台無關」呢?

原理:編譯後的 Java 程式指令並不直接在硬體系統的 CPU 上執行,而是由 JVM 執行。

為什麼與平台無關:JVM屏蔽了與具體平台相關的資訊,使Java語言編譯程式只需要生成在JVM上運行的目標位元組碼(.class),就可以在多種平台上不加修改地運行。Java 虛擬機在執行位元組碼時,把位元組碼解釋成具體平台上的機器指令執行。因此實現java平台無關性。

2、JVM所管理的記憶體被分成多少區域?每個區域有什麼作用?如何來管理這些區域?

2.1 運行時數據區

JVM在執行Java程式時會把其所管理的記憶體劃分成多個不同的數據區域,每個區域的創建時間、銷毀時間以及用途都各不相同。比如有的記憶體區域是所有執行緒共享的,而有的記憶體區域是執行緒隔離的。執行緒隔離的區域就會隨著執行緒的啟動和結束而創建和銷毀。JVM所管理的記憶體將會包含以下幾個運行時數據區域,如下圖的上半部分所示。

2.2 Method Area (方法區)

方法區是所有執行緒共享的記憶體區域,它用於存儲已被虛擬機載入的類資訊、常量、靜態變數、JIT編譯後的程式碼等數據。在Java虛擬機規範中,方法區屬於堆的一個邏輯部分,但很多情況下,都把方法區與堆區分開來說。大家平時開發中通過反射獲取到的類名、方法名、欄位名稱、訪問修飾符等資訊都是從這塊區域獲取的。

對於HotSpot虛擬機,方法區對應為永久代(Permanent Generation),但本質上,兩者並不等價,僅僅是因為HotSpot虛擬機的設計團隊是用永久代來實現方法區而已,對於其他的虛擬機(JRockit、J9)來說,是不存在永久代這一概念的。

2.3 Runtime Constant Pool (運行時常量池)

回過頭來看下圖1的下半部分,方法區主要包含:

運行時常量池(Runtime Constant Pool)

類資訊(Class & Field & Method data)

編譯器編譯後的程式碼(Code)

等等…

後面兩項都比較好理解,但運行時常量池有何作用,其意義何在?拋開運行時3個字,首先了解下何為常量池。

Java源文件經編譯後得到存儲位元組碼的Class文件,Class文件是一組以8位位元組為基礎單位的二進位流,各個數據項目嚴格按照順序緊湊地排列在Class文件中。也就是說,哪個位元組代表什麼含義,長度多少,先後順序如何都是被嚴格限定的,是不允許改變的。比如:開頭的4個位元組存放在魔數,用於確定這個文件是否能夠被JVM接受,接下來的4個位元組用於存放版本號,再接著存放的就是常量池,常量池的長度是不固定的,所以,在常量池的入口存放著常量池容量的計數值。

常量池主要用於存放兩大類常量:字面量和符號引用量,字面量相當於Java語言層面常量的概念,比如:字元串常量、聲明為final的常量等等。符號引用是用一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可。理解不了?舉個例子,有如下程式碼:

使用javap工具輸出M.class文件位元組碼的部分內容如下:

這裡只保留了常量池的部分,從中可以看到M.class文件的常量池總共24項,其中包含類的完整名稱、欄位名稱和描述符、方法名稱和描述符等等。當然其中還包含I、V、、LineNumberTable、LocalVariableTable等程式碼中沒有出現過的常量,其實這些常量是用來描述如下資訊:方法的返回值是什麼?有多少個參數?每個參數的類型是什麼…… 這個示例非常直觀的向大家展示了常量池中存儲的內容。

接下來就比較好理解運行時常量池了。我們都知道:Class文件中存儲的各種資訊,最終都需要載入到虛擬機中之後才能運行和使用。運行時常量池就可以理解為常量池被載入到記憶體之後的版本,但並非只有Class文件中常量池的內容才能進入方法區的運行時常量池,運行期間也可能產生新的常量,它們也可以放入運行時常量池中。

2.4 Heap Space (Java堆)

Java堆是JVM所管理的最大一塊記憶體,所有執行緒共享這塊記憶體區域,幾乎所有的對象實例都在這裡分配記憶體,因此,它也是垃圾收集器管理的主要區域。從記憶體回收的角度來看,由於現在的收集器基本都採用分代收集演算法,所以Java堆又可以細分成:新生代和老年代,新生代裡面有分為:Eden空間、From Survivor空間、To Survivor空間,如圖1所示。有一點需要注意:Java堆空間只是在邏輯上是連續的,在物理上並不一定是連續的記憶體空間。

默認情況下,新生代中Eden空間與Survivor空間的比例是8:1,注意不要被示意圖誤導,可以使用參數-XX:SurvivorRatio對其進行配置。大多數情況下,新生對象在新生代Eden區中分配,當Eden區沒有足夠的空間進行分配時,則觸發一次Minor GC,將對象Copy到Survivor區,如果Survivor區沒有足夠的空間來容納,則會通過分配擔保機制提前轉移到老年代去。

何為分配擔保機制?在發送Minor GC前,JVM會檢查老年代最大可用的連續空間是否大於新生代所有對象的總空間,如果是,那麼可以確保Minor GC是安全的,如果不是,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉陞到老年代對象的平均大小,如果小於,直接進行Full GC,如果大於,將嘗試著進行一次Minor GC,Minor GC失敗才會觸發Full GC。註:不同版本的JDK,流程略有不同

Survivor區作為Eden區和老年代的緩衝區域,常規情況下,在Survivor區的對象經過若干次垃圾回收仍然存活的話,才會被轉移到老年代。JVM通過這種方式,將大部分命短的對象放在一起,將少數命長的對象放在一起,分別採取不同的回收策略。

2.6 VM Stack (虛擬機棧) & Native Method Stack (本地方法棧)

虛擬機棧與本地方法棧都屬於執行緒私有,它們的生命周期與執行緒相同。虛擬機棧用於描述Java方法執行的記憶體模型:每個方法在執行的同時都會創建一個棧幀(Stack Frame)用於存儲局部變數表、操作數棧、動態連接、方法出口等資訊。

其中局部變數表用於存儲方法參數和方法內部定義的局部變數,它只在當前函數調用中有效,當函數調用結束,隨著函數棧幀的銷毀,局部變數表也隨之消失;操作數棧是一個後入先出棧,用於存放方法運行過程中的各種中間變數和位元組碼指令 (在學習棧的時候,有一個經典的例子就是用棧來實現4則運算,其實方法執行過程中操作數棧的變化過程,與4則預算中棧中數字與符號的變化類似);動態連接其實是指一個過程,即在程式運行過程中將符號引用解析為直接引用的過程。

如何理解動態連接?我們知道Class文件的常量池中存有大量的符號引用,在載入過程中會被原樣的拷貝到記憶體里先放著,到真正使用的時候就會被解析為直接引用 (直接引用包含:直接指向目標的指針、相對偏移量、能間接定位到目標的句柄等)。有些符號引用會在類的載入階段或者第一次使用的時候轉化為直接引用,這種轉化稱為靜態解析,而有的將在運行期間轉化為直接引用,這部分稱為動態連接。

全部靜態解析不是更好,為何會存在動態連接?Java多態的實現會導致一個引用變數到底指向哪個類的實例對象,或者說該引用變數發出的方法調用到底是調用哪個類中實現方法都需要在運行期間才能確定。因此有些符號引用在類載入階段是不知道它對應的直接引用的

每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程,下面通過一個非常簡單的圖例來描述這一過程,有如下的程式碼片段:

其調用過程中虛擬機棧的大致示意圖如下圖所示:

調用sayHello方法時,在棧中分配有一塊記憶體用來保存該方法的局部變數等資訊,①當函數執行到greet()方法時,棧中同樣有一塊記憶體用來保存greet方法的相關資訊,當然第二個記憶體塊位於第一個記憶體塊上面,②接著從greet方法返回,③現在棧頂的記憶體塊就是sayHello方法的,這表示你已經返回到sayHello方法,④接著繼續調用bye方法,在棧頂添加了bye方法的記憶體塊,⑤接著再從bye方法返回到sayHello方法中,由於沒有別的事了,現在就從sayHello方法返回。

本地方法棧與虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧為虛擬機執行Java方法 (也就是位元組碼) 服務,而本地方法棧則為虛擬機使用到的Native方法服務。

2.7 Program Counter Register (程式計數器)

程式計數器(Program Counter Register),很多地方也被稱為PC暫存器,但暫存器是CPU的一個部件,用於存儲CPU內部重要的數據資源,比如在彙編語言中,它保存的是程式當前執行的指令的地址(也可以說保存下一條指令的所在存儲單元的地址),當CPU需要執行指令時,需要從程式計數器中得到當前需要執行的指令所在存儲單元的地址,然後根據得到的地址獲取到指令,在得到指令之後,程式計數器便自動加1或者根據轉移指針得到下一條指令的地址,如此循環,直至執行完所有的指令。

類似的,JVM規範中規定,如果執行緒執行的是非native方法,則程式計數器中保存的是當前需要執行的指令的地址;如果執行緒執行的是native方法,則程式計數器中的值是undefined。

Java虛擬機可以支援多條執行緒同時執行,多執行緒是通過執行緒輪流切換來獲得CPU執行時間的,因此,在任一具體時刻,一個CPU的內核只會執行一條執行緒中的指令,因此,為了能夠使得每個執行緒都在執行緒切換後能夠恢復在切換之前的程式執行位置,每個執行緒都需要有自己獨立的程式計數器,並且不能互相被干擾,否則就會影響到程式的正常執行次序。因此,JVM中的程式計數器是每個執行緒私有的。

2.8 堆外記憶體

堆外記憶體又被稱為直接記憶體(Direct Memory),它並不是虛擬機運行時數據區的一部分,Java虛擬機規範中也沒有定義這部分記憶體區域,使用時由Java程式直接向系統申請,訪問直接記憶體的速度要優於Java堆,因此,讀寫頻繁的場景下使用直接記憶體,性能會有提升,比如Java NIO庫,就是使用Native函數直接分配堆外記憶體,然後通過一個存儲在Java堆中的DirectBytedBuffer對象作為這塊記憶體的引用進行操作。

由於直接記憶體在Java堆外,其大小不會直接受限於Xmx指定的堆大小,但它肯定會受到本機總記憶體大小以及處理器定址空間的限制,因此我們在配置JVM參數時,特別是有大量網路通訊場景下,要特別注意,防止各個記憶體區域的總記憶體大於物理記憶體限制 (包括物理的和OS的限制)。

文章部分內容整理至:方誌朋的部落格