Java 虛擬機器:看完就懂 JVM 架構和執行時資料區 (記憶體區域)

JAVA一方發表於2020-02-10

一、Java 虛擬機器架構 (JVM Architecture)

在我看來,不管學習什麼樣的知識或技術,首先要做的就是從全域性上去認識它,這樣才能避免盲人摸象,事倍功半的情況發生。既然要學習 JVM,就要先了解它的整體架構,於是我畫了個 JVM 架構圖來幫助大家認識它。

Java 虛擬機器架構圖

Java 虛擬機器架構圖

對 JVM 還不太瞭解的同學第一次看到這張花裡胡哨的圖肯定會一臉懵逼,不用怕,其實我們只需要重點理解並掌握其中一部分 (同時也是面試重點) 就好了,比如執行時資料區、垃圾收集器、記憶體分配策略和類載入機制等,類檔案結構也可以學習一下,其他的稍作了解即可。既然本篇文章是要帶領大家認識 JVM 架構的,那就先把圖中各個部分都介紹一下吧 (注:本文只做介紹,讓各位先對 JVM 有個整體的認識,本系列後續文章會做深入探討)。

1.1 Class 檔案 (位元組碼檔案)

Java 之所以號稱“一次編寫,處處執行”,就是得益於虛擬機器和 Class 檔案 (注:CLass 檔案、位元組碼檔案和類檔案是一個意思) 的組合機制。程式設計師並不需要自己去適配不同的作業系統,大家都知道我們平時編寫的 java 程式碼在編譯成 Class 檔案後才能執行,而 Class 檔案可以在任何作業系統上的 JVM 上執行,這樣就做到了“平臺無關性”。下面是一個最簡單的 HelloWorld 程式及其對應的 Class 檔案。

Class 檔案

HelloWorld 程式及其編譯後的 Class 檔案

得益於 Class 檔案,JVM 還可以做到“語言無關性”,也就是說不只有 Java 程式可以執行於 JVM 之上,很多其他語言例如最近在安卓開發者中大火的 Kotlin 語言,還有 Scala、Groovy 等語言也都是基於 JVM 平臺的,這些語言的程式碼都可以編譯成 Class 檔案,然後在 JVM 上執行。
無關性

JVM提供的平臺無關性和語言無關性

1.2 類載入器子系統 (ClassLoader Subsystem)

要執行 Class 檔案就需要先將其載入進記憶體,這一工作正是由類載入器 (ClassLoader) 完成的,系統為我們提供了三種類載入器,分別是啟動類載入器 (Bootstrap ClassLoader)、擴充套件類載入器 (Extension ClassLoader) 和應用程式類載入器 (Application ClassLoader),如果有必要,我們也可以加入自定義的類載入器。類載入過程如下:

類載入過程

類載入過程

類載入過程分為載入、連線和初始化三個階段,其中的連線階段又分為驗證、準備和解析三個階段 (詳細的類載入機制在後續文章中進行介紹)。

1.3 Java 虛擬機器執行時資料區 (JVM Runtime Data Area)

這部分內容較多,放在本文第二部分單獨進行介紹。

1.4 執行引擎 (Execution Engine)

位元組碼被載入進執行時資料區後,執行引擎會進行讀取並執行,執行引擎主要包含以下模組:

  • 直譯器 (Interpreter):相信大家很久以前就聽過“計算機只認識0和1”這句話,時至今日,計算機依然只認識0和1,所以任何程式語言的程式碼最終都要轉化成機器碼 (二進位制程式碼)才能執行,Java 也不例外,而直譯器的工作正是將編譯得到的位元組碼再轉化成機器碼,然後才能執行。正因為如此,Java 才被稱為解釋型語言,也正是因為邊解釋邊執行的特點,Java 程式在執行時才會慢於 C++ 之類的編譯型語言。
  • 即時編譯器 (JIT Compiler,just-in-time compiler):即時編譯器百度百科),為了彌補解釋執行帶來的速度劣勢,JVM 引入了即時編譯器,它的作用就是把熱點程式碼,比如重複呼叫的方法和迴圈程式碼等,編譯成機器碼並存放在 code cache 中,這樣之後再用到這些程式碼就不用重新解釋執行了,可以提高程式執行效率。
  • 垃圾收集器 (Garbage Collector):Java 程式設計師可以不用手動釋放記憶體,全是垃圾收集器的功勞,這也是 JVM 中尤其重要的內容,後續會有多篇文章對其進行介紹。

