天天看點

JVM學習筆記(四)Java堆

Java堆

JVM學習筆記(四)Java堆

同上,書上對于java堆的描述:

  • Java堆是Java虛拟機所管理記憶體最大的一塊。Java堆是被所有線程共享的一塊記憶體區域,在虛拟機啟動時建立。此記憶體區域的唯一目的就是存放對象執行個體,幾乎所有對象執行個體都在這裡配置設定記憶體。java虛拟機規範中描述:所有的對象執行個體以及數組都要在堆上配置設定,但随着技術發展與逃逸分析技術逐漸成熟,棧上配置設定、标量替換優化技術将會導緻一些微妙的變化發生,并不絕對了。
  • java堆是垃圾收集器管理的主要區域,是以被稱為“GC”堆,從記憶體回收角度來看,現在收集器基本采用分代收集算法(前面已經講過),從記憶體配置設定角度,線程共享的java堆中可能劃分出多個線程私有的配置設定緩沖區(Thread Local Allocation Buffer,TLAB)。
  • java堆可以處理于實體上不連續的記憶體空間中,隻要邏輯上是連續的即可,就要我們的磁盤空間一樣。如果堆中沒有記憶體完成執行個體配置設定,并且堆也無法再擴充時,将會抛出OutOfMemoryError異常。

JVM的GC用到了一些加速記憶體配置設定的技術

bump-the-pointer機制(指針碰撞):

Bump-the-pointer技術跟蹤在伊甸園空間建立的最後一個對象。這個對象會被放在伊甸園空間的頂部。如果之後再需要建立對象,隻需要檢查伊甸園空間是否有足夠的剩餘空間。如果有足夠的空間,對象就會被建立在伊甸園空間,并且被放置在頂部。這樣以來,每次建立新的對象時,隻需要檢查最後被建立的對象。這将極大地加快記憶體配置設定速度。但是,如果我們在多線程的情況下,事情将截然不同。如果想要以線程安全的方式以多線程在伊甸園空間存儲對象,不可避免的需要加鎖,而這将極大地的影響性能,是以有了TLABs.

TLAB(Thread-Loacl Allocatiuon Buffers):

TLAB的全稱是Thread Local Allocation Buffer,即線程本地配置設定緩存區,這是一個線程專用的記憶體配置設定區域。

由于對象一般會配置設定在堆上,而堆是全局共享的。是以在同一時間,可能會有多個線程在堆上申請空間。是以,每次對象配置設定都必須要進行同步(虛拟機采用CAS配上失敗重試的方式保證更新操作的原子性),而在競争激烈的場合配置設定的效率又會進一步下降。JVM使用TLAB來避免多線程沖突,在給對象配置設定記憶體時,每個線程使用自己的TLAB,這樣可以避免線程同步,提高了對象配置設定的效率。

TLAB本身占用eEden區空間,在開啟TLAB的情況下,虛拟機會為每個Java線程配置設定一塊TLAB空間。參數-XX:+UseTLAB開啟TLAB,預設是開啟的。TLAB空間的記憶體非常小,預設情況下僅占有整個Eden空間的1%,當然可以通過選項-XX:TLABWasteTargetPercent設定TLAB空間所占用Eden空間的百分比大小。

由于TLAB空間一般不會很大,是以大對象無法在TLAB上進行配置設定,總是會直接配置設定在堆上。TLAB空間由于比較小,是以很容易裝滿。比如,一個100K的空間,已經使用了80KB,當需要再配置設定一個30KB的對象時,肯定就無能為力了。這時虛拟機會有兩種選擇,第一,廢棄目前TLAB,這樣就會浪費20KB空間;第二,将這30KB的對象直接配置設定在堆上,保留目前的TLAB,這樣可以希望将來有小于20KB的對象配置設定請求可以直接使用這塊空間。實際上虛拟機内部會維護一個叫作refill_waste的值,當請求對象大于refill_waste時,會選擇在堆中配置設定,若小于該值,則會廢棄目前TLAB,建立TLAB來配置設定對象。這個門檻值可以使用TLABRefillWasteFraction來調整,它表示TLAB中允許産生這種浪費的比例。預設值為64,即表示使用約為1/64的TLAB空間作為refill_waste。預設情況下,TLAB和refill_waste都會在運作時不斷調整的,使系統的運作狀态達到最優。如果想要禁用自動調整TLAB的大小,可以使用-XX:-ResizeTLAB禁用ResizeTLAB,并使用-XX:TLABSize手工指定一個TLAB的大小。

