Java String为什么是不可变的?StringBuilder线程安全吗
理解 Java 中 String 的不可变性以及 StringBuilder 的线程安全问题,是编写高性能、线程安全代码的基础。以下将通过分析源码和实际应用场景,为你拆解这两个核心概念。
1. 理解 String 的不可变性
要明白 String 为什么不可变,首先需要查看 Java 源码中的 String 类定义。
打开 JDK 源码,你会发现 String 类使用了 final 修饰符:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
// ... 其他代码
}
分析 上述代码,两个关键点决定了它的不可变特性:
- 类被 final 修饰:这意味着
String不能被继承,没有任何子类可以重写或修改它的方法。 - 字符数组被 final 修饰:
private final char value[]中的final关键字表示引用value一旦被初始化,就不能指向其他数组对象。虽然 Java 9 之后将内部实现从char[]变更为byte[],但其final属性依然保留。
执行 下面的代码来验证这一特性:
String s = "Hello";
s.concat(" World");
System.out.println(s); // 输出依然是 "Hello"
观察 输出结果,你会发现 s 的值并没有改变。这是因为 concat 方法内部创建了一个新的 String 对象来容纳 "Hello World",而原对象 s 保持不变。
2. 掌握 String 设计为不可变的原因
Java 语言设计者将 String 设计为不可变,主要基于以下三个核心考量:
2.1 保证线程安全
在多线程环境下,如果一个对象是可变的,多个线程同时修改它就需要复杂的同步机制。由于 String 不可变,它在被多个线程共享时无需加锁,天然保证线程安全,极大地降低了并发编程的复杂度。
2.2 优化字符串常量池
Java 为了节省内存,维护了一个“字符串常量池”。当创建一个字符串字面量时,JVM 会先去池中查找是否存在。
如果 String 是可变的,假设引用 A 指向了池中的 "java",而引用 B 修改了该对象为 "python",那么 A 的值也会莫名其妙地变成 "python"。这会导致程序逻辑崩溃。因此,只有不可变对象才能安全地放入常量池并共享。
2.3 缓存 HashCode
String 经常被用作 HashMap 的键。因为不可变,所以它的 hashCode 值是可以被缓存且不会改变的。
查看 String 源码中的 hashCode 方法:
private int hash; // Default to 0
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;
}
注意 代码逻辑,HashCode 只在第一次计算时生成,后续直接返回缓存值。如果 String 可变,内容改变后 hashCode 也会变,这会导致 HashMap 中无法找到之前存入的键值对。
3. 分析 StringBuilder 的线程安全性
StringBuilder 是 Java 提供的可变字符序列,主要用于高效的字符串拼接。关于它的线程安全性,答案是:不安全。
对比 StringBuilder 和 StringBuffer 的 append 方法源码,即可一目了然。
查看 StringBuilder 的 append 方法:
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
查看 StringBuffer 的 append 方法:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
识别 关键差异:StringBuffer 的方法前添加了 synchronized 关键字,这意味着同一时刻只有一个线程能访问该方法,保证了线程安全。而 StringBuilder 没有任何同步控制。
模拟 一个多线程场景:
StringBuilder sb = new StringBuilder();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
sb.append("a");
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(sb.length()); // 结果可能小于 2000
运行 上述代码,sb.length() 的结果往往小于 2000。这是因为两个线程同时调用 append 时,可能会出现“写冲突”:线程 A 刚读取了当前长度,还没来得及写入数据,线程 B 也读取了相同的长度并进行了覆盖写入,导致数据丢失。
4. 实操指南:如何选择字符串处理类
为了在开发中做出正确选择,请遵循以下决策步骤。
-
定义 常量或少量字符串操作
- 优先使用
String。 - 原因:简单、安全、自动支持常量池优化。
- 优先使用
-
进行 大量的单线程字符串拼接(如循环拼接 SQL 语句、构建 JSON)
- 直接使用
StringBuilder。 - 原因:省去了
synchronized的开销,性能是最高的。
- 直接使用
-
处理 多线程环境下的共享变量拼接
- 选择使用
StringBuffer。 - 原因:虽然性能较低,但能保证数据一致性。
- 注意:更推荐的做法是使用
ThreadLocal<StringBuilder>或者将StringBuilder定义为方法内部的局部变量(方法栈封闭),从而完全避免多线程竞争,这样既能享受StringBuilder的高性能,又保证了安全。
- 选择使用
为了方便记忆,下表总结了三者的核心区别:
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全性 | 线程安全 | 不安全 | 线程安全 (synchronized) |
| 执行速度 | 慢 (涉及对象创建) | 最快 | 较慢 (锁开销) |
| 适用场景 | 常量、少量操作 | 单线程大量拼接 | 多线程共享变量操作 |

暂无评论,快来抢沙发吧!