一行 new String("abc") 到底创建几个对象
面试里有个经典开场:面试官写下 String s = new String("abc");,问这行创建了几个对象。你答"两个",他接着问"哪两个、分别在哪、为什么"。答不上来,后面聊并发和 JVM 时他会默认你基础不牢。这篇就把 String 这条线一次挖到底。
手把手实操:十行 == 结果,把字面量、new、intern 一次分清
先建一个类,把几种创建方式摆在一起跑一遍:
public class StringDemo {
public static void main(String[] args) {
String a = "abc"; // 字面量
String b = "abc"; // 字面量
String c = new String("abc"); // new,池里已有 "abc"
String d = new String("abc"); // new
System.out.println("a == b " + (a == b));
System.out.println("a == c " + (a == c));
System.out.println("c == d " + (c == d));
System.out.println("a == c.intern() " + (a == c.intern()));
System.out.println("a.equals(d) " + a.equals(d));
String e = "ab".concat("cd"); // 运行期构造,池里没有 "abcd"
System.out.println("e.intern() == e " + (e.intern() == e));
System.out.println("\"a\" + \"bc\" == \"abc\" " + (("a" + "bc") == "abc"));
System.out.println("new String(\"a\")+\"bc\" == \"abc\" " + ((new String("a") + "bc") == "abc"));
System.out.println("\"abc\".substring(0) == \"abc\" " + ("abc".substring(0) == "abc"));
System.out.println("\"abc\".substring(1) == \"bc\" " + ("abc".substring(1) == "bc"));
}
}
输出(JDK 26.0.1):
a == b true
a == c false
c == d false
a == c.intern() true
a.equals(d) true
e.intern() == e true
"a" + "bc" == "abc" true
new String("a")+"bc" == "abc" false
"abc".substring(0) == "abc" true
"abc".substring(1) == "bc" false
十行输出,逐行看:
a == b为true:两个字面量指向同一块内存,"abc"在常量池里只存了一份。a == c、c == d为false:两次new各在堆上分配了新对象,地址不同。a == c.intern()为true:intern()发现池里已有"abc",返回的是池里那一份,不是c自己。a.equals(d)为true:equals比的是字符内容,a和d内容一样,所以内容层面它们是相等的——==和equals的差别就在这一对和上一对之间。e.intern() == e为true:e是运行期用concat拼出来的"abcd",池里原本没有它,intern()就把e自己的引用登记进池并返回它,所以是自己等于自己。这一行和a == c.intern()方向相反,值得对照着记:池里已有就返回池里的,池里没有就登记当前的。"a" + "bc" == "abc"为true:两个都是字面量,编译期就拼成了"abc"。new String("a") + "bc" == "abc"为false:拼接里出现了new,结果只能在运行期产生,进不了常量池。- 最后两行是
substring的意外行为,后面单独说。
看源码及解析:不可变是怎么做到的
源码以 JDK 26 为基准,JDK 8 的差异单独标注。
类本身就是 final 的
第一层防线在类的声明上:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence,
Constable, ConstantDesc {
String 是 final 类,不能被继承。这一条经常被漏掉——如果 String 可以被继承,子类就能覆写 equals、hashCode、toString,那么所有依赖"String 的行为不会变"的代码(常量池去重、HashMap 用 String 当 key)都会失去保证。不可变的前提是"没人能改它的行为",final 类堵的就是这条路。
value 数组:final 加不暴露修改方法
private final byte[] value;
private final byte coder;
@Native static final byte LATIN1 = 0;
@Native static final byte UTF16 = 1;
value 是 final 的,数组引用本身不能再指向别的数组,而且 String 对外不提供任何能改到数组内容的方法。所有看起来像"修改"的方法——substring、concat、replace、toUpperCase——返回的都是新对象。
coder 是 JDK 9 加进来的,用来标记 value 里的字节是 Latin-1 还是 UTF-16。JDK 8 及以前只有一个 private final char[] value;,每个字符固定占 2 字节;改成 byte[] 加 coder 之后,纯拉丁字符(包括全部 ASCII)每个只占 1 字节,"hello" 这种字符串的内存占用直接砍半。isLatin1() 就是读这个字段,hashCode 和 substring 都要先看它决定走 Latin-1 分支还是 UTF-16 分支。这是版本差异,面试提一句能加分。
注意 final 修饰的是引用,不是数组内容——拿到那个数组的引用就还能改里面的字节。所以"不可变"实际由三件事共同保证:类 final(不能继承)、字段 private final(外部拿不到引用)、以及不提供任何泄露 value 数组的公开方法(拿不到引用就改不了)。三条缺一条,不可变就不成立。
hashCode 缓存:那个被省略掉的 hashIsZero
不可变的第三个好处是 hashCode 可以缓存。JDK 8 的实现是这样的:
// JDK 8
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;
}
核心公式就一行:h = 31 * h + val[i]。拿 "abc" 把每一步都打印出来,再和现成的 hashCode() 对一下:
public class HashTraceDemo {
public static void main(String[] args) {
String s = "abc";
int h = 0;
for (int i = 0; i < s.length(); i++) {
int before = h;
h = 31 * h + s.charAt(i);
System.out.println("h = 31 * " + before + " + " + (int) s.charAt(i) + " = " + h);
}
System.out.println("recomputed = " + h);
System.out.println("hashCode() = " + s.hashCode());
}
}
h = 31 * 0 + 97 = 97
h = 31 * 97 + 98 = 3105
h = 31 * 3105 + 99 = 96354
recomputed = 96354
hashCode() = 96354
手算结果和 "abc".hashCode() 完全一致。选 31 的原因有两个:31 是质数,乘它相当于一次移位减一次减法(31 * h == (h << 5) - h),当年的 JIT 能把它优化掉;另外 31 足够小,乘积不容易溢出到丢信息。这两点决定了它是个"便宜且够散"的乘子,不是随便挑的。
JDK 26 的实现变成:
// JDK 26
public int hashCode() {
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
两处变化值得注意。
一是判断条件从 value.length > 0 变成了 !hashIsZero。 这两个条件在解决同一个问题:hash 字段默认是 0,而 0 恰好也是"算出来就是 0"的合法哈希值。JDK 8 用 value.length > 0 来区分,对长度为 0 的字符串("")是够用的,但如果有某个非空字符串的哈希值真的是 0,JDK 8 那段代码每次调用都会重算一遍——因为它无法区分"还没算"和"算出来是 0"。JDK 26 的实现里多了一个字段专门记录"算过了,结果就是 0"这件事:
/** Cache the hash code for the string */
@Stable
private int hash; // Default to 0
/**
* Cache if the hash has been calculated as actually being zero, enabling
* us to avoid recalculating this. This field is _not_ annotated @Stable as
* the `hashCode()` method reads the field `hash` first anyhow and if `hash`
* is the default zero value, is not trusted.
*/
private boolean hashIsZero; // Default to false;
这段注释顺带解释了一个容易忽略的取舍:hash 带 @Stable 注解,hashIsZero 故意不带。因为 hashCode() 总是先读 hash,只有 hash 是默认的 0 时才会去看 hashIsZero,这时候 hashIsZero 的值根本不被信任,也就没必要为它付出 @Stable 的代价。两个字段一个带注解一个不带,不是漏写。
二是计算循环被挪到了 StringLatin1 / StringUTF16。 顺着 StringLatin1.hashCode 往下跟一层,到 ArraysSupport.hashCodeOfUnsigned:
public static int hashCodeOfUnsigned(byte[] a, int fromIndex, int length, int initialValue) {
return switch (length) {
case 0 -> initialValue;
case 1 -> 31 * initialValue + Byte.toUnsignedInt(a[fromIndex]);
default -> vectorizedHashCode(a, fromIndex, length, initialValue, T_BOOLEAN);
};
}
公式还是 31 * h + c,只是长字符串走 vectorizedHashCode,用 SIMD 指令一次算一批字节。算法没变,实现向量化了,这是"读不同版本源码要小心"的典型例子:光看 JDK 26 的 String.hashCode() 已经看不到那个循环了。
hashCode 并发的那段注释也值得一读:hash 和 hashIsZero 存在数据竞争,但因为"每次只会写其中一个字段"且"计算是幂等的",读到一个不完整的状态也不会算出错误结果,所以不需要额外的内存屏障。这是无锁编程里"靠不变量而不是靠锁"的一个小范例。
缓存带来多少收益?用一个干净 A/B 测一下——同一个字符串反复取哈希,对照一个自己重写、每次都真遍历的计算:
public class HashCacheDemo {
static int manualHash(String s) { // 等价于 JDK 内部的循环
int h = 0;
for (int i = 0; i < s.length(); i++) h = 31 * h + s.charAt(i);
return h;
}
public static void main(String[] args) {
String s = "x".repeat(1000);
final int N = 1_000_000;
long acc = 0;
long t0 = System.nanoTime();
for (int i = 0; i < N; i++) acc += s.hashCode(); // 命中 hash 字段
long t1 = System.nanoTime();
long t2 = System.nanoTime();
for (int i = 0; i < N; i++) acc += manualHash(s); // 每次都重算 1000 个字符
long t3 = System.nanoTime();
System.out.println("string length = " + s.length());
System.out.println("s.hashCode() x" + N + " = " + (t1 - t0) / 1_000_000 + " ms (cached field)");
System.out.println("manualHash(s) x" + N + " = " + (t3 - t2) / 1_000_000 + " ms (recomputed)");
System.out.println("both agree = " + (s.hashCode() == manualHash(s)));
}
}
两个循环都先把 acc 累加起来、最后一次性比较,是为了防止 JIT 把"算完不用"的循环整个优化掉——不消费结果,测出来的可能就是 0。跑一遍:
javac -encoding UTF-8 HashCacheDemo.java
java HashCacheDemo
string length = 1000
s.hashCode() x1000000 = 4 ms (cached field)
manualHash(s) x1000000 = 858 ms (recomputed)
both agree = true
同一个 1000 字符的字符串算一百万次,缓存命中 4 ms,真遍历 858 ms,差两百多倍。HashMap 频繁用 String 当 key 时,这个缓存省下的就是这些。关键在于 hash 是"算完存进去",如果 String 可变,缓存就必然是错的——这就是"为什么 String 要不可变"最直接的一个答案。
substring 返回 this:JDK 6 到 7 的一次修复
看 substring 的实现:
public String substring(int beginIndex, int endIndex) {
int length = length();
checkBoundsBeginEnd(beginIndex, endIndex, length);
if (beginIndex == 0 && endIndex == length) {
return this;
}
int subLen = endIndex - beginIndex;
return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen)
: StringUTF16.newString(value, beginIndex, subLen);
}
if (beginIndex == 0 && endIndex == length) return this;——当切的范围就是整个字符串时,直接返回自己,不创建新对象。这也解释了上面 "abc".substring(0) == "abc" 为 true。这是个纯优化,语义上没问题,因为 String 不可变,返回自己和返回一个内容相同的新对象对调用方没有区别。
再看切一部分的情况,跟一层到 StringLatin1.newString:
static String newString(byte[] val, int index, int len) {
if (len == 0) {
return "";
}
return new String(Arrays.copyOfRange(val, index, index + len),
LATIN1);
}
Arrays.copyOfRange 是真复制,新 String 有自己的数组。顺便还有两个小细节:len == 0 时直接返回 ""(空字符串是常量,不用新建);new String(..., LATIN1) 用的是包内私有构造,直接指定 coder,不做编码探测。
这里藏着一个著名的版本差异:
- JDK 6 及以前,
substring不复制,新 String 直接共享原 String 的value数组,只是记下offset和count。切串是 O(1) 的。 - JDK 7 起改成复制,新 String 有自己的数组。切串变成 O(len) 的。
JDK 6 那种做法在切小串时很快(不复制),但有一个致命副作用:只要那个小串还活着,原数组就一直被强引用,回收不掉。处理大报文时常见这个坑——把一个 100 MB 的响应 substring 出几百字节的字段,那 100 MB 就一直挂在堆里,哪怕原 String 早就没人用了。JDK 7 改成复制之后,小串只占小串的内存,代价是 substring 从 O(1) 变成了 O(拷贝长度)。这是个"用一点性能换内存安全"的取舍。
所以现代 JDK 上的 substring 有两副面孔:切的正好是整串(beginIndex == 0 && endIndex == length)时返回 this,零成本;切一部分时老老实实复制,长度按切出来的算。要频繁截取一个大字符串的很多小片段,这两条决定了实际开销不在一个量级。
拼接:+= 编译成了什么(这版的 javap 和 JDK 8 不一样)
这一节的结论和流传最广的那个说法不一样,先跑再看。写两个方法:
// 循环里用 += :每次迭代都产生新的临时对象
static String loopPlus(int n) {
String s = "";
for (int i = 0; i < n; i++) {
s += i;
}
return s;
}
// 复用同一个 StringBuilder:只在最后 toString 一次
static String loopBuilder(int n) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i);
}
return sb.toString();
}
编译之后反汇编 loopPlus 的循环体:
javac -encoding UTF-8 ConcatDemo.java
javap -c -p ConcatDemo
5: iload_2
6: iload_0
7: if_icmpge 24
10: aload_1
11: iload_2
12: invokedynamic #9, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;
17: astore_1
18: iinc 2, 1
21: goto 5
字节码里没有 new StringBuilder,也没有 StringBuilder.append,只有一条 invokedynamic makeConcatWithConstants。
这和"s += i 等价于 new StringBuilder().append(s).append(i).toString()"这个流传很广的说法对不上。那个说法在 JDK 8 及以前是对的,JDK 9 起(JEP 280)改了实现:字符串拼接在编译期不再展开成一串 StringBuilder 调用,而是编译成一条 invokedynamic 指令,具体怎么拼由运行时的 StringConcatFactory 决定。这样 JVM 可以在启动时把拼接方法生成成更优的形态,也避免了每次拼接都新建一个 StringBuilder 的开销。
想在现代 JDK 上看到老写法,可以强制编译器退回内联模式:
javac -encoding UTF-8 -XDstringConcat=inline -d inline ConcatDemo.java
javap -c -p -classpath inline ConcatDemo
10: new #9 // class java/lang/StringBuilder
13: dup
14: invokespecial #11 // Method java/lang/StringBuilder."<init>":()V
17: aload_1
18: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: iload_2
22: invokevirtual #16 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
25: invokevirtual #19 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
这才是 JDK 8 的形态,new StringBuilder 每次迭代一条。对比两份反汇编,差异一目了然。所以完整的回答是:JDK 8 及以前,+= 在循环里展开成每次 new 一个 StringBuilder;JDK 9 起编译成 invokedynamic,由运行时决定实现。
那为什么还是推荐在循环里显式用 StringBuilder?因为 invokedynamic 优化的是"单次拼接的实现方式",改变不了"字符串不可变、每次拼接都要产生新结果"这个事实。循环 n 次就是 n 个中间结果,这个量级没变。给 ConcatDemo 补一个 main 计时,一万次循环:
public static void main(String[] args) {
int n = args.length > 0 ? Integer.parseInt(args[0]) : 10000;
long t0 = System.nanoTime();
String a = loopPlus(n);
long t1 = System.nanoTime();
String b = loopBuilder(n);
long t2 = System.nanoTime();
System.out.println("same result = " + a.equals(b));
System.out.println("loop with += = " + (t1 - t0) / 1_000_000 + " ms");
System.out.println("loop with Builder = " + (t2 - t1) / 1_000_000 + " ms");
}
java ConcatDemo 10000
same result = true
loop with += = 70 ms
loop with Builder = 0 ms
一万次循环,+= 70 毫秒,复用 StringBuilder 不到 1 毫秒(打印成 0)。两个方法结果完全相同。这才是"循环拼接别用 +="的真正理由——不是因为编译器会生成 StringBuilder(在现代 JDK 上根本不会),而是因为不可变类型在循环里累加会产生 n 个临时对象。
常量折叠:什么时候编译期就拼好了
同一个"拼接",在编译期常量上表现完全不同:
public class FoldDemo {
static final String A = "a";
public static void main(String[] args) {
String s1 = "a" + "bc";
String s2 = A + "bc";
String s3 = new String("a") + "bc";
int n = args.length;
String s4 = n > 0 ? "x" : "ab" + "c";
System.out.println(s1 + s2 + s3 + s4);
}
}
javap -c -p FoldDemo
0: ldc #7 // String abc
3: ldc #7 // String abc
10: ldc #13 // String a
15: invokedynamic #18, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
30: ldc #22 // String x
35: ldc #7 // String abc
四处对比:
- 偏移 0 和 3 都是
ldc #7 // String abc,指向常量池里同一个条目。"a" + "bc"折叠了;A + "bc"也折叠了,因为A是static final String且初始值是字面量,属于编译期常量。所以s1 == s2在运行期是true,尽管它们写起来像两次拼接。 - 偏移 10 是
ldc "a",紧接着invokedynamic。new String("a") + "bc"没有折叠——拼接里出现了new,结果只能在运行期产生。 - 偏移 30、35 是三元表达式里的
"x"和"ab" + "c",后者也被折叠成了abc。折叠发生在编译期,和这个分支跑不跑没关系。
记住分界线:参与拼接的每一项都是编译期常量,就折叠;只要有一项是运行期才能确定的(new、方法返回值、非 final 变量),就留到运行期。 折叠后的结果进常量池,所以 == 可能意外为 true——这既是不该用 == 比字符串的又一条理由,也是"常量池里只有一份"的又一处体现。
常量池在哪:一块内存的搬迁史
JDK 6:永久代
JDK 6 及以前,字符串常量池在方法区的永久代(PermGen)里,和 String 对象在堆上的位置分属两个区域。intern() 的行为是:池里没有等值字符串,就把字符串内容复制一份到永久代,返回永久代里那个新对象的引用。这带来一个经典事故:代码里用 intern() 处理用户输入(比如解析出来的字段值),池子只增不减,永久代很快被撑满,抛出 OutOfMemoryError: PermGen space。这就是"intern 洪水"这个词的由来。
JDK 7:搬进堆
JDK 7 起,字符串常量池从永久代挪到了堆里(具体实现是 StringTable)。这个变化有两个后果:
一是 intern() 不再复制内容。池里没有等值时,直接把堆上那个已有对象的引用登记进去并返回,不再另存一份。因为这层关系,某些老代码里 == 的判断结果会发生变化——这也是升级 JDK 时容易踩的坑之一。JDK 8 用元空间(Metaspace)替换了永久代,但常量池已经在堆里了,这次替换跟常量池无关,这一点容易混。
二是常量池可以参与堆的 GC 了,不再是一个只进不出的固定区域。
这一小节讲的是历史行为,手上没有 JDK 6 可以跑,所以这里只能靠 JDK 版本说明和源码变更记录,不像后面几节有实测输出。下面两节开始都是能自己跑出来的。之所以仍然要讲,是因为"intern 会 OOM"这个结论只在 JDK 6 的语境下成立,不讲清楚来源,就没法判断它在今天还成不成立——这正是下一节要做的事。
intern 本身长什么样
intern() 的声明短到只有一行:
public native String intern();
native 说明它的实现不在 Java 层,而在 HotSpot 里(StringTable::intern),Java 源码里看不到逻辑,只能靠行为观察和文档。作者注释里给了一条可以直接依赖的保证:s.intern() == t.intern() 为 true 当且仅当 s.equals(t) 为 true——任何两个内容相同的字符串,intern() 之后一定是同一个引用。这是"用 intern() 做去重"的契约基础,用 == 比较 interned 字符串是安全的,比 equals 快。
池子的底层是一张哈希表(StringTable),表长由 -XX:StringTableSize 控制。-XX:+PrintFlagsFinal 会打印八百多行,用 Select-String 过滤出要看的那一行:
java -XX:+PrintFlagsFinal -version | Select-String StringTableSize
uintx StringTableSize = 65536 {product} {default}
65536 就是下面统计里那个 Number of buckets。{default} 说明没被改过,是默认值。
池子到底有多大
想直接看常量池的规模,用 -XX:+PrintStringTableStatistics。先跑 20000 次 intern():
public class TableStatDemo {
public static void main(String[] args) {
for (int i = 0; i < 20000; i++) {
("word-" + i).intern();
}
System.out.println("invoked 20000 intern() calls");
}
}
javac -encoding UTF-8 TableStatDemo.java
java -XX:+PrintStringTableStatistics TableStatDemo
invoked 20000 intern() calls
StringTable statistics:
Number of buckets : 65536 = 524288 bytes, each 8
Number of entries : 20133 = 322128 bytes, each 16
Number of literals : 20133 = 1166376 bytes, avg 57.000
Total footprint : = 2012792 bytes
Average bucket size : 0.307
把这几行算一遍,每个数都能对上:
Number of buckets是 65536,这就是默认的StringTableSize。每个桶 8 字节,65536 × 8 = 524288 字节,和输出里的数一致。Number of entries是 20133,比 20000 多出的 133 是 JDK 自己内部的字面量。每个 entry 16 字节,20133 × 16 = 322128 字节,同样一致。- 三者相加:524288 + 322128 + 1166376 = 2012792,正好等于
Total footprint。
最后这个加法说明 Total footprint 由三块拼成:桶数组、entry 数组、以及字符串内容本身。所以 2 万个字符串在常量池里的表结构开销是 524288 + 322128 = 846416 字节(约 0.81 MB),内容 1166376 字节(约 1.11 MB)另算,合计约 1.92 MB。
对照着看能发现一个有用的事实:intern() 不是免费的,每登记一个新字符串大约多 16 字节的条目开销,桶槽是按桶数固定摊掉的、不随字符串数量涨。所以"要不要用 intern 去重"要算账——去重省下的是重复内容的那份,付出的是每个唯一字符串十几到二十几字节的登记成本,字符串越长这笔账越划算。
intern 的现代行为:不保留没有引用的字符串
上面那个"intern 洪水撑满池子"的经典答案,在现代 HotSpot 上需要修正。做一个对照实验:50000 个 4005 字节的字符串,两组都调 intern(),区别只在第二组把返回值存进一个 List 保持强引用。
import java.util.*;
public class InternWeakDemo {
static long usedMB() {
Runtime rt = Runtime.getRuntime();
return (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
}
static void gc() throws Exception { System.gc(); Thread.sleep(300); System.gc(); Thread.sleep(300); }
public static void main(String[] args) throws Exception {
String pad = "x".repeat(4000);
int N = 50000; // 50000 x 4005 字节 约 190MB
gc();
System.out.println("baseline used = " + usedMB() + " MB");
for (int i = 0; i < N; i++) (i + pad).intern();
gc();
System.out.println("intern() " + N + " times, no refs kept used = " + usedMB() + " MB");
List<String> keep = new ArrayList<>();
for (int i = 0; i < N; i++) keep.add((i + pad).intern());
gc();
System.out.println("intern() " + N + " times, refs held used = " + usedMB() + " MB");
System.out.println("check the list is really alive: keep.get(N-1).length() = " + keep.get(N - 1).length());
}
}
javac -encoding UTF-8 InternWeakDemo.java
java -Xmx1g InternWeakDemo
baseline used = 1 MB
intern() 50000 times, no refs kept used = 1 MB
intern() 50000 times, refs held used = 196 MB
check the list is really alive: keep.get(N-1).length() = 4005
第一组 intern 了 50000 次,每个字符串 4005 字节,如果全部保留应该是 190 MB 量级,实测用完还是 1 MB。第二组只是多了一个 List 强引用,就涨到 196 MB,并且最后一行确认那个 List 确实活着(取到元素的 length 是 4005,不是被优化掉的假数据)。
结论:现代 HotSpot 的 StringTable 不会强留住"除此之外没有任何引用"的 interned 字符串。 那些字符串可以被正常回收,intern() 登记进去的条目也会被清理掉。所以前面"用户输入 intern 导致永久代溢出"那个故事,是一个 JDK 6 时代的故事——那时候池子在永久代、而且会复制内容进去,双重原因导致它只增不减。在 JDK 7 之后,用 intern() 做去重不会因为"池子无限膨胀"而 OOM;真正要担心的变成了"被 intern 的字符串只要还有别的地方引用着,它就一直活着,手动去重反而会让它比预期活得更久"。
这一点很值得在面试里主动说:能区分"JDK 6 的 intern 行为"和"现代 HotSpot 的 intern 行为",比背下"intern 会 OOM"更能说明真的读过、跑过。
验证方法
用 javap 看你手上那版 JDK 到底怎么拼
不要凭记忆回答 += 的问题,直接反汇编自己环境里的代码:
javac -encoding UTF-8 ConcatDemo.java
javap -c -p ConcatDemo
在 loopPlus 的循环体里找关键字:看到 invokedynamic 和 makeConcatWithConstants,说明是 JDK 9 及以后的实现;看到 new java/lang/StringBuilder 重复出现,说明是 JDK 8 及以前的实现。再补一句 javac -XDstringConcat=inline 重编译,就能在同一台机器上把两种形态都看一遍,对比最直观。
javap 不带 -p 只能看到 public/protected 成员,看 private 字段和方法要加 -p(-private 的简写)。看常量池本身用 javap -v。
用 PrintStringTableStatistics 看池子实际规模
java -XX:+PrintStringTableStatistics TableStatDemo
重点看两个数:Number of entries 应该约等于 intern() 次数加上一小部分 JDK 内部字面量;Number of buckets 固定等于 StringTableSize,不随字符串数量变。把 intern() 次数改大(比如 20 万),entries 和 Total footprint 会成比例增长,而 buckets 一动不动。再加上 -XX:StringTableSize=1000003 把桶数调大,Average bucket size 会明显变小——桶多了,哈希冲突少了,intern() 更快,代价是桶数组本身从 0.5 MB 涨到约 7.6 MB(1000003 × 8 = 8000024 字节)。
注意输出是在 JVM 退出时打印的,所以它是整个进程生命周期结束时的快照,不是运行中途的实时值。
用 intern 弱保留实验确认现代行为
上面那个"1 MB vs 196 MB"的实验自己跑一遍,两个数字一对比,StringTable 不保留无引用字符串这件事就确认了。跑之前 -Xmx 给足(比如 -Xmx1g),第二组要 196 MB。这个实验比背结论可靠,因为它的第一组结果(1 MB)和直觉相反——如果不亲自跑,很难相信自己看到的。
用 jmap 看字符串占用
排查线上字符串占用时,先拿直方图:
jps -l
jmap -histo:live <pid>
jps -l 拿进程号,jmap -histo:live 输出按占用排序的类直方图,看 [B(byte 数组)和 java.lang.String 两行的实例数和字节数。字符串多导致的堆上涨,这两行一定排在最前面。想进一步定位是哪些字符串,把堆转储下来用 MAT 打开按"按引用链聚合"看——jmap -dump:live,format=b,file=heap.hprof 。
面试速答
new String("abc"):池里没有"abc"则创建两个(池里一个、堆里一个),池里已有则只创建堆里那一个。标准答法是"一个或两个,取决于池里原本有没有"。- String 不可变靠三件事:类是
final、value字段是private final byte[]、不提供任何暴露或修改value的公开方法。所有"修改"方法都返回新对象。 - JDK 9 起
char[] value换成byte[] value加coder标记 Latin-1/UTF-16,ASCII 字符串内存砍半。 hashCode公式是h = 31 h + c,"abc"的结果是 96354。选 31 因为它是质数且31 h可优化成(h << 5) - h。hashIsZero是为了区分"还没算"和"算出来就是 0";JDK 8 那段用value.length > 0判断的实现在这一点上是有洞的。- JDK 26 里
hashCode的循环已经挪到StringLatin1/StringUTF16,长字符串走ArraysSupport.vectorizedHashCode(SIMD)。公式没变,实现向量化了。 substring(0)返回this(范围是整个串时直接返回自己);JDK 6 的substring共享原数组、JDK 7 起改成复制,为的是避免小串拖住大数组。s += i的编译结果分版本:JDK 8 及以前展开成new StringBuilder().append(...).toString();JDK 9 起(JEP 280)编译成invokedynamic makeConcatWithConstants。循环里仍然推荐显式StringBuilder,理由是不可变类型累加会产生 n 个临时对象,不是因为编译器会生成StringBuilder。- 常量折叠:
"a" + "bc"和static final String A + "bc"都在编译期折叠成同一个常量,new String("a") + "bc"不折叠。 - 常量池位置:JDK 6 在永久代且
intern()复制内容;JDK 7 起在堆里,intern()直接登记堆上对象的引用。 - 现代 HotSpot 不保留"没有其他引用"的 interned 字符串(实测 50000×4005 字节用完仍 1 MB,有强引用则 196 MB)。"intern 洪水 OOM"是 JDK 6 的故事。
-XX:+PrintStringTableStatistics可看池子规模;默认StringTableSize是 65536 个桶,每个 entry 16 字节。
核心收获:String 的不可变不是语法糖,是常量池去重、线程安全、hashCode 缓存、substring 可返回 this 这四件事的共同前提。这一篇里有两处"流传很广但和当前版本对不上"的说法——+= 的字节码和 intern 的 OOM——都在实际编译运行后得到了修正,这比记住结论更重要。下一步,把 StringBuilder 和 StringBuffer 的扩容策略翻出来,对照 AbstractStringBuilder 的 ensureCapacityInternal 看一遍。
本文关键词:Java 语言、String 不可变、字符串常量池、intern、hashCode 缓存、StringBuilder