1.5 本地庫介面 (JNI,Java Native Interface)

如果你經常看 JDK 原始碼的話,一定會注意到 native 這個關鍵詞,被它修飾的方法是沒有方法體的,是因為它呼叫了計算機本地的方法庫 (通常是 C 或 C++ 程式碼)。JDK 原始碼中有很多類的方法,特別是一些需要操作計算機硬體的方法,都呼叫了本地方法庫,畢竟與硬體打交道還是用 C 和 C++ 更方便,比如下面這些方法:

// 例一:這是 Thread 類中的 currentThread 方法,用於獲取當前正在執行的執行緒
public static native Thread currentThread();

// 例二:這是 FileInputStream 類中 open0 方法,用於開啟指定檔案
private native void open0(String name) throws FileNotFoundException;複製程式碼

1.6 本地方法庫 (Native Method Library)

本地庫介面所呼叫的物件正是位於這個庫中,一般是位於計算機本地的 C 或 C++ 語言程式碼。

二、Java 虛擬機器執行時資料區

Java 虛擬機器執行時資料區是我們需要重點了解並熟悉的部分,因為這與我們寫的程式息息相關,平時常見的 StackOverflowError 和 OutOfMemoryError 也幾乎都是來自這個區域。說“幾乎”是因為當本機直接記憶體不夠用時也會丟擲 OutOfMemoryError。如下圖所示,程式計數器、Java 虛擬機器棧和本地方法棧是執行緒私有的,堆和方法區是執行緒共享的,其中方法區又包含了執行時常量池。下面就對這個部分做個詳細的介紹吧 (注:本部分引用內容來自《深入理解Java虛擬機器》)。

Java 虛擬機器執行時資料區

Java 虛擬機器執行時資料區

2.1 程式計數器 (Program Counter Register)

怕有些小夥伴不清楚,提示一下:下面這樣的段落格式就是 Markdown 裡的引用格式,,一般用於引用他人的文章或別處的內容。

程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。在Java虛擬機器的概念裡,位元組碼直譯器工作時就是通過改變這個計數器 的值來選取下一條需要執行的位元組碼指令,它是程式控制流的指示器,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

由於Java虛擬機器的多執行緒是通過執行緒輪流切換、分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。

如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是本地 (Native) 方法,這個計數器值則應為空 (Undefined)。此記憶體區域是唯一一個在《Java虛擬機器規範》中沒有規定任何 OutOfMemoryError 情況的區域。

這裡引用了《深入理解Java虛擬機器》書中的內容,其實不難理解,程式計數器的作用就是儲存執行緒的執行狀態,引用部分的第三段中說“

如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址
”,這個地址就是位元組碼執行到的位置。我們平時說的 Java 多執行緒上下文切換就需要程式計數器的輔助,當 CPU 從一個執行緒切換到另一個執行緒時,要從程式計數器中讀取執行緒執行狀態從而恢復現場。後面又說“
如果執行的是本地 (Native)方法,這個計數器值為空(Undefined)
”,這是為何呢?是因為本地方法執行的是 C / C++ 程式碼,在原生平臺直接執行,也就不存在 Java 虛擬機器的概念,自然也無法儲存位元組碼指令地址,此時要想記錄程式碼執行狀態的話,只能使用原生 CPU 的 PC 暫存器。

2.2 Java 虛擬機器棧 (JVM Stacks)

與程式計數器一樣,Java虛擬機器棧(Java Virtual Machine Stack)也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是 Java 方法執行的執行緒記憶體模型:每個方法被執行的時候,Java 虛擬機器都 會同步建立一個棧幀(Stack Frame)用於儲存區域性變數表、運算元棧、動態連線、方法出口等資訊。每一個方法被呼叫直至執行完畢的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

區域性變數表存放了編譯期可知的各種Java虛擬機器基本資料型別(boolean、byte、char、short、int、 float、long、double)、物件引用 (reference 型別,它並不等同於物件本身,可能是一個指向物件起始地址的引用指標,也可能是指向一個代表物件的控制程式碼或者其他與此物件相關的位置) 和 returnAddress 型別(指向了一條位元組碼指令的地址)。

