1. 程式人生 > >Java中String連接性能的分析

Java中String連接性能的分析

order 幫助 執行 .get leg body near 應該 con

 總結:如果String的數量小於4(不含4),使用String.concat()來連接String,否則首先計算最終結果的長度,再用該長度來創建一個StringBuilder,最後使用這個StringBuilder來連接所有String。
我建議大家如果確定需要連接的String的數量小於4的,直接使用String.concat()來連接,雖然StringBundler能夠幫你自動處理這一情況,但創建一個String[]和那些方法調用都是一些無謂的開銷。

Java中的String是一個非常特殊的類,使它特殊的一個主要原因是:String是不可變的(immutable)。

String的不可變性是Java安全機制和線程安全的基石,沒了它Java將變的不堪一擊。

但不可變性的代價是昂貴的,當你試圖“改變”一個String時,你實際上是在創建一個新的String,而原來的那個String在大多數情況下將會成 為垃圾(garbage)。多虧有了Java的垃圾自動回收機制,開發者不必在這些String垃圾上操太多心。但如果你完全忽略這些垃圾的存在,甚至肆 意亂用String的api,你的程序無疑將遭受大量GC(垃圾回收)活動的困擾。

在JDK的發展史中,人們做過一些努力去改善String的垃圾創建開銷。JDK1.0中加入StringBuffer,JDK1.5中加入 StringBuilder。StringBuffer和StringBuilder在功能上是完全相同的,為一的不同點在於StringBuffer是 線程安全的,而StringBuilder不是。絕大多數的String連接操作發生在一個方法調用中,也就是說是單一線程的工作環境,所以線程安全在這 裏是絕對多余的。所以JDK給開發者的建議是當你要做String連接操作時,請使用StringBuffer或StringBuilder,當你確定連 接操作只發生在單一線程環境下時,使用StringBuilder而不是StringBuffer。在大多數情況下遵守這一建議與直接使用 String.concat()相比能夠大幅提高性能,但實際環境中某些情況遠比這復雜。這一建議並不能給你最佳的性能收益!今天我們要深入的探討一下 String連接操作的性能問題,希望能幫助大家徹底理解這一問題。

首先,需要辟謠,有些人說SB(StringBuffer和StringBuilder)總是比String.concat()有更好的性能。這一說法是不準確的!在特定條件下String.concat()要勝過SB。我們來通過一個例子證明這一點。

任務:
連接兩個String,

String a = "abcdefghijklmnopq"; //length=17
String b = "abcdefghijklmnopqr"; //length=18


說明:
我們將要來分析一下不同連接方案的垃圾生產情況。討論中我們將忽略由輸入參數引起的垃圾,因為他們不是由連接代碼創建的。另外我們只計算String內部的char[],因為除了這個字符數組String的其它域都非常小,完全可以忽略他們對GC的影響。

方案1:
使用String.concat()

代碼:

String result = a.concat(b);

這行代碼簡單到不能再簡單了,不過還是讓我們來看看Sun JDK java.lang.String的源代碼,搞清楚這個調用究竟是怎樣進行的。
Sun JDK java.lang.String的源代碼片段:

1 public String concat(String str) {
2 int otherLen = str.length();
3 if (otherLen == 0) {
4 return this;
5 }
6 char buf[] = new char[count + otherLen];
7 getChars(0, count, buf, 0);
8 str.getChars(0, otherLen, buf, count);
9 return new String(0, count + otherLen, buf);
10 }
11
12 String(int offset, int count, char value[]) {
13 this.value = value;
14 this.offset = offset;
15 this.count = count;
16 }

這段代碼首先創建一個新的char[],數組長度為a.length() + b.length(),然後分別將a和b的內容拷貝到新數組中,最後使用這個數組創建一個新的String對象。這裏我們要特殊註意一下使用的構造函數, 這個構造函數只有package訪問權限,它直接使用傳入的char[]作為新生成的String的內部字符數組,而沒有做任何拷貝保護。這個構造函數必 須是package級別的訪問權限,否則你就能用它創建出一個可變的String對象(在構造完String後修改傳入的char[])。JDK在 java.lang中的代碼保證不會在調用這一構造函數後再修改傳入的數組,加上java的安全機制不允許第三方代碼加入java.lang包(你可以嘗 試將自己的類放入java.lang包,此類將無法成功加載),所以String的不可變性不會被破壞。

整個過程我們沒有創建任何垃圾對象(我們有言在先,a和b是傳入參數,不是連接代碼創建的,所以即使他們變成垃圾我們也不去計算),所以一切良好!

方案2:


使用SB.append(), 這裏我使用StringBuilder來進行分析,對於StringBuffer也是完全一樣的。

代碼:

String result = new StringBuilder().append(a).append(b).toString();

這行代碼明顯比String.concat()方案的代碼復雜,但它的性能如何呢?讓我們分4步來分析它new StringBuilder(),append(a),append(b)和toString().
1)new StringBuilder().
讓我們來看看StringBuilder的源代碼:

