Java作為一種面向?qū)ο蟮目酱埽缙脚_語言替梨,其對象、內(nèi)存等一直是比較難的知識點装黑,所以,即使是一個Java的初學(xué)者弓熏,也一定或多或少的對JVM有一些了解恋谭。可以說挽鞠,關(guān)于JVM的相關(guān)知識疚颊,基本是每個Java開發(fā)者必學(xué)的知識點狈孔,也是面試的時候必考的知識點。
在JVM的內(nèi)存結(jié)構(gòu)中材义,比較常見的兩個區(qū)域就是堆內(nèi)存和棧內(nèi)存(如無特指均抽,本文提到的棧均指的是虛擬機棧),關(guān)于堆和棧的區(qū)別其掂,很多開發(fā)者也是如數(shù)家珍油挥,有很多書籍,或者網(wǎng)上的文章大概都是這樣介紹的:
1款熬、堆是線程共享的內(nèi)存區(qū)域深寥,棧是線程獨享的內(nèi)存區(qū)域。
2贤牛、堆中主要存放對象實例惋鹅,棧中主要存放各種基本數(shù)據(jù)類型、對象的引用殉簸。
但是闰集,作者可以很負責任的告訴大家,以上兩個結(jié)論均不是完全正確的般卑。
對象內(nèi)存分配
在《Java虛擬機規(guī)范》中武鲁,關(guān)于堆有這樣的描述:
在Java虛擬機中,堆是可供各個線程共享的運行時內(nèi)存區(qū)域椭微,也是供所有類實例和數(shù)組對象分配內(nèi)存的區(qū)域洞坑。
一個Java對象在堆上分配的時候,主要是在Eden區(qū)上蝇率,如果啟動了TLAB的話會優(yōu)先在TLAB上分配迟杂,少數(shù)情況下也可能會直接分配在老年代中,分配規(guī)則并不是百分之百固定的本慕,這取決于當前使用的是哪一種垃圾收集器排拷,還有虛擬機中與內(nèi)存有關(guān)的參數(shù)的設(shè)置。
但是一般情況下是遵循以下原則的:
- 對象優(yōu)先在Eden區(qū)分配
優(yōu)先在Eden分配锅尘,如果Eden沒有足夠空間监氢,會觸發(fā)一次Monitor GC - 大對象直接進入老年代
需要大量連續(xù)內(nèi)存空間的Java對象,當對象需要的內(nèi)存大于-XX:PretenureSizeThreshold參數(shù)的值時藤违,對象會直接在老年代分配內(nèi)存浪腐。
但是,雖然虛擬機規(guī)范中是有著這樣的要求顿乒,但是各個虛擬機廠商在實現(xiàn)虛擬機的時候议街,可能會針對對象的內(nèi)存分配做一些優(yōu)化。這其中最典型的就是HotSpot虛擬機中的JIT技術(shù)的成熟璧榄,使得對象在堆上分配內(nèi)存并不是一定的特漩。
其實在《深入理解Java虛擬機》中吧雹,作者也提出過類似的觀點,因為JIT技術(shù)的成熟使得"對象在堆上分配內(nèi)存"就不是那么絕對的了涂身。但是書中并沒有展開介紹到底什么是JIT雄卷,也沒有介紹JIT優(yōu)化到底做了什么。那么接下來我們就來深入了解一下:
JIT 技術(shù)
我們大家都知道蛤售,通過 javac 將可以將Java程序源代碼編譯丁鹉,轉(zhuǎn)換成 java 字節(jié)碼,JVM 通過解釋字節(jié)碼將其翻譯成對應(yīng)的機器指令悍抑,逐條讀入鳄炉,逐條解釋翻譯。這就是傳統(tǒng)的JVM的解釋器(Interpreter)的功能搜骡。很顯然拂盯,Java編譯器經(jīng)過解釋執(zhí)行,其執(zhí)行速度必然會比直接執(zhí)行可執(zhí)行的二進制字節(jié)碼慢很多记靡。為了解決這種效率問題谈竿,引入了 JIT(Just In Time ,即時編譯) 技術(shù)摸吠。
有了JIT技術(shù)之后空凸,Java程序還是通過解釋器進行解釋執(zhí)行,當JVM發(fā)現(xiàn)某個方法或代碼塊運行特別頻繁的時候寸痢,就會認為這是“熱點代碼”(Hot Spot Code)呀洲。然后JIT會把部分“熱點代碼”翻譯成本地機器相關(guān)的機器碼,并進行優(yōu)化啼止,然后再把翻譯后的機器碼緩存起來道逗,以備下次使用。
熱點檢測
上面我們說過献烦,要想觸發(fā)JIT滓窍,首先需要識別出熱點代碼。目前主要的熱點代碼識別方式是熱點探測(Hot Spot Detection)巩那,HotSpot虛擬機中采用的主要是基于計數(shù)器的熱點探測
基于計數(shù)器的熱點探測(Counter Based Hot Spot Detection)吏夯。采用這種方法的虛擬機會為每個方法,甚至是代碼塊建立計數(shù)器即横,統(tǒng)計方法的執(zhí)行次數(shù)噪生,某個方法超過閥值就認為是熱點方法,觸發(fā)JIT編譯东囚。
編譯優(yōu)化
JIT在做了熱點檢測識別出熱點代碼后杠园,除了會對其字節(jié)碼進行緩存,還會對代碼做各種優(yōu)化。這些優(yōu)化中抛蚁,比較重要的幾個有:逃逸分析、 鎖消除惕橙、 鎖膨脹瞧甩、 方法內(nèi)聯(lián)、 空值檢查消除弥鹦、 類型檢測消除肚逸、 公共子表達式消除等。
而這些優(yōu)化中的逃逸分析就和本文要介紹的內(nèi)容有關(guān)了彬坏。
逃逸分析
逃逸分析(Escape Analysis)是目前Java虛擬機中比較前沿的優(yōu)化技術(shù)朦促。這是一種可以有效減少Java 程序中同步負載和內(nèi)存堆分配壓力的跨函數(shù)全局數(shù)據(jù)流分析算法。通過逃逸分析栓始,Hotspot編譯器能夠分析出一個新的對象的引用的使用范圍從而決定是否要將這個對象分配到堆上务冕。
逃逸分析的基本行為就是分析對象動態(tài)作用域:當一個對象在方法中被定義后,它可能被外部方法所引用幻赚,例如作為調(diào)用參數(shù)傳遞到其他地方中禀忆,稱為方法逃逸。
例如:
public static String craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
sb是一個方法內(nèi)部變量落恼,上述代碼中并沒有將他直接返回箩退,這樣這個StringBuffer有不會被其他方法所改變,這樣它的作用域就只是在方法內(nèi)部佳谦。我們就可以說這個變量并沒有逃逸到方法外部戴涝。
有了逃逸分析,我們可以判斷出一個方法中的變量是否有可能被其他線程所訪問或者改變钻蔑,那么基于這個特性啥刻,JIT就可以做一些優(yōu)化:
- 同步省略
- 標量替換
- 棧上分配
關(guān)于同步省略,大家可以參考我之前的《深入理解多線程(五)—— Java虛擬機的鎖優(yōu)化技術(shù)》中關(guān)于鎖消除技術(shù)的介紹矢棚。本文主要來分析下標量替換和棧上分配郑什。
標量替換、棧上分配
我們說蒲肋,JIT經(jīng)過逃逸分析之后蘑拯,如果發(fā)現(xiàn)某個對象并沒有逃逸到方法體之外的話,就可能對其進行優(yōu)化兜粘,而這一優(yōu)化最大的結(jié)果就是可能改變Java對象都是在堆上分配內(nèi)存的這一原則申窘。
對象要分配在堆上其實有很多原因,但是有一點比較關(guān)鍵的和本文有關(guān)的孔轴,那就是因為堆內(nèi)存在訪問上是線程共享的剃法,這樣一個線程創(chuàng)建出來的對象,其他線程也能訪問到路鹰。
那么贷洲,試想下收厨,如果我們在某一個方法體內(nèi)部創(chuàng)建了一個對象,并且對象并沒有逃逸到方法外的話优构,那還有必要一定要把對象分配到堆上嗎诵叁?
其實就沒有必要了,因為這個對象并不會被其他線程所訪問到钦椭,生命周期也只是在一個方法內(nèi)部拧额,也就不用大費周折的在堆上分配內(nèi)存,也減少了內(nèi)存回收的必要彪腔。
那么侥锦,有了逃逸分析之后,發(fā)現(xiàn)一個對象并沒有逃逸到放法外的話德挣,通過什么辦法可以進行優(yōu)化恭垦,減少對象在堆上分配可能呢?
這就是棧上分配盲厌。在HotSopt中署照,棧上分配并沒有正在的進行實現(xiàn),而是通過標量替換來實現(xiàn)的吗浩。
所以我們重點介紹下建芙,什么是標量替換,如何通過標量替換實現(xiàn)棧上分配懂扼。
標量替換
標量(Scalar)是指一個無法再分解成更小的數(shù)據(jù)的數(shù)據(jù)禁荸。Java中的原始數(shù)據(jù)類型就是標量。相對的阀湿,那些還可以分解的數(shù)據(jù)叫做聚合量(Aggregate)赶熟,Java中的對象就是聚合量,因為他可以分解成其他聚合量和標量陷嘴。
在JIT階段映砖,如果經(jīng)過逃逸分析,發(fā)現(xiàn)一個對象不會被外界訪問的話灾挨,那么經(jīng)過JIT優(yōu)化邑退,就會把這個對象拆解成若干個其中包含的若干個成員變量來代替。這個過程就是標量替換劳澄。
public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}
以上代碼中地技,point對象并沒有逃逸出alloc方法,并且point對象是可以拆解成標量的秒拔。那么莫矗,JIT就會不會直接創(chuàng)建Point對象,而是直接使用兩個標量int x ,int y來替代Point對象作谚。
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
可以看到三娩,Point這個聚合量經(jīng)過逃逸分析后,發(fā)現(xiàn)他并沒有逃逸食磕,就被替換成兩個聚合量了尽棕。
通過標量替換,原本的一個對象彬伦,被替換成了多個成員變量。而原本需要在堆上分配的內(nèi)存伊诵,也就不再需要了单绑,完全可以在本地方法棧中完成對成員變量的內(nèi)存分配。
實驗證明
Talk Is Cheap, Show Me The Code
No Data, No BB曹宴;
接下來我們就來通過一個實驗搂橙,來看一下逃逸分析是否可以生效,生效后是否真的會發(fā)生棧上分配笛坦,而棧上分配又有什么好處呢区转?
我們來看以下代碼:
public static void main(String[] args) {
long a1 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
alloc();
}
// 查看執(zhí)行時間
long a2 = System.currentTimeMillis();
System.out.println("cost " + (a2 - a1) + " ms");
// 為了方便查看堆內(nèi)存中對象個數(shù),線程sleep
try {
Thread.sleep(100000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();
}
static class User {
}
其實代碼內(nèi)容很簡單版扩,就是使用for循環(huán)废离,在代碼中創(chuàng)建100萬個User對象。
我們在alloc方法中定義了User對象礁芦,但是并沒有在方法外部引用他蜻韭。也就是說,這個對象并不會逃逸到alloc外部柿扣。經(jīng)過JIT的逃逸分析之后肖方,就可以對其內(nèi)存分配進行優(yōu)化。
我們指定以下JVM參數(shù)并運行:
-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
其中-XX:-DoEscapeAnalysis表示關(guān)閉逃逸分析未状。
在程序打印出 cost XX ms 后俯画,代碼運行結(jié)束之前,我們使用jmap命令司草,來查看下當前堆內(nèi)存中有多少個User對象:
? ~ jmap -histo 2809
num #instances #bytes class name
----------------------------------------------
1: 524 87282184 [I
2: 1000000 16000000 StackAllocTest$User
3: 6806 2093136 [B
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
從上面的jmap執(zhí)行結(jié)果中我們可以看到艰垂,堆中共創(chuàng)建了100萬個StackAllocTest$User實例。
在關(guān)閉逃避分析的情況下(-XX:-DoEscapeAnalysis)翻伺,雖然在alloc方法中創(chuàng)建的User對象并沒有逃逸到方法外部材泄,但是還是被分配在堆內(nèi)存中。也就說吨岭,如果沒有JIT編譯器優(yōu)化拉宗,沒有逃逸分析技術(shù),正常情況下就應(yīng)該是這樣的。即所有對象都分配到堆內(nèi)存中旦事。
接下來魁巩,我們開啟逃逸分析,再來執(zhí)行下以上代碼姐浮。
-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
在程序打印出 cost XX ms 后谷遂,代碼運行結(jié)束之前,我們使用jmap命令卖鲤,來查看下當前堆內(nèi)存中有多少個User對象:
? ~ jmap -histo 2859
num #instances #bytes class name
----------------------------------------------
1: 524 101944280 [I
2: 6806 2093136 [B
3: 83619 1337904 StackAllocTest$User
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
從以上打印結(jié)果中可以發(fā)現(xiàn)肾扰,開啟了逃逸分析之后(-XX:+DoEscapeAnalysis),在堆內(nèi)存中只有8萬多StackAllocTest$User對象蛋逾。也就是說在經(jīng)過JIT優(yōu)化之后集晚,堆內(nèi)存中分配的對象數(shù)量,從100萬降到了8萬区匣。
除了以上通過jmap驗證對象個數(shù)的方法以外偷拔,讀者還可以嘗試將堆內(nèi)存調(diào)小,然后執(zhí)行以上代碼亏钩,根據(jù)GC的次數(shù)來分析莲绰,也能發(fā)現(xiàn),開啟了逃逸分析之后姑丑,在運行期間蛤签,GC次數(shù)會明顯減少。正是因為很多堆上分配被優(yōu)化成了棧上分配彻坛,所以GC次數(shù)有了明顯的減少顷啼。
逃逸分析并不成熟
前面的例子中,開啟逃逸分析之后昌屉,對象數(shù)目從100萬變成了8萬钙蒙,但是并不是0,說明JIT優(yōu)化并不會完完全全的所有情況都進行優(yōu)化间驮。
關(guān)于逃逸分析的論文在1999年就已經(jīng)發(fā)表了躬厌,但直到JDK 1.6才有實現(xiàn),而且這項技術(shù)到如今也并不是十分成熟的竞帽。
其根本原因就是無法保證逃逸分析的性能消耗一定能高于他的消耗扛施。雖然經(jīng)過逃逸分析可以做標量替換、棧上分配屹篓、和鎖消除疙渣。但是逃逸分析自身也是需要進行一系列復(fù)雜的分析的,這其實也是一個相對耗時的過程堆巧。
一個極端的例子妄荔,就是經(jīng)過逃逸分析之后泼菌,發(fā)現(xiàn)沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了啦租。
雖然這項技術(shù)并不十分成熟哗伯,但是他也是即時編譯器優(yōu)化技術(shù)中一個十分重要的手段。
總結(jié)
正常情況下篷角,對象是要在堆上進行內(nèi)存分配的焊刹,但是隨著編譯器優(yōu)化技術(shù)的成熟,雖然虛擬機規(guī)范是這樣要求的恳蹲,但是具體實現(xiàn)上還是有些差別的虐块。
如HotSpot虛擬機引入了JIT優(yōu)化之后,會對對象進行逃逸分析嘉蕾,如果發(fā)現(xiàn)某一個對象并沒有逃逸到方法外部非凌,那么就可能通過標量替換來實現(xiàn)棧上分配,而避免堆上分配內(nèi)存荆针。
所以,對象一定在堆上分配內(nèi)存颁糟,這是不對的航背。