一、开场:档案卡为什么不能直接涂改

想象你走进一座数字档案馆。每一张正式档案卡一旦盖章,就不能拿橡皮擦修改。姓名写错了怎么办?工作人员不会在原卡上涂改,而是重新制作一张新卡,再让你手里的编号牌指向新卡。

Java 的 String 很像这种盖章后的标准卡片

  • String 变量像你手里的编号牌,它保存的是引用;
  • String 对象像档案柜中的卡片;
  • 卡片内容创建后不能修改;
  • 字面量池像“标准卡片柜”,相同的标准内容可以共享;
  • new String(...) 像拿标准卡复印出一张独立卡片;
  • intern() 像交回复印件,领取标准卡片柜里的规范引用;
  • StringBuilder 像一块可以反复书写的草稿板,定稿时再生成 String。

Java String 档案馆标准卡片主题封面

这篇文章以 JDK 25 为背景,从日常使用一路走到字符串池、运行期拼接、Compact Strings 和 Unicode。先记住一句总纲:

String 变量可以改指向,String 对象的内容不能改;== 看是不是同一张卡,equals 看卡片上写的内容是否相同。

档案馆生活场景与 Java String 概念映射图

二、String 变量、引用和对象不是一回事

初学者看到下面代码,容易说“字符串被修改了”:

String card = "档案卡";
card = "新档案卡";

真正发生的事情是:

  1. "档案卡" 对应一个 String 对象;
  2. 变量 card 先引用它;
  3. "新档案卡" 对应另一个 String 对象;
  4. card 改为引用新对象;
  5. 原来的 "档案卡" 没有被涂改。

所以要把三个概念分开:

概念 档案馆类比 能否改变
String 变量 手里的编号牌 可以改为指向另一张卡
String 引用 编号牌记录的卡片位置 可以被重新赋值
String 对象内容 盖章后的卡片文字 创建后不能修改

final String card = "档案卡"; 只是让变量 card 不能重新指向另一张卡,并不是给 String 增加了新的不可变能力。String 对象本来就是不可变的。

三、不可变:所谓修改,其实是创建新卡片

String API 中很多方法看起来像在修改内容,例如 concatreplacetoUpperCasesubstring,但它们不会改写原对象,而是返回结果字符串。

String original = "archive";
String changed = original.concat("-2026");

System.out.println(original);          // archive
System.out.println(changed);           // archive-2026
System.out.println(original == changed); // false

不可变让字符串能够安全共享,也让它适合作为 Map 的 Key;内容不变,equalshashCode 的语义才稳定。类名、路径等信息也不会在校验后被原地篡改。

但不可变不代表每个方法都必定创建新对象:结果未变化时,当前实现可能返回 this。这是实现优化,业务代码只应依赖结果内容,并用 equals 比较文本。

四、字面量、new String、== 与 equals

档案馆的标准卡片柜会保存规范卡片。源代码中的字符串字面量,例如 "Java",会使用池中的规范实例。

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

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

String actual = new String("OPEN");
System.out.println("OPEN".equals(actual)); // true

这三行输出分别表示:

  • a == b:两个编号牌是否指向同一张标准卡;
  • a == c:标准卡和复印出来的新卡是否为同一对象;
  • a.equals(c):两张卡片写的字符序列是否相同。

String 字面量池、普通引用与 new String 对象关系图

4.1 为什么字符串比较不能用 ==

== 比较引用身份。只要字符串来自网络、数据库、文件、用户输入、运行期拼接或显式 new,就不应该猜测它是否恰好与池中对象共用引用。

常量放在左边还可以自然处理 actual == null,例如 boolean matched = "OPEN".equals(actual);

如果两边都可能为空,可以使用 Objects.equals(left, right)

五、字符串池与 intern:领取统一标准卡

String.intern() 返回字符串池中的规范表示:

  • 池中已经存在相同内容时,返回池中的对象;
  • 不存在时,把该字符串的规范表示加入池并返回;
  • 对任意非空字符串 st,只要 s.equals(t),就有 s.intern() == t.intern()
String standard = "archive-card";
String copied = new String("archive-card");

System.out.println(copied == standard);          // false
System.out.println(copied.intern() == standard); // true

字面量和“值为字符串的常量表达式”会被驻留,这是 Java 语言规范的一部分。不过,不要把字符串池简单背成“固定在方法区”“固定在永久代”之类的物理地址结论。内存布局会随 JVM 和版本变化,业务代码只应依赖规范公开的身份语义。

