本文源自於問題 Java字串連接最佳實踐?
java連接字串有多種方式,例如+操作符,StringBuilder.append方法,這些方法各有什麼優劣(可以適當說明各種方式的實現細節)?
按照高效的原則,那麼java中字符串連接的最佳實踐是什麼?
有關字符串處理,都有哪些其他的最佳實踐?
廢話不多說,直接開始, 環境如下:
JDK版本: 1.8.0_65
CPU: i7 4790
記憶體: 16G
直接使用+拼接
rr9個字串,這種字串拼接的方式優點很明顯: 程式碼簡單直觀,但是對比StringBuilder和StringBuffer在大部分情況下比後者都低,這裡說是大部分情況下,我們用javap工具對上面程式碼產生的字節碼進行反編譯看看在編譯器對這段程式碼做了什麼。
@Test public void test() { String str1 = "abc"; String str2 = "def"; logger.debug(str1 + str2); }
從反編譯的結果來看,實際上對字串使用+操作符進行拼接,編譯器會在編譯階段把程式碼最佳化成使用StringBuilder類,並呼叫append方法進行字串拼接,最後呼叫toString方法,這樣看來是否可以認為在一般情況下其實直接使用+,反正編譯器也會幫我優化為使用StringBuilder?
StringBuilder源碼分析
答案自然是不可以的,原因就在於StringBuilder這個類它內部做了些什麼時。
我們看一看StringBuilder類別的建構子
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提供了4個預設的建構器, 除了無參構造函數外,還提供了另外3個重載版本,而內部都呼叫父類別的super( int capacity)建構方法,它的父類別是AbstractStringBuilder,建構方法如下:
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); }
可以看到實際上StringBuilder內部使用的是char數組來儲存資料(String、StringBuffer也是),這裡capacity的值指定了陣列的大小。結合StringBuilder的無參建構函數,可以知道預設的大小是16個字元。
也就是說如果待拼接的字串總長度不小於16的字元的話,那麼其實直接拼接和我們手動寫StringBuilder區別不大,但是我們自己建構StringBuilder類別可以指定陣列的大小,避免分配過多的內存。
現在我們再看StringBuilder.append方法內部做了什麼事:
AbstractStringBuilder(int capacity) { value = new char[capacity]; }
直接呼叫的父類的append方法:
@Override public StringBuilder append(String str) { super.append(str); return this; }
在這個方法內部呼叫了ensureCapacityInternal方法,當拼接後的總大小大於內部數組value的大小時,就必須先擴容才能拼接,擴容的代碼如下:
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; }
StringBuilder在擴容時把容量增大到當前容量的兩倍+2,這是很可怕的,如果在構造的時候沒有指定容量,那麼很有可能在擴容之後佔用了浪費大量的記憶體空間。其次擴容後也呼叫了Arrays.copyOf方法,這個方法把擴容前的數據複製到擴容後的空間內,這樣做的原因是:StringBuilder內部使用char數組存放數據,java的數組是不可擴容的,所以只能重新申請一片記憶體空間,並把已有的資料複製到新的空間去,這裡它最終調用了System.arraycopy方法來複製,這是一個native方法,底層直接操作內存,所以比我們用循環來複製要塊的多,即便如此,大量申請記憶體空間和複製資料帶來的影響也不可忽視。
使用+拼接和使用StringBuilder比較
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); }
上面這段程式碼經過最佳化後相當於:
@Test public void test() { String str = ""; for (int i = 0; i < 10000; i++) { str += "asjdkla"; } }
一眼就能看出創建了太多的StringBuilder對象,而且在每次循環過後str越來越大,導致每次申請的內存空間越來越大,並且當str長度大於16時,每次都要擴容兩次!而實際上toString方法在創建String對象時,調用了Arrays.copyOfRange方法來複製數據,此時相當於每執行一次,擴容了兩次,複製了3次數據,這樣的代價是相當高的。
@Test public void test() { String str = null; for (int i = 0; i < 10000; i++) { str = new StringBuilder().append(str).append("asjdkla").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。