String不可变与常量池
# 12.String不可变与常量池
# 目录介绍
- 5.1 开篇疑问
- 5.2 String底层实现演进
- 5.3 不可变性设计原理
- 5.4 字符串常量池深入分析
- 5.5 经典面试题深度剖析
- 5.6 String拼接底层真相
- 5.7 StringBuilder与StringBuffer深度对比
- 5.8 String相关的JVM优化
- 5.9 生产环境中的String最佳实践
- 5.10 总结与核心要点
# 5.1 开篇疑问
疑惑:String s = new String("abc") 创建了几个对象?String 为什么是不可变的?intern() 方法有什么用?字符串拼接用 + 号效率到底低不低?JDK 9 为什么要把 char[] 改成 byte[]?反射能打破 String 的不可变性吗?
答疑:String 是 Java 中使用频率最高的类,据统计在典型 Java 应用中,String 对象占据了 25%~40% 的堆内存。理解它的底层实现和设计思想,不仅能回答面试题,更能帮你写出更高效的代码,理解 JVM 内存优化策略。
本篇将从 String 的底层数据结构演进出发,深入分析不可变性的三重保护机制、常量池的位置变迁与 intern 原理、拼接操作的编译优化,以及生产环境中的最佳实践。
# 5.2 String底层实现演进
# 5.2.1 JDK8的char数组
在 JDK 8 及之前,String 底层使用 char[] 数组存储字符数据:
// JDK 8 的 String 核心字段
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;
}
2
3
4
5
6
7
8
9
10
11
关键点:
- 每个
char占 2 字节(UTF-16 编码),无论存储的是 ASCII 字符还是中文字符 - 对于大量 Latin-1 字符(英文、数字、标点等),每个字符浪费了 1 字节
- 在英文为主的应用中(如大部分服务端应用),这种浪费相当可观
# 5.2.2 JDK9的byte数组紧凑设计
JDK 9 引入了紧凑字符串(Compact Strings, JEP 254),将底层改为 byte[]:
// JDK 9+ 的 String 核心字段
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value; // 底层存储从 char[] 变为 byte[]
private final byte coder; // 编码标识:LATIN1=0, UTF16=1
private int hash;
private boolean hashIsZero; // JDK 13+ 新增,区分hash=0和未计算
@Native static final byte LATIN1 = 0;
@Native static final byte UTF16 = 1;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
设计原理:
- 如果字符串中所有字符都是 Latin-1(0~255),使用 LATIN1 编码,每个字符 1 字节
- 如果包含非 Latin-1 字符(如中文),使用 UTF16 编码,每个字符 2 字节
coder字段标识当前使用的编码方式
// 紧凑字符串的编码选择
String ascii = "Hello"; // coder = LATIN1, value 长度 = 5 字节
String chinese = "你好"; // coder = UTF16, value 长度 = 4 字节(2字符×2字节)
String mixed = "Hi你好"; // coder = UTF16, value 长度 = 8 字节(4字符×2字节)
2
3
4
疑惑:混合字符串为什么全部用 UTF16,不能部分用 LATIN1 吗?
论证:如果混合编码,charAt(index) 就无法通过简单的偏移计算定位字符,需要遍历前面所有字符才能确定偏移量,时间复杂度从 O(1) 变为 O(n)。统一编码保证了随机访问的 O(1) 性能。
实际效果:Oracle 官方测试显示,紧凑字符串减少了约 10%~15% 的堆内存使用,GC 停顿也有所下降,因为需要扫描的对象体积变小了。
# 5.2.3 String对象的内存占用分析
一个 String 对象的实际内存占用(64 位 JVM,开启指针压缩):
JDK 8 中 "Hello" 的内存占用:
┌────────────────────────────────────┐
│ String 对象头: 12 字节 │ (Mark Word 8 + Klass Pointer 4)
│ char[] value 引用: 4 字节 │
│ int hash: 4 字节 │
│ 对齐填充: 4 字节 │
│ ────── │
│ String 对象合计: 24 字节 │
├────────────────────────────────────┤
│ char[] 数组对象头: 16 字节 │ (对象头12 + 数组长度4)
│ char[5] 数据: 10 字节 │ (5 × 2字节)
│ 对齐填充: 6 字节 │
│ ────── │
│ char[] 合计: 32 字节 │
├────────────────────────────────────┤
│ 总计: 56 字节 │
└────────────────────────────────────┘
JDK 9+ 中 "Hello" 的内存占用:
┌────────────────────────────────────┐
│ String 对象头: 12 字节 │
│ byte[] value 引用: 4 字节 │
│ byte coder: 1 字节 │
│ int hash: 4 字节 │
│ 对齐填充: 3 字节 │
│ ────── │
│ String 对象合计: 24 字节 │
├────────────────────────────────────┤
│ byte[] 数组对象头: 16 字节 │
│ byte[5] 数据: 5 字节 │ (5 × 1字节,LATIN1编码)
│ 对齐填充: 3 字节 │
│ ────── │
│ byte[] 合计: 24 字节 │
├────────────────────────────────────┤
│ 总计: 48 字节 │ (节省了 8 字节,14%)
└────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
关键洞察:对于短字符串,对象头和元数据的开销占比很大。"Hello" 存储 5 个字符实际数据只需 5~10 字节,但总开销是 48~56 字节。这也是为什么大量短字符串会给内存带来巨大压力。
# 5.3 不可变性设计原理
# 5.3.1 三重保护机制
String 通过三重保护实现不可变:
// 第一重:final 类——不可被继承
public final class String {
// 第二重:final 数组引用——引用不能指向其他数组
private final char value[]; // JDK 8
// private final byte[] value; // JDK 9+
// 第三重:不暴露修改方法
// replace、substring、concat 等都返回新 String 对象,不修改原对象
public String substring(int beginIndex) {
// 不修改 this.value,而是创建新的 String
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
public String replace(char oldChar, char newChar) {
// 如果没有找到 oldChar,返回 this(原对象)
// 如果找到了,创建新数组,构建新 String 返回
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
疑惑:final char value[] 中的 final 修饰的是数组引用还是数组内容?
论证:final 只保证引用不能重新指向其他数组对象,但数组内容理论上是可以修改的:
final char[] arr = {'a', 'b', 'c'};
// arr = new char[]{'x', 'y', 'z'}; // 编译错误!引用不可变
arr[0] = 'x'; // 完全合法!内容可变
2
3
所以 String 的不可变性不仅仅靠 final,还需要类不暴露任何修改 value 数组的公开方法。这三重保护缺一不可。
# 5.3.2 为什么设计成不可变
1. 字符串常量池的前提条件
字符串常量池允许多个变量引用同一个 String 对象。如果 String 可变,一处修改会影响所有引用方:
String s1 = "hello";
String s2 = "hello";
// s1 和 s2 指向常量池中的同一个对象
// 如果 String 可变且 s1 修改了内容:
// s1.modify("world"); // 假设有这个方法
// s2 也变成 "world" 了!这显然是灾难性的
2
3
4
5
6
7
2. 天然线程安全
不可变对象可以在多线程之间自由共享,无需任何同步措施:
// 以下代码在多线程环境下完全安全
public class Config {
private final String dbUrl; // 不可变,无需 volatile 或 synchronized
private final String username;
public Config(String dbUrl, String username) {
this.dbUrl = dbUrl;
this.username = username;
}
public String getDbUrl() { return dbUrl; }
}
2
3
4
5
6
7
8
9
10
11
12
3. hashCode 缓存优化
String 重写了 hashCode(),首次计算后缓存结果。如果内容可变,缓存的 hashCode 就可能过期:
public int hashCode() {
int h = hash; // 读取缓存
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h; // 缓存计算结果
}
return h;
}
2
3
4
5
6
7
8
9
10
11
如果 String 可变:
HashMap<String, Integer> map = new HashMap<>();
String key = "hello";
map.put(key, 1);
// 如果 key 的内容被修改为 "world"
// hashCode 变了,但 HashMap 中存储的是旧的 hashCode
// map.get("hello") → 找不到
// map.get("world") → 也找不到(因为 hash 桶位置不对)
// 整个 HashMap 崩溃
2
3
4
5
6
7
8
4. 安全性保证
Java 的很多安全敏感操作使用 String 传递参数:
// 类加载器根据类名加载类
Class.forName("com.example.MyClass");
// 数据库连接
DriverManager.getConnection(url, user, password);
// 文件路径
new File(path);
// 如果 String 可变,攻击者可以在验证后修改字符串内容
// 验证时是 "safe/path",实际使用时变成了 "/etc/shadow"
2
3
4
5
6
7
8
9
10
11
5. 作为 HashMap 的 key 更高效
由于 hashCode 被缓存,String 作为 HashMap 的 key 只需要在第一次计算 hash,后续查找都是 O(1) 的 hash 获取 + O(1) 的比较。
# 5.3.3 反射能否破坏不可变性
疑惑:都说 String 不可变,但 Java 反射可以突破 private 限制,能修改 value 数组吗?
论证:
// JDK 8 中可以通过反射修改(但强烈不推荐)
String s = "hello";
System.out.println(s); // hello
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true); // 突破 private
char[] value = (char[]) valueField.get(s);
value[0] = 'H';
System.out.println(s); // Hello —— 内容确实被修改了!
// 更可怕的是:
String s2 = "hello";
System.out.println(s2); // Hello —— 常量池中的 "hello" 也被修改了!
2
3
4
5
6
7
8
9
10
11
12
13
14
JDK 9+ 的限制:
// JDK 9+ 引入了模块系统(Jigsaw)
// 默认不允许反射访问 java.base 模块的内部字段
// 会抛出 InaccessibleObjectException
// 除非启动参数加 --add-opens java.base/java.lang=ALL-UNNAMED
2
3
4
结论:反射在旧版本中可以破坏不可变性,但这是"用枪指着自己的脚"。JDK 9+ 的模块系统封堵了这个漏洞。正常编程应该信任 String 的不可变性保证。
# 5.3.4 不可变对象的设计模式
String 的不可变设计是一种通用的设计模式。如果你需要创建自己的不可变类:
public final class Money { // 1. final 类
private final int amount; // 2. final 字段
private final String currency; // 3. 所有字段都是不可变类型
public Money(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public int getAmount() { return amount; } // 4. 只有 getter
public String getCurrency() { return currency; }
// 5. "修改"操作返回新对象
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount + other.amount, this.currency);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
不可变类的检查清单:
- 类声明为
final(防止子类重写行为) - 所有字段声明为
private final - 不提供 setter 方法
- 如果字段是可变对象(如数组、集合),构造函数中做防御性拷贝
- getter 方法也需要返回防御性拷贝(对于可变字段)
// 防御性拷贝示例
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
// 防御性拷贝——防止外部修改传入的 Date 对象
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.after(this.end)) {
throw new IllegalArgumentException("start after end");
}
}
public Date getStart() {
return new Date(start.getTime()); // 返回副本,防止外部修改
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 5.4 字符串常量池深入分析
# 5.4.1 常量池的本质与实现
字符串常量池在 JVM 中是一个哈希表(StringTable),底层实现类似 HashMap。
StringTable(JVM 内部 C++ 实现)
┌─────────────────────────────────┐
│ bucket[0] → null │
│ bucket[1] → "hello" → "abc" │
│ bucket[2] → null │
│ bucket[3] → "world" │
│ ... │
│ bucket[n] → ... │
└─────────────────────────────────┘
2
3
4
5
6
7
8
9
关键参数:
-XX:StringTableSize=N:设置 StringTable 的桶数量(默认 JDK 8 为 60013)- JDK 7u40 之前默认值很小(1009),容易导致哈希冲突,intern() 性能退化
# 查看 StringTable 统计信息
-XX:+PrintStringTableStatistics
# 示例输出:
# StringTable statistics:
# Number of buckets : 60013 = 480104 bytes
# Number of entries : 24136 = 579264 bytes
# Number of literals : 24136 = 1738112 bytes
# Total footprint : = 2797480 bytes
# Average bucket size : 0.402
# Variance of bucket size : 0.405
# Std. dev. of bucket size: 0.636
# Maximum bucket size : 5
2
3
4
5
6
7
8
9
10
11
12
13
# 5.4.2 常量池位置变迁的技术背景
| JDK 版本 | 常量池位置 | 原因 |
|---|---|---|
| JDK 1.6 | 永久代(PermGen) | 属于方法区的一部分 |
| JDK 1.7 | 堆内存(Heap) | 永久代空间有限,GC 效率低 |
| JDK 1.8+ | 堆内存(Heap) | 永久代被元空间替代 |
疑惑:为什么要把常量池从永久代搬到堆中?
论证:
问题一——PermGen 空间有限:
# JDK 6 中默认 PermGen 大小只有 64MB~82MB
-XX:MaxPermSize=82m
# 大量使用 intern() 或动态生成字符串(如 Spring、MyBatis 的 XML 解析)
# 很容易导致 OutOfMemoryError: PermGen space
2
3
4
5
问题二——PermGen 的 GC 效率低:
- 永久代只在 Full GC 时才会被回收
- Full GC 代价高昂,而且默认条件苛刻
- 字符串常量池中可能有大量临时字符串需要回收
问题三——分代假说不适用:
- 字符串常量池中的对象生命周期多样化
- 有些是永久存活的字面量,有些是临时 intern 的
- 放在堆中可以被 Minor GC 和 Major GC 正常管理
搬迁后的效果:
// JDK 6 中这段代码会导致 PermGen OOM
// JDK 7+ 中只会导致普通的 Heap OOM(可以被正常管理)
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
2
3
4
5
6
7
# 5.4.3 intern方法底层原理
intern() 的作用:检查常量池中是否已有内容相同的字符串。如果有,返回常量池中的引用;如果没有,将该字符串加入常量池。
底层流程(JDK 7+ HotSpot):
String s = new String("hello").intern()
1. 计算 "hello" 的 hashCode
2. 在 StringTable 中找到对应的 bucket
3. 遍历 bucket 中的链表,逐个比较内容
→ 找到内容相同的:返回已有的引用
→ 没找到:
a. JDK 6: 在永久代复制一份字符串,加入 StringTable
b. JDK 7+: 直接将堆中对象的引用加入 StringTable(不复制!)
4. 返回 StringTable 中的引用
2
3
4
5
6
7
8
9
10
这个"不复制"的改变是 JDK 7 intern 行为变化的根源。
# 5.4.4 JDK6与JDK7的intern差异详解
这是面试高频考点,我们用图解的方式彻底搞清楚:
场景一:简单字面量
String s1 = "abc";
String s2 = new String("abc");
String s3 = s2.intern();
// 不管 JDK 6 还是 JDK 7+,结果一致:
System.out.println(s1 == s2); // false(堆对象 vs 常量池)
System.out.println(s1 == s3); // true(都是常量池引用)
2
3
4
5
6
7
场景二:拼接后 intern(关键差异场景)
String s = new String("1") + new String("1"); // 堆上创建 "11"
// 注意:此时常量池中有 "1",但没有 "11"
s.intern(); // 将 "11" 放入常量池
String s2 = "11"; // 从常量池获取 "11"
System.out.println(s == s2);
2
3
4
5
6
JDK 6 的执行过程:
堆: 永久代常量池:
┌──────────┐ ┌──────────┐
│ s → "11" │ │ "1" │
└──────────┘ └──────────┘
s.intern():
堆: 永久代常量池:
┌──────────┐ ┌──────────┐
│ s → "11" │ │ "1" │
└──────────┘ │ "11"(副本)│ ← 复制了一份!
└──────────┘
s2 = "11":
s2 → 常量池中的 "11"(副本)
s → 堆中的 "11"(原件)
s == s2 → false(不同对象)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
JDK 7+ 的执行过程:
堆: 常量池(在堆中):
┌──────────┐ ┌──────────┐
│ s → "11" │ │ "1" │
└──────────┘ └──────────┘
s.intern():
堆: 常量池(在堆中):
┌──────────┐ ┌──────────────┐
│ s → "11" │ ←─────────────────── │ "11" → s的引用 │ ← 不复制,直接存引用!
└──────────┘ └──────────────┘
s2 = "11":
s2 → 常量池中的引用 → s 指向的同一个堆对象
s == s2 → true(同一个对象!)
2
3
4
5
6
7
8
9
10
11
12
13
14
再看一个变种:
String s = new String("1") + new String("1"); // 堆上的 "11"
String s2 = "11"; // 先于 intern() 在常量池创建 "11"
s.intern(); // 常量池已有 "11",intern 无效
System.out.println(s == s2); // false(JDK 6 和 JDK 7+ 都是 false)
2
3
4
5
关键结论:顺序很重要!如果常量池中已经有了该字符串,intern() 返回已有的引用。JDK 7+ 的"不复制"优化只在常量池中没有该字符串时生效。
# 5.4.5 intern的实战应用与性能
适用场景:大量重复字符串时,intern() 可以大幅减少内存:
// 不使用 intern:10万个相同字符串 = 10万个对象
List<String> list = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
list.add(new String("status_active")); // 10万个不同的堆对象
}
// 使用 intern:10万个引用 → 1个常量池对象
List<String> list = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
list.add(new String("status_active").intern()); // 所有引用指向同一个对象
}
2
3
4
5
6
7
8
9
10
11
Twitter 的经典案例:Twitter 曾通过 intern() 优化用户状态字符串,节省了数 GB 内存。
注意事项:
- StringTable 是有大小限制的哈希表,intern 过多字符串会导致哈希冲突增加
- intern() 有原生方法调用开销,不适合短期生存的临时字符串
- JDK 7+ 可以通过
-XX:StringTableSize调大桶数来优化 intern 性能
// 性能测试:intern 的时间开销
long start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
String.valueOf(i % 100).intern(); // 只有100个不同值
}
long cost = System.nanoTime() - start;
// 约 50-100ms(取决于 StringTableSize)
2
3
4
5
6
7
# 5.5 经典面试题深度剖析
# 5.5.1 new String创建几个对象
String s = new String("abc") 创建了几个对象?
严格回答:最多 2 个,最少 1 个。
场景一:常量池中没有 "abc"
1. 编译期确定字面量 "abc",类加载时在常量池创建 String 对象(对象1)
2. 运行时 new 在堆上创建 String 对象(对象2),内部 value 指向同一个字符数组
场景二:常量池中已有 "abc"
1. 只在堆上 new 一个对象(对象1),常量池的已存在,不再创建
2
3
4
5
6
更复杂的场景:
// 这行代码创建了几个对象?
String s = new String("a") + new String("b");
2
逐步分析:
- 常量池中创建
"a"(如果没有的话) - 堆上
new String("a") - 常量池中创建
"b"(如果没有的话) - 堆上
new String("b") +操作实际通过StringBuilder实现new StringBuilder()sb.toString()→ 内部new String(char[], 0, count)
最多创建 6 个对象(2 常量池 + 2 堆上 String + 1 StringBuilder + 1 最终结果 String)。
# 5.5.2 字符串相等性比较全解析
String s1 = "abc";
String s2 = "abc";
String s3 = new String("abc");
String s4 = new String("abc");
String s5 = "ab" + "c"; // 编译期优化
String s6 = "ab" + new String("c"); // 运行时拼接
System.out.println(s1 == s2); // true — 都指向常量池同一对象
System.out.println(s1 == s3); // false — 常量池 vs 堆
System.out.println(s3 == s4); // false — 堆上两个不同对象
System.out.println(s1 == s5); // true — "ab"+"c" 编译期变为 "abc"
System.out.println(s1 == s6); // false — 含变量/new 的拼接在运行时创建新对象
// equals() 比较内容
System.out.println(s1.equals(s3)); // true — 内容相同
System.out.println(s3.equals(s4)); // true — 内容相同
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
编译期常量折叠的条件:
final String a = "ab";
String b = "ab";
String s1 = a + "c"; // a 是 final,编译期可确定 → "abc"
String s2 = b + "c"; // b 不是 final,编译期无法确定 → StringBuilder
System.out.println("abc" == s1); // true — 常量折叠
System.out.println("abc" == s2); // false — 运行时拼接
2
3
4
5
6
7
8
关键规则:final 修饰的 String 变量在编译期视为常量,可以参与常量折叠。
# 5.5.3 拼接场景的对象创建分析
// 场景1:纯字面量拼接
String s1 = "a" + "b" + "c";
// 编译后:String s1 = "abc"; 只有1个常量池对象
// 场景2:含变量拼接
String a = "a";
String s2 = a + "b" + "c";
// 编译后:new StringBuilder().append(a).append("b").append("c").toString()
// 创建对象:1个StringBuilder + 1个String + 内部的char数组
// 场景3:final变量拼接
final String fa = "a";
String s3 = fa + "b" + "c";
// 编译后:String s3 = "abc"; fa 是编译期常量
// 场景4:方法返回值拼接
String s4 = getA() + "b";
// 即使 getA() 总是返回 "a",编译器也无法确定
// 编译后:new StringBuilder().append(getA()).append("b").toString()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 5.6 String拼接底层真相
# 5.6.1 常量拼接的编译期优化
编译器对纯字面量拼接做常量折叠(Constant Folding)优化:
// 源代码
String s = "Hello" + ", " + "World" + "!";
// javac 编译后的字节码等价于
String s = "Hello, World!";
// 可以通过 javap -c 验证
// ldc "Hello, World!" ← 直接加载完整字符串,没有任何拼接操作
2
3
4
5
6
7
8
常量折叠也适用于 final 变量和基本类型:
final int x = 10;
final String prefix = "ID_";
String s = prefix + x;
// 编译后等价于:String s = "ID_10";
2
3
4
# 5.6.2 变量拼接的StringBuilder转换
JDK 8 中,包含变量的 + 拼接会被编译器转换为 StringBuilder:
// 源代码
String name = "World";
String s = "Hello, " + name + "!";
// 编译后等价于
String name = "World";
String s = new StringBuilder()
.append("Hello, ")
.append(name)
.append("!")
.toString();
2
3
4
5
6
7
8
9
10
11
查看字节码验证:
javap -c StringConcat.class
0: ldc #2 // String World
2: astore_1
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method StringBuilder."<init>"
10: ldc #5 // String Hello,
12: invokevirtual #6 // Method StringBuilder.append
15: aload_1
16: invokevirtual #6 // Method StringBuilder.append
19: ldc #7 // String !
21: invokevirtual #6 // Method StringBuilder.append
24: invokevirtual #8 // Method StringBuilder.toString
27: astore_2
2
3
4
5
6
7
8
9
10
11
12
13
# 5.6.3 JDK9的invokedynamic拼接优化
JDK 9 引入了 invokedynamic 指令(JEP 280)替代 StringBuilder:
// JDK 9+ 编译后,字节码使用 invokedynamic
// 0: invokedynamic #2, 0 // InvokeDynamic #0:makeConcatWithConstants
2
优势:
- 延迟策略选择:JVM 在运行时根据实际情况选择最优的拼接策略
- 减少字节码大小:一条
invokedynamic替代了多条new/append/toString - 未来优化空间:JVM 可以在不修改字节码的情况下改进拼接算法
六种运行时策略(通过 -Djava.lang.invoke.stringConcat 控制):
| 策略 | 说明 |
|---|---|
| BC_SB | 使用 StringBuilder(默认兼容模式) |
| BC_SB_SIZED | 预计算 StringBuilder 容量 |
| BC_SB_SIZED_EXACT | 精确计算容量 |
| MH_SB_SIZED | 使用 MethodHandle 创建 StringBuilder |
| MH_SB_SIZED_EXACT | MethodHandle + 精确容量 |
| MH_INLINE_SIZED_EXACT | 直接操作 byte[],跳过 StringBuilder |
JDK 9 默认使用 MH_INLINE_SIZED_EXACT,直接拼接 byte 数组,性能最优。
# 5.6.4 循环拼接的性能陷阱与解决
反面教材:循环中使用 + 拼接
// 每次循环都创建新的 StringBuilder 和 String 对象
String result = "";
for (int i = 0; i < 10000; i++) {
result += i;
// 等价于:result = new StringBuilder(result).append(i).toString();
// 10000 次循环 = 10000 个 StringBuilder + 10000 个 String
// 且每次都要复制之前的内容,时间复杂度 O(n²)
}
2
3
4
5
6
7
8
正确做法一:循环外创建 StringBuilder
StringBuilder sb = new StringBuilder(64); // 预估容量
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String result = sb.toString();
// 时间复杂度 O(n),只创建 1 个 StringBuilder
2
3
4
5
6
正确做法二:使用 StringJoiner(JDK 8+)
StringJoiner sj = new StringJoiner(", ", "[", "]");
for (int i = 0; i < 10; i++) {
sj.add(String.valueOf(i));
}
String result = sj.toString(); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
2
3
4
5
正确做法三:使用 Stream(JDK 8+)
String result = IntStream.range(0, 10)
.mapToObj(String::valueOf)
.collect(Collectors.joining(", ", "[", "]"));
2
3
性能对比(拼接 10000 个数字):
| 方式 | 耗时 | 对象创建数 |
|---|---|---|
+ 循环拼接 | ~300ms | ~30000 |
| StringBuilder | ~1ms | 1 |
| StringJoiner | ~2ms | 1 |
| String.join | ~2ms | 1 |
# 5.7 StringBuilder与StringBuffer深度对比
# 5.7.1 底层数据结构与扩容机制
两者都继承自 AbstractStringBuilder,底层是可变的字符数组:
abstract class AbstractStringBuilder {
// JDK 8
char[] value; // 可变数组(不是 final!)
int count; // 实际使用的字符数
// JDK 9+
byte[] value;
byte coder;
int count;
}
2
3
4
5
6
7
8
9
10
扩容策略(关键源码):
// AbstractStringBuilder.ensureCapacityInternal
private void ensureCapacityInternal(int minimumCapacity) {
int oldCapacity = value.length >> coder;
if (minimumCapacity - oldCapacity > 0) {
// 新容量 = 旧容量 × 2 + 2
int newCapacity = (oldCapacity << 1) + 2;
if (newCapacity - minimumCapacity < 0)
newCapacity = minimumCapacity; // 仍不够则直接用需要的容量
if (newCapacity < 0) {
if (minimumCapacity < 0) throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
// 创建新数组并复制数据
value = Arrays.copyOf(value, newCapacity << coder);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
扩容过程示例:
初始容量:16(默认无参构造)
第1次扩容:16 × 2 + 2 = 34
第2次扩容:34 × 2 + 2 = 70
第3次扩容:70 × 2 + 2 = 142
...
2
3
4
5
优化建议:如果能预估字符串长度,在构造时指定初始容量,避免扩容:
// 不推荐:默认容量16,可能多次扩容
StringBuilder sb = new StringBuilder();
// 推荐:预估容量
StringBuilder sb = new StringBuilder(256);
// 带初始字符串:容量 = str.length() + 16
StringBuilder sb = new StringBuilder("Hello"); // 容量 = 5 + 16 = 21
2
3
4
5
6
7
8
# 5.7.2 线程安全实现原理
StringBuffer 通过 synchronized 关键字保证线程安全:
// StringBuffer 的 append 方法
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null; // 清除 toString 缓存
super.append(str); // 调用父类 AbstractStringBuilder
return this;
}
// StringBuilder 的 append 方法(无 synchronized)
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
StringBuffer 额外的 toString 缓存优化:
public class StringBuffer {
private transient String toStringCache; // 缓存上次 toString 的结果
@Override
public synchronized String toString() {
if (toStringCache == null) {
return toStringCache = isLatin1()
? StringLatin1.newString(value, 0, count)
: StringUTF16.newString(value, 0, count);
}
return new String(toStringCache);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
每次 append 操作都会清除缓存,只有连续调用 toString() 才会命中缓存。
# 5.7.3 性能基准测试对比
// JMH 基准测试结果(单线程,append 10000 次)
// StringBuilder: 约 0.2ms
// StringBuffer: 约 0.3ms (慢约 50%,因为 synchronized 即使无竞争也有轻量级锁开销)
// 多线程共享同一对象(4线程,各 append 2500 次)
// StringBuilder: 结果不确定(数据错乱、ArrayIndexOutOfBoundsException)
// StringBuffer: 正常运行,结果正确
2
3
4
5
6
7
选择建议:
- 99% 的场景使用 StringBuilder:字符串拼接几乎都在单个方法内完成,不存在多线程共享
- StringBuffer 适用场景极少:只有多个线程共享同一个 StringBuilder 对象时才需要
- 实际上更好的多线程方案是每个线程用自己的 StringBuilder,最后合并结果
# 5.8 String相关的JVM优化
# 5.8.1 字符串去重优化
G1 收集器从 JDK 8u20 开始支持字符串自动去重(String Deduplication, JEP 192):
# 开启字符串去重(默认关闭)
-XX:+UseStringDeduplication
-XX:+UseG1GC
# 触发去重的年龄阈值(对象经过多少次GC后参与去重)
-XX:StringDeduplicationAgeThreshold=3
2
3
4
5
6
原理:GC 时扫描堆中的 String 对象,如果两个 String 的 value 数组内容相同,让它们共享同一个 value 数组。
去重前:
String s1 → value1 = {'H','e','l','l','o'}
String s2 → value2 = {'H','e','l','l','o'} // 内容相同但不同的数组对象
去重后:
String s1 → value1 = {'H','e','l','l','o'}
String s2 ↗ (s2 的 value 指向了 s1 的 value)
// value2 被回收
2
3
4
5
6
7
8
注意:去重只作用于 value 数组,不合并 String 对象本身。这是因为 String 对象的引用可能被 == 比较。
适用场景:数据库查询结果中大量重复字符串(如状态值、城市名等)。
# 5.8.2 hashCode缓存机制
String 的 hashCode 计算使用经典的 31 进制公式:
// s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
for (int i = 0; i < value.length; i++) {
h = 31 * h + value[i];
}
hash = h; // 缓存结果
}
return h;
}
2
3
4
5
6
7
8
9
10
11
疑惑:为什么选择 31 作为乘法因子?
论证:
- 31 是奇素数:素数能减少哈希冲突概率
- 31 = 2^5 - 1:JIT 编译器可以将
31 * h优化为(h << 5) - h,位运算比乘法更快 - 经验选择:过小的素数(如 2、3)冲突率高,过大的素数溢出频繁。31 在实践中表现最佳
JDK 9+ 的 hashIsZero 优化:
// 问题:如果字符串的 hashCode 恰好计算出 0 怎么办?
// hash 字段初始值也是 0,无法区分"未计算"和"计算结果为0"
// JDK 13+ 引入了 hashIsZero 字段解决这个问题
private boolean hashIsZero;
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 5.8.3 编译器对String的特殊处理
Java 编译器(javac)和 JIT 编译器对 String 有很多特殊优化:
1. 编译期常量折叠(前面已详述)
2. switch-case 对 String 的支持(JDK 7+):
// 源代码
switch (str) {
case "hello": doA(); break;
case "world": doB(); break;
}
// 编译后等价于(先比较 hashCode,再用 equals 确认)
int h = str.hashCode();
switch (h) {
case 99162322: // "hello".hashCode()
if (str.equals("hello")) doA();
break;
case 113318802: // "world".hashCode()
if (str.equals("world")) doB();
break;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
3. JIT 编译器的 intrinsic 优化:
HotSpot JVM 对 String 的核心方法(如 equals、indexOf、compareTo)有 intrinsic 实现——用手写的机器码替代 Java 字节码,充分利用 CPU 的 SIMD 指令:
// String.equals() 的 intrinsic 优化:
// 普通 Java 实现:逐字符比较,一次比较 2 字节
// Intrinsic 实现:使用 SSE/AVX 指令,一次比较 16~32 字节
// 对于长字符串比较,性能可提升 4~8 倍
2
3
4
# 5.9 生产环境中的String最佳实践
# 5.9.1 避免内存泄漏的注意事项
JDK 6 的 substring 内存泄漏(已在 JDK 7 修复):
// JDK 6 的 substring 实现:共享原字符串的 char[]
// 仅通过 offset 和 count 截取视图
String huge = loadHugeString(); // 10MB 字符串
String small = huge.substring(0, 5);
huge = null;
// small 仍然持有 10MB 的 char[] 引用!内存泄漏
// JDK 7+ 修复:substring 创建新的 char[] 数组
// 只复制需要的部分,原 char[] 可以被 GC 回收
2
3
4
5
6
7
8
9
toCharArray 的防御性拷贝:
String s = "hello";
char[] chars = s.toCharArray(); // 返回副本,修改不影响原 String
chars[0] = 'H';
System.out.println(s); // 仍然是 "hello"
2
3
4
# 5.9.2 大规模字符串处理策略
1. 使用 CharSequence 接口提高灵活性:
// 方法参数使用 CharSequence 而非 String
// 可以接受 String、StringBuilder、StringBuffer、CharBuffer 等
public boolean containsKeyword(CharSequence text) {
return text.toString().contains("keyword");
}
2
3
4
5
2. 大文件处理避免一次性加载:
// 错误:一次性读取整个文件到 String
String content = new String(Files.readAllBytes(path)); // 可能 OOM
// 正确:流式读取
try (BufferedReader reader = Files.newBufferedReader(path)) {
String line;
while ((line = reader.readLine()) != null) {
processLine(line);
}
}
2
3
4
5
6
7
8
9
10
3. 字符串池化(自定义池):
// 对于有限枚举值的字符串,使用 Map 池化比 intern() 更可控
private static final Map<String, String> POOL = new ConcurrentHashMap<>();
public static String pool(String s) {
String existing = POOL.putIfAbsent(s, s);
return existing != null ? existing : s;
}
2
3
4
5
6
7
# 5.9.3 String与字符编码问题
常见编码陷阱:
// 陷阱1:默认编码不可靠
byte[] bytes = "你好".getBytes(); // 使用系统默认编码,不可移植!
// 正确做法
byte[] bytes = "你好".getBytes(StandardCharsets.UTF_8);
// 陷阱2:字符长度 ≠ 字节长度
String s = "你好World";
System.out.println(s.length()); // 7(字符数)
System.out.println(s.getBytes(StandardCharsets.UTF_8).length); // 11(UTF-8字节数)
// 陷阱3:supplementary characters(增补字符)
String emoji = "😀";
System.out.println(emoji.length()); // 2(UTF-16 用两个 char 表示)
System.out.println(emoji.codePointCount(0, emoji.length())); // 1(实际1个字符)
// 正确遍历所有 Unicode 字符
"Hello😀World".codePoints().forEach(cp -> {
System.out.println(new String(Character.toChars(cp)));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 5.10 总结与核心要点
String 设计哲学:
- 不可变性是基石:字符串常量池、线程安全、hashCode 缓存都建立在不可变之上
- 池化思想:字符串常量池通过复用减少内存消耗,是"享元模式"的经典实践
- 持续优化:从 char[] 到 byte[] 紧凑存储,从 StringBuilder 到 invokedynamic 拼接,每一版都在优化
技术演变总结:
| 版本 | 关键变化 | 影响 |
|---|---|---|
| JDK 1.0 | String 诞生,char[] 存储 | 奠定基础 |
| JDK 1.2 | hashCode 缓存 | HashMap 性能提升 |
| JDK 1.7 | 常量池移入堆,substring 不再共享数组 | 解决 PermGen OOM 和内存泄漏 |
| JDK 1.7u40 | StringTable 默认大小增至 60013 | intern() 性能提升 |
| JDK 8u20 | G1 字符串去重 | 减少重复字符串内存 |
| JDK 9 | byte[] + Compact Strings | 减少 ~15% 堆内存 |
| JDK 9 | invokedynamic 字符串拼接 | 更灵活的拼接优化 |
| JDK 13 | hashIsZero 字段 | 修复 hashCode=0 的重复计算问题 |
核心要点速记:
| 问题 | 答案 |
|---|---|
| String 为什么不可变 | final 类 + final 数组引用 + 不暴露修改方法 |
| 不可变的好处 | 常量池前提、线程安全、hashCode 缓存、安全性 |
| 常量池在哪 | JDK 7+ 在堆内存中 |
new String("abc") 几个对象 | 最多 2 个:常量池 1 个 + 堆 1 个 |
| JDK 6 vs JDK 7 intern 区别 | JDK 6 复制到永久代,JDK 7+ 直接存引用 |
| 循环拼接用什么 | StringBuilder,避免循环内反复创建 |
| JDK 9 底层改了什么 | char[] → byte[] + coder,节省内存 |
| 为什么 hashCode 用 31 | 奇素数 + 可优化为位运算 (h<<5)-h |
掌握 String 的原理,是理解 Java 内存模型、集合框架、并发编程的基础。