深入理解Java虛擬機 JVM 內存結構

前言

JVM是Java中比較難理解和掌握的一部分,也是面試中被問的比較多的,掌握好JVM底層原理有助於我們在開發中寫出效率更高的代碼,可以讓我們面對OutOfMemoryError時不再一臉懵逼,可以用掌握的JVM知識去查找分析問題、去進行JVM的調優、去讓我們的應用程序可以支持更高的並發量等。。。。。。總之一句話,學好JVM很重要!

JVM是什麼

JVM是Java Virtual Machine(Java虛擬機)的縮寫,JVM是一種用於計算設備的規范,它是一個虛構出來的計算機,是通過在實際的計算機上仿真模擬各種計算機功能來實現的,註意JVM是基於軟件的,不是基於硬件的。

Java語言的一個非常重要的特點就是與平臺的無關性。而使用Java虛擬機是實現這一特點的關鍵。一般的高級語言如果要在不同的平臺上運行,至少需要編譯成不同的目標代碼。而引入Java語言虛擬機後,Java語言在不同平臺上運行時不需要重新編譯。Java語言使用模式Java虛擬機屏蔽瞭與具體平臺相關的信息,使得Java語言編譯程序隻需生成在Java虛擬機上運行的目標代碼(字節碼),就可以在多種平臺上不加修改地運行。Java虛擬機在執行字節碼時,把字節碼解釋成具體平臺上的機器指令執行。

比如下圖:我們編譯後產生的.class文件是二進制的字節碼,字節碼是不能被機器直接運行的,通過JVM把編譯好的字節碼轉換成對應操作系統平臺可以直接識別運行的機器碼指令,JVM充當瞭一個中間轉換的橋梁,這樣我們編寫的Java文件就可以做到 “一次編譯,到處運行” 。

JVM內存結構概覽

JVM虛擬機規范官方文檔地址:https://docs.oracle.com/javase/specs/,JDK8虛擬機參考手冊地址:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/index.html,JDK8官方文檔地址為https://docs.oracle.com/javase/8/docs/,JDK8中內存結構文檔https://docs.oracle.com/javase/specs/jvms/se8/html/index.html。

我們先看下下面這張圖(這張圖非常重要!非常重要!非常重要!),一個Java文件的執行過程為:Hello.java文件通過javac被編譯為Hello.class文件,然後類裝載子系統將class文件加載到運行時數據區,通過執行引擎去執行生成的機器指令。

Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分為若幹個不同的數據區域,這個數據區域就叫運行時數據區。運行時數據區主要包含瞭PC寄存器(程序計數器)、Java虛擬機棧、本地方法棧、Java堆、方法區以及運行時常量池,這其中Java堆、方法區跟Java虛擬機棧是學習的重點。

但是,需要註意的是,上面的區域劃分隻是邏輯區域,對於有些區域的限制是比較松的,所以不同的虛擬機廠商在實現上,甚至是同一款虛擬機的不同版本也是不盡相同的。

運行時數據區

程序計數器

程序計數器(Program counter Register,也叫PC寄存器)是一塊較小的內存空間,是線程私有的,它可以看作是當前線程所執行的字節碼的行號指示器。在虛擬機的概念模型裡(僅是概念模到,各種虛擬機可能會通過一些更高效的方式去實現),字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需耍依賴這個計數器來完成。因為Java是可以多線程執行的,一個線程執行到一半可能因為CPU時間片輪轉切換到瞭另外一個線程,在切換回之前線程的時候,需要回到線程上次的執行位置,所以要線程私有。

如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是Native方法,這個計數器值則為空(Undefined)。此內存區城是唯一一個在Java虛擬機規范中沒有規定任何OutOfMemoryError情祝的區域。

比如在下面代碼中test1()中調用瞭test2(),test2()執行完成後退出,這時候需要回到test1()方法中繼續執行,程序計數器記錄瞭下一個需要執行的指令的行號。

public void test1(){    test2();    System.out.println("test1");}public void test2(){    System.out.println("test2");}

Java虛擬機棧

Java虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是Java方法執行的內存模型:每個方法在執行的同到都會創建一個棧幀(Stack Frame)用於存儲局部量表、操作數棧、動態鏈接、方法出口等信息。棧幀是Java方法運行時的基礎數據結構,每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬棧中從入棧到出棧的過程(說人話就是要執行一個方法,將該方法的棧幀壓入棧頂,方法執行完成其棧幀出棧)。在JVM裡面,棧幀的操作隻有兩種:出棧和入棧。正在被線程執行的方法稱為當前線程方法,而該方法的棧幀就稱為當前幀。