1 public StringBuilder() {
2 super(16);
3 }
4
5 AbstractStringBuilder(int capacity) {
6 value = new char[capacity];
7 }

它創建了一個大小為16的char[],目前為止還沒有創建任何垃圾對象。
2)append(a).
繼續看源代碼:

1 public StringBuilder append(String str) {
2 super.append(str);
3 return this;
4 }
5 public AbstractStringBuilder append(String str) {
6 if (str == null) str = "null";
7 int len = str.length();
8 if (len == 0) return this;
9 int newCount = count + len;
10 if (newCount > value.length)
11 expandCapacity(newCount);
12 str.getChars(0, len, value, count);
13 count = newCount;
14 return this;
15 }
16 void expandCapacity(int minimumCapacity) {
17 int newCapacity = (value.length + 1) * 2;
18 if (newCapacity < 0) {
19 newCapacity = Integer.MAX_VALUE;
20 } else if (minimumCapacity > newCapacity) {
21 newCapacity = minimumCapacity;
22 }
23 value = Arrays.copyOf(value, newCapacity);
24 }

這段代碼首先確保SB的內部char[]有足夠的剩余空間,這導致創建了一個新的大小為34的char[],而之前的大小為16的char[]成為垃圾對象。標記點1,我們創建了第一個垃圾對象,大小為16個char。
3)append(b).
相同的邏輯,首先確保內部char[]有足夠的剩余空間,這導致創建了一個新的大小為70的char[],而之前的大小為34的char[]成為垃圾對象。標記點2,我們創建了第二個垃圾對象,大小為34個char。
4)toString()
看源代碼:

1 public String toString() {
2 // Create a copy, don‘t share the array
3 return new String(value, 0, count);
4 }
5 public String(char value[], int offset, int count) {
6 if (offset < 0) {
7 throw new StringIndexOutOfBoundsException(offset);
8 }
9 if (count < 0) {
10 throw new StringIndexOutOfBoundsException(count);
11 }
12 // Note: offset or count might be near -1>>>1.
13 if (offset > value.length - count) {
14 throw new StringIndexOutOfBoundsException(offset + count);
15 }
16 this.offset = 0;
17 this.count = count;
18 this.value = Arrays.copyOfRange(value, offset, offset+count);
19 }

要重點註意一下這次的構造函數,它有public訪問權限,所以它必須做拷貝保護,不然就有可能破壞String的不可變性。但這又創建了一個垃圾對象。標記點3,我們創建了第三個垃圾對象,大小為70個char。

因此我們一共創建了3個垃圾對象,總大小為16+34+70=120個char! Java使用Unicode-16編碼,這就意味著240byte的垃圾!

有一件事情能夠改善SB的性能,把代碼改為:

String result = new StringBuilder(a.length() + b.length()).append(a).append(b).toString();

自己算一下吧,這次我們只創建了1個垃圾對象,大小為17+18=35個char,還是不怎麽樣,不是嗎?

和String.concat()比起來SB創建了“許多”垃圾(任何比0大的數和0比起來都是無窮大!),而且相信你也註意到了,SB比String.concat()有更多的方法調用(棧操作可不是免費的)。

進一步的分析可以發現(自己分析吧),當你連接少於4個String時(不含4),String.concat()要比SB高效的多。

所以當你要連接多於3個String時(不含3),我們應該使用SB,對嗎?

不全對!

SB有一個天生固有的毛病,它使用一個可以動態增長的內部char[]來追加新的String,當你追加新String且SB達到了內部容量上限時,它就 必須擴大內部緩沖區。之後SB獲得了一個更大的char[],而之前使用的char[]則變為了垃圾。如果我們能夠精確的告訴SB最終的結果有多長,它就 可以省掉許多由無謂的增長產生的垃圾。但想要預測最終結果的長度並不容易!

與預測最終結果的長度相比,預測要連接String的數量就顯得容易多了。我們可以先緩存要連接的String,然後在最後那一刻(調用 toString()的時候)計算最終結果的精確長度,用該長度創建一個SB來連接String,這樣就能節省掉許多無謂的中間垃圾char[]。盡管有 時想要精確預測要連接的String數量也是很難的,我們可以效仿SB的做法,使用一個動態增長的String[]來緩存String,因為 String[]要比原來的char[]小的多(現實世界中的String普遍多余一個字符),所以一個動態增長的String[]要比動態增長的 char[]便宜的多。接下來我要介紹的StringBundler就是基於這一原理工作的。

1 public StringBundler() {
2 _array = new String[_DEFAULT_ARRAY_CAPACITY]; // _DEFAULT_ARRAY_CAPACITY = 16
3 }
4
5 public StringBundler(int arrayCapacity) {
6 if (arrayCapacity <= 0) {
7 throw new IllegalArgumentException();
8 }
9 _array = new String[arrayCapacity];
10 }
11


第一個構造函數會創建一個默認數組大小為16的StringBundler,第二個構造函數允許你指定一個初始容量。每當你調用append()時,你並沒有真正的執行String連接操作,而是將該String放置到緩存數組中。

