String 是 JDK 里唯一被语言层面特殊对待的类:字面量直接进常量池、+ 被编译器改写、switch 支持它、intern() 给它开了后门。这些特权的底座是"不可变"——一旦可变,共享就是把 bug 分发出去。
这篇把三件事讲清:不可变换来了什么、+ 在各版本被编译成什么、以及那些"过期的老经验"现在还算不算数。
1. 不可变换来的四样东西
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final byte[] value; // JDK 9 起是 byte[],此前是 char[]
private final byte coder; // 0 = LATIN1, 1 = UTF16
private int hash; // 惰性缓存,0 表示未计算
}
类 final、数组 final 且从不外泄,构造完成后状态恒定。四个收益都能落到具体机制上:
| 收益 | 机制 | 若可变会发生什么 |
|---|---|---|
| 可安全共享 | 常量池、字面量复用同一实例 | 一处修改污染所有引用者 |
| 哈希可缓存 | hash 字段只算一次 |
作为 HashMap key 时哈希漂移,条目丢失 |
| 天然线程安全 | 无写入即无竞态 | 需要每次访问都加锁 |
| 安全边界 | 类名、文件路径、SQL、URL 不可被中途改写 | 校验通过后、使用前被篡改(TOCTOU) |
第三条最容易被低估:String 的线程安全不是"加了锁",而是"没有可变状态",所以它能在无同步的情况下跨线程传递。第四条在安全校验里是硬需求——ClassLoader 用类名做 key、File 用路径串,如果这些串能被第三方代码改写,校验就形同虚设。
final 类这一层还挡掉了"继承后重写 equals 破坏对称性"的路,这也是 equals/hashCode 契约能对 String 无条件成立的前提。
2. 常量池与 + 的三代实现
字面量在编译期就进入 class 文件的常量池,运行期由 JVM 解析为同一个 String 实例:
String a = "abc"; // 常量池
String b = "abc"; // 复用 a 的实例
String c = new String("abc"); // 堆上新对象,value 指向同一数组(JDK 9 前共享 char[])
System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(a == c.intern()); // true
+ 的处理取决于操作数是不是编译期常量,也取决于 JDK 版本:
| 场景 | 编译产物 | 说明 |
|---|---|---|
全常量:"a" + "b" |
常量池里直接是 "ab" |
常量折叠,运行时零开销 |
含 final 常量:FINAL_PREFIX + x |
同上(若 x 也是常量) |
编译期能算就编译期算 |
| 含变量(JDK 8 及以前) | new StringBuilder().append(...).toString() |
每次执行都新建 builder |
| 含变量(JDK 9+) | invokedynamic makeConcatWithConstants |
由 StringConcatFactory 生成策略 |
| 循环内拼接 | 每轮一次 indy / 一次 builder | 仍是 O(n²),需显式 StringBuilder |
$ javap -c -p Concat.class | grep -A2 "String b"
ldc "abc" # 常量折叠:直接取池里的 "ab"
invokedynamic #7 makeConcatWithConstants # JDK 9+ 的变量拼接
JDK 9 起 javac 不再生成 StringBuilder 链,而是交给 StringConcatFactory 在运行时绑定一个 MethodHandle。好处是拼接策略可以在启动时按参数类型选最优实现(比如全部 String 时用 String.join 一类的路径,混合类型时走预分配缓冲),并且后续可以替换实现而不改字节码。注意:这是编译期改写,不是运行期优化,+ 依旧不是"免费的"。
循环里拼接仍然要手写:
String result = "";
for (String s : list) {
result += s + ","; // 每轮新建对象,O(n^2)
}
StringBuilder sb = new StringBuilder(list.size() * 8);
for (String s : list) {
sb.append(s).append(',');
}
String joined = sb.toString();
JDK 8 起 javac 会把循环里的 += 编译成"每轮新建 StringBuilder",JDK 9+ 则是"每轮一次 indy 调用",两者都是每轮一次拷贝——数据量上去就是平方级复制。JDK 8 对单个表达式内的连续 + 已经会复用同一个 builder,所以 a + b + c 只建一个,但循环跨轮次不复用。
3. 紧凑字符串、substring 与 intern
JDK 9 的紧凑字符串(JEP 254)把 char[] 换成 byte[] + coder:纯 Latin-1 内容每字符 1 字节,含非 Latin-1 字符才升到 2 字节。
| 版本 | 变化 | 影响 |
|---|---|---|
| JDK 6 及以前 | char[],每字符 2 字节 |
英文串内存翻倍 |
| JDK 7u6 | substring 改为复制 |
"子串持有原数组导致泄漏"的说法到此过期 |
| JDK 9 | byte[] + coder(紧凑字符串) |
纯 ASCII 串内存约减半;coder 决定解码方式 |
| JDK 9+ | + 改走 invokedynamic |
拼接策略与字节码解耦 |
| JDK 17 | 上述全部为默认行为 | 无需任何开关 |
String big = "x".repeat(1_000_000);
String small = big.substring(0, 3); // JDK 7u6 起复制,不再持有 1MB 数组的引用
System.out.println(small == "xxx"); // false —— 运行期 substring 不进常量池
System.out.println(small.intern() == "xxx"); // true
"substring 会泄漏内存"这条经验来自 JDK 6:那时 substring 共享底层 char[],截一个 3 字符的子串会拖住整个 1MB 数组。现在它复制,代价是每次 substring 都产生一次拷贝——所以真正该关心的是热点路径上反复 substring 的分配压力,不是泄漏。
intern() 把字符串登记进字符串表(JDK 7 起位于堆内,此前在永久代),返回表内实例。它的正常用途是"同一份值在大量对象间共享",不是常规性能优化:
// 适合:字段值来自有限集合(省份、状态码、枚举名),且实例数量巨大
String status = raw.intern();
// 不适合:用户输入、日志文本、UUID —— 只会把表撑大且几乎不命中
字符串表的桶数可用 -XX:StringTableSize 调整,默认在 6 万量级;intern() 的代价是一次哈希查找加可能的表扩容,在并发下还会争抢。把一个每次请求都不同的串 intern() 进来,等于给字符串表做无上限写入。
4. 作为 HashMap key、以及不可擦除的敏感信息
String 是 HashMap key 的最优解,原因不是"常用",而是三条机制同时成立:
| 特性 | 对 HashMap 的意义 |
|---|---|
hashCode 缓存 |
同一个 key 多次查找不重复计算 |
| 不可变 | 入桶后哈希永不变,条目不会"消失" |
equals 先比引用再比长度再比内容 |
同池实例走最快路径 |
反过来,用 StringBuilder 当 key 是反面教材:StringBuilder 没有重写 equals/hashCode,且内容可变,放进去就再也取不出来。
敏感信息(口令、密钥、身份证号)放 String 的问题是无法擦除:字符串可能已被 intern() 进常量池、可能被 JIT 常量折叠进代码、可能被复制到别处,你没有任何 API 能把它清零。
char[] pwd = readPassword();
try {
authenticate(pwd);
} finally {
Arrays.fill(pwd, '\0'); // 用完立刻清零,GC 前窗口最小
}
char[] 不是"更安全",只是"可主动擦除"。它的代价是 API 兼容性差(大量库只收 String)、容易在转换时又生成一个 String 副本。取舍很明确:口令校验这类短生命周期场景值得用 char[];普通业务字段用 String,别再自我安慰式地 intern()。
日志拼接是另一个日常失血点:
// 反例:debug 关闭时照样拼接
log.debug("order=" + order.getId() + ", amount=" + order.getAmount());
// 正例:参数化,关闭时不求值(SLF4J 的 {} 占位符)
log.debug("order={}, amount={}", order.getId(), order.getAmount());
注意参数化只在"参数不需要预计算"时省事:log.debug("x={}", heavy()) 里的 heavy() 依然会被调用,因为它先求值再传参。
5. 排错:五个"字符串相关"的性能与正确性问题
| 现象 | 根因 | 处理 |
|---|---|---|
| 大列表拼字符串时 CPU 飙高、GC 频繁 | 循环内 +=,平方级复制 |
改 StringBuilder,预分配容量 |
map.get(key) 偶发 null |
key 用了可变对象(StringBuilder、char[]) |
换 String 或不可变值对象 |
| 日志量一大就拖慢接口 | 关闭级别仍在拼接字符串 | 参数化日志,或 isDebugEnabled() 包裹 |
内存里大量 1MB 级 char[]/byte[] 不释放 |
老代码沿用 JDK 6 的 substring 经验 |
实际已是复制语义,查真正持有者 |
字符串表膨胀、intern() 变慢 |
对高基数字符串调用 intern() |
只对有限枚举值 intern() |
6. 边界:什么时候不必为 String 操心
| 场景 | 选择 | 理由 |
|---|---|---|
| 单次拼接 3~5 个片段 | 直接用 + |
编译期已优化,可读性更好 |
| 循环内累积 | StringBuilder |
避免平方级复制 |
| 多线程共享拼接缓冲 | StringBuffer |
同步代价换正确性,通常应改局部 builder |
| 需要保留明文并可擦除 | char[] |
String 无法清零 |
| 需要按内容判等的长生命周期 key | String |
哈希缓存 + 不可变 |
| 需要频繁切片的大文本 | 记住 substring 是复制 |
必要时用偏移量(CharSequence 视图)避免拷贝 |
| 拼接 SQL / JSON | 用模板引擎或参数绑定 | 字符串拼装既慢又易注入 |
StringBuffer 那一行值得单独说:它同步的是 append 方法,只保证单次调用原子,多步拼接仍需外部同步,所以在绝大多数场景里它既没换来正确性、又付了锁开销。
7. 自测
String不可变带来的四个收益,分别对应哪个具体机制?"a" + "b"与"a" + x在编译期被处理成什么?两者为什么一个是零开销、一个不是?- JDK 9 起
+的字节码变成什么?为什么这不算"运行期自动优化"? - 循环里
+=的时间复杂度是多少?StringBuilder预分配容量解决的是哪部分开销? - "
substring会导致内存泄漏"这条说法从哪个版本起失效?现在该关心什么? intern()适合什么场景?把用户输入intern()会发生什么?