1. 程式人生 > >別讓Java物件逃逸(Object Escape)

別讓Java物件逃逸(Object Escape)

翻譯:吳嘉俊,叩丁狼高階講師 

 

關於逃逸分析

我在開源專案Speedment的開發過程中,我和專案的貢獻者都意識到我們的程式碼不僅要良好並易懂,同時還要有較高的效能,否則他們很容易轉向使用其他的解決方案。

逃逸分析(Escape Analysis)允許我們在寫出效能較好的程式碼的同時,能通過恰當的抽象,保證良好的程式碼風格。

逃逸分析(簡寫為“EA”)允許java編譯器在多種情況下優化我們的程式碼。請考慮一下程式碼:

public class Point {  

    private final int x, y;  

    public Point(int x, int y) {  
        this.x = x;  
        this.y = y;  
    }  

    @Override  
    public String toString() {  
        final StringBuilder sb = new StringBuilder()  
                .append("(")  
                .append(x)  
                .append(", ")  
                .append(y)  
                .append(")");  
        return sb.toString();  
    }  

}

每一次我們呼叫Point::toString方法的時候,都會建立一個新的StringBuilder物件。這個創建出來的物件,對於外面的方法或者運行當前程式碼的其他執行緒都是不可見的(因為其他執行緒只能看到自己的StringBuilder版本)。

所以,當我們大量的呼叫了這個方法之後,會出現大量的StringBuilder物件麼?不會。因為逃逸分析的作用,編譯器會可以在棧上為StringBuilder分配空間。所以,當我們的方法返回的時候,物件會自動的被刪除,棧上的指標會自動的回退到這個方法呼叫之前的值。

逃逸分析在Java中已經存在了很久了。在最開始的時候,我們需要在命令列選項中手動開啟逃逸分析(通過-XX:+DoEscapeAnalysis開啟),現在它已經作為預設開啟的選項了。Java8在以前的Java基礎上,對於逃逸分析,又有了新的增強。

逃逸分析如何工作

基於逃逸分析,一個物件會可能會被用三種逃逸狀態標記:

  • 全域性級別逃逸:一個物件可能從一個方法或者當前執行緒中逃逸。再明確一點,如果一個物件被作為一個方法的返回值,那麼物件被標記為全域性逃逸狀態。如果一個物件作為類靜態欄位(static field)或者類欄位(field),同樣會被標記為全域性逃逸狀態。另外,如果我們複寫了一個方法的finalize()方法,那麼這個類的物件都會被標記為全域性逃逸狀態並且一定會放在堆記憶體中,這也符合情理,因為這些物件需要對於JVM的finalizer必須是可見的(所以發生逃逸了)。當然,還有其他的一些情況也會讓物件標記為全域性逃逸狀態。
  • 引數級別逃逸:如果一個物件被作為引數傳遞給一個方法,但是在這個方法之外無法訪問或者對其他執行緒不可見,這個物件標記為引數級別逃逸。
  • 無逃逸狀態:一個物件不會產生逃逸

標記為全域性級別逃逸或者引數級別逃逸的物件必須在堆中分配空間,但是引數級別逃逸是可能在記憶體中去掉物件同步鎖的,因為上面已經解釋,引數級別逃逸物件不會被其他執行緒訪問。

無逃逸狀態的物件的記憶體分配會更加自由,可能會在棧上分配,也可能會在堆上分配。事實上,在某些情況下,甚至根本不會去建立一個物件,而直接使用該物件的標量值代替,比如僅僅在棧上建立一個int,去代替一個Integer物件。因為只有一個執行緒可以訪問該物件,所以物件上的同步鎖自然會被去掉。例如,我們使用無逃逸狀態的StringBuffer(較之StringBuilder,StringBuffer是執行緒安全的,所有方法都是synchronized),那麼這種情況下,所有方法的同步鎖都會被去掉,提高執行效率。

EA目前只在C2 HotSpot編譯器下有用,所以請確保我們執行在-server模式下。

為什麼它很重要

理論上來說,無逃逸狀態物件可以直接在棧上分配記憶體,甚至直接在CPU暫存器中分配空間,以提供非常快速的執行。

