字符串底层原理与 String Pool¶
1. 为什么要深入理解 String?¶
String 是 Java 中使用频率最高的类,几乎每个程序都离不开它。但它的底层实现远比表面复杂:
| 现象 | 根因 | 需要的知识 |
|---|---|---|
== 比较字符串结果不符合预期 | 常量池 vs 堆对象引用不同 | String Pool 机制 |
| 大量字符串拼接导致 OOM | String + 产生大量临时对象 | StringBuilder 原理 |
| JDK 9 升级后内存占用下降 | byte[] 替代 char[] 存储 | Compact Strings 优化 |
intern() 调用后内存反而增大 | 常量池膨胀 | intern() 的副作用 |
2. String 的底层存储结构¶
2.1 JDK 8:char[] 存储¶
JDK 8 及之前,String 内部使用 char[] 数组存储字符,每个 char 占 2 字节(UTF-16 编码):
// JDK 8 String 源码(简化)
public final class String implements Serializable, Comparable<String>, CharSequence {
private final char[] value; // 存储字符数据
private int hash; // 缓存 hashCode,默认 0
}
对于纯 ASCII 字符串(如 "hello"),每个字符实际只需 1 字节,但 char[] 强制使用 2 字节,造成 50% 的内存浪费。
2.2 JDK 9+:byte[] + coder(Compact Strings)¶
JDK 9 引入 Compact Strings 优化(JEP 254),将存储结构改为 byte[] + coder 标志:
// JDK 9+ String 源码(简化)
public final class String implements Serializable, Comparable<String>, CharSequence {
private final byte[] value; // 存储字符数据
private final byte coder; // 编码标志:LATIN1=0, UTF16=1
private int hash;
static final byte LATIN1 = 0;
static final byte UTF16 = 1;
}
编码策略对比:
| 字符串内容 | JDK 8(char[]) | JDK 9+(byte[]) | 节省 |
|---|---|---|---|
"hello" (纯 ASCII) | 10 字节 | 5 字节(LATIN1) | 50% |
"你好" (含中文) | 4 字节 | 4 字节(UTF16) | 0% |
"hello世界" (混合) | 14 字节 | 14 字节(UTF16) | 0% |
为什么选择 LATIN1 而不是 UTF-8?
LATIN1(ISO-8859-1)是单字节定长编码,每个字符恰好 1 字节,便于通过下标 O(1) 随机访问。UTF-8 是变长编码,随机访问需要 O(n) 扫描,不适合作为内部存储格式。
coder 由 JVM 自动判断:创建 String 对象时,JVM 会扫描所有字符,根据码点范围自动选择编码,对开发者完全透明。
展开:coder 的自动判断规则与示例
// 判断规则(伪代码)
if (所有字符码点 <= 0xFF) {
coder = LATIN1; // 每字符 1 字节
} else {
coder = UTF16; // 每字符 2 字节
}
String s1 = "hello"; // 全 ASCII → LATIN1(5 字节)
String s2 = "café"; // é 码点 0xE9 ≤ 0xFF → LATIN1(4 字节)
String s3 = "你好"; // 中文码点 > 0xFF → UTF16(4 字节)
String s4 = "hello世界"; // 含中文 → 整体升级为 UTF16(14 字节)
一票否决制:只要字符串中任何一个字符超出 LATIN1 范围(> 0xFF),整个字符串就必须使用 UTF16 存储,不能混合编码。这是为了保证 charAt(i)、length() 等方法能够 O(1) 随机访问。
可以通过 JVM 参数 -XX:-CompactStrings 关闭该优化,强制所有 String 使用 UTF16(退化为 JDK 8 的 char[] 等价行为),默认开启 -XX:+CompactStrings。
JDK 8 存储 "hello":
┌────┬────┬────┬────┬────┐
│'h' │'e' │'l' │'l' │'o' │ char[],每格 2 字节,共 10 字节
└────┴────┴────┴────┴────┘
JDK 9+ 存储 "hello"(LATIN1):
┌───┬───┬───┬───┬───┐
│104│101│108│108│111│ byte[],每格 1 字节,共 5 字节
└───┴───┴───┴───┴───┘
coder = 0 (LATIN1)
3. 字符串常量池(String Pool)¶
3.1 常量池的位置变迁¶
字符串常量池(String Pool / String Intern Table)是 JVM 维护的一张哈希表,用于存储字符串字面量,实现字符串复用。
flowchart LR
subgraph JDK6["JDK 6 及之前"]
PermGen["永久代 PermGen\n字符串常量池\n(大小固定,易 OOM)"]
end
subgraph JDK7["JDK 7+"]
Heap7["堆 Heap\n字符串常量池\n(随堆 GC 回收)"]
end
subgraph JDK8["JDK 8+"]
Heap8["堆 Heap\n字符串常量池"]
Meta["元空间 MetaSpace\n(替代永久代,存类元数据)"]
end
JDK6 -->|"JDK 7 迁移"| JDK7
JDK7 -->|"JDK 8 元空间替代永久代"| JDK8 为什么 JDK 7 将常量池从永久代迁移到堆?
- 永久代大小有限(HotSpot 默认值随版本与 Client/Server 模式有差,Server 模式约 64MB,上限由
-XX:MaxPermSize控制),大量使用String.intern()或动态生成字符串时容易触发java.lang.OutOfMemoryError: PermGen space - 迁移到堆后,常量池中的字符串对象可以被 GC 正常回收,不再受永久代大小限制
3.2 字符串字面量的创建过程¶
String s1 = "hello"; // ① 编译期写入 .class 常量池,运行时加载到 String Pool
String s2 = "hello"; // ② 直接复用 String Pool 中已有的对象
System.out.println(s1 == s2); // true,同一个对象引用
String Pool(堆中的哈希表):
┌──────────────────────────────┐
│ "hello" → 0x7f3a1b2c(引用)│
└──────────────────────────────┘
↑
s1 和 s2 都指向同一个堆对象
3.3 new String() 创建了几个对象?¶
这是面试高频题,答案取决于常量池中是否已存在该字符串:
场景一:常量池中已有 "abc"(之前有字面量 "abc" 出现过)
String s = new String("abc");
执行过程:
① 检查 String Pool,"abc" 已存在
② new String(...) 在堆上创建一个新的 String 对象
结果:创建 1 个新对象(堆上的 String 实例)
场景二:常量池中没有 "abc"(首次出现)
String s = new String("abc");
执行过程:
① 检查 String Pool,"abc" 不存在
② 在 String Pool 中创建 "abc" 对象
③ new String(...) 在堆上再创建一个新的 String 对象
结果:创建 2 个对象(String Pool 中 1 个 + 堆上 1 个)
== vs equals 的区别:
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
String s4 = new String("hello");
System.out.println(s1 == s2); // true - 同一个 String Pool 对象
System.out.println(s1 == s3); // false - s3 是堆上新对象
System.out.println(s3 == s4); // false - 两个不同的堆对象
System.out.println(s3.equals(s4)); // true - 内容相同
永远用 equals() 比较字符串内容
== 比较的是引用地址,只有当两个变量指向同一个对象时才为 true。比较字符串内容必须使用 equals() 或 equalsIgnoreCase()。
3.4 String.intern() 方法¶
intern() 方法的作用:将字符串对象手动加入 String Pool,并返回 Pool 中的引用。
// JDK 7+ 的 intern() 行为
String s1 = new String("hello"); // 堆上新对象
String s2 = s1.intern(); // 将 s1 加入(或查找)String Pool
String s3 = "hello"; // 直接引用 String Pool
System.out.println(s2 == s3); // true - 都是 String Pool 中的引用
System.out.println(s1 == s2); // false - s1 是堆上对象,s2 是 Pool 引用
JDK 6 vs JDK 7+ 的 intern() 差异:
| 版本 | intern() 行为 |
|---|---|
| JDK 6 | 若 Pool 中无此字符串,复制一份到永久代 Pool,返回永久代中的引用 |
| JDK 7+ | 若 Pool 中无此字符串,直接将堆中该对象的引用存入 Pool(不复制),返回该引用 |
JDK 7+ intern() 的内存优化
JDK 7+ 的 intern() 不再复制对象,Pool 中存储的是堆对象的引用,避免了重复存储,节省内存。
谨慎使用 intern()
大量调用 intern() 存入不重复的字符串会导致 Pool 持续膨胀,增加 GC 压力。将用户输入、随机 ID 等动态字符串全部 intern() 是典型的反模式。
4. 字符串不可变性¶
4.1 String 为什么设计为不可变?¶
String 不可变的三大设计原因:
① 线程安全:
② 字符串常量池共享:
// 如果 String 可变,常量池共享就会出问题
String s1 = "hello";
String s2 = "hello"; // s1 和 s2 指向同一个 Pool 对象
// 假设 String 可变:修改 s1 会同时影响 s2,破坏程序正确性
③ hashCode 缓存:
// String 的 hashCode 只计算一次,之后缓存在 hash 字段
public int hashCode() {
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
// 不可变性保证了 hashCode 永远不会改变,可以安全地作为 HashMap 的 key
final 修饰符的作用
private final byte[] value 中的 final 保证了 value 引用不可重新赋值,但数组内容本身理论上是可以修改的。String 的不可变性是通过 private 访问控制 + 不提供任何修改方法来保证的,而非单靠 final。
5. 字符串拼接性能对比¶
5.1 String + 的编译器优化¶
// 编译期常量折叠:纯字面量拼接直接合并
String result = "Hello" + ", " + "World";
// 编译后等价于:
// String result = "Hello, World";
// 含变量的拼接(JDK 8)
String name = "Java";
String result = "Hello, " + name + "!";
// 编译为:
String result = new StringBuilder()
.append("Hello, ")
.append(name)
.append("!")
.toString();
JDK 9+ 的字符串拼接优化
JDK 9 引入 invokedynamic 指令处理字符串拼接,StringConcatFactory 可以在运行时选择最优策略(如直接操作 byte[]),比 JDK 8 的 StringBuilder 方式减少了中间对象的创建。
5.2 循环中拼接的陷阱¶
// 反例:循环中使用 + 拼接(每次循环都创建新的 StringBuilder 和 String)
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 等价于 result = new StringBuilder(result).append(i).toString()
}
// 产生约 10000 个临时 StringBuilder 和 String 对象!
// 正例:循环外创建 StringBuilder,复用同一个实例
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String result = sb.toString();
5.3 各拼接方式性能与适用场景¶
| 方式 | 线程安全 | 适用场景 | 性能 |
|---|---|---|---|
String + | ✅(不可变) | 少量拼接(≤3次)、编译期常量 | 少量时最简洁 |
StringBuilder | ❌ | 单线程循环拼接、高性能场景 | ⭐⭐⭐⭐⭐ |
StringBuffer | ✅(synchronized) | 多线程共享拼接(极少使用) | ⭐⭐⭐ |
String.join() | ✅ | 固定分隔符连接集合/数组 | ⭐⭐⭐⭐ |
StringJoiner | ✅ | 需要前缀/后缀的连接,Stream 场景 | ⭐⭐⭐⭐ |
String.format() | ✅ | 格式化输出(可读性优先) | ⭐⭐(较慢) |
// String.join() - 最简洁的集合连接
List<String> list = List.of("a", "b", "c");
String result = String.join(", ", list); // "a, b, c"
// StringJoiner - 支持前缀和后缀
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("a").add("b").add("c");
String result = sj.toString(); // "[a, b, c]"
// Stream + Collectors.joining()
String result = list.stream()
.collect(Collectors.joining(", ", "[", "]")); // "[a, b, c]"
6. 常见面试题解析¶
6.1 String、StringBuilder、StringBuffer 的区别¶
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | ✅ | ❌ | ✅ |
| 性能 | 拼接慢 | 最快 | 较快 |
| 底层(JDK 9+) | byte[] value(final,长度不可变) | byte[] value(非final,可扩容,默认初始容量 16) | byte[] value(非final,可扩容,默认初始容量 16) |
6.2 字符串常量池相关题目¶
// 题目 1:编译期常量折叠
String s1 = "ab";
String s2 = "a" + "b";
System.out.println(s1 == s2); // true
// 原因:"a" + "b" 编译期折叠为 "ab",指向同一个 Pool 对象
// 题目 2:含变量的拼接
String a = "a";
String s3 = a + "b";
System.out.println(s1 == s3); // false
// 原因:a 是变量,运行时通过 StringBuilder 拼接,产生新的堆对象
// 题目 3:intern() 归一化
String s4 = s3.intern();
System.out.println(s1 == s4); // true
// 原因:intern() 返回 String Pool 中 "ab" 的引用,与 s1 相同
6.3 String 的 hashCode 为什么用 31 作为乘数?¶
选择 31 的原因:
31是奇素数,且是奇数:偶数作乘数在溢出时会丢失信息(相当于左移,低位被补 0),质数则对任意数的映射都不存在建立在整除上的碰撞规律,能使散列分布更均匀31 * i == (i << 5) - i,JVM 可以将乘法优化为位移和减法,性能更好- 其他候选(17 / 37 / 57 等)在常见英文字符串上实测碰撞率大同小异,选 31 是经验权衡下的惯例(Joshua Bloch《Effective Java》也沿用此设定)
7. 与 JVM 内存的关联¶
String Pool 本质上是 JVM 堆中的一块特殊区域,与 JVM 内存结构紧密相关:
- 字符串常量池存储在堆中(JDK 7+),受堆大小(
-Xmx)限制 - 类文件中的字符串字面量存储在
.class文件的常量池(Class Constant Pool)中,类加载时解析到运行时常量池(Runtime Constant Pool),再通过ldc指令加载到 String Pool - String Pool 中的对象在没有强引用时可以被 GC 回收
深入了解 JVM 内存结构
关于堆、元空间、运行时常量池的详细介绍,请参考 @java-JVM内存结构与GC。
flowchart TD
ClassFile[".class 文件\nClass Constant Pool\n存储字符串字面量符号引用"]
ClassLoad["类加载\nClassLoader"]
RuntimeCP["运行时常量池\nRuntime Constant Pool\n(元空间中)"]
StringPool["String Pool\n字符串常量池\n(堆中)"]
HeapObj["堆对象\nnew String() 创建的实例"]
ClassFile -->|"类加载时解析"| ClassLoad
ClassLoad -->|"符号引用 → 直接引用"| RuntimeCP
RuntimeCP -->|"ldc 指令首次执行时\n创建并加入 Pool"| StringPool
StringPool -->|"new String(str)"| HeapObj
HeapObj -->|"intern()"| StringPool 8. 小结¶
| 知识点 | 核心结论 |
|---|---|
| 底层存储 | JDK 8 用 char[](2字节/字符),JDK 9+ 用 byte[] + coder(ASCII 节省 50%) |
| 不可变性 | 线程安全 + 常量池共享 + hashCode 缓存,final 保证引用不变 |
| String Pool 位置 | JDK 6 在永久代,JDK 7+ 在堆(可 GC) |
new String("abc") | 常量池已有时创建 1 个对象,否则创建 2 个 |
intern() | 返回 Pool 中的引用;JDK 7+ 不复制对象,直接存引用 |
| 拼接性能 | 循环拼接用 StringBuilder;少量拼接 + 即可;集合连接用 String.join() |
== vs equals | == 比较引用地址,equals() 比较内容,字符串比较永远用 equals() |