# 字符串

Source: https://codewiki.com/zh/java/strings/

> - **what**: `String` 是不可变的 UTF-16 文本值。看似修改字符串的方法都会返回结果，原对象不会改变。
> - **trap**: `==` 比较引用身份，`length()` 统计 UTF-16 代码单元；默认字符集、默认语言环境和正则分割还会让代码随数据或机器改变行为。
> - **fix**: 用 `equals` 比较内容，先明确文本边界单位，并在编码与大小写操作中显式传入 `UTF_8` 或合适的 `Locale`。循环构建文本时使用 `StringBuilder`。

## 是什么，为什么存在

`String` 是 Java 表示文本的标准类。一个字符串包含固定的 UTF-16 代码单元序列；创建后，它的长度和内容都不会变化。字符串不是基本类型，但字面量语法、`+` 拼接和编译器常量折叠让它像语言内建值一样使用。

不可变性让一个字符串引用可以安全共享。方法能够把字符串作为映射键、类名、路径片段或协议字段传给其他代码，而不用担心接收方改写同一个对象。`hashCode()` 的结果也能在对象内部缓存，因为内容不会在计算后变化。

你会在源码字面量、命令行参数、HTTP 字段、文件内容、数据库值和日志中遇到字符串。可靠的字符串代码不只要会查找与拼接，还要明确内容相等、`null`、Unicode 边界、字符编码和语言环境的契约。

### 不可变值与变量重新赋值

不可变的是 `String` 对象，不是保存引用的变量。执行 `label = label.strip()` 时，变量 `label` 改为引用返回结果；原字符串仍保持不变。若忽略 `strip()`、`replace()` 或 `toUpperCase()` 的返回值，操作结果也就丢失了。

`String` 类是 `final`，公开 API 不提供原地修改字符的方法。构造器从可变的字符或字节输入建立字符串时，也不会把调用方可修改的存储直接暴露为字符串内容。

不可变不等于永远复用同一个对象。两段内容相同的字符串可以是两个不同对象，而一次没有实际变化的库调用也可能返回原对象。业务逻辑应依赖内容与 API 契约，不应依赖这种对象复用。

### 三种文本边界

Java 的 `char` 是一个UTF-16 代码单元（UTF-16 code unit）。因此，`String.length()` 返回代码单元数，`charAt()` 和 `substring()` 的索引也使用代码单元偏移。基本多文种平面之外的字符由代理对表示，会占两个索引位置。

一个Unicode 码点（Unicode code point）可能占一个或两个 UTF-16 代码单元。`codePointAt()`、`codePointCount()`、`offsetByCodePoints()` 和 `codePoints()` 可以在码点层面工作，但它们不会自动执行文本规范化。

用户看到的一个字符还可能是由多个码点组成的字素簇（grapheme cluster），例如字母与组合音标或一段 emoji 序列。若产品要求按光标移动或界面字符截断，码点计数仍然不够，需要使用符合目标 Unicode 分段规则的文本边界工具。

### 选择合适的文本 API

少量固定片段可以直接使用 `+`，集合已有元素可用 `String.join()` 或流的 `Collectors.joining()`。循环逐步追加内容时，使用 `StringBuilder` 能明确表达一个可变构建过程，最后再调用 `toString()` 得到不可变结果。

`StringBuffer` 也提供同步的可变缓冲区，但单个方法同步并不能让一组复合操作自动具备业务原子性。局部构建器不需要跨线程共享，通常使用 `StringBuilder`；确实共享可变文本时，应先重新审视所有权与锁定范围。

外部字节与文本之间的转换必须经过字符集。协议规定 UTF-8 时，使用 `StandardCharsets.UTF_8`，不要依赖进程默认字符集。大小写转换同样需要契约：机器可读标识符通常使用 `Locale.ROOT`，面向用户的自然语言则使用对应语言环境。

## 工作原理

### 内容相等与引用身份