當我們在堆上分配空間的時候,因為物件可能會被分配在不連續的彼此遠離的堆地址上,這種地址分配方式會非常快的耗盡我們L1 CPU快取,效能會受到影響。而當使用EA通過棧來分配空間,在大部分情況下,使用的都是L1快取中已經分配的空間。所以,EA在棧上分配空間,會在資料儲存位置分配上面更加優化。這對於效能是有益的。

當我們使用EA在棧上分配空間,垃圾回收器的工作量會極大減低,這可能是EA帶來的一個最大的效能提升點。因為每一次在垃圾回收執行的時候,會對堆進行一次完整的掃描,這對於我們的CPU效能,和CPU快取的消耗是非常大的。更不用說,如果伺服器部分虛擬記憶體在獨立的儲存裝置上,過於頻繁的GC帶來的絕對是災難性的影響。

EA帶來的最重要的提升不僅僅體現在效能上。EA允許我們使用本地抽象(local abstractions),比如Lambdas, Functions, Streams, Iterators等等。依賴於EA,我們能輕鬆的寫出既易讀,效能又高的程式碼,讓程式碼專注於我們在做的事情。

一個例子

public class Main {  

    public static void main(String[] args) throws IOException {  
        Point p = new Point(100, 200);  

        sum(p);  
        System.gc();  
        System.out.println("Press any key to continue");  
        System.in.read();  
        long sum = sum(p);  

        System.out.println(sum);  
        System.out.println("Press any key to continue2");  
        System.in.read();  

        sum = sum(p);  

        System.out.println(sum);  
        System.out.println("Press any key to exit");  
        System.in.read();  

    }  

    private static long sum(Point p) {  
        long sumLen = 0;  
        for (int i = 0; i < 1_000_000; i++) {  
            sumLen += p.toString().length();  
        }  
        return sumLen;  

    }  

}

上面的程式碼中,建立了一個Point例項,並且通過呼叫sum方法,大量的呼叫Point物件的toString()方法。我們分了三個階段,首先,執行一次sum,然後立刻GC掉所有建立的物件,接下來兩次我們又呼叫兩次sum方法,但沒有從堆中刪除任何東西,我們來驗證一下每一步執行之後堆的狀態。

我們使用如下的引數來執行應用,我們就可以看到在JVM中發生了什麼:

-server
-XX:BCEATraceLevel=3
-XX:+PrintCompilation
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintInlining
-verbose:gc
-XX:MaxInlineSize=256
-XX:FreqInlineSize=1024
-XX:MaxBCEAEstimateSize=1024
-XX:MaxInlineLevel=22
-XX:CompileThreshold=10
-Xmx4g
-Xms4g

大量的執行引數來更清楚的讓我們看到到底發生了什麼。

當第一步呼叫完成,我們來看看堆的使用(在System.gc()執行之後)

pemi$ jps | grep Main
50903 Main
pemi$ jps | grep Main
50903 Main
pemi$ jmap -histo 50903 | head
 num     #instances         #bytes  class name

----------------------------------------------
   1:            95       42952184  [I
   2:          1079         101120  [C
   3:           485          55272  java.lang.Class
   4:           526          25936  [Ljava.lang.Object;
   5:            13          25664  [B
   6:          1057          25368  java.lang.String
   7:            74           5328  java.lang.reflect.Field

後面兩次呼叫完成之後:

pemi$ jmap -histo 50903 | head
 num     #instances         #bytes  class name
----------------------------------------------
   1:       2001080       88101152  [C
   2:           100       36777992  [I
   3:       1001058       24025392  java.lang.String
   4:         64513        1548312  java.lang.StringBuilder
   5:           485          55272  java.lang.Class
   6:           526          25936  [Ljava.lang.Object;
   7:            13          25664  [B


pemi$ jmap -histo 50903 | head
 num     #instances         #bytes  class name
----------------------------------------------
   1:       4001081      176101184  [C
   2:       2001059       48025416  java.lang.String
   3:           105       32152064  [I
   4:         64513        1548312  java.lang.StringBuilder
   5:           485          55272  java.lang.Class
   6:           526          25936  [Ljava.lang.Object;
   7:            13          25664  [B

可以看到,EA最終能刪掉在堆上建立的StringBuilder例項。兩個操作對比,一個只需要63K空間,另一個需要2M。確實是一個很大的進步。

原文地址:https://minborgsjavapot.blogspot.com/2015/12/do-not-let-your-java-objects-escape.html