求你了,別再說Java對(duì)象都是在堆內(nèi)存上分配空間的了!
△Hollis, 一個(gè)對(duì)Coding有著獨(dú)特追求的人△
這是Hollis的第?256篇原創(chuàng)分享作者 l Hollis來源 l Hollis(ID:hollischuang)Java作為一種面向?qū)ο蟮?,跨平臺(tái)語言,其對(duì)象、內(nèi)存等一直是比較難的知識(shí)點(diǎn),所以,即使是一個(gè)Java的初學(xué)者,也一定或多或少的對(duì)JVM有一些了解。可以說,關(guān)于JVM的相關(guān)知識(shí),基本是每個(gè)Java開發(fā)者必學(xué)的知識(shí)點(diǎn),也是面試的時(shí)候必考的知識(shí)點(diǎn)。
在JVM的內(nèi)存結(jié)構(gòu)中,比較常見的兩個(gè)區(qū)域就是堆內(nèi)存和棧內(nèi)存(如無特指,本文提到的棧均指的是虛擬機(jī)棧),關(guān)于堆和棧的區(qū)別,很多開發(fā)者也是如數(shù)家珍,有很多書籍,或者網(wǎng)上的文章大概都是這樣介紹的:
1、堆是線程共享的內(nèi)存區(qū)域,棧是線程獨(dú)享的內(nèi)存區(qū)域。
2、堆中主要存放對(duì)象實(shí)例,棧中主要存放各種基本數(shù)據(jù)類型、對(duì)象的引用。
但是,作者可以很負(fù)責(zé)任的告訴大家,以上兩個(gè)結(jié)論均不是完全正確的。
在我之前的文章《Java堆內(nèi)存是線程共享的!面試官:你確定嗎?》中,介紹過了關(guān)于堆內(nèi)存并不是完完全全的線程共享有關(guān)的知識(shí)點(diǎn),本文就第二個(gè)話題來探討一下。
對(duì)象內(nèi)存分配
在《Java虛擬機(jī)規(guī)范》中,關(guān)于堆有這樣的描述:
在Java虛擬機(jī)中,堆是可供各個(gè)線程共享的運(yùn)行時(shí)內(nèi)存區(qū)域,也是供所有類實(shí)例和數(shù)組對(duì)象分配內(nèi)存的區(qū)域。
在《Java堆內(nèi)存是線程共享的!面試官:你確定嗎?》文章中,我們也介紹過,一個(gè)Java對(duì)象在堆上分配的時(shí)候,主要是在Eden區(qū)上,如果啟動(dòng)了TLAB的話會(huì)優(yōu)先在TLAB上分配,少數(shù)情況下也可能會(huì)直接分配在老年代中,分配規(guī)則并不是百分之百固定的,這取決于當(dāng)前使用的是哪一種垃圾收集器,還有虛擬機(jī)中與內(nèi)存有關(guān)的參數(shù)的設(shè)置。
但是一般情況下是遵循以下原則的:
- 對(duì)象優(yōu)先在Eden區(qū)分配
- 優(yōu)先在Eden分配,如果Eden沒有足夠空間,會(huì)觸發(fā)一次Monitor GC
- 大對(duì)象直接進(jìn)入老年代
- 需要大量連續(xù)內(nèi)存空間的Java對(duì)象,當(dāng)對(duì)象需要的內(nèi)存大于-XX:PretenureSizeThreshold參數(shù)的值時(shí),對(duì)象會(huì)直接在老年代分配內(nèi)存。
JIT 技術(shù)
我們大家都知道,通過 javac 將可以將Java程序源代碼編譯,轉(zhuǎn)換成 java 字節(jié)碼,JVM 通過解釋字節(jié)碼將其翻譯成對(duì)應(yīng)的機(jī)器指令,逐條讀入,逐條解釋翻譯。這就是傳統(tǒng)的JVM的解釋器(Interpreter)的功能。很顯然,Java編譯器經(jīng)過解釋執(zhí)行,其執(zhí)行速度必然會(huì)比直接執(zhí)行可執(zhí)行的二進(jìn)制字節(jié)碼慢很多。為了解決這種效率問題,引入了 JIT(Just In Time ,即時(shí)編譯) 技術(shù)。有了JIT技術(shù)之后,Java程序還是通過解釋器進(jìn)行解釋執(zhí)行,當(dāng)JVM發(fā)現(xiàn)某個(gè)方法或代碼塊運(yùn)行特別頻繁的時(shí)候,就會(huì)認(rèn)為這是“熱點(diǎn)代碼”(Hot Spot Code)。然后JIT會(huì)把部分“熱點(diǎn)代碼”翻譯成本地機(jī)器相關(guān)的機(jī)器碼,并進(jìn)行優(yōu)化,然后再把翻譯后的機(jī)器碼緩存起來,以備下次使用。熱點(diǎn)檢測(cè)
上面我們說過,要想觸發(fā)JIT,首先需要識(shí)別出熱點(diǎn)代碼。目前主要的熱點(diǎn)代碼識(shí)別方式是熱點(diǎn)探測(cè)(Hot Spot Detection),HotSpot虛擬機(jī)中采用的主要是基于計(jì)數(shù)器的熱點(diǎn)探測(cè)基于計(jì)數(shù)器的熱點(diǎn)探測(cè)(Counter Based Hot Spot Detection)。采用這種方法的虛擬機(jī)會(huì)為每個(gè)方法,甚至是代碼塊建立計(jì)數(shù)器,統(tǒng)計(jì)方法的執(zhí)行次數(shù),某個(gè)方法超過閥值就認(rèn)為是熱點(diǎn)方法,觸發(fā)JIT編譯。
編譯優(yōu)化
JIT在做了熱點(diǎn)檢測(cè)識(shí)別出熱點(diǎn)代碼后,除了會(huì)對(duì)其字節(jié)碼進(jìn)行緩存,還會(huì)對(duì)代碼做各種優(yōu)化。這些優(yōu)化中,比較重要的幾個(gè)有:逃逸分析、 鎖消除、 鎖膨脹、 方法內(nèi)聯(lián)、 空值檢查消除、 類型檢測(cè)消除、 公共子表達(dá)式消除等。而這些優(yōu)化中的逃逸分析就和本文要介紹的內(nèi)容有關(guān)了。逃逸分析
逃逸分析(Escape Analysis)是目前Java虛擬機(jī)中比較前沿的優(yōu)化技術(shù)。這是一種可以有效減少Java 程序中同步負(fù)載和內(nèi)存堆分配壓力的跨函數(shù)全局?jǐn)?shù)據(jù)流分析算法。通過逃逸分析,Hotspot編譯器能夠分析出一個(gè)新的對(duì)象的引用的使用范圍從而決定是否要將這個(gè)對(duì)象分配到堆上。逃逸分析的基本行為就是分析對(duì)象動(dòng)態(tài)作用域:當(dāng)一個(gè)對(duì)象在方法中被定義后,它可能被外部方法所引用,例如作為調(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是一個(gè)方法內(nèi)部變量,上述代碼中并沒有將他直接返回,這樣這個(gè)StringBuffer又不會(huì)被其他方法所改變,這樣它的作用域就只是在方法內(nèi)部。我們就可以說這個(gè)變量并沒有逃逸到方法外部。有了逃逸分析,我們可以判斷出一個(gè)方法中的變量是否有可能被其他線程所訪問或者改變,那么基于這個(gè)特性,JIT就可以做一些優(yōu)化:- 同步省略
- 標(biāo)量替換
- 棧上分配
標(biāo)量替換、棧上分配
我們說,JIT經(jīng)過逃逸分析之后,如果發(fā)現(xiàn)某個(gè)對(duì)象并沒有逃逸到方法體之外的話,就可能對(duì)其進(jìn)行優(yōu)化,而這一優(yōu)化最大的結(jié)果就是可能改變Java對(duì)象都是在堆上分配內(nèi)存的這一原則。對(duì)象要分配在堆上其實(shí)有很多原因,但是有一點(diǎn)比較關(guān)鍵的和本文有關(guān)的,那就是因?yàn)槎褍?nèi)存在訪問上是線程共享的,這樣一個(gè)線程創(chuàng)建出來的對(duì)象,其他線程也能訪問到。那么,試想下,如果我們?cè)谀骋粋€(gè)方法體內(nèi)部創(chuàng)建了一個(gè)對(duì)象,并且對(duì)象并沒有逃逸到方法外的話,那還有必要一定要把對(duì)象分配到堆上嗎?其實(shí)就沒有必要了,因?yàn)檫@個(gè)對(duì)象并不會(huì)被其他線程所訪問到,生命周期也只是在一個(gè)方法內(nèi)部,也就不用大費(fèi)周折的在堆上分配內(nèi)存,也減少了內(nèi)存回收的必要。那么,有了逃逸分析之后,發(fā)現(xiàn)一個(gè)對(duì)象并沒有逃逸到放法外的話,通過什么辦法可以進(jìn)行優(yōu)化,減少對(duì)象在堆上分配可能呢?這就是棧上分配。在HotSopt中,棧上分配并沒有正在的進(jìn)行實(shí)現(xiàn),而是通過標(biāo)量替換來實(shí)現(xiàn)的。所以我們重點(diǎn)介紹下,什么是標(biāo)量替換,如何通過標(biāo)量替換實(shí)現(xiàn)棧上分配。標(biāo)量替換
標(biāo)量(Scalar)是指一個(gè)無法再分解成更小的數(shù)據(jù)的數(shù)據(jù)。Java中的原始數(shù)據(jù)類型就是標(biāo)量。相對(duì)的,那些還可以分解的數(shù)據(jù)叫做聚合量(Aggregate),Java中的對(duì)象就是聚合量,因?yàn)樗梢苑纸獬善渌酆狭亢蜆?biāo)量。在JIT階段,如果經(jīng)過逃逸分析,發(fā)現(xiàn)一個(gè)對(duì)象不會(huì)被外界訪問的話,那么經(jīng)過JIT優(yōu)化,就會(huì)把這個(gè)對(duì)象拆解成若干個(gè)其中包含的若干個(gè)成員變量來代替。這個(gè)過程就是標(biāo)量替換。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對(duì)象并沒有逃逸出alloc方法,并且point對(duì)象是可以拆解成標(biāo)量的。那么,JIT就會(huì)不會(huì)直接創(chuàng)建Point對(duì)象,而是直接使用兩個(gè)標(biāo)量int x ,int y來替代Point對(duì)象。private?static?void?alloc()?{
???int?x?=?1;
???int?y?=?2;
???System.out.println("point.x="+x+";?point.y="+y);
}
可以看到,Point這個(gè)聚合量經(jīng)過逃逸分析后,發(fā)現(xiàn)他并沒有逃逸,就被替換成兩個(gè)聚合量了。通過標(biāo)量替換,原本的一個(gè)對(duì)象,被替換成了多個(gè)成員變量。而原本需要在堆上分配的內(nèi)存,也就不再需要了,完全可以在本地方法棧中完成對(duì)成員變量的內(nèi)存分配。實(shí)驗(yàn)證明
Talk Is Cheap, Show Me The CodeNo Data, No BB;接下來我們就來通過一個(gè)實(shí)驗(yàn),來看一下逃逸分析是否可以生效,生效后是否真的會(huì)發(fā)生棧上分配,而棧上分配又有什么好處呢?我們來看以下代碼:public?static?void?main(String[]?args)?{
????long?a1?=?System.currentTimeMillis();
????for?(int?i?=?0;?i?1000000;?i++)?{
????????alloc();
????}
????//?查看執(zhí)行時(shí)間
????long?a2?=?System.currentTimeMillis();
????System.out.println("cost?"?+?(a2?-?a1)?+?"?ms");
????//?為了方便查看堆內(nèi)存中對(duì)象個(gè)數(shù),線程sleep
????try?{
????????Thread.sleep(100000);
????}?catch?(InterruptedException?e1)?{
????????e1.printStackTrace();
????}
}
private?static?void?alloc()?{
????User?user?=?new?User();
}
static?class?User?{
}
其實(shí)代碼內(nèi)容很簡(jiǎn)單,就是使用for循環(huán),在代碼中創(chuàng)建100萬個(gè)User對(duì)象。我們?cè)赼lloc方法中定義了User對(duì)象,但是并沒有在方法外部引用他。也就是說,這個(gè)對(duì)象并不會(huì)逃逸到alloc外部。經(jīng)過JIT的逃逸分析之后,就可以對(duì)其內(nèi)存分配進(jìn)行優(yōu)化。我們指定以下JVM參數(shù)并運(yùn)行:-Xmx4G?-Xms4G?-XX:-DoEscapeAnalysis?-XX:+PrintGCDetails?-XX:+HeapDumpOnOutOfMemoryError?
其中-XX:-DoEscapeAnalysis表示關(guān)閉逃逸分析。在程序打印出 cost XX ms 后,代碼運(yùn)行結(jié)束之前,我們使用jmap命令,來查看下當(dāng)前堆內(nèi)存中有多少個(gè)User對(duì)象:???~?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萬個(gè)StackAllocTest在關(guān)閉逃逸分析的情況下(-XX:-DoEscapeAnalysis),雖然在alloc方法中創(chuàng)建的User對(duì)象并沒有逃逸到方法外部,但是還是被分配在堆內(nèi)存中。也就說,如果沒有JIT編譯器優(yōu)化,沒有逃逸分析技術(shù),正常情況下就應(yīng)該是這樣的。即所有對(duì)象都分配到堆內(nèi)存中。接下來,我們開啟逃逸分析,再來執(zhí)行下以上代碼。-Xmx4G?-Xms4G?-XX:+DoEscapeAnalysis?-XX:+PrintGCDetails?-XX:+HeapDumpOnOutOfMemoryError?
在程序打印出 cost XX ms 后,代碼運(yùn)行結(jié)束之前,我們使用jmap命令,來查看下當(dāng)前堆內(nèi)存中有多少個(gè)User對(duì)象:???~?jmap?-histo?2859?num?????#instances?????????#bytes??class?name從以上打印結(jié)果中可以發(fā)現(xiàn),開啟了逃逸分析之后(?XX:+DoEscapeAnalysis),在堆內(nèi)存中只有8萬多個(gè)StackAllocTestUser對(duì)象。也就是說在經(jīng)過JIT優(yōu)化之后,堆內(nèi)存中分配的對(duì)象數(shù)量,從100萬降到了8萬。除了以上通過jmap驗(yàn)證對(duì)象個(gè)數(shù)的方法以外,讀者還可以嘗試將堆內(nèi)存調(diào)小,然后執(zhí)行以上代碼,根據(jù)GC的次數(shù)來分析,也能發(fā)現(xiàn),開啟了逃逸分析之后,在運(yùn)行期間,GC次數(shù)會(huì)明顯減少。正是因?yàn)楹芏喽焉戏峙浔粌?yōu)化成了棧上分配,所以GC次數(shù)有了明顯的減少。
----------------------------------------------
???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
逃逸分析并不成熟
前面的例子中,開啟逃逸分析之后,對(duì)象數(shù)目從100萬變成了8萬,但是并不是0,說明JIT優(yōu)化并不會(huì)完完全全的所有情況都進(jìn)行優(yōu)化。關(guān)于逃逸分析的論文在1999年就已經(jīng)發(fā)表了,但直到JDK 1.6才有實(shí)現(xiàn),而且這項(xiàng)技術(shù)到如今也并不是十分成熟的。其根本原因就是無法保證逃逸分析的性能消耗一定能高于他的消耗。雖然經(jīng)過逃逸分析可以做標(biāo)量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進(jìn)行一系列復(fù)雜的分析的,這其實(shí)也是一個(gè)相對(duì)耗時(shí)的過程。一個(gè)極端的例子,就是經(jīng)過逃逸分析之后,發(fā)現(xiàn)沒有一個(gè)對(duì)象是不逃逸的。那這個(gè)逃逸分析的過程就白白浪費(fèi)掉了。雖然這項(xiàng)技術(shù)并不十分成熟,但是他也是即時(shí)編譯器優(yōu)化技術(shù)中一個(gè)十分重要的手段。總結(jié)
正常情況下,對(duì)象是要在堆上進(jìn)行內(nèi)存分配的,但是隨著編譯器優(yōu)化技術(shù)的成熟,雖然虛擬機(jī)規(guī)范是這樣要求的,但是具體實(shí)現(xiàn)上還是有些差別的。如HotSpot虛擬機(jī)引入了JIT優(yōu)化之后,會(huì)對(duì)對(duì)象進(jìn)行逃逸分析,如果發(fā)現(xiàn)某一個(gè)對(duì)象并沒有逃逸到方法外部,那么就可能通過標(biāo)量替換來實(shí)現(xiàn)棧上分配,而避免堆上分配內(nèi)存。所以,對(duì)象一定在堆上分配內(nèi)存,這是不對(duì)的。最后,我們留一個(gè)思考題,我們之前討論過了TLAB,今天又介紹了棧上分配。大家覺得這兩個(gè)優(yōu)化有什么相同點(diǎn)和不同點(diǎn)嗎?關(guān)于作者:Hollis,一個(gè)對(duì)Coding有著獨(dú)特追求的人,現(xiàn)任阿里巴巴技術(shù)專家,個(gè)人技術(shù)博主,技術(shù)文章全網(wǎng)閱讀量數(shù)千萬,《程序員的三門課》聯(lián)合作者。
推薦閱讀:
喜歡我可以給我設(shè)為星標(biāo)哦

好文章,我?在看?

