String 是面试里被问得最多的一块。单看每个问题都不难,但答案之间互相牵连:不可变是常量池能成立的前提,常量池又决定了 == 和 intern() 的结果。
String、StringBuilder、StringBuffer
三个类都表示字符串,底层都是一个字节数组,区别在于这个数组能不能改。
String是final class,内部的value是private final byte[],所有看起来像"修改"的方法(concat、replace、substring)都返回新对象。StringBuilder的value是普通byte[],append直接往数组里写,写完把count加一,不产生新对象。StringBuffer和StringBuilder共用AbstractStringBuilder,主要方法上加了synchronized。
| String | StringBuilder | StringBuffer | |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 安全(不可变) | 不安全 | 安全(方法同步) |
| 频繁拼接 | 每次新建对象 | 快 | 比 StringBuilder 多一层锁 |
| 适用 | 内容固定的字符串 | 单线程拼接 | 多线程共享的拼接 |
StringBuffer的线程安全是靠给方法加锁实现的,同一时刻只有一个线程能append。String的线程安全是因为它根本改不了,两者不是一回事。
不可变性
"不可变"指对象创建之后,它内部的字符内容不会再变。注意区分对象和变量:
java
String a = "abc", b = a;
a = a + "d";
System.out.println(b); // abc
a = a + "d" 改的是变量 a 指向谁,原来那个 "abc" 对象一个字都没动,b 还指着它。新拼接出来的 "abcd" 是一个新对象。
JDK 25 里 String 的字段是这样:
text
private final byte[] value;
private final byte coder;
private int hash;
final class:不能被继承,防止子类加一些能改内容的方法。value是private final:外部拿不到这个数组的引用,类内部也没有任何方法去改它的元素。- 构造函数收到外部传进来的
char[]/byte[]时会拷贝一份,传完之后调用方再改原数组也影响不到String。
hash 没加 final,因为它不是构造时算的,而是第一次调 hashCode() 时算出来存进去,后面直接读缓存。
所以"修改字符串"这个动作在 Java 里根本不存在,我们能做的只是创建一个内容不同的新
String。
不可变带来四个好处:
| 好处 | 原因 |
|---|---|
| 常量池可以复用 | 池里的对象不会被谁改掉,多个引用共享是安全的 |
| 线程安全 | 没有可变状态,不需要同步 |
| 哈希值能缓存 | 内容不变,hashCode() 算一次就够了 |
| 安全性 | 类名、文件路径、URL 这类内容如果能在校验后被改掉,检查就白做了 |
Java 9 之前 value 是 char[],每个字符固定占 2 字节。Java 9 之后 改成了 byte[] 加一个 coder:如果所有字符都在 Latin-1 范围内,就用 1 字节存一个字符(coder = LATIN1),否则用 UTF-16(coder = UTF16)。这就是 compact strings,纯英文的字符串内存直接减半。对外行为没变,变的只是内部存储。
字符串常量池
字符串字面量在代码里出现得非常频繁,如果每次都新建对象,内存浪费很大,而且比较时还得逐字符比。常量池的作用就是让相同内容的字面量共用同一个对象。
java
String a = "abc"; // 字面量,指向池里的对象
String b = new String("abc"); // 堆里新建一个
前者编译进 class 文件时就是一个 CONSTANT_String 常量,第一次执行 ldc 时会在池里找到或创建 "abc",把引用给 a。后者一定会 new 一个新对象,构造时把 "abc" 的内容拷进去。
所以:
text
a == b : false
a.equals(b) : true
池在哪:Java 7 之前字符串常量池在永久代里,Java 7 移到了堆,Java 8 永久代被元空间取代。现在可以认为池是堆里的一张表,表里存的是 String 对象的引用,字面量对应的对象本身也在堆里。
new String("abc") 涉及几个对象
这题要分情况:
- 池里还没有
"abc":创建一个字面量对象放进池,再new一个,两个。 - 池里已经有
"abc"(比如前面已经用过这个字面量):只new一个,一个。
面试里说"最多两个"是安全的。被问细节时,把"池里有没有"这个前提讲出来就行。
== 和 equals
== 比的是引用,两个变量指向同一个对象才为 true。equals() 在 String 里被重写过,比的是内容。判断两个字符串内容是否相同,一律用 equals()。
intern()
intern() 做的事只有一件:去常量池里查有没有内容相同的字符串。
- 有:返回池里的那个引用。
- 没有:把当前对象的引用登记进池,返回当前对象。
注意第二句是 Java 7 之后的行为。Java 6 及以前,池在永久代,如果不存在会把字符串复制一份放进去;Java 7 把池移到堆之后,直接存堆里那个对象的引用,不再复制。所以 intern() 不一定会创建新对象。
用代码验证:
java
String a = "abc";
String b = new String("abc");
System.out.println(b.intern() == a);
String c = new String("计算") + new String("机");
System.out.println(c == c.intern());
text
b.intern() == a : true
c == c.intern() : true
第一个:b 是堆里 new 出来的,intern() 发现池里已经有字面量 "abc",返回池里那个,所以和 a 相等。
第二个:"计算机" 从头到尾没有以字面量出现过(拼接用的是 "计算" 和 "机" 两个字面量),池里没有,intern() 就把 c 自己登记进去,返回的当然还是 c。
这行代码的结果依赖池里的状态。如果前面某处写过
String x = "计算机";,池里已经有了,上面第二个输出就会变成false。
intern() 的实际用途是给大量重复的字符串去重,比如解析日志时字段值反复出现。代价是要查一次哈希表,而且池只增不减,不适合拿外部输入无脑 intern()。
字符串拼接
编译期常量会直接折叠
两个都是编译期常量时,+ 在编译阶段就算完了:
java
String d = "a" + "b";
final String e = "a";
String f = e + "b";
String g = new String("a");
String h = g + "b";
System.out.println(d == "ab"); // true
System.out.println(f == "ab"); // true
System.out.println(h == "ab"); // false
d 编译出来就是字面量 "ab"。e 是 final 且初始值是常量表达式,编译器把它当常量,e + "b" 照样折叠。g 虽然内容也是 "a",但它是运行时 new 出来的变量,h 只能运行时拼,结果是一个新对象。
编译器怎么处理运行时的 +
Java 8 及以前,javac 把 a + b 编译成 new StringBuilder().append(a).append(b).toString()。Java 9 之后改成了 invokedynamic,javap -c 看到的是:
text
2: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(...)
字节码里已经没有 StringBuilder 了。两种方式的结果一样:每次执行 + 都会产生一个新的 String。
循环里用 + 的问题
java
String s = "";
for (int i = 0; i < 10000; i++) {
s += i;
}
第 i 次要复制前面已经拼出来的所有字符,总复制量是 1 + 2 + ... + n,复杂度 O(n²)。次数一多,时间和内存都会明显上去。
换成 StringBuilder:
java
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String s = sb.toString();
只在一开始分配数组,后面 append 往里写,扩容时按 oldCapacity * 2 + 2 翻倍,均摊下来每次追加是 O(1)。
默认容量 16。实测写满 16 个字符时容量还是 16,再写第 17 个就变成 34(16 * 2 + 2),然后 Arrays.copyOf 把旧数据拷过去。如果大概知道最终长度,直接 new StringBuilder(10000),能省掉多次扩容和拷贝。
什么时候用哪个
- 一次性的、两三个值的拼接:直接用
+,可读性最好,编译器也会优化。 - 循环里拼、或者有很多条件分支往里追加:
StringBuilder。 - 多线程共享同一个拼接缓冲:
StringBuffer,或者每个线程各自用StringBuilder再合并。
equals()
Object.equals() 的默认实现就是引用比较:
java
public boolean equals(Object obj) {
return (this == obj);
}
String 重写了它,先比引用,再比长度,最后逐字节比较内容。引用相同直接返回 true,省掉后面的比较。
为什么要重写:字符串的语义相等是内容相等,不是同一个对象。new String("abc") 和 "abc" 是两个对象,但我们希望它们相等。String 又经常当 HashMap 的 key,没有内容级的 equals 和 hashCode,查表就找不到。
String.hashCode() 的公式是:
hash=s0×31n−1+s1×31n−2+⋯+sn−1
"abc".hashCode() 算出来是 96354,和实测一致。结果第一次算完会存进 hash 字段,后面直接读。
三者关系:
==为true→equals()一定为trueequals()为true→hashCode()一定相等- 反过来都不成立
判断内容时用 equals()。如果担心空指针,把常量放前面:"abc".equals(s),或者用 Objects.equals(s, "abc")。
几个容易混淆的点
变量和常量参与 + 运算。 两个字面量(或编译期常量)相加,编译期折叠,结果在池里;只要有一个是运行时变量,就产生新对象。上面那组 d / f / h 的输出就是这三种情况。
final String 不等于内容可改。 final 修饰的是引用,s = "b" 编译不过,因为不能重新赋值。但对象内容本身照样改不了,因为 String 不可变。如果换成 final List<String> list,引用不能换,list.add() 却是可以的 ------ final 管的是变量,不是对象。
StringBuilder 为什么适合频繁拼接。 它从头到尾只有一个 byte[],append 是往数组末尾写,必要时扩容。不像 String,每次拼接都要新建对象再拷贝。
常量池、堆和引用的关系。 变量里存的是引用;new 出来的 String 在堆里,变量指向它;字面量的对象也在堆里,常量池这张表存的是它的引用。intern() 就是在"池里有没有内容相同的引用"这件事上做文章。
总结
| 问题 | 结论 |
|---|---|
| 内容比较 | equals();== 只比引用 |
字面量 vs new |
字面量复用池里的对象,new 必新建 |
new String("abc") |
最多两个对象:池里一个,堆里一个 |
intern() |
池里有就返回池里的,没有就登记当前引用,不一定新建 |
| 拼接 | 编译期常量会折叠;运行时 + 每次产生新对象;循环里用 StringBuilder |
| 线程安全 | String 靠不可变,StringBuffer 靠同步,StringBuilder 不安全 |
几条能串起来的结论:
- 不可变是常量池能成立的前提,不然共享的对象被改掉都不会被发现。
- 常量池决定了
==的结果:字面量和intern()返回的是池里的引用,new出来的是新对象。 - 拼接的优化分两层:编译期能算的直接折叠,算不了的运行时生成新对象,循环里换成
StringBuilder。