【深入學習JVM 01】執行時資料區域劃分

Coder_Ring發表於2018-08-27

前言

在使用c++進行程式設計時,我們通過new建立的每一個物件都需要有對應的delete操作去釋放物件所佔用的記憶體,對記憶體的掌控度比較高,但是程式設計師需要知道物件什麼時候不需要使用了,並需要手動釋放記憶體,如果忘記了delete釋放,很容易出現記憶體洩漏(申請記憶體後,沒有釋放,會一直佔用著)和記憶體溢位(因為過多的記憶體洩漏導致無法申請足夠的記憶體,即out of memory)的問題。

相比之下,java虛擬機器提供了自動記憶體管理機制,java程式設計師可以解放雙手,不再需要去寫delete等手動釋放記憶體的程式碼,虛擬機器會自動將記憶體中無用的物件佔用的記憶體釋放。

瞭解jvm的必要

雖然有自動記憶體管理機制的存在,但是不代表寫的每個java程式都不存在記憶體洩漏和記憶體溢位問題,我們需要對虛擬機器有足夠的瞭解,才能在發生記憶體洩露和記憶體溢位的時候有效地排查問題。

本文將對jvm虛擬機器執行時記憶體進行一個基本的介紹,後續的文章也會講解jvm其他知識,大部分都是自己的讀書總結加上自己的理解。希望將自己的所學進行總結的同時能惠及他人,如果有什麼地方講的不對,希望各位同學能夠指出。

記憶體劃分

java虛擬機器將其管理的記憶體劃分為以下幾塊:

  • 程式計數器 (PC Register)
  • 虛擬機器棧 (JVM Stack)
  • 本地方法棧 (Native Method Stack)
  • 堆 (Heap)
  • 方法區 (Method Area)

執行時資料區域.png

各個區域都有其各自的特點和作用,以及不同的建立和銷燬的時間

各個區域的介紹

程式計數器

  • 描述
    • 程式計數器是一個較小的記憶體區域
  • 作用
    • 記錄著當前執行緒所執行的位元組碼行號
    • 位元組碼直譯器在工作的時候,通過改變這個計數器的值來選取下一條要執行的位元組碼指令。
    • 分支迴圈跳轉異常處理執行緒恢復等功能都需要使用到這個程式計數器。
  • 特點
    • 執行緒私有--每個執行緒都有一個獨立的程式計數器。
    • 如果當前執行緒正在執行一個java方法,這程式計數器的值為虛擬機器位元組碼指令的地址,如果執行的是一個Native 方法,這個計數器的值則為空。
    • 程式計數器是唯一沒有規定OutOfMemoryError的記憶體區域
  • 建立時間
    • 每個執行緒啟動的時候會建立一個較小的記憶體區域作為執行緒的程式計數器
  • 銷燬時間
    • 執行緒結束時會釋放該記憶體區域

擴充套件問題1:為什麼需要程式計數器?

java虛擬機器的多執行緒是通過執行緒輪轉,分配CPU時間片來執行java程式,當執行緒切換時,為了能夠回到原來的位元組碼執行位置繼續程式的執行,所以每個執行緒會有一個程式計數器。

擴充套件問題2:Native方法是什麼?

java程式執行的時候呼叫的方法,有些是用java語言實現的,有些是用其他語言編寫實現的,用其他語言實現的方法稱為Native方法本地方法,native方法會使用native關鍵字進行標註,如Object類的getClass()方法:

public class Object {
    
    public final native Class<?> getClass();
    ...
}
複製程式碼

由於native方法不是java實現的,也就沒有位元組碼行號之說,此時程式計數器的值應當為空(undefined)。

虛擬機器棧

  • 描述
    • 虛擬機器棧是描述java方法執行過程的一個記憶體模型
    • 具體描述:每個方法在執行的時候都會建立一個棧幀,棧幀中儲存的是java方法的區域性變數表、運算元棧、動態連結、方法出口等資訊。java程式在執行的時候每呼叫一個java方法都會對應的建立棧幀並壓入虛擬機器棧中,當方法執行完畢,又會將棧幀從虛擬機器棧中彈出。虛擬機器棧就是棧幀存放的一個棧結構的記憶體區域。
  • 作用
    • 描述java方法執行的過程,儲存棧幀。
  • 特點
    • 執行緒私有
    • 此區域可能會有兩種記憶體異常情況:
      • 當棧的深度大於虛擬機器所限制的最大深度,會丟擲StackOverflowError異常。
      • 如果虛擬機器棧動態擴充套件無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常。
  • 建立時間
    • 執行緒啟動的時候
  • 銷燬時間
    • 執行緒結束的時候

擴充套件1:區域性變數表

區域性變數表用於存放編譯期可知的各種基本資料型別、物件引用、returnAddress型別(一條位元組碼指令的地址)

對於基本資料型別,存放的是變數的名和值;

對於引用型別,存放的是指向物件在堆中的起始地址。

ps: 對於64位的long或double型別的區域性變數會佔用兩個區域性變數表空間(Slot),其餘的資料型別都是隻佔用一個區域性變數表空間。

