天天看點

Java記憶體區域有哪些構成?

作者:小牛呼噜噜

#頭條創作挑戰賽#

大家好,我是呼噜噜,這次我們一起來看看Java記憶體區域,本文 基于HotSpot 虛拟機,JDK8

前言

Java 記憶體區域, 也叫運作時資料區域、記憶體區域、JVM記憶體模型,和 Java 虛拟機(JVM)的運作時區域相關,是指 JVM運作時将資料分區域存儲,強調對記憶體空間的劃分。 經常與Java記憶體模型(JMM)混淆,其定義了程式中各個變量的通路規則,即在虛拟機中将變量存儲到記憶體和從記憶體中取出變量這樣的底層細節。

JVM并不是隻有唯一版本的,在Java發展曆史中,有許多優秀的Java虛拟機,其中目前大家最熟悉的就是HotSpot虛拟機,什麼你不知道?

Java記憶體區域有哪些構成?

我們去Oracle官網,下載下傳JDK,其自帶的虛拟機,就是HotSpot。

HotSpot VM的最大特色:熱點代碼探測,其可以通過執行計數器,找出最具有編譯價值的代碼,然後通知JIT編譯器進行編譯,通過編譯器和解釋器的協同合作,在最優程式響應時間和最佳執行性能中取得平衡。
Java記憶體區域有哪些構成?

簡單介紹一下,上圖的主要組成部分:

  • 類加載器系統:主要用于子系統将編譯好的.class檔案加載到JVM中,了解見:類加載器
  • 執行引擎:包括即時編譯器和垃圾回收器,即時編譯器将Java位元組碼編譯成具體的機器碼,垃圾回收器用于回收在運作過程中不再使用的對象
  • 本地庫接口:用于調用作業系統的本地方法庫,完成具體的指令操作
  • 運作時資料區:用于儲存在JVM運作過程中産生的資料,不同的虛拟機在記憶體配置設定上也略有差異,但總體來說都遵循《Java虛拟機規範》。在《Java虛拟機規範》中規定了五種虛拟機運作時資料區,他們分别為:程式計數器、Java虛拟機棧、本地方法棧、本地方法區、堆 以及方法區。下文我們以此圖為基準,詳細地分析各個部分,慢慢道來

Java 記憶體區域

程式計數器

程式計數器(Program Counter Register)是用于存放下一條指令所在單元位址的一塊記憶體,在虛拟機的規範裡,位元組碼解析器的工作是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴這個計數器來完成。

我們來對Java中class檔案反編譯:

Java記憶體區域有哪些構成?

在JVM邏輯上規定,程式計數器是一塊較小的記憶體空間,可以看作是目前線程所執行位元組碼的行号訓示器,PC寄存器,也叫"程式計數器",其是CPU中寄存器的一種,偏硬體概念

由于程式計數器儲存了 下一條指令要執行位址,是以在JVM中,執行指令的一般過程:執行引擎會從 程式計數器中獲得下一條指令的位址,拿到其對應的操作指令,對其進行執行,當該指令結束,位元組碼解釋器會根據pc寄存器裡的值選取下一條指令并修改pc寄存器裡面的值,達到執行下一條指令的目的,周而複始直至程式結束。

位元組碼解釋器可以拿到所有的位元組碼指令執行順序,而程式計數器隻是為了記錄目前執行的位元組碼指令位址,防止線程切換找不到下一條指令位址

我們知道作業系統中線程是由CPU排程來執行指令的,JVM的多線程是通過CPU時間片輪轉來實作的,某個線程在執行的過程中可能會因為時間片耗盡而挂起。當它再次擷取時間片時,需要從挂起的地方繼續執行。在JVM中,通過程式計數器來記錄程式的位元組碼執行位置。

執行程式在單線程情況下還好,但在多線程的情況下:線程在執行的指令時,CPU可能切換線程,去另一個更緊急的指令,執行完再繼續執行先前的指令。特别是單核CPU的情況下,CPU會頻繁的切換線程,"同時"執行多個任務。

