字符串

Java String 的值语义、UTF-16 索引、字符串池、比较、Unicode 边界、字符编码与高效构建方法。

难度 入门 时长 标准深度约 16分钟
版本 Java 25 LTS
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() 比较内容,== 比较对象身份。字面量常量折叠会复用池中实例,但显式构造的副本不会与它共享身份。

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));
    }
}
status=draft
upper=DRAFT
sameContent=true
literalIdentity=true
copyIdentity=false

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

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

规范化外部文本

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

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));
    }
}
status=PENDING
fields=[A17, READY, ]
bytes=8
roundTrip=true

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

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

按码点截取前缀

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

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));
    }
}
units=5
codePoints=4
brokenHighSurrogate=true
prefix=A🚗

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

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

用构建器组合结果

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

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()));
    }
}
items=A17 | B04 | C22; count=3
items=; count=0

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

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

陷阱

深入 字符串池与实现边界

字符串池与实现边界

字符串池是一项语言可观察到的规范化机制,但不是应用级缓存 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() 都由内容决定,因此字符串适合作为 HashMapHashSet 的键。内容不可变保证了一个键放入集合后,不会因为字符串自身变化而移动到错误的哈希桶。

哈希相同不代表内容相等。哈希表仍会在候选键之间调用 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、真实数据形状和合适的基准工具验证。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

下一篇 Collections 即将上线 Stream api 即将上线 Nio 即将上线
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?