區域性變數表所需要的空間在編譯期間已經計算好了,在一個方法執行時,需要為棧幀分配多少區域性變數表空間是完全確定的

本地方法棧

本地方法棧的特性和虛擬機器棧幾乎一樣。

  • 本地方法棧與虛擬機器棧的區別
    • 本地方法棧為本地方法服務
  • 本地方法棧可能出現的異常
    • 同虛擬機器棧一樣可能丟擲StackOverflowErrorOutOfMemoryError 異常。

  • 描述
    • 堆記憶體的唯一目的是存放物件例項
    • 堆記憶體是垃圾收集的主要區域,因此也叫GC堆
  • 作用
    • 存放物件例項
  • 特點
    • 虛擬機器所管理的記憶體中最大的一塊
    • 幾乎所有的物件都在堆區分配記憶體,當然也有例外,JIT編譯器有可能會進行優化,直接在棧上分配,有關資訊可以直接搜尋“逃逸分析”瞭解,這不在本文的討論範圍內。
    • 所有執行緒共享的一塊記憶體區域
    • 堆記憶體在物理上不一定是連續的,保證邏輯連續即可
    • 堆記憶體區域無法滿足分配物件例項所需記憶體,可能丟擲OutOfMemoryError異常
    • 堆記憶體設定固定大小也可以動態擴充套件,可在啟動引數上指定最小大小及擴容的上限。
  • 建立時間
    • 虛擬機器啟動的時候就建立了堆記憶體

擴充套件1: 堆區細分

jvm為了垃圾回收的方便,將堆劃分為新生代老年代,新建立的物件基本上都放在新生代中,而存活比較久的物件則會移到老年代中。新生代和老年代採用不同的垃圾收集演算法,可以更高效地回收記憶體。採用複製演算法的新生代還可以細分為EdenFrom SurvivorTo Survivor。具體的詳情是怎樣的,為了不偏離這篇文章的主旨,這裡先打個問號,後序的文章將會詳細介紹堆區的幾個劃分的用途。

堆區雖然是執行緒共享的,但是如果設定了啟動引數-XX:+UseTLAB,則開啟了本地執行緒分配緩衝(Thread local Allocation Buffer, TLAB),會為每個執行緒單獨在堆中劃分出一個TLAB,哪個執行緒需要分配記憶體,就先在該執行緒對應的TLAB中分配記憶體,當TLAB用完,才在堆區的Eden中繼續申請一塊TLAB

方法區

方法區是用於存放虛擬機器載入的類資訊、常量、靜態變數、編譯後的程式碼等資料。

方法區特點:

  • 執行緒共享
  • 方法區大小可固定也可以動態擴充套件。
  • 與堆區一樣不需要連續的實體記憶體,但要求邏輯連續。
  • 該區域的垃圾收集目標主要是針對執行時常量池的回收和對類進行解除安裝
  • 可能出現OutOfMemoryError異常。

擴充套件1:執行時常量池:

class檔案中有個常量池,執行時常量池就是class檔案中常量池經過類載入後存放的記憶體區域。

常量池主要存放兩類常量:字面量和符號引用。

字面量指字串,宣告為final的常量值等;而符號引用是java編譯後生成的各種常量,其包括:

  • 類和介面的全限定名
  • 成員變數的名稱和描述符
  • 方法的名稱和描述符

jdk1.8之前,方法區是用永久代實現的, 在jdk1.7以下的版本,執行時常量池是方法區的一部分,而jdk1.7及之後的版本,執行時常量池中的字串常量池已經不在方法區,而是在java堆中開闢了一塊區域作為字串常量池。

在jdk1.8開始,已經沒有永久代的概念,譬如符號引用(Symbols)轉移到了native 堆中的元空間;字面量也在 java heap;類的靜態變數(class statics)轉移到了java heap

擴充套件2:常量是否只能在編譯期產生? 否,執行期也可能將新的常量放入執行時常量池中,比如Stringintern方法。在jdk1.7的表現如下:

// 如果執行時常量池中,存在"10"這個字串常量
// 則將常量池中的字串物件返回,
// 如果不存在,則直接在執行時常量池中建立“10"這個字串,並將其返回。
String s = String.valueOf(10).intern();

複製程式碼

直接記憶體

前面講的幾塊都屬於虛擬機器管理的執行時資料區域,java程式中也有可能會用到不是虛擬機器執行時記憶體區域的一部分。這塊記憶體我們通常稱為直接記憶體

  • 直接記憶體不受java堆大小的限制,但是受本機實體記憶體的限制。

  • 直接記憶體也可能導致出現OutOfMemoryError異常。

直接記憶體的例子: jdk 1.4 加入的NIO類,引入了一種基於通道Channel和緩衝區Buffer的IO方式。直接通過Native方法在java堆外的直接記憶體中分配記憶體, 通過儲存在java堆中的DirectByteBuffer物件作為這塊直接記憶體的引用。操作DirectByteBuffer即可操作直接記憶體,這樣做的好處是避免了要使用直接記憶體的時候需要先複製到java堆中。直接操作直接記憶體更加高效。

相關文章