求你了,別再說Java物件都是在堆記憶體上分配空間的了!
阿新 • • 發佈:2020-03-16
Java作為一種面向物件的,跨平臺語言,其物件、記憶體等一直是比較難的知識點,所以,即使是一個Java的初學者,也一定或多或少的對JVM有一些瞭解。可以說,關於JVM的相關知識,基本是每個Java開發者必學的知識點,也是面試的時候必考的知識點。
在JVM的記憶體結構中,比較常見的兩個區域就是堆記憶體和棧記憶體(如無特指,本文提到的棧均指的是虛擬機器棧),關於堆和棧的區別,很多開發者也是如數家珍,有很多書籍,或者網上的文章大概都是這樣介紹的:
> 1、堆是執行緒共享的記憶體區域,棧是執行緒獨享的記憶體區域。
>
> 2、堆中主要存放物件例項,棧中主要存放各種基本資料型別、物件的引用。
但是,作者可以很負責任的告訴大家,以上兩個結論均不是完全正確的。
在我之前的文章《Java堆記憶體是執行緒共享的!面試官:你確定嗎?》中,介紹過了關於堆記憶體並不是完完全全的執行緒共享有關的知識點,本文就第二個話題來探討一下。
### 物件記憶體分配
在《Java虛擬機器規範》中,關於堆有這樣的描述:
> 在Java虛擬機器中,堆是可供各個執行緒共享的執行時記憶體區域,也是供所有類例項和陣列物件分配記憶體的區域。
在《Java堆記憶體是執行緒共享的!面試官:你確定嗎?》文章中,我們也介紹過,一個Java物件在堆上分配的時候,主要是在Eden區上,如果啟動了TLAB的話會優先在TLAB上分配,少數情況下也可能會直接分配在老年代中,分配規則並不是百分之百固定的,這取決於當前使用的是哪一種垃圾收集器,還有虛擬機器中與記憶體有關的引數的設定。
但是一般情況下是遵循以下原則的:
* 物件優先在Eden區分配
* 優先在Eden分配,如果Eden沒有足夠空間,會觸發一次Monitor GC
* 大物件直接進入老年代
* 需要大量連續記憶體空間的Java物件,當物件需要的記憶體大於-XX:PretenureSizeThreshold引數的值時,物件會直接在老年代分配記憶體。
**但是,雖然虛擬機器規範中是有著這樣的要求,但是各個虛擬機器廠商在實現虛擬機器的時候,可能會針對物件的記憶體分配做一些優化。這其中最典型的就是HotSpot虛擬機器中的JIT技術的成熟,使得物件在堆上分配記憶體並不是一定的。**
其實在《深入理解Java虛擬機器》中,作者也提出過類似的觀點,因為JIT技術的成熟使得"物件在堆上分配記憶體"就不是那麼絕對的了。但是書中並沒有展開介紹到底什麼是JIT,也沒有介紹JIT優化到底做了什麼。那麼接下來我們就來深入瞭解一下:
### JIT 技術
我們大家都知道,通過 javac 將可以將Java程式原始碼編譯,轉換成 java 位元組碼,JVM 通過解釋位元組碼將其翻譯成對應的機器指令,逐條讀入,逐條解釋翻譯。這就是傳統的JVM的直譯器(Interpreter)的功能。很顯然,**Java編譯器經過解釋執行,其執行速度必然會比直接執行可執行的二進位制位元組碼慢很多。為了解決這種效率問題,引入了 JIT(Just In Time ,即時編譯) 技術。**
有了JIT技術之後,Java程式還是通過直譯器進行解釋執行,當JVM發現某個方法或程式碼塊執行特別頻繁的時候,就會認為這是“熱點程式碼”(Hot Spot Code)。然後JIT會把部分“熱點程式碼”翻譯成本地機器相關的機器碼,並進行優化,然後再把翻譯後的機器碼快取起來,以備下次使用。
#### 熱點檢測
上面我們說過,要想觸發JIT,首先需要識別出熱點程式碼。目前主要的熱點程式碼識別方式是熱點探測(Hot Spot Detection),HotSpot虛擬機器中採用的主要是基於計數器的熱點探測
> 基於計數器的熱點探測(Counter Based Hot Spot Detection)。採用這種方法的虛擬機器會為每個方法,甚至是程式碼塊建立計數器,統計方法的執行次數,某個方法超過閥值就認為是熱點方法,觸發JIT編譯。
#### 編譯優化
JIT在做了熱點檢測識別出熱點程式碼後,除了會對其位元組碼進行快取,還會對程式碼做各種優化。這些優化中,比較重要的幾個有:逃逸分析、 鎖消除、 鎖膨脹、 方法內聯、 空值檢查消除、 型別檢測消除、 公共子表示式消除等。
而這些優化中的逃逸分析就和本文要介紹的內容有關了。
### 逃逸分析
逃逸分析(Escape Analysis)是目前Java虛擬機器中比較前沿的優化技術。這是一種可以有效減少Java 程式中同步負載和記憶體堆分配壓力的跨函式全域性資料流分析演算法。通過逃逸分析,Hotspot編譯器能夠分析出一個新的物件的引用的使用範圍從而決定是否要將這個物件分配到堆上。
**逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞到其他地方中,稱為方法逃逸。**
例如:
public static String craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
sb是一個方法內部變數,上述程式碼中並沒有將他直接返回,這樣這個StringBuffer有不會被其他方法所改變,這樣它的作用域就只是在方法內部。我們就可以說這個變數並沒有逃逸到方法外部。
有了逃逸分析,我們可以判斷出一個方法中的變數是否有可能被其他執行緒所訪問或者改變,那麼基於這個特性,JIT就可以做一些優化:
* 同步省略
* 標量替換
* 棧上分配
關於同步省略,大家可以參考我之前的《深入理解多執行緒(五)—— Java虛擬機器的鎖優化技術》中關於鎖消除技術的介紹。本文主要來分析下標量替換和棧上分配。
### 標量替換、棧上分配
我們說,JIT經過逃逸分析之後,如果發現某個物件並沒有逃逸到方法體之外的話,就可能對其進行優化,而這一優化最大的結果就是可能改變Java物件都是在堆上分配記憶體的這一原則。
物件要分配在堆上其實有很多原因,但是有一點比較關鍵的和本文有關的,那就是因為堆記憶體在訪問上是執行緒共享的,這樣一個執行緒創建出來的物件,其他執行緒也能訪問到。
*那麼,試想下,如果我們在某一個方法體內部建立了一個物件,並且物件並沒有逃逸到方法外的話,那還有必要一定要把物件分配到堆上嗎?*
其實就沒有必要了,因為這個物件並不會被其他執行緒所訪問到,生命週期也只是在一個方法內部,也就不用大費周折的在堆上分配記憶體,也減少了記憶體回收的必要。
那麼,有了逃逸分析之後,發現一個物件並沒有逃逸到放法外的話,通過什麼辦法可以進行優化,減少物件在堆上分配可能呢?
這就是棧上分配。在HotSopt中,棧上分配並沒有正在的進行實現,而是通過標量替換來實現的。
所以我們重點介紹下,什麼是標量替換,如何通過標量替換實現棧上分配。
#### 標量替換
標量(Scalar)是指一個無法再分解成更小的資料的資料。Java中的原始資料型別就是標量。相對的,那些還可以分解的資料叫做聚合量(Aggregate),Java中的物件就是聚合量,因為他可以分解成其他聚合量和標量。
**在JIT階段,如果經過逃逸分析,發現一個物件不會被外界訪問的話,那麼經過JIT優化,就會把這個物件拆解成若干個其中包含的若干個成員變數來代替。這個過程就是標量替換。**
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就會不會直接建立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這個聚合量經過逃逸分析後,發現他並沒有逃逸,就被替換成兩個聚合量了。
**通過標量替換,原本的一個物件,被替換成了多個成員變數。而原本需要在堆上分配的記憶體,也就不再需要了,完全可以在本地方法棧中完成對成員變數的記憶體分配。**
### 實驗證明
**Talk Is Cheap, Show Me The Code**
**No Data, No BB;**
接下來我們就來通過一個實驗,來看一下逃逸分析是否可以生效,生效後是否真的會發生棧上分配,而棧上分配又有什麼好處呢?
我們來看以下程式碼:
public static void main(String[] args) {
long a1 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
alloc();
}
// 檢視執行時間
long a2 = System.currentTimeMillis();
System.out.println("cost " + (a2 - a1) + " ms");
// 為了方便檢視堆記憶體中物件個數,執行緒sleep
try {
Thread.sleep(100000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();
}
static class User {
}
其實程式碼內容很簡單,就是使用for迴圈,在程式碼中建立100萬個User物件。
我們在alloc方法中定義了User物件,但是並沒有在方法外部引用他。也就是說,這個物件並不會逃逸到alloc外部。經過JIT的逃逸分析之後,就可以對其記憶體分配進行優化。
我們指定以下JVM引數並執行:
-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
**其中-XX:-DoEscapeAnalysis表示關閉逃逸分析。**
在程式打印出 cost XX ms 後,程式碼執行結束之前,我們使用jmap命令,來檢視下當前堆記憶體中有多少個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執行結果中我們可以看到,堆中共建立了100萬個StackAllocTest$User例項。
在關閉逃避分析的情況下(-XX:-DoEscapeAnalysis),雖然在alloc方法中建立的User物件並沒有逃逸到方法外部,但是還是被分配在堆記憶體中。也就說,如果沒有JIT編譯器優化,沒有逃逸分析技術,正常情況下就應該是這樣的。即所有物件都分配到堆記憶體中。
接下來,我們開啟逃逸分析,再來執行下以上程式碼。
-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
在程式打印出 cost XX ms 後,程式碼執行結束之前,我們使用jmap命令,來檢視下當前堆記憶體中有多少個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
從以上列印結果中可以發現,開啟了逃逸分析之後(-XX:+DoEscapeAnalysis),在堆記憶體中只有8萬多個StackAllocTest$User物件。也就是說在經過JIT優化之後,堆記憶體中分配的物件數量,從100萬降到了8萬。
除了以上通過jmap驗證物件個數的方法以外,讀者還可以嘗試將堆記憶體調小,然後執行以上程式碼,根據GC的次數來分析,也能發現,開啟了逃逸分析之後,在執行期間,GC次數會明顯減少。正是因為很多堆上分配被優化成了棧上分配,所以GC次數有了明顯的減少。
### 逃逸分析並不成熟
前面的例子中,開啟逃逸分析之後,物件數目從100萬變成了8萬,但是並不是0,說明JIT優化並不會完完全全的所有情況都進行優化。
關於逃逸分析的論文在1999年就已經發表了,但直到JDK 1.6才有實現,而且這項技術到如今也並不是十分成熟的。
**其根本原因就是無法保證逃逸分析的效能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。**
一個極端的例子,就是經過逃逸分析之後,發現沒有一個物件是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。
雖然這項技術並不十分成熟,但是他也是即時編譯器優化技術中一個十分重要的手段。
### 總結
正常情況下,物件是要在堆上進行記憶體分配的,但是隨著編譯器優化技術的成熟,雖然虛擬機器規範是這樣要求的,但是具體實現上還是有些差別的。
如HotSpot虛擬機器引入了JIT優化之後,會對物件進行逃逸分析,如果發現某一個物件並沒有逃逸到方法外部,那麼就可能通過標量替換來實現棧上分配,而避免堆上分配記憶體。
所以,物件一定在堆上分配記憶體,這是不對的。
*最後,我們留一個思考題,我們之前討論過了TLAB,今天又介紹了棧上分配。大家覺得這兩個優化有什麼相同點和不同