保持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
TIntDoubleMap map = new TIntDoubleHashMap(); map.put(5, 7.0); map.put(-1, 9.999);...
Trove 的底层实现使用了原生类型的数组,所以当操作集合的时候不会发生元素的装箱(int->Integer)或者拆箱(Integer->int), 没有存储对象,因为底层使用原生数据类型存储。
最后
随着垃圾收集器持续的改进,以及运行时的优化和 JIT 编译器也变得越来越智能。我们作为开发者将会发现越来越少地考虑如何编写 GC 友好的代码。然而,就目前阶段,不论 G1 如何改进,我们仍然有很多可以做的事来帮 JVM 提升性能。