5.1 intern 不是免费的去重按钮

intern() 更适合少量、重复度高且生命周期明确的规范文本。海量随机内容盲目驻留可能增加内存压力;若只是想让 ==“能用”,应直接改用 equals。是否采用 intern,必须用真实数据测量。

六、编译期折叠与运行期拼接

下面四个表达式看起来都得到 "Java",对象身份却不完全一样:

String literal = "Java";
String folded = "Ja" + "va";

final String constantPart = "Ja";
String foldedByConstant = constantPart + "va";

String runtimePart = "Ja";
String runtime = runtimePart + "va";

System.out.println(literal == folded);           // true
System.out.println(literal == foldedByConstant); // true
System.out.println(literal == runtime);          // JDK 25 实验中为 false

System.out.println(1 + 2 + " 张卡"); // 3 张卡
System.out.println("卡片 " + 1 + 2); // 卡片 12
System.out.println("合计 " + (1 + 2) + " 张卡");

"Ja" + "va" 是常量表达式,编译器可以在编译期直接折叠成 "Java"constantPart 是常量变量,也参与折叠。

runtimePart 不是常量变量,拼接需要在运行期完成。不要根据这次输出形成“所有运行期拼接一定产生某种特定对象”的业务依赖;判断内容仍然使用 equals

6.1 一个容易忽略的从左到右规则

字符串拼接从左到右计算。上例第一行先完成整数加法,第二行从第一个字符串开始,后续操作都变成字符串拼接。需要表达数学含义时应主动加括号。

七、JDK 25 的 invokedynamic 拼接路径

“Java 的 + 一定编译成 StringBuilder”已经不够准确。拼接实现由编译器决定;当前 javac 通常为运行期拼接生成 invokedynamic 调用点,再由 StringConcatFactory 组织策略。JEP 280 让这条路径可以独立演进。

public class ConcatBytecodeDemo {
    static String makeCard(String owner, int number) {
        return owner + "-CARD-" + number;
    }
}

编译后执行:

javac ConcatBytecodeDemo.java
javap -c -v ConcatBytecodeDemo

JDK 25 输出中可以看到 InvokeDynamicmakeConcatWithConstants。这只是当前工具链的结果,不是跨编译器契约。日常少量拼接用 +,循环追加用 StringBuilder;模板化输出可使用 formatted 或专门模板工具。

八、Compact Strings:byte[] 加 coder

从 JEP 254 开始,OpenJDK 把 String 的内部存储从固定的 char[] 改为更紧凑的表示。JDK 25 的精简结构可以理解为:

精简来看,当前实现包含 private final byte[] valueprivate final byte coder 两个关键字段。

coder 用来区分内部采用 LATIN1 还是 UTF16:

  • 内容都能用 Latin-1 表示时,一个字符通常只需一个字节;
  • 出现中文等字符时,使用 UTF16 路径;
  • 这是空间优化,不会改变 String 对外的字符语义。

JDK 25 Compact Strings 的 value、coder、LATIN1 与 UTF16 结构图

可以通过反射做观察实验:

Field valueField = String.class.getDeclaredField("value");
Field coderField = String.class.getDeclaredField("coder");
valueField.setAccessible(true);
coderField.setAccessible(true);

byte[] value = (byte[]) valueField.get("Java2026");
byte coder = coderField.getByte("Java2026");

System.out.println(value.length);
System.out.println(coder == 0 ? "LATIN1" : "UTF16");

运行时需要显式开放模块:

java --add-opens java.base/java.lang=ALL-UNNAMED StringArchiveCardDemo

--add-opens 只用于学习实验。业务代码不应反射访问 String 私有字段,因为字段名、编码标记和布局都不是公开 API。

还有一个非常重要的区分:

String API 对外把文本表示为 UTF-16 字符序列;Compact Strings 是内部存储优化。

不能因为内部使用 byte[],就说“Java String 已经变成 UTF-8”。

九、length、char 与 Emoji:两个格子不一定是两个字符

String 的索引和 length() 以 UTF-16 的 char 代码单元为基础。一个基本多文种平面中的字符通常占一个 char,而某些补充字符需要一对代理项。

String emoji = "😀";

System.out.println(emoji.length()); // 2
System.out.println(emoji.codePointCount(0, emoji.length())); // 1
System.out.printf("U+%X%n", emoji.codePointAt(0)); // U+1F600

