一文搞懂 Java String:把它想成档案馆里不能涂改的标准卡片
文章目录
-
- 一、开场:档案卡为什么不能直接涂改
- 二、String 变量、引用和对象不是一回事
- 三、不可变:所谓修改,其实是创建新卡片
- 四、字面量、new String、== 与 equals
- 五、字符串池与 intern:领取统一标准卡
- 六、编译期折叠与运行期拼接
- 七、JDK 25 的 invokedynamic 拼接路径
- 八、Compact Strings:byte[] 加 coder
- 九、length、char 与 Emoji:两个格子不一定是两个字符
- 十、StringBuilder 与 StringBuffer:先在草稿板上写
- 十一、五个高频 API 陷阱
- 十二、完整实验:观察标准卡、复印卡与内部编码
- 十三、常见误区
- 十四、总结:卡片不能改,编号牌可以换
- 参考资料
一、开场:档案卡为什么不能直接涂改
想象你走进一座数字档案馆。每一张正式档案卡一旦盖章,就不能拿橡皮擦修改。姓名写错了怎么办?工作人员不会在原卡上涂改,而是重新制作一张新卡,再让你手里的编号牌指向新卡。
Java 的 String 很像这种盖章后的标准卡片:
- String 变量像你手里的编号牌,它保存的是引用;
- String 对象像档案柜中的卡片;
- 卡片内容创建后不能修改;
- 字面量池像“标准卡片柜”,相同的标准内容可以共享;
new String(...)像拿标准卡复印出一张独立卡片;intern()像交回复印件,领取标准卡片柜里的规范引用;- StringBuilder 像一块可以反复书写的草稿板,定稿时再生成 String。

这篇文章以 JDK 25 为背景,从日常使用一路走到字符串池、运行期拼接、Compact Strings 和 Unicode。先记住一句总纲:
String 变量可以改指向,String 对象的内容不能改;
==看是不是同一张卡,equals看卡片上写的内容是否相同。

二、String 变量、引用和对象不是一回事
初学者看到下面代码,容易说“字符串被修改了”:
String card = "档案卡";
card = "新档案卡";
真正发生的事情是:
"档案卡"对应一个 String 对象;- 变量
card先引用它; "新档案卡"对应另一个 String 对象;card改为引用新对象;- 原来的
"档案卡"没有被涂改。
所以要把三个概念分开:
| 概念 | 档案馆类比 | 能否改变 |
|---|---|---|
| String 变量 | 手里的编号牌 | 可以改为指向另一张卡 |
| String 引用 | 编号牌记录的卡片位置 | 可以被重新赋值 |
| String 对象内容 | 盖章后的卡片文字 | 创建后不能修改 |
final String card = "档案卡"; 只是让变量 card 不能重新指向另一张卡,并不是给 String 增加了新的不可变能力。String 对象本来就是不可变的。
三、不可变:所谓修改,其实是创建新卡片
String API 中很多方法看起来像在修改内容,例如 concat、replace、toUpperCase 和 substring,但它们不会改写原对象,而是返回结果字符串。
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;内容不变,equals 和 hashCode 的语义才稳定。类名、路径等信息也不会在校验后被原地篡改。
但不可变不代表每个方法都必定创建新对象:结果未变化时,当前实现可能返回 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):两张卡片写的字符序列是否相同。

4.1 为什么字符串比较不能用 ==
== 比较引用身份。只要字符串来自网络、数据库、文件、用户输入、运行期拼接或显式 new,就不应该猜测它是否恰好与池中对象共用引用。
常量放在左边还可以自然处理 actual == null,例如 boolean matched = "OPEN".equals(actual);。
如果两边都可能为空,可以使用 Objects.equals(left, right)。
五、字符串池与 intern:领取统一标准卡
String.intern() 返回字符串池中的规范表示:
- 池中已经存在相同内容时,返回池中的对象;
- 不存在时,把该字符串的规范表示加入池并返回;
- 对任意非空字符串
s、t,只要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 输出中可以看到 InvokeDynamic 和 makeConcatWithConstants。这只是当前工具链的结果,不是跨编译器契约。日常少量拼接用 +,循环追加用 StringBuilder;模板化输出可使用 formatted 或专门模板工具。
八、Compact Strings:byte[] 加 coder
从 JEP 254 开始,OpenJDK 把 String 的内部存储从固定的 char[] 改为更紧凑的表示。JDK 25 的精简结构可以理解为:
精简来看,当前实现包含 private final byte[] value 和 private final byte coder 两个关键字段。
coder 用来区分内部采用 LATIN1 还是 UTF16:
- 内容都能用 Latin-1 表示时,一个字符通常只需一个字节;
- 出现中文等字符时,使用 UTF16 路径;
- 这是空间优化,不会改变 String 对外的字符语义。

可以通过反射做观察实验:
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、罕见汉字或昵称截断时,可使用 codePointAt、codePoints()、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 陷阱
下面先把五类问题放在同一个示例片段中(已导入 StandardCharsets 与 Locale):
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。
选择建议:
| 场景 | 推荐方式 |
|---|---|
| 比较字符串内容 | equals 或 Objects.equals |
| 少量表达式拼接 | + |
| 循环或分步骤构建 | StringBuilder |
| 多线程确实共享同一缓冲区 | 评估 StringBuffer 或重新设计 |
| 统一机器标识符大小写 | Locale.ROOT |
| 网络、文件、数据库二进制转换 | 显式字符集 |
| 处理 Emoji 和补充字符 | 码点 API |
| 观察 String 私有字段 | 仅限实验,不进入业务代码 |
把 String 想成档案馆的密封卡片:引用可以换,内容不能涂;比较内容看文字,比较身份看卡号。
参考资料
- Oracle Java SE 25:String API
- Java Language Specification 25:3.10.5 String Literals
- Java Language Specification 25:15.18.1 String Concatenation Operator +
- OpenJDK JEP 254:Compact Strings
- OpenJDK JEP 280:Indify String Concatenation
- OpenJDK JDK 25:String.java
说明:文中的私有字段和拼接路径以 JDK 25 为背景,仅用于理解当前实现;实际开发应依赖公开 API 和语言规范。
- 个人小游戏


更多推荐



所有评论(0)