1. 程式人生 > >Java中物件都是分配在堆上嗎?你錯了!

Java中物件都是分配在堆上嗎?你錯了!

我們在學習使用Java的過程中,一般認為new出來的物件都是被分配在堆上,但是這個結論不是那麼的絕對,通過對Java物件分配的過程分析,可以知道有兩個地方會導致Java中new出來的物件並不一定分別在所認為的堆上。這兩個點分別是Java中的逃逸分析和TLAB(Thread Local Allocation Buffer)。本文首先對這兩者進行介紹,而後對Java物件分配過程進行介紹。

1. 逃逸分析

1.1 逃逸分析的定義
逃逸分析,是一種可以有效減少Java 程式中同步負載和記憶體堆分配壓力的跨函式全域性資料流分析演算法。通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的物件的引用的使用範圍從而決定是否要將這個物件分配到堆上。
在計算機語言編譯器優化原理中,逃逸分析是指分析指標動態範圍的方法,它同編譯器優化原理的指標分析和外形分析相關聯。當變數(或者物件)在方法中分配後,其指標有可能被返回或者被全域性引用,這樣就會被其他過程或者執行緒所引用,這種現象稱作指標(或者引用)的逃逸(Escape)。
Java在Java SE 6u23以及以後的版本中支援並預設開啟了逃逸分析的選項。Java的 HotSpot JIT編譯器,能夠在方法過載或者動態載入程式碼的時候對程式碼進行逃逸分析,同時Java物件在堆上分配和內建執行緒的特點使得逃逸分析成Java的重要功能。

1.2 逃逸分析的方法
Java Hotspot編譯器使用的是
[plain] view plain copy
Choi J D, Gupta M, Serrano M, et al. Escape analysis for Java[J]. Acm Sigplan Notices, 1999, 34(10): 1-19.
Jong-Deok Choi, Manish Gupta, Mauricio Seffano,Vugranam C. Sreedhar, Sam Midkiff等在論文《Escape Analysis for Java》中描述的演算法進行逃逸分析的。該演算法引入了連通圖,用連通圖來構建物件和物件引用之間的可達性關係,並在次基礎上,提出一種組合資料流分析法。由於演算法是上下文相關和流敏感的,並且模擬了物件任意層次的巢狀關係,所以分析精度較高,只是執行時間和記憶體消耗相對較大。
絕大多數逃逸分析的實現都基於一個所謂“封閉世界(closed world)”的前提:所有可能被執行的,方法在做逃逸分析前都已經得知,並且,程式的實際執行不會改變它們之間的呼叫關係 。但當真實的 Java 程式執行時,這樣的假設並不成立。Java 程式擁有的許多特性,例如動態類載入、呼叫本地函式以及反射程式呼叫等等,都將打破所謂“封閉世界”的約定。
不管是在“封閉世界”還是在“開放世界”,逃逸分析,作為一種演算法而非程式語言的存在,吸引了國內外大量的學者對其進行研究。在這裡本文就不進行學術上了論述了,有需要的可以參見谷歌學術搜尋:

http://www.gfsoso.com/scholar?q=Escape%20Analysis

1.3 逃逸分析後的處理
經過逃逸分析之後,可以得到三種物件的逃逸狀態。
GlobalEscape(全域性逃逸), 即一個物件的引用逃出了方法或者執行緒。例如,一個物件的引用是複製給了一個類變數,或者儲存在在一個已經逃逸的物件當中,或者這個物件的引用作為方法的返回值返回給了呼叫方法。
ArgEscape(引數級逃逸),即在方法呼叫過程當中傳遞物件的應用給一個方法。這種狀態可以通過分析被調方法的二進位制程式碼確定。
NoEscape(沒有逃逸),一個可以進行標量替換的物件。可以不將這種物件分配在傳統的堆上。
編譯器可以使用逃逸分析的結果,對程式進行一下優化。
堆分配物件變成棧分配物件。一個方法當中的物件,物件的引用沒有發生逃逸,那麼這個方法可能會被分配在棧記憶體上而非常見的堆記憶體上。
消除同步。執行緒同步的代價是相當高的,同步的後果是降低併發性和效能。逃逸分析可以判斷出某個物件是否始終只被一個執行緒訪問,如果只被一個執行緒訪問,那麼對該物件的同步操作就可以轉化成沒有同步保護的操作,這樣就能大大提高併發程度和效能。
向量替代。逃逸分析方法如果發現物件的記憶體儲存結構不需要連續進行的話,就可以將物件的部分甚至全部都儲存在CPU暫存器內,這樣能大大提高訪問速度。
下面,我們看一下逃逸分析的例子。