`equals()` 比较两个字符串是否包含相同的代码单元序列，`compareTo()` 按字典序比较序列。`Objects.equals(left, right)` 在其中一个引用可能为 `null` 时提供空安全的内容比较。

`==` 比较的是引用身份（reference identity），也就是两个表达式是否指向同一个对象。字符串池会让某些内容相同的表达式碰巧具有相同引用，因此用 `==` 编写的缺陷可能通过只含字面量的测试，随后在文件、网络或构造器产生的字符串上失败。

`equalsIgnoreCase()` 执行与语言环境无关的逐字符比较，不是完整的自然语言搜索或 Unicode 规范化方案。需要登录名、标签或搜索键时，应先写清楚大小写、规范化和允许字符的领域规则，再选择相应 API。

### 字面量、常量表达式与字符串池

Java 为字符串字面量和文本块维护规范化实例。规范要求内容相同的字面量引用同一个实例；由常量表达式计算出的字符串也会被驻留。这个机制通常称为字符串池（string pool）。

运行时得到的字符串不会因为内容相同就自动与池中实例共享引用。`new String("ready")` 明确构造一个不同对象，而解析输入、切片或大小写转换是否复用已有实例不是调用方可依赖的契约。

`intern()` 返回内容相同字符串的规范化池实例，并在没有对应实例时建立一个。它适合经过测量且重复度高、生命周期明确的数据集，不是普通内容比较的替代品。无条件驻留用户输入会把内存与全局共享策略绑定在一起。

### 操作返回新结果

`substring()`、`replace()`、`strip()`、`repeat()` 和大小写转换都返回字符串结果。结果可能是新对象，也可能在无需改变时复用接收者；API 保证的是内容，不是分配次数。

这种设计让链式调用自然成立，例如 `raw.strip().toUpperCase(Locale.ROOT)`。链中的每一步仍有自己的语义：`strip()` 使用 Unicode 空白定义，`toUpperCase()` 可能改变长度，而索引应根据当前结果重新计算。

`StringBuilder` 采用相反的模型。`append()`、`insert()`、`delete()` 和 `setCharAt()` 修改构建器状态，多数方法返回同一个构建器以便链式调用。`toString()` 产生独立的 `String` 快照，之后继续修改构建器不会改写已经返回的字符串。

### 拼接的编译与运行时语义

只由常量组成的字符串拼接可以在编译期折叠并驻留。包含运行时值的 `+` 表达式会按从左到右的顺序求值，把操作数转换为字符串，再产生新的字符串结果。具体使用 `invokedynamic`、内部辅助类还是消除临时分配，属于编译器与运行时实现细节。

一条短表达式中的 `+` 通常最清楚。问题出现在循环里的累积赋值：每轮都必须保留先前内容，并产生下一轮结果，可能反复复制不断增长的前缀。显式 `StringBuilder` 把多次追加集中到一个可变缓冲区中。

`String.valueOf(object)` 对 `null` 返回文本 `"null"`，而直接调用 `object.toString()` 会抛出 `NullPointerException`。两种行为都可能不符合领域要求，所以生成消息前应决定缺失值是拒绝、跳过、留空还是显示占位符。

### 查找、分割与替换

`indexOf()` 和 `contains()` 查找字面内容，`matches()`、`split()`、`replaceFirst()` 与 `replaceAll()` 则解释正则表达式。方法名相近，但输入语言不同；把用户提供的字面文本送入正则 API，会把 `.`、`[` 或 `*` 等字符误当成语法。

需要字面替换时使用 `replace(CharSequence, CharSequence)`。确实需要正则时，模式中的字面片段用 `Pattern.quote()` 保护，替换侧的字面文本用 `Matcher.quoteReplacement()` 保护，因为模式语法与替换语法拥有不同的元字符。

