一文詳解JVM內存模型,從線程共享到本地方法棧再到Java堆

2019-12-06     老男孩的成長之路

前言

在正式學習 JVM 內存模型之前,先注意以下幾個是問題:

  1. JVM 內存模型與 JAVA 內存模型不是同一個概念。JVM 內存模型是從運行時數據區的結構的角度描述的概念;而 JAVA 內存模型是從主內存和線程私有內存角度的描述。從以下兩張圖可以看出:

​ JAVA內存模型

​ JVM內存模型

  1. Java虛擬機總共由三大模塊組成:類加載器子系統運行時數據區執行引擎本篇我們介紹第二大模塊——運行時數據區(JVM內存模型)。
  2. 其實虛擬機的這些模塊並不是獨立的,都是相互聯繫的。java 文件編譯為 class 文件,通過類加載子系統加載,信息再到 JVM 託管的內存中(部分操作會與本地內存交互)的流轉,再到垃圾回收等等,都是一系列的操作。

概覽

運行時數據區分為幾大模塊(如上圖所示):

線程共享區:

  • JAVA堆
  • 方法區

線程私有區:

  • JAVA棧
  • 本地方法棧
  • 程序計數器

本文中,我們將從以下幾個方法面來分析各個區域:

  • 功能
  • 存儲的內容
  • 是否有內存溢出和內存泄露
  • 是否進行垃圾回收
  • 對應的垃圾回收算法
  • 垃圾回收流程
  • 性能調優

線程私有區

程序計數器

程序計數器是一塊較小的內存空間,它的作用可以看做是當前線程所執行的位元組碼的行號指示器。位元組碼解釋器工作時通過該計數器的值來選擇選取下一條需要執行的位元組碼的指令,分支、循環、跳轉、異常處理、線程恢復都需要依賴該區域。

通俗點講,該區域存放的就是一個指針,指向方法區的方法位元組碼,用來存儲指向下一條指令的地址,也就是即將要執行的指令代碼

如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機位元組碼指令的地址;如果正在執行的是Native方法,這個計數器值則為空(Undefined)。

當執行完一行指令碼,JVM執行引擎會更新程序計數器的值。

由於Java 虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個內核)只會執行一條線程中的指令。因此,為了線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間的計數器互不影響,獨立存儲,我們稱這類內存區域為「線程私有」的內存。(方法的調用,方法中又調用另外一個方法,正式滿足棧的「先進先出,後進後出」的模型)。

OutOfMemoryError:無

虛擬機棧

它描述的是java方法執行的內存模型,其生命周期與線程相同。

每個方法在執行的同時都會創建一個棧幀(StackFrame),每一個棧幀又包括局部變量表、操作數棧、動態連結、方法出口等。方法的調用,方法中又調用另外一個方法,正式滿足棧的「先進先出,後進後出」的模型。即每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程。

以上都只是幾個很機械的概念,難以深入理解。下面我通過一個示例,來分析虛擬機棧的存儲內容。

首先創建一個簡單的程序:

