本章概要
- 字符串的不可变
-
- 的重载与 StringBuilder
- 意外递归
- 字符串操作
字符串操作毫无疑问是计算机程序设计中最常见的行为之一。
在 Java 大展拳脚的 Web 系统中更是如此。在本章中,我们将深入学习在 Java 语言中应用最广泛的 String
类,并研究与之相关的类及工具。
字符串的不可变
String
对象是不可变的。查看 JDK 文档你就会发现,String
类中每一个看起来会修改 String
值的方法,实际上都是创建了一个全新的 String
对象,以包含修改后的字符串内容。而最初的 String
对象则丝毫未动。
看看下面的代码:
public class Immutable {
public static String upcase(String s) {
return s.toUpperCase();
}
public static void main(String[] args) {
String q = "howdy";
System.out.println(q); // howdy
String qq = upcase(q);
System.out.println(qq); // HOWDY
System.out.println(q); // howdy
}
}
当把 q
传递给 upcase()
方法时,实际传递的是引用的一个拷贝。其实,每当把 String 对象作为方法的参数时,都会复制一份引用,而该引用所指向的对象其实一直待在单一的物理位置上,从未动过。
回到 upcase()
的定义,传入其中的引用有了名字 s
,只有 upcase()
运行的时候,局部引用 s
才存在。一旦 upcase()
运行结束,s
就消失了。当然了,upcase()
的返回值,其实是最终结果的引用。这足以说明,upcase()
返回的引用已经指向了一个新的对象,而 q
仍然在原来的位置。
String
的这种行为正是我们想要的。例如:
String s = "asdf";
String x = Immutable.upcase(s);
难道你真的希望 upcase()
方法改变其参数吗?对于一个方法而言,参数是为该方法提供信息的,而不是想让该方法改变自己的。在阅读这段代码时,读者自然会有这样的感觉。这一点很重要,正是有了这种保障,才使得代码易于编写和阅读。
+ 的重载与 StringBuilder
String
对象是不可变的,你可以给一个 String
对象添加任意多的别名。因为 String
是只读的,所以指向它的任何引用都不可能修改它的值,因此,也就不会影响到其他引用。
不可变性会带来一定的效率问题。为 String
对象重载的 +
操作符就是一个例子。重载的意思是,一个操作符在用于特定的类时,被赋予了特殊的意义(用于 String
的 +
与 +=
是 Java 中仅有的两个重载过的操作符,Java 不允许程序员重载任何其他的操作符 )。
操作符 +
可以用来连接 String
:
public class Concatenation {
public static void main(String[] args) {
String mango = "mango";
String s = "abc" + mango + "def" + 47;
System.out.println(s);
}
}
可以想象一下,这段代码是这样工作的: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
。
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
对象,用它来构建最终结果。请参考以下示例:
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());
}
}
在方法 string1()
中,最终结果是用 append()
语句拼接起来的。如果你想走捷径,例如:append(a + ": " + c)
,编译器就会掉入陷阱,从而为你另外创建一个 StringBuilder
对象处理括号内的字符串操作。如果拿不准该用哪种方式,随时可以用 javap
来分析你的程序。
StringBuilder
提供了丰富而全面的方法,包括 insert()
、replace()
、substring()
,甚至还有reverse()
,但是最常用的还是 append()
和 toString()
。还有 delete()
,上面的例子中我们用它删除最后一个逗号和空格,以便添加右括号。
string2()
使用了 Stream
,这样代码更加简洁美观。可以证明,Collectors.joining()
内部也是使用的 StringBuilder
,这种写法不会影响性能!
StringBuilder
是 Java SE5 引入的,在这之前用的是 StringBuffer
。后者是线程安全的,因此开销也会大些。使用 StringBuilder
进行字符串操作更快一点。
意外递归
Java 中的每个类从根本上都是继承自 Object
,标准集合类也是如此,它们都有 toString()
方法,并且覆盖了该方法,使得它生成的 String
结果能够表达集合自身,以及集合包含的对象。例如 ArrayList.toString()
,它会遍历 ArrayList
中包含的所有对象,调用每个元素上的 toString()
方法:
import java.util.*;
import java.util.stream.*;
public class ArrayListDisplay {
public static void main(String[] args) {
List<Coffee> coffees =
Stream.generate(new CoffeeSupplier())
.limit(10)
.collect(Collectors.toList());
System.out.println(coffees);
}
}
如果你希望 toString()
打印出类的内存地址,也许你会考虑使用 this
关键字:
import java.util.stream.*;
public class InfiniteRecursion {
@Override
public String toString() {
return " InfiniteRecursion address: " + this + "\n";
}
public static void main(String[] args) {
Stream.generate(InfiniteRecursion::new)
.limit(10)
.forEach(System.out::println);
}
}
当你创建了 InfiniteRecursion
对象,并将其打印出来的时候,你会得到一串很长的异常信息。如果你将该 InfiniteRecursion
对象存入一个 ArrayList
中,然后打印该 ArrayList
,同样也会抛出异常。其实,当运行到如下代码时:
"InfiniteRecursion address: " + this
这里发生了自动类型转换,由 InfiniteRecursion
类型转换为 String
类型。因为编译器发现一个 String
对象后面跟着一个 “+”,而 “+” 后面的对象不是 String
,于是编译器试着将 this
转换成一个 String
。它怎么转换呢?正是通过调用 this
上的 toString()
方法,于是就发生了递归调用。
如果你真的想要打印对象的内存地址,应该调用 Object.toString()
方法,这才是负责此任务的方法。所以,不要使用 this
,而是应该调用 super.toString()
方法。
字符串操作
以下是 String
对象具备的一些基本方法。重载的方法归纳在同一行中:
方法 | 参数,重载版本 | 作用 |
---|---|---|
构造方法 | 默认版本,String ,StringBuilder ,StringBuffer ,char 数组,byte 数组 | 创建String 对象 |
length() | String 中字符的个数 | |
charAt() | int 索引 | 获取String 中索引位置上的char |
getChars() ,getBytes() | 待复制部分的开始和结束索引,复制的目标数组,目标数组的开始索引 | 复制char 或byte 到一个目标数组中 |
toCharArray() | 生成一个char[] ,包含String 中的所有字符 | |
equals() ,equalsIgnoreCase() | 与之进行比较的String | 比较两个String 的内容是否相同。如果相同,结果为true |
compareTo() ,compareToIgnoreCase() | 与之进行比较的String | 按词典顺序比较String 的内容,比较结果为负数、零或正数。注意,大小写不等价 |
contains() | 要搜索的CharSequence | 如果该String 对象包含参数的内容,则返回true |
contentEquals() | 与之进行比较的CharSequence 或StringBuffer | 如果该String 对象与参数的内容完全一致,则返回true |
isEmpty() | 返回boolean 结果,以表明String 对象的长度是否为0 | |
regionMatches() | 该String 的索引偏移量,另一个String 及其索引偏移量,要比较的长度。重载版本增加了“忽略大小写”功能 | 返回boolean 结果,以表明所比较区域是否相等 |
startsWith() | 可能的起始String 。重载版本在参数中增加了偏移量 | 返回boolean 结果,以表明该String 是否以传入参数开始 |
endsWith() | 该String 可能的后缀String | 返回boolean 结果,以表明此参数是否是该字符串的后缀 |
indexOf() ,lastIndexOf() | 重载版本包括:char ,char 与起始索引,String ,String 与起始索引 | 如果该String 并不包含此参数,就返回-1;否则返回此参数在String 中的起始索引。lastIndexOf ()是从后往前搜索 |
matches() | 一个正则表达式 | 返回boolean 结果,以表明该String 和给出的正则表达式是否匹配 |
split() | 一个正则表达式。可选参数为需要拆分的最大数量 | 按照正则表达式拆分String ,返回一个结果数组 |
join() (Java8引入的) | 分隔符,待拼字符序列。用分隔符将字符序列拼接成一个新的String | 用分隔符拼接字符片段,产生一个新的String |
substring() (即subSequence() ) | 重载版本:起始索引;起始索引+终止索引 | 返回一个新的String 对象,以包含参数指定的子串 |
concat() | 要连接的String | 返回一个新的String 对象,内容为原始String 连接上参数String |
replace() | 要替换的字符,用来进行替换的新字符。也可以用一个CharSequence 替换另一个CharSequence | 返回替换字符后的新String 对象。如果没有替换发生,则返回原始的String 对象 |
replaceFirst() | 要替换的正则表达式,用来进行替换的String | 返回替换首个目标字符串后的String 对象 |
replaceAll() | 要替换的正则表达式,用来进行替换的String | 返回替换所有目标字符串后的String 对象 |
toLowerCase() ,toUpperCase() | 将字符的大小写改变后,返回一个新的String 对象。如果没有任何改变,则返回原始的String 对象 | |
trim() | 将String 两端的空白符删除后,返回一个新的String 对象。如果没有任何改变,则返回原始的String 对象 | |
valueOf() (static ) | 重载版本:Object ;char[] ;char[] ,偏移量,与字符个数;boolean ;char ;int ;long ;float ;double | 返回一个表示参数内容的String |
intern() | 为每个唯一的字符序列生成一个且仅生成一个String 引用 | |
format() | 要格式化的字符串,要替换到格式化字符串的参数 | 返回格式化结果String |
从这个表可以看出,当需要改变字符串的内容时,String
类的方法都会返回一个新的 String
对象。同时,如果内容不改变,String
方法只是返回原始对象的一个引用而已。这可以节约存储空间以及避免额外的开销。
本章稍后还将介绍正则表达式在 String
方法中的应用。