??內存是非常重要的系統資源,是硬盤和CPU的中間倉庫及橋梁,承載著操作系統和應用程序的實時運行。JVM內存布局規定了Java在運行過程中內存申請、分配、管理的策略,保證了JVM的高效穩定運行。不同的JVM對于內存的劃分方式和管理機制存在著部分差異。結合JVM虛擬機規范,來學習一 下經典的JVM內存布局。
??話不多說,先來一圖(截圖來至阿里的<碼出高效:java開發手冊>)。上圖就是jdk8之后的jvm經典布局,接下來主要詳細分析各個分區的功能及作用。
Stacks(虛擬機棧)
??棧( Stack )是一個先進后出的數據結構,就像子彈的彈夾,最后壓入的子彈先發射,壓在底部的子彈最后發射,撞針只能訪問位于頂部的那一顆子彈。相對于基于寄存器的運行環境來說,JVM 是基于棧結構的運行環境。棧結構移植性更好,可控性更強。JVM中的虛擬機棧是描述Java方法執行的內存區域,它是線程私有的(每一條Java虛擬機線程都有自己私有的Java虛擬機棧,這個棧與線程同時創建)。
??棧中的元素用于支持虛擬機進行方法調用,每個方法從開始調用到執行完成的過程,就是棧幀從入棧到出棧的過程。在活動線程中,只有位于棧頂的幀才是有效的,稱為當前棧幀。正在執行的方法稱為當前方法,棧幀是方法運行的基本結構。在執行引擎運行時,所有指令都只能針對當前棧幀進行操作。StackOverflowError表示請求的棧溢出,導致內存耗盡,通常出現在遞歸方法中。JVM能夠橫掃千軍, 虛擬機棧就是它的心腹大將,當前方法的棧幀,都是正在戰斗的戰場,其中的操作棧是參與戰斗的士兵。
- 局部表量表
??每個棧幀(見上圖)內部都包含一組稱為局部變量表的變量列表。棧幀中局部變量表的長度由編譯期決定,并且存儲于類或接口的二進制表示之中,即通過方法的code屬性保存及提供給棧幀使用。
??一個局部量可以保存一個類型為boolean、byte、char、short、int、float、reference或returnAddress的數據。兩個局部變量可以保存一個類型為long或double的數據。
??局部變量使用索引來進行定位訪問。首個局部變量的索引值為0。局部變量的索引值是個整數,它大于等于0,且小于局部變量表的長度。
??long和double類型的數據占用兩個連續的局部變量,這兩種類型的數據值采用兩個局部變量中較小的索引值來定位。例如,將一個double類型的值存儲在索引值為n的局部變量中,實際上的意思是索引值為n和n+1的兩個局部變量都用來存儲這個值。然而,索引值為n+1的局部變量是無法直接讀取的,但是可能會被寫人。不過,如果進行了這種操作,那將會導致局部變量n的內容失效。前面提及的局部變量索引值n并不要求一定是偶數,Java虛擬機也不要求double和long類型數據采用64位對齊的方式連續地存儲在局部變量表中。虛擬機實現者可以自由地選擇適當的方式,通過兩個局部變量來存儲一個double或long類型的值。
??Java虛擬機使用局部變量表來完成方法調用時的參數傳遞。當調用類方法時,它的參數將會依次傳遞到局部變量表中從0開始的連續位置上。當調用實例方法時,第0個局部變量一定用來存儲該實例方法所在對象的引用(即Java語言中的this關鍵字)。后續的其他參數將會傳遞至局部變量表中從1開始的連續位置上。
- 操作數棧
??每個棧幀(見上圖)內部都包含一個稱為操作數棧的后進先出( Last-In-First-Out,LIFO)棧。棧幀中操作數棧的最大深度由編譯期決定,并且通過方法的code屬性保存及提供給棧幀使用。
??棧幀在剛剛創建時,操作數棧是空的。Java虛擬機提供一些字節碼指令來從局部變量表或者對象實例的字段中復制常量或變量值到操作數棧中(如:iload,aload,getfield等),也提供了一些指令用于從操作數棧取走數據(將一個數值從操作數棧存儲到局部變量表。如:istore,astore等)、操作數據(iadd,isub等)以及把操作結果重新人棧。在調用方法時,操作數棧也用來準備調用方法的參數以及接收方法返回結果。
??例如,iadd字節碼指令的作用是將兩個int類型的數值相加,它要求在執行之前操作數棧的棧頂已經存在兩個由前面的其他指令所放人的int類型數值。在執行iadd指令時,兩個int類型數值從操作棧中出棧,相加求和,然后將求和結果重新入棧。在操作數棧中,一項運算常由多個子運算( subcomputation) 嵌套進行,一個子運算過程的結果可以被其他外圍運算所使用。
操作數棧與局部變量表之間傳遞參數示例:
static class VmStacks {
/******************方法下方字節碼是通過javap -v class文件獲得*********************/
/**
* 該方法主要演示jvm對方法調用過程
*/
public int directInvoke() {
return addI(1, 2);
}
public int directInvoke();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1 // 最大棧深度為3,局部變量表個數為1
0: aload_0 // 將this從局部變量表壓入操作數棧,因為該方法為實例方法,故局部變量表slot為0的就是實例本身(this)
1: iconst_1 // 將常量值1壓入操作數棧
2: iconst_2 // 將常量值2壓入操作數棧
3: invokevirtual #2 // Method addI:(II)I 調用addI(int x, int y)方法
6: ireturn 返回int類型的值
LineNumberTable:
line 86: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lmayfly/core/util/CodeByteTest$VmStacks;
1. 因為該方法為對象實例方法,故方法調用的第一步是將當前實例的自身引用this壓人操作數棧中
(如果是類方法,即static方法則沒有該步驟)。傳遞給方法的int類型參數值1和2隨后人棧。
2. 當調用addI方法(即invokevirtual #2 指令)時,Java虛擬機會創建一個新的棧幀,
傳遞給addI方法的參數值會成為新棧幀中對應局部變量的初始值。即由directInvoke
方法推人操作數棧的this和兩個傳遞給addI方法的參數1與2,會作為addI方法棧幀的
第0、1、2個局部變量。
3. 當addI方法執行結束、方法返回時,int類型的返回值被壓入方法調用者的棧幀的操作數棧,
即directInvoke方法的操作數棧中。而這個返回值又會立即返回給directInvoke的調用者。
directInvoke方法的返回過程由directInvoke方法中的ireturn指令實現。由addI方法所返回的
int類型值會壓入當前操作數棧的棧頂,而ireturn指令則會把當前操作數棧的棧頂值(此處就是addI的返回值)
壓人directInvoke方法的調用者的操作數棧。然后跳轉至調用directInvoke的那個方法的下一條指令繼續執行,
并將調用者的棧幀重新設為當前棧幀。Java虛擬機對不同數據類型(包括聲明為void,即沒有返回值的方法)
的返回值提供了不同的方法返回指令,各種不同返回值類型的方法都使用這一組返回指令來返回。
/**********************************************************************************/
public int addI(int x, int y) {
int z = y++; // 純粹為了演示 i++與++i之間字節碼的區別,無其他意義
return ++x + y;
}
public int addI(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=3
0: iload_2
1: iinc 2, 1
4: istore_3
5: iinc 1, 1
8: iload_1
9: iload_2
10: iadd
11: ireturn
LineNumberTable:
line 71: 0
line 72: 5
LocalVariableTable:
Start Length Slot Name Signature
0 12 0 this Lmayfly/core/util/CodeByteTest$VmStacks;
0 12 1 x I
0 12 2 y I
5 7 3 z I
1. 方法調用者(如上個方法directInvoke)將操作棧上的變量出棧并傳遞給該方法棧幀的局部變量表中的的
0,1,2位置的slot上。
2. 在0~4索引(指令操作碼在數組中的下標,該數組以字節形式來存儲當前方法的java虛擬機代碼,
也可以認為是相對于方法起始處的字節偏移量)上的三條字節碼表示的是int z = y++ 該行代碼;
iload_ 2 從局部變量表的第2號抽屜里取出一個數,壓入棧頂,下一步直接在抽屜(局部變量表中的slot)
里實現+1的操作,而這個操作對棧頂元素的值沒有影響。所以istore_ 3只是把棧頂元素賦值給z,即z == y而不是y+1后的值;
3. 索引5~8表示++x, iinc 1, 1先在第1號抽屜里執行+1操作,然后通過iload_ 1 把第1號抽屜里的數壓入棧頂,
所以操作數棧中存入的是+1之后的值。
4. 接著將2號抽屜的數值(即y)壓入棧頂,隨后執行iadd指令,將棧頂的兩個元素彈出棧相加后,
并將相加后的結果重新壓入棧頂并return給調用者。
/**********************************************************************************/
}
- 動態鏈接
??每個棧幀內部都包含一個指向當前方法所在類型的運行時常量池的引用,以便對當前方法的代碼實現動態鏈接。在class文件里面,一個方法若要調用其他方法,或者訪問成員變量,則需要通過符號引用(symbolic reference) 來表示,動態鏈接的作用就是將這些以符號引用所表示的方法轉換為對實際方法的直接引用。類加載的過程中將要解析尚未被解析的符號引用,并且將對變量的訪問轉化為變量在程度運行時,位于存儲結構中的正確偏移量。由于對其他類中的方法和變量進行了晚期綁定(latebinding),所以即便那些類發生變化,也不會影響調用它們的方法。
- 方法返回地址
??方法執行時有兩種退出情況:第一,正常退出,即正常執行到任何方法的返回字節碼指令,如RETURN、IRETURN、ARETURN等;第二,異常退出。無論何種退出情況,都將返回至方法當前被調用的位置。方法退出的過程相當于彈出當前棧幀,退出可能有三種方式:
- 返回值壓入上層調用棧幀。
- 異常信息拋給能夠處理的棧幀。
- PC計數器指向方法調用后的下一條指令。
PC程序計數器
??Java虛擬機可以支持多條線程同時執行,每一條Java虛擬機線程都有自的pc ( program counter) 寄存器。在任意時刻,一條Java 虛擬機線程只會執行一個方法的代碼,這個正在被線程執行的方法稱為該線程的當前方法。如果這個方法不是native的,那pc寄存器就保存Java虛擬機正在執行的字節碼指令的地址,如果該方法是native的,那pc寄存器的值是undefined。pc寄存器的容量至少應當能保存一個returnAddress類型的數據或者一個與平臺相關的本地指針的值。
Heap(堆區)
??Heap是OOM故障最主要的發源地,它存儲著幾乎所有的實例對象,堆由垃圾收集器自動回收,堆區由各子線程共享使用。通常情況下,它占用的空間是所有內存區域中最大的,但如果無節制地創建大量對象,也容易消耗完所有的空間。堆的內存空間既可以固定大小,也可以在運行時動態地調整,通過如下參數設定初始值和最大值,比如-Xms256M -Xmx1024M, 其中-X表示它是JVM運行參數,ms是memory start的簡稱,mx是memory max的簡稱,分別代表最小堆容量和最大堆容量。但是在通常情況下,服務器在運行過程中,堆空間不斷地擴容與回縮,勢必形成不必要的系統壓力,所以在線上生產環境中,JVM的Xms和Xmx設置成一樣大小,避免在GC后調整堆大小時帶來的額外壓力。
??Java內存運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧三個區域隨線程而生,隨線程而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知的(盡管在運行期會由JIT編譯器進行一些優化,但在基于概念模型的討論中,大體上可以認為是編譯期可知的),因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內不需要過多考慮回收的問題,因為方法結束或線程結束時,內存自然就跟隨著回收了。因此堆是垃圾收集的最主要內存區域。
??如最開始那個布局圖,為何要將堆空間分為新生代、老年代,以及新生代又為何要劃分為一個Eden區和兩個Survivor(幸存者)區,結合GC講解可更好地理解為何要醬紫劃分。
?? GC(Garbage Collection 垃圾收集)
判斷對象已死
??堆中幾乎存放著Java世界中所有的對象實例,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些對象有哪些還“存活”著,哪些已經“死去”(即不可能再被任何途徑使用的對象)。
- 引用計數算法
??給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1 ;當引用失效時,計數器值就減1 ;任何時刻計數器都為0的對象就是不可能再被使用的。引用計數算法(Reference Counting)的實現簡單,判定效率也很高,但是,Java語言中沒有選用引用計數算法來管理內存,其中最主要的原因是它很難解決對象之間的相互循環引用的問題。舉個簡單的例代碼如下:
public class ReferenceCountingGC {
public Object instance;
public ReferenceCountingGC(String name){}
}
public static void testGC(){
ReferenceCountingGC a = new ReferenceCountingGC("objA");
ReferenceCountingGC b = new ReferenceCountingGC("objB");
a.instance = b;
b.instance = a;
a = null;
b = null;
}
??我們可以看到,最后這2個對象已經不可能再被訪問了,但由于他們相互引用著對方,導致它們的引用計數永遠都不會為0,通過引用計數算法,也就永遠無法通知GC收集器回收它們。
- 根搜索算法
??在主流的商用程序語言中(Java和C#,甚至包括前面提到的古老的Lisp),都是使用根搜索算法(GC Roots Tracing)判定對象是否存活的。這個算法的基本思路就是通過一系列的名為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說就是從GCRoots到這個對象不可達)時,則證明此對象是不可用的。如下圖所示,對象object 5、object 6、object 7雖然互相有關聯,但是它們到GCRoots是不可達的,所以它們將會被判定為是可回收的對象。
??通過根搜索算法,成功解決了引用計數所無法解決的問題“循環依賴”,只要你無法與 GC Root 建立直接或間接的連接,系統就會判定你為可回收對象。那這樣就引申出了另一個問題,哪些屬于 GC Root。在Java語言里,可作為GCRoots的對象包括下面幾種:
- 虛擬機棧(棧幀中的本地變量表)中的引用的對象。
- 方法區中的類靜態屬性引用的對象。
- 方法區中的常量引用的對象。
- 本地方法棧中JNI (即一般說的Native方法)的引用的對象。
垃圾收集算法
- 標記-清除算法
??最基礎的收集算法是“標記-清除”(Mark-Sweep) 算法,如它的名字一樣,算法分為"標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成后統一回收掉所有被標記的對象,之所以說它是最基礎的收集算法,是因為后續的收集算法都是基于這種思路并對其缺點進行改進而得到的。它的主要缺點有兩個:一個是效率問題,標記和清除過程的效率都不高,另外一個是空間問題,標記清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致,當程在以后的運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集 動作。標記-清除算法的執行過程如下圖所示。
- 復制算法
??為了解決效率問題,一種稱為“復制”(Copying)的收集算法出現了,它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。這樣使得每次都是對其中的一塊進行內存回收,內存分配時也就不用考慮內存碎片等復雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小為原來的一半,未免太高了一點。復制算法的執行過程如下圖所示。
??現在的商業虛擬機都采用這種收集算法來回收新生代,IBM的專門研究表明,新生代中的對象98%是朝生夕死的,所以并不需要按照1: 1的比例來劃分內存空間,而是將內存分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中的一塊Survivor。當回收時,將Eden和Survivor中還存活著的對象一次性地拷貝到另外一塊Survivor空間上,最后清理掉Eden和剛才用過的Survivor的空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,也就是每次新生代中可用內存空間為整個新生代容量的90% ( 80%+10%),只有10%的內存是會被“浪費”的。當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多于10%的對象存活,當Survivor空間不夠用時,就需要依賴其他內存(這里指老年代)。
- 標記-整理算法
??復制收集算法在對象存活率較高時就要執行較多的復制操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。根據老年代的特點,有人提出了另外一種“標記-整理”(Mark-Compact)算法,標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存,“標記-整理”算法的示意圖如下圖所示。
- 分代收集算法
??當前商業虛擬機的垃圾收集都采用“分代收集”(Generational Collection)算法,這種算法并沒有什么新的思想,只是根據對象存活周期的不同將內存劃分為幾塊。一般般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用復制算法,只需要付出少量存活對象的復制成本就可以完成收集。而老年代中因為對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記一清理”或者“標記一整理” 算法來進行回收。
堆內存及GC總結
??堆分成兩大塊:新生代和老年代。對象產生之初在新生代,步入暮年時進入老年代,但是老年代也接納在新生代無法容納的超大對象。新生代= 1個Eden區+ 2個Survivor區。絕大部分對象在Eden區生成,當Eden區裝填滿的時候,會觸發YoungGarbage Collection, 即YGC(也叫MinorGc)。垃圾回收的時候,在Eden區實現清除策略,沒有被引用的對象則直接回收。依然存活的對象會被移送到Survivor區,這個區真是名副其實的存在。Survivor 區分為S0和S1兩塊內存空間,送到哪塊空間呢?每次YGC的時候,它們將存活的對象復制到未使用的那塊空間,然后將當前正在使用的空間完全清除,交換兩塊空間的使用狀態。如果YGC要移送的對象大于Survivor區容量的上限,則直接移交給老年代。假如一些沒有進取心的對象以為可以一直在新生代的Survivor區交換來交換去,那就錯了。每個對象都有一個計數器,每次YGC都會加1。
-XX:MaxTenuringThreshold參數能配置計數器的值到達某個閾值的時候,對象從新生代晉升至老年代。如果該參數配置為1,那么從新生代的Eden區直接移至老年代。默認值是15,可以在Survivor區交換14次之后,晉升至老年代。對象分配及晉升流程圖如下圖所示。
Metaspace (元空間)
??早在JDK8版本中,元空間的前身Perm區(永久代)已經被淘汰。在JDK7及之前的版本中,只有Hotspot才有Perm區,譯為永久代,它在啟動時固定大小,很難進行調優,并且FGC時會移動類元信息。在某些場景下,如果動態加載類過多,容易產生Perm區的0OM。比如某個實際Web工程中,因為功能點比較多,在運行過程中,要不斷動態加載很多的類,經常出現致命錯誤:" java.lang.OutOfMemoryError: PermGenspace"為了解決該問題,需要設定運行參數-XX:MaxPermSize= 1280m,如果部署到新機器上,往往會因為JVM參數沒有修改導致故障再現。不熟悉此應用的人排查問題時往往苦不堪言,除此之外,永久代在垃圾回收過程中還存在諸多問題。所以,JDK8使用元空間替換永久代。在JDK8及以上版本中,設定MaxPermSize參數,JVM在啟動時并不會報錯,但是會提示: Java HotSpot 64Bit Server VM warning:ignoring option MaxPermSize- =2560m; support was removed in 8.0。區別于永久代,元空間在本地內存中分配。在JDK8里,Perm 區中的所有內容中字符串常量移至堆內存,其他內容包括類元信息、字段、靜態屬性、方法、常量等都移動至元空間內,比如下圖中的Object類元信息、靜態屬性System.out、整型常量1000000等。圖中顯示在常量池中的String,其實際對象是被保存在堆內存中的。
注:以上大部分內容(除代碼示例)摘抄整理自《深入理解java虛擬機》、《Java虛擬機規范 JavaSE 8版本》、《碼出高效:Java開發手冊》