面試常問的 Java 虛擬機運行時數據區

寫在前面

本文描述的有關於 JVM 的運行時數據區是基於 HotSpot 虛擬機。

概述

JVM 在執行 Java 程式的過程中會把它所管理的記憶體劃分為若干個不同的數據區域。這些區域都有各自的用途,以及創建和銷毀的時間,有的區域隨著虛擬機的進程啟動而存在,有的區域則依賴於用戶執行緒的啟動和結束而建立和銷毀。

HotSpot 運行時數據區

運行時數據區在 HotSpot 1.8 之前的版本和 1.8 版本有所不同,主要是 方法區移到元空間 了。

圖 1-1:JDK1.8 之前 JVM 運行時數據區

圖 1-2:JDK1.8 JVM 運行時數據區

執行緒私有區域

程式計數器(PROGRAM COUNTER REGISTER)

程式計數器是一塊很小的區域,它存儲的是當前執行緒正在執行的位元組碼的地址(在這裡,其實有兩個「當前」,一個是:當前正在被 CPU 執行的執行緒,另一個是:當前這個被執行的執行緒中正在被執行的位元組碼指令)。位元組碼解釋器工作時就是改變程式計數器的值來選取下一條需要執行的位元組碼。對於單核心而言,多執行緒是通過執行緒輪流切換的方式實現的,在任一時刻只有一個執行緒能夠得到 CPU 的執行權從而執行執行緒中的位元組碼指令,因此,為了使執行緒切換後能夠恢復到正在執行的位元組碼的位置,每個執行緒都需要擁有自己的程式計數器。

注意:程式計數器是唯一的一塊在 Java 虛擬機規範中沒有規定任何 OutOfMemoryError 的區域。由於它是執行緒私有的,所以它的生命周期隨著執行緒的創建而創建,隨著執行緒的結束而死亡 。

虛擬機棧(VM STACK)

虛擬機棧也是執行緒私有的,所以它的生命周期與程式計數器相同。虛擬機棧描述的是 Java 方法執行的記憶體模型。

每個方法在執行的時候都會創建一個棧幀(一個方法對應一個棧幀,棧幀即棧的基本單位)用於存儲局部變數表、操作數棧、動態鏈接、方法出口等資訊。每個方法被執行緒執行從開始到結束,就對應著一個棧幀在虛擬機棧中入棧(壓棧)和出棧(彈棧)的過程。局部變數表中存放了編譯可知的各種基本數據類型(byte,short,int,long,float,double,char,boolean)、對象引用(reference 類型,它存儲的是:對象的地址或者是指向代表對象的句柄)。

Java 虛擬機規範中規定了虛擬機棧可能出現的兩種異常狀況:StackOverflowError 和 OutOfMemoryError。

StackOverflowError: 若當執行緒請求棧的深度超過當前 Java 虛擬機棧的最大深度的時候會拋出 StackOverflowError。

OutOfMemoryError: 若虛擬機棧動態擴展過程中,如果執行緒請求申請棧空間無法申請到足夠的記憶體,就會拋出 OutOfMemoryError。

本地方法棧(NATIVE METHOD STACK)

本地方法棧與虛擬機棧類似,虛擬機棧是執行 Java 方法開闢的記憶體空間,而本地方法棧是執行 Native 方法開闢的記憶體空間。

與虛擬機棧一樣,本地方法棧也會拋出 StackOverflowError 和 OutOfMemoryError 異常,拋出條件也是類似的。

執行緒間共享的記憶體區域

堆(HEAP)

堆是所有執行緒共享的一塊區域,主要用來存放對象和數組。

在 Java 虛擬機規範中有描述:所有的對象實例和數組都要在堆上分配,但是 隨著 JIT(JUST-IN-TIME)編譯器的發展與逃逸分析技術的逐漸成熟,並不是所有對象都只在堆上分配了,比如:隨著逃逸分析技術的逐漸成熟,在即時能被回收的對象也有可能會在虛擬機棧上分配。

由於現在都採用分代回收演算法,所以從記憶體回收的角度來看,堆還可以細分為:新生代、老年代。新生代又可以分為:Eden 空間、From Survivor 空間、To Survivor 空間。

注意:1.8 中已經徹底將方法區的實現由之前的永久代改為元空間。

堆裡面可能拋出的異常就是 OutOfMemoryError, 出現這種錯誤的表現形式主要有兩種:

OutOfMemoryError: GC Overhead Limit Exceeded:當 JVM 花太多時間執行垃圾回收並且只能回收很少的堆空間時,就會發生此錯誤。