-XX:+PrintTLAB可以跟蹤TLAB的使用情況。一般不建議手工修改TLAB相關參數,推薦使用虛拟機預設行為。

每個TLAB都隻有一個線程可以操作,TLAB結合bump-the-pointer技術可以實作快速的對象配置設定,而不需要任何的鎖進行同步,也就是說,在對象配置設定的時候不用鎖住整個堆,而隻需要在自己的緩沖區配置設定即可。

java對象配置設定過程:

JVM學習筆記(四)Java堆

1.編譯器通過逃逸分析,确定對象是在棧上配置設定還是在堆上配置設定。如果是在堆上配置設定,則進入選項2.

2.如果tlab_top + size <= tlab_end,則在在TLAB上直接配置設定對象并增加tlab_top 的值,如果現有的TLAB不足以存放目前對象則3.

3.重新申請一個TLAB,并再次嘗試存放目前對象。如果放不下,則4.

4.在Eden區加鎖(這個區是多線程共享的),如果eden_top + size <= eden_end則将對象存放在Eden區,增加eden_top 的值,如果Eden區不足以存放,則5.

5.執行一次Young GC(minor collection)。

6.經過Young GC之後,如果Eden區任然不足以存放目前對象,則直接配置設定到老年代。

對象不在堆上配置設定主要的原因還是堆是共享的,在堆上配置設定有鎖的開銷。無論是TLAB還是棧都是線程私有的,私有即避免了競争(當然也可能産生額外的問題例如可見性問題),這是典型的用空間換效率的做法。

棧上配置設定

在JVM中,堆是線程共享的,是以堆上的對象對于各個線程都是共享和可見的,隻要持有對象的引用,就可以通路堆中存儲的對象資料。虛拟機的垃圾收集系統可以回收堆中不再使用的對象,但對于垃圾收集器來說,無論篩選可回收對象,還是回收和整理記憶體都需要耗費時間。

如果确定一個對象的作用域不會逃逸出方法之外,那可以将這個對象配置設定在棧上,這樣,對象所占用的記憶體空間就可以随棧幀出棧而銷毀。在一般應用中,不會逃逸的局部對象所占的比例很大,如果能使用棧上配置設定,那大量的對象就會随着方法的結束而自動銷毀了,無須通過垃圾收集器回收,可以減小垃圾收集器的負載。

JVM允許将線程私有的對象打散配置設定在棧上,而不是配置設定在堆上。配置設定在棧上的好處是可以在函數調用結束後自行銷毀,而不需要垃圾回收器的介入,進而提高系統性能。

棧上配置設定的技術基礎:

一是逃逸分析:

逃逸分析,是一種可以有效減少Java 程式中同步負載和記憶體堆配置設定壓力的跨函數全局資料流分析算法。

通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的對象的引用的使用範圍進而決定是否要将這個對象配置設定到堆上。

逃逸分析的基本行為就是分析對象的動态作用域:當一個對象在方法中被定義後,它可能被外部方法所引用。

經過逃逸分析之後,可以得到三種對象的逃逸狀态。

  • 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方法。是以,編譯器可以将這兩個對象配置設定到棧上。

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

測試代碼:

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));
    }
}
           

設定Idea JVM運作參數:

