String 为什么是不可变的
候选人小林在阿里 P6 面试,面试官问:"你知道 String 为什么设计成不可变的吗?"
小林答:"因为字符串用得很多,设计成不可变可以放到常量池里共享,节省内存。"
面试官:"还有呢?"
小林:"...线程安全?"
面试官:"能展开说说吗?"
小林卡住了,说不出所以然。
【面试官心理】
这道题不是在考记忆,是在考候选人对"不可变对象"这个设计模式的理解深度。能说出"hashCode 缓存"和"安全性"两个维度的候选人,基本能确认是 P6 水准。
一、不可变的底层实现 🔴
public final class String {
// JDK 8:char 数组
private final char[] value;
// JDK 9+:byte 数组(Latin-1 优化)
private final byte[] value;
private final byte coder;
// 关键:value 是 private + final
// private:外部无法直接访问
// final:引用不能重新指向新数组
// 且没有提供任何修改 value 的方法
}
两道保险:
final 修饰引用,不能重新赋值
- 没有任何修改
value 的公开方法(setter)
String 类本身也是 final 的,不能被继承,避免子类破坏不可变性。
二、不可变的四大原因 🔴
2.1 字符串常量池(性能)
String a = "hello"; // 放入常量池
String b = "hello"; // 直接复用常量池中的对象
System.out.println(a == b); // true,同一对象
// 如果 String 可变:
// a 修改了 "hello" → b 也会受影响,这是灾难性的
不可变性是字符串常量池得以实现的前提。如果字符串可变,共享同一对象就会导致互相干扰。
2.2 hashCode 缓存(性能)
public final class String {
private int hash; // 缓存 hashCode,初始值 0
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
// 只在第一次调用时计算
for (char c : value) h = 31 * h + c;
hash = h;
}
return h;
}
}
String 的 hashCode 只计算一次,后续从缓存返回。这也是 String 是 HashMap 最常见 key 的原因之一——get 操作不需要重新计算 hashCode,性能极好。
如果 String 可变,hashCode 可能随时变化,缓存就毫无意义,而且会导致 HashMap 查找失效。
2.3 线程安全
不可变对象天然是线程安全的:
// String 可以安全地在多个线程间共享,无需任何同步
String url = "https://example.com/api";
// 多个线程同时读取 url,不会有问题
// 因为没有任何操作能修改 url 指向的对象
这是不可变对象最重要的工程价值:多读共享,零锁开销。
2.4 安全性
// String 作为文件路径
void readFile(String path) {
// 如果 path 是可变的,调用方可能在另一个线程中修改 path
// 导致实际读取的文件和预期不同(安全漏洞!)
FileReader fr = new FileReader(path);
}
// 网络连接
Socket socket = new Socket(host, port);
// 如果 host 可变,连接建立后被修改,日志记录和实际连接不一致
JDK 的设计者明确表示,String 不可变是出于安全考虑——很多安全敏感的 API(网络、文件、类加载器)都以 String 作为参数。
三、"修改" String 的假象 🔴
String s = "hello";
s = s.toUpperCase(); // s 现在指向 "HELLO"
// 但原来的 "hello" 对象没有被修改,只是 s 的引用变了
所有看起来像"修改"的 String 方法(toUpperCase、replace、substring 等),实际上都是创建并返回新对象,原对象不变。
反射能破坏 String 的不可变性?
String s = "hello";
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(s);
value[0] = 'H'; // 修改了底层 char 数组
System.out.println(s); // "Hello",看起来被修改了
// 但这非常危险:
// 1. 常量池中的 "hello" 也被修改了
// 2. 其他指向同一对象的引用都受影响
// 3. s.hashCode() 还是旧的缓存值,不再匹配实际内容
⚠️
反射修改 String 内部数组是极其危险的操作,会破坏常量池、hashCode 缓存,导致程序行为完全不可预期。绝对禁止在生产代码中使用。
四、不可变对象的工程价值 🔴
4.1 自定义不可变对象
// 不可变类的标准设计
public final class Money { // final 防止继承
private final long amount; // 金额(分)
private final String currency; // 货币
public Money(long amount, String currency) {
// 在构造器中做参数校验
if (amount < 0) throw new IllegalArgumentException("Amount cannot be negative");
this.amount = amount;
this.currency = currency;
}
// 只有 getter,没有 setter
public long getAmount() { return amount; }
public String getCurrency() { return currency; }
// "修改"操作返回新对象
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);
}
}
4.2 防御性拷贝
当不可变类包含可变字段时,需要防御性拷贝:
public final class Period {
private final Date start; // Date 是可变的!
private final Date end;
// ❌ 错误:直接保存外部传入的引用
// public Period(Date start, Date end) {
// this.start = start; // 外部可通过 start 引用修改对象内容
// this.end = end;
// }
// ✅ 正确:防御性拷贝
public Period(Date start, Date end) {
this.start = new Date(start.getTime()); // 拷贝,不是引用
this.end = new Date(end.getTime());
}
// getter 也需要防御性拷贝
public Date getStart() {
return new Date(start.getTime()); // 不暴露内部引用
}
}
💡
能说出"防御性拷贝"的候选人,面试官会认为有 Effective Java 的功底,直接加分。
五、追问链
第一层:String 为什么是不可变的?
→ 常量池共享、hashCode 缓存、线程安全、安全性
第二层:String 的 substring 方法在 JDK 6 和 JDK 7 有什么不同?
→ JDK 6:substring 返回的 String 和原 String 共享 char 数组(节省内存,但可能导致内存泄漏);JDK 7+:substring 会复制需要的部分,独立存储(修复了内存泄漏问题)
第三层:如果要设计一个高频访问的不可变 Value Object,需要注意什么?
→ final 类、final 字段、防御性拷贝可变字段、重写 equals/hashCode、考虑缓存 hashCode
【面试官心理】
JDK 6 和 JDK 7 substring 的差异是一个很少人知道的细节,能说出来直接拉开档次。这个问题在大数据处理场景中实际出现过内存泄漏,阿里的线上系统曾踩过这个坑。