一文洞悉JVM記憶體管理機制

許朋友愛玩?發表於2020-03-27

前言

本文已經收錄到我的Github個人部落格,歡迎大佬們光臨寒舍:

我的GIthub部落格

學習導圖:

學習導圖

一.為什麼要學習記憶體管理?

JavaC++之間有一堵由記憶體動態分配垃圾回收機制所圍成的高牆,牆外面的人想進去,牆裡面的人出不來

對於Java程式設計師來說,JVM給我們提供了自動記憶體管理機制,不需要既當“皇帝”,又當“人民”,不需要人為地給每一個new操作寫配對的delete/free程式碼,不容易出現記憶體洩漏和記憶體溢位問題。然而一旦出現記憶體洩漏和溢位方面的問題,如果不清楚JVM記憶體的記憶體管理機制,那麼將很難定位與解決問題。而且,JVM的記憶體管理機制在面試中也是非常重要的考點之一。

綜上,想要更加深入瞭解JVM的奧祕,探究JVM記憶體管理機制是必不可少的!!!

二.核心知識點歸納

2.1 JVM執行時資料區域

JVM 執行 Java 程式的過程:Java 原始碼檔案 (.java) 會被 Java 編譯器編譯為位元組碼檔案(.class),然後由 JVM 中的類載入器載入各個類的位元組碼檔案,載入完畢之後,交由 JVM 執行引擎執行

執行Java程式的過程

在上述過程中,JVM會用一段空間來儲存執行程式期間需要用到的資料和相關資訊,這段空間就是執行時資料區,也就是常說的JVM記憶體

JVM會將它所管理的記憶體劃分為若干個不同的資料區域,劃分結果如圖:

JVM執行時資料區

可見,執行時資料區被分為執行緒私有資料區執行緒共享資料區兩大類:

  • 執行緒私有資料區包含:程式計數器、虛擬機器棧、本地方法棧
  • 執行緒共享資料區包含:Java堆、方法區(內部包含執行時常量池

下面將為您詳細介紹各個資料區的內容

2.1.1 程式計數器

  • 定義:當前執行緒所執行的位元組碼的行號指示器
  • 如果執行緒正在執行的是一個 Java 方法,那麼計數器記錄的是正在執行的虛擬機器位元組碼指令的地址
  • 如果執行緒正在執行的是一個 Native 方法,那麼計數器的值則為

位元組碼直譯器工作時,就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

  • 為什麼必須是私有:為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,因此它是執行緒私有的記憶體
  • 在《 Java 虛擬機器規範》中,是唯一一個沒有規定任何 OutOfMemoryError 情況的區域

2.1.2 Java 虛擬機器棧

想更加詳細瞭解 JVM 棧的讀者,可以看下筆者寫的這篇文章:執行時棧幀結構

  • 定義: Java 方法執行的記憶體模型
  • 每個方法在執行的同時都會建立一個棧幀,用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊

  • 每個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程

區域性變數表存放了編譯期可知的各種基本資料型別、物件引用型別和 returnAddress 型別,它所需的記憶體空間在編譯期間完成分配

  • 執行緒私有的記憶體,與執行緒生命週期相同
  • 一般把 Java 記憶體區分為堆記憶體(Heap)和棧記憶體(Stack),其中『棧』指的是虛擬機器棧,『堆』指的是 Java
  • Java 虛擬機器規範中,對這個區域規定了兩種異常狀況:
  • 如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲 StackOverflowError 異常
  • 如果虛擬機器棧可動態擴充套件且擴充套件時無法申請到足夠的記憶體,將丟擲 OutOfMemoryError 異常

2.1.3 本地方法棧

  • 定義:虛擬機器使用到的 Native 方法服務

想要了解Native方法的讀者,可以看下這篇文章:Java中native方法

  • 在虛擬機器規範中,對這個區域無強制規定,由具體的虛擬機器自由實現。與虛擬機器棧一樣,本地方法棧區域也會丟擲 StackOverflowErrorOutOfMemoryError 異常

2.1.4 Java堆

  • 定義:被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立
  • 作用:用於存放幾乎所有的物件例項和陣列

Java 堆中,可能劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer,TLAB),但無論哪個區域,儲存的都仍然是物件例項,進一步劃分的目的是為了更好地回收記憶體,或者更快地分配記憶體

  • 是垃圾收集器管理的主要區域,也被稱做 “GC 堆”(可別叫做垃圾堆orz)
  • Java 虛擬機器所管理的記憶體中最大的一塊
  • 可處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可
  • Java 虛擬機器規範中,如果在堆中沒有記憶體完成例項分配,且堆也無法再擴充套件時,將會丟擲 OutOfMemoryError 異常