“用户看到一个符号”不一定等于 length() == 1。处理 Emoji、罕见汉字或昵称截断时,可使用 codePointAtcodePoints()offsetByCodePoints。但肤色、国旗等 Emoji 还可能由多个码点组成,真正按字素簇截断需要更高层的 Unicode 工具。

十、StringBuilder 与 StringBuffer:先在草稿板上写

String 不可变。如果在循环中反复写:

String result = "";
for (int i = 1; i <= 1000; i++) {
    result = result + "卡片" + i; // 每轮产生新的拼接结果
}

StringBuilder builder = new StringBuilder(16_000);
for (int i = 1; i <= 1000; i++) {
    builder.append("卡片").append(i).append('\n');
}
String optimized = builder.toString();

第一段循环的中间对象和复制工作会不断增加;第二段先在可变缓冲区中追加,最后一次性生成结果 String。

StringBuilder 与 StringBuffer 的主要区别是:

类型 方法同步 典型使用场景
StringBuilder 局部变量、单线程拼接
StringBuffer 确实需要共享同一缓冲区的旧式并发场景

StringBuffer 的单方法同步不能保证多步骤业务原子性。多数场景让每个线程使用自己的局部 StringBuilder 更简单。

十一、五个高频 API 陷阱

下面先把五类问题放在同一个示例片段中(已导入 StandardCharsetsLocale):

String text = "\u3000档案卡\u3000";
System.out.println("[" + text.trim() + "]");
System.out.println("[" + text.strip() + "]");

String[] wrong = "A.B".split(".");
String[] right = "A.B".split("\\.");

String source = "档案卡";
byte[] bytes = source.getBytes(StandardCharsets.UTF_8);
String restored = new String(bytes, StandardCharsets.UTF_8);

String input = "open";
String normalized = input.toUpperCase(Locale.ROOT);

11.1 trim 和 strip 不完全相同

trim() 按较早的规则移除两端编码值不大于 U+0020 的字符;strip() 使用 Unicode 空白判断。

包含全角空格等 Unicode 空白时,strip() 通常更符合直觉。

11.2 split 的参数是正则表达式

. 在正则中表示任意字符:

上例中的 wrong 得到空数组,而转义后的 right 才得到 [A, B]

分隔符来自用户输入时,可考虑 Pattern.quote(delimiter)

11.3 substring 使用的是下标,不是码点数量

substring(begin, end) 左闭右开,并按 UTF-16 下标操作。下标越界会抛出 StringIndexOutOfBoundsException,在代理项中间截断还可能产生不完整文本。

当前 JDK 25 会为子串结果构造适当的新存储,但这属于实现细节,不应把“子串一定共享或一定不共享原数组”当成跨版本契约。

11.4 getBytes 不要依赖默认字符集

协议、文件和跨系统通信都应显式指定字符集。发送端用 UTF-8、接收端却按其他编码解码,就像双方拿着不同的密码本。

11.5 大小写转换可能与地区有关

用于界面展示时,可以采用用户地区;用于协议关键字、机器标识符时,通常显式使用 Locale.ROOT

equalsIgnoreCase 也不是完整的地区化排序方案。需要语言学意义的比较时,应使用 Collator 等更合适的工具。

十二、完整实验:观察标准卡、复印卡与内部编码

下面的完整程序一次验证不可变性、对象身份、字符串池、常量折叠、Compact Strings、Unicode、StringBuilder、正则分割和 UTF-8 往返:

import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

public class StringArchiveCardDemo {

