Java記憶體區域與記憶體溢位異常 - 執行時資料區

SnailsH發表於2024-07-31

一、執行時資料區

Java虛擬機器執行時資料區

1.1 程式計數器 - 執行緒私有

可以看做當前執行緒所執行的位元組碼行號指示器,在任意時刻一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。所以為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的執行緒計數器,各條執行緒之間計數器互不影響,獨立儲存。

執行緒執行方法:

  • Java 方法 - 執行緒計數器記錄虛擬機器位元組碼指令的地址
  • Native 方法 - 計數器為空(Undefined)

此區域是唯一一個不會產生 OutOfMemoryError 情況的區域

1.2 Java 虛擬機器棧 - 執行緒私有

每個方法被執行的時候,Java虛擬機器都會同步建立一個棧幀(Stack Frame)用於儲存區域性變數表、運算元棧、動態連線、方法出口等資訊。每一個方法被呼叫直至執行完畢的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

1.2.1 區域性變數表

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

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

上面的描述可能比較難記憶和理解,我們可以透過 JDK 提供的 javap 命令來反彙編一個 class 檔案看看。

先編寫一個類,Main 方法中定義基本型別和應用型別:

public class Main {
    public static void main(String[] args) {
        boolean isBooleanType = true;
        byte byteType = 24;
        char charType = 's';
        short shortType = 16;
        float floatType = 32;
        long longType = 648;
        double doubleType = 64;
        int intType = 11;
        People people = new People("張三", 18);
        System.out.println("Hello world!");
    }
}

透過 java 執行得到 .class 位元組碼檔案,然後透過 javap -l Main.class 命令反彙編檢視行和區域性變數表:

Compiled from "Main.java"
public class com.snails.Main {
  public com.snails.Main();
    LineNumberTable:
      line 5: 0
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0       5     0  this   Lcom/snails/Main;

  public static void main(java.lang.String[]);
    LineNumberTable:
      line 7: 0
      line 8: 2
      line 9: 5
      line 10: 8
      line 11: 12
      line 12: 16
      line 13: 21
      line 14: 26
      line 15: 30
      line 16: 43
      line 17: 51
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0      52     0  args   [Ljava/lang/String;
          2      50     1 isBooleanType   Z
          5      47     2 byteType   B
          8      44     3 charType   C
         12      40     4 shortType   S
         16      36     5 floatType   F
         21      31     6 longType   J
         26      26     8 doubleType   D
         30      22    10 intType   I
         43       9    11 people   Lcom/snails/entity/People;
}

可以看到 longType 的 Slot 索引位置是6 到下一個 doubleType 的索引位置 8 確佔用 2 個變數槽,doubleType 同理。驗證了上面所說的變數槽 Slot 佔用大小。

異常情況丟擲如下:

if (執行緒請求的棧深度 > 虛擬機器所允許的深度) {
    throw new StackOverflowError();
} else {
    throw new OutOfMemoryError();
}

1.3 本地方法棧

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

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

本地方法棧

1.4 Java 堆

Java 堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。Java世界裡“幾乎”所有的物件例項都在這裡分配記憶體。

Java堆是垃圾收集器管理的記憶體區域,因此一些資料中它也被稱作“GC堆”。從回收記憶體的角度看,由於現代垃圾收集器大部分都是基於分代收集理論設計的,所以Java堆中經常會出現“新生代”“老年代”“永久代”“Eden空間”“From Survivor空間”“To Survivor空間”等名詞。在十年之前(以G1收集器的出現為分界),作為業界絕對主流的HotSpot虛擬機器,它內部的垃圾收集器全部都基於“經典分代”[3]來設計,需要新生代、老年代收集器搭配才能工作,在這種背景下,上述說法還算是不會產生太大歧義。但是到了今天,垃圾收集器技術與十年前已不可同日而語,HotSpot裡面也出現了不採用分代設計的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了。

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

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

1.5 方法區

方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等資料。

JDK 8以前,許多Java程式設計師都習慣在HotSpot虛擬機器上開發、部署程式,很多人都更願意把方法區稱呼為“永久代”(PermanentGeneration),或將兩者混為一談。

到了JDK 7的HotSpot,已經把原本放在永久代的字串常量池、靜態變數等移出,而到了JDK 8,終於完全廢棄了永久代的概念,改用與JRockit、J9一樣在本地記憶體中實現的元空間(Meta-space)來代替,把JDK 7中永久代還剩餘的內容(主要是型別資訊)全部移到元空間中。

這區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,一般來說這個區域的回收效果比較難令人滿意,尤其是型別的解除安裝,條件相當苛刻,但是這部分割槽域的回收有時又確實是必要的。

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

1.6 執行時常量池

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

執行時常量池相對於Class檔案常量池的另外一個重要特徵是具備動態性,Java語言並不要求常量一定只有編譯期才能產生,也就是說,並非預置入Class檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可以將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。

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

1.7 直接記憶體

在JDK 1.4中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O方式,它可以使用 Native 函式庫直接分配堆外記憶體,然後透過一個儲存在 Java 堆裡面的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在 Java 堆和 Native 堆中來回複製資料。

顯然,本機直接記憶體的分配不會受到 Java 堆大小的限制,但是,既然是記憶體,則肯定還是會受到本機總記憶體(包括實體記憶體、SWAP分割槽或者分頁檔案)大小以及處理器定址空間的限制,一般伺服器管理員配置虛擬機器引數時,會根據實際記憶體去設定-Xmx等引數資訊,但經常忽略掉直接記憶體,使得各個記憶體區域總和大於實體記憶體限制(包括物理的和作業系統級的限制),從而導致動態擴充套件時出現OutOfMemoryError 異常。

相關文章