Java 基础体系 · 第 37/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 字符串:不可变、String Pool、文本块、格式化和性能

字符串在 Java 中不是“字符数组的语法包装”,而是一个具有明确值语义、编译期规则、运行时存储策略和多套格式化 API 的标准库类型。要正确使用字符串,至少需要区分以下几层:

  1. String 对象的不可变性;
  2. 字符串字面量、编译期常量和 String Pool;
  3. UTF-16 代码单元与 Unicode 代码点;
  4. 普通字符串字面量和文本块的语法、缩进及转义规则;
  5. +StringBuilderString.formatformatted 等不同操作的语义和代价;
  6. 规范保证、JDK 实现细节和性能测量之间的边界。

本文以 Java 25 LTS 为范围。除特别说明外,示例不依赖预览特性。

1. String 的本质:不可变的字符序列

String 表示一个字符串值。字符串对象创建后,其内容不能通过公开 API 修改:

String s = "Java";
s.concat(" 25");

System.out.println(s); // Java

concat 返回一个新字符串,但示例没有接收返回值,因此原变量 s 仍然引用内容为 "Java" 的对象。

正确写法是:

s = s.concat(" 25");
System.out.println(s); // Java 25

这里发生的是引用重新赋值,而不是修改原有 String 对象:

原对象 "Java"  --concat-->  新对象 "Java 25"
s ────────────────────────> 新对象

1.1 不可变性带来的三个直接结果

结果一:字符串操作通常返回新值

String a = "A";
String b = a + "B";

System.out.println(a); // A
System.out.println(b); // AB

变量 a 的值没有改变。b 可能引用已有对象,也可能引用新创建的对象,但它的字符串值是 "AB"

结果二:字符串可以安全地作为哈希表键

HashMap 依赖键的 equalshashCode。如果键放入映射后内容还能改变,键所在的哈希桶可能与新的哈希值不匹配。

Map<String, Integer> map = new HashMap<>();
String key = new String("user-id");

map.put(key, 1);

System.out.println(map.get(key)); // 1

由于 String 的内容不会变化,hashCode() 的结果也不会因外部操作而改变。这是字符串适合作为 HashMapHashSet 键的根本原因之一。

结果三:多个代码位置可以安全共享同一个字符串对象

如果多个变量引用同一个不可变字符串,那么一个变量无法通过字符串 API 影响另一个变量:

String x = "config";
String y = x;

x = x.toUpperCase();

System.out.println(x); // CONFIG
System.out.println(y); // config

toUpperCase() 产生了新的字符串值,y 仍然引用原值。

1.2 final 引用不等于字符串不可变

下面的 final 限制的是变量不能重新指向其他对象:

final String s = "Java";
// s = "Kotlin"; // 编译错误

但是即使不写 finalString 对象本身也不能被修改:

String s = "Java";
s = "Kotlin"; // 合法:修改的是变量引用,不是原字符串对象

因此需要区分:

  • final String s:引用不能重新赋值;
  • String 不可变:对象内容不能改变;
  • 两者是不同层面的约束。

2. equals==hashCode:值相等与对象相同

字符串比较通常应使用 equals

String a = new String("Java");
String b = new String("Java");

System.out.println(a == b);      // false,通常是两个对象
System.out.println(a.equals(b)); // true,字符串值相同

== 比较的是两个引用是否指向同一个对象;equals 比较的是字符串内容。

对于非空常量,也可以使用常量在前的写法避免左侧引用为 null

String value = null;

System.out.println("Java".equals(value)); // false
// value.equals("Java");                  // NullPointerException

StringhashCode 按字符顺序计算。可以抽象为:

h=s0×31n1+s1×31n2++sn1h = s_0 \times 31^{n-1} + s_1 \times 31^{n-2} + \cdots + s_{n-1}

其中:

  • sis_i 是第 ii 个 UTF-16 代码单元的数值;
  • nn 是字符串的 length()
  • 初始值为 0,每一步相当于 h = 31 * h + s_i

相等字符串必须具有相同的哈希值,但不同字符串可能发生哈希冲突。因此不能用 hashCode() 代替 equals()

3. Java 字符串的 Unicode 边界:char、代码单元和代码点

Java String 的抽象内容由 UTF-16 代码单元组成。char 是一个 16 位代码单元,而不是必然代表一个完整 Unicode 字符。

例如,拉丁字母 A 只需要一个代码单元:

String latin = "A";

System.out.println(latin.length()); // 1
System.out.println(latin.charAt(0)); // A

而某些补充平面字符需要一对代理项:

String emoji = "😀";

System.out.println(emoji.length()); // 2
System.out.println(emoji.codePointCount(0, emoji.length())); // 1

这里:

  • length() 返回 UTF-16 代码单元数量;
  • codePointCount() 返回 Unicode 代码点数量;
  • charAt(0)charAt(1) 分别取出代理项,单独看并不是完整字符。

遍历 Unicode 代码点应使用:

String text = "A😀B";

for (int i = 0; i < text.length(); ) {
    int codePoint = text.codePointAt(i);
    System.out.printf("U+%04X%n", codePoint);
    i += Character.charCount(codePoint);
}

预期输出:

U+0041
U+1F600
U+0042

因此以下代码不一定按用户感知的“字符”截取:

String firstTwo = text.substring(0, 2);

对于 "A😀B",索引 02 可能截断代理项,得到一个包含孤立代理项的字符串。字符串 API 的索引大多以 UTF-16 代码单元为单位,而不是以用户感知字符、字素簇或屏幕显示宽度为单位。

此外,Unicode 等价形式不一定具有相同的 String 值。例如预组装字符和“基础字符 + 组合标记”可能视觉相同,但 equals 返回 false。需要规范化时,应显式使用 java.text.Normalizer,不能假设 String 自动进行 Unicode 规范化。

4. String Pool:字面量为什么可以共享

String Pool 是 JVM 为字符串驻留提供的运行时机制。字符串字面量具有特殊规则:

String a = "Java";
String b = "Java";

System.out.println(a == b); // true

Java 语言规范规定,字符串字面量是 String 实例;相同字符串字面量通常指向同一个驻留实例。规范还定义了编译期常量表达式可以参与这种共享。

String a = "Ja" + "va";
String b = "Java";

System.out.println(a == b); // true

"Ja" + "va" 的两个操作数都是字符串字面量,连接结果是编译期常量表达式,因此结果与 "Java" 对应同一个驻留字符串。

但运行时计算的结果不能仅凭内容相同就推断对象相同:

String prefix = "Ja";
String a = prefix + "va";
String b = "Java";

System.out.println(a.equals(b)); // true
System.out.println(a == b);      // 通常为 false

此时 prefix 是变量,连接发生在运行时。要显式获得驻留引用,可以调用 intern()

String a = new String("Java");
String b = "Java";

System.out.println(a == b);          // false
System.out.println(a.intern() == b); // true

intern() 的语义是:返回与该字符串值相同的规范化表示;如果池中还没有对应值,则将该值加入池并返回池中的引用。

4.1 常量表达式与运行时连接的区别

下面三种代码的性质不同:

String x = "a" + "b";                 // 编译期常量
String y = "a" + Integer.toString(1); // 运行时计算
String z = new String("ab");          // 显式创建对象

可以按以下步骤判断:

  1. 是否全部由编译器已知的常量表达式组成?
  2. 是否包含方法调用、变量读取或运行时对象?
  3. 是否显式调用了 new String(...)

只有第一类可以按编译期常量规则处理。第二类的结果值可能等于某个池中字符串,但不自动意味着引用相同。第三类明确创建了一个新的 String 对象,尽管其内容可能来自池中的字面量。

4.2 String Pool 不是“所有字符串都自动去重”

以下对象不会因为内容相同而自动合并为同一引用:

String a = new String("Java");
String b = new String("Java");

System.out.println(a == b); // false

new String(String) 几乎没有应用层价值,通常只会增加对象创建和内存占用。若只是需要相同的字符串值,应直接使用字面量或已有引用。

intern() 也不是通用的性能优化工具。大量不同字符串驻留后,字符串会被长期保留,可能增加池相关内存压力。是否驻留应基于实际的重复率、对象生命周期和内存分析决定,而不是因为“池会节省内存”就普遍调用。

String Pool 的具体存储位置、垃圾回收细节和内部实现属于 JVM 实现问题;现代 HotSpot 中它不应再被简单描述为永久代的一部分。语言规范保证的是字符串字面量和驻留语义,不保证某个具体 JVM 的内部数据结构。

5. 字符串字面量、转义和文本块

普通字符串字面量用双引号包围:

String s = "line 1\nline 2";

常见转义包括:

写法 含义
\n 换行
\r 回车
\t 制表符
\" 双引号
\\ 反斜杠
\b 退格
\f 换页

Java 15 引入的文本块用于表达多行字符串,Java 25 中仍是标准语言能力:

String json = """
        {
          "name": "Java",
          "version": 25
        }
        """;

System.out.print(json);

文本块的价值不只是少写几个 \n,而是让源代码中的布局接近字符串的逻辑内容。

5.1 文本块的处理顺序

理解文本块时,需要区分三个概念:

  1. 起始和结束分隔符;
  2. 行终止符;
  3. 每行的缩进和转义。

例如:

String text = """
        alpha
          beta
        gamma
        """;

公共缩进会被移除,结果等价于:

alpha
  beta
gamma

文本块会去除由源代码布局引入的 incidental indentation,即“附带缩进”。它不是简单地删除每一行所有前导空格,而是根据内容整体计算公共缩进。因此 beta 相对多出的两个空格会保留。

如果确实需要保留行尾空格,可以使用 \s

String text = """
        A\s
        B
        """;

这里 \s 表示一个空格,并且可以防止行尾空格在处理过程中被误认为只是源代码排版产生的空白。

文本块中的反斜杠还可以抑制换行:

String text = """
        first \
        second
        """;

结果是:

first second

反斜杠位于行尾时不会产生换行;它与普通的 \n 不是同一概念。

5.2 文本块仍然是编译期字符串值

文本块不是运行时模板,也不会自动替换变量:

String name = "Ada";

String message = """
        Hello, name
        """;

System.out.print(message); // Hello, name

如果需要插值,必须使用格式化 API:

String message = """
        Hello, %s!
        """.formatted(name);

System.out.print(message); // Hello, Ada!

因此文本块解决的是多行字面量的可读性问题;格式化解决的是值插入问题,二者职责不同。

5.3 换行符的可移植性

文本块和普通字符串中的源代码行终止符会经过语言规定的处理。Java 字符串内部通常使用 \n 表示逻辑换行,但如果目标协议要求特定换行符,例如 Windows 文本协议需要 \r\n,应显式生成或替换:

String payload = """
        line1
        line2
        """.replace("\n", "\r\n");

不能仅依据当前编辑器显示的换行样式推断最终字符串内容。

6. 字符串格式化:+formattedFormatter

6.1 简单连接使用 +

String user = "Ada";
int count = 3;

String message = "user=" + user + ", count=" + count;
System.out.println(message); // user=Ada, count=3

连接过程中,非字符串操作数会通过字符串转换参与结果构造。对于 null 引用,字符串连接通常会得到 "null"

Object value = null;
System.out.println("value=" + value); // value=null

这有时很方便,但也可能隐藏错误:业务上如果 null 不应出现,直接连接会把错误转换成看似正常的文本。

+ 还具有左结合性:

String result = "value=" + 1 + 2;
System.out.println(result); // value=12

第一步是 "value=" + 1,结果已经是字符串 "value=1";第二步再连接 2。如果希望先计算数值,必须加括号:

String result = "value=" + (1 + 2);
System.out.println(result); // value=3

6.2 String.formatted

formatted 以当前字符串作为格式模板:

String name = "Ada";
int score = 98;

String result = "name=%s, score=%d".formatted(name, score);
System.out.println(result); // name=Ada, score=98

常用格式说明符包括:

说明符 典型用途
%s 字符串或一般对象
%d 十进制整数
%f 浮点数
%b 布尔值
%n 平台相关换行
%tF 日期,例如 2025-01-02

宽度、精度和参数索引也属于 Formatter 语法:

String result = "%2$s scored %1$d points".formatted(98, "Ada");
System.out.println(result); // Ada scored 98 points

格式错误会在运行时暴露:

String result = "%d".formatted("not an integer");
// java.util.IllegalFormatConversionException

缺少参数、非法格式说明符等情况也会抛出 IllegalFormatException 的相应子类。因此格式字符串如果来自外部输入,不应直接当作可信模板使用。

6.3 String.format 与 Locale

静态方法适合格式模板和参数分开表达:

String result = String.format("name=%s, score=%d", "Ada", 98);

需要稳定的区域设置时,应显式传入 Locale

String price = String.format(Locale.US, "%.2f", 1234.5);
System.out.println(price); // 1234.50

对于面向用户的内容,区域设置可能应来自用户或请求上下文;对于日志、协议、CSV 或机器解析数据,通常应使用明确的 Locale,避免服务器默认区域设置变化导致小数点、月份名称或大小写规则变化。

String.formatted(...) 使用默认格式化区域设置;如果必须控制 Locale,应使用 String.format(Locale, ...) 或显式创建 Formatter

6.4 StringBuilder 与循环拼接

不可变字符串在循环中反复连接,逻辑上会产生多个中间值:

String result = "";
for (int i = 0; i < 100_000; i++) {
    result += i;
}

kk 次连接可能需要处理此前累计的约 kk 个字符。若把所有中间结果都视为独立构造,累计字符复制量接近:

1+2++n=n(n+1)21 + 2 + \cdots + n = \frac{n(n+1)}{2}

这解释了为什么循环拼接可能呈现接近二次方的增长。

应改为:

StringBuilder builder = new StringBuilder();

for (int i = 0; i < 100_000; i++) {
    if (i > 0) {
        builder.append(',');
    }
    builder.append(i);
}

String result = builder.toString();

数据流变为:

数值 1 ─┐
数值 2 ─┼─> StringBuilder 的可变缓冲区 ──> 最终 String
数值 3 ─┘

StringBuilder 适合单线程构造;StringBuffer 提供同步方法,但同步本身有代价,只有确实需要共享可变字符缓冲区时才选择它。更高层的集合连接可以使用 StringJoinerCollectors.joining()

String result = IntStream.rangeClosed(1, 3)
        .mapToObj(Integer::toString)
        .collect(Collectors.joining(", "));

System.out.println(result); // 1, 2, 3

6.5 现代 Java 中 + 的编译实现不能简单等同于手写 StringBuilder

Java 语言规范规定的是连接结果和求值语义,不要求编译器必须生成某一种字节码。现代 JDK 通常会使用 invokedynamic 字符串连接机制,并由运行时选择适合的拼接策略。

因此:

  • 短小、非循环的表达式使用 + 通常最清晰;
  • 循环累积仍应使用 StringBuilder 或收集后连接;
  • 不能仅根据旧版本反编译经验断言每个 + 都必然生成固定形式的 StringBuilder
  • 也不能因为现代 JIT 可能优化,就把所有循环拼接都视为无成本。

7. 常用字符串操作的语义边界

7.1 substring 使用半开区间

String s = "Java25";
System.out.println(s.substring(0, 4)); // Java

范围是 [beginIndex, endIndex),包含起点,不包含终点。索引越界会抛出 StringIndexOutOfBoundsException

7.2 replacereplaceAll 不是一回事

String s = "a.b";

System.out.println(s.replace(".", "-"));    // a-b
System.out.println(s.replaceAll("\\.", "-")); // a-b

replace(CharSequence, CharSequence) 按字面值替换;replaceAll 使用正则表达式。. 在正则表达式中表示任意字符,因此下面代码含义完全不同:

System.out.println(s.replaceAll(".", "-")); // ---

正则表达式中的反斜杠还需要经过 Java 字符串转义层,导致 \\. 才表示正则中的字面量点。

7.3 trimstrip 和空白定义

trim() 基于较早的字符值规则;strip() 使用 Unicode 空白判断,更适合现代文本处理:

String s = "\u2003Java\u2003"; // EM SPACE

System.out.println(s.trim().equals("Java"));  // 可能为 false
System.out.println(s.strip().equals("Java")); // true

如果处理的是协议中的固定 ASCII 空格,应根据协议定义选择方法;不能把“Unicode 空白”和“ASCII 空格”混为一谈。

8. 字符串的内存与实现:规范不等于 HotSpot 细节

从 Java API 和语言规范角度,应把 String 当作不可变字符序列使用。具体对象内部如何存储,不属于应用代码可以依赖的语言语义。

在现代 HotSpot 中,字符串通常采用 Compact Strings 思路:

  • 如果内容可以用 Latin-1 表示,内部可能使用更紧凑的单字节存储;
  • 否则使用 UTF-16 形式;
  • 具体字段、编码标记和对象布局属于实现细节。

这并不改变以下事实:

String s = "😀";
System.out.println(s.length()); // 2

即使底层进行了压缩,length() 的定义仍然基于 UTF-16 代码单元。

