Java 字符串创建后就不会再变,每次“修改”都会生成新字符串。本文讲 String 常用方法及其陷阱、循环里的 += 为什么要复制全部内容、StringBuilder、文本块、Unicode,以及数组和 Arrays 工具类。
Java 的 String 一旦创建就不能再改。每个看起来在修改字符串的方法,其实都返回一个新字符串。这一点能解释一个经典 bug、一个经典的慢循环,也解释了 StringBuilder 为什么存在。
本文会讲 String 的常用方法及其陷阱、字符串比较、StringBuilder、Unicode、文本块,以及数组和 Arrays 工具类。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存为 Main.java,然后执行 java Main.java。
字符串是不可变的
String 对象里的字符从创建起就固定不变,没有哪个方法能改动它们。toUpperCase 不会把字符串变成大写。它返回一个新的大写字符串,如果你不保存这个返回值,它就丢了:
void main() {
String name = "ada";
name.toUpperCase();
IO.println(name);
String shout = name.toUpperCase();
IO.println(shout);
IO.println(name);
name = name.toUpperCase();
IO.println(name);
}
输出:
ada
ADA
ada
ADA
main 的第二行就是那个 bug。它能编译、能运行,却什么也没做,而且 javac 不会警告,就算加上 -Xlint:all 也不会。像最后几行那样把结果存下来。即便如此,name = name.toUpperCase() 也没有改变 "ada"。它只是让 name 引用了另一个字符串。
String 的常用方法
处理字符串,大多是找位置、截取片段,或者检查里面有什么。位置叫作索引,从 0 开始:
void main() {
String s = "hello, world";
IO.println(s.length());
IO.println(s.charAt(0));
IO.println(s.charAt(s.length() - 1));
IO.println(s.substring(7));
IO.println(s.substring(0, 5));
IO.println(s.indexOf("o"));
IO.println(s.lastIndexOf("o"));
IO.println(s.indexOf("z"));
IO.println(s.contains("world"));
IO.println(s.replace("l", "L"));
IO.println(s.startsWith("hell") + " " + s.endsWith("!"));
}
输出:
12
h
d
world
hello
4
8
-1
true
heLLo, worLd
true false
从这段输出里可以记住几条规则:
- 最后一个索引是
length() - 1。 12 个字符的字符串,索引是 0 到 11。 substring(begin, end)包含begin,在end之前停下。 结果的长度总是end - begin。substring(7)一直截到末尾。- 没有匹配时,
indexOf返回 -1。 把结果当索引用之前,先检查这个值。 replace会替换所有匹配的普通文本。replaceAll看起来差不多,但参数是正则表达式。
索引越界会抛出异常
substring 和 charAt 会检查索引,传入错误的索引,程序就会停下:
void main() {
String word = "hello";
IO.println(word.substring(1, 5));
IO.println(word.substring(2, 9));
}
输出后停止:
ello
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Range [2, 9) out of bounds for length 5
substring(1, 5) 没问题,因为结尾等于长度,意思就是“一直到末尾”。错误信息用数学记号写出了出错的区间:[2, 9) 包含 2、不包含 9,正是“不含结尾”这条规则的写法。
split 的参数是正则表达式
split 在分隔符每次出现的地方切开字符串,返回由各段组成的数组。分隔符是正则表达式,不是普通文本,几乎每个人都会在这里栽一次:
void main() {
IO.println(Arrays.toString("a,b,c".split(",")));
IO.println(Arrays.toString("1.2.3".split(".")));
IO.println(Arrays.toString("1.2.3".split("\\.")));
IO.println(Arrays.toString("red|green".split("|")));
IO.println(Arrays.toString("red|green".split(Pattern.quote("|"))));
IO.println(Arrays.toString("a,b,,".split(",")));
IO.println(Arrays.toString("a,b,,".split(",", -1)));
IO.println(Arrays.toString(",a".split(",")));
}
输出:
[a, b, c]
[]
[1, 2, 3]
[r, e, d, |, g, r, e, e, n]
[red, green]
[a, b]
[a, b, , ]
[, a]
按顺序看,有三个意外:
split(".")返回空数组。.表示“任意字符”,所以每一段都是空的,而末尾的空段会被丢掉。把点转义成"\\."。split("|")在每两个字符之间都切一刀。|表示“或”,“空或空”在哪里都能匹配。Pattern.quote能让任何文本按字面匹配。- 末尾的空字符串会消失。
"a,b,,"有四个字段,split(",")却只返回两个。开头的空字符串会保留,",a"就是例子。传入 -1 作为 limit 参数,就能保留所有字段,比如最后几列为空的 CSV 行。
去空白、判空、重复、拼接和格式化
这些方法用来清理和构建字符串。strip、isBlank 和 repeat 是 Java 11 加入的,formatted 是 Java 15 加入的:
void main() {
String spaces = " total ";
IO.println("[" + spaces.trim() + "] [" + spaces.strip() + "]");
String emSpaces = "\u2003total\u2003";
IO.println(emSpaces.trim().length() + " " + emSpaces.strip().length());
IO.println("".isEmpty() + " " + " ".isEmpty() + " " + " ".isBlank());
IO.println("ab".repeat(3));
IO.println(String.join(", ", "Ana", "Ben", "Chen"));
IO.println(String.join("/", List.of("usr", "local", "bin")));
IO.println(String.format("%-6s|%5d|%.2f", "tea", 42, 3.14159));
IO.println("%s scored %d%% on %,d questions".formatted("Ana", 95, 1200));
}
输出:
[total] [total]
7 5
true false true
ababab
Ana, Ben, Chen
usr/local/bin
tea | 42|3.14
Ana scored 95% on 1,200 questions
对普通空格,trim 和 strip 的结果一样。遇到 \u2003 就不一样了。它是 em 空格,从网页上复制来的文本里常常带着它。trim 只删除字符表里排在普通空格之前(含普通空格)的字符,所以两个 em 空格都留了下来,返回 7 个字符。strip 认识所有 Unicode 空白字符。新代码里请用 strip。
字符串只由空白字符组成时,isBlank 为 true,检查用户输入时通常要的就是它。isEmpty 只在长度为 0 时才为 true。
formatted 和 String.format 做的是同一件事,只不过直接在格式串上调用。%s 是字符串,%d 是整数,%.2f 是保留两位小数,%-6s 是在六列宽度内左对齐,%,d 是带千位分隔符的数字,%% 是百分号。
比较字符串
equals 逐个字符比较两个字符串。讲值和引用的那一篇说明了为什么不能用 == 比较字符串:它检查的是不是同一个对象,而不是文本是否相同。另外还有两个方法,分别处理大小写和先后顺序:
void main() {
String a = "Java";
IO.println(a.equals("java"));
IO.println(a.equalsIgnoreCase("java"));
IO.println("apple".compareTo("banana"));
IO.println("banana".compareTo("apple"));
IO.println("pear".compareTo("pear"));
IO.println("app".compareTo("apple"));
IO.println("Zebra".compareTo("apple"));
IO.println("Zebra".compareToIgnoreCase("apple"));
String missing = null;
IO.println("Java".equals(missing));
}
输出:
false
true
-1
1
0
-2
-7
25
false
compareTo 给两个字符串排先后。只有正负号有意义:负数表示第一个字符串排在前面,零表示相等,正数表示排在后面。这个数字是第一对不同字符之间的差值;如果没有不同的字符,就是长度之差,比如 "app" 和 "apple"。
"Zebra" 排在 "apple" 前面,因为每个大写字母的字符编码都比每个小写字母小。compareToIgnoreCase 给出的才是人期望的顺序。
最后一行是个值得学的习惯。"Java".equals(missing) 遇到 null 返回 false,而 missing.equals("Java") 会抛出 NullPointerException。
Java 在字符串池里为每个字符串字面量只保留一份共享的副本,所以两个相同的字面量是同一个对象,对它们用 == 碰巧会得到 true。运行时构建的字符串,比如来自输入或 StringBuilder 的,不会进入字符串池。这就是为什么对字符串用 ==,简单测试能通过,遇到真实数据就出错。
为什么循环里的 += 要复制全部内容
字符串不能变长,所以 s += "!" 会新建一个长一个字符的字符串,把旧字符串的每个字符都复制进去,加上 "!",再让 s 指向结果。旧字符串就成了垃圾。在循环里,每一步都比上一步复制得更多。
下面的程序不计时,而是统计复制了多少。对 +=,每一步都复制当前的整个字符串。对 StringBuilder,统计 builder 换到更大缓冲区的次数,以及换的时候复制了多少:
void main() {
int n = 10_000;
String s = "";
long copiedByPlus = 0;
for (int i = 0; i < n; i++) {
copiedByPlus += s.length();
s += "!";
}
var sb = new StringBuilder();
long copiedByBuilder = 0;
int regrowths = 0;
for (int i = 0; i < n; i++) {
int before = sb.capacity();
sb.append('!');
if (sb.capacity() != before) {
regrowths++;
copiedByBuilder += sb.length() - 1;
}
}
IO.println("+= made " + n + " strings and copied " + copiedByPlus + " characters");
IO.println("StringBuilder grew " + regrowths + " times and copied "
+ copiedByBuilder + " characters");
IO.println("same text: " + s.contentEquals(sb));
}
输出:
+= made 10000 strings and copied 49995000 characters
StringBuilder grew 10 times and copied 18394 characters
same text: true
两个循环构建的是同样的 10,000 个字符。+= 循环先复制 0 个,再复制 1 个、2 个,一直到 9,999 个,总共约 n² / 2。n 翻一倍,复制量就变成四倍。这就是 O(n²)。
StringBuilder 把字符放在一个留有富余空间的缓冲区里,append 把字符写进下一个空闲槽位。缓冲区满了,builder 就分配一个大约两倍大的新缓冲区,把内容整体复制过去一次。这种情况只发生了 10 次,所以复制量始终接近 n。
像 "Hi " + name + ", you have " + n + " messages" 这样的单个表达式,不需要 StringBuilder。从 Java 9 起,javac 会把整个表达式编译成一次调用,只构建一次字符串。只有在循环里反复 += 时才有代价,因为每一轮都要从完整的字符串重新开始。
使用 StringBuilder
StringBuilder 是允许修改的字符串。你先一点点构建它,最后调用一次 toString,得到一个普通的 String:
void main() {
var sb = new StringBuilder("world");
sb.insert(0, "hello ");
sb.append('!').append(" x").append(3);
IO.println(sb);
sb.setCharAt(0, 'H');
sb.deleteCharAt(sb.length() - 1);
String done = sb.toString();
IO.println(done + " has " + done.length() + " characters");
IO.println(new StringBuilder("stressed").reverse());
var one = new StringBuilder("abc");
var two = new StringBuilder("abc");
IO.println(one.equals(two));
IO.println(one.toString().equals(two.toString()));
IO.println("abc".contentEquals(one));
}
输出:
hello world! x3
Hello world! x has 14 characters
desserts
false
true
true
append 接受字符串、字符和数字,并返回同一个 builder,所以调用可以串起来。insert 在某个索引处插入文本,reverse 原地反转 builder 的内容。
最后三行藏着一个陷阱。StringBuilder 没有重写 equals,所以两个文本相同的 builder 并不相等。要比较它们的字符串,或者用 String.contentEquals。你还可能遇到 StringBuffer,它是更老的、带同步的版本。请用 StringBuilder。
用十岁孩子能懂的话说
String 是一块印好的招牌。谁也改不了上面的字。想把“Sale”改成“Sale!”,你得重新印一块完整的新招牌,把旧的扔掉。
现在每天加一个字母。每天你都要印一块新招牌,印上所有旧字母再加一个,回收箱越堆越满。到月底,第一个字母你已经印了 30 遍。
StringBuilder 是一块白板。你在最后一个字母后面写下一个,什么都不用重印。写完以后,再照着白板印一块招牌。
准确的说法
String 内部持有一个私有的字符数组,构造完成后就没有任何代码再写它。replace、toUpperCase 和 + 都会分配一个新的 String 和一个新数组。
StringBuilder 持有一个数组,数组的容量至少等于它的长度。append 往没用到的部分里写。某次写入放不下时,builder 会分配一个更大的数组,在这个 JDK 上是旧容量的两倍再加 2(16、34、70、142,依此类推),再把字符复制过去。因为大小是翻倍增长的,平均下来每个字符只被复制几次,所以 n 次 append 的代价是 O(n)。toString 把用到的部分复制进一个新的 String,所以之后再改 builder,也影响不到它。
这个比喻的局限:真正的白板大小是固定的。StringBuilder 写满时,会把自己换成一块更大的,这次替换确实要复制全部内容,只是很少发生。如果你知道最终大小,new StringBuilder(10_000) 就能省掉这些替换。
看 += 怎样复制、append 怎样填满缓冲区
下面的动画先执行两次 s += "!",再用 StringBuilder 做同样的事:
每次 s += “!” 都会新建一个字符串,把旧字符串的每个字符复制过去,旧的那个成了垃圾。StringBuilder 把每个 ! 写进同一个缓冲区的空位,toString 只在最后复制一次。
如果动画没有播放,下面用文字把这几步再说一遍:
s引用一个存着"go"的String。s += "!"分配一个三个字符的字符串,把g和o复制进去,再加上!。s现在引用它,旧的"go"成了垃圾。复制了两个字符。- 再次
s += "!",分配四个字符,先把"go!"的三个字符全部复制进去,再加上!。"go!"也成了垃圾。为了构建四个字符,一共复制了 5 个字符。 StringBuilder把"go"放在一个 8 个槽位的缓冲区里。- 两次
sb.append('!'),先把!写进第 3 个槽位,再写进第 4 个。始终是同一个缓冲区,什么都没有移动。 sb.toString()把这 4 个字符复制进一个新的String,这是 builder 唯一的一次复制。
char 不一定是一个字符
Java 的 char 是一个 16 位的 UTF-16 代码单元,不是人眼看到的一个字母。大多数字母,包括带重音的字母,一个 char 就放得下。emoji 要占两个,叫作代理对,而 length() 统计的是 char 的个数:
void main() {
String word = "café";
String thumb = "👍";
IO.println(word.length() + " " + thumb.length());
IO.println(thumb.codePointCount(0, thumb.length()));
IO.println(Character.isHighSurrogate(thumb.charAt(0)));
IO.println(Integer.toHexString(thumb.codePointAt(0)));
IO.println(Character.toString(0x1F44D));
String text = "ok👍";
IO.println(text.length() + " " + text.codePointCount(0, text.length()));
IO.println(new StringBuilder(text).reverse());
IO.println(text.substring(0, 3));
}
输出:
4 2
1
true
1f44d
👍
4 3
👍ko
ok?
码点是 Unicode 给每个字符编的号,这里是 U+1F44D。点赞手势是一个码点,存成两个 char,而 charAt(0) 只返回前一半,也就是“高位代理”。
最后两行让我们也吃了一惊。StringBuilder.reverse 会让代理对保持在一起,所以 emoji 完好无损。substring(0, 3) 把代理对从中间切开了,而落单的代理没有 UTF-8 编码,所以 Java 打印出 ?。在固定索引处截断用户文本,就可能这样把文本弄坏。
就连码点也不是全部。像 🇮🇳 这样的旗帜是两个码点,读者看到的却是一个符号。截断名字,或者按上限统计字符数时,要记住这一点。
文本块:不用转义的多行字符串
文本块是跨多行的字符串字面量,写在两组三个双引号之间。它在 Java 15 成为正式特性,javac --release 14 会拒绝它,报 text blocks are not supported in -source 14。有了它,JSON、SQL 和 HTML 都好读多了:
void main() {
String json = """
{
"name": "Ana",
"score": 95
}
""";
IO.print(json);
String shifted = """
{
"name": "Ana"
}
""";
IO.print(shifted);
String noNewline = """
one line""";
IO.println("[" + noNewline + "]");
}
输出:
{
"name": "Ana",
"score": 95
}
{
"name": "Ana"
}
[one line]
里面的引号不需要反斜杠。缩进值得细看,因为源代码缩进了 8 个空格,第一个结果却没有缩进:
- 附带缩进会被去掉。 Java 找出内容行和结尾
"""那一行里最小的缩进,从每一行删去这么多空格。"name"相对于{的缩进保留了下来。 - 结尾的
"""决定左边界。 在shifted里,它比内容靠左 4 个空格,所以每一行都保留了 4 个空格。 - 开头的
"""后面必须换行。 把"""hello"""写在一行里,会报illegal text block open delimiter sequence, missing line terminator。 - 结尾的
"""单独占一行,会多出一个末尾换行。 像noNewline那样把它紧跟在文本后面,就不会有这个换行。
用 \ 续行,用 \s 保留空格
有两个转义只能在文本块里用。行尾的 \ 把这一行和下一行连起来,\s 是一个 Java 不会删掉的空格:
void main() {
String sql = """
SELECT name, score \
FROM results \
WHERE score > %d \
ORDER BY score DESC""".formatted(90);
IO.println(sql);
String padded = """
red \s
green\s
""";
IO.print(padded.replace(' ', '.'));
IO.println("""
She said "hi", then wrote \""" on the board.
""".strip());
}
输出:
SELECT name, score FROM results WHERE score > 90 ORDER BY score DESC
red...
green.
She said "hi", then wrote """ on the board.
这段 SQL 在源代码里占四行,输出却是一行,因为每个 \ 都去掉了一个换行。formatted 在文本块上和在普通字符串上一样好用。
Java 会删除每行末尾的空格,因为编辑器会悄悄地加上或删掉它们。\s 是标记行尾的空格,所以它前面的空格得以保留,red 后面的点就说明了这一点。要在文本块里放三个引号,就把第一个转义成 \"""。
数组:固定数量的槽位
数组存放固定数量、同一类型的值,数量在创建时就定下来。新槽位的初始值是该类型的零值,和字段的默认值一样:
void main() {
int[] counts = new int[4];
double[] prices = new double[2];
boolean[] seen = new boolean[3];
String[] names = new String[3];
IO.println(Arrays.toString(counts));
IO.println(Arrays.toString(prices));
IO.println(Arrays.toString(seen));
IO.println(Arrays.toString(names));
int[] scores = {72, 95, 88};
String[] days = new String[] {"Mon", "Tue"};
scores[0] = 75;
IO.println(scores.length + " " + days.length);
IO.println(Arrays.toString(scores) + " " + Arrays.toString(days));
}
输出:
[0, 0, 0, 0]
[0.0, 0.0]
[false, false, false]
[null, null, null]
3 2
[75, 95, 88] [Mon, Tue]
{72, 95, 88} 创建并填充一个数组,长度由编译器来数。如果不是直接赋给一个声明好的变量,比如把数组传给方法,就需要完整写法 new String[] {...}。
scores.length 后面没有括号:对数组来说,length 是字段,对字符串来说它是方法。数组不能变长。想要第五个计数,你得新建一个数组,再把内容复制过去。
直接打印数组,看到的是哈希值
数组没有重写 toString,所以 IO.println(scores) 打印的不是内容,而是 [I@ 加上一串十六进制数字。[I 是 Java 内部对“int 数组”的叫法,那串数字来自标识哈希码,每次运行都可能不同。String[] 打印出来是 [Ljava.lang.String;@ 加一串数字。
有个细节让我们也吃了一惊。System.out.println 有一个专门针对 char[] 的版本,会打印出字母。IO.println 接受任意 Object,所以 char[] 打印出来是 [C@ 加哈希值。请用 new String(letters)。
索引越界会抛出异常
Java 会检查每一次数组访问,索引小于 0,或者大于等于 length,都会抛出 ArrayIndexOutOfBoundsException:
void main() {
int[] scores = {72, 95, 88};
IO.println("first: " + scores[0]);
IO.println("last: " + scores[scores.length - 1]);
IO.println("before first: " + scores[-1]);
}
输出后停止:
first: 72
last: 88
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 3
负数索引不像在 Python 里那样从末尾往前数。讲控制流的那一篇介绍了另一个常见原因:循环条件用了 <=,而不是 <。
Arrays 工具类
数组本身没有排序、填充、复制和比较这些方法,java.util.Arrays 把它们补上了。紧凑源文件里用它不需要 import:
void main() {
int[] scores = {88, 72, 95, 61};
int[] sorted = Arrays.copyOf(scores, scores.length);
Arrays.sort(sorted);
IO.println(Arrays.toString(scores) + " -> " + Arrays.toString(sorted));
String[] words = {"pear", "apple", "Banana"};
Arrays.sort(words);
IO.println(Arrays.toString(words));
int[] bigger = Arrays.copyOf(scores, 6);
int[] smaller = Arrays.copyOf(scores, 2);
IO.println(Arrays.toString(bigger) + " " + Arrays.toString(smaller));
IO.println(Arrays.toString(Arrays.copyOfRange(scores, 1, 3)));
char[] line = new char[10];
Arrays.fill(line, '-');
IO.println(new String(line));
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
int[] c = a;
IO.println((a == b) + " " + a.equals(b) + " " + Arrays.equals(a, b) + " " + (a == c));
}
输出:
[88, 72, 95, 61] -> [61, 72, 88, 95]
[Banana, apple, pear]
[88, 72, 95, 61, 0, 0] [88, 72]
[72, 95]
----------
false false true true
逐行来看:
Arrays.sort原地排序,不返回任何东西,所以程序先复制了一份scores。- 字符串按
compareTo排序,所以"Banana"因为大写的B排在最前面。 copyOf会用零值补齐,或者截掉末尾。copyOfRange和substring一样,遵循不含结尾的规则。fill把每个槽位都设成同一个值。==和a.equals(b)比较的都是引用,因为数组的equals继承自Object。Arrays.equals比较的是元素。a == c为true,因为c = a复制的是引用。
二维数组
Java 没有真正的网格类型。二维数组就是元素为数组的数组,所以它的每一行可以长度不同:
void main() {
int[][] grid = new int[2][3];
grid[1][2] = 7;
IO.println(grid.length + " rows, " + grid[0].length + " columns");
IO.println(Arrays.toString(grid[1]));
IO.println(Arrays.deepToString(grid));
int[][] triangle = {{1}, {1, 1}, {1, 2, 1}};
for (int[] row : triangle) {
IO.println(row.length + ": " + Arrays.toString(row));
}
int[][] first = {{1, 2}, {3, 4}};
int[][] second = {{1, 2}, {3, 4}};
IO.println(Arrays.equals(first, second) + " " + Arrays.deepEquals(first, second));
}
输出:
2 rows, 3 columns
[0, 0, 7]
[[0, 0, 0], [0, 0, 7]]
1: [1]
2: [1, 1]
3: [1, 2, 1]
false true
grid[1][2] 表示第 1 行,再取这一行的第 2 列。grid.length 统计行数,grid[0].length 是第 0 行的长度。
最后一行常让人栽跟头。Arrays.equals 用 == 比较外层元素,也就是各行数组,所以结果是 false。嵌套数组要用 Arrays.deepEquals,就像打印它们要用 Arrays.deepToString 一样。
用数组还是 List?
数组适合大小固定且已知的场景:64 个棋盘格、像素数据,或者 split 返回的 String[]。要存会增减的集合,就用 List。它能自动调整大小,能打印出内容,equals 也能正常工作:
void main() {
String[] fixed = {"Ana", "Ben"};
var names = new ArrayList<String>(List.of(fixed));
names.add("Chen");
IO.println(names + " " + names.size());
List<String> view = Arrays.asList(fixed);
view.set(0, "Zoe");
IO.println(fixed[0]);
view.add("Dev");
}
输出后停止:
[Ana, Ben, Chen] 3
Zoe
Exception in thread "main" java.lang.UnsupportedOperationException
new ArrayList<>(List.of(fixed)) 把数组复制进一个可增长的列表。Arrays.asList 什么也不复制。它把数组包装成一个 List 视图,所以 set 连 fixed[0] 也改了,而 add 会抛出异常,因为数组不能变长。
List<int> 是不允许的,所以数字列表里存的是装箱后的 Integer 对象,而 int[] 直接存数字本身。讲 equals、hashCode 和集合的那一篇会系统地介绍 List、Set 和 Map。
要点
String永远不会改变。单独写一句s.toUpperCase()什么也不做,要把结果存下来。substring(begin, end)在end之前停下。split的参数是正则表达式,而且会丢掉末尾的空字符串。- 用
equals比较字符串。compareTo把大写字母排在小写字母前面。 - 在循环里用
+=,每一轮都要复制整个字符串,代价是 O(n²)。改用StringBuilder,最后调用一次toString。 char是一个 UTF-16 代码单元。一个 emoji 的length()是 2,substring可能把它从中间切开。- 文本块会去掉缩进,以结尾的
"""为准。\连接两行,\s保留行尾空格。 - 数组长度固定。用
Arrays.toString打印,用Arrays.equals比较,大小会变时就用List。
对 String 的每次修改都会产生新的 String,所以不断增长的文本要放在 StringBuilder 里构建。