2.1.5 方法區

  • 定義:與 Java 堆一樣,是各個執行緒共享的記憶體區域

  • 作用:用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料

方法區裝了啥

  • 人們更願意把這個區域稱為 “永久代”,它還有個別名叫做 Non-Heap(非堆)

    JDK7HotSpot 中,已經把原本放在永久代的字串常量池靜態變數移出;

    JDK8中,廢棄永久代的概念,改用元空間

  • 對用元空間替換永久代的原因感興趣的話,可以看下這篇文章:一文讀懂 - 元空間和永久代

永久代/元空間 和方法區的區別:

  • 永久代/元空間 可看作是方法區的實現
  • Java 堆一樣不需要連續的記憶體和可以選擇固定大小或可擴充套件外,還可選擇不實現 GC
  • Java 虛擬機器規範中,當方法區無法滿足記憶體分配需求時,將丟擲 OutOfMemoryError 異常

2.1.6 執行時常量池

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

Q1:字面量是什麼

可以理解為字面意思的常量。

int a; //變數
const int b = 10; //b為常量,10為字面量
string str = “hello world!”; // str 為變數,hello world!為字面量
複製程式碼

由例子可知,字面量就是如此容易理解

Q2:符號引用是什麼

可以是任意型別的字面量。只要能無歧義的定位到目標。在編譯期間由於暫時不知道類的直接引用,因此先使用符號引用代替。最終還是會轉換為直接引用訪問目標

比如:java/lang/StringBuilder

Q3:執行時常量池是什麼

  • 相對於 Class 檔案常量池的一個重要特徵是具備動態性,體現在並非只有預置入 Class 檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中
  • 方法區的一部分,會受到方法區記憶體的限制
  • Java 虛擬機器規範中,當常量池無法再申請到記憶體時會丟擲 OutOfMemoryError 異常

2.1.7 直接記憶體

  • 它並不是虛擬機器執行時資料區的一部分,也不是《Java虛擬機器規範》中定義的記憶體區域,但是這部分記憶體也被頻繁地呼叫
  • 作用:避免了在JAVA堆和Native堆中來回複製資料,因此在一些場景下能顯著提高效能

JDK1.4中新加入了NIO類,引入了基於通道與緩衝區的IO方式,可以使用Native函式庫直接分配直接記憶體(堆外記憶體),然後通過DirectByteBuffer作為這塊記憶體的引用進行操作

2.2 HotSpot 虛擬機器記憶體物件探祕

在熟悉虛擬機器記憶體劃分及其具體內容之後,為詳細瞭解虛擬機器記憶體中資料的其他細節,以常用的虛擬機器 HotSpot 和常用的記憶體區域 Java 堆為例,探討 HotSpot 虛擬機器在 Java 堆中物件分配、佈局和訪問的全過程

2.2.1 物件的建立

遇到一個 new 指令後建立過程分三步

1.類載入檢查

檢查 new 指令的引數是否能在常量池中定位到一個類的符號引用且該符號引用代表的類是否已被載入、解析和初始化,若沒有則需先執行相應的類載入,反之下一步

想詳細瞭解類載入的知識的話,可以看下筆者的一篇文章:一夜搞懂 | JVM 類載入機制

2.分配記憶體

  • Java 堆中的記憶體是否規整決定如何給新生物件分配可用空間
  • 由堆所採用的垃圾收集器是否帶有空間壓縮整理的能力決定Java 堆中的記憶體是否規整

PS:想詳細瞭解GC或者記憶體分配的話,可以看下筆者的這篇文章:一夜搞懂 | JVM GC&記憶體分配

  • 若規整,採用 “指標碰撞” 分配方式:
  • 過程:將用過和空閒的記憶體放在兩邊,中間以一個指標作為分界指示器。當分配記憶體時,就把指標向空閒一邊挪動與物件大小相等的距離即可
  • 應用:SerialParNew 等帶 壓縮過程的收集器
  • 若非規整,採用 “空閒列表” 分配方式:
  • 過程:維護一個記錄可用記憶體塊的列表。當分配記憶體時,就從列表中找到一塊足夠大的空間劃分給物件例項並更新記錄
  • 應用:基於 Mark-Sweep 演算法的 CMS 收集器

