深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

還有頭髮還能學發表於2019-12-10

前言

Java是目前使用者最多、使用範圍最廣的軟體開發技術,Java 的技術體系主要由支撐Java程式執行的虛擬機器。為各開發領域提供介面支援的Java API, Java程式語言及許許多多的第三方Java框架( 如Spring和Struts等)構成。在國內,有關Java API、Java 語言及第三方框架的技術資料和書籍非常豐富,相比之下,有關Java虛擬機器的資料卻顯得異常貧乏。

這種狀況很大程度上是由Java開發技術本身的一個重要優點導致的:

在虛擬機器層面隱藏了底層技術的複雜性以及機器與作業系統的差異性。執行程式的物理機器情況千差萬別,而Java虛擬機器則在千差萬別的物理機上面建立了統一的執行平臺,實現了在任意一臺虛擬機器上編譯的程式都能在任何一臺虛擬機器上正常執行。這一極大的優勢使得Java應用的開發比傳統C/C++應用的開發更高效和快捷,程式設計師可以把主要精力集中在具體業務邏輯上,而不是物理硬體的相容性上。

一般情況下,一個程式設計師只要瞭解了必要的Java API, Java語法井學習適當的第三方開發框架,就已經基本能滿足日常開發的需要了,虛擬機器會在使用者不知不覺中完成對硬體平臺的相容以及對記憶體等資源的管理工作。因此,瞭解虛擬機器的運作並不是一般開發人員必須掌握的知識。然而,凡事都具備兩面性。隨著Java技術的不斷髮展,它被應用於越來越多的領域之中。其中一些領域,如電力、金融、通訊等,對程式的效能、穩定性和可擴充套件性方面都有極高的要求。

一個程式很可能在10個人同時使用時完全正常,但是在10000個人同時使用時就會變慢、死鎖甚至崩潰。毫無疑問,要滿足10000個人同時使用需要更高效能的物理硬體,但是在絕大多數情況下,提升硬體效能無法等比例地提升程式的效能和併發能力,有時甚至可能對程式的效能沒有任何改善作用。

這裡面有Java虛擬機器的原因:為了達到為所有硬體提供一致的虛擬平臺的目的,犧牲了一些硬體相關的效能特性。

更重要的是人為原因:開發人員如果不瞭解虛擬機器的一些技術特性的執行原理,就無法寫出最適合虛擬機器執行和可自優化的程式碼。

其實,目前商用的高效能Java虛擬機器都提供了相當多的優化特性和調節手段,用於滿足應用程式在實際生產環境中對效能和穩定性的要求。如果只是為了入門學習,讓程式在自己的機器上正常執行,那麼這些特性可以說是可有可無的;如果用於生產環境,尤其是企業級應用開發中,就迫切需要開發人員中至少有一部分人對虛擬機器的特性及調節方法具有很清晰的認識,所以在Java開發體系中,對架構師、系統調優師、高階程式設計師等角色的需求一直都非常大。

關於JVM

JVM是Java Virtual Machine(Java虛擬機器)的縮寫,JVM是一種用於計算裝置的規範,它是一個虛構出來的計算機,是通過在實際的計算機上模擬模擬各種計算機功能來實現的。

引入Java語言虛擬機器後,Java語言在不同平臺上執行時不需要重新編譯。Java語言使用Java虛擬機器遮蔽了與具體平臺相關的資訊,使得Java語言編譯程式只需生成在Java虛擬機器上執行的目的碼(位元組碼),就可以在多種平臺上不加修改地執行。

Java記憶體區域透徹分析

這篇文章主要介紹Java記憶體區域,也是作為Java虛擬機器的一些最基本的知識,理解了這些知識之後,才能更好的進行Jvm調優或者更加深入的學習,本來這些知識是晦澀難懂的,所以希望能夠講解的透徹且形象。

執行時資料區域

JVM載執行Java程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域。

Java 虛擬機器所管理的記憶體一共分為Method Area(方法區)、VM Stack(虛擬機器棧)、Native Method Stack(本地方法棧)、Heap(堆)、Program Counter Register(程式計數器)五個區域。

這些區域都有各自的用途,以及建立和銷燬的時間,有的區域隨著虛擬機器程式的啟動而存在,有些區域則是依賴使用者執行緒的啟動和結束而建立和銷燬。具體如下圖所示:

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