    public static void main(String[] args) throws Exception {
        String original = "档案卡";
        String changed = original.concat("-2026");
        System.out.printf("[不可变] original=%s, changed=%s, same=%s%n",
                original, changed, original == changed);

        String literalA = "Java";
        String literalB = "Java";
        String copied = new String("Java");
        System.out.printf("[身份] literalSame=%s, newSame=%s, equals=%s%n",
                literalA == literalB, literalA == copied, literalA.equals(copied));

        String standard = "archive-card";
        String copy = new String("archive-card");
        System.out.printf("[池] before=%s, after=%s%n",
                copy == standard, copy.intern() == standard);

        String runtimePart = "Ja";
        String runtime = runtimePart + "va";
        System.out.printf("[拼接] folded=%s, runtime=%s, intern=%s%n",
                literalA == "Ja" + "va",
                literalA == runtime,
                literalA == runtime.intern());

        inspectStorage("ASCII", "Java2026");
        inspectStorage("中文", "档案卡");

        String emoji = "😀";
        System.out.printf("[Unicode] length=%d, codePoints=%d%n",
                emoji.length(),
                emoji.codePointCount(0, emoji.length()));

        StringBuilder builder = new StringBuilder(32);
        for (int i = 1; i <= 3; i++) {
            if (i > 1) builder.append(" -> ");
            builder.append("卡片").append(i);
        }
        System.out.println("[Builder] " + builder);

        String spaces = "\u3000档案卡\u3000";
        System.out.printf("[空白] trimLength=%d, strip=%s%n",
                spaces.trim().length(), spaces.strip());
        System.out.println("[split] " + Arrays.toString("A.B".split("\\.")));

        String source = "档案卡😀";
        byte[] bytes = source.getBytes(StandardCharsets.UTF_8);
        String restored = new String(bytes, StandardCharsets.UTF_8);
        System.out.println("[UTF-8] roundTrip=" + source.equals(restored));
    }

    private static void inspectStorage(String label, String text) throws Exception {
        Field valueField = String.class.getDeclaredField("value");
        Field coderField = String.class.getDeclaredField("coder");
        valueField.setAccessible(true);
        coderField.setAccessible(true);

        byte[] value = (byte[]) valueField.get(text);
        byte coder = coderField.getByte(text);
        System.out.printf("[Compact] %s coder=%s, bytes=%d, length=%d%n",
                label, coder == 0 ? "LATIN1" : "UTF16",
                value.length, text.length());
    }
}

运行命令:

javac StringArchiveCardDemo.java
java --add-opens java.base/java.lang=ALL-UNNAMED StringArchiveCardDemo
javap -c -v StringArchiveCardDemo

预期结果是:字面量共享规范引用,new String 内容相等但身份不同,intern() 返回规范引用;ASCII 走 LATIN1、中文走 UTF16;Emoji 的 length() 为 2、码点数为 1,UTF-8 往返成功。javap 结果中还应出现运行期拼接的 InvokeDynamic

十三、常见误区

误区 正确认识
String 不可变,所以变量不能重新赋值 不可变的是对象内容,普通变量可以改指向
文本相同,== 就为 true 内容比较用 equals== 比较身份
new String 会改掉池中对象 它创建独立对象,不修改规范实例
所有 + 都等于手写 StringBuilder 常量可折叠,当前运行期拼接通常走 invokedynamic
内部是 byte[],所以 length 返回字节数 length() 返回 UTF-16 代码单元数
StringBuffer 能让任意组合操作原子 单方法同步不覆盖整段业务逻辑
intern 一定节省内存 是否受益取决于重复度、生命周期和规模
无参 getBytes() 到处结果相同 跨系统数据应显式指定字符集

十四、总结:卡片不能改,编号牌可以换

最后把全文压缩成一组口诀:

变量是编号牌,对象是档案卡;卡片不能改,改动就换卡。
双等看同一张,equals 看内容;字面量进标准柜,new 出复印件。
常量拼接编译算,运行拼接看工具链;循环追加用 Builder,共享缓冲慎用 Buffer。
内部 byte 加 coder,对外仍是 UTF-16;Emoji 可能占两格,编码传输写 UTF-8。

选择建议:

场景 推荐方式
比较字符串内容 equalsObjects.equals
少量表达式拼接 +
循环或分步骤构建 StringBuilder
多线程确实共享同一缓冲区 评估 StringBuffer 或重新设计
统一机器标识符大小写 Locale.ROOT
网络、文件、数据库二进制转换 显式字符集
处理 Emoji 和补充字符 码点 API
观察 String 私有字段 仅限实验,不进入业务代码

把 String 想成档案馆的密封卡片:引用可以换,内容不能涂;比较内容看文字,比较身份看卡号。

参考资料

  1. Oracle Java SE 25:String API
  2. Java Language Specification 25:3.10.5 String Literals
  3. Java Language Specification 25:15.18.1 String Concatenation Operator +
  4. OpenJDK JEP 254:Compact Strings
  5. OpenJDK JEP 280:Indify String Concatenation
  6. OpenJDK JDK 25:String.java

说明:文中的私有字段和拼接路径以 JDK 25 为背景,仅用于理解当前实现;实际开发应依赖公开 API 和语言规范。


  • 个人小游戏

个人小游戏

在这里插入图片描述

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