分配記憶體

保證記憶體分配是執行緒安全的解決方案:

  • 對記憶體分配的動作進行同步處理
  • 每個執行緒在 Java 堆中預先分配一塊記憶體(本地執行緒分配緩衝 TLAB),在本執行緒的 TLAB 上進行分配,當 TLAB 用完需要分配新的 TLAB 時再同步鎖定

3.設定物件頭

將物件的所屬類、找到類的後設資料資訊的方式、物件的雜湊碼、物件的 GC 分代年齡等資訊存放在物件的物件頭中

2.2.2 物件的記憶體分佈

分為三塊區域

物件的記憶體分佈

  • 物件頭:包括兩部分資訊
  • Mark Word:用於儲存物件自身的執行時資料,如雜湊碼、GC 分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等
  • 型別指標:用於確定這個物件的所屬類
  • 例項資料:儲存真正的有效資訊,是程式程式碼中定義的各種型別的欄位內容。儲存順序會受虛擬機器分配策略引數和欄位在 Java 原始碼中定義順序這兩個因素影響。
  • 對齊填充:佔位符,幫助補全未對齊的物件例項資料部分(保證是 8 位元組的倍數),非必需

2.2.3 物件的訪問定位

兩種主流的訪問方式

  • 通過控制程式碼訪問物件

    Java 堆中劃分出一塊記憶體來作為控制程式碼池,reference 儲存的是物件的控制程式碼地址,在控制程式碼中包含了物件例項資料與型別資料各自的具體地址資訊

    好處:reference 中儲存的是穩定的控制程式碼地址,在物件被移動時只會改變控制程式碼中的例項資料指標,而 reference 本身不需要修改

    通過控制程式碼訪問物件

  • 通過直接指標訪問物件

    Java 堆物件的佈局中考慮如何放置訪問型別資料的相關資訊,reference 儲存的直接就是物件地址

    好處:速度更快,節省了一次指標定位的時間開銷

    通過直接指標訪問物件

2.3 實戰:OutOfMemoryError 異常

這部分的內容可以看下這篇文章:JVM記憶體溢位詳解(棧溢位,堆溢位,持久代溢位、無法建立本地執行緒)

三.課堂小測試

恭喜你!已經看完了前面的文章,相信你對JVM記憶體管理機制已經有一定深度的瞭解,下面,進行一下課堂小測試,驗證一下自己的學習成果吧!

Q1:JVM中,為什麼要把堆與棧分離?棧不是也可以儲存資料嗎?

  • 從軟體設計的角度看,棧代表了處理邏輯,而堆代表了資料,分工明確,處理邏輯更為清晰體現了“分而治之”以及“隔離”的思想。

  • 堆與棧的分離,使得堆中的內容可以被多個棧共享(也可以理解為多個執行緒訪問同一個物件)。這樣共享的方式有很多收益:提供了一種有效的資料互動方式(如:共享記憶體);堆中的共享常量和快取可以被所有棧訪問,節省了空間。

  • 棧因為執行時的需要,比如儲存系統執行的上下文,需要進行地址段的劃分。由於棧只能向上增長,因此就會限制住棧儲存內容的能力。而堆不同,堆中的物件是可以根據需要動態增長的,因此棧和堆的拆分,使得動態增長成為可能,相應棧中只需記錄堆中的一個地址即可。

  • 堆和棧的結合完美體現了物件導向的設計。當我們將物件拆開,你會發現,物件的屬性即是資料,存放在堆中;而物件的行為(方法)即是執行邏輯,放在棧中。因此編寫物件的時候,其實即編寫了資料結構,也編寫的處理資料的邏輯。

Q2:為啥說堆和JVM棧是程式執行的關鍵

  • 棧是執行時的單位(解決程式的執行問題,即程式如何執行,或者說如何處理資料),而堆是儲存的單位(解決的是資料儲存的問題,即資料怎麼放、放在哪兒)
  • 堆儲存的是物件。棧儲存的是基本資料型別和堆中物件的引用;(引數傳遞的值傳遞和引用傳遞)

如果文章對您有一點幫助的話,希望您能點一下贊,您的點贊,是我前進的動力

本文參考連結:

相關文章