java.lang.OutOfMemoryError: Java heap space:假如在創建新的對象時, 堆記憶體中的空間不足以存放新創建的對象, 就會引發java.lang.OutOfMemoryError: Java heap space 錯誤。

方法區(METHOD AREA)

方法區和堆一樣也是所有執行緒共享的一塊區域,主要用來存儲已經被虛擬機載入的類資訊、常量(final 修飾的)、靜態變數、即時編譯器(JIT)編譯後產生的程式碼等數據。雖然 Java 虛擬機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

永久代就是方法區域?

早些時候,很多開發者更願意稱方法區為「永久代」。其實「永久代」這個稱呼的由來是因為 HotSpot 團隊並不打算為方法區重新設計垃圾回收演算法,為了在方法區中能夠沿用堆中的分代回收演算法,所以按照堆中的命名方式,將方法去稱為「永久代」。對於 JRocket、J9 而言是不存在「永久代」的概念的,所以當 HotSpot 1.8 和 JRocket 合併時,就徹底放棄了「永久代」的概念(其實從 1.7 就已經開始了),取而代之是元空間,元空間使用的是直接記憶體。

方法區的垃圾回收很困難!!!

由於 Java 虛擬機規範對方法區的限制非常松,甚至可以不實現垃圾回收,一般而言,這個區域的記憶體回收很不令人滿意,尤其是類型的卸載,條件非常苛刻,但是由於現代框架大量的依賴於 JIT 技術,導致方法區的佔用比逐漸提高,所以對於方法區的回收至關重要。根據 Java 虛擬機規範規定,當方法區無法滿足記憶體分配需求時,將拋出 OutOfMemoryError 異常。

運行時常量池(RUNTIME CONSTANT POOL)

JDK1.7 及之後版本的 JVM 已經將運行時常量池從方法區中移了出來,在 Java 堆(Heap)中開闢了一塊區域存放運行時常量池。

這塊區域在 1.7 之前原來是方法區的一部分,Class 文件中有一項資訊是常量池(或者說是一張常量表,Class 文件以表存儲數據)。

圖 1-3:Class 文件常量池

運行時常量池存儲的東西較為複雜,主要分為字面量和符號引用

字面量

存放的字面量主要包括 常量(final 修飾的),比如:final int x = 1、靜態變數(static 修飾的),還有一些其他的字面量。

符號引用

符號引用主要包括:類的完全限定名、欄位名稱和描述符、方法名稱和描述符,包括很多符號,比如:() 也可以看做符號引用。

字面量和符號引用將在類載入(ClassLoader 載入 Class 位元組碼文件)後進入方法區的運行時常量池中存放。不過,除了保存 Class 文件中描述的符號引用外,還會把翻譯出來的直接引用也存儲在運行時常量池中。運行時常量池相對於 Class 文件常量池一個重要的特徵就是具備動態性,Java 語言並不要求常量一定產生於編譯期的 Class 文件的常量池中,也並不是只有 Class 文件常量池中的常量才能夠進入運行時常量池中,在執行緒執行方法的過程當中可能產生新的常量存放到運行時常量池中,例如:String 類的 intern() 方法。當運行時常量池無法申請到記憶體的時候就會拋出 OutOfMemoryError 異常。

直接記憶體(DIRECT MEMORY)

直接記憶體並不是 JVM 運行時數據區的一部分,也不是虛擬機規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用。而且也可能導致 OutOfMemoryError 錯誤出現。

在 JDK1.4 中新加入的 NIO(New Input/Output) 類,引入了一種基於通道(Channel) 與快取區(Buffer) 的 I/O 方式,它可以直接使用 Native 函數庫直接分配堆外記憶體,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作。這樣就能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆之間來回複製數據。

本機直接記憶體的分配不會受到 Java 堆的限制,但是,既然是記憶體就會受到本機總記憶體大小以及處理器定址空間的限制。

總結

Java 虛擬機包含的內容很多,本篇文章也只是對 Java 記憶體管理模組的 Java 虛擬機運行時數據區做了簡要的分析,關於記憶體管理模組的其他部分後續會繼續更新,敬請期待!

參考

公眾號

如果大家想要實時關注我更新的文章以及我分享的乾貨的話,可以關注我的公眾號 我們都是小白鼠。公眾號內有一些整理過的 原創精品腦圖,不僅包含技術點的知識脈絡,更多的底層原理的梳理,目前涵蓋 Redis,RabbitMQ,Mysql,Java 虛擬機等 ,這些都是部落客自己的學習筆記,整理的過程花費了很多心血,除此之外還有一些整理過的 面試題 以及日常開發常用到的一些 開發工具 等,在公眾號內分別回復【技術腦圖】、【面試題】、【開發工具】即可獲取。

乾貨分享