🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
## [`+`的重载与`StringBuilder`](https://lingcoder.gitee.io/onjava8/#/book/18-Strings?id=-%e7%9a%84%e9%87%8d%e8%bd%bd%e4%b8%8e-stringbuilder) `String`对象是不可变的,你可以给一个`String`对象添加任意多的别名。因为`String`是只读的,所以指向它的任何引用都不可能修改它的值,因此,也就不会影响到其他引用。 不可变性会带来一定的效率问题。为`String`对象重载的`+`操作符就是一个例子。重载的意思是,一个操作符在用于特定的类时,被赋予了特殊的意义(用于`String`的`+`与`+=`是 Java 中仅有的两个重载过的操作符,Java 不允许程序员重载任何其他的操作符 \[^1\])。 操作符`+`可以用来连接`String`: ~~~ // strings/Concatenation.java public class Concatenation { public static void main(String[] args) { String mango = "mango"; String s = "abc" + mango + "def" + 47; System.out.println(s); } } /* Output: abcmangodef47 */ ~~~ 可以想象一下,这段代码是这样工作的:`String`可能有一个`append()`方法,它会生成一个新的`String`对象,以包含“abc”与`mango`连接后的字符串。该对象会再创建另一个新的`String`对象,然后与“def”相连,生成另一个新的对象,依此类推。 这种方式当然是可行的,但是为了生成最终的`String`对象,会产生一大堆需要垃圾回收的中间对象。我猜想,Java 设计者一开始就是这么做的(这也是软件设计中的一个教训:除非你用代码将系统实现,并让它运行起来,否则你无法真正了解它会有什么问题),然后他们发现其性能相当糟糕。 想看看以上代码到底是如何工作的吗?可以用 JDK 自带的`javap`工具来反编译以上代码。命令如下: ~~~ javap -c Concatenation ~~~ 这里的`-c`标志表示将生成 JVM 字节码。我们剔除不感兴趣的部分,然后做细微的修改,于是有了以下的字节码: ~~~ public static void main(java.lang.String[]); Code: Stack=2, Locals=3, Args_size=1 0: ldc #2; //String mango 2: astore_1 3: new #3; //class StringBuilder 6: dup 7: invokespecial #4; //StringBuilder."<init>":() 10: ldc #5; //String abc 12: invokevirtual #6; //StringBuilder.append:(String) 15: aload_1 16: invokevirtual #6; //StringBuilder.append:(String) 19: ldc #7; //String def 21: invokevirtual #6; //StringBuilder.append:(String) 24: bipush 47 26: invokevirtual #8; //StringBuilder.append:(I) 29: invokevirtual #9; //StringBuilder.toString:() 32: astore_2 33: getstatic #10; //Field System.out:PrintStream; 36: aload_2 37: invokevirtual #11; //PrintStream.println:(String) 40: return ~~~ 如果你有汇编语言的经验,以上代码应该很眼熟(其中的`dup`和`invokevirtual`语句相当于Java虚拟机上的汇编语句。即使你完全不了解汇编语言也无需担心)。需要重点注意的是:编译器自动引入了`java.lang.StringBuilder`类。虽然源代码中并没有使用`StringBuilder`类,但是编译器却自作主张地使用了它,就因为它更高效。 在这里,编译器创建了一个`StringBuilder`对象,用于构建最终的`String`,并对每个字符串调用了一次`append()`方法,共计 4 次。最后调用`toString()`生成结果,并存为`s`(使用的命令为`astore_2`)。 现在,也许你会觉得可以随意使用`String`对象,反正编译器会自动为你做性能优化。可是在这之前,让我们更深入地看看编译器能为我们优化到什么程度。下面的例子采用两种方式生成一个`String`:方法一使用了多个`String`对象;方法二在代码中使用了`StringBuilder`。 ~~~ // strings/WhitherStringBuilder.java public class WhitherStringBuilder { public String implicit(String[] fields) { String result = ""; for(String field : fields) { result += field; } return result; } public String explicit(String[] fields) { StringBuilder result = new StringBuilder(); for(String field : fields) { result.append(field); } return result.toString(); } } ~~~ 现在运行`javap -c WhitherStringBuilder`,可以看到两种不同方法(我已经去掉不相关的细节)对应的字节码。首先是`implicit()`方法: ~~~ public java.lang.String implicit(java.lang.String[]); 0: ldc #2 // String 2: astore_2 3: aload_1 4: astore_3 5: aload_3 6: arraylength 7: istore 4 9: iconst_0 10: istore 5 12: iload 5 14: iload 4 16: if_icmpge 51 19: aload_3 20: iload 5 22: aaload 23: astore 6 25: new #3 // StringBuilder 28: dup 29: invokespecial #4 // StringBuilder."<init>" 32: aload_2 33: invokevirtual #5 // StringBuilder.append:(String) 36: aload 6 38: invokevirtual #5 // StringBuilder.append:(String;) 41: invokevirtual #6 // StringBuilder.toString:() 44: astore_2 45: iinc 5, 1 48: goto 12 51: aload_2 52: areturn ~~~ 注意从第 16 行到第 48 行构成了一个循环体。第 16 行:对堆栈中的操作数进行“大于或等于的整数比较运算”,循环结束时跳转到第 51 行。第 48 行:重新回到循环体的起始位置(第 12 行)。注意:`StringBuilder`是在循环内构造的,这意味着每进行一次循环,会创建一个新的`StringBuilder`对象。 下面是`explicit()`方法对应的字节码: ~~~ public java.lang.String explicit(java.lang.String[]); 0: new #3 // StringBuilder 3: dup 4: invokespecial #4 // StringBuilder."<init>" 7: astore_2 8: aload_1 9: astore_3 10: aload_3 11: arraylength 12: istore 4 14: iconst_0 15: istore 5 17: iload 5 19: iload 4 21: if_icmpge 43 24: aload_3 25: iload 5 27: aaload 28: astore 6 30: aload_2 31: aload 6 33: invokevirtual #5 // StringBuilder.append:(String) 36: pop 37: iinc 5, 1 40: goto 17 43: aload_2 44: invokevirtual #6 // StringBuilder.toString:() 47: areturn ~~~ 可以看到,不仅循环部分的代码更简短、更简单,而且它只生成了一个`StringBuilder`对象。显式地创建`StringBuilder`还允许你预先为其指定大小。如果你已经知道最终字符串的大概长度,那预先指定`StringBuilder`的大小可以避免频繁地重新分配缓冲。 因此,当你为一个类编写`toString()`方法时,如果字符串操作比较简单,那就可以信赖编译器,它会为你合理地构造最终的字符串结果。但是,如果你要在`toString()`方法中使用循环,且可能有性能问题,那么最好自己创建一个`StringBuilder`对象,用它来构建最终结果。请参考以下示例: ~~~ // strings/UsingStringBuilder.java import java.util.*; import java.util.stream.*; public class UsingStringBuilder { public static String string1() { Random rand = new Random(47); StringBuilder result = new StringBuilder("["); for(int i = 0; i < 25; i++) { result.append(rand.nextInt(100)); result.append(", "); } result.delete(result.length()-2, result.length()); result.append("]"); return result.toString(); } public static String string2() { String result = new Random(47) .ints(25, 0, 100) .mapToObj(Integer::toString) .collect(Collectors.joining(", ")); return "[" + result + "]"; } public static void main(String[] args) { System.out.println(string1()); System.out.println(string2()); } } /* Output: [58, 55, 93, 61, 61, 29, 68, 0, 22, 7, 88, 28, 51, 89, 9, 78, 98, 61, 20, 58, 16, 40, 11, 22, 4] [58, 55, 93, 61, 61, 29, 68, 0, 22, 7, 88, 28, 51, 89, 9, 78, 98, 61, 20, 58, 16, 40, 11, 22, 4] */ ~~~ 在方法`string1()`中,最终结果是用`append()`语句拼接起来的。如果你想走捷径,例如:`append(a + ": " + c)`,编译器就会掉入陷阱,从而为你另外创建一个`StringBuilder`对象处理括号内的字符串操作。如果拿不准该用哪种方式,随时可以用`javap`来分析你的程序。 `StringBuilder`提供了丰富而全面的方法,包括`insert()`、`replace()`、`substring()`,甚至还有`reverse()`,但是最常用的还是`append()`和`toString()`。还有`delete()`,上面的例子中我们用它删除最后一个逗号和空格,以便添加右括号。 `string2()`使用了`Stream`,这样代码更加简洁美观。可以证明,`Collectors.joining()`内部也是使用的`StringBuilder`,这种写法不会影响性能! `StringBuilder` 是 Java SE5 引入的,在这之前用的是`StringBuffer`。后者是线程安全的(参见[并发编程](https://lingcoder.gitee.io/onjava8/#/./24-Concurrent-Programming)),因此开销也会大些。使用`StringBuilder`进行字符串操作更快一点。