一文洞悉JVM內存管理機制


前言

本文已經收錄到我的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)
  • JVM 所管理的內存中最大的一塊
  • 可處於物理上不連續的內存空間中,只要邏輯上是連續的即可
  • Java 虛擬機規范中,如果在堆中沒有內存完成實例分配,且堆也無法再擴展時,將會拋出 OutOfMemoryError 異常

2.1.5 方法區

注意:方法區必須和虛擬機棧區分開,方法區不存方法,虛擬機棧存 Java 方法

  • 定義:與 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棧是程序運行的關鍵

  • 棧是運行時的單位(解決程序的運行問題,即程序如何執行,或者說如何處理數據),而堆是存儲的單位(解決的是數據存儲的問題,即數據怎么放、放在哪兒)
  • 堆存儲的是對象。棧存儲的是基本數據類型和堆中對象的引用;(參數傳遞的值傳遞和引用傳遞)

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

本文參考鏈接:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM