《Java 编程入门》15.2 String、StringBuilder 与文本块

从借阅凭证和馆藏报表出发,讲清 String 不可变与字符串池、字符串拼接的真正代价、StringBuilder 的使用边界,以及文本块的缩进规则和 formatted;最后用 library 的凭证与报表代码展示什么时候该拼接、什么时候该换模板。

本节目标:能解释 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发