1 public StringBundler append(String s) {
2 if (s == null) {
3 s = StringPool.NULL;
4 }
5 if (_arrayIndex >= _array.length) {
6 expandCapacity();
7 }
8 _array[_arrayIndex++] = s;
9 return this;
10 }
11

如果你追加的String數量超過了緩存數組容量,內部的String[]會動態增長。

1 protected void expandCapacity() {
2 String[] newArray = new String[_array.length << 1];
3 System.arraycopy(_array, 0, newArray, 0, _array.length);
4 _array = newArray;
5 }
6


擴充一個String[]要比擴充char[]便宜的多。因為String[]比較小,而且增長的頻度要遠比原來的char[]低。
當你完成了全部追加後,調用toString()來獲取最終結果。

1 public String toString() {
2 if (_arrayIndex == 0) {
3 return StringPool.BLANK;
4 }
5 String s = null;
6 if (_arrayIndex <= 3) {
7 s = _array[0];
8 for (int i = 1; i < _arrayIndex; i++) {
9 s = s.concat(_array[i]);
10 }
11 }
12 else {
13 int length = 0;
14 for (int i = 0; i < _arrayIndex; i++) {
15 length += _array[i].length();
16 }
17 StringBuilder sb = new StringBuilder(length);
18 for (int i = 0; i < _arrayIndex; i++) {
19 sb.append(_array[i]);
20 }
21 s = sb.toString();
22 }
23 return s;
24 }
25

如果String的數量小於4(不含4),使用String.concat()來連接String,否則首先計算最終結果的長度,再用該長度來創建一個StringBuilder,最後使用這個StringBuilder來連接所有String。

我建議大家如果確定需要連接的String的數量小於4的,直接使用String.concat()來連接,雖然StringBundler能夠幫你自動處理這一情況,但創建一個String[]和那些方法調用都是一些無謂的開銷。

如果大家想進一步了解StringBundler,可以查看Liferay的JIRA連接,
http://support.liferay.com/browse/LPS-6072

好了,解釋的已經夠多了,是時候看看性能測試結果了,這些測試結果將向你展示StringBundler能為你帶來多大的性能提升!

我們將要比較String.concat(),StringBuffer,StringBuilder,使用默認構造函數的StringBundler,使用給定初始化容量構造函數的StringBundler在連接String時的性能差異。

具體比較內容有兩部分:

  1. 比較在完成相同次數連接操作情況下,各種連接方式的時間消耗。
  2. 比較在完成相同次數連接操作情況下,各種連接方式的垃圾生產量。


測試中使用連接String長度均為17,要連接的String的數量從72到2,對每個連接數量執行100,000次重復操作。
對於1,我只采用連接數量從40到2時產生的結果進行比較分析,因為JVM的預熱會對前面的結果產生影響(JIT會占用大量的CPU時間)。
對於2,我采用全部結果進行比較分析,因為JVM的預熱不會對總的垃圾生成數量產生影響(JIT雖然也會產生垃圾,但對於各個測試應是近似平等的,我只比較差值,所以該影響可以忽略)。

順便說一下,我使用如下JVM參數來生成GC日誌:

-XX:+UseSerialGC -Xloggc:gc.log -XX:+PrintGCDetails

之所以采用SerialGC是為了消除多處理器對測試結果的影響。

下面的圖片展示各種連接方式間時間消耗的不同:

由圖可以看出:

  1. 當連接2或3個String時,String.concat()的性能最好
  2. StringBundler整體上優於SB
  3. StringBuilder優於StringBuffer(由於節省了大量的同步操作)

對於3,在今後的blog中我還會更進一步的展開討論,在我們自己的代碼和JDK的代碼中存在大量相似的情況,許多同步保護都是不必要的(至少在特定的情 況下是不必要的),比如JDK的IO包。如果我們能夠繞過這些不必要的同步操作,我們就能大幅提高程序性能。

下面我們來分析以下GC日誌(GC日誌並不能100%準確的告訴你垃圾的數量,但它可以告訴你一個大致的趨勢)

String.concat() 229858963K
StringBuffer 34608271K
StringBuilder 34608144K
StringBundler(默認構造函數) 21214863K
StringBundler(明確指定String數量構造函數) 19562434K


由統計數字可以看出,StringBundler節省了大量的String垃圾。

最後我給大家留下4點建議:

  1. 當你連接2或3個String時,使用String.concat()。
  2. 如果你要連接多於3個String(不含3),並且你能夠精確預測出最終結果的長度,使用StringBuilder/StringBuffer,並設定初始化容量。
  3. 如果你要連接多於3個String(不含3),並且你不能夠精確預測出最終結果的長度,使用StringBundler。
  4. 如果你使用StringBundler,並且你能預測出要連接的String數量,使用指定初始化容量的構造函數。

如果你很懶!直接使用StringBundler吧,他在絕大多數情況下是最佳選擇,在其他情況下雖然他不是最佳選擇,但也能提供足夠的性能保障。

這裏我提供了一個消除了對Liferay其他類文件依賴的StringBundler供大家下載使用。

Java中String連接性能的分析