跳到主要内容

String 深入

问题

String 为什么是不可变的?String Pool 是什么?String、StringBuilder、StringBuffer 有什么区别?

答案

String 的不可变性

String 类被声明为 final,内部存储字符的数组也是 final 的:

String.java(JDK 8 简化)
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
}

不可变的原因

  1. String 类是 final 的,不能被继承,防止子类破坏不可变性
  2. value 数组是 private final 的,初始化后引用不可变
  3. String 类没有暴露任何修改 value 数组内容的方法

不可变的好处

  • 线程安全:多线程共享无需同步
  • 哈希值缓存hashCode 只需计算一次,适合作为 HashMap 的 key
  • 字符串池:相同内容的字符串可以共享同一个对象
  • 安全性:作为类加载、网络连接等参数时,不可变防止被篡改
通过反射破坏不可变性

虽然 valuefinal 的(引用不可变),但通过反射可以修改数组内容:

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 为了减少字符串对象的重复创建而维护的一个特殊存储区域:

StringPoolDemo.java
// 字面量方式:先查常量池,存在则复用,不存在则创建并放入池中
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 个

  1. 如果常量池中没有 "hello",先在常量池创建一个字符串对象
  2. 然后在堆上创建一个新的 String 对象,其 value 指向常量池中字符串的 char[]

如果常量池中已有 "hello",则只在堆上创建 1 个对象。

String 拼接的底层实现

StringConcatDemo.java
// 编译期常量折叠
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

特性StringStringBuilderStringBuffer
可变性不可变可变可变
线程安全安全(不可变)不安全安全(synchronized)
性能拼接慢(创建新对象)最快较慢(同步开销)
使用场景少量操作、常量单线程大量拼接多线程大量拼接
JDK 版本1.01.51.0
StringBuilderDemo.java
// 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 标志位:

String.java(JDK 9+ 简化)
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,且没有暴露修改方法。好处包括:

  1. 线程安全:不需要同步就可以在多线程间共享
  2. hashCode 缓存:只需计算一次,作为 HashMap 的 key 效率极高
  3. 字符串池复用:相同内容共享同一对象,节省内存
  4. 安全性:作为网络连接参数、类加载路径等不会被篡改

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) 更好。如果 snulls.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 6substring() 返回的新字符串与原字符串共享同一个 char[],只是 offsetcount 不同。如果原字符串很大但只取了一小部分,原字符串无法被 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)

相关链接