為了CPU切換線程後,依舊能恢複到先前指令執行的位置,這就需要每個線程有自己獨立的程式計數器,互不影響。我們可以發現程式計數器是線程私有的,每條線程都有一個程式計數器。

程式計數器是java虛拟機規範中唯一一個沒有規定任何OutofMemeryError(記憶體洩漏)的區域,它的生命周期随着線程的建立而建立,随着線程的結束而死亡。因為目前線程正在執行Java中的方法,程式計數器記錄的就是正在執行虛拟機位元組碼指令的位址,如果是Native方法,這個計數器就為空(undefined)

PC寄存器(程式計數器)與JVM中的程式計數器還是有所差別的:

PC寄存器永遠指向下一條待執行指令的記憶體位址(永遠不會為undefined),并且在程式開始執行前,将程式指令序列的起始位址,即程式的第一條指令所在的記憶體單元位址送入PC, CPU按照PC的訓示從記憶體讀取第一條指令(取指)

當執行指令時,CPU會自動地修改PC的内容,即每執行一條指令PC增加一個量,這個量等于指令所含的位元組數(指令位元組數),使PC總是指向下一條将要取指的指令位址。

由于大多數指令都是按順序來執行的,是以修改PC的過程通常隻是簡單的對PC 加“指令位元組數”。當程式轉移時,轉移指令執行的最終結果就是要改變PC的值,此PC值就是轉去的目标位址。處理器總是按照PC指向,取指、譯碼、執行,以此實作了程式轉移。

虛拟機棧

虛拟機棧(JVM Stacks),和資料結構上的棧類似,先進後出。其與程式計數器一樣,也是線程私有的,其生命周期和線程相同,随着線程的建立而建立,随着線程的死亡而死亡。

虛拟機棧描述的是Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀,用于存儲局部變量表、操作數棧、動态連接配接、方法出口等資訊。棧幀在虛拟機棧中入棧到出棧(順序: 先進後出)的過程,其實就對應Java中方法的調用至執行完成的過程

棧幀是用于支援虛拟機進行方法調用和方法執行的資料結構,它是虛拟機運作時資料區中的虛拟機棧的棧元素,每個棧幀存儲了方法的變量表、操作數棧、動态連接配接和方法傳回等資訊。

Java記憶體區域有哪些構成?

其中:

  1. 在目前活動線程中,隻有位于棧頂的幀才是有效的,稱為目前棧幀。正在執行的方法稱為目前方法,棧幀是方法運作的基本結構。在執行引擎運作時,所有指令都隻能針對目前棧幀進行操作。
  2. 方法調用的資料需要通過棧進行傳遞,每一次方法調用都會有一個對應的棧幀被壓入棧中,每一個方法調用結束後,都會有一個棧幀被彈出。
  3. 每個棧幀包含四個區域:局部變量表、操作數棧、動态連接配接、傳回位址
  4. 在《Java虛拟機規範》中,對這個記憶體區域規定了兩類異常狀況:
  • 如果線程請求的棧深度大于虛拟機所允許的深度,将抛出StackOverflowError異常;
  • 如果Java虛拟機棧容量可以動态擴充,當棧嘗試擴充時無法申請到足夠的記憶體,或為一個新線程初始化JVM棧時沒有足夠的記憶體時會抛出OutOfMemoryError異常。《Java虛拟機規範》明确允許Java虛拟機實作自行選擇是否支援棧的動态擴充,HotSpot虛拟機是選擇不支援擴充,是以HotSpot虛拟機線上程運作時是不會因為擴充而導緻OutOfMemoryError(記憶體溢出)的異常

我們下面主要介紹一下棧幀的結構:

  1. 局部變量表

局部變量表:是存放方法參數和局部變量的區域,主要存放了編譯期可知的各種資料類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference 類型,它不同于對象本身,可能是一個指向對象起始位址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)

