本节目标:能解释
String为什么不可变、==和equals在什么情况下结果不同,知道循环拼接为什么慢、什么时候用StringBuilder,并能用文本块写出可读的多行模板。
适用版本:JDK 25(对照 JDK 21);文本块从 JDK 15 正式提供,String.formatted从 JDK 15 起可用。
上一节留下的问题
library 现在能算出借期和罚金,接下来要把结果展示给人看:借阅凭证要打印读者、书名、借出日、应还日、续借次数和罚金;馆藏报表要按作者分组统计。这些输出都是多行文本,既有固定格式,又有动态值。
最直接的写法是字符串拼接:
String receipt = "读者:" + member.name() + "\n"
+ "图书:" + book.title() + "\n"
+ "应还:" + loan.dueOn() + "\n";
能跑,但一旦行数多起来,引号和换行会把代码弄得很碎。如果这段拼接还在循环里,性能也会出问题。这一节先把 String 的底层行为讲清楚,再决定用拼接、StringBuilder 还是文本块。
String 不可变与字符串池
String 是不可变类。concat、replace、substring 都返回新对象,原对象不变。这带来两个直接后果:
- 可以安全共享。 把字符串传给方法、放进集合、被多个线程读取,都不需要复制。
- 每次修改都产生新对象。 循环里
s += x会不断创建新字符串。
字符串字面量会进入字符串池(string pool)。相同的字面量通常指向同一个对象:
String title = "Effective Java";
String same = "Effective Java";
String built = "Effective " + "Java";
String prefix = "Effective ";
String runtime = prefix + "Java";
System.out.println("same ref = " + (title == same));
System.out.println("built ref = " + (title == built));
System.out.println("runtime ref= " + (title == runtime));
System.out.println("equals = " + title.equals(runtime));
本机 JDK 25 输出:
same ref = true
built ref = true
runtime ref= false
equals = true
四个结果分别说明:
title == same为true,因为两个字面量都指向池里的同一个对象。"Effective " + "Java"是编译期常量,编译器直接把它折叠成"Effective Java",所以也进池。prefix + "Java"里prefix是局部变量,拼接发生在运行期,结果是新对象,所以==为false。equals比较内容,总是为true。
结论只有一句:比较字符串内容用 equals,不要用 ==。 == 比较的是引用,是否相等取决于对象从哪来、编译器做了哪些优化,不能作为业务判断依据。
String 的不可变也让它可以安全地做 HashMap 的 key。如果 key 的内容会变,哈希值就变了,查不到原来的桶。
拼接的代价与 StringBuilder
看一个循环:
String result = "";
for (Book book : books) {
result += book.title() + "\n";
}
每次 += 都创建一个新 String,把旧内容复制进去再追加。n 本书大约复制 1 + 2 + … + n 次字符,复杂度是 O(n²)。书少时看不出来,几万行报表就会明显变慢,还会产生大量垃圾对象。
StringBuilder 内部维护一个可变的 char 数组,追加时只在容量不够时扩容:
StringBuilder report = new StringBuilder(32);
report.append("馆藏:").append(books.size()).append(" 本").append('\n');
report.append("更新时间:").append(LocalDate.of(2026, 10, 14));
System.out.println(report);
本机输出:
馆藏:3 本
更新时间:2026-10-14
new StringBuilder(32) 预设容量,减少扩容次数。容量不是必须写,但如果你大致知道结果长度,预设能少几次数组复制。append 返回 this,所以可以链式调用。
什么时候用哪个:
| 场景 | 选择 | 原因 |
|---|---|---|
| 固定几个值拼一次 | + 或 String.join | 编译器会优化,代码更短 |
| 循环里累加 | StringBuilder | 避免 O(n²) 复制 |
| 拼接集合 | Collectors.joining | 表达意图清楚,内部也是 StringBuilder |
| 多行固定模板 | 文本块 + formatted | 保留排版,减少转义 |
JDK 9 之后,单条 + 表达式在编译期会被转成 invokedynamic 加 StringConcatFactory,并不一定比 StringBuilder 慢。但循环里的 += 仍然是每次新建对象,不会被优化成一次 StringBuilder。所以规则可以简化为:一次性拼接随便写,循环拼接必须用 StringBuilder 或 Collectors.joining。
常用文本方法
String 有一批方法容易混淆,先分清:
String text = " Java 25 LTS ";
System.out.println("strip = [" + text.strip() + "]");
System.out.println("repeat = " + "ab".repeat(3));
System.out.println("isBlank = " + " \t\n".isBlank());
System.out.println("lines = " + "a\nb\nc".lines().count());
System.out.println("codePoints = " + "图书".codePoints().count());
本机输出:
strip = [Java 25 LTS]
repeat = ababab
isBlank = true
lines = 3
codePoints = 2
strip()去掉首尾的 Unicode 空白,trim()只去掉码点小于等于U+0020的字符。新代码用strip()。isBlank()判断是否只包含空白,空字符串也返回true。lines()按\n、\r\n、\r拆行,返回Stream<String>,适合统计行数或逐行处理。chars()返回 UTF-16 代码单元,codePoints()返回真正的码点。处理 emoji 时用后者。
格式化用 formatted 或 String.format:
String fine = "¥%d.%02d".formatted(cents / 100, cents % 100);
String line = String.format("| %-20s | %5d |", title, year);
%02d 表示至少两位、不足补零;%-20s 表示左对齐、宽度 20。金额、编号、对齐表格都靠这些格式符。注意 String.format 使用默认 Locale,在某些地区 %d 会使用本地数字字符,%f 的小数点也可能不同。要稳定输出用 String.format(Locale.ROOT, ...)。
文本块:多行模板不再堆转义
文本块用三个双引号开始,换行后写内容,再以三个双引号结束:
String json = """
{"isbn":"%s","title":"%s","year":%d}"""
.formatted(book.isbn(), book.title(), book.year());
几个规则:
- 开头
"""后面必须换行,不能在同一行写内容。 - 结束
"""的位置决定缩进。 编译器会去掉每行共同的前导空白,去掉的宽度由「内容行和结束行中最小的缩进」决定。 - 结束符在同一行时没有结尾换行;结束符单独一行时,字符串末尾有一个
\n。 - 文本块里的
"不需要转义,\仍然需要;需要保留行尾空格时用\s,需要把长行折成两行但不要换行时用\。
library 的借阅凭证就是典型的多行模板:
public static String render(Book book, Member member, Loan loan, long fineCents) {
String fine = "¥%d.%02d".formatted(fineCents / 100, fineCents % 100);
return """
================================
借阅凭证
--------------------------------
读者:%s(%s)
图书:%s
作者:%s
ISBN:%s
借出:%s
应还:%s
续借:%d 次
罚金:%s
================================
"""
.formatted(
member.name(),
member.id(),
book.title(),
book.author(),
book.isbn(),
loan.borrowedOn().format(DATE),
loan.dueOn().format(DATE),
loan.renewals(),
fine);
}
本机渲染结果:
================================
借阅凭证
--------------------------------
读者:张三(M-1)
图书:Effective Java
作者:Joshua Bloch
ISBN:9780134685991
借出:2026-10-14
应还:2026-10-28
续借:0 次
罚金:¥0.00
================================
对比字符串拼接版本,文本块保留了表格形状,改一行不用数引号,也不会漏掉 \n。代价是动态值仍然要经过 formatted,而且 % 在模板里是特殊字符;如果模板本身要输出百分号,得写 %%。
在 library 里落地
library 的文本输出分成两类:
- 凭证:固定模板 + 少量变量,用文本块。
- 报表:行数随数据变化,用
StringBuilder或Collectors.joining。
按作者统计的报表:
public static String byAuthor(List<Book> books) {
Map<String, List<Book>> grouped = books.stream()
.collect(Collectors.groupingBy(
Book::author, TreeMap::new, Collectors.toList()));
StringBuilder report = new StringBuilder();
report.append("馆藏按作者统计\n");
for (Map.Entry<String, List<Book>> entry : grouped.entrySet()) {
report.append("- ")
.append(entry.getKey())
.append(":")
.append(entry.getValue().size())
.append(" 本\n");
}
return report.toString();
}
本机输出:
馆藏按作者统计
- Joshua Bloch:1 本
- Randal E. Bryant:1 本
- Robert C. Martin:1 本
- Thomas H. Cormen:1 本
书名列表用 Collectors.joining:
public static String titles(List<Book> books) {
return books.stream()
.map(Book::title)
.sorted()
.collect(Collectors.joining(" / "));
}
Clean Code / Effective Java / Introduction to Algorithms / 深入理解计算机系统
排序结果里中文书名排在最后,因为 String.compareTo 按 UTF-16 代码单元比较,不是按拼音。要按中文拼音排序,用 Collator.getInstance(Locale.CHINA);要按业务自定义顺序,传 Comparator。这一点第 10 章讲过,文本处理时仍然适用。
常见错误与边界
用 == 比较字符串。 字面量、常量折叠、intern() 会让结果时真时假。内容比较一律 equals,忽略大小写用 equalsIgnoreCase。
循环里 +=。 每次创建新对象,复杂度 O(n²)。用 StringBuilder,或者 Collectors.joining。
String.format 不指定 Locale。 默认 Locale 会影响数字分隔符和小数点。面向机器解析的输出用 Locale.ROOT。
split(".")。 split 的参数是正则,. 匹配任意字符,结果会把每个字符拆开。要按字面量点拆分,写 split("\\.");要按字面量竖线拆分,写 split("\\|")。
replace 和 replaceAll 混用。 replace 按字面量替换,replaceAll 按正则替换。替换内容里出现 $ 或 \ 时,replaceAll 会把它们当分组引用,需要用 Matcher.quoteReplacement。
文本块缩进对不齐。 结束 """ 的缩进参与「共同缩进」计算。如果结束符比内容更靠左,内容会保留多余空格;如果更靠右,可能编译失败。把结束符和内容左边界对齐,输出最可预测。
strip() 和 trim() 混用。 trim() 不处理全角空格、不换行空格等 Unicode 空白。新代码用 strip()。
用 String 拼 JSON/CSV 却不转义。 书名里有引号、逗号、换行时,手写拼接会破坏格式。CSV 用 Csv.row,JSON 用 Json.escape,不要直接 +。
小结
String不可变,字面量进字符串池;内容比较用equals,不要用==。- 编译期常量拼接会被折叠,运行期拼接产生新对象;循环拼接用
StringBuilder或Collectors.joining。 strip处理 Unicode 空白,isBlank判断空白串,lines按行拆分,codePoints处理 emoji。- 文本块保留多行排版,缩进由内容与结束符共同决定,动态值用
formatted注入。 - 固定模板用文本块,行数可变的报表用
StringBuilder,集合拼接用joining。 - 格式化数字和金额时显式指定
Locale,避免默认地区影响输出。 - 手写 CSV/JSON 前先想好转义,书名里的逗号和引号会破坏格式。
文本能拼好了,接下来要判断一段文本「是否符合规则」:ISBN 格式、搜索语法、日期串。下一节讲正则表达式和 Pattern/Matcher。
阅读导航:上一节:15.1 java.time:日期、时刻与时区 · 下一节:15.3 正则表达式与 Pattern/Matcher 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。