Java 字符串拼接效率分析及最佳實踐
-
java連接字符串有多種方式,比如+操作符, StringBuilder.append 方法,這些方法各有什么優劣(可以適當說明各種方式的實現細節)?
-
按照高效的原則,那么java中字符串連接的最佳實踐是什么?
-
有關字符串處理,都有哪些其他的最佳實踐?
廢話不多說,直接開始, 環境如下:
JDK版本: 1.8.0_65
CPU: i7 4790`
內存: 16G
直接使用 + 拼接
看下面的代碼:
@Test
public void test() {
String str1 = "abc";
String str2 = "def";
logger.debug(str1 + str2);
}
在上面的代碼中,我們使用加號來連接四個字符串,這種字符串拼接的方式優點很明顯: 代碼簡單直觀,但是對比 StringBuilder 和 StringBuffer 在 大部分情況下 比后者都低,這里說是 大部分情況下 ,我們用javap工具對上面代碼生成的字節碼進行反編譯看看在編譯器對這段代碼做了什么。
public void test();
Code:
0: ldc #5 // String abc
2: astore_1
3: ldc #6 // String def
5: astore_2
6: aload_0
7: getfield #4 // Field logger:Lorg/slf4j/Logger;
10: new #7 // class java/lang/StringBuilder
13: dup
14: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V
17: aload_1
18: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: aload_2
22: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
25: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
28: invokeinterface #11, 2 // InterfaceMethod org/slf4j/Logger.debug:(Ljava/lang/String;)V
33: return
從反編譯的結果來看,實際上對字符串使用 + 操作符進行拼接,編譯器會在編譯階段把代碼優化成使用 StringBuilder 類,并調用 append 方法進行字符串拼接,最后調用 toString 方法,這樣看來是否可以認為在一般情況下 其實直接使用+,反正編譯器也會幫我優化為使用StringBuilder ?
StringBuilder 源碼分析
答案自然是 不可以 的,原因就在于 StringBuilder 這個類它內部做了些什么時。
我們看一看 StringBuilder 類的構造器
public StringBuilder() {
super(16);
}
public StringBuilder(int capacity) {
super(capacity);
}
public StringBuilder(String str) {
super(str.length() + 16);
append(str);
}
public StringBuilder(CharSequence seq) {
this(seq.length() + 16);
append(seq);
}</code></pre>
StringBuilder 提供了4個默認的構造器, 除了無參構造函數外,還提供了另外3個重載版本,而內部都調用父類的 super(int capacity) 構造方法,它的父類是 AbstractStringBuilder ,構造方法如下:
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}
可以看到實際上StringBuilder內部使用的是 char數組 來存儲數據(String、StringBuffer也是),這里 capacity 的值指定了數組的大小。結合 StringBuilder 的無參構造函數,可以知道默認的大小是 16 個字符。
也就是說如果待拼接的字符串總長度不小于16的字符的話,那么其實直接拼接和我們手動寫StringBuilder區別不大,但是我們自己構造StringBuilder類可以指定數組的大小,避免分配過多的內存。
現在我們再看看 StringBuilder.append 方法內部做了什么事:
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
直接調用的父類的 append方法 :
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
在這個方法內部調用了 ensureCapacityInternal 方法,當拼接后的字符串總大小大于內部數組 value 的大小時,就必須先擴容才能拼接,擴容的代碼如下:
void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if (newCapacity - minimumCapacity < 0)
newCapacity = minimumCapacity;
if (newCapacity < 0) {
if (minimumCapacity < 0) // overflow
throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}
StringBuilder 在擴容時把容量增大到 當前容量的兩倍+2 ,這是很可怕的,如果在構造的時候沒有指定容量,那么很有可能在擴容之后占用了浪費大量的內存空間。其次擴容后還調用了 Arrays.copyOf 方法,這個方法把擴容前的數據復制到擴容后的空間內,這樣做的原因是: StringBuilder 內部使用 char數組 存放數據,java的數組是不可擴容的,所以只能重新申請一片內存空間,并把已有的數據復制到新的空間去,這里它最終調用了 System.arraycopy 方法來復制,這是一個native方法,底層直接操作內存,所以比我們用循環來復制要塊的多,即便如此,大量申請內存空間和復制數據帶來的影響也不可忽視。
使用 + 拼接和使用 StringBuilder 比較
@Test
public void test() {
String str = "";
for (int i = 0; i < 10000; i++) {
str += "asjdkla";
}
}
上面這段代碼經過優化后相當于:
@Test
public void test() {
String str = null;
for (int i = 0; i < 10000; i++) {
str = new StringBuilder().append(str).append("asjdkla").toString();
}
}
一眼就能看出 創建了太多的StringBuilder對象 ,而且在每次循環過后str越來越大,導致每次申請的內存空間越來越大,并且當str長度大于16時,每次都要擴容兩次!而實際上 toString 方法在創建 String 對象時,調用了 Arrays.copyOfRange 方法來復制數據,此時相當于每執行一次,擴容了兩次,復制了3次數據,這樣的代價是相當高的。
public void test() {
StringBuilder sb = new StringBuilder("asjdkla".length() * 10000);
for (int i = 0; i < 10000; i++) {
sb.append("asjdkla");
}
String str = sb.toString();
}
這段代碼的執行時間在我的機器上都是0ms(小于1ms)和1ms,而上面那段代碼則大約在380ms!效率的差距相當明顯。
同樣是上面的代碼,將循環次數調整為 1000000 時,在我的機器上,有指定 capacity 時耗時大約20ms,沒有指定 capacity 時耗時大約29ms,這個差距雖然和直接使用 + 操作符有了很大的提升(且循環次數增大了100倍),但是它依舊會觸發多次擴容和復制。
將上面的代碼改成使用 StringBuffer ,在我的機器上,耗時大約為33ms,這是因為 StringBuffer 在大部分方法上都加上了 synchronized 關鍵字來保證線程安全,執行效率有一定程度上的降低。
使用 String.concat 拼接
現在再看這段代碼:
@Test
public void test() {
String str = "";
for (int i = 0; i < 10000; i++) {
str.concat("asjdkla");
}
}
這段代碼使用了 String.concat 方法,在我的機器上,執行時間大約為130ms,雖然直接相加要好的多,但是比起使用 StringBuilder 還要太多了,似乎沒什么用。其實并不是,在很多時候,我們只需要連接兩個字符串,而不是多個字符串的拼接,這個時候使用 String.concat 方法比 StringBuilder 要簡潔且效率要高。
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
上面這段是 String.concat 的源碼,在這個方法中,調用了一次Arrays.copyOf,并且指定了 len + otherLen ,相當于分配了一次內存空間,并分別從str1和str2各復制一次數據。而如果使用 StringBuilder 并指定 capacity ,相當于分配一次內存空間,并分別從str1和str2各復制一次數據,最后因為調用了 toString 方法,又復制了一次數據。
結論
現在根據上面的分析和測試可以知道:
-
Java中字符串拼接不要直接使用 + 拼接。
-
使用StringBuilder或者StringBuffer時,盡可能準確地估算capacity,并在構造時指定,避免內存浪費和頻繁的擴容及復制。
-
在沒有線程安全問題時使用 StringBuilder , 否則使用 StringBuffer 。
-
兩個字符串拼接直接調用 String.concat 性能最好。
關于 String 的其他最佳實踐:
-
用 equals 時總是把能確定不為空的變量寫在左邊,如使用 "".equals(str) 判斷空串,避免空指針異常。
-
第二點是用來排擠第一點的.. 使用 str != null && str.length() != 0 來判斷空串,效率比第一點高。
-
在需要把其他對象轉換為字符串對象時,使用 String.valueOf(obj) 而不是直接調用 obj.toString() 方法,因為前者已經對空值進行檢測了,不會拋出空指針異常。
-
使用 String.format() 方法對字符串進行格式化輸出。
-
在JDK 7及以上版本,可以在 switch 結構中使用字符串了,所以對于較多的比較,使用 switch 代替 if-else 。
-
我暫時想的起來的就這么幾個了.. 請大家幫忙補充補充...
來自:https://segmentfault.com/a/1190000007099818