我們知道局部變量沒有賦初始值是不能使用的,而全局變量是放在堆的,有兩次指派的階段,一次在類加載的準備階段,賦予系統初始值;另外一次在類加載的初始化階段,賦予代碼定義的初始值。拓展見:類加載器

局部變量表的容量以 Variable Slot(變量槽)為最小機關,每個變量槽都可以存儲 32 位長度的記憶體空間.基本類型資料以及引用和 returnAddress(傳回位址)占用一個變量槽,long 和 double 需要兩個

在方法執行時,虛拟機使用局部變量表完成參數值到參數變量清單的傳遞過程的,如果執行的是執行個體方法,那局部變量表中第 0 位索引的 Slot 預設是用于傳遞方法所屬對象執行個體的引用(在方法中可以通過關鍵字 this 來通路到這個隐含的參數)其餘參數則按照參數表順序排列,占用從 1 開始的局部變量 Slot。關鍵字this詳解 我們可以寫個例子驗證一下

public class Test {
    void fun(){
    }
}           

javac -g:vars Test.java生成Test.class檔案,一定要加參數-g:vars,不然反編譯時,無法顯示局部變量表LocalVariableTable 我們接着反編譯一下:

javap -v Test


Classfile /D:/GiteeProjects/study-java/study/src/com/company/test3/Test.class
  Last modified 2022-11-20; size 261 bytes
  MD5 checksum 72c7d1fcc5d83dd6fc82c43ae55f2b34
public class com.company.test3.Test
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#11         // java/lang/Object."<init>":()V
   #2 = Class              #12            // com/company/test3/Test
   #3 = Class              #13            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LocalVariableTable
   #8 = Utf8               this
   #9 = Utf8               Lcom/company/test3/Test;
  #10 = Utf8               fun
  #11 = NameAndType        #4:#5          // "<init>":()V
  #12 = Utf8               com/company/test3/Test
  #13 = Utf8               java/lang/Object
{
  public com.company.test3.Test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/company/test3/Test;

  void fun();
    descriptor: ()V
    flags:
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   Lcom/company/test3/Test; //!!!可以看出this在Slot的第0位!!!
}           
  1. 操作數棧

操作數棧 主要用于存放方法執行過程中産生的中間計算結果或者臨時變量,通過變量的入棧、出棧等操作來執行計算。 在方法執行的過程中,會有各種位元組碼指令往操作數棧中寫入和提取内容,也就是出棧和入棧操作。我們前文說的JVM執行引擎,是基于棧的執行引擎, 其中的棧指的就是操作數棧

  1. 動态連結

每個棧幀都儲存了 一個 可以指向目前方法所在類的 運作時常量池, 目的是: 目前方法中如果需要調用其他方法的時候, 能夠從運作時常量池中找到對應的符号引用, 然後将符号引用轉換為直接引用,然後就能直接調用對應方法, 這就是動态連結。本質就是,在方法運作時将符号引用轉為調用方法的直接引用,這種引用轉換的過程具備動态性 不是所有方法調用都需要動态連結的, 有一部分符号引用會在 類加載階段, 将符号引用轉換為直接引用, 這部分操作稱之為: 靜态解析. 就是編譯期間就能确定調用的版本, 包括: 調用靜态方法, 調用執行個體的私有構造器, 私有方法, 父類方法

Java記憶體區域有哪些構成?
  1. 傳回位址

Java 方法有兩種傳回方式:

  • 正常退出,即正常執行到任何方法的傳回位元組碼指令,如 return等;
  • 異常退出

無論何種退出情況,都将傳回至方法目前被調用的位置。方法退出的過程相當于彈出目前棧幀 我們可以發現:棧幀随着方法調用而建立,随着方法結束而銷毀。無論方法正常完成還是異常完成都算作方法結束.

本地方法棧

本地方法棧(Native Method Stack):是線程私有的,其與虛拟機棧的作用基本是一樣的,有點差別的是:虛拟機棧是服務Java方法的,而本地方法棧是為虛拟機調用Native方法服務的,通過 JNI (Java Native Interface) 直接調用本地 C/C++ 庫,不再受JVM控制。