上圖介紹的是JDK1.8 JVM執行時記憶體資料區域劃分。1.8同1.7比,最大的差別就是:後設資料區取代了永久代。元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:後設資料空間並不在虛擬機器中,而是使用本地記憶體

程式計數器(Program Counter Register)

程式計數器(Program Counter Register)是一塊較小的記憶體空間,可以看作是當前執行緒所執行的位元組碼的行號指示器。在虛擬機器概念模型中,位元組碼直譯器工作時就是通過改變計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

程式計數器是一塊 “執行緒私有” 的記憶體,每條執行緒都有一個獨立的程式計數器,能夠將切換後的執行緒恢復到正確的執行位置。

  • 執行的是一個Java方法

計數器記錄的是正在執行的虛擬機器位元組碼指令的地址

  • 執行的是Native方法

計數器為空(Undefined),因為native方法是java通過JNI直接呼叫本地C/C++庫,可以近似的認為native方法相當於C/C++暴露給java的一個介面,java通過呼叫這個介面從而呼叫到C/C++方法。由於該方法是通過C/C++而不是java進行實現。那麼自然無法產生相應的位元組碼,並且C/C++執行時的記憶體分配是由自己語言決定的,而不是由JVM決定的。

  • 程式計數器也是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況的記憶體區域。

其實,我感覺這塊區域,作為我們開發人員來說是不能過多的干預的,我們只需要瞭解有這個區域的存在就可以,並且也沒有虛擬機器相應的引數可以進行設定及控制。

Java虛擬機器棧(Java Virtual Machine Stacks)

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

Java虛擬機器棧(Java Virtual Machine Stacks)描述的是Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame),從上圖中可以看出,棧幀中儲存著區域性變數表運算元棧動態連結方法出口等資訊。每一個方法從呼叫直至執行完成的過程,會對應一個棧幀在虛擬機器棧中入棧到出棧的過程。

與程式計數器一樣,Java虛擬機器棧也是執行緒私有的。

區域性變數表中存放了編譯期可知的各種:

  • 基本資料型別(boolen、byte、char、short、int、 float、 long、double)
  • 物件引用(reference型別,它不等於物件本身,可能是一個指向物件起始地址的指標,也可能是指向一個代表物件的控制程式碼或其他與此物件相關的位置)
  • returnAddress型別(指向了一條位元組碼指令的地址)

其中64位長度的long和double型別的資料會佔用2個區域性變數空間(Slot),其餘資料型別只佔用1個。區域性變數表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變數表的大小。

Java虛擬機器規範中對這個區域規定了兩種異常狀況:

  • StackOverflowError:執行緒請求的棧深度大於虛擬機器所允許的深度,將會丟擲此異常。
  • OutOfMemoryError:當可動態擴充套件的虛擬機器棧在擴充套件時無法申請到足夠的記憶體,就會丟擲該異常。

一直覺得上面的概念性的知識還是比較抽象的,下面我們通過JVM引數的方式來控制棧的記憶體容量,模擬StackOverflowError異常現象。

本地方法棧(Native Method Stack)

本地方法棧(Native Method Stack) 與Java虛擬機器棧作用很相似,它們的區別在於虛擬機器棧為虛擬機器執行Java方法(即位元組碼)服務,而本地方法棧則為虛擬機器使用到的Native方法服務。

在虛擬機器規範中對本地方法棧中使用的語言、方式和資料結構並無強制規定,因此具體的虛擬機器可實現它。甚至有的虛擬機器(Sun HotSpot虛擬機器)直接把本地方法棧和虛擬機器棧合二為一。與虛擬機器一樣,本地方法棧會丟擲StackOverflowErrorOutOfMemoryError異常。

  • 使用-Xss引數減少棧記憶體容量(更多的JVM引數可以參考這篇文章:深入理解Java虛擬機器-常用vm引數分析)

這個例子中,我們將棧記憶體的容量設定為256K(預設1M),並且再定義一個變數檢視棧遞迴的深度。

 /**
 * @ClassName Test_02
 * @Description 設定Jvm引數:-Xss256k
 * @Author 歐陽思海
 * @Date 2019/9/30 11:05
 * @Version 1.0
 **/
 public class Test_02 {
 
 private int len = 1;

 public void stackTest() {
 len++;
 System.out.println("stack len:" + len);
 stackTest();
 }

 public static void main(String[] args) {
 Test_02 test = new Test_02();
 try {
 test.stackTest();
2 } catch (Throwable e) {
23 e.printStackTrace();
24 }
25 }
26}
複製程式碼