這些資料型別在區域性變數表中的儲存空間以區域性變數槽 (Slot) 來表示,其中64位長度的 long 和 double 型別的資料會佔用兩個變數槽,其餘的資料型別只佔用一個。區域性變數表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變數表的大小。請讀者注意,這裡說的“大小”是指變數槽的數量,虛擬機器真正使用多大的記憶體空間 (譬如按照1個變數槽佔用32個位元、64個位元,或者更多)來實現一個變數槽,這是完全由具體的虛擬機器實現自行決定的事情。

在《Java虛擬機器規範》中,對這個記憶體區域規定了兩類異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲 StackOverflowError 異常;如果 Java 虛擬機器棧容量可以動態擴充套件,當棧擴充套件時無法申請到足夠的記憶體會丟擲 OutOfMemoryError 異常。

Java 虛擬機器棧的內部結構如下圖所示:

Java虛擬機器棧

Java 虛擬機器棧

2.2.1 區域性變數表

區域性變數表是存放方法引數和區域性變數的區域。 區域性變數沒有準備階段, 必須顯式初始化。如果是非靜態方法,則在 index[0] 位置上儲存的是方法所屬物件的例項引用,一個引用變數佔 4 個位元組,隨後儲存的是引數和區域性變數。

2.2.2 運算元棧

運算元棧是個初始狀態為空的桶式結構棧。在方法執行過程中, 會有各種指令往棧中寫入和提取資訊。JVM 的執行引擎是基於棧的執行引擎,其中的棧指的就是運算元棧。位元組碼指令集的定義都是基於棧型別的,棧的深度在方法元資訊的 stack 屬性中。下面使用 i++ 和 ++i 的區別來幫助理解運算元棧:

i++ 和 ++i 的區別:

  1. i++:從區域性變數表取出 i 並壓入操作棧,然後對區域性變數表中的 i 自增 1,將操作棧棧頂值取出使用,最後,使用棧頂值更新區域性變數表,如此執行緒從操作棧讀到的是自增之前的值。
  2. ++i:先對區域性變數表的 i 自增 1,然後取出並壓入操作棧,再將操作棧棧頂值取出使用,最後,使用棧頂值更新區域性變數表,執行緒從操作棧讀到的是自增之後的值。

之所以說 i++ 不是原子操作,即使使用 volatile 修飾也不是執行緒安全,就是因為,可能 i 被從區域性變數表(記憶體)取出,壓入操作棧(暫存器),操作棧中自增,使用棧頂值更新區域性變數表(暫存器更新寫入記憶體),其中分為 3 步,volatile 保證可見性,保證每次從區域性變數表讀取的都是最新的值,但可能這 3 步可能被另一個執行緒的 3 步打斷,產生資料互相覆蓋問題,從而導致 i 的值比預期的小。

2.2.3 動態連線

每個棧幀中包含一個在常量池中對當前方法的引用, 目的是支援方法呼叫過程的動態連線。

2.2.4 方法出口

方法執行時有兩種退出情況:

  1. 正常退出,即正常執行到任何方法的返回位元組碼指令,如 RETURN、IRETURN、ARETURN 等;
  2. 異常退出。

無論何種退出情況,都將返回至方法當前被呼叫的位置。方法退出的過程相當於彈出當前棧幀,退出可能有三種方式:

  1. 返回值壓入上層呼叫棧幀。
  2. 異常資訊拋給能夠處理的棧幀。
  3. 程式計數器指向方法呼叫後的下一條指令。

2.3 本地方法棧 (Native Method Stacks)

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

《Java虛擬機器規範》對本地方法棧中方法使用的語言、使用方式與資料結構並沒有任何強制規定,因此具體的虛擬機器可以根據需要自由實現它,甚至有的Java虛擬機器 (譬如Hot-Spot虛擬機器)直接就把本地方法棧和虛擬機器棧合二為一。與虛擬機器棧一樣,本地方法棧也會在棧深度溢位或者棧擴充套件失 敗時分別丟擲 StackOverflowError 和OutOfMemoryError 異常。

這部分比較好理解,就不做解析了。

2.4 Java 堆 (Heap)

對於Java應用程式來說,Java 堆 (Java Heap)是虛擬機器所管理的記憶體中最大的一塊。Java 堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,Java 世界裡“幾乎”所有的物件例項都在這裡分配記憶體。Java 堆是垃圾收集器管理的記憶體區域,因此也常被稱為“GC 堆”。

根據《Java虛擬機器規範》的規定,Java堆可以處於物理上不連續的記憶體空間中,但在邏輯上它應該被視為連續的,這點就像我們用磁碟空間去儲存檔案一樣,並不要求每個檔案都連續存放。但對於大 物件(典型的如陣列物件),多數虛擬機器實現出於實現簡單、儲存高效的考慮,很可能會要求連續的記憶體空間。