JNI 類本地方法最著名的應該是 System.currentTimeMillis() ,JNI使 Java 深度使用作業系統的特性功能,複用非 Java 代碼。 當大量本地方法出現時,勢必會削弱 JVM 對系統的控制力

本地方法被執行的時候,在本地方法棧也會建立一個棧幀,用于存放該本地方法的局部變量表、操作數棧、動态連結、出口資訊。方法執行完畢後相應的棧幀也會出棧并釋放記憶體空間。與虛拟機棧一樣,本地方法棧區域也會抛出StackOverflowError和OutOfMemoryError

另外在Java虛拟機規範中對于本地方法棧沒有特殊的要求,虛拟機可以自由的實作它,是以在HotSpot虛拟機直接把本地方法棧和虛拟機棧合二為一了。是以對于HotSpot來說,-Xoss參數(設定 本地方法棧大小)雖然存在,但實際上是沒有任何效果的,棧容量隻能由-Xss參數來設定。

堆(Heap)是Java虛拟機所管理的最大的一塊記憶體區域,是被所有線程共享的,Java堆唯一的目的就是存放對象執行個體,幾乎所有的對象執行個體都在堆上配置設定記憶體,但是随着JIT編譯器的發展和逃逸分析技術的逐漸成熟,棧上配置設定、線程本地配置設定緩存(TLAB)也可以存放對象執行個體

Java虛拟機規範規定,Java堆可以處在實體上不連續的記憶體空間中,隻要邏輯上連續即可,目前主流的虛拟機都是按照可擴充來實作的(通過 -Xmx 和 -Xms 控制)。如果在堆中沒有記憶體完成執行個體配置設定,并且堆也無法再擴充時,将會抛出 OutOfMemoryError 異常。

方法區

方法區(Methed Area)用于存儲已被虛拟機加載的類資訊、常量、靜态變量、即時編譯後的代碼等資料。其是所有線程共享的記憶體區域。

在Java 虛拟機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個别名叫做 Non-Heap(非堆),與 Java 堆區分開來。

方法區是JVM規範的一個概念定義,并不是一個具體的實作,由于Java虛拟機對于方法區的限制是非常寬松的,是以也就導緻了不同的虛拟機上方法區有不同的表現,我們還是以HotSpot虛拟機為例:

  • 在JDK8前,HotSpot 虛拟機對Java虛拟機規範中方法區的實作方式是永久代
  • 在JDK8及其以後,HotSpot 虛拟機對Java虛拟機規範中方法區的實作方式變成了元空間

網上許多文章喜歡拿"永久代"或者"元空間" 來代替方法區,但本質上兩者并不等價。方法區是Java虛拟機規範的概念,"永久代"或者"元空間"是方法區的2中實作方式

方法區在JDK7之前是一塊單獨的區域,HotSpot虛拟機的設計團隊把GC分代收集擴充到了方法區。這樣HotSpot的垃圾收集器就可以向管理Java堆一樣管理這部分記憶體。但是對于其它虛拟機(如BEA JRockit、IBM J9等)來說其實是不存在永久代的概念的。

HotSpot的團隊顯然也意識到了,用永久代來實作方法區并不是一個好主意:

字元串存在永久代中,容易出現性能問題和記憶體溢出

類及方法的資訊等比較難确定其大小,是以對于永久代的大小指定比較困難,太小容易出現永久代溢出,太大則容易導緻老年代溢出。

永久代會為 GC 帶來不必要的複雜度,并且回收效率偏低。

是以,在JDK1.8中完全廢除了“永久代”,使用元空間替代了永久代,其他内容移至元空間,元空間直接在本地記憶體配置設定。

當方法區無法滿足記憶體配置設定需求時,将抛出OutOfMemoryError異常。元空間是使用直接記憶體實作的,我們下文再詳細說。

Java記憶體區域大緻就這些了,下面我們再補充幾個比較讓人迷惑的概念