`split(regex)` 等价于限制为零的分割，会删除结果末尾的空字符串。CSV、定长记录或协议字段可能把末尾空值当成真实列，这时要使用 `split(regex, -1)`；完整 CSV 还包括引号、换行和转义规则，不应只靠一次正则分割实现。

### 字节、代码单元与码点

字符串没有“当前编码”属性；它在 Java API 中表现为 UTF-16 代码单元序列。`getBytes(charset)` 才把文本编码为字节，`new String(bytes, charset)` 则按字符集解码字节。两端字符集不一致会破坏内容，即使代码没有抛出异常。

`substring(begin, end)` 使用半开代码单元区间。端点越界会抛出 `StringIndexOutOfBoundsException`，端点落在合法范围内却位于代理对中间时不会自动报错，而会产生含未配对代理项的字符串。

按码点截取时，应先用 `codePointCount()` 限制需要的码点数量，再用 `offsetByCodePoints()` 把码点数量转换为代码单元端点。按字素簇截取则要使用文本分段器，不能继续套用码点数量。

## 示例

### 不可变性、内容相等与身份

第一个示例把三项规则放在一起：转换方法返回新值，`equals()` 比较内容，`==` 比较对象身份。字面量常量折叠会复用池中实例，但显式构造的副本不会与它共享身份。

<!-- quick -->

```java
// file: StringValues.java
import java.util.Locale;

public class StringValues {
    public static void main(String[] args) {
        String status = "draft";
        status.toUpperCase(Locale.ROOT); // 忽略了返回值

        String literal = "ready";
        String folded = "re" + "ady";
        String copied = new String(literal);

        System.out.println("status=" + status);
        System.out.println("upper=" + status.toUpperCase(Locale.ROOT));
        System.out.println("sameContent=" + literal.equals(copied));
        System.out.println("literalIdentity=" + (literal == folded));
        System.out.println("copyIdentity=" + (literal == copied));
    }
}
```

```text
status=draft
upper=DRAFT
sameContent=true
literalIdentity=true
copyIdentity=false
```

<!-- /quick -->

`status` 仍然是 `"draft"`，因为第一次大小写转换的结果没有保存。`folded` 来自常量表达式，所以与字面量共享规范化实例；`copied` 内容相等，但引用不同。

不要把这个输出当成鼓励使用身份比较。把 `folded` 改为运行时输入后，身份结果就不再由内容决定；只有 `equals()` 表达了这里真正需要的值语义。

### 规范化外部文本

外部输入经常同时涉及空白、大小写、分隔符和编码。下面的程序显式选择 Unicode 空白处理、与语言无关的大小写规则、保留尾部空字段的分割方式，以及 UTF-8 编解码。

```java
// file: NormalizeInput.java
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Locale;

public class NormalizeInput {
    public static void main(String[] args) {
        String rawStatus = "\u2003 pending \u2003";
        String status = rawStatus.strip().toUpperCase(Locale.ROOT);

        String record = "A17,READY,";
        String[] fields = record.split(",", -1); // 保留结尾空字段

        String city = "München";
        byte[] encoded = city.getBytes(StandardCharsets.UTF_8);
        String decoded = new String(encoded, StandardCharsets.UTF_8);

        System.out.println("status=" + status);
        System.out.println("fields=" + Arrays.toString(fields));
        System.out.println("bytes=" + encoded.length);
        System.out.println("roundTrip=" + city.equals(decoded));
    }
}
```

```text
status=PENDING
fields=[A17, READY, ]
bytes=8
roundTrip=true
```

`strip()` 能去掉示例中的 Unicode 空白，而旧式 `trim()` 只处理码点不大于 U+0020 的字符。`split(",", -1)` 使用负数限制保留尾部空字段；省略限制时，结果会丢掉该字段。

UTF-8 往返成功，是因为编码与解码显式使用同一字符集。真实系统还要决定畸形字节是替换还是报错；需要控制该策略时，应使用 `CharsetDecoder`，而不是只调用便利构造器。

### 按码点截取前缀

`length()` 与码点数量在纯 BMP 文本上经常相等，容易掩盖索引单位错误。这个示例包含一个代理对和一个组合音标，并把码点上限转换成安全的代码单元端点。

```java
// file: CodePointPrefix.java
public class CodePointPrefix {
    static String prefixByCodePoints(String text, int limit) {
        if (limit < 0) {
            throw new IllegalArgumentException("limit must be non-negative");
        }

        int available = text.codePointCount(0, text.length());
        int end = text.offsetByCodePoints(0, Math.min(limit, available));
        return text.substring(0, end);
    }

    public static void main(String[] args) {
        String label = "A🚗e\u0301";
        String broken = label.substring(0, 2);

        System.out.println("units=" + label.length());
        System.out.println("codePoints="
                + label.codePointCount(0, label.length()));
        System.out.println("brokenHighSurrogate="
                + Character.isHighSurrogate(broken.charAt(1)));
        System.out.println("prefix=" + prefixByCodePoints(label, 2));
    }
}
```

```text
units=5
codePoints=4
brokenHighSurrogate=true
prefix=A🚗
```

`substring(0, 2)` 没有越界，却把汽车 emoji 的代理对拆开了。`offsetByCodePoints()` 得到的端点位于完整码点之后，因此前缀保持代理对完整。

最后的 `e` 与组合音标仍是两个码点，却常显示为一个字素簇。这个函数的名称明确承诺按码点工作；若界面需求按用户可见字符限制长度，需要换用字素分段契约。

### 用构建器组合结果

当片段数量在运行时决定时，`StringBuilder` 可以把分隔符策略与追加过程放在同一个位置。示例只在元素之间添加分隔符，避免先添加再删除末尾分隔符带来的空输入边界。

```java
// file: OrderSummary.java
import java.util.List;

public class OrderSummary {
    static String summarize(List<String> itemCodes) {
        StringBuilder output = new StringBuilder("items=");

        for (int index = 0; index < itemCodes.size(); index++) {
            if (index > 0) {
                output.append(" | ");
            }
            output.append(itemCodes.get(index));
        }

        output.append("; count=").append(itemCodes.size());
        return output.toString();
    }

    public static void main(String[] args) {
        System.out.println(summarize(List.of("A17", "B04", "C22")));
        System.out.println(summarize(List.of()));
    }
}
```

```text
items=A17 | B04 | C22; count=3
items=; count=0
```

构建器属于方法内部，不会在调用之间共享状态。`toString()` 返回最终不可变值，之后即使构建器继续变化，已经返回的字符串也不会跟着改变。

如果任务只是连接已有字符串，`String.join(" | ", itemCodes)` 更短。需要同时写入标签、计数、条件片段或不同类型的值时，构建器通常更清楚。

## 陷阱

> **陷阱:** 用 `==` 比较字符串内容，会把字符串池的复用误当成值相等。修复方法是用 `equals()` 比较非空字符串，引用可能为 `null` 时用 `Objects.equals()`；测试数据至少要包含运行时构造的等值字符串。

> **陷阱:** 把 `null`、空字符串 `""` 和只含空白的字符串当成同一种状态，会让验证与序列化规则互相冲突。修复方法是在 API 边界分别定义缺失、空值和空白值的含义，再按契约使用空检查、`isEmpty()` 或 `isBlank()`。

> **陷阱:** 用 `length()` 或 `substring()` 按“字符数”截取文本，可能把代理对或字素簇拆开。修复方法是先给限制单位命名为代码单元、码点或字素簇，再使用相应的偏移或分段 API，并加入补充码点与组合序列测试。

> **陷阱:** `getBytes()`、`new String(bytes)` 和无参数大小写转换依赖环境默认值，代码可能在另一台机器上产生不同结果。修复方法是对协议使用明确字符集，对机器标识符使用 `Locale.ROOT`；自然语言转换则传入用户或内容对应的语言环境。