執行時設定JVM引數

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

輸出結果:

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

Java堆(Heap)

對於大多數應用而言,Java堆(Heap)是Java虛擬機器所管理的記憶體中最大的一塊,它被所有執行緒共享的,在虛擬機器啟動時建立。此記憶體區域唯一的目的存放物件例項,幾乎所有的物件例項都在這裡分配記憶體,且每次分配的空間是不定長的。在Heap 中分配一定的記憶體來儲存物件例項,實際上只是儲存物件例項的屬性值屬性的型別物件本身的型別標記等,並不儲存物件的方法(方法是指令,儲存在Stack中),在Heap 中分配一定的記憶體儲存物件例項和物件的序列化比較類似。

Java堆是垃圾收集器管理的主要區域,因此也被稱為 “GC堆(Garbage Collected Heap)” 。從記憶體回收的角度看記憶體空間可如下劃分:

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

圖片摘自https://blog.csdn.net/bruce128/article/details/79357870

  • 新生代(Young):新生成的物件優先存放在新生代中,新生代物件朝生夕死,存活率很低。在新生代中,常規應用進行一次垃圾收集一般可以回收70% ~ 95% 的空間,回收效率很高。

如果把新生代再分的細緻一點,新生代又可細分為Eden空間From Survivor空間To Survivor空間,預設比例為8:1:1。

  • 老年代(Tenured/Old):在新生代中經歷了多次(具體看虛擬機器配置的閥值)GC後仍然存活下來的物件會進入老年代中。老年代中的物件生命週期較長,存活率比較高,在老年代中進行GC的頻率相對而言較低,而且回收的速度也比較慢。
  • 永久代(Perm):永久代儲存類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料,對這一區域而言,Java虛擬機器規範指出可以不進行垃圾收集,一般而言不會進行垃圾回收。

其中新生代和老年代組成了Java堆的全部記憶體區域,而永久代不屬於堆空間,它在JDK 1.8以前被Sun HotSpot虛擬機器用作方法區的實現

另外,再強調一下堆空間記憶體分配的大體情況,這對於後面一些Jvm優化的技巧還是有幫助的。

  • 老年代 :三分之二的堆空間
  • 年輕代 :三分之一的堆空間
    eden區:8/10 的年輕代空間
    survivor0 : 1/10 的年輕代空間
    survivor1 : 1/10 的年輕代空間

最後,我們再通過一個簡單的例子更加形象化的展示一下堆溢位的情況。

  • JVM引數設定:-Xms10m -Xmx10m

這裡將堆的最小值和最大值都設定為10m,如果不瞭解這些引數的含義,可以參考這篇文章:深入理解Java虛擬機器-常用vm引數分析

 /**
 * VM Args:-Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
 * @author zzm
 */
 public class HeapTest {
 
 static class HeapObject {
 }
 
 public static void main(String[] args) {
 List<HeapObject> list = new ArrayList<HeapObject>();

 //不斷的向堆中新增物件
 while (true) {
 list.add(new HeapObject());
 }
 }
}
複製程式碼

輸出結果:

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

圖中出現了java.lang.OutOfMemoryError,並且提示了Java heap space,這就說明是Java堆記憶體溢位的情況。

堆的Dump檔案分析

我的使用的是VisualVM工具進行分析,關於如何使用這個工具檢視這篇文章(深入理解Java虛擬機器-如何利用VisualVM對高併發專案進行效能分析 )。在執行程式之後,會同時開啟VisualVM工具,檢視堆記憶體的變化情況。

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

在上圖中,可以看到,堆的最大值是30m,但是使用的堆的容量也快接近30m了,所以很容易發生堆記憶體溢位的情況。

接著檢視dump檔案。

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

如上圖,堆中的大部分的物件都是HeapObject,所以,就是因為這個物件的一直產生,所以導致堆記憶體不夠分配,所以出現記憶體溢位。

我們再看GC情況。

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