字元串常量池

字元串屬于引用資料類型,但是可以說字元串是Java中使用頻繁的一種資料類型。是以,為了節省程式記憶體,提高性能,Java的設計者開辟了一塊叫字元串常量池的區域,用來存儲這些字元串,避免字元串的重複建立。字元串常量池是所有類公用的一塊空間,在一個虛拟機中隻有一塊常量池區域。

在類加載完成,經過驗證,準備階段之後在堆中生成字元串對象執行個體,然後将該字元串對象執行個體的引用值存到字元串常量池中(這裡描述指的是JDK7及以後的HotSpot虛拟機)。 在HotSpot虛拟機中字元串常量池是通過一個StringTable類來實作的。它是一個哈希表,裡面存的是字元串引用

在JDK7以前,字元串常量池在方法區(永久代)中,此時常量池中存放的是字元串對象。而在JDK7及其以後中,字元串常量池從方法區遷移到了堆記憶體,同時将字元串對象存到了堆記憶體,隻在字元串常量池中存入了字元串對象的引用。

在JDK7 就已經開始了HotSpot 的永久代的移除工作,主要由于永久代的 GC 回收效率太低。等到JDK 8 的時候,永久代被徹底移除了 Java 程式中通常會有大量的被建立的字元串等待回收,将字元串常量池放到堆中,能夠更高效及時地回收字元串記憶體。

運作時常量池

運作時常量池(Runtime Constant Pool)是方法區的一部分。我們知道Class 檔案中除了有類的版本、字段、方法、接口等常見描述資訊外,但還有一項資訊是常量池(Constant Pool Table),用于存放編譯期生成的各種字面量,符号引用還有翻譯出來的直接引用,這部分内容将在類加載後進入方法區的運作時常量池中存放。是以,每一個類都會有一個運作時常量池

因為Java語言并不要求常量一定在編譯期間才能生成。也就是并非預置入Class檔案常量池中的内容才能進入運作時常量池,運作期間也可以将新的常量放入常量池中,運作時常量池另外一個重要特征是具備動态性。

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

直接記憶體

JDK 8 版本之後 永久代已被元空間取代,元空間使用的就是直接記憶體。直接記憶體(Direct Memory)并不是Java虛拟機運作時資料區的一部分,也不是 Java 虛拟機規範中定義的記憶體區域。

在 JDK 1.4 中新加入了 NIO,引入了一種基于通道(Channel)與緩沖區(Buffer)的 I/O 方式,它可以使用 Native 函數庫直接配置設定堆外記憶體,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆中來回複制資料。

顯然,本機直接記憶體的配置設定不會受到 Java 堆大小的限制,但是,既然是記憶體,肯定還是會受到本機總記憶體(包括 RAM 以及 SWAP 區或者分頁檔案)大小以及處理器尋址空間的限制。伺服器管理者在配置虛拟機參數時,會根據實際記憶體設定 -Xmx 等參數資訊,但經常忽略直接記憶體,使得各個記憶體區域總和大于實體記憶體限制(包括實體的和作業系統級的限制),進而導緻動态擴充時出現 OutOfMemoryError 異常。

小結

  1. 線程私有區域(包括 程式計數器, 虛拟機棧, 本地方法棧),生命周期跟随線程的啟動而建立,随線程的結束而銷毀
  2. 線程共享區域(包括 方法區 和 堆 ),生命周期跟随虛拟機的啟動而建立,随虛拟機的關閉而銷毀
Java記憶體區域有哪些構成?

本篇文章到這裡就結束啦,如果我的文章對你有所幫助的話,還請點個免費的贊,你的支援會激勵我輸出更高品質的文章,感謝!

原文鏡像:https://mp.weixin.qq.com/s/gvW6AXQXdvSq7oOI4BP4Ew

計算機内功、源碼解析、科技故事、項目實戰、面試八股等更多硬核文章,首發于公衆号「小牛呼噜噜」,我們下期再見!

繼續閱讀