Java 堆既可以被實現成固定大小的,也可以是可擴充套件的,不過當前主流的Java虛擬機器都是按照可擴充套件來實現的(通過引數-Xmx和-Xms設定)。如果在 Java 堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,Java 虛擬機器將會丟擲 OutOfMemoryError 異常。

Java 堆的唯一作用就是存放物件例項,這也是垃圾收集器最關注的記憶體區域,因為大多數物件例項的存活時間都很短,比如在方法內部建立的例項在方法執行完之後就沒有存在價值了,所以這個區域的垃圾回收價效比最高。關於垃圾回收的詳細內容,見後續文章。

2.5 方法區 (Method Area)

方法區 (Method Area)與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入 的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等資料。雖然《Java虛擬機器規範》中把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫作“非堆”(Non-Heap),目的是與 Java 堆區分開來。

說到方法區,不得不提一下“永久代”這個概念,尤其是在JDK 8以前,許多 Java 程式設計師都習慣在 HotSpot 虛擬機器上開發、部署程式,很多人都更願意把方法區稱呼為“永久代”(Permanent Generation),或將兩者混為一談。本質上這兩者並不是等價的,因為僅僅是當時的 HotSpot 虛擬機器設計團隊選擇把收集器的分代設計擴充套件至方法區,或者說使用永久代來實現方法區而已,這樣使得 HotSpot的垃圾收集器能夠像管理Java堆一樣管理這部分記憶體,省去專門為方法區編寫記憶體管理程式碼的工作。但是對於其他虛擬機器實現,譬如 BEA JRockit、IBM J9 等來說,是不存在永久代的概念的。原則上如何實現方法區屬於虛擬機器實現細節,不受《Java虛擬機器規範》管束,並不要求統一。但現在回頭來看,當年使用永久代來實現方法區的決定並不是一個好主意,這種設計導致了 Java 應用更容易遇到 記憶體溢位的問題(永久代有-XX:M axPermSize 的上限,即使不設定也有預設大小,而 J9 和 JRockit 只要沒有觸碰到程式可用記憶體的上限,例如32位系統中的4GB限制,就不會出問題 ),而且有極少數方法 (例如 String :: intern() ) 會因永久代的原因而導致不同虛擬機器下有不同的表現。當 Oracle 收購 BEA 獲得了 JRockit 的所有權後,準備把 JRockit 中的優秀功能,譬如 Java Mission Control 管理工具,移植到 HotSpot 虛擬機器時,但因為兩者對方法區實現的差異而面臨諸多困難。考慮到 HotSpot 未來的發展,在 JDK 6 的 時候 HotSpot 開發團隊就有放棄永久代,逐步改為採用本地記憶體 (Native Memory) 來實現方法區的計劃了,到了JDK 7 的 HotSpot,已經把原本放在永久代的字串常量池、靜態變數等移出,而到了 JDK 8,終於完全廢棄了永久代的概念,改用與 JRockit、J9 一樣在本地記憶體中實現的元空間(Metaspace)來代替,把JDK 7中永久代還剩餘的內容(主要是型別資訊)全部移到元空間中。

《Java虛擬機器規範》對方法區的約束是非常寬鬆的,除了和 Java 堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,甚至還可以選擇不實現垃圾收集。相對而言,垃圾收集行為在這個區域的確是比較少出現的,但並非資料進入了方法區就如永久代的名字一樣“永久”存在了。這區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,一般來說這個區域的回收效果比較難令人滿意,尤其是型別的解除安裝,條件相當苛刻,但是這部分割槽域的回收有時又確實是必要的。

根據《Java虛擬機器規範》的規定,如果方法區無法滿足新的記憶體分配需求時,將丟擲 OutOfMemoryError 異常。

這部分引用內容對方法區的介紹十分全面,切記不要將方法區和永久代混為一談,從JDK 8 以後已經沒有永久代的概念了。

2.6 執行時常量池 (Runtime Constant Pool)

執行時常量池 (Runtime Constant Pool) 是方法區的一部分。Class 檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池表 (Constant Pool Table),用於存放編譯期生成的各種字面量與符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。

既然執行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體 時會丟擲OutOfMemoryError異常。

常量池是為了避免頻繁的建立和銷燬物件而影響系統效能,其實現了物件的共享。


相關文章