字符串对象的成本不仅包括字符存储,还包括对象头、引用、哈希缓存以及临时中间对象。实际内存占用取决于:

  • 字符串长度和字符范围;
  • 是否被多个对象共享;
  • 是否处于 String Pool;
  • GC 后的对象存活时间;
  • JVM 的具体实现和压缩指针配置。

因此不能用一个固定的“每字符多少字节”公式准确估算所有 Java 25 字符串。

9. 如何正确测量字符串性能

仅用 System.nanoTime() 包围一次操作,通常不能得到可靠结论:

long start = System.nanoTime();
String result = buildText();
long elapsed = System.nanoTime() - start;

问题包括:

  • JIT 编译可能在测量期间发生;
  • 预热不足;
  • 死代码消除可能移除未使用的结果;
  • GC 会引入偶发暂停;
  • 输入规模、字符分布和最终结果长度会改变结论。

应使用 JMH 等基准测试工具,并确保:

  1. 预热多个迭代;
  2. 使用多个测量迭代;
  3. 通过返回值或 Blackhole 消费结果;
  4. 分别测试小输入、大输入、循环连接和格式化;
  5. 记录 JDK、JVM 参数、处理器和输入数据。

一个值得测量的对比不是“哪个 API 永远最快”,而是具体场景:

@Benchmark
public String plus() {
    return "id=" + id + ", name=" + name;
}

@Benchmark
public String builder() {
    return new StringBuilder()
            .append("id=").append(id)
            .append(", name=").append(name)
            .toString();
}

@Benchmark
public String format() {
    return "id=%d, name=%s".formatted(id, name);
}

在短小表达式中,+ 的可读性和性能通常都足够;Formatter 为格式语法、Locale 和类型检查提供了能力,但通常比简单连接承担更多处理。这个“通常”不是规范保证,最终结论必须以目标 JDK 和实际输入的基准为准。

10. 生产代码中的故障表现与诊断

10.1 把 == 当成内容比较

失败表现:

if (requestValue == "READY") {
    // 某些情况下进入,某些情况下不进入
}

字面量、常量折叠和池化可能让测试看起来正常,但来自网络、文件或数据库的字符串通常不是同一个对象。应改为:

if ("READY".equals(requestValue)) {
    // 按内容判断,且 requestValue 为 null 时不会抛异常
}

10.2 使用 intern() 试图修复内存问题

失败表现可能是:

  • 程序保留大量低频、唯一字符串;
  • 长时间运行后内存压力增大;
  • GC 或驻留相关行为变差。

诊断时应先用堆转储查看字符串数量、重复率和引用链,再决定是否需要应用层去重。intern() 适用于确实存在大量重复、生命周期长且集合规模可控的值,不适合作为盲目的全局去重机制。

10.3 格式化结果依赖默认 Locale

失败表现:

String value = String.format("%.2f", 1234.5);

在不同默认区域设置下,结果可能使用不同的小数分隔符。日志、签名输入、协议字段和机器解析文本应显式指定 Locale:

String value = String.format(Locale.ROOT, "%.2f", 1234.5);

10.4 截断代理项

失败表现是文本显示为替换字符或出现非法的半个补充字符。处理用户可见字符时,应先明确需求:

  • 只限制 Java length():按 UTF-16 代码单元;
  • 按 Unicode 代码点限制:使用 codePointAtoffsetByCodePoints
  • 按用户感知字符限制:还需要字素簇级别的文本处理,不能只调用 substring

11. 选择哪种字符串工具

可以按操作的性质选择:

场景 合适工具
固定短文本 字符串字面量
多行固定内容 文本块
少量变量插入 +
需要宽度、精度、类型格式 formattedString.format
需要稳定区域设置 String.format(Locale, ...)
循环累积文本 StringBuilder
集合带分隔符连接 StringJoinerCollectors.joining()
需要可变字符缓冲区 StringBuilder
多线程共享并修改缓冲区 谨慎考虑 StringBuffer,或改用外部同步设计
内容相等判断 equals
需要处理 null 的内容判断 "constant".equals(value)
Unicode 代码点遍历 codePointAtCharacter.charCount

核心原则不是“字符串永远慢”或“String Pool 永远节省内存”,而是根据语义选择工具:String 负责不可变值,文本块负责多行字面量,格式化 API 负责模板与表示规则,StringBuilder 负责高效构造可变中间结果,而 String Pool 只解决受控的字符串驻留问题。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。