> **陷阱:** `split()` 的参数是正则表达式，而且默认会删除尾部空字符串；`replaceAll()` 的替换文本还会解释 `$` 与反斜杠。修复方法是对字面分隔符使用 `Pattern.quote()`，需要保留尾字段时传入负数限制，并用 `Matcher.quoteReplacement()` 保护字面替换文本。

> **陷阱:** 在循环中反复执行 `result += part` 会不断产生新的不可变结果，而共享一个 `StringBuffer` 也不会让完整的多步协议自动原子化。修复方法是让 `StringBuilder` 保持局部并一次构建；确需跨线程共享时，为整组操作设计所有权和同步边界。

<!-- deep -->

## 字符串池与实现边界

字符串池是一项语言可观察到的规范化机制，但不是应用级缓存 API。字面量、文本块和常量字符串表达式具有规范规定的驻留行为；普通运行时结果只有显式调用 `intern()` 后，才能要求取得规范化引用。

这条边界解释了为什么 `"ab" == "a" + "b"` 可以为真，而从两个变量运行时拼接出的相同内容不应使用身份预测。编译期常量折叠和运行时拼接属于不同阶段，内容相等才是跨阶段稳定的业务性质。

### 常量变量影响折叠

常量表达式可以包含满足规范条件的基本类型或 `String` 常量变量。一个 `final String` 只有在声明时由常量表达式初始化，并符合常量变量定义时，才能参与编译期字符串常量折叠。

方法参数、运行时方法结果，以及在静态初始化块中赋值的字段都不是这种字符串常量表达式。不要根据源码里是否出现 `final` 猜测身份；让编译器优化表达式，同时让应用使用 `equals()`。

池中到底有多少对象也不能仅由一行源码可靠推导。类加载、先前执行、编译器生成常量和运行时实现都会影响对象是否已存在。“`new String` 创建一个还是两个对象”不是稳定的 API 契约，也不是有用的业务判断。

### 紧凑字符串是实现细节

OpenJDK 通过紧凑字符串（Compact Strings）优化许多字符串的内部表示。JEP 254 把 OpenJDK 9 以前的 `char[]` 表示改为 `byte[]` 加编码标志，使只含 Latin-1 字符的字符串可以使用单字节内部元素，其他字符串使用 UTF-16 表示。

这不会改变 `String` 的公开 UTF-16 语义。`length()`、`charAt()`、`substring()` 和代码点 API 的契约没有因内部数组类型而改变，调用方也无法通过标准 API 选择内部编码器。

“Latin-1 字符串占一半空间”只描述特定内部有效载荷，不等于每个完整对象都精确节省一半。对象头、对齐、压缩引用、垃圾收集器和 JIT 优化都会影响测量结果。没有针对目标 JVM 的基准与内存分析时，不应给出完整对象大小或吞吐量倍数。

反射访问私有字段来判断 `coder` 会依赖模块开放选项和 OpenJDK 实现。生产逻辑应依赖公开文本语义；诊断实现布局时，则要记录供应商、具体构建和 JVM 参数。

### 哈希值与映射键

`String.equals()` 与 `String.hashCode()` 都由内容决定，因此字符串适合作为 `HashMap` 和 `HashSet` 的键。内容不可变保证了一个键放入集合后，不会因为字符串自身变化而移动到错误的哈希桶。

哈希相同不代表内容相等。哈希表仍会在候选键之间调用 `equals()`，应用不能把 `hashCode()` 当成唯一标识符、校验和或安全摘要。跨进程持久化哈希值也不应代替保存原始键。

字符串不可变也不会冻结它关联的业务对象。若映射值、包含字符串字段的记录或围绕字符串构建的缓存条目可变，仍需单独定义所有权、相等性和并发规则。

## Unicode 边界与规范化