package com.sunwin.robotcloud.test;
/**
* Created by 追夢1819 on 2019-11-01.
*/
public class CalculateMain {
public int calculate(){
int a = 3;
int b=4;
int c = a+b;
return c;
}
public static void main(String[] args) {
CalculateMain main = new CalculateMain();
int d = main.calculate();
System.out.println(d);
}

對於以上程序,線程啟動時,虛擬機會給主線程 main 分配一個大的內存空間,然後給main方法分配一個棧幀,存放該方法的局部變量;
執行calculate()方法時又分配一個calculate()的棧幀,存放對應方法的局部變量。

要注意的是,一個方法分配一個單獨的內存區域,即棧幀。

Java 屬於高級語言,難以直接通過代碼看出它的執行過程。我們通過底層的位元組碼,反解析出執行的指令碼,來分析底層執行過程。

進入 CalculateMain.class 文件目錄,執行命令:

將指令碼直接輸出到文件 CalculateMain.txt:

Compiled from "CalculateMain.java"
public class com.sunwin.robotcloud.test.CalculateMain {
public com.sunwin.robotcloud.test.CalculateMain();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return

public int calculate();
Code:
0: iconst_3
1: istore_1
2: iconst_4
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: iload_3
9: ireturn

public static void main(java.lang.String[]);
Code:
0: new #2 // class com/sunwin/robotcloud/test/CalculateMain
3: dup
4: invokespecial #3 // Method "":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method calculate:()I
12: istore_2
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
20: return
}

先看看calculate()方法,根據以上指令,查詢JVM指令手冊,可以得到以上程序的執行流程:

0.將int類型常量3壓入(操作數)棧;

1.將int類型值3存入局部變量1(1是數組下標),也就是在局部變量表中給a分配一塊內存(用以存儲3);

2.將int類型常量4壓入(操作數)棧;

3.將int類型值4存入局部變量2;

4.從局部變量1中裝載int類型值,也就是將局部變量表的值3,拿出來加載到操作數棧;

5.從局部變量2中裝載int類型值;

6.兩值相加;

7.(將數存入到操作數棧?)將int類型值7存入局部變量3;

8.從局部變量3中裝載int類型值;

9.返回計算值。

以上是方法執行時的局部變量在內存中的流轉過程。總結就是:
操作數棧相當於數據在操作時的臨時中轉站

局部變量表:局部變量存放空間。是一個字長為單位、從0開始計數的數組。類型為int、float、reference、retrueAddress的值,只占據一項。類型為byte、short、char的值存入數組前都被轉化為int值。類型為long、double的值在其中占據連續的兩項。索引指向第一個值即可。
不過需要注意的是,虛擬機對byte、short、char是直接支持的,只不過在局部變量表和操作數棧中是被轉化為了int值,在堆和方法區中,依然是原來的類型。

操作數棧:數據操作的臨時空間。與局部變量表類似。唯一不同的是,它並非是通過索引來訪問的,而是通過壓棧和出棧來訪問的。

動態連結:存放的是方法的jvm指令碼的內存地址,運行時動態生成的。
對象有對象頭,其中一個類型指針指向方法區的類元信息

方法出口:存放的是出該方法,進入下一個方法的程序計數器的值。

JAVA棧結構

異常情況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError 異常;如果虛擬機棧可以動態擴展(當前大部分的Java 虛擬機都可動態擴展,只不過Java 虛擬機規範中也允許固定長度的虛擬機棧),當擴展時無法申請到足夠的內存時會拋出OutOfMemoryError 異常。

本地方法棧

本地方法棧其實與java虛擬機棧極其相似。唯一的區別就是java虛擬機棧是為java方法服務,本地方法棧是為本地方法服務,虛擬機規範中對本地方法棧中的方法使用的語言、使用方式與數據結構並沒有強制規定,因此具體的虛擬機可以自由實現它。

也會拋出StackOverflowError和OutOfMemoryError異常。

線程共享區

方法區

該區域是存儲虛擬機加載的類信息(欄位方法的位元組碼、部分方法的構造器)、常量、靜態變量、編譯後的代碼信息等,類的所有欄位和方法位元組碼。以及一些特殊方法如構造函數,接口的代碼也在此定義。簡而言之,所有定義的方法的信息都保存在該區域。靜態變量+常量+類信息(構造方法/接口定義)+運行時常量池都存在。

可不連續,可固定大小,可擴展,也可不選擇垃圾回收器。垃圾回收存在在該區域,但是出現較少。

方法區是一種定義,概念,而永久代或者元空間是一種實現機制。

OutOfMemoryError:有

運行時常量池

Class文件中除了有類的版本、欄位、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中存放。

OutOfMemoryError:有

JAVA堆

堆是Java虛擬機所管理的內存中最大的一塊,它唯一的功能就是存儲對象實例。幾乎所有的對象(包含常量池),都會在堆上分配內存。

如果在堆中沒有內存完成實例分配,並且堆也無法再擴展時,將會拋出OutOfMemoryError 異常。

垃圾回收器的主要管理區域。

該區域,從垃圾回收的角度看,又分為新生代和老年代,新生代又分為 伊甸區(Eden space)和倖存者區(Survivor pace) ,Survivor 區又分為Survivor From 區和 Survivor To 區。如下圖所示:


以上區域的大小分配是:

新生代:堆的 1/3

老年代:堆的 2/3

Eden 區: 新生代的 8/10

Survivor From 區:新生代的 1/10

Survivor To區:新生代的 1/10

如果是從內存分配的角度來看,可以劃分多個線程私有的分配緩衝區。

對於堆空間來說,本質都是存儲對象實例。不過如何分區,都只是為了更好地分配和管理對象實例。關於堆空間對對象實例的管理和回收,在下一章節闡述。

同時,物理上可以不連續,但是邏輯上必須是連續的。

以下是JVM內存模型整體結構:

對象回收流程

下圖摘自網絡:

所有的類都是在伊甸區被 new 出來的,等到 Eden 區滿的時候,會觸發 Minor GC,將不需要再被其他對象引用的對象進行銷毀,將剩餘的對象移動到 From Survivor 區,每觸發一次 Minor GC,對象的分代年齡會+1(分代年齡是存放在對象頭裡面的),From Survivor 區滿的時候, From Survivor 區觸發 Minor GC,未被回收的對象,分代年齡會繼續+1,會移至 to survior 區,此時Eden的未被回收的對象也是移至 To Survivor 區,To Survivor 區滿的時候,被移至 From Survivor 區,以此類推。

對象的分代年齡到15的時候,對象會進入到老年代(靜態變量(對象類型)、資料庫連接池等)。若老年代也滿了,這個時候會產生 Major GC(Full GC),進行老年區的內存清理。若老年區執行了 Full GC之後發現依然無法進行對象的保存,就會產生OOM 異常 OutOfMemoryError。

注意事項

  1. 運行時數據區,版本不同,會有細微的差別,具體如下:元數據區元數據區取代了永久代(jdk1.8以前),本質和永久代類似,都是對JVM規範中方法區的實現,區別在於元數據區並不在虛擬機中,而是使用本地物理內存,永久代在虛擬機中,永久代邏輯結構上屬於堆,但是物理上不屬於堆,堆大小=新生代+老年代。元數據區也有可能發生OutOfMemory異常;jdk1.6及以前:有永久代,常量池在方法區;jdk1.7:有永久代,但已經逐步「去永久代」,常量池在堆;jdk1.8及以後:無永久代,常量池在元空間(用的是計算機的直接內存,而不是虛擬機管理的內存)。
  2. 為什麼jdk1.8用元數據區取代了永久代?官方解釋:移除永久代是為融合HotSpot JVM與JRockit VM而做出的努力,因為JRockit沒有永久代,不需要配置永久代。(簡單說,就是兩者競爭,誰贏了就聽誰的。)
  3. 元數據區的動態擴展,默認–XX:MetaspaceSize值為21MB的高水位線。一旦觸及則Full GC將被觸發並卸載沒有用的類(類對應的類加載器不再存活),然後高水位線將會重置。新的高水位線的值取決於GC後釋放的元空間。如果釋放的空間少,這個高水位線則上升。如果釋放空間過多,則高水位線下降。

文章來源: https://twgreatdaily.com/zh-cn/v1P84W4BMH2_cNUgFHtg.html