JVM學習筆記(四)Java堆

未開啟逃逸分析設定為:

-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。對于大量的零散小對象,棧上配置設定提供了一種很好的對象配置設定政策,棧上配置設定的速度快,并且可以有效地避免垃圾回收帶來的負面的影響,但由于和堆空間相比,棧空間比較小,是以對于大對象無法也不适合在棧上進行配置設定。

二是标量替換:允許将對象打散配置設定在棧上,比如若一個對象擁有兩個字段,會将這兩個字段視作局部變量進行配置設定。

1.标量和聚合量

标量即不可被進一步分解的量,而JAVA的基本資料類型就是标量(如:int,long等基本資料類型以及reference類型等),标量的對立就是可以被進一步分解的量,而這種量稱之為聚合量。而在JAVA中對象就是可以被進一步分解的聚合量。

2.替換過程

通過逃逸分析确定該對象不會被外部通路,并且對象可以被進一步分解時,JVM不會建立該對象,而會将該對象成員變量分解若幹個被這個方法使用的成員變量所代替。這些代替的成員變量在棧幀或寄存器上配置設定空間。

通過-XX:+EliminateAllocations可以開啟标量替換, -XX:+PrintEliminateAllocations檢視标量替換情況(Server VM 非Product版本支援)

代碼如下:

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這個聚合量經過逃逸分析後,發現他并沒有逃逸,就被替換成兩個聚合量了。那麼标量替換有什麼好處呢?就是可以大大減少堆記憶體的占用。因為一旦不需要建立對象了,那麼就不再需要配置設定堆記憶體了。

标量替換為棧上配置設定提供了很好的基礎。

同步消除

同步消除是java虛拟機提供的一種優化技術。通過逃逸分析,可以确定一個對象是否會被其他線程進行通路。

如果你定義的類的方法上有同步鎖,但在運作時,卻隻有一個線程在通路,此時逃逸分析後的機器碼,會去掉同步鎖運作,這就是沒有出現線程逃逸的情況。那該對象的讀寫就不會存在資源的競争,不存在資源的競争,則可以消除對該對象的同步鎖。

如以下代碼:

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}
           

代碼中對hollis這個對象進行加鎖,但是hollis對象的生命周期隻在f()方法中,并不會被其他線程所通路到,是以在JIT編譯階段就會被優化掉。優化成:

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}
           

是以,在使用synchronized的時候,如果JIT經過逃逸分析之後發現并無線程安全問題的話,就會做鎖消除。

通過-XX:+EliminateLocks可以開啟同步消除,進行測試執行的效率

補充注意事項

關于逃逸分析的論文在1999年就已經發表了,但直到JDK 1.6才有實作,而且這項技術到如今也并不是十分成熟的。

其根本原因就是無法保證逃逸分析的性能消耗一定能高于他的消耗。雖然經過逃逸分析可以做标量替換、棧上配置設定、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。

一個極端的例子,就是經過逃逸分析之後,發現沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。

雖然這項技術并不十分成熟,但是他也是即時編譯器優化技術中一個十分重要的手段。

堆棧的OOM

public static void main(String[] args)
    {
        List<Byte[]> list=new ArrayList<Byte[]>();
        for(int i=0;i<100;i++){
            //構造1M大小的byte數值
            Byte[] bytes=new Byte[1024*1024];
            //将byte數組添加到list清單中,因為存在引用關系是以bytes數組不會被GC回收
            list.add(bytes);
        }
    }
           

以上程式設定最大堆記憶體50M,執行:

JVM學習筆記(四)Java堆
JVM學習筆記(四)Java堆

顯然程式通過循環将占用100M的堆空間,超過了設定的50M,是以發生了堆記憶體的OOM。

針對這種OOM,解決辦法是增加堆記憶體空間,在實際開發中必要的時候去掉引用關系,使垃圾回收器盡快對無用對象進行回收。

繼續閱讀