局部變量表存放瞭編譯期可知的各種基本數據類型(boolean、byte、char、short、int、long、float、double)、對象引用(reference類型,它不等同於對象本身,可能是一個指向對象始地址的引用指針,也可能是指向一個代表對象的句柄或其地與此對象相關的位置)和returnAddress類型(指向瞭一條字節碼指令的地址)。

在Java虛擬機規范中,對這個區域定瞭兩種異狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverFlowError異常;一般的虛擬機棧都是可擴展的,如果擴展時無法豐請到足夠的內存,就會拋出OutOfMemoryError異常,可以通過-Xss設置每個線程的堆棧大小。

Java虛擬機棧的結構如下圖所示:Java虛擬機棧的生命周期與線程一致,一個方法對應一塊棧幀內存區域,棧幀中包含局部變量表、操作數棧、動態鏈接、方法出口等信息。拿下面代碼舉例,程序執行main(),main()先壓入棧頂,然後main()方法中new瞭一個Math對象,math變量是指向堆中Math對象的引用,math變量就屬於局部變量表,創建Math對象之後,調用瞭其compute(),然後compute()壓入棧頂,compute方法執行完成後其棧幀出棧,然後根據程序計數器記錄程序執行的行號,繼續回到main方法執行,main方法中已經沒有其他執行指令瞭,則main方法退出,main方法對應的棧幀出棧,虛擬機棧中已經沒有其他棧幀,main線程生命周期結束。

註意:關於Java虛擬機棧中的棧幀,還有棧幀中的組成部分,這裡隻是做個簡單的概述,後續會單獨進行詳細講解,希望繼續關註。