如上圖,Eden新生代總共48次minor gc,耗時1.168s,基本滿足要求,但是survivor卻沒有,這不正常,同時Old Gen老年代總共27次full gc,耗時4.266s,耗時長,gc多,這正是因為大量的大物件進入到老年代導致的,所以,導致full gc頻繁。

方法區(Method Area)

方法區(Method Area) 與Java堆一樣,是各個執行緒共享的記憶體區域。它用於儲存一杯虛擬機器載入的類資訊、常量、靜態變數、及時編譯器編譯後的程式碼等資料。正因為方法區所儲存的資料與堆有一種類比關係,所以它還被稱為 Non-Heap

執行時常量池(Runtime Constant Pool)

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

Java虛擬機器對Class檔案每一部分(自然包括常量池)的格式有嚴格規定,每一個位元組用於儲存那種資料都必須符合規範上的要求才會被虛擬機器認可、裝載和執行。但對於執行時常量池,Java虛擬機器規範沒有做任何有關細節的要求,不同的提供商實現的虛擬機器可以按照自己的需求來實現此記憶體區域。不過一般而言,除了儲存Class檔案中的描述符號引用外,還會把翻譯出的直接引用也儲存在執行時常量池中。

執行時常量池相對於Class檔案常量池的另外一個重要特徵是具備動態性,Java語言並不要求常量一定只有編譯器才能產生,也就是並非置入Class檔案中的常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中

執行時常量池舉例

上面的動態性在開發中用的比較多的便是String類的intern() 方法。所以,我們以intern() 方法舉例,講解一下執行時常量池

String.intern()是一個native方法,作用是:如果字串常量池中已經包含有一個等於此String物件的字串,則直接返回池中的字串;否則,加入到池中,並返回。

 /**
 * @ClassName MethodTest
 * @Description vm引數設定:-Xms512m -Xmx512m -Xmn128m -XX:PermSize=10M -XX:MaxPermSize=10M -XX:NewRatio=4 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:-HeapDumpOnOutOfMemoryError -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
 * @Author 歐陽思海
 * @Date 2019/11/25 20:06
 * @Version 1.0
 **/
 
 public class MethodTest {

 public static void main(String[] args) {
 List<String> list = new ArrayList<String>();
 long i = 0;
 while (i < 1000000000) {
 System.out.println(i);
 list.add(String.valueOf(i++).intern());
 }
 }
}
複製程式碼

vm引數介紹:

-Xms512m -Xmx512m -Xmn128m -XX:PermSize=10M -XX:MaxPermSize=10M -XX:NewRatio=4 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:-HeapDumpOnOutOfMemoryError -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
開始堆記憶體和最大堆記憶體都是512m,永久代大小10m,新生代和老年代1:4,E:S1:S2=8:1:1,最大經過15次survivor進入老年代,使用的,垃圾收集器是新生代ParNew,老年代CMS。

通過這樣的設定之後,檢視執行結果:

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

首先堆記憶體耗完,然後看看GC情況,設定這些引數之後,GC情況應該會不錯,拭目以待。

深入理解 Java 虛擬機器:Java 記憶體區域透徹分析

上圖是GC情況,我們可以看到新生代 21 次minor gc,用了1.179秒,平均不到50ms一次,效能不錯,老年代 117 次full gc,用了45.308s,平均一次不到1s,效能也不錯,說明jvm執行是不錯的。

注意: 在JDK1.6及以前的版本中執行以上程式碼,因為我們通過-XX:PermSize=10M -XX:MaxPermSize=10M設定了方法區的大小,所以也就是設定了常量池的容量,所以執行之後,會報錯:java.lang.OutOfMemoryError:PermGen space,這說明常量池溢位;在JDK1.7及以後的版本中,將會一直執行下去,不會報錯,在前面也說到,JDK1.7及以後,去掉了永久代。

直接記憶體

直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。但這部分記憶體也被頻繁運用,而卻可能導致OutOfMemoryError異常出現。

這個我們實際中主要接觸到的就是NIO,在NIO中,我們為了能夠加快IO操作,採用了一種直接記憶體的方式,使得相比於傳統的IO快了很多。

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

在配置虛擬機器引數時,會根據實際記憶體設定-Xmx等引數資訊,但經常忽略直接記憶體,使得各個記憶體區域總和大於實體記憶體限制(包括物理的和作業系統的限制),從而導致動態擴充套件時出現OutOfMemoryError異常。


相關文章