降低Java垃圾回收開銷的5條建議

潘凌霄發表於2016-08-15

保持GC低開銷的竅門有哪些?

隨著一再拖延而即將釋出的 Java9,G1(“Garbage First”)垃圾回收器將被成為 HotSpot 虛擬機器預設的垃圾回收器。從 serial 垃圾回收器到CMS 收集器, JVM 見證了許多 GC 實現,而 G1 將成為其下一代垃圾回收器。

隨著垃圾收集器的發展,每一代 GC 與其上一代相比,都帶來了巨大的進步和改善。parallel GC 與 serial GC 相比,它讓垃圾收集器以多執行緒的方式工作,充分利用了多核計算機的計算能力。CMS(“Concurrent Mark-Sweep”)收集器與 parallel GC 相比,它將回收過程分成了多個階段,使得應用執行緒正在執行的時候,收集工作可以併發地完成,大大改善了頻繁執行 “stop-the-world” 的情況。G1 對於擁有大量堆記憶體的 JVM 表現出更好的效能,並且具有更好的可預測和統一的暫停過程。

Tip #1: 預測集合的容量

所有標準的 Java 集合,包括定製和擴充套件的實現(比如 Trove 和 Google 的 Guava),底層都使用了陣列(原生資料型別或者基於物件的型別)。因為陣列一旦被分配,其大小就不可變,因此新增元素到集合時,大多數情況下都會導致需要重新申請一個新的大容量陣列替換老的陣列(指集合底層實現使用的陣列)。

即使沒有提供集合初始化的大小,大多數集合的實現都儘量優化重新分配陣列的處理並且將其開銷平攤到最低。不過,在構造集合的時候就提供大小可以得到最佳的效果。

讓我們將下面的程式碼作為一個簡單的例子分析一下:

public static List reverse(List & lt; ? extends T & gt; list) {

    List result = new ArrayList();

    for (int i = list.size() - 1; i & gt; = 0; i--) {
        result.add(list.get(i));
    }

    return result;
}

This method allocates a new array, then fills it up with items from another list, only in reverse order. 這個方法分配了一個新的陣列,然後用另一個 list 中元素對該陣列進行填充,只是元素的數序發生了變化。

這個處理方式可能會付出慘重的效能代價,其優化的點在新增元素到新的 list 中這行程式碼。 隨著每一次新增元素,list 都需要確保其底層陣列擁有足夠的位置來容納新的元素。如果有空閒的位置,那麼只是簡單地將新元素儲存到下一個空閒的槽位。如果沒有的話,將分配一個新的底層陣列,拷貝舊的陣列內容到新的陣列中,然後新增新的元素。這將導致多次分配陣列,那些剩餘的舊陣列最終被 GC 所回收。

我們可以通過在構造集合時讓其底層的陣列知道它將儲存多少元素,從而避免這些多餘的分配

public static List reverse(List & lt; ? extends T & gt; list) {

    List result = new ArrayList(list.size());

    for (int i = list.size() - 1; i & gt; = 0; i--) {
        result.add(list.get(i));
    }

    return result;

}

上面的程式碼通過 ArrayList 的構造器指定足夠大的空間來儲存 list.size() 個元素,在初始化時完成分配的執行,這意味著 List 在迭代的過程中無需再次分配記憶體。

Guava 的集合類則更進一步,允許初始化集合時明確指定期望元素的個數或者指定一個預測值。

List result = Lists.newArrayListWithCapacity(list.size());
List result = Lists.newArrayListWithExpectedSize(list.size());

上面的程式碼中,前者用於我們已經準確地知道集合將要儲存多少元素,而後者的分配方式考慮了錯誤預估的情況。

Tip #2:直接處理資料流

當處理資料流時,比如從一個檔案讀取資料或者從網路中下載資料,下面的程式碼是非常常見的:

byte[] fileData = readFileToByteArray(new File("myfile.txt"));

所產生的位元組陣列可能被解析 XML 文件、JSON 物件或者協議緩衝訊息,以及一些常見的可選項。

當處理大檔案或者檔案的大小無法預測時,上面的做法很是不明智的,因為當 JVM 無法分配一個緩衝區來處理真正檔案時,就會導致OutOfMemeoryErrors。

即使資料的大小是可管理的,當到垃圾回收時,使用上面的模式依然會造成巨大的開銷,因為它在堆中分配了一塊非常大的區域來儲存檔案資料。

一種更加好的處理方式是使用合適的 InputStream (比如在這個例子中使用 FileInputStream)直接傳遞給解析器,不再一次性將整個檔案讀取到一個位元組陣列中。所有主流的開源庫都提供相應的 API 來直接接受一個輸入流進行處理,比如:

FileInputStream fis = new FileInputStream(fileName);
MyProtoBufMessage msg = MyProtoBufMessage.parseFrom(fis);

Tip #3: 使用不可變的物件

不變性有太多的好處。甚至不用我贅述什麼。然而,有一個優點會對垃圾回收產生影響,應該關注一下。

一個不可變物件的屬性在物件被建立後就不能被修改(在這裡的例子使用的是引用資料型別的屬性),比如:

public class ObjectPair {

    private final Object first;
    private final Object second;

    public ObjectPair(Object first, Object second) {
        this.first = first;
        this.second = second;
    }

    public Object getFirst() {
        return first;
    }

