求你了,別再說Java對象都是在堆內存上分配空間的了!

Java做爲一種面向對象的,跨平臺語言,其對象、內存等一直是比較難的知識點,因此,即便是一個Java的初學者,也必定或多或少的對JVM有一些瞭解。能夠說,關於JVM的相關知識,基本是每一個Java開發者必學的知識點,也是面試的時候必考的知識點。java

在JVM的內存結構中,比較常見的兩個區域就是堆內存和棧內存(如無特指,本文提到的棧均指的是虛擬機棧),關於堆和棧的區別,不少開發者也是如數家珍,有不少書籍,或者網上的文章大概都是這樣介紹的:面試

一、堆是線程共享的內存區域,棧是線程獨享的內存區域。算法

二、堆中主要存放對象實例,棧中主要存放各類基本數據類型、對象的引用。數組

可是,做者能夠很負責任的告訴你們,以上兩個結論均不是徹底正確的。緩存

在我以前的文章《Java堆內存是線程共享的!面試官:你肯定嗎?》中,介紹過了關於堆內存並非完徹底全的線程共享有關的知識點,本文就第二個話題來探討一下。多線程

對象內存分配

在《Java虛擬機規範》中,關於堆有這樣的描述:app

在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,今天又介紹了棧上分配。你們以爲這兩個優化有什麼相同點和不一樣點嗎?

相關文章
相關標籤/搜索