class Main {  
  public static void main(String[] args) {  
    example();  
  }  
  public static void example() {  
    Foo foo = new Foo(); //alloc  
    Bar bar = new Bar(); //alloc  
    bar.setFoo(foo);  
  }  
}  

class Foo {}  

class Bar {  
  private Foo foo;  
  public void setFoo(Foo foo) {  
    this.foo = foo;  
  }  
}  

在這個例子當中,我們建立了兩個物件,Foo物件和Bar物件,同時我們把Foo物件的應用賦值給了Bar物件的方法。此時,如果Bar對在堆上就會引起Foo物件的逃逸,但是,在本例當中,編譯器通過逃逸分析,可以知道Bar物件沒有逃出example()方法,因此這也意味著Foo也沒有逃出example方法。因此,編譯器可以將這兩個物件分配到棧上。

1.4 編譯器經過逃逸分析的效果

測試程式碼:

package com.yang.test2;  

/** 
 * Created by yangzl2008 on 2015/1/29. 
 */  
class EscapeAnalysis {  
    private static class Foo {  
        private int x;  
        private static int counter;  

        public Foo() {  
            x = (++counter);  
        }  
    }  

    public static void main(String[] args) {  
        long start = System.nanoTime();  
        for (int i = 0; i < 1000 * 1000 * 10; ++i) {  
            Foo foo = new Foo();  
        }  
        long end = System.nanoTime();  
        System.out.println("Time cost is " + (end - start));  
    }  
}  

設定JVM執行引數:
未開啟逃逸分析設定為:
-server -verbose:gc
開啟逃逸分析設定為:
-server -verbose:gc -XX:+DoEscapeAnalysis
在未開啟逃逸分析的狀況下執行情況如下:
[GC 5376K->427K(63872K), 0.0006051 secs]
[GC 5803K->427K(63872K), 0.0003928 secs]
[GC 5803K->427K(63872K), 0.0003639 secs]
[GC 5803K->427K(69248K), 0.0003770 secs]
[GC 11179K->427K(69248K), 0.0003987 secs]
[GC 11179K->427K(79552K), 0.0003817 secs]
[GC 21931K->399K(79552K), 0.0004342 secs]
[GC 21903K->399K(101120K), 0.0002175 secs]
[GC 43343K->399K(101184K), 0.0001421 secs]
Time cost is 58514571
開啟逃逸分析的狀況下,執行情況如下:
Time cost is 10031306
未開啟逃逸分析時,執行上訴程式碼,JVM執行了GC操作,而在開啟逃逸分析情況下,JVM並沒有執行GC操作。同時,操作時間上,開啟逃逸分析的程式執行時間是未開啟逃逸分析時間的1/5。

2. TLAB
JVM在記憶體新生代Eden Space中開闢了一小塊執行緒私有的區域,稱作TLAB(Thread-local allocation buffer)。預設設定為佔用Eden Space的1%。在Java程式中很多物件都是小物件且用過即丟,它們不存線上程共享也適合被快速GC,所以對於小物件通常JVM會優先分配在TLAB上,並且TLAB上的分配由於是執行緒私有所以沒有鎖開銷。因此在實踐中分配多個小物件的效率通常比分配一個大物件的效率要高。
也就是說,Java中每個執行緒都會有自己的緩衝區稱作TLAB(Thread-local allocation buffer),每個TLAB都只有一個執行緒可以操作,TLAB結合bump-the-pointer技術可以實現快速的物件分配,而不需要任何的鎖進行同步,也就是說,在物件分配的時候不用鎖住整個堆,而只需要在自己的緩衝區分配即可。
關於物件分配的JDK原始碼可以參見JVM 之 Java物件建立[初始化]中對OpenJDK原始碼的分析。

3. Java物件分配的過程
編譯器通過逃逸分析,確定物件是在棧上分配還是在堆上分配。如果是在堆上分配,則進入選項2.
如果tlab_top + size <= tlab_end,則在在TLAB上直接分配物件並增加tlab_top 的值,如果現有的TLAB不足以存放當前物件則3.
重新申請一個TLAB,並再次嘗試存放當前物件。如果放不下,則4.
在Eden區加鎖(這個區是多執行緒共享的),如果eden_top + size <= eden_end則將物件存放在Eden區,增加eden_top 的值,如果Eden區不足以存放,則5.
執行一次Young GC(minor collection)。
經過Young GC之後,如果Eden區任然不足以存放當前物件,則直接分配到老年代。
物件不在堆上分配主要的原因還是堆是共享的,在堆上分配有鎖的開銷。無論是TLAB還是棧都是執行緒私有的,私有即避免了競爭(當然也可能產生額外的問題例如可見性問題),這是典型的用空間換效率的做法。

4. 參考

這裡寫圖片描述