查漏补缺:Java 语言与并发遗漏补齐学习笔记(1/2):Java 语言主题查漏(String 不可变与常量池)
面试里有个经典开场:面试官写下 String s = new String("abc");,问这行创建了几个对象。你答"两个",他接着问"哪两个、分别在哪、为什么"。答不上来,后面聊并发和 JVM 时他会默认你基础不牢。这篇就把 String 这条线一次挖到底。
手把手实操
先建一个类,把几种创建方式摆在一起,跑一遍看结果。
public class StringDemo {
public static void main(String[] args) {
String a = "abc"; // 字面量
String b = "abc"; // 字面量
String c = new String("abc"); // new
String d = new String("abc"); // new
System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(c == d); // false
System.out.println(a == c.intern()); // true
}
}
问题是什么?a == b 为 true,说明两个字面量指向同一块内存;a == c 为 false,说明 new 出来的对象在别处。要搞清"别处"是哪,得知道字符串常量池的位置。
关键点在 intern():它把堆里对象的引用尝试放进常量池,池里已有 "abc" 就直接返回池里那个,所以 a == c.intern() 是 true。踩坑点:== 比的是引用地址,不是内容,别拿它判断字符串相等。
再看拼接。把下面这段也跑一遍:
String s = "";
for (int i = 0; i < 10000; i++) {
s += i; // 每次循环都 new 一个 StringBuilder
}
循环里 s += i 编译后等价于每次 new StringBuilder().append(s).append(i).toString(),一万次就是一万个临时对象。换成循环外一个 StringBuilder 复用,对象数从一万降到个位数。这是"String 不可变"最直接的代价体现——每次修改都产生新对象。
看源码及解析
先回答"几个对象"。new String("abc") 这行:字面量 "abc" 在类加载时进常量池(如果池里没有),这是第一个;new 在堆上再分配一个 String 对象,这是第二个。所以是"一个或两个"——池里已有 "abc" 时只创建堆里那一个。面试官问"几个",标准答法是"常量池没有则两个,有则一个"。
为什么 String 要设计成不可变?看 java.lang.String 的字段:
private final char[] value; // JDK 8 及以前
private final byte[] value; // JDK 9 起,配合 coder 字段压缩
value 是 final 的,且 String 不对外暴露修改数组的方法,所有"修改"方法(substring、concat、replace)都返回新对象。JDK 9 把 char[] 换成 byte[] 加一个 coder 标记编码,是为了省内存——拉丁字符一个字节就够,不用两个字节。这是版本差异,面试提一句能加分。
不可变带来三个实际好处。一是可以安全共享,常量池才有意义,a == b 才能成立。二是天然线程安全,多线程读同一个 String 不用加锁。三是 hashCode 可以缓存,看源码:
private int hash; // 默认 0,惰性计算后缓存
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
// 遍历计算
hash = h;
}
return h;
}
第一次算完存进 hash 字段,之后直接返回。HashMap 拿 String 当 key 时,这个缓存省掉大量重复计算。如果 String 可变,缓存 hashCode 就是错的。
intern() 的实现在 String.intern(),是 native 方法,逻辑是:池里已有等值字符串就返回池里的引用,没有就把当前对象的引用登记进池。JDK 7 起常量池从永久代挪到了堆里,所以 intern() 返回的是堆中对象的引用,不再是永久代里另存一份。这个变化直接导致某些老代码的 == 判断行为改变,是真实踩坑点。
验证方法
跑上面的 StringDemo,四个输出依次是 true / false / false / true,说明你分清了字面量、new、intern 三者的内存归属。
再验证拼接开销:把循环拼接改成 StringBuilder 复用,用 javac 编译后 javap -c 反编译看字节码,循环体里能看到 invokevirtual StringBuilder.append 和 new StringBuilder 反复出现,改成复用后 new 只剩一次。看到字节码里确实有 new,就说明你理解了编译器对 += 的处理。
最后验证 hashCode 缓存:对同一个长字符串连续调两次 hashCode(),第二次明显更快(缓存命中),用 System.nanoTime() 前后夹一下能观察到差异。具体数值因机器而异,看趋势即可。
面试速答
new String("abc"):池里没有则创建两个(常量池一个、堆一个),有则只创建堆里一个。- String 不可变靠
final的value数组加不暴露修改方法;好处是共享安全、线程安全、hashCode 可缓存。 ==比引用,equals比内容;字面量走常量池所以==为 true,new 出来的不行。intern()把引用登记进常量池,JDK 7 起池在堆中,老代码==行为可能变。- 循环拼接用
StringBuilder复用,别用+=,否则每次循环都 new 一个临时对象。
核心收获:String 的不可变不是语法糖,是常量池、线程安全、hashCode 缓存三件事的共同前提。下一步,把 StringBuilder 和 StringBuffer 的扩容策略翻出来,对照 AbstractStringBuilder 的 ensureCapacityInternal 看一遍。
本文关键词:Java 语言、String 不可变、字符串常量池、intern、hashCode 缓存、StringBuilder