public class Math {private static final Integer CONSTANT=666;private int compute() {//一個方法對應一塊棧幀內存區域int a=3;int b=5;int c=(a+b)*10;return c;}public static void main(String[] args) {Math math=new Math();math.compute();}}

本地方法棧

本地方法棧(Native Method Stack)與虛擬機棧非常相似,也是線程私有的,它們的區別不過是虛擬機棧執行的是Java方法(也就是字節碼),而本地方法棧用到的是Native方法。與虛擬機戰一樣。本地方法棧區域也會出現StackOverFlowError和OutOfMemoryError異常。

方法區

方法區(Method Area),是各個線程共享的內存區域,,它用於存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。雖然JVM規范將方法區描述為堆的一個邏輯部分,但它卻還有一個別名叫做Non一Heap(非堆),目的就是要和堆分開。這部分存儲的是運行時必須的類相關信息,裝載進此區域的數據是不會被垃圾收集器回收的,隻有關閉Jvm才會釋放這塊區域占用的內存。

對於Hotspot虛擬機,很多開發者習慣將方法區稱之為“永久代(Parmanent Gen)”,但嚴格本質上說兩者不同,或者說使用永久代來實現方法區而己,永久代是方法區(相當於是一個接口interface)的一個實現,idkl.7的版本中,己經將原本放在永久代的字符串常量池移走。Jdk1.7中方法區是用永久代實現的,到1.8中是用元空間(MetaSpace)實現的,而元空間使用的是直接內存。

根據Java虛擬機規范的規定,當方法區無法滿足內存分配需求時會拋出OutOfMemoryError異常。可以通過-XX:PermSize和 -XX:MaxPermSize來分別設置永久區最小、最大空間。

運行時常量池

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

常量池中主要存放兩大類常量:字面量(Literal)和符號引用(Symbolic References)。字面量比較接近於Java語言層面的常量概念,如文本字符串、聲明為final的常量值等。而符號引用則屬於編譯原理方面的概念,包括瞭下面三類常量:

類和接口的全限定名(Fully Qualified Name)

字段的名稱和描述符(Descriptor)

方法的名稱和描述符

Java代碼在進行Javac編譯的時候,並不像C和C++那樣有“連接”這一步驟,而是在虛擬機加載Class文件的時候進行動態連接。也就是說,在Class文件中不會保存各個方法、字段的最終內存佈局信息,因此這些字段、方法的符號引用不經過運行期轉換的話無法得到真正的內存人口地址,也就無法直接被虛擬機使用。當虛擬機運行時,需要從常量池獲得對應的符號引用,再在類創建時或運行時解析、翻譯到具體的內存地址之中。

Java語言不要求常量一定隻有編譯器才能產生,運行時也可能將新的常量放入池中,該特性用的比較多的就是String類的intern()方法。運行時常量池是方法區的一部分,在內存不夠時,也會拋出OutOfMemoryError異常。

Java堆

對於大多數應用來說,Java堆(Java Heap)是Java虛擬機所管理的內存中最大的一塊。Java堆是線程共享的,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這裡分配內存。這一點在Java擬機規范中的描述是:所有的對象實例以及數組都要在堆上分配,但是著JIT編譯器的發展與逸分析技術逐漸成熟,棧上分配、標量替換優化技術會導致一些微妙的變化發生,所有的對象都分配在堆上也漸漸變得不是那麼”絕對”瞭。

Java堆是被收集管理的主要區域,因此很多時候也被稱做”GC堆”(Garbage Collected Heap)。從內存回收角度來看,由於現在收集器基本都采用分代算法(為什麼要采用分代算法,常用的垃圾收集算法有哪些後面會進行介紹),所以堆中還以細分:新生代(Young/New)和老年代(Old/Tenure),新生代又可以劃分為Eden(伊甸園)空間、survivor(幸存區,其又可以分為from survivor和to survivor,也就是S0和S1)空間等。從內存分配的角度來看,線程共享的Java堆中可劃分出多個程私有的分配緩沖區(Thread Local Allocation Buffer,TLAB)。不過無論如何劃分,都與存放內容無關,無論哪個區域,存儲的都仍然是對象實例,進一步劃分的是為瞭更好地回收內存,或更快地分配內存。

根據Java虛擬機規范的規定,Java堆可以處於物理上不連續的內存空間中,隻邏輯上是連續的即可。.Java虛擬機中可以對堆進行擴展,可以通過-Xms 設置起始堆大小、通過-Xmx設置最大堆大小、通過-XX:NewSize設置新生代最小空間大小、通過 -XX:MaxNewSize設置新生代最大空間大小。如果在堆中沒有完成實例分配,並且地也無法再擴展時,將會拋OutOfMemoryError異常。

下圖是Java7中的Jvm內存劃分:

堆(Heap)、永久代(PermGen)

堆(Heap)又分為新生代(NewGen)或者叫年輕代(YoungGen)、老年代(OldGen)

年輕代(YoungGen)又可分為Eden區(伊甸園區)、Survivor區(幸存區)

Survivor區(幸存區)又可分為FromSpace(S0)和ToSpace(S1),整個年輕代中默認比例Eden:S0:S1=8:1:1,同一時間內S0跟S1隻會有一個區域被占用

年輕代(New):年輕代用來存放JVM剛分配的Java對象年老代(Old):年輕代中經過垃圾回收沒有回收掉的對象將被Copy到年老代永久代(Perm):永久代存放Class、Method元信息,其大小跟項目的規模、類、方法的量有關

年輕代發生的GC叫Minor GC,老年代發生的GC叫Major GC

另外還有一個Full GC,是清理整個堆空間—包括年輕代和永久代

關於堆的分代、還有對象是如何從年輕代進入老年代等都會在後面的章節中介紹。

我們看下面這張圖,在JDK1.8中將永久代去掉瞭,改由元空間(MetaSpace)去實現方法區,而元空間跟永久代的最大區別就是其不在JVM內存中,而是使用的直接內存。

關於方法區、常量池、永久代在JDK6、7、8中的變動還是挺大的:

Jdk1.6及之前:有永久代,常量池1.6在方法區Jdk1.7:有永久代,但己經逐步“去永久代”,常量池1.7在堆Jdk1.8及之後:無永久代,常量池1.8在元空間

直接內存

直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規范中定義的內存區域。在JDK1.4中新加入瞭NlO(New Inpu/Output)類,引入瞭一種基於通道(Channel)與緩沖區(Buffer)的I/O方式,它可以使用Native函數庫直接分配堆外內存,然後通過一個存儲在Java堆中的DirectByteBuffer對象作為這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免瞭在Java堆和Native堆中來回復制數據。

顯然,本機直接內存的分配不會受到Java堆大小的限制,但是,既然是內存,肯定還是會受到本機總內存(包括RAM以及SWAP區或者分頁文件)大小以及處理器尋址空間的限制。當各個內存區域總和大於物理內存限制(包括物理的和操作系統級的限制),會導致動態擴展時出現OutOfMemoryError異常。

關於JVM的內存結構本節先做瞭一個大概的介紹,其中還有很多細節沒有介紹:棧幀中的各個組成部分分別是幹什麼用的,堆內存的劃分,對象是如果從新生代到老年代的,為什麼要分代收集,垃圾收集算法有哪些,垃圾收集器有哪些。。。。。這些在後面的章節中會慢慢一一介紹,希望繼續關註。

文章內容參考瞭周志明老師的《深入理解Java虛擬機第二版》以及他翻譯的《Java虛擬機規范 JavaSE8版》,想學習JVM的話強烈推薦這本《深入理解Java虛擬機第二版》。

到此這篇關於深入理解Java虛擬機 JVM 內存結構的文章就介紹到這瞭,更多相關Java 虛擬機 JVM 內存結構內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: