String 深入
问题
String 为什么是不可变的?String Pool 是什么?String、StringBuilder、StringBuffer 有什么区别?
答案
String 的不可变性
String 类被声明为 final,内部存储字符的数组也是 final 的:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
// JDK 8: char[],JDK 9+ 改为 byte[](Compact Strings)
private final char[] value;
private int hash; // 缓存 hashCode
}
不可变的原因:
String类是final的,不能被继承,防止子类破坏不可变性value数组是private final的,初始化后引用不可变String类没有暴露任何修改value数组内容的方法
不可变的好处:
- 线程安全:多线程共享无需同步
- 哈希值缓存:
hashCode只需计算一次,适合作为 HashMap 的 key - 字符串池:相同内容的字符串可以共享同一个对象
- 安全性:作为类加载、网络连接等参数时,不可变防止被篡改
虽然 value 是 final 的(引用不可变),但通过反射可以修改数组内容:
String s = "hello";
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(s);
value[0] = 'H'; // 修改了原始字符串!
System.out.println(s); // "Hello"
这在 JDK 9+ 中会收到警告,JDK 16+ 默认不允许(强封装)。实际开发中绝不应该这么做。
String Pool(字符串常量池)
字符串常量池是 JVM 为了减少字符串对象的重复创建而维护的一个特殊存储区域:
// 字面量方式:先查常量池,存在则复用,不存在则创建并放入池中
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true(指向常量池中同一个对象)
// new 方式:在堆上创建新对象,不会自动入池
String s3 = new String("hello");
System.out.println(s1 == s3); // false(s3 在堆上,s1 在常量池)
// intern() 方法:将字符串放入常量池(如果池中已存在则返回池中的引用)
String s4 = s3.intern();
System.out.println(s1 == s4); // true
常量池的位置变化:
| JDK 版本 | 位置 |
|---|---|
| JDK 6 及之前 | 永久代(PermGen) |
| JDK 7 | 移到堆(Heap)中 |
| JDK 8+ | 堆中(PermGen 被 Metaspace 替代) |
new String("hello") 创建了几个对象?最多 2 个:
- 如果常量池中没有
"hello",先在常量池创建一个字符串对象 - 然后在堆上创建一个新的 String 对象,其
value指向常量池中字符串的char[]
如果常量池中已有 "hello",则只在堆上创建 1 个对象。
String 拼接的底层实现
// 编译期常量折叠
String s1 = "hello" + " world"; // 编译后等价于 "hello world"
// 变量拼接 — JDK 8:编译器使用 StringBuilder
String a = "hello";
String b = a + " world";
// 编译后等价于:
// new StringBuilder().append(a).append(" world").toString();
// JDK 9+:使用 invokedynamic + StringConcatFactory(性能更优)
// 反例:每次循环都创建 StringBuilder 对象
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 每次创建一个新的 StringBuilder
}
// 正例:在循环外创建 StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String result = sb.toString();
String vs StringBuilder vs StringBuffer
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 安全(不可变) | 不安全 | 安全(synchronized) |
| 性能 | 拼接慢(创建新对象) | 最快 | 较慢(同步开销) |
| 使用场景 | 少量操作、常量 | 单线程大量拼接 | 多线程大量拼接 |
| JDK 版本 | 1.0 | 1.5 | 1.0 |
// StringBuilder — 单线程字符串操作首选
StringBuilder sb = new StringBuilder("hello");
sb.append(" world") // 追加
.insert(5, ",") // 插入
.delete(0, 5) // 删除
.reverse(); // 反转
String result = sb.toString(); // 转为 String
// StringBuffer — API 与 StringBuilder 完全一致,仅多了 synchronized
StringBuffer buf = new StringBuffer("hello");
buf.append(" world"); // 线程安全的追加
JDK 9 Compact Strings
JDK 9 将 String 内部存储从 char[] 改为 byte[] + coder 标志位:
public final class String {
private final byte[] value;
private final byte coder; // LATIN1 = 0, UTF16 = 1
// 如果字符串只包含 Latin-1 字符(ASCII + 西欧字符),
// 每个字符只需 1 字节(而非 2 字节),节省约 50% 内存
}
常见面试问题
Q1: String 为什么是不可变的?有什么好处?
答案:
String 被设计为 final 类,内部 value 数组是 private final,且没有暴露修改方法。好处包括:
- 线程安全:不需要同步就可以在多线程间共享
- hashCode 缓存:只需计算一次,作为 HashMap 的 key 效率极高
- 字符串池复用:相同内容共享同一对象,节省内存
- 安全性:作为网络连接参数、类加载路径等不会被篡改
Q2: String s = new String("abc") 创建了几个对象?
答案:
最多 2 个对象。首先检查常量池中是否存在 "abc":
- 如果不存在:在常量池创建 1 个字符串对象 + 在堆上创建 1 个 String 对象 = 2 个
- 如果已存在:只在堆上创建 1 个 String 对象 = 1 个
Q3: String 的 intern() 方法有什么作用?
答案:
intern() 会检查常量池中是否已存在内容相同的字符串:
- 如果存在,返回常量池中的引用
- 如果不存在(JDK 7+),将堆中字符串的引用放入常量池,并返回该引用
String s1 = new String("hello");
String s2 = s1.intern();
String s3 = "hello";
System.out.println(s2 == s3); // true
适用于大量重复字符串的场景,可以减少内存占用,但 intern() 本身有性能开销。
Q4: 字符串拼接哪种方式效率最高?
答案:
- 少量拼接(2-3 个):直接用
+,编译器会优化 - 循环拼接:用
StringBuilder,避免重复创建对象 - 多线程拼接:用
StringBuffer - JDK 8+:
String.join()或StringJoiner适合带分隔符的拼接
// String.join — 带分隔符拼接
String result = String.join(", ", "a", "b", "c"); // "a, b, c"
// StringJoiner — 更灵活
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("a").add("b").add("c");
String result = sj.toString(); // "[a, b, c]"
Q5: "abc".equals(s) 和 s.equals("abc") 哪种写法更好?
答案:
"abc".equals(s) 更好。如果 s 为 null,s.equals("abc") 会抛出 NullPointerException,而 "abc".equals(s) 只会返回 false。这是防御性编程的最佳实践。(JDK 7+ 也可以用 Objects.equals(s, "abc"))
Q6: String 的 hashCode 是怎么计算的?
答案:
// 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) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
选择 31 的原因:31 是奇素数,31 * i 可以被 JVM 优化为 (i << 5) - i(位运算更快),且统计表明 31 的哈希分布效果好,碰撞少。
Q7: substring() 在 JDK 6 和 JDK 7 中有什么区别?
答案:
- JDK 6:
substring()返回的新字符串与原字符串共享同一个char[],只是offset和count不同。如果原字符串很大但只取了一小部分,原字符串无法被 GC,造成内存泄漏。 - JDK 7+:
substring()会创建新的char[],拷贝所需的字符,不再共享底层数组,避免了内存泄漏问题。
Q8: String 与基本类型之间如何转换?
答案:
// String → 基本类型
int i = Integer.parseInt("123");
double d = Double.parseDouble("3.14");
boolean b = Boolean.parseBoolean("true");
// 基本类型 → String(推荐 valueOf)
String s1 = String.valueOf(123); // "123"
String s2 = Integer.toString(123); // "123"
String s3 = 123 + ""; // "123"(不推荐,会创建 StringBuilder)