Java 字符串允许包含未配对的代理代码单元。构造 `String` 并不会自动证明它是规范良好的 UTF-16 序列，`substring()` 也能从代理对中间切开。把字符串编码到某些字符集时，编码器可能替换畸形输入；严格协议需要配置 `CharsetEncoder` 的错误动作。

代码单元、码点和字素簇解决不同问题，不能用一个数字替代另一个。

| 单位 | Java 操作 | 适合的契约 |
| --- | --- | --- |
| UTF-16 代码单元 | `length()`、`charAt()`、`substring()` | Java 索引、与 UTF-16 API 互操作 |
| Unicode 码点 | `codePointCount()`、`codePoints()` | 标量级分类、补充字符遍历 |
| 字素簇 | 文本分段器 | 光标移动、用户可见字符限制 |

Unicode 规范化是另一条轴。视觉或语义上等价的文本可能使用预组字符，也可能使用基本字符加组合标记；`equals()` 会把不同代码单元序列判为不等。只有领域确实要求规范化相等时，才应在受控边界选择 NFC、NFD 等形式，并保存这项规则。

大小写映射也不是规范化。某些映射会改变长度，而且不同语言环境可能产生不同结果。持久化键若选择 `Locale.ROOT`，仍要说明是否执行 Unicode 规范化；自然语言显示则不能自动套用机器键规则。

## 文本块仍会处理内容

文本块（text block）是多行字符串字面量，不是原始字符串。编译器会处理附带缩进、换行和转义序列，因此源码排版变化可能改变结果，也可能被公共缩进规则消除。

文本块与普通字符串字面量具有相同的 `String` 类型和驻留规则。它适合内嵌 JSON、SQL 或测试文本，但不会自动转义目标语言，也不会让拼接进去的数据免受注入。动态 SQL、HTML 或 JSON 仍应使用对应的参数化 API、转义器或序列化器。

测试精确文本时，应断言换行和尾部空白，而不只在控制台目视检查。跨平台协议如果要求固定行尾，应显式构造或规范化行尾，不要把编辑器保存方式当成契约。

## 构建器的容量与并发语义

`StringBuilder` 维护可增长的字符序列和容量。容量不足时会扩大内部存储，但具体增长公式不是应用契约。能够可靠估计最终代码单元数量时，可传入初始容量减少扩容；估计不可靠时，默认构造器通常更清楚。

预分配容量不会改变算法错误。把不受信任的声明长度直接作为容量，可能提前申请过多内存；根据不准确的“平均字符数”计算容量，也可能溢出。容量提示应经过范围检查，并且只在分析或测量证明分配重要时加入。

`StringBuilder` 不提供跨线程同步保证。`StringBuffer` 的公开方法带同步，但 `if (buffer.length() > 0) buffer.append(...)` 这样的读取后写入包含多个方法调用，仍需要更高层同步才能保持整体原子性。

多数文本构建天然属于一次请求或一次方法调用。让构建器保持局部、只返回最终字符串，通常同时得到清楚的所有权与足够的性能。若多个线程要生成各自消息，应让每个线程拥有自己的构建器，而不是争用一个共享缓冲区。

编译器可以优化单条 `+` 表达式，所以不要把所有拼接机械改写成构建器。选择依据是源码是否表达了一个逐步、重复的构建过程；性能结论则应由目标 JDK、真实数据形状和合适的基准工具验证。

<!-- /deep -->

[检查点: java/strings](https://codewiki.com/zh/java/strings/#checkpoint)

## 延伸阅读

- [Java 语言规范：字符串字面量](https://docs.oracle.com/javase/specs/jls/se25/html/jls-3.html#jls-3.10.5)
- [Java 语言规范：字符串拼接运算符](https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html#jls-15.18.1)
- [Java SE 25 API：`String`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/String.html)
- [Java SE 25 API：`StringBuilder`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/StringBuilder.html)
- [JEP 254：紧凑字符串](https://openjdk.org/jeps/254)
