教妹學 Java:字符串拼接
“哥,你讓我看的《Java 開發手冊》上有這么一段內容:循環體內,拼接字符串最好使用 StringBuilder 的 append() 方法,而不是 + 號操作符。這是為什么呀?”三妹疑惑地問。
“好的,三妹,哥來慢慢給你講。”我回答。
三妹能在學習的過程中不斷地發現問題,讓我感到非常的開心。其實很多時候,我們不應該只是把知識點記在心里,還應該問一問自己,到底是為什么,只有邁出去這一步,才能真正的成長起來。
“+ 號操作符其實被 Java 在編譯的時候重新解釋了,換一種說法就是,+ 號操作符是一種語法糖,讓字符串的拼接變得更簡便了。”一邊給三妹解釋,我一邊在 Intellij IDEA 中敲出了下面這段代碼。
- class Demo {
- public static void main(String[] args) {
- String chenmo = "沉默";
- String wanger = "王二";
- System.out.println(chenmo + wanger);
- }
- }
在 Java 8 的環境下,使用 javap -c Demo.class 反編譯字節碼后,可以看到以下內容:
- Compiled from "Demo.java"
- class Demo {
- Demo();
- Code:
- 0: aload_0
- 1: invokespecial #1 // Method java/lang/Object."<init>":()V
- 4: return
- public static void main(java.lang.String[]);
- Code:
- 0: ldc #2 // String 沉默
- 2: astore_1
- 3: ldc #3 // String 王二
- 5: astore_2
- 6: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
- 9: new #5 // class java/lang/StringBuilder
- 12: dup
- 13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
- 16: aload_1
- 17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
- 20: aload_2
- 21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
- 24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
- 27: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 30: return
- }
“你看,三妹,這里有一個 new 關鍵字,并且 class 類型為 java/lang/StringBuilder。”我指著標號為 9 的那行對三妹說,“這意味著新建了一個 StringBuilder 的對象。”
“然后看標號為 17 的這行,是一個 invokevirtual 指令,用于調用對象的方法,也就是 StringBuilder 對象的 append() 方法。”
“也就意味著把 chenmo 這個字符串添加到 StringBuilder 對象中了。”
“再往下看,標號為 21 的這行,又調用了一次 append() 方法,意味著把 wanger 這個字符串添加到 StringBuilder 對象中了。”
換成 Java 代碼來表示的話,大概是這個樣子:
- class Demo {
- public static void main(String[] args) {
- String chenmo = "沉默";
- String wanger = "王二";
- System.out.println((new StringBuilder(String.valueOf(chenmo))).append(wanger).toString());
- }
- }
“哦,原來編譯的時候把“+”號操作符替換成了 StringBuilder 的 append() 方法啊。”三妹恍然大悟。
“是的,不過到了 Java 9,情況發生了一些改變,同樣的代碼,字節碼指令完全不同了。”我說。
同樣的代碼,在 Java 11 的環境下,字節碼指令是這樣的:
- Compiled from "Demo.java"
- public class com.itwanger.thirtyseven.Demo {
- public com.itwanger.thirtyseven.Demo();
- Code:
- 0: aload_0
- 1: invokespecial #1 // Method java/lang/Object."<init>":()V
- 4: return
- public static void main(java.lang.String[]);
- Code:
- 0: ldc #2 // String
- 2: astore_1
- 3: iconst_0
- 4: istore_2
- 5: iload_2
- 6: bipush 10
- 8: if_icmpge 41
- 11: new #3 // class java/lang/String
- 14: dup
- 15: ldc #4 // String 沉默
- 17: invokespecial #5 // Method java/lang/String."<init>":(Ljava/lang/String;)V
- 20: astore_3
- 21: ldc #6 // String 王二
- 23: astore 4
- 25: aload_1
- 26: aload_3
- 27: aload 4
- 29: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
- 34: astore_1
- 35: iinc 2, 1
- 38: goto 5
- 41: return
- }
看標號為 29 的這行,字節碼指令為 invokedynamic,該指令允許由應用級的代碼來決定方法解析,所謂的應用級的代碼其實是一個方法——被稱為引導方法(Bootstrap Method),簡稱 BSM,BSM 會返回一個 CallSite(調用點) 對象,這個對象就和 invokedynamic 指令鏈接在一起。以后再執行這條 invokedynamic 指令時就不會創建新的 CallSite 對象。CallSite 其實就是一個 MethodHandle(方法句柄)的 holder,指向一個調用點真正執行的方法——此時就是 StringConcatFactory.makeConcatWithConstants() 方法。
“哥,你別再說了,再說我就聽不懂了。”三妹打斷了我的話。
“好吧,總之就是 Java 9 以后,JDK 用了另外一種方法來動態解釋 + 號操作符,具體的實現方式在字節碼指令層面已經看不到了,所以我就以 Java 8 來繼續講解吧。”
“再回到《Java 開發手冊》上的那段內容:循環體內,拼接字符串最好使用 StringBuilder 的 append() 方法,而不是 + 號操作符。原因就在于循環體內如果用 + 號操作符的話,就會產生大量的 StringBuilder 對象,不僅占用了更多的內存空間,還會讓 Java 虛擬機不同的進行垃圾回收,從而降低了程序的性能。”
更好的寫法就是在循環的外部新建一個 StringBuilder 對象,然后使用 append() 方法將循環體內的字符串添加進來:
- class Demo {
- public static void main(String[] args) {
- StringBuilder sb = new StringBuilder();
- for (int i = 1; i < 10; i++) {
- String chenmo = "沉默";
- String wanger = "王二";
- sb.append(chenmo);
- sb.append(wanger);
- }
- System.out.println(sb);
- }
- }
來做個小測試。
第一個,for 循環中使用”+”號操作符。
- String result = "";
- for (int i = 0; i < 100000; i++) {
- result += "六六六";
- }
第二個,for 循環外部新建 StringBuilder,循環體內使用 append() 方法。
- StringBuilder sb = new StringBuilder();
- for (int i = 0; i < 100000; i++) {
- sb.append("六六六");
- }
“這兩個小測試分別會耗時多長時間呢?三妹你來運行下。”
“哇,第一個小測試的執行時間是 6212 毫秒,第二個只用了不到 1 毫秒,差距也太大了吧!”三妹說。
“是的,這下明白了原因吧?”我說。
“是的,哥,原來如此。”
“好了,三妹,來看一下 StringBuilder 類的 append() 方法的源碼吧!”
- public StringBuilder append(String str) {
- super.append(str);
- return this;
- }
這 3 行代碼其實沒啥看的。我們來看父類 AbstractStringBuilder 的 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;
- }
1)判斷拼接的字符串是不是 null,如果是,當做字符串“null”來處理。appendNull() 方法的源碼如下:
- private AbstractStringBuilder appendNull() {
- int c = count;
- ensureCapacityInternal(c + 4);
- final char[] value = this.value;
- value[c++] = 'n';
- value[c++] = 'u';
- value[c++] = 'l';
- value[c++] = 'l';
- count = c;
- return this;
- }
2)獲取字符串的長度。
3)ensureCapacityInternal() 方法的源碼如下:
- private void ensureCapacityInternal(int minimumCapacity) {
- // overflow-conscious code
- if (minimumCapacity - value.length > 0) {
- value = Arrays.copyOf(value,
- newCapacity(minimumCapacity));
- }
- }
由于字符串內部是用數組實現的,所以需要先判斷拼接后的字符數組長度是否超過當前數組的長度,如果超過,先對數組進行擴容,然后把原有的值復制到新的數組中。
4)將拼接的字符串 str 復制到目標數組 value 中。
- str.getChars(0, len, value, count)
5)更新數組的長度 count。
“說到 StringBuilder 就必須得提一嘴 StringBuffer,兩者就像是孿生雙胞胎,該有的都有,只不過大哥 StringBuffer 因為多呼吸兩口新鮮空氣,所以是線程安全的。”我說,“它里面的方法基本上都加了 synchronized 關鍵字來做同步。”
- public synchronized StringBuffer append(String str) {
- toStringCache = null;
- super.append(str);
- return this;
- }
“除了可以使用 + 號操作符,StringBuilder 和 StringBuilder 的 append() 方法,還有其他的字符串拼接方法嗎?”三妹問。
“有啊,比如說 String 類的 concat() 方法,有點像 StringBuilder 類的 append() 方法。”
- String chenmo = "沉默";
- String wanger = "王二";
- System.out.println(chenmo.concat(wanger));
可以來看一下 concat() 方法的源碼。
- 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);
- }
1)如果拼接的字符串的長度為 0,那么返回拼接前的字符串。
2)將原字符串的字符數組 value 復制到變量 buf 數組中。
3)把拼接的字符串 str 復制到字符數組 buf 中,并返回新的字符串對象。
我一行一行地給三妹解釋著。
“和 + 號操作符相比,concat() 方法在遇到字符串為 null 的時候,會拋出 NullPointerException,而“+”號操作符會把 null 當做是“null”字符串來處理。”
如果拼接的字符串是一個空字符串(""),那么 concat 的效率要更高一點,畢竟不需要 new StringBuilder 對象。
如果拼接的字符串非常多,concat() 的效率就會下降,因為創建的字符串對象越來越多。
“還有嗎?”三妹似乎對字符串拼接很感興趣。
“有,當然有。”
String 類有一個靜態方法 join(),可以這樣來使用。
- String chenmo = "沉默";
- String wanger = "王二";
- String cmower = String.join("", chenmo, wanger);
- System.out.println(cmower);
第一個參數為字符串連接符,比如說:
- String message = String.join("-", "王二", "太特么", "有趣了");
輸出結果為:王二-太特么-有趣了。
來看一下 join 方法的源碼:
- public static String join(CharSequence delimiter, CharSequence... elements) {
- Objects.requireNonNull(delimiter);
- Objects.requireNonNull(elements);
- // Number of elements not likely worth Arrays.stream overhead.
- StringJoiner joiner = new StringJoiner(delimiter);
- for (CharSequence cs: elements) {
- joiner.add(cs);
- }
- return joiner.toString();
- }
里面新建了一個叫 StringJoiner 的對象,然后通過 for-each 循環把可變參數添加了進來,最后調用 toString() 方法返回 String。
“實際的工作中,org.apache.commons.lang3.StringUtils 的 join() 方法也經常用來進行字符串拼接。”
- String chenmo = "沉默";
- String wanger = "王二";
- StringUtils.join(chenmo, wanger);
該方法不用擔心 NullPointerException。
- StringUtils.join(null) = null
- StringUtils.join([]) = ""
- StringUtils.join([null]) = ""
- StringUtils.join(["a", "b", "c"]) = "abc"
- StringUtils.join([null, "", "a"]) = "a"
來看一下源碼:
- public static String join(final Object[] array, String separator, final int startIndex, final int endIndex) {
- if (array == null) {
- return null;
- }
- if (separator == null) {
- separator = EMPTY;
- }
- final StringBuilder buf = new StringBuilder(noOfItems * 16);
- for (int i = startIndex; i < endIndex; i++) {
- if (i > startIndex) {
- buf.append(separator);
- }
- if (array[i] != null) {
- buf.append(array[i]);
- }
- }
- return buf.toString();
- }
內部使用的仍然是 StringBuilder。
“好了,三妹,關于字符串拼接的知識點我們就講到這吧。注意 Java 9 以后,對 + 號操作符的解釋和之前發生了變化,字節碼指令已經不同了,等后面你學了字節碼指令后我們再詳細地講一次。”我說。
“嗯,哥,你休息吧,我把這些例子再重新跑一遍。”三妹說。
本文轉載自微信公眾號「沉默王二」,可以通過以下二維碼關注。轉載本文請聯系沉默王二公眾號。