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. 參考

1、對象都是在堆上分配的嗎?
2、JVM 之 Java對象創建[初始化]
3、In what cases is Java slower than C++ by a big margin?
4、Size of Huge Objects directly allocated to Old Generation
5、Escape analysis in Java
6、Escape Analysis

這裏寫圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章