← 返回博客
2026-09-17 08:00:01

一行 new String("abc") 到底创建几个对象

一行 new String("abc") 到底创建几个对象

面试里有个经典开场:面试官写下 String s = new String("abc");,问这行创建了几个对象。你答"两个",他接着问"哪两个、分别在哪、为什么"。答不上来,后面聊并发和 JVM 时他会默认你基础不牢。这篇就把 String 这条线一次挖到底。

一行 new 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

十行输出,逐行看:

看源码及解析:不可变是怎么做到的

源码以 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 那种做法在切小串时很快(不复制),但有一个致命副作用:只要那个小串还活着,原数组就一直被强引用,回收不掉。处理大报文时常见这个坑——把一个 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

四处对比:

记住分界线:参与拼接的每一项都是编译期常量,就折叠;只要有一项是运行期才能确定的(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

把这几行算一遍,每个数都能对上:

最后这个加法说明 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 。

面试速答

核心收获:String 的不可变不是语法糖,是常量池去重、线程安全、hashCode 缓存、substring 可返回 this 这四件事的共同前提。这一篇里有两处"流传很广但和当前版本对不上"的说法——+= 的字节码和 intern 的 OOM——都在实际编译运行后得到了修正,这比记住结论更重要。下一步,把 StringBuilder 和 StringBuffer 的扩容策略翻出来,对照 AbstractStringBuilder 的 ensureCapacityInternal 看一遍。

本文关键词:Java 语言、String 不可变、字符串常量池、intern、hashCode 缓存、StringBuilder