由于Java程序是交由JVM執行的,所以我們在談Java內存區域劃分的時候事實上是指JVM內存區域劃分。在討論JVM內存區域劃分之前,先來看一下Java程序具體執行的過程:
首先Java源代碼文件(.java后綴)會被Java編譯器編譯為字節碼文件(.class后綴),然后由JVM中的類加載器加載各個類的字節碼文件,加載完畢之后,交由JVM執行引擎執行。在整個程序執行過程中,JVM會用一段空間來存儲程序執行期間需要用到的數據和相關信息,這段空間一般被稱作為Runtime Data Area(運行時數據區),也就是我們常說的JVM內存。因此,在Java中我們常常說到的內存管理就是針對這段空間進行管理(如何分配和回收內存空間)。
一、運行時數據區域
1. 程序計數器
在匯編語言中,程序計數器是指CPU中的寄存器,它保存的是程序當前執行的指令的地址(也可以說保存下一條指令的所在存儲單元的地址),當CPU需要執行指令時,需要從程序計數器中得到當前需要執行的指令所在存儲單元的地址,然后根據得到的地址獲取到指令,在得到指令之后,程序計數器便自動加1或者根據轉移指針得到下一條指令的地址,如此循環,直至執行完所有的指令。
雖然JVM中的程序計數器并不像匯編語言中的程序計數器一樣是物理概念上的CPU寄存器,但是JVM中的程序計數器的功能跟匯編語言中的程序計數器的功能在邏輯上是等同的,也就是說是用來指示 執行哪條指令的。
jvm中,程序計數器是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。在虛擬機的概念模型里,字節碼解釋器的工作就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令。
每個線程都有一個獨立的程序計數器
如果線程執行的是非Native方法(Java方法),則程序計數器中保存的是當前需要執行的指令的地址;如果線程執行的是Native方法,則程序計數器中的值是undefined。
由于程序計數器中存儲的數據所占空間的大小不會隨程序的執行而發生改變,因此,程序計數器不會發生內存溢出現象(OutOfMemory)。
2. 虛擬機棧(又稱Java棧)
與程序計數器一樣,Java虛擬機棧也是線程私有的,即每個線程都會有一個自己的Java棧(因為每個線程正在執行的方法可能不同),Java虛擬機棧的生命周期與線程相同。虛擬機棧描述的是Java方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀,棧幀用于存儲局部變量表、操作數棧、指向當前方法所屬的類的運行時常量池的引用、方法返回地址和一些額外的附加信息。
當線程執行一個方法時,就會隨之創建一個對應的棧幀,并將建立的棧幀壓棧。當方法執行完畢之后,便會將棧幀出棧。因此可知,線程當前執行的方法所對應的棧幀必定位于Java棧的頂部。講到這里,我們就應該會明白為什么在使用遞歸方法的時候容易導致棧內存溢出的現象了。
即每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程。
經常有人把Java內存區分為堆內存(Heap)和棧內存(Stack),這種分法比較粗糙,Java內存區域的劃分實際上遠比這復雜。這里所指的“棧”就是虛擬機棧,或者說是虛擬機棧中的局部變量表部分。
下圖表示了一個虛擬機棧的模型:
?(1). 局部變量表,顧名思義,就是用來存儲方法中的局部變量(包括在方法中聲明的非靜態變量以及函數形參)。對于基本數據類型的變量,則直接存儲它的值,對于引用類型的變量,則存的是指向對象的引用。局部變量表的大小在編譯期就可以確定其大小了,因此在程序執行期間局部變量表的大小是不會改變的。
八個基本數據類型:byte、short、int、long、float、double、char、boolean
?(2). 操作數棧,在數據結構中,棧最典型的一個應用就是用來對表達式求值。想想一個線程執行方法的過程中,實際上就是不斷執行語句的過程,而歸根到底就是進行計算的過程。因此可以這么說,程序中的所有計算過程都是在借助于操作數棧來完成的。
當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種字節碼指令向操作數棧中寫入和提取內容,也就是入棧出棧操作。例如,在做算術運算的時候是通過操作數棧來進行的,又或者在調用其他方法的時候是通過操作數棧來進行參數傳遞的。
舉個例子,整數加法的字節碼指令iadd在運行的時候要求操作數棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會將這兩個int值出棧并相加,然后將相加的結果入棧。
?(3). 指向運行時常量池的引用,因為在方法執行的過程中有可能需要用到類中的常量,所以必須要有一個引用指向運行時常量。
?(4). 方法返回地址,當一個方法執行完畢之后,要返回之前調用它的地方,因此在棧幀中必須保存一個方法返回地址。
3. 本地方法棧
本地方法棧與Java棧的作用和原理非常相似。區別只不過是Java棧是為執行Java方法服務的,而本地方法棧則是為執行本地方法(Native Method)服務的。在JVM規范中,并沒有對本地方法棧中方法使用的語言、使用方式以及數據結構作強制規定,虛擬機可以自由實現它。在HotSopt虛擬機中直接就把本地方法棧和Java棧合二為一。
與虛擬機棧一樣,本地方法棧區域也會拋出StackOverflowError和OutOfMemoryError異常。
關于Native方法:
在某些情況下,若Java必須要調用其它語言的代碼,如c或C++,就需要使用Native。
Native方法稱為本地方法。在Java中以關鍵字“Native”聲明的程序,不提供函數體。其實現使用C/C++語言在另外的文件中編寫,編寫的規則遵循Java本地接口的規范(簡稱JNI)。簡而言就是Java中聲明的可調用使用C/C++實現的方法。
即Native方法就是不由Java實現的方法,一般這些方法都是很底層,跟平臺結合緊密,或者使用Java實現性能很差。
4. Java堆
對大多數應用來說,Java堆是Java虛擬機所管理的內存最大的一塊。Java堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這里分配內存。
Java堆用來存儲對象本身的以及數組(當然,數組引用是存放在Java棧中的)。
Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱為“GC堆”。
根據Java虛擬機規范的規定,Java堆可以處于物理上不連續的內存空間,只要邏輯上是連續的即可,就像我們的磁盤空間一樣。在實現時,即可以實現成固定大小,也可以是可擴展的,不過當前主流的虛擬機都是按照可擴展來實現的。
如果在堆中沒有內存完成實例分配,并且堆也無法再擴展時,將會拋出OutOfMemoryError異常。
5. 方法區
方法區與Java堆一樣,是各個線程共享的內存區域。在方法區中,存儲了每個類的信息(包括類的名稱、方法信息、字段信息)、靜態變量、常量、即時編譯器編譯后的代碼等。
在Class文件中除了類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池,用來存儲編譯期間生成的字面量和符號引用。
在方法區中有一個非常重要的部分就是運行時常量池,它是每一個類或接口的常量池的運行時表示形式,在類和接口被加載到JVM后,對應的運行時常量池就被創建出來。例:存放final修飾的常量
在JVM規范中,沒有強制要求方法區必須實現垃圾回收。很多人習慣將方法區稱為“永久代”,是因為HotSpot虛擬機以永久代來實現方法區,從而JVM的垃圾收集器可以像管理堆區一樣管理這部分區域,從而不需要專門為這部分設計垃圾回收機制。不過自從JDK7之后,Hotspot虛擬機便將字符串常量池從永久代移除了。
例:看下面這段程序,然后畫出內存分析圖
運行過程分析:
1、首先運行程序,Demo1_car.java就會變為Demo1_car.class,將Demo1_car.class加入方法區,檢查是否字節碼文件常量池中是否有常量值,如果有,那么就加入運行時常量池
2、遇到main方法,創建一個棧幀,入虛擬機棧,然后開始運行main方法中的程序
3、Car c1 = new Car(); 第一次遇到Car這個類,所以將Car.java編譯為Car.class文件,然后加入方法區,跟第一步一樣。然后new Car()。就在堆中創建一塊區域,用于存放創建出來的實例對象,地址為0X001.其中有兩個屬性值 color和num。默認值是null 和 0
4、然后通過c1這個引用變量去設置color和num的值,
5、調用run方法,然后會創建一個棧幀,用來裝run方法中的局部變量的,入虛擬機棧,run方法中就打印了一句話,結束之后,該棧幀出虛擬機棧。又只剩下main方法這個棧幀了
6、接著又創建了一個Car對象,所以又在堆中開辟了一塊內存,之后就是跟之前的步驟一樣了。
參考:
JVM的內存區域劃分?
java虛擬機內存區域的劃分以及作用詳解
二、HotSpot虛擬機對象
(一)、對象的創建
在語言層面上,創建對象通常僅僅是一個new關鍵字而已,在虛擬機中,對象的創建過程如圖:
1. 類加載檢查 根據new指令的參數在常量池中定位對應類的符號引用
首先檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執行相應的類的加載過程。
2. 為新生對象分配內存(分配內存空間的方法:指針碰撞、空閑列表。并發情況下保證線程安全:CAS、TLAB)
對象所需內存的大小在類加載完成后便完全確定,為對象分配空間的任務等同于把一塊確定大小的內存從Java堆中劃分出來。
?(1).根據Java堆中是否規整劃分為兩種內存的分配方式:(Java堆是否規整由所采用的垃圾收集器是否帶有壓縮整理功能決定)
? ? a. 指針碰撞(Bump the pointer): Java堆中的內存是絕對規整的,所有用過的內存都放在一邊,空閑的內存放在另一邊,中間放著一個指針作為分界點的指示器,分配內存也就是把指針向空閑空間那邊移動一段與內存大小相等的距離。
? ? b. 空閑列表(Free List): Java堆中的內存不是規整的,已使用的內存和空閑的內存相互交錯,就沒有辦法簡單的進行指針碰撞了。虛擬機必須維護一張列表,記錄哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,并更新列表上的記錄。
?(2).分配內存時解決并發問題的兩種方案:(可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況)
? ?a. 分配內存空間的動作進行同步處理---實際上虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性;
? ?b. 把內存分配的動作按照線程劃分為在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱為本地線程分配緩沖(TLAB)。哪個線程要分配內存,就在哪個線程的TLAB上分配。只有TLAB用完并分配新的TLAB時,才需要同步鎖定。
3. 將分配的內存空間都初始化為零值
虛擬機將分配到的內存空間都初始化為零值(不包括對象頭),如果使用了TLAB,這一工作過程也可以提前至TLAB分配時進行。這一操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。
4. 對對象進行必要的設置,如設置對象頭
虛擬機對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的GC分代年齡等信息。這些信息存放在對象的對象頭之中。
5. 執行<init>方法,把對象進行初始化
在上面的工作都完成之后,從虛擬機的角度看,一個新的對象已經產生了。但是從Java程序的角度看,對象的創建才剛剛開始,<init>方法還沒有執行,所有的字段都還是零。所以,一般來說,執行new指令之后會接著執行<init>方法,把對象按照程序員的意愿進行初始化,這樣一個真正可用的對象才算產生出來。
(二)、對象的內存布局
對象在內存中存儲的布局可以分為3塊區域:對象頭、實例數據、對齊填充
對象頭:
第一部分存儲對象自身的運行時數據,如哈希碼、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等
第二部分存儲類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。如果對象是一個java數組,那么對象頭中還必須有一塊用于記錄數組長度的數據。
實例數據:
實例數據部分是對象真正存儲的有效信息,也是程序代碼中所定義的各種類型的字段內容。無論是從父類中繼承下來的,還是在子類中定義的,都需要記錄下來。
對齊填充:
對齊填充并不是必然存在的,也沒有特定的含義,僅僅起著占位符的作用。由于HotSpot虛擬機的自動內存管理系統要求對象的起始地址必須是8字節的整數倍,也就是對象的大小必須是8字節的整數倍。而對象頭部分正好是8字節的倍數(1倍或者2倍),因此,當對象實例數據部分沒有對齊的時候,就需要通過對齊填充來補全。
(三)、對象的訪問定位
對象的訪問方式取決于虛擬機實現,目前主流的訪問方式有使用句柄和直接指針兩種。
1. 使用句柄
如果使用句柄的話,那么Java堆中將會劃分出一塊內存來作為句柄池,引用中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。
2. 直接指針
如果使用直接指針訪問,那么Java堆對象的布局中就必須考慮如何放置訪問類型數據的相關信息,而引用中存儲的直接就是對象地址。
使用句柄訪問的優勢在于引用中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而引用本身不需要修改。
使用直接指針訪問的優勢在于速度更快,節省了一次指針定位的時間開銷。由于對象的訪問在Java中非常頻繁,因此這類開銷積少成多后也是非常可觀的執行成本。
關于對象類型數據,因為的存儲在方法區中,因此我的理解就是被虛擬機加載的類信息
主要來自:
《深入理解java虛擬機 JVM高級特性與最佳實踐》