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

查漏补缺:Java 语言与并发遗漏补齐学习笔记(1/2):Java 语言主题查漏(String 不可变与常量池)

查漏补缺: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 字段压缩

valuefinal 的,且 String 不对外暴露修改数组的方法,所有"修改"方法(substringconcatreplace)都返回新对象。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.appendnew StringBuilder 反复出现,改成复用后 new 只剩一次。看到字节码里确实有 new,就说明你理解了编译器对 += 的处理。

最后验证 hashCode 缓存:对同一个长字符串连续调两次 hashCode(),第二次明显更快(缓存命中),用 System.nanoTime() 前后夹一下能观察到差异。具体数值因机器而异,看趋势即可。

面试速答

核心收获:String 的不可变不是语法糖,是常量池、线程安全、hashCode 缓存三件事的共同前提。下一步,把 StringBuilderStringBuffer 的扩容策略翻出来,对照 AbstractStringBuilderensureCapacityInternal 看一遍。

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