深刻理解 Java 虛擬機:Java 內存區域透徹分析

前言

Java是目前用戶最多、使用範圍最廣的軟件開發技術,Java 的技術體系主要由支撐Java程序運行的虛擬機。爲各開發領域提供接口支持的Java API, Java編程語言及許許多多的第三方Java框架( 如Spring和Struts等)構成。在國內,有關Java API、Java 語言及第三方框架的技術資料和書籍很是豐富,相比之下,有關Java虛擬機的資料卻顯得異常貧乏。java

這種情況很大程度上是由Java開發技術自己的一個重要優勢致使的:程序員

在虛擬機層面隱藏了底層技術的複雜性以及機器與操做系統的差別性。運行程序的物理機器狀況千差萬別,而Java虛擬機則在千差萬別的物理機上面創建了統一的運行平臺,實現了在任意一臺虛擬機上編譯的程序都能在任何一臺虛擬機上正常運行。這一極大的優點使得Java應用的開發比傳統C/C++應用的開發更高效和快捷,程序員能夠把主要精力集中在具體業務邏輯上,而不是物理硬件的兼容性上。編程

通常狀況下,一個程序員只要瞭解了必要的Java API, Java語法井學習適當的第三方開發框架,就已經基本能知足平常開發的須要了,虛擬機會在用戶不知不覺中完成對硬件平臺的兼容以及對內存等資源的管理工做。所以,瞭解虛擬機的運做並非通常開發人員必須掌握的知識。然而,凡事都具有兩面性。隨着Java技術的不斷髮展,它被應用於愈來愈多的領域之中。其中一些領域,如電力、金融、通訊等,對程序的性能、穩定性和可擴展性方面都有極高的要求。bash

一個程序極可能在10我的同時使用時徹底正常,可是在10000我的同時使用時就會變慢、死鎖甚至崩潰。毫無疑問,要知足10000我的同時使用須要更高性能的物理硬件,可是在絕大多數狀況下,提高硬件效能沒法等比例地提高程序的性能和併發能力,有時甚至可能對程序的性能沒有任何改善做用。數據結構

這裏面有Java虛擬機的緣由:爲了達到爲全部硬件提供一致的虛擬平臺的目的,犧牲了一些硬件相關的性能特性。架構

更重要的是人爲緣由:開發人員若是不瞭解虛擬機的一些技術特性的運行原理,就沒法寫出最適合虛擬機運行和可自優化的代碼。併發

其實,目前商用的高性能Java虛擬機都提供了至關多的優化特性和調節手段,用於知足應用程序在實際生產環境中對性能和穩定性的要求。若是隻是爲了入門學習,讓程序在本身的機器上正常運行,那麼這些特性能夠說是無關緊要的;若是用於生產環境,尤爲是企業級應用開發中,就迫切須要開發人員中至少有一部分人對虛擬機的特性及調節方法具備很清晰的認識,因此在Java開發體系中,對架構師、系統調優師、高級程序員等角色的需求一直都很是大。框架

關於JVM

JVM是Java Virtual Machine(Java虛擬機)的縮寫,JVM是一種用於計算設備的規範,它是一個虛構出來的計算機,是經過在實際的計算機上仿真模擬各類計算機功能來實現的。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異常。

相關文章
相關標籤/搜索