String 的不可变性
Java 中的 String 被设计为不可变(Immutable),这是一个影响深远的设计决策。它不仅仅是"用 final 修饰一下"这么简单,而是与字符串常量池、哈希缓存、线程安全、JVM 内存模型深度耦合。理解不可变性,是理解 Java 字符串体系的第一步。
不可变性的源码实现
我们先从源码层面看 String 是怎么做到不可变的。
JDK 8 的实现
public final class String implements java.io.Serializable, Comparable<String> {
/** 存储字符串内容的字符数组 */
private final char value[];
/** 缓存的哈希值 */
private int hash; // 默认值为 0
}
这里有三层防护:
- 类被
final修饰:防止子类通过继承来覆写方法,破坏不可变性 value数组被private修饰:外部无法直接访问底层数组value数组被final修饰:引用一旦指向某个数组对象,就不能再指向其他数组
但要注意一个常见误解:final 只保证引用不可变,不保证数组内容不可变。也就是说,final char[] 只是让 value 这个变量不能指向另一个数组,但数组里的元素理论上是可以修改的。String 的不可变性,最终靠的是所有方法都不对 value 数组做任何修改——每个看似"修改"的方法(如 substring()、replace()、concat())都会创建一个新的 String 对象返回。
比喻:
final就像把钥匙焊死在锁上——你不能换一把锁(换引用),但有人翻窗户进去改里面的东西(改数组元素)理论上是可以的。String 的真正防护是把所有窗户也焊死了(不提供任何修改value的方法)。
JDK 9+ 的 Compact Strings
历史背景:从 Compressed Strings 到 Compact Strings
其实早在 JDK 6 Update 21 中,Sun 就尝试过一个类似的优化——Compressed Strings(-XX:+UseCompressedStrings)。那个方案的思路和现在类似,也是用 byte[] 存储纯 ASCII 字符串。但它有一个致命问题:它是在 JVM 层面做的 hack,而不是对 String 类本身的重新设计。这意味着大量 String 的内部方法需要在 char[] 和 byte[] 之间做判断和转换,导致性能回退严重。最终这个特性在 JDK 7 中被移除了。
JDK 9 的 JEP 254(Compact Strings) 吸取了教训,选择了一条更彻底的路线——直接重写 String 类的内部实现,将编码信息嵌入 String 的字段中,并把所有字符操作逻辑委托给专门的辅助类。
核心改动:从 char[] 到 byte[] + coder
public final class String implements java.io.Serializable, Comparable<String> {
/** 存储字符串内容的字节数组(不再是 char[]) */
private final byte[] value;
/** 编码标识:0 = Latin-1,1 = UTF-16 */
private final byte coder;
/** 缓存的哈希值 */
private int hash;
// 内部常量
static final byte LATIN1 = 0;
static final byte UTF16 = 1;
}
这里的 LATIN1 和 UTF16 不是随便选的两种编码,它们背后是整个字符编码体系演进的结果。要理解 Compact Strings 为什么这样设计,需要先搞清楚这些编码方案是怎么来的。
字符编码的演进:从 ASCII 到 Unicode
1. ASCII(1963 年)——一切的起点
ASCII(American Standard Code for Information Interchange)是最早的字符编码标准,用 7 位二进制(0-127)表示 128 个字符:
码值范围 内容
0 - 31 控制字符(换行、回车、制表符等)
32 - 47 标点和符号(空格、!、"、#...)
48 - 57 数字 0-9
65 - 90 大写字母 A-Z
97 - 122 小写字母 a-z
128 个字符 对英语来说够用了,但无法表示其他语言
ASCII 的问题很明显:只够英语用。法语的 é、德语的 ü、中文的"你"——统统无法表示。
2. Latin-1 / ISO-8859-1(1987 年)——扩展到西欧
Latin-1 将 ASCII 的 7 位扩展为 8 位(0-255),完全兼容 ASCII(前 128 个码值完全一致),多出的 128-255 码位用来表示西欧语言的特殊字符:
ASCII 区域(0-127):与 ASCII 完全相同
'A' = 65, 'z' = 122, '0' = 48 ...
扩展区域(128-255):西欧特殊字符
'é' = 233 (法语)
'ü' = 252 (德语)
'ñ' = 241 (西班牙语)
'©' = 169 (版权符号)
'±' = 177 (正负号)
Latin-1 覆盖了英语、法语、德语、西班牙语、葡萄牙语等大部分西欧语言。每个字符恰好 1 字节——这就是为什么 Java 的 Compact Strings 用 LATIN1 的编码模式时,每个字符只需要 1 字节。
但 Latin-1 仍然只有 256 个字符,对于中文(几万个汉字)、日文(假名 + 汉字)、韩文(谚文)等语言,远远不够。
3. Unicode(1991 年)——统一全球字符
Unicode 的目标是给世界上所有语言的所有字符分配一个唯一的编号。
码点(Code Point)范围:U+0000 到 U+10FFFF(共 1,114,112 个位置)
已分配字符超过 14 万个,涵盖:
U+0000 - U+007F 基本拉丁文(= ASCII)
U+0080 - U+00FF 拉丁文补充(= Latin-1 扩展区)
U+4E00 - U+9FFF CJK 统一汉字(常用汉字约 2 万个)
U+0400 - U+04FF 西里尔字母(俄语等)
U+1F600 - U+1F64F 表情符号 😀😎🤔
...
关键区分:Unicode 本身只是一张字符 → 编号的映射表("码点表"),它不规定这些编号在内存中怎么存储。具体怎么把码点编码成字节序列,由下面的编码方案来决定。
4. UTF-16——Java 的默认选择
UTF-16 是一种变长编码,用 2 字节或 4 字节表示一个 Unicode 字符。要理解 UTF-16 的编码规则,需要先知道 Unicode 的"平面"结构。
Unicode 将全部码点空间(U+0000 到 U+10FFFF)划分为 17 个平面(Plane),每个平面包含 65536(2¹⁶)个码位:
| 平面编号 | 码点范围 | 名称 | 内容 |
|---|---|---|---|
| 第 0 平面 | U+0000 - U+FFFF | 基本多语言平面(BMP) | 几乎所有现代语言的常用字符:拉丁字母、汉字、假名、谚文、标点、数学符号等 |
| 第 1 平面 | U+10000 - U+1FFFF | 补充多语言平面(SMP) | 表情符号 😀、古埃及文、音乐符号等 |
| 第 2 平面 | U+20000 - U+2FFFF | 补充表意文字平面(SIP) | 罕见汉字(如"𠮷") |
| 第 3-16 平面 | U+30000 - U+10FFFF | 其余补充平面 | 大部分尚未使用 |
BMP(Basic Multilingual Plane,基本多语言平面) 就是第 0 号平面,码点范围 U+0000 到 U+FFFF。它之所以叫"基本",是因为日常使用中 99% 以上的字符都在这个平面内——你能在键盘上打出来的字符、中文、日文、韩文的常用字符,基本都在 BMP 里。超出 BMP 的字符(如 emoji、罕见汉字)需要用到补充平面。
UTF-16 就是基于这个结构设计的编码方案:
基本多语言平面(BMP,U+0000 - U+FFFF):
直接用 2 字节(一个 16 位编码单元)表示
涵盖了绝大多数常用字符(汉字、字母、符号等)
补充平面(U+10000 - U+10FFFF):
用 4 字节(一对代理对 / Surrogate Pair)表示
包括罕见汉字、表情符号等
高代理:U+D800 - U+DBFF(前 2 字节)
低代理:U+DC00 - U+DFFF(后 2 字节)
代理区的精妙设计:不需要额外信息
你可能会问:一个 16 位的值摆在面前,解码器怎么知道它是一个独立的 BMP 字符,还是代理对的前半部分或后半部分?是不是需要额外的标记位来区分?
答案是:不需要任何额外信息。UTF-16 的设计有一个精妙之处——Unicode 标准特意将 U+D800 到 U+DFFF 这 2048 个码位永久保留,不分配给任何真实字符。这个范围叫做代理区(Surrogate Area),它的唯一用途就是在 UTF-16 中充当代理对的标记。
Unicode 码点空间中的代理区:
U+0000 ─────── U+D7FF 正常 BMP 字符(可分配给真实字符)
U+D800 ─────── U+DBFF 高代理区 ──┐
├── 代理区(永远不会是真实字符)
U+DC00 ─────── U+DFFF 低代理区 ──┘
U+E000 ─────── U+FFFF 正常 BMP 字符(可分配给真实字符)
这样一来,解码器只需要看一个 16 位值落在哪个范围,就能立刻判断它的身份:
读取一个 16 位值 x:
如果 x < 0xD800 或 x > 0xDFFF → 它是一个独立的 BMP 字符,直接使用
如果 0xD800 ≤ x ≤ 0xDBFF → 它是高代理(代理对的前半部分)
继续读取下一个 16 位值作为低代理
如果 0xDC00 ≤ x ≤ 0xDFFF → 它是低代理(代理对的后半部分)
应该和前面的高代理配对
举一个具体的例子——表情 😀(U+1F600)的编码过程:
第 1 步:码点 U+1F600 > U+FFFF,需要代理对
第 2 步:计算代理对
偏移量 = 0x1F600 - 0x10000 = 0xF600
偏移量的二进制 = 0000 1111 0110 0000 0000(20 位)
高 10 位 = 0000111101 = 0x03D
低 10 位 = 1000000000 = 0x200
高代理 = 0xD800 + 0x03D = 0xD83D
低代理 = 0xDC00 + 0x200 = 0xDE00
第 3 步:存储为两个 16 位值
[0xD83D, 0xDE00]
解码时:
读到 0xD83D → 落在 0xD800-0xDBFF 范围 → 这是高代理
读到 0xDE00 → 落在 0xDC00-0xDFFF 范围 → 这是低代理
还原码点 = 0x10000 + (0x03D << 10) + 0x200 = 0x1F600 = 😀 ✓
类比:这就像交通信号灯——你不需要额外的牌子告诉你"这是信号灯",因为红绿黄三种颜色本身就不会出现在其他地方。同样,0xD800-0xDFFF 这个范围的值在 Unicode 中不会是任何真实字符,所以它们天然就是"信号灯"——看到就知道这是代理对的一部分。
这种设计叫做**自同步(Self-synchronizing)**编码:你可以从字节流的任意位置开始读取,只需检查当前 16 位值的范围,就能立即判断是独立字符、代理对的开头,还是代理对的结尾,不需要从头扫描。
Java 的 char = UTF-16 编码单元,不是"字符"
这里有一个历史上的重要误解需要澄清。Java 1.0(1996 年)设计 char 时,Unicode 标准还只定义了不到 65536 个字符,全部落在 BMP 范围内。当时一个 16 位的 char 确实能表示所有 Unicode 字符,所以 Java 的设计者理所当然地让 1 个 char = 1 个字符。
但后来 Unicode 不断扩展,加入了补充平面(Supplementary Planes)的字符——表情符号、罕见汉字(如"𠮷")、古文字等。这些字符的码点超过了 U+FFFF,一个 16 位的 char 装不下了。
Java 的解决方案是:char 的定义不变(仍然是 16 位),但它的语义从"一个字符"降级为"一个 UTF-16 编码单元(Code Unit)"。对于补充平面的字符,用两个 char(一个代理对)来表示:
String emoji = "😀"; // U+1F600,补充平面字符
// char 层面:这个"字符"其实由两个 char 组成
System.out.println(emoji.length()); // 2(不是 1!)
System.out.println(emoji.charAt(0)); // ?(高代理 U+D83D,无意义的半个字符)
System.out.println(emoji.charAt(1)); // ?(低代理 U+DE00,无意义的半个字符)
// 码点层面:才是真正的字符计数
System.out.println(emoji.codePointCount(0, emoji.length())); // 1(正确!)
System.out.println(emoji.codePointAt(0)); // 128512(= 0x1F600,正确的码点)
// 再看一个混合例子
String mixed = "A😀B"; // 3 个字符
System.out.println(mixed.length()); // 4(A=1 + 😀=2 + B=1)
System.out.println(mixed.codePointCount(0, mixed.length())); // 3
"A😀B" 在内存中的 char[] 表示:
索引: [0] [1] [2] [3]
char: 'A' U+D83D U+DE00 'B'
│ └──┬──┘ │
│ 代理对 = 😀 │
│ (1个字符占2个char) │
└── 1个字符 ──┘ └── 1个字符 ──┘
length() = 4 但实际字符数 = 3
这意味着什么? 当我们说 Java 的
char是"固定 2 字节"时,说的是每个 Code Unit 固定 2 字节,而不是每个字符固定 2 字节。UTF-16 本身仍然是变长编码——BMP 字符用 1 个 Code Unit(2 字节),补充平面字符用 2 个 Code Unit(4 字节)。
同样,在 JDK 9+ 的 Compact Strings 中,UTF-16 模式下的 byte[] 数组存储的也是 Code Unit 序列:
"😀" 在 UTF-16 模式下的 byte[] 存储:
coder = UTF16 (1)
byte[]: [0xD8, 0x3D, 0xDE, 0x00] ← 4 字节 = 2 个 Code Unit
└── 高代理 ──┘ └── 低代理 ──┘
如果你需要正确处理包含 emoji 或罕见汉字的字符串,应该使用码点级别的 API:
String text = "Hello😀世界";
// ❌ 错误:用 charAt 遍历(会把代理对拆成两个无意义的 char)
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i); // 遍历到 😀 时会得到两个残缺的 char
}
// ✅ 正确:用 codePoints() 流遍历(Java 8+)
text.codePoints().forEach(cp -> {
System.out.println(Character.toString(cp)); // 每次得到一个完整字符
});
// ✅ 正确:用 codePointAt + offsetByCodePoints 遍历
for (int i = 0; i < text.length(); ) {
int cp = text.codePointAt(i);
System.out.println(Character.toString(cp));
i += Character.charCount(cp); // BMP 字符跳 1,补充字符跳 2
}
5. UTF-8——互联网的事实标准
UTF-8 是一种变长编码,用 1-4 字节表示一个 Unicode 字符。它的核心设计思想是:用每个字节的高位前缀来标识这个字节的角色。
前缀位规则:
字节数 首字节格式 后续字节格式 可用数据位 码点范围
─────────────────────────────────────────────────────────────────
1 字节 0xxxxxxx (无) 7 位 U+0000 - U+007F
2 字节 110xxxxx 10xxxxxx 11 位 U+0080 - U+07FF
3 字节 1110xxxx 10xxxxxx ×2 16 位 U+0800 - U+FFFF
4 字节 11110xxx 10xxxxxx ×3 21 位 U+10000 - U+10FFFF
关键在于首字节的前缀:
0开头(0xxxxxxx):这是一个单字节字符,后面 7 位就是数据——完全等同于 ASCII110开头(110xxxxx):这是一个 2 字节序列的起始字节1110开头(1110xxxx):这是一个 3 字节序列的起始字节11110开头(11110xxx):这是一个 4 字节序列的起始字节10开头(10xxxxxx):这是一个后续字节(continuation byte),不是独立的起始
规律:首字节开头有几个连续的
1,这个字符就由几个字节组成(1 字节的情况用0开头表示特殊)。后续字节统一用10开头,和首字节不会混淆。
编码示例:汉字 "你"(U+4F60)
第 1 步:确定字节数
U+4F60 落在 U+0800 - U+FFFF 范围 → 需要 3 字节
模板:1110xxxx 10xxxxxx 10xxxxxx(共 16 个数据位)
第 2 步:将码点转为二进制
0x4F60 = 0100 1111 0110 0000(16 位)
第 3 步:将二进制位填入模板
0100 111101 100000
↓ ↓ ↓
1110 0100 10 111101 10 100000
───────── ───────── ─────────
0xE4 0xBD 0xA0
结果:"你" 的 UTF-8 编码 = [0xE4, 0xBD, 0xA0](3 字节)
再看一个单字节的例子:字母 "A"(U+0041)
U+0041 = 0100 0001(7 位以内)→ 1 字节
模板:0xxxxxxx
填入:0 1000001 = 0x41
结果与 ASCII 完全一致!这就是 UTF-8 兼容 ASCII 的原因。
UTF-8 也是自同步编码
和 UTF-16 的代理区设计类似,UTF-8 的前缀位设计也实现了自同步——你可以从字节流的任意位置开始读取,只看一个字节的前缀就能判断它的角色:
读取一个字节 b:
如果 b 以 0 开头 → 它是一个完整的单字节字符
如果 b 以 10 开头 → 它是后续字节(当前位于某个多字节序列的中间)
如果 b 以 110 开头 → 它是 2 字节序列的起始
如果 b 以 1110 开头 → 它是 3 字节序列的起始
如果 b 以 11110 开头→ 它是 4 字节序列的起始
这意味着即使数据传输中丢失了几个字节,解码器也能快速恢复同步,不会导致后续所有字符都解析错误。
UTF-8 是目前互联网上使用最广泛的编码(Web 页面的 95% 以上使用 UTF-8),主要因为它对 ASCII 完全兼容,英文文本零膨胀。但 Java 的 String 内部没有使用 UTF-8——因为 UTF-8 是变长的,第 n 个字符的位置无法用 O(1) 时间计算(必须从头扫描),这会让 charAt(n) 等操作变得很慢。
编码方案对比
| 编码 | 年份 | 字符数 | 字节/字符 | ASCII 兼容 | 特点 |
|---|---|---|---|---|---|
| ASCII | 1963 | 128 | 1 | ✅ 就是 ASCII | 仅英文 |
| Latin-1 | 1987 | 256 | 1 | ✅ 前 128 位相同 | 覆盖西欧语言 |
| UTF-16 | 1996 | 全部 Unicode | 2 或 4 | ❌ | Java/Windows 内部使用 |
| UTF-8 | 1993 | 全部 Unicode | 1-4 | ✅ 前 128 位相同 | 互联网标准 |
UTF-8 vs UTF-16:深度对比
UTF-8 和 UTF-16 都能表示全部 Unicode 字符,但它们的编码策略完全不同,导致了各自擅长的场景也不同。
同一段文字在两种编码下的字节对比:
字符串 "Hello你好"(5 个英文 + 2 个中文)
UTF-8 编码:
'H' → 0x48 (1 字节)
'e' → 0x65 (1 字节)
'l' → 0x6C (1 字节)
'l' → 0x6C (1 字节)
'o' → 0x6F (1 字节)
'你' → 0xE4 0xBD 0xA0 (3 字节)
'好' → 0xE5 0xA5 0xBD (3 字节)
总计:5 + 6 = 11 字节
UTF-16 编码:
'H' → 0x00 0x48 (2 字节)
'e' → 0x00 0x65 (2 字节)
'l' → 0x00 0x6C (2 字节)
'l' → 0x00 0x6C (2 字节)
'o' → 0x00 0x6F (2 字节)
'你' → 0x4F 0x60 (2 字节)
'好' → 0x59 0x7D (2 字节)
总计:14 字节
对比:UTF-8 省了 3 字节(英文部分省 5 字节,中文部分多用 4 字节)
纯中文字符串 "你好世界"(4 个汉字)
UTF-8: 4 × 3 = 12 字节(汉字在 UTF-8 中需要 3 字节)
UTF-16: 4 × 2 = 8 字节 (汉字在 UTF-16 中只需 2 字节)
对比:纯中文场景下 UTF-16 反而更紧凑!
核心差异总结:
| 对比维度 | UTF-8 | UTF-16 |
|---|---|---|
| 英文(ASCII)字符 | 1 字节 ✅ 最优 | 2 字节 |
| 中日韩汉字 | 3 字节 | 2 字节 ✅ 更优 |
| 表情符号 | 4 字节 | 4 字节(代理对) |
| ASCII 兼容性 | ✅ 完全兼容 | ❌ 不兼容(字节中会出现 0x00) |
随机访问 charAt(n) |
❌ O(n)(变长,需从头扫描) | ⚠️ O(1)(BMP 内)/ O(n)(含代理对) |
| 字节序问题 | ✅ 无(逐字节读取) | ❌ 有(大端/小端,需要 BOM 标记) |
| 网络传输/存储 | ✅ 主流(HTTP、JSON、HTML) | ⚠️ 较少用于传输 |
| 内存中操作 | ⚠️ 不适合频繁随机访问 | ✅ 适合作为内部表示 |
一句话总结:UTF-8 适合存储和传输(对英文友好、无字节序问题、兼容 ASCII),UTF-16 适合内存中操作(BMP 字符定长 2 字节,支持高效的随机访问)。这就是为什么互联网用 UTF-8,而 Java/Windows/JavaScript 内部用 UTF-16。
为什么 Java 的 String 只需要 Latin-1 和 UTF-16
现在回头看 Compact Strings 的设计就很清晰了:
- Latin-1:1 字节/字符,能覆盖大部分实际应用中的字符串(英文、URL、JSON key、HTTP Header 等)
- UTF-16:2 字节/字符(BMP 内),能覆盖所有 Unicode 字符
不用 UTF-8 是因为变长编码让随机访问变成 O(n);不用 ASCII 是因为 Latin-1 完全兼容 ASCII 且覆盖范围更广。Latin-1 + UTF-16 = 最小字节数 + 全覆盖,这就是 Compact Strings 选择这两种编码的原因。
与 JDK 8 相比,有两个关键变化:
- 存储类型从
char[]变为byte[]:统一用字节数组存储,不再假设每个字符都是 2 字节 - 新增
coder字段:标识当前字符串使用的编码方式——LATIN1(0)或UTF16(1)
为什么要改? 因为 Oracle 对大量生产环境的 Java 应用做了调研,发现绝大多数字符串只包含 Latin-1 字符(码值 0-255,涵盖 ASCII 及西欧字符)。在 JDK 8 中,每个字符固定用 char(2 字节 / UTF-16)存储,对于纯英文字符串,一半的内存都是 0x00 的填充,纯属浪费。
编码选择:创建时的"压缩尝试"
创建字符串时,JVM 会先尝试将内容压缩为 Latin-1 编码。具体流程如下:
创建字符串时的判断流程:
输入字符数据
│
▼
遍历所有字符,检查是否每个字符的码值 ≤ 255
│
┌────┴────┐
│ 是 │ 否
▼ ▼
coder=0 coder=1
LATIN1 UTF16
1字节/字符 2字节/字符
这个压缩尝试由 StringUTF16.compress() 方法完成:
// JDK 源码简化版
public static int compress(char[] src, int srcOff, byte[] dst, int dstOff, int len) {
for (int i = 0; i < len; i++) {
char c = src[srcOff++];
if (c > 0xFF) { // 码值超过 255,无法用 Latin-1 表示
return 0; // 压缩失败,回退到 UTF-16
}
dst[dstOff++] = (byte) c; // 安全截断为 1 字节
}
return len; // 压缩成功
}
关键细节:这个压缩尝试发生在字符串创建时(构造器中),不是运行时动态切换的。一旦创建完成,coder 就是 final 的,不会再改变。
实际效果:
创建字符串 "hello":
- 所有字符都在 Latin-1 范围 → coder = 0
- 每个字符用 1 字节存储 → byte[5]
- 内存: 5 字节(JDK 8 是 10 字节,节省 50%)
创建字符串 "café":
- 所有字符(c=99, a=97, f=102, é=233)都 ≤ 255 → coder = 0
- 每个字符用 1 字节存储 → byte[4]
- 内存: 4 字节(JDK 8 是 8 字节,节省 50%)
创建字符串 "你好":
- '你' = 20320,超出 Latin-1 范围 → 压缩失败 → coder = 1
- 回退到 UTF-16 编码 → byte[4](2 字符 × 2 字节)
- 内存: 4 字节(与 JDK 8 相同,无额外开销)
创建字符串 "hello你好":
- 存在非 Latin-1 字符 → 压缩失败 → coder = 1
- 整个字符串用 UTF-16 → byte[14](7 字符 × 2 字节)
- 注意:即使前 5 个字符可以用 1 字节,也不会"混合编码"
注意:编码选择是全有或全无的。只要有一个字符超出 Latin-1 范围,整个字符串就回退到 UTF-16。不存在"前半段 Latin-1,后半段 UTF-16"的混合编码。
内部委托机制:StringLatin1 和 StringUTF16
JDK 9 的 String 类本身不再直接操作 byte[] 数组中的字符数据。而是根据 coder 的值,将操作委托给两个内部辅助类:
java.lang.StringLatin1:处理 Latin-1 编码的字符串(1 字节/字符)java.lang.StringUTF16:处理 UTF-16 编码的字符串(2 字节/字符)
以 charAt() 方法为例:
// String.java(JDK 9+)
public char charAt(int index) {
if (isLatin1()) {
return StringLatin1.charAt(value, index);
} else {
return StringUTF16.charAt(value, index);
}
}
boolean isLatin1() {
return coder == LATIN1;
}
// StringLatin1.java
public static char charAt(byte[] value, int index) {
// Latin-1:直接取一个字节,零扩展为 char
return (char)(value[index] & 0xff);
}
// StringUTF16.java
public static char charAt(byte[] value, int index) {
// UTF-16:取两个字节,组合为一个 char
// index * 2 和 index * 2 + 1 分别是高低字节
return getChar(value, index);
}
这种设计模式贯穿了 String 的所有方法——equals()、hashCode()、compareTo()、indexOf()、substring() 等全部采用类似的委托结构:
// equals() 方法
public boolean equals(Object anObject) {
// ...类型检查等...
String aString = (String) anObject;
if (coder() == aString.coder()) {
// 编码相同,直接比较底层字节数组
return isLatin1()
? StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
// 编码不同(一个 Latin1 一个 UTF16),需要逐字符比较
return false; // 优化:Latin1 和 UTF16 不可能 equals(实际逻辑更复杂)
}
// hashCode() 方法
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
hash = h = isLatin1()
? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
}
return h;
}
比喻:这就像一个翻译中心,有两个翻译团队(StringLatin1 和 StringUTF16)。客户(String 的调用方)把文档(字符串操作请求)交过来时,前台(String 类)看一下文档是英文(Latin1)还是中文(UTF16),然后分配给对应的团队处理。客户完全不需要知道后面有两个团队。
性能影响分析
Compact Strings 的优化效果不仅仅是"省了点内存"那么简单,它对整个运行时性能都有深远影响:
1. 内存占用大幅下降
对于以英文为主的应用(如 Web 服务、JSON 解析、日志处理),字符串的内存占用可以减少 30%-50%。整体堆内存占用通常下降 10%-30%。
2. CPU 缓存命中率提升
字符串占的内存更小 → 同样大小的 CPU L1/L2/L3 缓存能放下更多字符串 → 缓存命中率提高 → 字符串操作(查找、比较、哈希计算)变快。
JDK 8: "hello world" → char[11] = 22 字节
一个 64 字节的缓存行只能放 2-3 个这样的字符串
JDK 9+: "hello world" → byte[11] = 11 字节
一个 64 字节的缓存行能放 5-6 个这样的字符串
3. GC 压力降低
字符串占用更少的堆空间意味着 GC 触发频率降低、每次 GC 需要扫描和复制的数据量减小,GC 停顿时间相应缩短。
4. SIMD 向量化优化
对于两个 Latin-1 字符串的 equals() 比较,底层可以直接比较两个 byte[] 数组,JVM 可以利用 CPU 的 SIMD(Single Instruction, Multiple Data)指令一次比较多个字节,速度可以达到逐字符比较的 2 倍以上。类似地,hashCode() 对 Latin-1 字符串直接操作 byte[],可以减少约 15% 的 CPU 开销。
对中文/日文/韩文应用的影响
如果你的应用主要处理 CJK(中日韩)字符,Compact Strings 的内存优化效果不明显,因为这些字符的码值都大于 255,字符串会回退到 UTF-16 编码,与 JDK 8 相同。
但有一个细微的性能开销需要注意:每次创建字符串时,JVM 都会先尝试 Latin-1 压缩。对于明显包含非 Latin-1 字符的字符串,这次尝试必然失败,但仍然会消耗少量 CPU 时间来遍历字符做检查。
在实践中,这个开销通常可以忽略不计。但如果你的应用极端敏感(比如高频创建大量中文字符串的场景),可以通过 JVM 参数 -XX:-CompactStrings 禁用此特性,跳过压缩尝试,直接使用 UTF-16 编码。不过大多数情况下不需要这样做——JIT 编译器会对这个判断做优化。
这个优化对开发者完全透明——String 的公共 API 没有任何变化,不需要修改任何业务代码。
为什么要设计成不可变
String 的不可变性不是随便选的,它是多个核心需求共同作用的结果。
字符串常量池
JVM 在堆内存中维护了一个特殊的区域叫字符串常量池(String Pool),用于存放字符串字面量。当你用双引号创建字符串时,JVM 会先检查池中是否已有相同内容的字符串:
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true,指向池中同一个对象
堆内存
┌─────────────────────────────────┐
│ 字符串常量池 │
│ ┌─────────┐ │
│ │ "hello" │ ← s1 和 s2 都指向这里 │
│ └─────────┘ │
└─────────────────────────────────┘
这种共享机制能大幅节省内存,但前提是字符串不可变。如果 s1 能修改 "hello" 的内容,s2 看到的也会变——这就像两个人共用一本日记,一个人偷偷改了内容,另一个人读到的就是错的。
常量池位置的变迁:JDK 6 及之前,常量池在永久代(PermGen),容量有限容易 OOM。JDK 7 将常量池移到堆中,与普通对象一起接受 GC 管理。JDK 8 移除了永久代,用元空间(Metaspace)替代,但常量池仍在堆中。
哈希值缓存
String 是 HashMap 中最常用的 key 类型。每次调用 hashCode() 时,如果都要重新遍历字符数组计算,性能开销会很大。由于 String 不可变,哈希值只需要计算一次就可以缓存起来:
public int hashCode() {
int h = hash; // 读取缓存值
if (h == 0 && value.length > 0) { // 未计算过
// JDK 8
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h; // 缓存结果
}
return h;
}
注意这里用了一个巧妙的延迟计算策略:hash 字段初始值为 0,只有第一次调用 hashCode() 时才计算。之后直接返回缓存值,时间复杂度从 O(n) 降到 O(1)。
你可能会问:哈希值为 0 的字符串呢? 确实存在这种情况(比如空字符串),但这只会导致每次都重新计算,不会产生错误结果——是一个用极小的概率换巨大性能收益的权衡。
线程安全
不可变对象天然是线程安全的。多个线程可以同时读取同一个 String 对象,无需加锁、无需同步。这在高并发场景下意义重大——如果 String 可变,那么每次传递、共享字符串都要考虑线程安全问题,代码复杂度会急剧上升。
安全性
String 在很多安全敏感的场景中被使用:
- 数据库连接 URL
- 文件路径
- 类加载器的类名
- 网络连接的主机名
如果字符串是可变的,攻击者就可以在安全检查通过后、实际使用前,修改字符串对象内部的内容,绕过安全检查。这种攻击叫做 TOCTOU(Time-of-check to time-of-use,检查时与使用时的时差攻击)。
理解这个攻击的前提:什么是"修改字符串内容"
首先要区分两件完全不同的事:
-
对变量重新赋值(
path = "/etc/passwd"):只是把path这个局部变量指向了一个新的字符串对象,原来那个对象完全没变。而且path是方法的局部变量,存在于调用栈上,其他线程根本无法访问另一个线程的局部变量,这种"重新赋值"的攻击根本不可能发生。 -
修改字符串对象内部的内容:如果字符串是可变的,攻击者可以直接找到
path引用的那个对象,把它内部存储的字节数组从"/home/passwd"改成"/etc/passwd"。path变量本身没动,但它指向的对象的内容变了。
后者才是真正的威胁。我们来看对比:
假设 String 是可变的(危险场景)
线程 A(正常逻辑) 线程 B(攻击者)
─────────────────────────────────────────────────────────
1. openFile(path) 被调用
path ──→ [ String 对象, 内容: "/home/passwd" ]
2. securityCheck(path) 通过 ✅
(路径是合法的 /home/passwd)
3. ⚠️ 此刻线程 B 抢占执行 ────────────────→ 找到同一个 String 对象
直接修改其内部字节数组:
"/home/passwd" → "/etc/passwd"
4. open(path) 执行
path ──→ [ String 对象, 内容: "/etc/passwd" ]
❌ 实际打开了敏感文件!
攻击能成功,是因为 path 变量和线程 B 拿到的引用,指向的是同一个对象。如果这个对象的内容可以被修改,安全检查的结论就会失效。
实际情况:String 不可变(安全)
void openFile(String path) {
// path ──→ [ String 对象, value: final byte[]{'/','h','o','m','e',...} ]
// ↑
// value 是 private final,没有 setter
// 任何线程都无法修改这个数组的内容
if (securityCheck(path)) { // 检查通过:"/home/passwd" 合法
// 无论其他线程做什么,path 指向的对象内容永远不会改变
open(path); // 打开的一定还是 "/home/passwd" ✅
}
}
由于 String 内部的 value 字段是 private final byte[],且没有任何公开的方法能修改数组的内容,攻击者在拿到同一个 String 对象引用后,没有任何手段能改变它存储的字符序列。从检查到使用,字符串的内容被"冻结"了,TOCTOU 攻击从根源上无法成立。
new String("abc") 创建了几个对象
这是系统底层设计的经典问题,答案取决于字符串常量池的状态。
情况一:常量池中已有 "abc"
String s0 = "abc"; // 常量池中创建了 "abc"
String s1 = new String("abc"); // 只在堆上创建 1 个新对象
new String("abc") 只创建了 1 个对象——堆上的 String 实例。它的 value 数组会引用常量池中 "abc" 已有的字符数组(JDK 8 下共享 char[])。
情况二:常量池中没有 "abc"
String s1 = new String("abc"); // 创建 2 个对象
运行期共创建 2 个对象:①编译器将字面量 "abc" 写入 .class 文件的常量池(CONSTANT_String_info 条目),JVM 在首次执行到该字面量(ldc 指令)时,才在字符串常量池中创建对应的 String 对象(第 1 个);②执行到 new String("abc") 时,又在堆上额外创建一个新的 String 对象(第 2 个)。
堆内存
┌──────────────────────────────────┐
│ 字符串常量池 │
│ ┌───────────────┐ │
│ │ String "abc" │ ← 对象 1 │
│ │ value: [a,b,c] │ │
│ └───────────────┘ │
│ │
│ 堆(常量池外) │
│ ┌───────────────┐ │
│ │ String "abc" │ ← 对象 2 │
│ │ value: ───────────→ 共享数组 │
│ └───────────────┘ │
└──────────────────────────────────┘
s1 ─→ 对象 2(堆上的新对象)
字节码验证
可以用 javap -c 反编译查看字节码来验证这个过程:
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // 从常量池加载 "abc"
6: invokespecial #4 // 调用 String(String) 构造器
9: astore_1
ldc指令:加载常量池中的 "abc"(如果不存在则创建)new+invokespecial:在堆上创建新的 String 对象
字符串常量池的实现原理
前面多次提到「字符串常量池」,但它到底是什么数据结构?字符串字面量是什么时候进入池中的?JVM 内部又是如何做到快速查找和去重的?这一节我们深入到 HotSpot JVM 的实现层面来回答这些问题。
三层「常量池」——别搞混了
Java 中有三个容易混淆的概念都叫「常量池」,但它们的层次和作用完全不同:
┌─────────────────────────────────────────────────────────────────┐
│ 第 1 层:Class 文件常量池(Constant Pool) │
│ ───────────────────────────────────────────────────────────── │
│ 存在于 .class 文件中,编译期生成 │
│ 存放字面量(字符串、数值)和符号引用(类名、方法名、字段名等) │
│ 字符串字面量以 CONSTANT_Utf8 + CONSTANT_String 结构存储 │
│ 此时只是「文本描述」,还没有创建任何 Java 对象 │
└──────────────────────────────┬──────────────────────────────────┘
│ 类加载
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 2 层:运行时常量池(Runtime Constant Pool) │
│ ───────────────────────────────────────────────────────────── │
│ 每个类被加载后,JVM 在方法区(JDK 8+ 为元空间)为其创建 │
│ 是 Class 文件常量池的运行时表示 │
│ 符号引用在这里被逐步解析(resolve)为直接引用 │
│ 字符串条目最初仍是未解析状态,只有在被使用时才会触发解析 │
└──────────────────────────────┬──────────────────────────────────┘
│ 首次使用(ldc 指令)
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 3 层:字符串常量池 / StringTable │
│ ───────────────────────────────────────────────────────────── │
│ 全局唯一的哈希表,存放所有被 intern 的字符串的引用 │
│ JDK 7+ 位于堆中,存储的是指向堆中 String 对象的引用 │
│ 是实现字符串去重和共享的核心数据结构 │
└─────────────────────────────────────────────────────────────────┘
比喻:Class 文件常量池就像一份「食材采购清单」(只有名字),运行时常量池是「厨房备料台」(把清单上的东西找出来),StringTable 是「冰箱」(实际存放食材的地方,保证同一种食材只存一份)。
StringTable 的底层数据结构
在 HotSpot JVM 的 C++ 源码中,StringTable 是一个全局唯一的哈希表。它的实现经历了几个阶段:
| JDK 版本 | 底层数据结构 | 特点 |
|---|---|---|
| JDK 7 - JDK 10 | 固定大小的 Hashtable(C++ 实现) |
不可动态扩容,bucket 数固定 |
| JDK 11+ | ConcurrentHashTable |
支持并发访问,lock-free 读取 |
StringTable 的核心结构可以简化理解为:
StringTable(全局哈希表)
┌────────┬────────┬────────┬────────┬─── ... ───┬────────┐
│ bucket │ bucket │ bucket │ bucket │ │ bucket │
│ 0 │ 1 │ 2 │ 3 │ │ N-1 │
└───┬────┴───┬────┴────────┴───┬────┴─── ... ───┴────────┘
│ │ │
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│entry │ │entry │ │entry │
│"abc" │ │"xyz" │ │"hello"│
└──┬───┘ └──────┘ └──┬───┘
│ │ 堆内存
▼ ▼
┌──────┐ ┌──────┐ ┌────────────────┐
│entry │ │entry │ │ String 对象 │
│"def" │ │"world"│ │ value: [h,e,..]│
└──────┘ └──────┘ └────────────────┘
↑
每个 entry 内部持有一个 WeakHandle ──────────┘
(弱引用),指向堆中的 String 对象
关键设计要点:
- 哈希定位:对字符串内容计算哈希值,确定落入哪个 bucket。相同内容的字符串一定落在同一个 bucket 中
- 链表解决冲突:不同字符串可能哈希到同一个 bucket,通过链表(或更高级的结构)串联
- 弱引用(WeakHandle):JDK 7+ 中,StringTable 的 entry 持有的是指向堆中 String 对象的弱引用。这意味着如果一个 interned 的字符串不再被任何强引用指向,GC 可以回收它,对应的 entry 也会被清理。这避免了常量池无限膨胀导致内存泄漏
为什么用弱引用? 想象一个 Web 服务器会动态
intern()用户输入的各种字符串。如果 StringTable 使用强引用,这些字符串永远不会被 GC 回收,最终会把堆撑满。弱引用让 GC 可以在必要时回收不再使用的 interned 字符串。注意:字符串字面量(代码中用双引号写死的)不会被回收,因为 Class 对象始终持有对它们的强引用。
字符串字面量的懒解析:ldc 指令
一个常见的误解是:「类一加载,所有字符串字面量就进入了 StringTable」。实际上,字符串字面量是懒加载的——只有在代码第一次执行到引用该字面量的位置时,才会真正创建 String 对象并放入 StringTable。
触发这个过程的是字节码中的 ldc(Load Constant)指令。我们来看完整的解析流程:
// 源码
String s = "hello";
// 编译后的字节码
0: ldc #2 // String hello
2: astore_1
当 JVM 执行到 ldc #2 时,内部发生了以下步骤:
执行 ldc #2(加载字符串常量)
第 1 步:查看运行时常量池中 #2 的状态
│
┌────┴────┐
│ 已解析? │
└────┬────┘
┌────┴────────────────────┐
│ 是 │ 否
▼ ▼
直接返回已缓存的 第 2 步:从运行时常量池取出 CONSTANT_String
String 引用 它内部指向一个 CONSTANT_Utf8
(后续执行走这条路, 条目,包含字符串的字节内容 "hello"
所以是 O(1)) │
▼
第 3 步:计算 "hello" 的哈希值
在 StringTable 中查找
│
┌────┴────┐
│ 找到? │
└────┬────┘
┌───────┴───────┐
│ 是 │ 否
▼ ▼
返回已有的 第 4 步:在堆上创建
String 引用 新的 String 对象
将其引用插入 StringTable
返回这个新 String 的引用
│
▼
第 5 步:将返回的引用缓存到运行时常量池的 #2 位置
下次再执行 ldc #2 时直接走「已解析」分支
这个流程有几个关键点:
- 首次解析有开销:需要查 StringTable(哈希查找),可能还需要创建 String 对象
- 后续访问是 O(1):解析结果会被缓存到运行时常量池中,后续执行同一条
ldc指令时直接返回缓存的引用,不再查表 - 全局去重:不同类中出现的相同字面量
"hello",最终都指向 StringTable 中的同一个 String 对象
// 不同类中的相同字面量
class A { String s = "hello"; } // A 的 ldc 先执行,创建 String 并放入 StringTable
class B { String s = "hello"; } // B 的 ldc 后执行,直接从 StringTable 找到并复用
// A.s == B.s 为 true(指向同一个对象)
StringTable 的大小与性能调优
StringTable 的 bucket 数量直接影响查找性能——bucket 越多,哈希冲突越少,链表越短,查找越快。但 bucket 太多又浪费内存。不同 JDK 版本的默认值经历了多次调整:
| JDK 版本 | 默认 bucket 数 | 说明 |
|---|---|---|
| JDK 7 - 7u40 之前 | 1009 | 数量太少,大量字符串时冲突严重 |
| JDK 7u40 - JDK 10 | 60013 | 大幅增加,是一个质数(减少冲突) |
| JDK 11+ | 65536 | 进一步增加 |
可以通过 JVM 参数 -XX:StringTableSize=N 指定 bucket 数量(建议设为质数,因为质数能让哈希分布更均匀)。
诊断 StringTable 状态
JVM 提供了参数来查看 StringTable 的运行时统计信息:
# JVM 退出时打印 StringTable 统计
java -XX:+PrintStringTableStatistics MyApp
输出类似:
StringTable statistics:
Number of buckets : 65536 = 524288 bytes, each 8
Number of entries : 25637 = 410192 bytes, each 16
Number of literals : 25637 = 1576648 bytes, avg 61.502
Total footprint : = 2511128 bytes
Average bucket size : 0.391
Variance of bucket size : 0.396
Std. dev. of bucket size: 0.630
Maximum bucket size : 5
关键指标:
- Average bucket size:平均每个 bucket 的链表长度,越接近 0-1 越好
- Maximum bucket size:最长链表的长度,如果过大说明冲突严重,需要增大
StringTableSize
常量池位置的版本变迁
StringTable 的物理位置在不同 JDK 版本中发生了重要变化,这直接影响了 GC 行为和内存管理:
JDK 6 及之前 JDK 7 JDK 8+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 堆内存 │ │ 堆内存 │ │ 堆内存 │
│ │ │ │ │ │
│ new 出来的对象 │ │ new 出来的对象 │ │ new 出来的对象 │
│ │ │ │ │ │
│ │ │ ┌────────────┐ │ │ ┌────────────┐ │
│ │ │ │ StringTable│ │ │ │ StringTable│ │
│ │ │ │(字符串常量池)│ │ │ │(字符串常量池)│ │
│ │ │ └────────────┘ │ │ └────────────┘ │
├─────────────────┤ └─────────────────┘ └─────────────────┘
│ 永久代 │
│ (PermGen) │ ┌─────────────────┐ ┌─────────────────┐
│ │ │ 永久代 │ │ 元空间 │
│ ┌────────────┐ │ │ (PermGen) │ │ (Metaspace) │
│ │ StringTable│ │ │ │ │ │
│ │(字符串常量池)│ │ │ 运行时常量池 │ │ 运行时常量池 │
│ │ │ │ │ 类元数据 │ │ 类元数据 │
│ └────────────┘ │ │ │ │ │
│ 运行时常量池 │ └─────────────────┘ └─────────────────┘
│ 类元数据 │ (使用本地内存,不再有
└─────────────────┘ 固定大小限制)
| 版本 | StringTable 位置 | GC 回收 | 风险 |
|---|---|---|---|
| JDK 6 | 永久代(PermGen) | FullGC 才回收 | 大量 intern() → PermGen OOM |
| JDK 7 | 堆内存 | 常规 GC 即可回收 | 风险大幅降低 |
| JDK 8+ | 堆内存(永久代被移除) | 常规 GC 即可回收 | 同 JDK 7 |
为什么要移到堆中? JDK 6 的永久代大小是固定的(默认 64MB-82MB),如果程序大量使用
intern()或加载大量类,永久代很容易溢出(java.lang.OutOfMemoryError: PermGen space),而且 FullGC 的频率有限。把 StringTable 移到堆中后,interned 的字符串可以像普通对象一样被 Young GC 或 Mixed GC 回收,内存管理更灵活。
G1 GC 的 String Deduplication
JDK 8u20 引入了一个与字符串常量池思路类似但机制不同的优化——String Deduplication(-XX:+UseStringDeduplication),专门用于 G1 垃圾收集器。
常量池的去重针对的是字面量和 intern() 的字符串,而 String Deduplication 针对的是堆上所有的 String 对象(包括 new 创建的)。它的工作原理是:
普通的堆内存(未开启 String Deduplication):
String obj1 = new String("hello");
String obj2 = new String("hello");
obj1 ──→ String { value: byte[]{104,101,108,108,111} } ← 独立的数组
obj2 ──→ String { value: byte[]{104,101,108,108,111} } ← 又一份相同的数组
开启 String Deduplication 后,G1 GC 在后台检测:
obj1 ──→ String { value: ─────→ byte[]{104,101,108,108,111} } ← 共享同一个数组
obj2 ──→ String { value: ─────↗ }
注意:String Deduplication 不改变 == 的结果。obj1 == obj2 仍然是 false(它们是不同的 String 对象),但它们内部的 value 数组指向了同一份数据,从而节省了数组占用的内存。
这个优化对开发者完全透明,不需要修改任何代码。它特别适合存在大量内容相同但独立创建的字符串的场景(如 JSON/XML 解析、日志处理等)。
intern() 方法详解
intern() 是一个 native 方法,它的作用是将字符串放入常量池并返回池中的引用。
String s1 = new String("hello"); // s1 指向堆
String s2 = s1.intern(); // s2 指向常量池中的 "hello"
String s3 = "hello"; // s3 也指向常量池中的 "hello"
System.out.println(s1 == s2); // false(堆 vs 常量池)
System.out.println(s2 == s3); // true(都指向常量池)
JDK 6 vs JDK 7+ 的行为差异
intern() 在不同 JDK 版本中的行为有重要差异:
JDK 6:常量池在永久代中。调用 intern() 时,如果常量池中没有该字符串,JVM 会复制一份字符串到永久代的常量池中。
JDK 7+:常量池在堆中。调用 intern() 时,如果常量池中没有该字符串,JVM 不再复制,而是直接在常量池中存储一个指向堆中该对象的引用。
这个底层差异会导致代码行为截然不同:
String s1 = new String("a") + new String("b"); // 堆上创建 "ab"
s1.intern(); // 将 "ab" 放入常量池
String s2 = "ab";
System.out.println(s1 == s2);
// JDK 6: false(常量池里是副本,s1 指向堆上的原始对象)
// JDK 7+: true(常量池里存的就是 s1 的引用)
intern() 的使用建议
intern() 在大量重复字符串的场景下可以显著节省内存(比如 XML/JSON 解析中的重复字段名)。但要注意:
- 常量池有大小限制:底层是一个固定大小的 HashTable(默认约 60000 个 bucket),大量
intern()会导致哈希冲突加剧,性能下降 - JDK 6 中要特别谨慎:
intern()会往永久代复制字符串,可能导致 PermGen 溢出 - 考虑替代方案:JDK 9+ 的 Compact Strings 和 String Deduplication(
-XX:+UseStringDeduplication,G1 GC 专属)已经能自动完成类似优化
String、StringBuilder、StringBuffer 对比
当需要频繁拼接字符串时,不应该直接用 + 操作符,因为每次 + 都会创建新的 String 对象。Java 提供了两个可变的字符串类来解决这个问题。
内部实现的差异
StringBuilder 和 StringBuffer 都继承自 AbstractStringBuilder,内部使用一个可扩容的 char[](JDK 9+ 为 byte[])来存储字符:
abstract class AbstractStringBuilder {
byte[] value; // 非 final,可以扩容
byte coder;
int count; // 实际使用的字符数(不同于 value.length)
}
关键区别在于扩容策略:
初始容量 = 16
扩容规则 = 当前容量 × 2 + 2
示例:
初始: 16 → 不够用 → 34 → 不够用 → 70 → ...
三者对比
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 是(不可变) | 否 | 是(方法加 synchronized) |
| 性能 | 拼接时最差 | 最好 | 较好(锁开销) |
| JDK 版本 | JDK 1.0 | JDK 1.5 | JDK 1.0 |
| 使用场景 | 常量、少量操作 | 单线程大量拼接 | 多线程大量拼接(实际罕见) |
编译器的优化
JDK 5 开始,编译器会自动将简单的字符串 + 操作优化为 StringBuilder:
// 源码
String s = "a" + "b" + "c";
// 编译器优化为(JDK 5-8)
String s = new StringBuilder().append("a").append("b").append("c").toString();
// JDK 9+ 使用 invokedynamic 进一步优化(StringConcatFactory)
// 避免了创建 StringBuilder 对象的开销
但如果是在循环中拼接,编译器优化不了——每次循环都会创建一个新的 StringBuilder:
// 这段代码性能很差
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 每次循环创建一个 StringBuilder + 一个 String
}
// 应该改为
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String result = sb.toString();
实际开发建议:StringBuffer 的多线程场景在实际中非常罕见。如果真的需要多线程拼接字符串,通常有更好的设计(比如每个线程拼接自己的 StringBuilder,最后合并)。大多数情况下,直接用 StringBuilder 就够了。
反射破坏不可变性
虽然 String 在 API 层面是不可变的,但通过反射可以强行修改其内部数据——这是 Java 安全模型中的一个已知"逃生舱门":
String s = "hello";
System.out.println(s); // hello
// 通过反射获取 value 字段
Field field = String.class.getDeclaredField("value");
field.setAccessible(true); // 绕过 private 访问控制
// JDK 8 下修改 char[]
char[] value = (char[]) field.get(s);
value[0] = 'H';
System.out.println(s); // Hello(被修改了!)
这种做法非常危险,原因包括:
- 常量池中的字符串被修改后,所有引用同一个字面量的变量都会受影响
- 缓存的
hashCode可能与实际内容不一致,导致 HashMap 行为异常 - JDK 9+ 引入了模块系统(JPMS),默认禁止跨模块的反射访问,这种代码会直接抛出
InaccessibleObjectException
结论:了解这个可能性有助于深度理解不可变性的本质,但在生产代码中永远不要这样做。
总结
String 不可变性的设计是一个多目标优化的结果:
| 设计目标 | 不可变性如何实现 |
|---|---|
| 内存效率 | 启用字符串常量池,共享相同内容的字符串 |
| 性能 | 缓存 hashCode,作为 HashMap key 时 O(1) 获取 |
| 线程安全 | 无需同步,天然可被多线程共享 |
| 安全性 | 防止 TOCTOU 攻击,保护敏感信息 |
| JVM 优化 | Compact Strings、String Deduplication 等优化建立在不可变性基础之上 |
理解了 String 的不可变性,后续学习 HashMap 中 String 作为 key 的行为、并发编程中的对象共享策略,都会更加顺畅。