    public Object getSecond() {
        return second;
    }

}

將上面的類例項化後會產生一個不可變物件—它的所有屬性用 final 修飾,構造完成後就不能改變了。

不可變性意味著所有被一個不可變容器所引用的物件,在容器構造完成前物件就已經被建立。就 GC 而言:這個容器年輕程度至少和其所持有的最年輕的引用一樣。這意味著當在年輕代執行垃圾回收的過程中,GC 因為不可變物件處於老年代而跳過它們,直到確定這些不可變物件在老年代中不被任何物件所引用時,才完成對它們的回收。

更少的掃描物件意味著對記憶體頁更少的掃描,越少的掃描記憶體頁就意味著更短的 GC 生命週期,也意味著更短的 GC 暫停和更好的總吞吐量。

Tip #4: 小心字串拼接

字串可能是在所有基於 JVM 應用程式中最常用的非原生資料結構。然而,由於其隱式地開銷負擔和簡便的使用,非常容易成為佔用大量記憶體的罪歸禍首。

這個問題很明顯不在於字串字面值,而是在執行時分配記憶體初始化產生的。讓我們快速看一下動態構建字串的例子:

public static String toString(T[] array) {

    String result = "[";

    for (int i = 0; i & lt; array.length; i++) {
        result += (array[i] == array ? "this" : array[i]);
        if (i & lt; array.length - 1) {
            result += ", ";
        }
    }

    result += "]";

    return result;
}

這是個看似不錯的方法,接收一個字元陣列然後返回一個字串。但是這對於物件記憶體分配卻是災難性的。

很難看清這語法糖的背後,但是幕後的實際情況是這樣的:

public static String toString(T[] array) {

    String result = "[";

    for (int i = 0; i & lt; array.length; i++) {

        StringBuilder sb1 = new StringBuilder(result);
        sb1.append(array[i] == array ? "this" : array[i]);
        result = sb1.toString();

        if (i & lt; array.length - 1) {
            StringBuilder sb2 = new StringBuilder(result);
            sb2.append(", ");
            result = sb2.toString();
        }
    }

    StringBuilder sb3 = new StringBuilder(result);
    sb3.append("]");
    result = sb3.toString();

    return result;
}

字串是不可變的,這意味著每發生一次拼接時,它們本身不會被修改,而是依次分配新的字串。此外,編譯器使用了標準的 StringBuilder 類來執行這些拼接操作。這就會有問題了,因為每一次迭代,既隱式地分配了一個臨時字串,又隱式分配了一個臨時的 StringBuilder 物件來幫助構建最終的結果。

最佳的方式是避免上面的情況,使用 StringBuilder 和直接的追加,以取代本地拼接操作符(“+”)。下面是一個例子:

public static String toString(T[] array) {

    StringBuilder sb = new StringBuilder("[");

    for (int i = 0; i & lt; array.length; i++) {
        sb.append(array[i] == array ? "this" : array[i]);
        if (i & lt; array.length - 1) {
            sb.append(", ");
        }
    }

    sb.append("]");
    return sb.toString();
}

這裡,我們只在方法開始的時候分配了唯一的一個 StringBuilder。至此,所有的字串和 list 中的元素都被追加到單獨的一個StringBuilder中。最終使用 toString() 方法一次性將其轉成成字串返回。

Tip #5: 使用特定的原生型別的集合

Java 標準的集合庫簡單且支援泛型,允許在使用集合時對型別進行半靜態地繫結。比如想要建立一個只存放字串的 Set 或者儲存 Map<Pair, List>這樣的 map,這種處理方式是非常棒的。

真正的問題源於當我們想要使用一個 list 儲存 int 型別,或者一個 map 儲存 double 型別作為 value。因為泛型不支援原生資料型別,因此另外的一種選擇是使用包裝型別來進行替換,這裡我們使用 List 。

這種處理方式是非常浪費的,因為一個 Integer 是一個完全的物件,一個物件的頭部佔用12個位元組以及其內部的所維護的 int 屬性,每個Integer 物件總共佔用16個位元組。這比起儲存相同個數的 int 型別的 list 而言,其消耗的空間是它的四倍!比這個更加嚴重的問題在於,事實上因為 Integer 是真正的物件例項,因此它需要垃圾收集階段被垃圾收集器所考慮是否要回收。

為了處理這個問題,我們在 Takipi 中使用非常棒的 Trove 集合庫。Trove 摒棄了部分泛型的特定來支援特定的使用記憶體更高效的原生型別的集合。比如,我們使用非常消耗效能的 Map<Integer, Double>,在 Trove 中有另一種特別的選擇方案,其形式為 TIntDoubleMap

TIntDoubleMap map = new TIntDoubleHashMap();
map.put(5, 7.0);
map.put(-1, 9.999);
...

Trove 的底層實現使用了原生型別的陣列,所以當操作集合的時候不會發生元素的裝箱(int->Integer)或者拆箱(Integer->int), 沒有儲存物件,因為底層使用原生資料型別儲存。

最後

隨著垃圾收集器持續的改進,以及執行時的優化和 JIT 編譯器也變得越來越智慧。我們作為開發者將會發現越來越少地考慮如何編寫 GC 友好的程式碼。然而,就目前階段,不論 G1 如何改進,我們仍然有很多可以做的事來幫 JVM 提升效能。

相關文章