引言
對(duì)于Java程序員來說,在虛擬機(jī)自動(dòng)內(nèi)存管理機(jī)制的幫助下,不再需要為每一個(gè)new操作去寫配對(duì)的delete/free代碼,不容易出現(xiàn)內(nèi)存泄漏和內(nèi)存溢出問題,由虛擬機(jī)管理內(nèi)存。但正是因?yàn)镴ava程序員把內(nèi)存控制權(quán)利交給了Java虛擬機(jī),一旦出現(xiàn)內(nèi)存泄漏和溢出方面,如果不了解虛擬機(jī)是如何使用內(nèi)存的,那么排查錯(cuò)誤將會(huì)非常困難。
2.2 運(yùn)行時(shí)數(shù)據(jù)區(qū)域
2.2.1 程序計(jì)數(shù)器
程序計(jì)數(shù)器是一塊較小的內(nèi)存空間。它可以看作是當(dāng)前線程所執(zhí)行的字節(jié)碼的行號(hào)指示器。字節(jié)碼解釋器工作時(shí)就是通過改變這個(gè)計(jì)數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令。
為什么需要引入程序計(jì)數(shù)器?
由于Java虛擬機(jī)的多線程是通過線程輪流切換并分配處理器執(zhí)行時(shí)間的方式來實(shí)現(xiàn)的,在任何一個(gè)時(shí)刻,一個(gè)處理器都只會(huì)執(zhí)行一條線程中的指令。因此,為了線程切換后能恢復(fù)到正確的執(zhí)行位置,每條線程都需要一個(gè)獨(dú)立的程序計(jì)數(shù)器,各條線程之間計(jì)數(shù)器不受影響,獨(dú)立存儲(chǔ),我們稱這類內(nèi)存區(qū)域?yàn)椤熬€程私有”的內(nèi)存。
簡(jiǎn)而言之,就是存取當(dāng)前線程執(zhí)行的位置,以便下次執(zhí)行時(shí)正確恢復(fù)。注意:
如果線程執(zhí)行的是一個(gè)Java方法,那么計(jì)數(shù)器記錄的是正在執(zhí)行的虛擬機(jī)字節(jié)碼指令的地址;如果正在執(zhí)行的是native方法,那么這個(gè)計(jì)數(shù)器值為空。此內(nèi)存區(qū)域是唯一一個(gè)在Java虛擬機(jī)規(guī)范中沒有規(guī)定任何OutOfMemoryError情況的區(qū)域。
2.2.2 Java虛擬機(jī)棧
Java虛擬機(jī)棧是線程私有的,其生命周期與線程相同。虛擬機(jī)棧描述的是Java方法執(zhí)行的內(nèi)存模型:每個(gè)方法在執(zhí)行時(shí)都會(huì)創(chuàng)建一個(gè)棧幀用于存儲(chǔ)局部變量表,操作數(shù)棧,動(dòng)態(tài)鏈接,方法出口等。每個(gè)方法從調(diào)用直至執(zhí)行完成的過程,就對(duì)應(yīng)了一個(gè)棧幀在虛擬機(jī)中入棧到出棧的過程。
局部變量表
概念:存放了編譯器可知的各種基本數(shù)據(jù)類型(boolean,byte,char,short,int,float,long,double),對(duì)象引用和returnAddress類型(指向一條字節(jié)碼指令的地址);
注意:
a. 只有64位長(zhǎng)度的long和double類型的數(shù)據(jù)會(huì)占用2個(gè)局部變量空間,其余的類型只會(huì)占用1個(gè)局部變量空間。
b. 局部變量表所需的內(nèi)存空間在編譯器完成分配,在方法運(yùn)行期間不會(huì)改變局部變量表的大小。
2.2.3 本地方法棧
本地方法棧和虛擬機(jī)棧的作用是非常相似的,它們之間的區(qū)別在于虛擬機(jī)棧為虛擬機(jī)執(zhí)行Java方法,而本地方法棧為虛擬機(jī)執(zhí)行native方法。
2.2.4 Java堆
- Java堆是被所有線程共享的一塊內(nèi)存區(qū)域,在虛擬機(jī)啟動(dòng)時(shí)創(chuàng)建。該內(nèi)存區(qū)域的唯一目的是存放對(duì)象實(shí)例。
- 幾乎所有的對(duì)象實(shí)例以及數(shù)組都要在堆上分配。
- Java堆是垃圾收集器的主要區(qū)域。
- Java堆可以處于物理上不連續(xù)的內(nèi)存空間中,只要邏輯上是連續(xù)的即可。
- 如果在堆中沒有足夠的內(nèi)存空間分配,并且堆也無法再擴(kuò)展時(shí),那么將會(huì)拋出OutOfMemoryError異常。
2.2.5 方法區(qū)
方法區(qū)是各個(gè)線程共享的內(nèi)存區(qū)域。它用于存儲(chǔ)已被虛擬機(jī)加載的類信息,常量,靜態(tài)變量,即時(shí)編譯器編譯后的代碼等數(shù)據(jù)。
2.2.6 運(yùn)行時(shí)常量池
運(yùn)行時(shí)常量池為方法區(qū)的一部分,用于存放編譯期生成的各種字面量和符號(hào)引用。
2.3 HotSpot虛擬機(jī)對(duì)象探秘
2.3.1 對(duì)象的創(chuàng)建
在語言層面上,創(chuàng)建對(duì)象(例如克隆,反序列化)通常僅僅是一個(gè)new關(guān)鍵字而已,但在虛擬機(jī)中,對(duì)象的創(chuàng)建卻是一個(gè)復(fù)雜的過程。
對(duì)象創(chuàng)建的具體過程:
a. 類加載檢查:虛擬機(jī)遇到一條new指令時(shí),首先會(huì)去檢查這個(gè)指令的參數(shù)是否能在常量池中定位到一個(gè)類的符號(hào)引用,并且檢查這個(gè)符號(hào)引用代表的類是否已被加載,解析和初始化過。如果沒有,則必須先執(zhí)行相應(yīng)的操作。
b. 為對(duì)象分配內(nèi)存:為對(duì)象分配空間的任務(wù)等同于把一塊確定大小的內(nèi)存從Java堆中劃分出來。
劃分方法有兩種:”指針碰撞“和”空閑列表“
(1) 指針碰撞:假設(shè)Java堆中內(nèi)存是絕對(duì)規(guī)整的,所有可用的內(nèi)存放在一邊,空閑的內(nèi)存放在一邊,中間放著一個(gè)指針作為分界點(diǎn)的指示器,那么分配內(nèi)存就僅僅是把那個(gè)指針向空閑空間那邊挪動(dòng)一段與對(duì)象大小相等的距離。
(2) 空閑列表:假設(shè)Java堆內(nèi)存并不是規(guī)整的,已使用的內(nèi)存空間和空閑的內(nèi)存空間是相互交錯(cuò)的,那么就無法通過指針碰撞來劃分了,虛擬機(jī)就必須維護(hù)一個(gè)表,記錄哪些內(nèi)存塊是可用的,在分配內(nèi)存時(shí)找到一塊足夠大的空間劃分給對(duì)象,并更新表中的記錄。關(guān)于在并發(fā)情況下,使用指針碰撞分配空間存在的問題:
我們知道“指針碰撞”方法是通過修改指針的位置來劃分空間,那么在并發(fā)的情況下,存在線程不安全性。可能出現(xiàn)正在給對(duì)象A分配空間,對(duì)象B又同時(shí)使用原來的指針位置來分配空間。
解決方法:
a. 對(duì)分配內(nèi)存空間的動(dòng)作進(jìn)行同步處理。
b. 把內(nèi)存分配的動(dòng)作按照線程劃分在不同的空間之中進(jìn)行。即每個(gè)線程在Java堆中預(yù)先分配一小塊內(nèi)存,稱為本地線程分配緩沖(簡(jiǎn)稱:TLAB)。哪個(gè)線程要分配內(nèi)存,就在哪個(gè)線程的TLAB上分配,只有TLAB用完并分配新的TLAB時(shí),才需要同步鎖定。c. 將分配的內(nèi)存空間都初始化為零值:如果使用的是TLAB,這一工作過程可以提前至TLAB分配時(shí)進(jìn)行。這一步操作保證了對(duì)象的實(shí)例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數(shù)據(jù)類型所對(duì)應(yīng)的零值。
d. 對(duì)對(duì)象進(jìn)行必要的設(shè)置:例如這個(gè)對(duì)象是哪個(gè)類的實(shí)例,如何找到類的元數(shù)據(jù)類型信息等,將這些信息存放在對(duì)象頭之中。
2.3.2 對(duì)象的內(nèi)存布局
對(duì)象在內(nèi)存中存儲(chǔ)的布局分為:對(duì)象頭,實(shí)例數(shù)據(jù)和對(duì)齊填充。
對(duì)象頭包含兩部分信息:一部分是用于存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù),另一部分是類型指針,即對(duì)象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)通過這個(gè)指針來確定這個(gè)對(duì)象是哪個(gè)類的實(shí)例。如果對(duì)象是一個(gè)Java數(shù)組,那么在對(duì)象頭中還必須有一塊用于記錄數(shù)組長(zhǎng)度的數(shù)據(jù)。
實(shí)例數(shù)據(jù):對(duì)象真正存儲(chǔ)的有效信息,也是在程序代碼中所定義的各種類型的字段內(nèi)容。
對(duì)齊填充:并不是必然存在的,僅僅起的是占位符的作用。因?yàn)?strong>HotSpot VM的自動(dòng)內(nèi)存管理要求對(duì)象起始地址必須是8字節(jié)的整數(shù)倍。對(duì)象頭部分正好是8字節(jié)的倍數(shù),但當(dāng)對(duì)象實(shí)例部分沒有對(duì)齊時(shí),就需要通過對(duì)齊填充來補(bǔ)全。
2.3.3 對(duì)象的訪問定位
建立對(duì)象是為了使用對(duì)象,我們的Java程序需要通過棧上的reference數(shù)據(jù)來操作堆上的具體對(duì)象。由于reference類型在Java虛擬機(jī)規(guī)范中只規(guī)定了一個(gè)指向?qū)ο蟮囊茫]有定義這個(gè)引用通過何種方式去定位,訪問堆中對(duì)象的具體位置,所以對(duì)象的訪問方式也是取決于虛擬機(jī)實(shí)現(xiàn)而定的。目前主要的訪問方式為使用句柄和直接指針。
- 使用句柄:Java堆劃分一塊內(nèi)存來作為句柄池,reference中存儲(chǔ)的是對(duì)象的句柄地址,而句柄包好了對(duì)象實(shí)例與類型數(shù)據(jù)各自的具體地址信息。
- 使用直接指針:如果使用指針訪問,那么Java堆對(duì)象的布局中就必須考慮如何放置訪問類型數(shù)據(jù)的相關(guān)信息,而reference中存儲(chǔ)的直接就是對(duì)象地址。
- 兩者優(yōu)缺點(diǎn):
使用句柄的最大好處是reference中存儲(chǔ)的是穩(wěn)定的句柄地址,在對(duì)象被移動(dòng)時(shí)只會(huì)改變句柄中的實(shí)例數(shù)據(jù)指針,而reference本身不會(huì)移動(dòng)。
使用直接指針訪問的最大好處是速度更快,它節(jié)省了一次指針定位的時(shí)間開銷,由于對(duì)象的訪問在Java中是非常頻繁的,因此這類開銷積少成多也是一項(xiàng)非常可觀的執(zhí)行成本。
2.4 實(shí)戰(zhàn):OutOfMemoryError異常
在Java虛擬機(jī)中,除了程序計(jì)數(shù)器外,虛擬機(jī)內(nèi)存的其他幾個(gè)運(yùn)行時(shí)區(qū)域都有發(fā)生OutOfMemoryError異常的可能。
2.4.1 Java堆溢出
Java堆用于存儲(chǔ)對(duì)象,只要不斷地創(chuàng)建對(duì)象,并且保證GC Roots到對(duì)象之間有可達(dá)路徑來避免垃圾回收機(jī)制清除這些對(duì)象。
我們來看一個(gè)例子:
public class HeapOOM {
static class OOMObject{
}
public static void main(String[] args){
List<OOMObject> list = new ArrayList<>();
//不斷創(chuàng)建對(duì)象,并添加到list中,以保留引用,防止被垃圾回收器回收
while (true){
list.add(new OOMObject());
}
}
}
在這里設(shè)置Java堆的大小為20MB,不可擴(kuò)展(將堆的最小值-Xms參數(shù)與最大值-Xmx參數(shù)設(shè)置為一樣即可避免堆自動(dòng)擴(kuò)展),通過參數(shù)-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機(jī)在出現(xiàn)內(nèi)存溢出異常時(shí)Dump出當(dāng)前的內(nèi)存堆轉(zhuǎn)儲(chǔ)快照以便事后進(jìn)行分析。
輸出結(jié)果如下:
2.4.2 虛擬機(jī)棧和本地方法棧溢出
關(guān)于虛擬機(jī)棧和本地方法棧,在Java虛擬機(jī)規(guī)范中描述了兩種異常:
- 如果線程請(qǐng)求的棧深度大于虛擬機(jī)所允許的最大深度,將拋出StackOverflowError異常。
- 如果虛擬機(jī)在擴(kuò)展棧時(shí)無法申請(qǐng)到足夠的內(nèi)存空間,則拋出OutOfMemoryError異常。
我們通過設(shè)置-Xss:128k來設(shè)定棧容量為128k,然后通過不斷調(diào)用方法來增加棧的深度。
public class JavaVMStackSOF {
private int stackLength = 1;
//不斷調(diào)用方法,從而增加棧深度
public void stackLength(){
stackLength ++;
stackLength();
}
public static void main(String[] args){
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLength();
} catch (Throwable e){
System.out.println("stack length: " + oom.stackLength);
throw e;
}
}
}
輸出結(jié)果:
2.4.3 方法區(qū)和運(yùn)行時(shí)常量池溢出
由于常量池分配在永久代內(nèi),我們可以通過-XX:PermSize 和 -XX:MaxPermSize限制方法區(qū)大小,從而間接限制其中常量池的容量。
String.intern()是一個(gè)Native方法,它的作用是:如果字符串常量池中已經(jīng)包含了一個(gè)等于此String對(duì)象的字符串,則返回代表池中這個(gè)字符串的String對(duì)象;否則,將此String對(duì)象包含的字符添加到常量池中,并且返回此String對(duì)象的引用。
/**
* VM Args: -XX:PermSize=20M -XX:MaxPermSize=20M
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args){
//使用List保持著常量池引用,避免Full GC回收常量池行為
List<String> list = new ArrayList<>();
int i = 0;
while (true){
list.add(String.valueOf(i ++).intern());
}
}
}